Tracks GitHub issues and PRs, Jira work items, Claude artifacts and web links mentioned in a session, and the Markdown files Claude writes, edits, reads or…

A Claude Code mod. It tracks the GitHub issues and pull requests, the Jira work items, Claude artifacts and the web links that you or Claude mention in a session, and the Markdown files that Claude writes, edits, reads or names. It shows them in a /track pane and in a one-line band above the prompt.
gh and acli are optional. With them, the mod also shows live status: title, state, failing checks, Jira status and the Epic tree.
Design: docs/superpowers/specs/2026-10-10-artifact-tracking-design.md. Evidence: docs/knowledge-base/claude-code-artifact-tracking/.
Mods are in early access. Tested on Claude Code 2.1.295 and 2.1.296.
Install it from the marketplace of this repo. In a Claude Code session (2.1.275 or later):
/plugin install artifact-tracking --marketplace martin-macak/claude-code-mod-tracking
Answer y to add the marketplace mod-tracking, then pick a scope. From a shell (2.1.292 or later): claude plugin install artifact-tracking --marketplace martin-macak/claude-code-mod-tracking. Get a new release with claude plugin update artifact-tracking@mod-tracking.
Load a release zip for one session. Each GitHub release attaches artifact-tracking-<version>.zip:
claude --plugin-url https://github.com/martin-macak/claude-code-mod-tracking/releases/download/artifact-tracking--v<version>/artifact-tracking-<version>.zip
Load the checkout for one session:
claude --plugin-dir plugins/artifact-tracking
| Row | Taken |
|---|---|
Your prompt (typed, bridge, -p) | Every pattern below. |
| Your slash command | The text inside <command-args>. |
| Claude's reply, also from subagents | Every pattern below. |
| Tool output | Explicit GitHub issue and PR URLs and Jira URLs only. No bare #N, no web links. |
| Artifact tool call, also from subagents | A publish, a republish, an Artifact made from a type and an asset upload: the artifact, published. A delete marks its row deleted. An error, a refusal and the other actions add nothing. |
Write, Edit and Read tool calls | A .md path: see Markdown files. |
File tools, Bash and agent.spawn | Other git repos that the session or subagents touch (Read, Edit, Write, NotebookEdit, Grep, Glob, cd, git -C, gh -R, spawn cwd). |
Repo tracking runs in the background, after the tool has returned. One queue runs the git lookups one at a time and drops new ones when 32 wait. A file-tool call that failed or was denied adds nothing. A denied Bash command adds nothing. A Bash command that failed still counts: it touched the repo. A relative path in a Bash command or a file-tool call starts from the session cwd as it was before the call ran. In a subagent the cwd is unknown, so only absolute paths count. A Glob with no path and an absolute pattern counts for the part of the pattern before the first glob character. A nested subagent with no cwd counts with the cwd of its parent.
Each Repos row shows read or edit. Edit, Write, NotebookEdit and a git write subcommand (commit, add, push, checkout, reset, rebase and the like, after git -C <dir> or cd <dir> &&) mark the repo edit. Edit wins: a later read does not lower it. Everything else is read. The mark is a heuristic: a Bash write without git (sed -i, npm install, tee) counts as read. A failed or denied call adds nothing.
Repo tracking has two limits:
cd, pushd, git -C and gh -R. Other paths in a command are not tracked. A cd or -C target with $, a backtick, * or ?, or with -, ~ or ~name, is skipped.Each Repos row has an x button. It hides the repo for the rest of the run: a later visit, a restore and /clear do not bring it back. The dismiss list is not stored, so a new run shows the repo again. When the last repo is dismissed, the flush skips the empty list, so the stored copy may still hold it until the next write.
Not captured: notifications from background tasks, meta rows, prompts that a subagent or a coordinator wrote, and our own /track output.
The own repo never shows as a foreign repo. It is the toplevel of the session root, any worktree that shares its git common dir, and any clone with the same origin. A repo nested inside the root folder counts as own. A repo without a remote gets a local:<git common dir> key, so its worktrees share one row.
| Pattern | Item |
|---|---|
https://github.com/o/r/issues/N, /pull/N | GitHub issue or PR |
o/r#N | GitHub item, type found by gh |
#N outside code and URLs | GitHub item of the session repo |
https://<site>.atlassian.net/browse/KEY-1, …?selectedIssue=KEY-1 | Jira work item |
/browse/KEY-1 on another host | Jira item, track-only |
https://claude.ai/artifact/<id>, https://claude.ai/code/artifact/<uuid> | Claude artifact, mentioned. The two forms of one artifact are one item. Another path or a bad id is a web link. |
Any other http or https URL | Web link. The name is the Markdown label, else host and path. |
An artifact row shows its name: the page title from the Artifact tool, else the Markdown label, else the file name or the id. A mention never changes a published row back. The same item written as a URL and as #N is one item. The session repo comes from the git origin remote. Without a remote, the mod asks gh repo view once.
| Command | Action |
|---|---|
/track | Opens the pane and starts a refresh of the due items. Prints a short list. |
/track refresh | Forgets "not logged in" and "not found", closes open circuit breakers, refreshes every item, waits, and prints the list. Use it in claude -p. |
/track clear | Removes all items of this session and its stored copy. |
The pane has one section for each of Jira, GitHub, Artifacts, Repos, web links and files. A Jira row with children has a [-] or [+] button. The Refresh button (key r) does what /track refresh does. Esc closes the pane. A 30 s tick runs from the start of the session, with the pane open or closed. An item is stale after 5 minutes, or after 60 s for a PR with pending checks.
A deleted artifact stays in its section, dim and without a link.
The band shows tracking Jira 6 · GitHub 4 (1 ✗) · Artifacts 3 · Links 2 · Files 3 · Repos 2 /track. (n ✗) counts PRs with failing checks. Artifacts n counts the artifacts that are not deleted. Repos and the auto parts (Worktrees, Agents, PRs) come after Files. The band is empty when nothing is tracked.
The Files section has one row per Markdown file (issue #28):
Files 3 files
docs/plan.md created edited 3 viewed 2
README.md edited 1 viewed 5
docs/notes.md mentioned viewed 1 subagent
| Cell | Counts |
|---|---|
| path | Relative to the session root when the file is below it, else absolute. |
created | A successful Write of type create made the file. It stays set. |
mentioned | Your prompt, your command arguments or Claude's reply named the path, and the file exists. Shown when the file was not created. |
edited N | Successful Edit calls and Write calls of type update. |
viewed N | Successful Read calls. A Read alone never adds a row: it counts only on a row that a write, an edit or a mention made. |
subagent | A subagent wrote, edited or read the file. |
An empty cell means that activity did not happen. The /track text leaves empty cells out.
Rules:
.md paths count, in any case. A failed, denied or staged call adds nothing.~/ in a mention joins HOME.file:<real path>): two spellings of one file, such as /tmp/a.md and /private/tmp/a.md, are one row. A case alias or a hard link stays a second row. The row keeps the path as first spelled.a.md:42) or anchor (a.md#L7) is cut..git or node_modules folder, auto-memory under <config dir>/projects/<project>/memory/ or under the autoMemoryDirectory setting, and the Claude scratchpad (claude-<uid>/<project>/<session>/scratchpad/ under any temp folder). The config dir is CLAUDE_CONFIG_DIR, else ~/.claude. Below the session root, only a .git or node_modules folder below the root counts, so a project inside such a folder keeps its files. CLAUDE.md, AGENTS.md and plans stay tracked.x button dismisses a file row for the run. A later write, edit, read or mention of the same file does not bring it back.Write, Edit and Read results and the mentions in the transcript. A subagent's writes come back from the stored copy only: its transcript holds no results.The mod also tracks the work around the session, with no mention:
| Section | Content |
|---|---|
| Worktrees | The root (the cwd at session start, pinned) and the worktrees that this session or its agents made. Only proven children show. A removed one stays, dimmed. |
| Agents | The main loop, its subagents and its workflow agents, each with branch and worktree. An ended agent stays, dimmed. |
| Pull requests | PRs whose head is a tracked branch, or whose base is a tracked branch (stacked PRs). One GraphQL query for each 30 s tick. |
The refs in a PR body become items with a ↳#N prefix. HTML comments are skipped. The items go when the PR leaves the tracked set. The band adds Worktrees n · Agents n · PRs n.
flowchart LR
E[tool.call and agent.spawn events] --> S[AutoScheduler 750 ms window]
T[30 s tick] --> S
S --> R[AutoRound]
R --> G[git worktree list]
R --> A[agent.list]
R --> Q[one GraphQL query]
R --> U[pane and band]
Press x on an auto row to hide it for the run. /track clear hides every auto row that is on screen. A gh failure trips the same circuit breaker as item refresh. It never stops worktree or agent tracking.
Limits: peer sessions in other processes are not tracked. A worktree that git created without this session is not shown. Design: docs/superpowers/specs/2026-10-10-auto-tracking-design.md.
gh 2.102 or newer, logged in. It refreshes GitHub items with one GraphQL call for each host. A GitHub Enterprise host works when gh auth status --hostname <host> succeeds.acli 1.3.19 or newer, logged in. It refreshes Jira items on its active site.not installed or not logged in. The items stay in the list.A tool that fails three times in a row is paused for 5 minutes (circuit breaker). The section says paused 4m. /track refresh lifts the pause.
$.state and are copied to $.store 1.5 s after a change. The store keeps the newest 20 sessions, and each session keeps up to 500 items. Only the extracted fields are stored, never raw CLI output./clear gives a new session. The items of the old session carry over.--resume and --continue restore the stored copy, then add what the transcript holds. Restored items are due for a refresh.rank #1 counts as issue 1 of the session repo. The scanner cannot tell it from fixed in #1.acli works on one site at a time. A work item on any other site is track-only. The mod never runs acli jira auth switch. A host that is not *.atlassian.net is also track-only.gh is logged in to it.#N without a repo stays unresolved. It has no link and no status./clear. The items come back at the next hook, command or 30 s tick, not at the /clear itself. The tick runs from session.start. A /resume of a session that the store knows restores that session only, not the one you left.--resume backfill. The transcript has no isMeta or origin flags. The backfill skips the wrappers it knows (<system-reminder>, <teammate-message>, command output, task notifications). Other meta rows, for example a skill body or a coordinator message, may add items that the live session did not track.claude -p "/track" ends before its background refresh finishes. Use /track refresh.claude -p does not offer it, so nothing is published there. A mentioned artifact URL is still tracked.--resume. The stored copy keeps the origin, the title and the delete mark. Without a stored copy, the transcript gives them back, except for a subagent's publish: the main transcript does not hold it, so its URL comes back mentioned at most. Only deletes in this session mark a row.Only hooks/register.tsx uses $. Every other module gets plain functions, so the unit tests need no engine.
cd plugins/artifact-tracking
claude plugin validate .
mise exec node@24 -- npx -y -p typescript tsc -p .
claude plugin test .
| Folder | Content |
|---|---|
hooks/register.tsx | Hooks, command, timers, state, store, render. |
hooks/scan/ | Text to references (refs.ts), artifact URLs and ids (artifact-url.ts), Markdown paths (files.ts) and the link scanner (links.ts). |
hooks/capture/ | An Artifact tool call as an event (artifact-tool.ts), for the hook and the resume backfill. |
hooks/model/ | Items, keys, merge, dedupe, caps. |
hooks/tools/ | GhTool, AcliTool, the base Tool and the circuit breaker. |
hooks/refresh/ | The refresh coordinator. |
hooks/auto/ | Ledger, worktrees, agents, events, scheduler and round of auto tracking. |
hooks/ui/ | The view model, the pane and the band. |
tests/ | Unit tests, fixtures, and the kit tests of the wiring (register.test.tsx, artifact-wiring.test.tsx). |
.github/workflows/test.yml runs on every pull request, on each push to main, and by hand. It installs Claude Code 2.1.296 and TypeScript 7.0.2 at fixed versions. Then it runs make test: claude plugin validate, tsc -p ., claude plugin test. No login or secret is needed.
A release is a git tag artifact-tracking--v<version>. The <version> is the version in .claude-plugin/plugin.json. Marketplace users get a new copy only when that version changes.
version in .claude-plugin/plugin.json. Use semver. Merge the PR.main, in a clean and current checkout, run make release-dry. Check the tag name.make release. It runs make test, then claude plugin tag --push.The tag push runs .github/workflows/release.yml. The workflow:
<name>--v<version> of plugin.json and that the tagged commit is on main,claude plugin validate --strict on the plugin and the marketplace, then make test,make dist: artifact-tracking-<version>.zip (the plugin folder at the tag) and SHA256SUMS,- suffix, such as 0.3.0-rc.1, becomes a prerelease.Verify a downloaded zip:
sha256sum -c SHA256SUMS
gh attestation verify artifact-tracking-<version>.zip --repo martin-macak/claude-code-mod-tracking
Run make from the repo root. make help lists the targets. Set PLUGIN=<dir> to use another plugin folder.
| Target | Action |
|---|---|
init | Checks claude, mise and node. Reports gh, acli and pre-commit as present or missing. A missing optional tool does not fail. Warms the TypeScript cache for offline runs. |
build | Runs claude plugin validate on the plugin, and --strict on the repo root (the marketplace), then typecheck. Nothing is emitted. Claude Code loads the .ts and .tsx files as they are. |
typecheck | Runs tsc only. The typecheck pre-commit hook calls it. |
lint | Runs every pre-commit hook on all files. |
hooks | Installs the pre-commit, commit-msg and pre-push git hooks. |
verify | The gate before every push: lint with auto-fix, then test, e2e-unit and test-scripts. Fails on untracked files, because the hooks skip them. |
test | Runs build, then claude plugin test. check is an alias. |
test-scripts | Tests scripts/dev/install-local-watch.sh. Needs no claude. |
dist | Builds dist/<name>-<version>.zip from the committed plugin folder at HEAD, and dist/SHA256SUMS. Uncommitted edits are not in the zip. |
release | On a clean main equal to origin/main: runs test, then claude plugin tag --push. See Release. |
release-dry | Prints the tag that release would create. |
install | Links the mod into $CLAUDE_CONFIG_DIR/skills/. Without that variable, it uses ~/.claude/skills/. |
uninstall | Removes that link. It removes only a link that points at this plugin. |
install-local | Copies the mod into <repo>/.claude/skills/artifact-tracking-dev/ as artifact-tracking-dev. Only a session that starts in this repo loads it. The user config dir stays untouched. The .envrc runs it for you. |
uninstall-local | Removes that copy and its line in .git/info/exclude. |
clean | Deletes .claude-plugin/types/ and dist/. The engine writes that folder on a real load. |
install makes a symlink, so Claude Code loads the mod in place as artifact-tracking@skills-dir. Edits reload by themselves. Run make install from the checkout that you keep, not from a temporary worktree. Start a new session or run /reload-plugins after make install. Running it twice is safe.
install-local uses a project skills folder. Start claude at the repo root. A subfolder does not load the mod. Claude Code asks you to trust the folder first. claude -p never counts as trust. The copy is not tracked by git: .gitignore lists it.
The copy differs from install in three ways:
.envrc does that, see below. Run /reload-plugins to load the new copy.artifact-tracking-dev. A second plugin named artifact-tracking is refused while the released one is installed.artifact-tracking-dev.*. State belongs to the plugin name, and the copy may not write artifact-tracking.*.An earlier install-local made a symlink named artifact-tracking instead. /plugin then reports "the name "artifact-tracking" is already taken". install-local and uninstall-local remove that old link from every worktree of the repo, not only from the current one.
The released artifact-tracking still loads next to the copy, and both answer /track. Disable it while you test: claude plugin disable artifact-tracking@mod-tracking, and later claude plugin enable artifact-tracking@mod-tracking. If /reload-plugins leaves /track unknown, restart with claude --continue.
With direnv, the repo's .envrc keeps the copy in step with no manual step. Run direnv allow once in each checkout and each worktree. Then:
cd into the checkout runs install-local when the copy is missing or stale, and starts a background watcher (scripts/dev/install-local-watch.sh).plugins/artifact-tracking/ changes, also when claude edits it.claude) has its cwd in the checkout any more, the watcher runs uninstall-local and exits. A claude that still runs there keeps the copy.The watcher logs to install-local-watch.log in the checkout's git dir (git rev-parse --git-dir).
hooks/register.tsx 1625 lines1// Wiring for the artifact-tracking mod. The only file that touches `$`.
2// Capture hooks observe rows. A coordinator refreshes the items with gh and acli. A pane and a band draw them.
3// Specs: docs/superpowers/specs/2026-10-10-artifact-tracking-design.md sections 6 to 10, and
4// docs/superpowers/specs/2026-10-10-auto-tracking-design.md (the `auto` atom, the tick, the round, the hooks of spec 7).
5
6import type { EngineInterface, Register } from 'claude-code'
7import { atom, read, update } from 'claude-code'
8import { autoIds, dismiss } from './auto/dismiss'
9import { fromAgentResult, fromBash, fromEnterResult, fromSpawn, fromTurnEnd } from './auto/events'
10import {
11 carryAuto,
12 emptyAuto,
13 pinRoot,
14 recordActivity,
15 recordAgentBranch,
16 recordAgentResult,
17 recordEnter,
18 recordSpawn,
19 recordTurnEnd,
20} from './auto/ledger'
21import { AutoRound } from './auto/round'
22import { AutoScheduler } from './auto/scheduler'
23import { artifactEvent } from './capture/artifact-tool'
24import type {
25 AutoReason,
26 AutoState,
27 DiscoveryApplied,
28 FileActivity,
29 FileRecord,
30 GitHubRef,
31 GitHubTarget,
32 ItemRecord,
33 ProcessRecord,
34 ProcessUiState,
35 Ref,
36 RepoRecord,
37 RepoRef,
38 Runner,
39 ScanMode,
40 Sighting,
41 Source,
42 ToolStatus,
43 UiState,
44} from './contracts'
45import { AUTO_SOURCES, isRowId, LIMITS, repoRowId } from './contracts'
46import { isRecord } from './json'
47import { dropAuto } from './model/discovered'
48import { artifactOrigin, keyOf } from './model/keys'
49import {
50 hydrate,
51 markArtifactDeleted,
52 mergeFiles,
53 mergeRefs,
54 mergeResults,
55 recordFromFile,
56 rekeyArtifacts,
57 resolveBareRefs,
58 unionRecords,
59} from './model/registry'
60import {
61 monitorRow,
62 parseBashProcesses,
63 processUpdateFromNotification,
64 processUpdateFromNotificationText,
65 processUpdateFromTaskStop,
66} from './process/parse'
67import {
68 composeState,
69 dockerEntryMatchesRow,
70 findDetachedPid,
71 parseDockerPs,
72 psStillMatches,
73 screenLive,
74 tmuxLive,
75} from './process/probe'
76import type { Timer } from './refresh/coordinator'
77import { RefreshCoordinator } from './refresh/coordinator'
78import { extractRepoPathsFromTool, PATH_TOOLS, parseRepoHintsFromBash, type RepoHit, repoBaseDir } from './repos/hints'
79import { RepoQueue } from './repos/queue'
80import { capRepoRows, mergeRepoRows, upsertRepoRows } from './repos/rows'
81import { RepoTracker, type RepoVisit } from './repos/tracker'
82import {
83 type FileTouch,
84 type IgnoreContext,
85 isIgnored,
86 mdCandidates,
87 normalizePath,
88 resolvePath,
89 touchFromToolResult,
90} from './scan/files'
91import { textOfContent } from './scan/links'
92import { commandArgs, extractRefs, parseRemote } from './scan/refs'
93import { AcliTool } from './tools/acli-tool'
94import { CircuitBreaker } from './tools/circuit-breaker'
95import { GhTool } from './tools/gh-tool'
96import { composeBand, composeCommandText, composePane } from './ui/auto-view'
97import { renderBand } from './ui/band'
98import { renderSections } from './ui/pane'
99import type { ViewContext } from './ui/view'
100
101// ------------------------------------------------------------------ state
102
103// Working set in $.state (survives hot reload, reset by /clear). Mirror in $.store, one key per session.
104const items = atom({ plugin: 'artifact-tracking', key: 'items' } as const, [] as ItemRecord[])
105const repos = atom({ plugin: 'artifact-tracking', key: 'repos' } as const, [] as RepoRecord[])
106const processes = atom({ plugin: 'artifact-tracking', key: 'processes' } as const, [] as ProcessRecord[])
107const tools = atom(
108 { plugin: 'artifact-tracking', key: 'tools' } as const,
109 {
110 gh: { name: 'gh', available: null, loggedIn: null, breaker: 'closed' },
111 acli: { name: 'acli', available: null, loggedIn: null, breaker: 'closed' },
112 } as { gh: ToolStatus; acli: ToolStatus },
113)
114const ui = atom({ plugin: 'artifact-tracking', key: 'ui' } as const, { folded: [], paneOpen: false } as UiState)
115/** The run: root, proofs, agents, dismiss list and the last snapshot (spec 6.2). Never goes to $.store. */
116const auto = atom({ plugin: 'artifact-tracking', key: 'auto' } as const, emptyAuto() as AutoState)
117
118const PANE = 'track'
119const USAGE = 'Usage: /track [refresh|clear]'
120/** D3: the person's own text. Never a machine, a notification or a coordinator. */
121const PERSON = new Set(['composer', 'bridge', 'sdk', 'unclassified'])
122
123// Module state. A hot reload loses it. Each part is rebuilt on the next use.
124let gh: GhTool | undefined
125let acli: AcliTool | undefined
126let coordinator: RefreshCoordinator | undefined
127let runCmd: Runner | undefined
128const processBreaker = new CircuitBreaker()
129let processUiState: ProcessUiState = { breaker: 'closed' }
130/** undefined: not resolved yet. null: this session has no known repo. */
131let sessionRepo: RepoRef | null | undefined
132let repoTask: Promise<void> | undefined
133let githubHosts: string[] = []
134/** The session id last seen. A change means /clear, /resume or /branch. */
135let knownId: string | undefined
136let tick: Timer | null = null
137let flushTimer: Timer | null = null
138/** `$.clock.now()` when `flushTimer` was armed. */
139let flushArmedAt = 0
140/**
141 * The list as last written for `knownId`. A module variable outlives /clear, `$.state` does not.
142 * So a /clear still finds what was captured in the last moments (D10).
143 */
144let mirror: { id: string; items: ItemRecord[] } | undefined
145let repoMirror: { id: string; rows: RepoRecord[] } | undefined
146let processMirror: { id: string; rows: ProcessRecord[] } | undefined
147let scheduler: AutoScheduler | undefined
148let round: AutoRound | undefined
149/** `$.clock.now()` when the tick was armed, and at each beat (A1). */
150let lastBeatAt = 0
151/** The `auto` atom as last written. A module variable outlives /clear, `$.state` does not (A18). */
152let autoMirror: AutoState | undefined
153/** When the last round ended. The Worktrees note reads it before `snapshot.at` (A22). */
154let lastRoundAt: number | undefined
155/** The last list written to $.store: id and JSON. A flush of an equal list writes nothing (A22). */
156let flushed: { id: string; json: string } | undefined
157let repoTracker: RepoTracker | undefined
158/** Repo lookups run here, one at a time, after the tool has returned. */
159const repoQueue = new RepoQueue()
160/** Bumped by /track clear. A job that began before it writes nothing. */
161let repoGen = 0
162
163// ------------------------------------------------------------------ runtime
164
165/** Builds the tools and the coordinator once. `$` only appears in calls, never as a value. */
166function ensureRuntime($: EngineInterface): void {
167 if (gh && acli && coordinator && scheduler && round) return
168 const run: Runner = (argv, init) => $.process.run(argv, init)
169 runCmd = run
170 repoTracker = new RepoTracker({
171 run,
172 now: Date.now,
173 get rootPath() {
174 return autoMirror?.root?.top ?? (typeof autoMirror?.root?.cwd === 'string' ? autoMirror.root.cwd : undefined)
175 },
176 get rootRepo() {
177 return sessionRepo ?? null
178 },
179 })
180 const g = new GhTool(run, Date.now)
181 const a = new AcliTool(run, Date.now)
182 gh = g
183 acli = a
184 // Probe without waiting: acli takes about 0.6 s. Until a probe settles, the tool reads as not available.
185 void Promise.allSettled([g.probe(), a.probe()]).then(() => publishTools($))
186 coordinator = new RefreshCoordinator({
187 now: Date.now,
188 load: () => read($, items),
189 save: async refreshed => {
190 const refreshedKeys = new Set(refreshed.map(rec => rec.key))
191 const before = await read($, items)
192 await setItems($, list => mergeResults(list, refreshed))
193 const after = await read($, items)
194 const kept = new Set(after.map(rec => rec.key))
195 const dropped = before.filter(rec => refreshedKeys.has(rec.key) && !kept.has(rec.key)).map(rec => rec.key)
196 if (dropped.length > 0) {
197 // Keep discarded keys in this run's dismiss list, so transcript replay does not bring them back on /resume.
198 try {
199 await setAuto($, s => dismiss(s, dropped))
200 } catch {
201 // The list is already merged; dismissal tracking is best-effort.
202 }
203 }
204 },
205 hydrate: rec => hydrate(rec, { gh: g, acli: a }),
206 tools: { gh: g, acli: a },
207 after: (ms, fn) => $.clock.after(ms, fn),
208 onRun: async () => {
209 await publishTools($)
210 await schedule($)
211 },
212 onError: () => {
213 // The tools record their own failures. A run must not break the session.
214 },
215 })
216 const git = (argv: readonly string[], cwd: string) => run(['git', ...argv], { cwd, timeoutMs: LIMITS.gitTimeoutMs })
217 const autoRound = new AutoRound({
218 now: Date.now,
219 load: async () => {
220 const s = await currentAuto($)
221 autoMirror = s
222 return s
223 },
224 save: change => setAuto($, change),
225 git,
226 listAgents: async () =>
227 (await $.agent.list()).map(a => ({
228 id: a.id,
229 status: a.status,
230 ...(a.teammateId !== undefined ? { teammateId: a.teammateId } : {}),
231 })),
232 sessionCwd: () => $.session.cwd(),
233 repo: async () => {
234 await ensureRepo($)
235 return sessionRepo ?? null
236 },
237 gh: g,
238 ghReady: async () => {
239 await g.probe()
240 return g.isAvailable
241 },
242 githubHosts: () => githubHosts,
243 applyItems: change => applyItems($, change),
244 onAdded: keys => {
245 if (keys.length > 0) coordinator?.kick('capture', keys)
246 },
247 })
248 round = autoRound
249 scheduler = new AutoScheduler({
250 now: Date.now,
251 after: (ms, fn) => $.clock.after(ms, fn),
252 run: async reasons => {
253 await autoRound.run(reasons)
254 lastRoundAt = Date.now()
255 },
256 })
257}
258
259/** The status as the pane sees it. `lastRunAt` moves on every call and no view shows it, so it is not compared (A22). */
260const shown = (x: { gh: ToolStatus; acli: ToolStatus }): string =>
261 JSON.stringify([
262 { ...x.gh, lastRunAt: undefined },
263 { ...x.acli, lastRunAt: undefined },
264 ])
265
266/**
267 * Active Jira site state:
268 * - `undefined`: no acli runtime (do not filter Jira refs)
269 * - `null`: acli runtime exists, but no active site could be loaded
270 * - `<host>`: active Jira site host
271 */
272async function activeJiraSite(): Promise<string | null | undefined> {
273 const a = acli
274 if (!a) return undefined
275 if (a.activeSite !== undefined) return a.activeSite
276 try {
277 return await a.loadActiveSite()
278 } catch {
279 return null
280 }
281}
282
283function filterToolRefsForSite(refs: Ref[], site: string | null | undefined): Ref[] {
284 if (!refs.some(r => r.category === 'jira')) return refs
285 if (site === undefined) return refs
286 if (site === null) return refs.filter(r => r.category !== 'jira')
287 const active = site.toLowerCase()
288 return refs.filter(r => r.category !== 'jira' || r.site.toLowerCase() === active)
289}
290
291async function filterToolRefs(refs: Ref[]): Promise<Ref[]> {
292 return filterToolRefsForSite(refs, await activeJiraSite())
293}
294
295/**
296 * Copies the tool statuses to $.state for the pane. Reads first and writes nothing when nothing the pane shows
297 * changed, so a quiet tick makes no write (A22).
298 */
299async function publishTools($: EngineInterface): Promise<void> {
300 const g = gh
301 const a = acli
302 if (!g || !a) return
303 const next = { gh: g.status(), acli: a.status() }
304 if (shown(await read($, tools)) === shown(next)) return
305 await update($, tools, () => next)
306}
307
308/** Change the list in $.state and remember the result in `mirror`. Every write to `items` goes through here. */
309async function setItems($: EngineInterface, change: (list: ItemRecord[]) => ItemRecord[]): Promise<void> {
310 const next = await update($, items, list => change(list ?? []))
311 const id = await $.session.id()
312 // A write that lands after /clear but before the id change is seen belongs to the old session. Keep the old mirror.
313 if (knownId !== undefined && id === knownId) mirror = { id, items: next }
314}
315
316/**
317 * Like setItems, for tracked repositories. Every write to `repos` goes through here, so this is the one place that
318 * drops the repos the person dismissed with the x button. It drops them before the cap. Reads first and writes
319 * nothing when the result is the input list itself.
320 */
321async function setRepos($: EngineInterface, change: (rows: RepoRecord[]) => RepoRecord[]): Promise<void> {
322 const dismissed = new Set((await currentAuto($)).dismissed)
323 const apply = (rows: RepoRecord[]): RepoRecord[] => {
324 const out = change(rows)
325 const kept = dismissed.size === 0 ? out : out.filter(r => !dismissed.has(repoRowId(r.key)))
326 return capRepoRows(kept.length === out.length ? out : kept)
327 }
328 const cur = (await read($, repos)) ?? []
329 if (apply(cur) === cur) return // nothing changed: no write, no flush
330 const next = await update($, repos, rows => apply(rows ?? []))
331 const id = await $.session.id()
332 if (knownId !== undefined && id === knownId) repoMirror = { id, rows: next }
333 await schedule($)
334}
335
336/** Like setItems, for tracked background processes. */
337async function setProcesses($: EngineInterface, change: (rows: ProcessRecord[]) => ProcessRecord[]): Promise<void> {
338 const next = await update($, processes, rows => change(rows ?? []))
339 const id = await $.session.id()
340 if (knownId !== undefined && id === knownId) processMirror = { id, rows: next }
341 await schedule($)
342}
343
344/**
345 * Change the `auto` atom. Every write goes through here (A18, A22). It reads first and stops when the change
346 * returns its input: no write. Else `update` runs the change on the current value, with the module copy as the
347 * fallback after /clear, and retries on a version miss.
348 */
349async function setAuto($: EngineInterface, change: (cur: AutoState) => AutoState): Promise<AutoState> {
350 const cur = await read($, auto)
351 if (change(carryAuto(autoMirror, cur)) === cur) {
352 autoMirror = cur
353 return cur
354 }
355 const next = await update($, auto, c => change(carryAuto(autoMirror, c)))
356 autoMirror = next
357 return next
358}
359
360/** The `auto` atom, or the module copy when /clear reset it. */
361async function currentAuto($: EngineInterface): Promise<AutoState> {
362 return carryAuto(autoMirror, await read($, auto))
363}
364
365/**
366 * The item write of a round. `change` also gets the dismiss list, read inside the change from `autoMirror` (A20).
367 * Reads first and skips `update` when the change returns the input list itself (A22).
368 * Like `capture`, it looks at the session id first: a PR step that was in flight over /resume must not fill the new,
369 * empty list before `restore` has loaded the stored copy.
370 */
371async function applyItems(
372 $: EngineInterface,
373 change: (list: ItemRecord[], dismissed: ReadonlySet<string>) => DiscoveryApplied,
374): Promise<DiscoveryApplied> {
375 await ensureSession($)
376 const dismissed = (): ReadonlySet<string> => new Set((autoMirror ?? emptyAuto()).dismissed)
377 const cur = await read($, items)
378 let result = change(cur, dismissed())
379 if (result.list === cur) return result
380 await setItems($, list => {
381 result = change(list, dismissed())
382 return result.list
383 })
384 return result
385}
386
387/** A sign of life for a workflow agent (A7). A main-loop call has no agent id and does nothing. */
388async function touch($: EngineInterface, agentId: string | undefined): Promise<void> {
389 if (agentId !== undefined) await setAuto($, s => recordActivity(s, agentId, Date.now()))
390}
391
392function touchedBy(agentId: string | undefined): string {
393 if (agentId === undefined) return 'main'
394 const label = autoMirror?.agents.find(a => a.id === agentId)?.label
395 return label && label !== '' ? label : `agent:${agentId.slice(0, 8)}`
396}
397
398/**
399 * Queues a repo lookup and returns at once: a lookup costs several git calls and must not hold a tool result back.
400 * Who, when, the clear count and the session id are taken now, so the row says who touched the repo even when the job
401 * runs late. The job writes nothing after /track clear or a new session id. Never rejects.
402 */
403function enqueueRepo<T>(
404 $: EngineInterface,
405 key: string,
406 items: readonly T[],
407 label: (item: T) => string,
408 agentId: string | undefined,
409 resolve: (tracker: RepoTracker, item: T, who: string) => Promise<RepoVisit | null>,
410): void {
411 const tracker = repoTracker
412 if (!tracker || items.length === 0) return
413 const who = touchedBy(agentId)
414 const at = Date.now()
415 const gen = repoGen
416 const sid = knownId
417 repoQueue.push(`${key}\0${gen}\0${sid ?? ''}\0${who}\0${items.map(label).join('\0')}`, async () => {
418 const visits: RepoVisit[] = []
419 for (const item of items) {
420 try {
421 const v = await resolve(tracker, item, who)
422 if (v) visits.push(v)
423 } catch {
424 // one bad item must not stop the others
425 }
426 }
427 if (visits.length === 0 || repoGen !== gen) return
428 if (sid !== undefined && (await $.session.id()) !== sid) return
429 if (repoGen !== gen) return
430 await setRepos($, rows => {
431 let out = rows
432 for (const v of visits) out = upsertRepoRows(out, { ...v, at })
433 return out
434 })
435 })
436}
437
438function enqueueRepoPaths($: EngineInterface, hits: readonly RepoHit[], agentId: string | undefined): void {
439 enqueueRepo(
440 $,
441 'path',
442 hits,
443 h => `${h.mode}${h.branchMayChange ? '!' : ''}:${h.path}`,
444 agentId,
445 (t, h, who) => t.resolvePath(h.path, who, h.mode, { branchMayChange: h.branchMayChange === true }),
446 )
447}
448
449function enqueueRepoRefs($: EngineInterface, refs: readonly RepoRef[], agentId: string | undefined): void {
450 enqueueRepo(
451 $,
452 'ref',
453 refs,
454 r => `${r.host}/${r.owner}/${r.repo}`,
455 agentId,
456 (t, ref, who) => t.fromRepoRef(ref, who),
457 )
458}
459
460/** A `tool.call` result of a call that was refused (`deny`) or that the tool reported as an error (`isError`). */
461const refused = (ran: unknown): boolean =>
462 !!ran && typeof ran === 'object' && (ran as { deny?: unknown }).deny !== undefined
463const failed = (ran: unknown): boolean =>
464 refused(ran) || (!!ran && typeof ran === 'object' && (ran as { isError?: unknown }).isError === true)
465
466/**
467 * Arms the 30 s tick, or arms it again when the last beat is over two periods old (A1). One refused period ends a
468 * `clock.every` for good, so every hook, kick, command and button press calls this. `now` and `lastBeatAt` both come
469 * from `$.clock.now()`. Render hooks do not call it: a render must not start timers. Never rejects.
470 */
471async function ensureTick($: EngineInterface): Promise<void> {
472 try {
473 const now = await $.clock.now()
474 if (tick && now - lastBeatAt <= 2 * LIMITS.tickMs) return
475 tick?.cancel()
476 lastBeatAt = now
477 tick = $.clock.every(LIMITS.tickMs, async () => {
478 try {
479 lastBeatAt = await $.clock.now()
480 } catch {
481 // Keep the old beat: the watchdog will arm the interval again.
482 }
483 await beat($)
484 })
485 } catch {
486 // No tick this time. The next hook tries again.
487 }
488}
489
490/** One beat: find the session and the repo again (after /clear), then one round and one refresh. */
491async function beat($: EngineInterface): Promise<void> {
492 try {
493 ensureRuntime($)
494 await ensureSession($)
495 await ensureRepo($)
496 } catch {
497 // The beat still kicks what it can.
498 }
499 scheduler?.kick('tick')
500 coordinator?.kick('tick')
501 await probeProcesses($).catch(() => {})
502}
503
504/** Builds the runtime if needed and looks at the tick. The first step of every event hook. */
505function arm($: EngineInterface): void {
506 ensureRuntime($)
507 void ensureTick($)
508}
509
510/** An event reason for the round, and a look at the tick. */
511function kickAuto($: EngineInterface, reason: AutoReason): void {
512 void ensureTick($)
513 scheduler?.kick(reason)
514}
515
516/** The x button: the `auto` atom first, the items second. The order matters (spec 8, 10.5). */
517async function dismissOne($: EngineInterface, id: string): Promise<void> {
518 try {
519 ensureRuntime($)
520 void ensureTick($)
521 await setAuto($, s => dismiss(s, [id]))
522 // A repo row: setRepos is the choke point that drops it now that the id is on the dismiss list.
523 if (id.startsWith('rp:')) await setRepos($, rows => rows)
524 else if (!isRowId(id)) await setItems($, list => list.filter(rec => rec.key !== id))
525 } catch {
526 // The row stays. The person can press again.
527 }
528}
529
530// ------------------------------------------------------------------ session id and repo
531
532/**
533 * /clear, /resume and /branch give a new id and reset $.state, and `session.start` does not fire again.
534 * So compare the id on every use: in each hook, in the tick and in the flush. On a change:
535 * 1. Save the last list of the session just left. Its flush may not have run (D10).
536 * 2. A new id that the store knows is a /resume: restore that session (its copy, then the transcript).
537 * 3. Any other new id is a /clear or a /branch: carry the items of the old session over.
538 */
539async function ensureSession($: EngineInterface): Promise<void> {
540 const id = await $.session.id()
541 if (knownId === id) return
542 const prev = knownId
543 knownId = id
544 sessionRepo = undefined
545 repoTask = undefined
546 if (prev === undefined) return
547 // The `auto` atom belongs to the run, not to a session id. After /clear $.state is empty; the module copy fills it.
548 await setAuto($, s => s)
549 const stored = (await $.store.get('s:' + prev)) as StoredCopy | undefined
550 const held: SessionSlices = {
551 items: mirror?.id === prev ? mirror.items : [],
552 repos: repoMirror?.id === prev ? repoMirror.rows : [],
553 processes: processMirror?.id === prev ? processMirror.rows : [],
554 }
555 mirror = undefined
556 repoMirror = undefined
557 processMirror = undefined
558 const left: SessionSlices = {
559 items: rekeyArtifacts(unionRecords(held.items, stored?.items ?? [])),
560 repos: mergeRepoRows(stored?.repos ?? [], held.repos),
561 processes: mergeProcessRows(stored?.processes ?? [], held.processes),
562 }
563 if (held.items.length > 0 || held.repos.length > 0 || held.processes.length > 0) await writeStore($, prev, left)
564 const own = (await $.store.get('s:' + id)) as StoredCopy | undefined
565 if (own?.items || own?.repos || own?.processes) {
566 await restore($)
567 } else {
568 if (left.items.length > 0) await setItems($, list => unionRecords(left.items, list))
569 if (left.repos.length > 0) await setRepos($, rows => mergeRepoRows(left.repos, rows))
570 if (left.processes.length > 0) await setProcesses($, rows => mergeProcessRows(left.processes, rows))
571 }
572 await publishTools($) // the tools atom was reset too
573}
574
575/** Finds the session repo once per session: git remote first, else one `gh repo view`. */
576function ensureRepo($: EngineInterface): Promise<void> {
577 repoTask ??= resolveRepo($)
578 return repoTask
579}
580
581async function resolveRepo($: EngineInterface): Promise<void> {
582 let repo: RepoRef | null = null
583 try {
584 const info = await $.session.repo()
585 if (info?.remote) repo = parseRemote(info.remote)
586 const g = gh
587 if (g) {
588 // A remote host that gh does not know is an ssh alias (git@github-work:o/r) or not GitHub at all.
589 // Drop it and ask `gh repo view`, which resolves aliases.
590 if (repo && repo.host !== 'github.com') {
591 await g.probe()
592 if (g.isAvailable && (await g.checkHost(repo.host))) githubHosts = [repo.host]
593 else repo = null
594 }
595 if (!repo) {
596 await g.probe()
597 if (g.isAvailable) {
598 repo = await g.defaultRepo()
599 if (repo && repo.host !== 'github.com') {
600 if (await g.checkHost(repo.host)) githubHosts = [repo.host]
601 else repo = null
602 }
603 }
604 }
605 }
606 } catch {
607 repo = null // No repo: bare #N stays unresolved.
608 }
609 sessionRepo = repo
610 if (repo) {
611 const known = repo
612 try {
613 await setItems($, list => resolveBareRefs(list, known))
614 } catch {
615 // A refused write must not poison `repoTask`. Every later capture awaits it.
616 }
617 coordinator?.kick('capture')
618 }
619}
620
621// ------------------------------------------------------------------ capture
622
623/** Scan text and merge the refs into the session list. Never throws into the caller. */
624async function capture($: EngineInterface, text: string, mode: ScanMode, source: Source): Promise<void> {
625 if (text === '') return
626 arm($)
627 await ensureSession($)
628 await ensureRepo($)
629 if (mode === 'full') await captureMentions($, text, source).catch(() => {}) // a file fault must not stop the refs
630 let refs = extractRefs(text, {
631 mode,
632 defaultRepo: sessionRepo ?? null,
633 githubHosts,
634 strictShortRefs: mode === 'full',
635 })
636 if (mode === 'full') refs = await keepOpenSessionShortRefs(refs)
637 if (source === 'tool' && mode === 'urls-only') refs = await filterToolRefs(refs)
638 await trackRefs($, refs, source)
639}
640
641/** Merge refs into the session list, less the dismissed keys. The common tail of every capture. */
642async function trackRefs($: EngineInterface, refs: readonly Ref[], source: Source): Promise<void> {
643 const dismissed = new Set((await currentAuto($)).dismissed)
644 const keep = refs.filter(ref => !dismissed.has(keyOf(ref)))
645 if (keep.length === 0) return
646 let added: string[] = []
647 await setItems($, list => {
648 const merged = mergeRefs(list, keep, { source, at: Date.now() })
649 added = merged.added
650 return merged.list
651 })
652 if (added.length > 0) coordinator?.kick('capture', added)
653 await schedule($)
654}
655
656/**
657 * An Artifact tool call (issue #29). A publish tracks the page with source `publish`. A delete marks its row;
658 * a dismissed or unknown artifact gets no row. Errors, refusals and other actions change nothing.
659 */
660async function applyArtifactCall($: EngineInterface, input: unknown, ran: unknown): Promise<void> {
661 const ev = artifactEvent(input, ran)
662 if (!ev) return
663 await ensureSession($)
664 if (ev.kind === 'published') return trackRefs($, [ev.ref], 'publish')
665 const cur = await read($, items)
666 if (markArtifactDeleted(cur, ev.key) === cur) return // no such row: a write would change nothing (A22)
667 await setItems($, list => markArtifactDeleted(list, ev.key))
668 await schedule($)
669}
670
671function sameSessionRepo(ref: Ref): ref is GitHubRef & { owner: string; repo: string; type: 'unknown' } {
672 if (
673 ref.category !== 'github' ||
674 ref.short !== true ||
675 ref.type !== 'unknown' ||
676 !ref.owner ||
677 !ref.repo ||
678 !sessionRepo
679 )
680 return false
681 return (
682 ref.host.toLowerCase() === sessionRepo.host.toLowerCase() &&
683 ref.owner.toLowerCase() === sessionRepo.owner.toLowerCase() &&
684 ref.repo.toLowerCase() === sessionRepo.repo.toLowerCase()
685 )
686}
687
688async function keepOpenSessionShortRefs(refs: Ref[]): Promise<Ref[]> {
689 const probe = gh
690 if (!probe) return refs.filter(ref => !sameSessionRepo(ref))
691 const candidates = refs.filter(sameSessionRepo)
692 if (candidates.length === 0) return refs
693 const targets: GitHubTarget[] = candidates.map(ref => ({
694 key: keyOf(ref),
695 host: ref.host,
696 owner: ref.owner,
697 repo: ref.repo,
698 number: ref.number,
699 type: ref.type,
700 }))
701 await probe.refreshItems(targets)
702 // GhTool sets either `error` or a normalized lower-case `state` for each queried item.
703 const keep = new Set(targets.filter(t => t.error === undefined && t.state === 'open').map(t => t.key))
704 return refs.filter(ref => !sameSessionRepo(ref) || keep.has(keyOf(ref)))
705}
706
707// ------------------------------------------------------------------ Markdown files (issue #28)
708
709type FileEnv = { home?: string; configDir?: string; memoryDir?: string }
710
711/** Read once per process: every Write, Edit, Read and mention needs it (issue #28 study, "Resolve and label"). */
712let fileEnvTask: Promise<FileEnv> | undefined
713
714function fileEnv($: EngineInterface): Promise<FileEnv> {
715 fileEnvTask ??= readFileEnv($)
716 return fileEnvTask
717}
718
719/**
720 * HOME, the Claude config dir (`CLAUDE_CONFIG_DIR`, else `$HOME/.claude`) and the `autoMemoryDirectory` setting.
721 * The setting is newer than the pinned types, so it is read as plain data. Unset or unreadable: absent.
722 */
723async function readFileEnv($: EngineInterface): Promise<FileEnv> {
724 const home = await $.env.get('HOME').catch(() => undefined)
725 const dir = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
726 const configDir = dir ? normalizePath(dir) : home ? normalizePath(`${home}/.claude`) : undefined
727 const settings: unknown = await $.settings.read().catch(() => undefined)
728 const mem = isRecord(settings) && typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : ''
729 const memoryDir = mem ? resolvePath(mem, configDir ?? '/', home) : null
730 return { ...(home ? { home } : {}), ...(configDir ? { configDir } : {}), ...(memoryDir ? { memoryDir } : {}) }
731}
732
733/** Where an absolute path lands: the real path from one stat, else the path itself. */
734async function landed($: EngineInterface, path: string): Promise<{ path: string; exists: boolean }> {
735 const st = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
736 if (!st) return { path, exists: false }
737 return { path: st.realPath ? normalizePath(st.realPath) : path, exists: st.kind === 'file' }
738}
739
740/** What a file sighting needs: the cwd of a relative path, HOME for `~/`, and the ignore rule's root and config dir. */
741type FileCtx = IgnoreContext & { cwd: string; home?: string }
742
743/** A subagent's relative path joins its spawn cwd (A6); everything else joins the session cwd. */
744async function fileCtx($: EngineInterface, agentId?: string): Promise<FileCtx> {
745 const auto = await currentAuto($)
746 const spawn = agentId === undefined ? undefined : auto.agents.find(a => a.id === agentId)?.cwd
747 return {
748 ...(await fileEnv($)),
749 cwd: spawn ?? (await $.session.cwd()),
750 ...(auto.root ? { root: auto.root.cwd } : {}),
751 }
752}
753
754/** One sighting of a Markdown file. null: an ignored path, or a mention of a file that is not there. */
755async function sighting(
756 $: EngineInterface,
757 ctx: FileCtx,
758 spelled: string,
759 kind: FileActivity,
760 bySubagent: boolean,
761 s: Sighting,
762): Promise<FileRecord | null> {
763 const label = resolvePath(spelled, ctx.cwd, ctx.home)
764 if (label === null || isIgnored(label, ctx)) return null
765 const hit = await landed($, label)
766 if (kind === 'mentioned' && !hit.exists) return null
767 return recordFromFile({ path: hit.path, label, kind, bySubagent }, s)
768}
769
770/** Drops the views of files that no earlier sighting tracks: a Read alone never adds a row. */
771function withoutLoneViews(list: readonly ItemRecord[], recs: readonly FileRecord[]): FileRecord[] {
772 const known = new Set(list.map(i => i.key))
773 return recs.filter(r => {
774 if (r.views > 0) return known.has(r.key)
775 known.add(r.key)
776 return true
777 })
778}
779
780/** Merges file sightings into the list. A dismissed key never comes back. */
781async function addFiles($: EngineInterface, recs: readonly FileRecord[]): Promise<void> {
782 const dismissed = new Set((await currentAuto($)).dismissed)
783 const wanted = recs.filter(r => !dismissed.has(r.key))
784 if (withoutLoneViews(await read($, items), wanted).length === 0) return // only views of untracked files: no write
785 await setItems($, list => mergeFiles(list, withoutLoneViews(list, wanted)))
786 await schedule($)
787}
788
789/** A Write, Edit or Read result: one sighting of the file it touched. */
790async function captureFileTouch($: EngineInterface, t: FileTouch, agentId: string | undefined): Promise<void> {
791 await ensureSession($)
792 const ctx = await fileCtx($, agentId)
793 const rec = await sighting($, ctx, t.spelled, t.kind, agentId !== undefined, { source: 'tool', at: Date.now() })
794 if (rec) await addFiles($, [rec])
795}
796
797/** `.md` paths that a prompt or reply names and that exist: one `mentioned` sighting each (R8). */
798async function mentionRecords($: EngineInterface, ctx: FileCtx, text: string, s: Sighting): Promise<FileRecord[]> {
799 const recs: FileRecord[] = []
800 for (const name of mdCandidates(text)) {
801 const rec = await sighting($, ctx, name, 'mentioned', false, s)
802 if (rec) recs.push(rec)
803 }
804 return recs
805}
806
807async function captureMentions($: EngineInterface, text: string, source: Source): Promise<void> {
808 if (mdCandidates(text).length === 0) return // most rows name no file: no state read
809 const recs = await mentionRecords($, await fileCtx($), text, { source, at: Date.now() })
810 if (recs.length > 0) await addFiles($, recs)
811}
812
813/** The Write, Edit and Read hooks: track the Markdown file, then count the call as a sign of life (A7). */
814async function onFileTool($: EngineInterface, tool: string, ran: unknown, agentId: string | undefined): Promise<void> {
815 try {
816 arm($)
817 const t = touchFromToolResult(tool, ran)
818 if (t) await captureFileTouch($, t, agentId)
819 await touch($, agentId)
820 } catch {
821 // Tracking a file must not change the tool result.
822 }
823}
824
825// ------------------------------------------------------------------ process tracking
826
827function mergeProcessRows(cur: ProcessRecord[], added: ProcessRecord[]): ProcessRecord[] {
828 if (added.length === 0) return cur
829 const now = Date.now()
830 const byId = new Map(cur.map(r => [r.id, r]))
831 for (const row of added) {
832 const old = byId.get(row.id)
833 byId.set(
834 row.id,
835 old
836 ? {
837 ...old,
838 ...row,
839 firstSeen: old.firstSeen,
840 lastSeen: now,
841 }
842 : row,
843 )
844 }
845 return [...byId.values()].sort((a, b) => a.firstSeen - b.firstSeen || a.id.localeCompare(b.id))
846}
847
848async function addProcessRows($: EngineInterface, rows: ProcessRecord[]): Promise<void> {
849 if (rows.length === 0) return
850 await setProcesses($, cur => mergeProcessRows(cur, rows))
851}
852
853async function applyTaskUpdate(
854 $: EngineInterface,
855 u: { taskId?: string; toolUseId?: string; state: ProcessRecord['state'] } | null,
856): Promise<void> {
857 if (!u) return
858 await setProcesses($, rows => {
859 let changed = false
860 const next = rows.map(r => {
861 const byTask = u.taskId !== undefined && r.taskId === u.taskId
862 const byToolUse = u.toolUseId !== undefined && r.toolUseId === u.toolUseId
863 if (!byTask && !byToolUse) return r
864 changed = true
865 return { ...r, state: u.state, lastSeen: Date.now() }
866 })
867 return changed ? next : rows
868 })
869}
870
871/** A probe command ran and exited non-zero. Not a breaker failure. */
872class ExitError extends Error {}
873
874async function probeProcesses($: EngineInterface): Promise<void> {
875 const run = runCmd
876 if (!run) return
877 const now = Date.now()
878 if (!processBreaker.allows(now)) {
879 processUiState = { ...processUiState, breaker: 'open', openUntil: processBreaker.openUntil }
880 return
881 }
882 const rows = await read($, processes)
883 const of = (kind: ProcessRecord['kind']) => rows.filter(r => r.kind === kind)
884 const dockerRows = of('docker-container')
885 const composeRows = of('docker-compose')
886 const tmuxRows = of('tmux')
887 const screenRows = of('screen')
888 const pidRows = of('detached-process').filter(r => r.pid !== undefined)
889 const lostRows = of('detached-process').filter(r => r.pid === undefined && r.fingerprint !== undefined)
890 const opts = { timeoutMs: LIMITS.discoveryTimeoutMs }
891 const updates = new Map<string, Partial<ProcessRecord>>()
892 const mark = (r: ProcessRecord, state: ProcessRecord['state'], pid?: number) => {
893 if (r.state === state && (pid === undefined || r.pid === pid)) return
894 updates.set(r.id, { state, ...(pid !== undefined ? { pid } : {}) })
895 }
896 // Each source is probed on its own: a Docker daemon that is down must not stop the tmux probe.
897 // A failed source leaves its rows at their last known state. Only a thrown error (timeout, no binary) counts
898 // for the breaker. A quick non-zero `docker ps` (daemon down) is shown as the reason and does not open it,
899 // else after three ticks the open breaker would stop the tmux, screen and ps probes too.
900 const failures: string[] = []
901 let dockerError: string | undefined
902 const probe = async (what: string, fn: () => Promise<void>) => {
903 try {
904 await fn()
905 } catch (e) {
906 const msg = e instanceof Error ? e.message : String(e)
907 if (!(e instanceof ExitError)) failures.push(msg)
908 if (what === 'docker') dockerError = msg.includes('ENOENT') ? 'docker not installed' : msg.trim()
909 }
910 }
911
912 if (dockerRows.length > 0 || composeRows.length > 0)
913 await probe('docker', async () => {
914 const ps = await run(['docker', 'ps', '-a', '--no-trunc', '--format', '{{.ID}}\t{{.Names}}\t{{.Status}}'], opts)
915 if (ps.exitCode !== 0) throw new ExitError(ps.stderr.trim() || `docker ps failed (${ps.exitCode})`)
916 const seen = parseDockerPs(ps.stdout)
917 const found = new Map<string, ProcessRecord['state']>()
918 for (const r of dockerRows) found.set(r.id, seen.find(x => dockerEntryMatchesRow(x, r))?.state ?? 'exited')
919 for (const r of composeRows) {
920 const label = `label=com.docker.compose.project=${r.project ?? r.name}`
921 const listed = await run(['docker', 'ps', '-a', '--filter', label, '--format', '{{.Status}}'], opts)
922 if (listed.exitCode !== 0) throw new ExitError(listed.stderr.trim() || `docker ps failed (${listed.exitCode})`)
923 found.set(r.id, composeState(listed.stdout))
924 }
925 for (const r of [...dockerRows, ...composeRows]) mark(r, found.get(r.id)!)
926 })
927
928 if (tmuxRows.length > 0)
929 await probe('tmux', async () => {
930 const live = new Set(tmuxLive(await run(['tmux', 'ls'], opts)))
931 for (const r of tmuxRows) mark(r, live.has(r.name) ? 'running' : 'exited')
932 })
933
934 if (screenRows.length > 0)
935 await probe('screen', async () => {
936 const live = new Set(screenLive(await run(['screen', '-ls'], opts)))
937 for (const r of screenRows) mark(r, live.has(r.name) ? 'running' : 'exited')
938 })
939
940 if (pidRows.length > 0)
941 await probe('ps', async () => {
942 for (const r of pidRows) {
943 const out = await run(['ps', '-p', String(r.pid), '-o', 'lstart=,command='], opts)
944 const alive = out.exitCode === 0 && psStillMatches(out.stdout, r.fingerprint ?? r.name, r.firstSeen)
945 mark(r, alive ? 'running' : 'exited')
946 }
947 })
948
949 // A detached process with no `echo $!` in its Bash call: find it once by command and start time.
950 if (lostRows.length > 0)
951 await probe('ps', async () => {
952 const all = await run(['ps', '-A', '-o', 'pid=,lstart=,command='], opts)
953 if (all.exitCode !== 0) throw new Error(all.stderr.trim() || `ps failed (${all.exitCode})`)
954 for (const r of lostRows) {
955 const pid = findDetachedPid(all.stdout, r.fingerprint!, r.firstSeen)
956 mark(r, pid !== undefined ? 'running' : 'exited', pid)
957 }
958 })
959
960 if (updates.size > 0)
961 await setProcesses($, current => {
962 const seenNow = Date.now()
963 let changed = false
964 const merged = current.map(r => {
965 const u = updates.get(r.id)
966 if (u === undefined || (u.state === r.state && (u.pid === undefined || u.pid === r.pid))) return r
967 changed = true
968 return { ...r, ...u, lastSeen: seenNow }
969 })
970 return changed ? merged : current
971 })
972 if (failures.length > 0) processBreaker.failure(now)
973 else processBreaker.success()
974 const dockerProbed = dockerRows.length > 0 || composeRows.length > 0
975 processUiState = {
976 ...processUiState,
977 breaker: processBreaker.state,
978 openUntil: processBreaker.openUntil,
979 dockerIssue: dockerError ?? (dockerProbed ? undefined : processUiState.dockerIssue),
980 }
981}
982
983// ------------------------------------------------------------------ store mirror
984
985/**
986 * Copy the list to $.store later. Many rows in one burst make one write.
987 * A timer that a hook refused never runs. So a pending timer that is long overdue is replaced.
988 * Never rejects.
989 */
990async function schedule($: EngineInterface): Promise<void> {
991 try {
992 if (flushTimer) {
993 const now = await $.clock.now()
994 if (flushTimer && now - flushArmedAt <= 2 * LIMITS.flushMs) return
995 flushTimer?.cancel()
996 flushTimer = null
997 }
998 const timer: Timer = $.clock.after(LIMITS.flushMs, async () => {
999 if (flushTimer === timer) flushTimer = null
1000 try {
1001 await flush($)
1002 } catch {
1003 // A failed copy is retried by the next change.
1004 }
1005 })
1006 flushTimer = timer
1007 flushArmedAt = await $.clock.now()
1008 } catch {
1009 // No copy this time. The next change tries again.
1010 }
1011}
1012
1013async function flush($: EngineInterface): Promise<void> {
1014 await ensureSession($) // a /clear since the last row: save the old session first, carry its items over
1015 const slices: SessionSlices = {
1016 items: await read($, items),
1017 repos: await read($, repos),
1018 processes: await read($, processes),
1019 }
1020 if (slices.items.length === 0 && slices.repos.length === 0 && slices.processes.length === 0) return // after /track clear: never replace a stored copy with an empty one
1021 await writeStore($, await $.session.id(), slices)
1022}
1023
1024/** The three lists that one session's stored copy holds. */
1025type SessionSlices = { items: ItemRecord[]; repos: RepoRecord[]; processes: ProcessRecord[] }
1026
1027/** A stored copy as read back: an older copy may lack a slice. */
1028type StoredCopy = Partial<SessionSlices>
1029
1030/** One session's copy, the index of copies (newest first) and the cap on how many copies stay. */
1031async function writeStore($: EngineInterface, id: string, slices: SessionSlices): Promise<void> {
1032 const json = JSON.stringify([slices.items, slices.repos, slices.processes])
1033 if (flushed?.id === id && flushed.json === json) return
1034 await $.store.set('s:' + id, {
1035 items: slices.items,
1036 repos: slices.repos,
1037 processes: slices.processes,
1038 })
1039 const index = ((await $.store.get('index')) as string[] | undefined) ?? []
1040 const next = [id, ...index.filter(x => x !== id)]
1041 for (const old of next.slice(LIMITS.keepSessions)) await $.store.delete('s:' + old)
1042 await $.store.set('index', next.slice(0, LIMITS.keepSessions))
1043 flushed = { id, json }
1044}
1045
1046// ------------------------------------------------------------------ restore
1047
1048/**
1049 * Wrappers of user-role rows that the person did not type. `SessionMessage` has no `isMeta` or `origin`,
1050 * so the backfill can only skip the wrappers it knows (D3 lets the live hook use the real flags).
1051 */
1052const NOT_TYPED = ['<local-command-stdout>', '<task-notification>', '<system-reminder>', '<teammate-message']
1053
1054/** The text a transcript message offers, by the rules of its row door. null: skip it. */
1055function backfillText(role: 'user' | 'assistant', text: string): string | null {
1056 if (text === '') return null
1057 if (role === 'assistant') return text
1058 if (NOT_TYPED.some(tag => text.includes(tag))) return null
1059 return text.includes('<command-args>') ? commandArgs(text) : text
1060}
1061
1062/**
1063 * Items from `$.session.messages()`: a `--resume` load fires no append hooks. File rows come from the Write, Edit
1064 * and Read results and the mentions, artifact rows from the Artifact tool uses, in transcript order. A subagent's
1065 * transcript stores no result, so its writes and publishes come back from the stored copy only.
1066 */
1067async function fromTranscript($: EngineInterface): Promise<ItemRecord[]> {
1068 const messages = await $.session.messages()
1069 if (!Array.isArray(messages)) return []
1070 // In transcript order, so a delete lands after the publish before it.
1071 const batches: ({ refs: Ref[]; source: Source } | { deleted: string })[] = []
1072 const fileRecs: FileRecord[] = []
1073 const opts = { defaultRepo: sessionRepo ?? null, githubHosts }
1074 const toolSite = await activeJiraSite()
1075 const ctx = await fileCtx($)
1076 const at = Date.now()
1077 for (const m of messages) {
1078 const text = backfillText(m.role, m.text)
1079 const source: Source = m.role === 'user' ? 'prompt' : 'response'
1080 if (text !== null) {
1081 batches.push({
1082 refs: await keepOpenSessionShortRefs(extractRefs(text, { mode: 'full', ...opts, strictShortRefs: true })),
1083 source,
1084 })
1085 fileRecs.push(...(await mentionRecords($, ctx, text, { source, at })))
1086 }
1087 for (const use of m.toolUses) {
1088 const t = touchFromToolResult(use.tool, use)
1089 const rec = t && (await sighting($, ctx, t.spelled, t.kind, false, { source: 'tool', at }))
1090 if (rec) fileRecs.push(rec)
1091 if (use.tool === 'Artifact') {
1092 const ev = artifactEvent(use.input, use)
1093 if (ev?.kind === 'published') batches.push({ refs: [ev.ref], source: 'publish' })
1094 else if (ev?.kind === 'deleted') batches.push({ deleted: ev.key })
1095 }
1096 if (!use.text) continue
1097 const refs = filterToolRefsForSite(extractRefs(use.text, { mode: 'urls-only', ...opts }), toolSite)
1098 if (refs.length > 0) batches.push({ refs, source: 'tool' })
1099 }
1100 }
1101 let list: ItemRecord[] = []
1102 for (const b of batches) {
1103 if ('deleted' in b) list = markArtifactDeleted(list, b.deleted)
1104 else if (b.refs.length > 0) list = mergeRefs(list, b.refs, { source: b.source, at }).list
1105 }
1106 return mergeFiles(list, withoutLoneViews(list, fileRecs))
1107}
1108
1109/** A stored record comes back due: its status stays for display, its refresh time goes. */
1110function due(rec: ItemRecord): ItemRecord {
1111 const out = { ...rec }
1112 delete out.refreshedAt
1113 return out
1114}
1115
1116/** A stored copy comes back without auto sources: a new process has no proofs, so the first complete PR step adds back what is still tracked. */
1117function withoutAuto(list: ItemRecord[]): ItemRecord[] {
1118 const keys = new Set(
1119 list.filter(r => r.via !== undefined || r.sources.some(s => AUTO_SOURCES.has(s))).map(r => r.key),
1120 )
1121 return dropAuto(list, keys)
1122}
1123
1124function keepStored(rec: ItemRecord, activeSite: string | null | undefined): boolean {
1125 if (rec.category !== 'jira') return true
1126 if (activeSite === undefined) return true
1127 if (rec.sources.length !== 1 || rec.sources[0] !== 'tool') return true
1128 return activeSite !== null && rec.site.toLowerCase() === activeSite.toLowerCase()
1129}
1130
1131/** Fill the list again at startup: the stored copy of this session, then what the transcript adds. */
1132async function restore($: EngineInterface): Promise<void> {
1133 const live = await read($, items)
1134 if (live.length > 0) {
1135 // A hot reload keeps $.state: nothing to restore. Rows an older version stored as web links move (R13).
1136 if (rekeyArtifacts(live) !== live) await setItems($, rekeyArtifacts)
1137 return
1138 }
1139 // Do not wait for the repo: it may need `gh` calls of up to 30 s. Bare #N is re-keyed when it arrives.
1140 const repoReady = ensureRepo($).catch(() => {})
1141 const id = await $.session.id()
1142 // No gate on the index: two sessions that flush together can drop each other's id from it.
1143 const copy = (await $.store.get('s:' + id)) as StoredCopy | undefined
1144 const activeSite = await activeJiraSite()
1145 const dismissed = new Set((autoMirror ?? emptyAuto()).dismissed)
1146 const stored = withoutAuto(rekeyArtifacts(copy?.items ?? []))
1147 .filter(rec => keepStored(rec, activeSite))
1148 .filter(rec => rec.error?.kind !== 'not-found')
1149 .filter(rec => !dismissed.has(rec.key))
1150 .map(due)
1151 const have = new Map(stored.map(r => [r.key, r]))
1152 // A stored key wins over the transcript, except for what only the transcript may know of an artifact:
1153 // a publish or a delete that landed after the last flush.
1154 const news = (r: ItemRecord): boolean => {
1155 const old = have.get(r.key)
1156 if (!old) return true
1157 if (r.category !== 'artifact' || old.category !== 'artifact') return false
1158 return (artifactOrigin(r) === 'published' && artifactOrigin(old) === 'mentioned') || (!!r.deleted && !old.deleted)
1159 }
1160 const fresh = (await fromTranscript($)).filter(r => news(r) && !dismissed.has(r.key))
1161 const all = unionRecords(stored, fresh)
1162 const repoRows = copy?.repos ?? []
1163 const proc = copy?.processes ?? []
1164 if (all.length === 0 && repoRows.length === 0 && proc.length === 0) return
1165 if (all.length > 0) await setItems($, list => unionRecords(list, all))
1166 if (repoRows.length > 0) await setRepos($, rows => mergeRepoRows(rows, repoRows))
1167 if (proc.length > 0) await setProcesses($, rows => mergeProcessRows(rows, proc))
1168 void repoReady
1169 .then(async () => {
1170 const repo = sessionRepo
1171 if (repo) await setItems($, list => resolveBareRefs(list, repo))
1172 })
1173 .catch(() => {})
1174 coordinator?.kick('capture')
1175 await schedule($)
1176}
1177
1178// ------------------------------------------------------------------ /track
1179
1180/** The view context from what is known now. It does not wait for the repo: a render must not wait on `gh`. */
1181async function contextOf($: EngineInterface, bodyColumns?: number): Promise<ViewContext> {
1182 return {
1183 now: Date.now(),
1184 folded: (await read($, ui)).folded,
1185 tools: await read($, tools),
1186 sessionRepo,
1187 auto: await currentAuto($),
1188 ...(lastRoundAt !== undefined ? { autoCheckedAt: lastRoundAt } : {}),
1189 ...(bodyColumns !== undefined ? { bodyColumns } : {}),
1190 }
1191}
1192
1193/** The context of the `/track` text: waits for the repo first, so `#N` keys are right. */
1194async function viewContext($: EngineInterface): Promise<ViewContext> {
1195 await ensureRepo($)
1196 return contextOf($)
1197}
1198
1199async function listText($: EngineInterface): Promise<string> {
1200 return composeCommandText(hooks/auto/dismiss.ts 51 lines1// The dismiss list (spec A14): ids of rows and keys of items that the person hid for this run.
2
3import type { AgentRow, AutoState, ItemRecord } from '../contracts'
4import { AUTO_SOURCES, LIMITS } from '../contracts'
5
6/** The ids of every current auto row and item. Never the root or the master. */
7export function autoIds(s: AutoState, items: readonly ItemRecord[]): string[] {
8 const ids: string[] = []
9 for (const r of s.snapshot?.worktrees ?? []) if (r.depth === 1) ids.push(r.id)
10 for (const r of s.snapshot?.agents ?? []) if (r.kind !== 'master') ids.push(r.id)
11 for (const it of items) if (it.sources.some(x => AUTO_SOURCES.has(x))) ids.push(it.key)
12 return ids
13}
14
15/** Rows without the dismissed ones and their subtrees. */
16function withoutSubtrees(rows: readonly AgentRow[], gone: ReadonlySet<string>): AgentRow[] {
17 const out: AgentRow[] = []
18 let skipBelow: number | null = null
19 for (const r of rows) {
20 if (skipBelow !== null && r.depth > skipBelow) continue
21 skipBelow = null
22 if (gone.has(r.id)) {
23 skipBelow = r.depth
24 continue
25 }
26 out.push(r)
27 }
28 return out
29}
30
31/** Adds the ids and drops their rows (and the subtree of an agent) from the snapshot. Known ids: the same object. */
32export function dismiss(s: AutoState, ids: readonly string[]): AutoState {
33 const known = new Set(s.dismissed)
34 const fresh = [...new Set(ids)].filter(id => !known.has(id))
35 if (fresh.length === 0) return s
36 const gone = new Set(ids)
37 const dismissed = [...s.dismissed, ...fresh].slice(-LIMITS.maxDismissed)
38 const snap = s.snapshot
39 return {
40 ...s,
41 dismissed,
42 snapshot: snap
43 ? {
44 ...snap,
45 worktrees: snap.worktrees.filter(r => !gone.has(r.id)),
46 agents: withoutSubtrees(snap.agents, gone),
47 }
48 : null,
49 }
50}
51hooks/auto/events.ts 95 lines1// Raw engine input and results to plain facts (spec 5.1, 7). Pure: no `$`, no I/O.
2// An unknown shape gives null or an empty object, so the hooks in register.tsx hold no parsing of their own.
3
4import type { AgentResultFacts, BashFacts, EnterFacts, SpawnFacts, TurnCompleteReason } from '../contracts'
5import { isRecord, type Json } from '../json'
6
7/** A shell command that can change worktrees or branches. `echo git` does not match. */
8export const GIT_CMD = /\bgit\s+(?:-C\s+\S+\s+)?(?:worktree|checkout|switch|branch)\b/
9
10const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
11const REASONS = new Set<string>(['answer', 'aborted', 'error', 'refusal'])
12
13/** The `result` of `await next(e)` for a tool.call. */
14const resultOf = (ran: unknown): Json | null => (isRecord(ran) && isRecord(ran.result) ? ran.result : null)
15
16/** EnterWorktree result: `worktreePath`, `worktreeBranch`. null when `ran` has no result. */
17export function fromEnterResult(ran: unknown, agentId?: string): EnterFacts | null {
18 const r = resultOf(ran)
19 const path = str(r?.worktreePath)
20 if (!r || path === undefined) return null
21 const branch = str(r.worktreeBranch)
22 return { path, ...(branch !== undefined ? { branch } : {}), ...(agentId !== undefined ? { agentId } : {}) }
23}
24
25/** Agent result: `completed` has the path and branch, `async_launched` has the id only. */
26export function fromAgentResult(ran: unknown): AgentResultFacts | null {
27 const r = resultOf(ran)
28 const agentId = str(r?.agentId)
29 if (!r || agentId === undefined) return null
30 if (r.status === 'async_launched') return { agentId }
31 if (r.status !== 'completed') return null
32 const worktreePath = str(r.worktreePath)
33 const worktreeBranch = str(r.worktreeBranch)
34 return {
35 agentId,
36 ...(worktreePath !== undefined ? { worktreePath } : {}),
37 ...(worktreeBranch !== undefined ? { worktreeBranch } : {}),
38 }
39}
40
41/** The `agent.spawn` input plus the `next(e)` answer. null when the answer has no agent id. */
42export function fromSpawn(input: unknown, answer: unknown): SpawnFacts | null {
43 if (!isRecord(input) || !isRecord(answer)) return null
44 const agentId = str(answer.agentId)
45 if (agentId === undefined) return null
46 const name = str(input.name)
47 const parentAgentId = str(input.parentAgentId)
48 const cwd = str(input.cwd)
49 const wf = input.workflow
50 const runId = isRecord(wf) ? str(wf.runId) : undefined
51 const index = isRecord(wf) && typeof wf.agentIndex === 'number' ? wf.agentIndex : undefined
52 return {
53 agentId,
54 description: str(input.description) ?? '',
55 subagentType: str(input.subagentType) ?? '',
56 ...(name !== undefined ? { name } : {}),
57 ...(parentAgentId !== undefined ? { parentAgentId } : {}),
58 ...(runId !== undefined && index !== undefined ? { workflow: { runId, agentIndex: index } } : {}),
59 ...(cwd !== undefined ? { cwd } : {}),
60 }
61}
62
63/**
64 * Bash: the branch of an agent's own commit or push, and the kick reason.
65 * `gitOperation.pr` kicks `pr-event`. Another operation, or a worktree or branch command, kicks `git`.
66 * No result gives nothing.
67 */
68export function fromBash(command: string, ran: unknown, agentId?: string): BashFacts {
69 const r = resultOf(ran)
70 if (!r) return {}
71 const op = isRecord(r.gitOperation) ? r.gitOperation : undefined
72 const out: BashFacts = {}
73 if (agentId !== undefined && op) {
74 const commit = isRecord(op.commit) ? str(op.commit.branch) : undefined
75 const push = isRecord(op.push) ? str(op.push.branch) : undefined
76 const branch = commit ?? push
77 if (branch !== undefined) {
78 out.agentId = agentId
79 out.branch = branch
80 }
81 }
82 if (op && isRecord(op.pr)) out.kick = 'pr-event'
83 else if ((op && Object.keys(op).length > 0) || GIT_CMD.test(command)) out.kick = 'git'
84 return out
85}
86
87/** turn.complete with an agent id. The main loop (no id) gives null. */
88export function fromTurnEnd(e: unknown): { agentId: string; reason: TurnCompleteReason } | null {
89 if (!isRecord(e)) return null
90 const agentId = str(e.agentId)
91 const reason = e.reason
92 if (agentId === undefined || typeof reason !== 'string' || !REASONS.has(reason)) return null
93 return { agentId, reason: reason as TurnCompleteReason }
94}
95hooks/auto/ledger.ts 192 lines1// The ledger: root pin, worktree proofs and agent records of this run (spec A3 to A7, A18).
2// Pure functions over AutoState. A call that changes nothing returns its input object (A22).
3
4import type {
5 AgentProof,
6 AgentResultFacts,
7 AutoState,
8 EnterFacts,
9 SpawnFacts,
10 TurnCompleteReason,
11 WorktreeProof,
12} from '../contracts'
13import { LIMITS } from '../contracts'
14import { normPath } from './porcelain'
15
16/** A bump of `lastActiveAt` younger than this is skipped, so a busy agent does not write `auto` per command. */
17const ACTIVITY_MIN_MS = 60_000
18const HELD = new Set(['pending', 'running', 'waiting'])
19
20export function emptyAuto(): AutoState {
21 return { root: null, worktrees: [], agents: [], dismissed: [], snapshot: null }
22}
23
24/** A3. Pins `cwd` at the first session.start of the run. Later calls change nothing. */
25export function pinRoot(s: AutoState, cwd: string, at: number): AutoState {
26 return s.root ? s : { ...s, root: { cwd, at } }
27}
28
29/** Sets the resolved toplevel once. `null`: not a git repository. */
30export function setRootTop(s: AutoState, top: string | null): AutoState {
31 if (!s.root || s.root.top !== undefined) return s
32 return { ...s, root: { ...s.root, top } }
33}
34
35function isRootPath(s: AutoState, path: string): boolean {
36 if (!s.root) return false
37 return path === normPath(s.root.cwd) || (typeof s.root.top === 'string' && path === normPath(s.root.top))
38}
39
40/** Adds a proof for `path`, or updates the branch of the one that exists. The root path never becomes a proof. */
41function withProof(s: AutoState, path: string, make: () => WorktreeProof, branch?: string): AutoState {
42 if (isRootPath(s, path)) return s
43 const i = s.worktrees.findIndex(w => w.path === path)
44 if (i < 0) return { ...s, worktrees: [...s.worktrees, make()].slice(-LIMITS.maxProofs) }
45 const w = s.worktrees[i]!
46 if (branch === undefined || w.branch === branch) return s
47 const worktrees = s.worktrees.slice()
48 worktrees[i] = { ...w, branch }
49 return { ...s, worktrees }
50}
51
52/** Proof 1 (A4): an EnterWorktree result path. */
53export function recordEnter(s: AutoState, ev: EnterFacts, at: number): AutoState {
54 const path = normPath(ev.path)
55 return withProof(
56 s,
57 path,
58 () => {
59 const p: WorktreeProof = { path, origin: 'enter', provenAt: at }
60 if (ev.agentId !== undefined) p.owner = ev.agentId
61 if (ev.branch !== undefined) p.branch = ev.branch
62 return p
63 },
64 ev.branch,
65 )
66}
67
68export type AgentPatch = { [K in keyof AgentProof]?: AgentProof[K] | undefined }
69
70/** A copy of `a` with the patch (undefined removes a key). No change returns `a` itself. */
71export function patchRecord(a: AgentProof, patch: AgentPatch): AgentProof {
72 const next: Record<string, unknown> = { ...a }
73 let changed = false
74 for (const [k, v] of Object.entries(patch)) {
75 if (v === undefined) {
76 if (k in next) {
77 delete next[k]
78 changed = true
79 }
80 } else if (next[k] !== v) {
81 next[k] = v
82 changed = true
83 }
84 }
85 return changed ? (next as AgentProof) : a
86}
87
88/** Replaces agent `i` with its patched copy. No change returns `s`. */
89function patchAgent(s: AutoState, i: number, patch: AgentPatch): AutoState {
90 const cur = s.agents[i]!
91 const next = patchRecord(cur, patch)
92 if (next === cur) return s
93 const agents = s.agents.slice()
94 agents[i] = next
95 return { ...s, agents }
96}
97
98const indexOfAgent = (s: AutoState, id: string): number => s.agents.findIndex(a => a.id === id)
99
100/**
101 * Records an `agent.spawn`. A second spawn for a known id (a retry) keeps the first record (A5).
102 * The proof's cwd is `ev.cwd`, else the parent's recorded cwd for a nested child, else `sessionCwd` (A6).
103 */
104export function recordSpawn(s: AutoState, ev: SpawnFacts, at: number, sessionCwd?: string): AutoState {
105 if (indexOfAgent(s, ev.agentId) >= 0) return s
106 const parent = ev.parentAgentId === undefined ? undefined : s.agents.find(a => a.id === ev.parentAgentId)
107 const cwd = ev.cwd ?? (ev.parentAgentId === undefined ? sessionCwd : parent?.cwd)
108 const a: AgentProof = {
109 id: ev.agentId,
110 kind: ev.workflow ? 'workflow' : 'subagent',
111 label: ev.name || ev.description || ev.subagentType,
112 spawnedAt: at,
113 }
114 if (ev.parentAgentId !== undefined) a.parentId = ev.parentAgentId
115 if (ev.workflow) a.workflow = { runId: ev.workflow.runId, agentIndex: ev.workflow.agentIndex }
116 if (cwd !== undefined) a.cwd = cwd
117 return { ...s, agents: [...s.agents, a].slice(-LIMITS.maxAgents) }
118}
119
120/**
121 * A sign of life (A7): sets `lastActiveAt` of a workflow agent and clears a stale end.
122 * Returns `s` for an unknown id, a subagent (the list rules its end), an agent that an event ended,
123 * and a `lastActiveAt` under 60 s old.
124 */
125export function recordActivity(s: AutoState, agentId: string, at: number): AutoState {
126 const i = indexOfAgent(s, agentId)
127 if (i < 0) return s
128 const a = s.agents[i]!
129 if (a.kind !== 'workflow') return s
130 if (a.endedAt !== undefined && !a.stale) return s
131 if (!a.stale && at - (a.lastActiveAt ?? a.spawnedAt) < ACTIVITY_MIN_MS) return s
132 return patchAgent(s, i, { lastActiveAt: at, endedAt: undefined, stale: undefined })
133}
134
135/** Proof 2 (A4): the exact `worktreePath` of an Agent result. Also sets the agent's path and branch. */
136export function recordAgentResult(s: AutoState, ev: AgentResultFacts, at: number): AutoState {
137 if (ev.worktreePath === undefined) return s
138 const path = normPath(ev.worktreePath)
139 if (isRootPath(s, path)) return s
140 let next = withProof(
141 s,
142 path,
143 () => {
144 const p: WorktreeProof = { path, origin: 'agent', owner: ev.agentId, provenAt: at }
145 if (ev.worktreeBranch !== undefined) p.branch = ev.worktreeBranch
146 return p
147 },
148 ev.worktreeBranch,
149 )
150 const i = indexOfAgent(next, ev.agentId)
151 if (i >= 0) {
152 const patch: AgentPatch = { worktreePath: path }
153 if (ev.worktreeBranch !== undefined) patch.branch = ev.worktreeBranch
154 next = patchAgent(next, i, patch)
155 }
156 return next
157}
158
159/** The agent's own Bash `gitOperation.commit.branch` or `push.branch`. */
160export function recordAgentBranch(s: AutoState, agentId: string, branch: string): AutoState {
161 const i = indexOfAgent(s, agentId)
162 return i < 0 ? s : patchAgent(s, i, { branch })
163}
164
165/** The list holds a subagent while it says pending, running, waiting, or idle for a teammate. */
166function heldByList(a: AgentProof): boolean {
167 if (a.kind !== 'subagent' || !a.listed || a.status === undefined) return false
168 return HELD.has(a.status) || (a.status === 'idle' && a.teammate === true)
169}
170
171/** Ends an agent the list does not hold (A7). `error` and `refusal` fail it. Unknown ids: returns `s`. */
172export function recordTurnEnd(s: AutoState, agentId: string, reason: TurnCompleteReason, at: number): AutoState {
173 const i = indexOfAgent(s, agentId)
174 if (i < 0) return s
175 const a = s.agents[i]!
176 if (a.endedAt !== undefined && !a.stale) return s
177 if (heldByList(a)) return s
178 const failed = reason === 'error' || reason === 'refusal'
179 return patchAgent(s, i, { endedAt: at, failed: failed ? true : undefined, stale: undefined })
180}
181
182/**
183 * /clear and reload (A18). `cur` is the $.state copy, `held` the module copy.
184 * Returns `cur` whenever `cur.root` is set. Returns `held` only when `cur` has no root (after /clear).
185 * Else an empty state.
186 */
187export function carryAuto(held: AutoState | undefined, cur: AutoState | undefined): AutoState {
188 if (cur?.root) return cur
189 if (held?.root) return held
190 return cur ?? emptyAuto()
191}
192hooks/auto/round.ts 289 lines1// AutoRound (spec 8): worktrees, agents, then pull requests in their own lane. Each step catches its own failure.
2// A refused `auto` write is not recorded anywhere: the round goes on, and the next round writes again.
3// No `$`: it gets plain closures. A round reads `auto` for inputs only and writes through `save(change)`,
4// where `change` re-runs the pure units on the current value (A20).
5
6import type {
7 AutoReason,
8 AutoSnapshot,
9 AutoState,
10 DiscoveryApplied,
11 GitHubDiscoveryApi,
12 GitWorktree,
13 Health,
14 ItemRecord,
15 LiveAgent,
16 Now,
17 Ref,
18 RepoRef,
19 RunResult,
20} from '../contracts'
21import { applyDiscovery } from '../model/discovered'
22import { prKey } from '../model/keys'
23import { prBodyRefs } from '../scan/pr-body'
24import { discoverPrs } from '../tools/pr-discovery'
25import { trackAgents } from './agents'
26import { setRootTop } from './ledger'
27import { parsePorcelain } from './porcelain'
28import { trackWorktrees } from './worktrees'
29
30export type RoundDeps = {
31 now: Now
32 /** Read for inputs only. The round never writes this copy back (A20). */
33 load: () => Promise<AutoState>
34 /** register: setAuto. `change` gets the current value and may run more than once. */
35 save: (change: (cur: AutoState) => AutoState) => Promise<AutoState>
36 git: (argv: readonly string[], cwd: string) => Promise<RunResult> // rejects like Runner
37 listAgents: () => Promise<LiveAgent[]>
38 sessionCwd: () => Promise<string>
39 /** Session repo after ensureRepo. null: none. */
40 repo: () => Promise<RepoRef | null>
41 gh: GitHubDiscoveryApi
42 /** Waits for the cached `gh --version` probe. */
43 ghReady: () => Promise<boolean>
44 githubHosts: () => readonly string[]
45 /**
46 * Change the item list. `change` gets the current list and the current dismiss list, read inside
47 * the change (register: from `autoMirror`), and may run more than once. Returns the committed result.
48 * Register reads first and skips `update` when the change returns the input list itself (A22).
49 */
50 applyItems: (
51 change: (list: ItemRecord[], dismissed: ReadonlySet<string>) => DiscoveryApplied,
52 ) => Promise<DiscoveryApplied>
53 /** New keys to refresh: register passes coordinator.kick('capture', keys). */
54 onAdded: (keys: string[]) => void
55}
56
57/** The reasons that start a PR step. Another reason starts one only when the tracked branches changed. */
58const PR_REASONS: ReadonlySet<AutoReason> = new Set(['start', 'tick', 'manual', 'pr-event'])
59
60const firstLine = (s: string): string => s.trim().split('\n')[0]?.trim() ?? ''
61const message = (err: unknown): string => (err instanceof Error ? err.message : String(err))
62
63type GitOut = { ok: true; res: RunResult } | { ok: false; error: string }
64
65/** A snapshot without its times, so a quiet round compares equal (A22). */
66function sig(x: AutoSnapshot): string {
67 return JSON.stringify([
68 x.worktrees,
69 x.agents,
70 x.branches,
71 x.overflow,
72 [x.git.ok, x.git.error ?? null],
73 [x.agentList.ok, x.agentList.error ?? null],
74 prsSig(x.prs),
75 x.defaultBranch ?? null,
76 ])
77}
78const prsSig = (p: AutoSnapshot['prs']) => [p.ok, p.skipped ?? null, p.error ?? null, p.untracked ?? 0]
79
80function withPrs(cur: AutoState, prs: AutoSnapshot['prs'], defaultBranch?: string): AutoState {
81 const snap = cur.snapshot
82 if (!snap) return cur
83 const db = defaultBranch ?? snap.defaultBranch
84 if (JSON.stringify(prsSig(snap.prs)) === JSON.stringify(prsSig(prs)) && db === snap.defaultBranch) return cur
85 return { ...cur, snapshot: { ...snap, prs, ...(db !== undefined ? { defaultBranch: db } : {}) } }
86}
87
88export class AutoRound {
89 /** The last porcelain list that git answered. Used when git fails. */
90 private lastList: GitWorktree[] = []
91 private prRunning = false
92 private prQueued = false
93 private prWaiters: Array<() => void> = []
94
95 constructor(private readonly d: RoundDeps) {}
96
97 /**
98 * One round. Never rejects. Settles after the snapshot save; the PR step starts in its lane and is not awaited (A16).
99 */
100 async run(reasons: ReadonlySet<AutoReason>): Promise<void> {
101 try {
102 await this.step(reasons)
103 } catch {
104 // The scheduler's next tick runs again.
105 }
106 }
107
108 /** Settles when no PR step is in flight or queued. */
109 idle(): Promise<void> {
110 if (!this.prRunning) return Promise.resolve()
111 return new Promise<void>(resolve => {
112 this.prWaiters.push(resolve)
113 })
114 }
115
116 private async git(argv: readonly string[], cwd: string): Promise<GitOut> {
117 try {
118 return { ok: true, res: await this.d.git(argv, cwd) }
119 } catch (err) {
120 return { ok: false, error: message(err) }
121 }
122 }
123
124 /** A refused write gives null. The caller keeps its own copy and goes on. */
125 private async save(change: (cur: AutoState) => AutoState): Promise<AutoState | null> {
126 try {
127 return await this.d.save(change)
128 } catch {
129 return null
130 }
131 }
132
133 private async step(reasons: ReadonlySet<AutoReason>): Promise<void> {
134 const at = this.d.now()
135 let s = await this.d.load()
136 if (!s.root) return // no run yet
137
138 // Step 1: worktrees (spec 8.1).
139 let top = s.root.top
140 let revError: string | undefined
141 if (top === undefined) {
142 const r = await this.git(['rev-parse', '--show-toplevel'], s.root.cwd)
143 if (r.ok && r.res.exitCode === 0 && r.res.stdout.trim() !== '') top = r.res.stdout.trim()
144 else if (r.ok && /not a git repository/i.test(r.res.stderr)) top = null
145 else revError = r.ok ? firstLine(r.res.stderr) || `exit ${r.res.exitCode}` : r.error
146 if (top !== undefined) {
147 const resolved = top
148 s = (await this.save(cur => setRootTop(cur, resolved))) ?? s
149 }
150 }
151 let git: Health = { ok: true, at }
152 let list: GitWorktree[] = []
153 if (typeof top === 'string') {
154 const r = await this.git(['worktree', 'list', '--porcelain'], top)
155 if (r.ok && r.res.exitCode === 0) {
156 list = parsePorcelain(r.res.stdout)
157 this.lastList = list
158 } else {
159 list = this.lastList
160 git = { ok: false, at, error: r.ok ? firstLine(r.res.stderr) || `exit ${r.res.exitCode}` : r.error }
161 }
162 } else if (top === undefined) {
163 list = this.lastList
164 git = { ok: false, at, error: revError ?? 'git rev-parse failed' }
165 }
166
167 // Step 2: agents (spec 8.2), then one save of the snapshot.
168 let live: LiveAgent[] | null = null
169 let agentList: Health = { ok: true, at }
170 try {
171 live = await this.d.listAgents()
172 } catch (err) {
173 agentList = { ok: false, at, error: message(err) }
174 }
175 let masterCwd: string | null = null
176 try {
177 masterCwd = await this.d.sessionCwd()
178 } catch {
179 masterCwd = null
180 }
181 const committed = (await this.save(cur => this.snapshotOf(cur, list, live, masterCwd, at, git, agentList))) ?? s
182
183 // Step 3: pull requests, in their own lane (spec 8.3, A16).
184 const branches = committed.snapshot?.branches ?? []
185 const before = s.snapshot?.branches ?? []
186 const changed = branches.length !== before.length || branches.some((b, i) => b !== before[i])
187 if ([...reasons].some(r => PR_REASONS.has(r)) || changed) this.requestPr()
188 }
189
190 /** Runs `trackWorktrees` and `trackAgents` again on the current value, then builds the snapshot (A20, A22). */
191 private snapshotOf(
192 cur: AutoState,
193 list: readonly GitWorktree[],
194 live: readonly LiveAgent[] | null,
195 masterCwd: string | null,
196 at: number,
197 git: Health,
198 agentList: Health,
199 ): AutoState {
200 const wv = trackWorktrees(cur, list, at)
201 const av = trackAgents(wv.state, live, list, masterCwd, at)
202 const prev = cur.snapshot
203 const next: AutoSnapshot = {
204 at,
205 worktrees: wv.rows,
206 agents: av.rows,
207 branches: wv.branches,
208 overflow: wv.overflow,
209 git,
210 agentList,
211 prs: prev?.prs ?? { ok: true },
212 ...(prev?.defaultBranch !== undefined ? { defaultBranch: prev.defaultBranch } : {}),
213 }
214 if (av.state === cur && prev && sig(prev) === sig(next)) return cur
215 return { ...av.state, snapshot: next }
216 }
217
218 // ---------------------------------------------------------------- the PR lane
219
220 private requestPr(): void {
221 if (this.prRunning) {
222 this.prQueued = true
223 return
224 }
225 void this.runLane()
226 }
227
228 /** One PR step at a time. A request during a step queues as one and runs after it. */
229 private async runLane(): Promise<void> {
230 this.prRunning = true
231 try {
232 do {
233 this.prQueued = false
234 try {
235 await this.prStep()
236 } catch {
237 // The next tick asks again.
238 }
239 } while (this.prQueued)
240 } finally {
241 this.prRunning = false
242 const done = this.prWaiters
243 this.prWaiters = []
244 for (const w of done) w()
245 }
246 }
247
248 /** One PR step. A throw in any part of it lands in the Pull requests note, as an `ok: false` outcome does. */
249 private async prStep(): Promise<void> {
250 try {
251 await this.discover()
252 } catch (err) {
253 const at = this.d.now()
254 const error = message(err)
255 await this.d.save(cur => withPrs(cur, { at, ok: false, error }))
256 }
257 }
258
259 private async discover(): Promise<void> {
260 const start = await this.d.load()
261 const branches = start.snapshot?.branches ?? []
262 const defaultBranch = start.snapshot?.defaultBranch
263 await this.d.ghReady()
264 const repo = await this.d.repo()
265 const at = this.d.now()
266 const out = await discoverPrs(this.d.gh, repo, branches, at, defaultBranch)
267 if (!out.ok) {
268 await this.d.save(cur =>
269 withPrs(cur, {
270 at,
271 ok: false,
272 ...(out.skipped ? { skipped: out.skipped } : {}),
273 ...(out.error ? { error: out.error } : {}),
274 }),
275 )
276 return
277 }
278 const refsByPr = new Map<string, Ref[]>()
279 for (const pr of out.prs) refsByPr.set(prKey(pr), prBodyRefs(pr, this.d.githubHosts()))
280 const r = await this.d.applyItems((list, dismissed) =>
281 applyDiscovery(list, { prs: out.prs, refsByPr, complete: out.complete, dismissed, at }),
282 )
283 this.d.onAdded(r.added)
284 await this.d.save(cur =>
285 withPrs(cur, { at, ok: true, ...(r.untracked > 0 ? { untracked: r.untracked } : {}) }, out.defaultBranch),
286 )
287 }
288}
289hooks/auto/scheduler.ts 99 lines1// AutoScheduler (spec A16): a fixed window for events, an immediate path for tick and manual, one round at a time.
2// Built on the coordinator's timer pattern (timerAt, the overdue-timer replacement, idle). No `$`.
3
4import type { AutoReason, Now } from '../contracts'
5import { LIMITS } from '../contracts'
6import type { Timer } from '../refresh/coordinator'
7
8export type SchedulerDeps = {
9 now: Now
10 after: (ms: number, fn: () => void | Promise<void>) => Timer
11 run: (reasons: ReadonlySet<AutoReason>) => Promise<void>
12 onError?: (err: unknown) => void
13}
14
15export class AutoScheduler {
16 private running = false
17 private queued?: Set<AutoReason>
18 private pending = new Set<AutoReason>()
19 private timer?: Timer
20 /** When the window timer was armed. A timer the engine refused never fires; this tells it apart. */
21 private timerAt = 0
22 private waiters: Array<() => void> = []
23
24 constructor(private readonly deps: SchedulerDeps) {}
25
26 get busy(): boolean {
27 return this.running
28 }
29
30 /**
31 * `tick` and `manual` run at once. The other reasons join a fixed window of `LIMITS.autoDebounceMs`
32 * that the first of them arms. A later kick joins it and does not move its end, so a burst of events
33 * (a workflow that spawns many agents) gives at most one round per window.
34 */
35 kick(reason: AutoReason): void {
36 if (reason === 'tick' || reason === 'manual') {
37 void this.exec(new Set([reason]))
38 return
39 }
40 this.pending.add(reason)
41 const at = this.deps.now()
42 if (this.timer && at - this.timerAt > 2 * LIMITS.autoDebounceMs) {
43 // The timer is long overdue: the engine never ran it. Arm a new one. The pending reasons stay.
44 this.timer.cancel()
45 this.timer = undefined
46 }
47 if (this.timer) return
48 this.timerAt = at
49 this.timer = this.deps.after(LIMITS.autoDebounceMs, () => {
50 const reasons = this.pending
51 this.pending = new Set()
52 this.timer = undefined
53 return this.exec(reasons)
54 })
55 }
56
57 /** Settles when no round is in flight or queued. */
58 idle(): Promise<void> {
59 if (!this.running) return Promise.resolve()
60 return new Promise<void>(resolve => {
61 this.waiters.push(resolve)
62 })
63 }
64
65 private async exec(reasons: ReadonlySet<AutoReason>): Promise<void> {
66 if (this.running) {
67 this.queued = new Set([...(this.queued ?? []), ...reasons])
68 return
69 }
70 this.running = true
71 try {
72 await this.once(reasons)
73 while (this.queued) {
74 const next = this.queued
75 this.queued = undefined
76 await this.once(next)
77 }
78 } finally {
79 this.running = false
80 const done = this.waiters
81 this.waiters = []
82 for (const w of done) w()
83 }
84 }
85
86 /** Never rejects. */
87 private async once(reasons: ReadonlySet<AutoReason>): Promise<void> {
88 try {
89 await this.deps.run(reasons)
90 } catch (err) {
91 try {
92 this.deps.onError?.(err)
93 } catch {
94 // The error handler must not break a round.
95 }
96 }
97 }
98}
99hooks/capture/artifact-tool.ts 57 lines1// An Artifact tool call as an event (issue #29; the table of the design study, §3). Pure: no `$`, no I/O.
2// The `tool.call` hook and the resume backfill both call it, so a live call and its transcript copy agree.
3
4import { basename } from '../auto/porcelain'
5import type { ArtifactRef } from '../contracts'
6import { dig, isRecord } from '../json'
7import { keyOf } from '../model/keys'
8import { clipLabel, parseArtifactUrl } from '../scan/artifact-url'
9
10export type ArtifactEvent = { kind: 'published'; ref: ArtifactRef } | { kind: 'deleted'; key: string }
11
12const text = (v: unknown): string | undefined => {
13 const s = typeof v === 'string' ? clipLabel(v) : ''
14 return s === '' ? undefined : s
15}
16
17/** The page a publish names, with its title. Without a title the label is `fallback`, else the id. */
18function published(url: unknown, title: string | undefined, fallback?: string): ArtifactEvent | null {
19 const ref = typeof url === 'string' ? parseArtifactUrl(url) : null
20 if (!ref) return null
21 if (title) return { kind: 'published', ref: { ...ref, label: title, titled: true } }
22 return { kind: 'published', ref: fallback ? { ...ref, label: fallback } : ref }
23}
24
25/**
26 * The event of one call: `input` as the model gave it, `ran` as `next(e)` resolved (or a transcript tool use).
27 * First match wins:
28 * - an error, a deny, or a result that is not an object: nothing;
29 * - `delete` with `artifact_delete.deleted`: deleted;
30 * - any action but publish (omitted means publish), or an `opened` result: nothing;
31 * - `asset: true` with an asset result: published, no title (an asset's file name is not the page's name);
32 * - `created_from_type` with `url`: published, with its title;
33 * - `url` with `artifact_id` or `path`: published, titled `result.title`, else the title input, else the file name.
34 */
35export function artifactEvent(input: unknown, ran: unknown): ArtifactEvent | null {
36 if (!isRecord(ran) || ran.isError || ran.deny) return null
37 const result = ran.result
38 if (!isRecord(result)) return null
39 const call = isRecord(input) ? input : {}
40 if (call.action === 'delete') {
41 const del = result.artifact_delete
42 if (!isRecord(del) || del.deleted !== true || typeof del.url !== 'string') return null
43 const ref = parseArtifactUrl(del.url)
44 return ref ? { kind: 'deleted', key: keyOf(ref) } : null
45 }
46 if ((call.action !== undefined && call.action !== 'publish') || result.opened === true) return null
47 if (call.asset === true) {
48 if (!isRecord(result.asset_upload) && !isRecord(result.asset_uploads) && !isRecord(result.asset_copy)) return null
49 const url = dig(result, 'written', 'url') ?? dig(result, 'asset_uploads', 'url') ?? dig(result, 'asset_copy', 'url')
50 return published(url, undefined)
51 }
52 if (result.created_from_type === true) return published(result.url, text(result.title) ?? text(call.title))
53 if (typeof result.artifact_id !== 'string' && typeof result.path !== 'string') return null
54 const file = typeof call.file_path === 'string' && call.file_path !== '' ? text(basename(call.file_path)) : undefined
55 return published(result.url, text(result.title) ?? text(call.title), file)
56}
57hooks/contracts.ts 288 lines1// Shared contracts between the modules of the mod. No `$` here: only register.tsx touches it.
2// Spec: docs/superpowers/specs/2026-10-10-artifact-tracking-design.md
3
4import type { TurnCompleteReason } from 'claude-code'
5import type {
6 Category,
7 Checks,
8 GitHubFields,
9 GitHubState,
10 ItemError,
11 ItemRecord,
12 JiraFields,
13 PrSkip,
14 Source,
15 ToolName,
16 ToolStatus,
17} from '../types'
18
19export type * from '../types'
20
21// ---------------------------------------------------------------- process runner
22
23export type RunInit = { timeoutMs?: number; cwd?: string; env?: Record<string, string> }
24
25export type RunResult = {
26 exitCode: number
27 stdout: string
28 stderr: string
29 isStdoutTruncated?: boolean
30 isStderrTruncated?: boolean
31}
32
33/**
34 * Runs one command by argument vector, no shell. In production a closure over `$.process.run`.
35 * Rejects when the command cannot start (not on PATH) or runs past its timeout.
36 * A non-zero exit resolves.
37 */
38export type Runner = (argv: readonly string[], init?: RunInit) => Promise<RunResult>
39
40/** Epoch milliseconds. Production: `Date.now`. Tests: a fake. */
41export type Now = () => number
42
43// ---------------------------------------------------------------- references found in text
44
45export type RepoRef = { host: string; owner: string; repo: string }
46
47export type GitHubRef = {
48 category: 'github'
49 host: string
50 /** Absent for a bare `#N` with no known repo. */
51 owner?: string
52 repo?: string
53 number: number
54 type: 'issue' | 'pr' | 'unknown'
55 /** The URL as written, when the reference was a URL. */
56 url?: string
57 /** True when a ref came from short `#N` text matching (not from a full GitHub URL). */
58 short?: true
59}
60
61export type JiraRef = {
62 category: 'jira'
63 site: string
64 issueKey: string
65 url: string
66 trackOnly: boolean
67}
68
69/**
70 * A Claude artifact. `uuid` is the key: both URL forms of one artifact share it. `titled`: the label is the
71 * page title from an Artifact publish, so it replaces the label of a known item. Else it is a Markdown label,
72 * a file name or the id, and a known item keeps a better one.
73 */
74export type ArtifactRef = { category: 'artifact'; uuid: string; url: string; label: string; titled?: true }
75
76export type WebRef = { category: 'web'; url: string; label: string }
77
78export type Ref = GitHubRef | JiraRef | ArtifactRef | WebRef
79
80/** `full`: every pattern (prompt, command, response rows). `urls-only`: explicit GitHub and Jira URLs (tool-result rows). */
81export type ScanMode = 'full' | 'urls-only'
82
83export type ExtractOptions = {
84 mode: ScanMode
85 /** Repo for bare `#N`. null or absent: bare `#N` stays unresolved. */
86 defaultRepo?: RepoRef | null
87 /** Hosts besides github.com whose issue and PR URLs count as GitHub (GHE hosts gh is logged in to). */
88 githubHosts?: readonly string[]
89 /** In conversation text, track short GitHub refs only when a keyword prefixes them and they target the current repo. */
90 strictShortRefs?: boolean
91}
92
93// ---------------------------------------------------------------- what tools mutate
94
95/** A GitHub item as GhTool sees it. GitHubItem implements it. */
96export interface GitHubTarget extends GitHubFields {
97 readonly key: string
98 refreshedAt?: number
99 error?: ItemError
100}
101
102/** A Jira item as AcliTool sees it. JiraItem implements it. */
103export interface JiraTarget extends JiraFields {
104 readonly key: string
105 refreshedAt?: number
106 error?: ItemError
107}
108
109export interface RefreshTool<T> {
110 readonly name: ToolName
111 /** True once the `--version` probe succeeded. False before the probe settles. */
112 readonly isAvailable: boolean
113 /** Available, not known to be logged out, and the breaker lets a call through. */
114 canCall(now: number): boolean
115 /** Refreshes the given items in place: status fields, `refreshedAt`, `error`. Never rejects. */
116 refreshItems(items: T[]): Promise<void>
117 /** Manual refresh: forget `loggedIn: false`, move an open breaker to half-open. */
118 reset(): void
119 status(): ToolStatus
120}
121
122export interface GitHubToolApi extends RefreshTool<GitHubTarget> {
123 /** Cached answer of `gh auth status --hostname <host>`. undefined: not asked yet. */
124 isHostAuthed(host: string): boolean | undefined
125 /** Asks gh once per host and caches the answer. */
126 checkHost(host: string): Promise<boolean>
127 /** `gh repo view --json nameWithOwner` in the session cwd, at most once per tool instance. */
128 defaultRepo(): Promise<RepoRef | null>
129}
130
131export interface JiraToolApi extends RefreshTool<JiraTarget> {
132 /** Host of acli's active site. undefined: not loaded yet. null: none (not logged in). */
133 readonly activeSite: string | null | undefined
134 /** Runs `acli jira auth status` and caches the site. */
135 loadActiveSite(): Promise<string | null>
136}
137
138export type Tools = { gh: GitHubToolApi; acli: JiraToolApi }
139
140// ---------------------------------------------------------------- hydrated items
141
142/** What the coordinator needs from a TrackedItem. */
143export interface Refreshable {
144 readonly key: string
145 readonly category: Category
146 canRefresh(now: number): boolean
147 isDue(now: number): boolean
148 refresh(): Promise<void>
149 toRecord(): ItemRecord
150}
151
152export type HydratedItem =
153 | (Refreshable & GitHubTarget & { readonly category: 'github' })
154 | (Refreshable & JiraTarget & { readonly category: 'jira' })
155 | (Refreshable & { readonly category: 'artifact' })
156 | (Refreshable & { readonly category: 'web' })
157 | (Refreshable & { readonly category: 'file' })
158
159export type Sighting = { source: Source; at: number }
160
161// ---------------------------------------------------------------- limits (spec sections 6.2, 7, 8, 10)
162
163export const LIMITS = {
164 maxItems: 500,
165 keepSessions: 20,
166 staleMs: 5 * 60_000,
167 pendingStaleMs: 60_000,
168 tickMs: 30_000,
169 captureDebounceMs: 1_000,
170 flushMs: 1_500,
171 breakerThreshold: 3,
172 breakerOpenMs: 5 * 60_000,
173 toolTimeoutMs: 30_000,
174 acliConcurrency: 3,
175 epicChildLimit: 50,
176 autoDebounceMs: 750,
177 gitTimeoutMs: 5_000,
178 discoveryTimeoutMs: 10_000,
179 workflowIdleMs: 60 * 60_000,
180 maxBranches: 12,
181 headPrsPerBranch: 5,
182 basePrsPerBranch: 10,
183 closingRefsPerPr: 10,
184 maxBodyRefs: 50,
185 maxAutoItems: 100,
186 maxProofs: 100,
187 maxAgents: 200,
188 maxDismissed: 500,
189 /** `.md` paths read from one prompt or reply row (issue #28). Each one costs a stat. */
190 maxMentionsPerRow: 20,
191 maxRepos: 50,
192 maxRepoAgents: 8,
193 maxRepoQueue: 32,
194} as const
195
196export const JIRA_KEY = /^[A-Z][A-Z0-9_]+-\d+$/
197
198// ---------------------------------------------------------------- auto tracking (spec 5.3, 6.3)
199
200/** Sources that discovery adds. The transcript backfill and the capture hooks never write them. */
201export const AUTO_SOURCES: ReadonlySet<Source> = new Set<Source>(['auto', 'pr-body'])
202
203// Row ids of the auto sections (spec 6.2). An item key never starts with `wt:` or `ag:`. Build and test ids here only.
204export const MASTER_ID = 'ag:master'
205export const worktreeId = (path: string): string => `wt:${path}`
206export const agentRowId = (agentId: string): string => `ag:${agentId}`
207/** The id of a repo row (`rp:` + the repo key). A repo key starts with `remote:` or `local:`, so it is no item key. */
208export const repoRowId = (key: string): string => `rp:${key}`
209/** True for the id of a worktree, agent or repo row, false for an item key. */
210export const isRowId = (id: string): boolean => id.startsWith('wt:') || id.startsWith('ag:') || id.startsWith('rp:')
211
212export type GitWorktree = {
213 path: string
214 head?: string
215 /** Without `refs/heads/`. Absent when detached or bare. */
216 branch?: string
217 detached: boolean
218 bare: boolean
219 /** Lock reason ('' when locked with none). */
220 locked?: string
221 prunable?: string
222}
223
224/** The part of AgentInfo the round reads. */
225export type LiveAgent = { id: string; status: string; teammateId?: string }
226
227export type DiscoveredPr = {
228 host: string
229 owner: string
230 repo: string
231 number: number
232 url: string
233 title: string
234 state: GitHubState
235 isDraft: boolean
236 reviewDecision?: string
237 checks: Checks
238 headRefName: string
239 baseRefName: string
240 body: string
241 closing: { host: string; owner: string; repo: string; number: number; url: string }[]
242 /** Which alias found it. A PR found by both keeps `head`. */
243 by: 'head' | 'base'
244}
245
246export type GraphqlReply = { data: Record<string, unknown>; errors: unknown[] }
247
248/** A21. `blocked`: breaker open; `no-gh`: the probe found no gh; `transport`: cannot start, timeout or non-JSON output. */
249export type GraphqlOutcome =
250 | { ok: true; reply: GraphqlReply }
251 | { ok: false; kind: 'blocked' | 'no-gh' | 'auth' | 'transport' | 'truncated' | 'query-error'; error?: string }
252
253/** One gh reply, classified once for `graphql` and `refreshChunk`. */
254export type ReplyClass =
255 | { kind: 'data'; data: Record<string, unknown>; errors: unknown[] }
256 | { kind: 'auth' | 'truncated' | 'query-error' | 'unparsable'; detail?: string }
257
258export type DiscoveryOutcome =
259 | { ok: true; prs: DiscoveredPr[]; complete: boolean; defaultBranch?: string }
260 | { ok: false; skipped?: PrSkip; error?: string }
261
262export type AutoReason = 'start' | 'tick' | 'worktree' | 'agent' | 'git' | 'pr-event' | 'manual'
263
264export type EnterFacts = { path: string; branch?: string; agentId?: string }
265export type AgentResultFacts = { agentId: string; worktreePath?: string; worktreeBranch?: string }
266export type BashFacts = { agentId?: string; branch?: string; kick?: 'pr-event' | 'git' }
267/** Copied from the `agent.spawn` input and its `next(e)` answer. */
268export type SpawnFacts = {
269 agentId: string
270 name?: string
271 description: string
272 subagentType: string
273 parentAgentId?: string
274 workflow?: { runId: string; agentIndex: number }
275 /** `agent.spawn` cwd. Absent: the parent's at spawn time (A6). */
276 cwd?: string
277}
278/** What `applyDiscovery` returns. `list` is the input list itself when nothing changed (A22). */
279export type DiscoveryApplied = { list: ItemRecord[]; added: string[]; untracked: number }
280
281/** A new interface, so the existing GitHubToolApi fakes in tests still compile. */
282export interface GitHubDiscoveryApi extends GitHubToolApi {
283 /** One `gh api graphql --hostname <host> -f query=<q>` through the breaker, with `LIMITS.discoveryTimeoutMs` (A21). */
284 graphql(host: string, query: string): Promise<GraphqlOutcome>
285}
286
287export type { TurnCompleteReason }
288hooks/json.ts 18 lines1// Helpers for untrusted JSON: engine results and `gh` replies. Pure. One copy for every reader.
2
3export type Json = Record<string, unknown>
4
5export function isRecord(v: unknown): v is Json {
6 return typeof v === 'object' && v !== null && !Array.isArray(v)
7}
8
9/** Reads `obj.a.b.c`, with array indexes as numbers. undefined when any step is missing. */
10export function dig(obj: unknown, ...path: (string | number)[]): unknown {
11 let cur = obj
12 for (const step of path) {
13 if (typeof step === 'number') cur = Array.isArray(cur) ? cur[step] : undefined
14 else cur = isRecord(cur) ? cur[step] : undefined
15 }
16 return cur
17}
18hooks/model/discovered.ts 193 lines1// Discovered items (spec A9 to A12, 8.4): PRs on tracked branches and the refs in their bodies.
2// Pure: no `$`. A call that changes nothing returns its input list (A22).
3
4import type { DiscoveredPr, DiscoveryApplied, GitHubRecord, ItemRecord, Ref, Source } from '../contracts'
5import { AUTO_SOURCES, LIMITS } from '../contracts'
6import { isAutoOnly, keyOf, prKey } from './keys'
7import { enforceCap, recordFromRef } from './registry'
8
9const isAuto = (s: Source): boolean => AUTO_SOURCES.has(s)
10
11/** The fields discovery writes on a PR record (spec 8.4 step 1). */
12function discoveryFields(pr: DiscoveredPr) {
13 return {
14 type: 'pr' as const,
15 url: pr.url,
16 title: pr.title,
17 state: pr.state,
18 isDraft: pr.isDraft,
19 checks: pr.checks,
20 headRefName: pr.headRefName,
21 baseRefName: pr.baseRefName,
22 ...(pr.reviewDecision !== undefined ? { reviewDecision: pr.reviewDecision } : {}),
23 }
24}
25
26function isCurrent(rec: GitHubRecord, pr: DiscoveredPr, at: number): boolean {
27 return (
28 rec.sources.includes('auto') &&
29 rec.error === undefined &&
30 rec.type === 'pr' &&
31 rec.url === pr.url &&
32 rec.title === pr.title &&
33 rec.state === pr.state &&
34 rec.isDraft === pr.isDraft &&
35 rec.checks === pr.checks &&
36 rec.headRefName === pr.headRefName &&
37 rec.baseRefName === pr.baseRefName &&
38 rec.reviewDecision === pr.reviewDecision &&
39 at - (rec.refreshedAt ?? Number.NEGATIVE_INFINITY) < LIMITS.staleMs / 2
40 )
41}
42
43function withDiscovery(rec: GitHubRecord, pr: DiscoveredPr, at: number): GitHubRecord {
44 const next: GitHubRecord = {
45 ...rec,
46 sources: rec.sources.includes('auto') ? rec.sources : [...rec.sources, 'auto'],
47 ...discoveryFields(pr),
48 refreshedAt: at,
49 }
50 delete next.error
51 if (pr.reviewDecision === undefined) delete next.reviewDecision
52 return next
53}
54
55function newPrRecord(pr: DiscoveredPr, at: number): GitHubRecord {
56 const base = recordFromRef(
57 { category: 'github', host: pr.host, owner: pr.owner, repo: pr.repo, number: pr.number, type: 'pr', url: pr.url },
58 { source: 'auto', at },
59 ) as GitHubRecord
60 return { ...base, seenCount: 0, ...discoveryFields(pr), refreshedAt: at }
61}
62
63export function applyDiscovery(
64 list: readonly ItemRecord[],
65 input: {
66 prs: readonly DiscoveredPr[]
67 refsByPr: ReadonlyMap<string, readonly Ref[]>
68 complete: boolean
69 dismissed: ReadonlySet<string>
70 at: number
71 },
72): DiscoveryApplied {
73 const { refsByPr, complete, dismissed, at } = input
74 const out: ItemRecord[] = list.slice()
75 const index = new Map<string, number>(out.map((r, i) => [r.key, i]))
76 const added: string[] = []
77 const notAdded = new Set<string>()
78 let changed = false
79 // 0. The budget for new auto-only items. Existing records always update.
80 let budget = LIMITS.maxAutoItems - list.filter(isAutoOnly).length
81
82 const livePrs = input.prs.filter(pr => !dismissed.has(prKey(pr)))
83 const live = new Set(livePrs.map(prKey))
84
85 // 1. PRs, in reply order (head aliases first).
86 for (const pr of livePrs) {
87 const key = prKey(pr)
88 const i = index.get(key)
89 if (i !== undefined) {
90 const rec = out[i]!
91 if (rec.category !== 'github' || isCurrent(rec, pr, at)) continue
92 out[i] = withDiscovery(rec, pr, at)
93 changed = true
94 } else if (budget > 0) {
95 budget--
96 index.set(key, out.length)
97 out.push(newPrRecord(pr, at))
98 changed = true
99 } else {
100 notAdded.add(key)
101 }
102 }
103
104 // 2. Body refs.
105 for (const pr of livePrs) {
106 const pk = prKey(pr)
107 for (const ref of refsByPr.get(pk) ?? []) {
108 const rk = keyOf(ref)
109 if (dismissed.has(rk)) continue
110 const i = index.get(rk)
111 if (i !== undefined) {
112 const rec = out[i]!
113 const hasSource = rec.sources.includes('pr-body')
114 const hasVia = rec.via?.includes(pk) === true
115 if (hasSource && hasVia) continue
116 out[i] = {
117 ...rec,
118 sources: hasSource ? rec.sources : [...rec.sources, 'pr-body'],
119 via: hasVia ? (rec.via as string[]) : [...(rec.via ?? []), pk],
120 }
121 changed = true
122 } else if (budget > 0) {
123 budget--
124 const rec = recordFromRef(ref, { source: 'pr-body', at })
125 rec.seenCount = 0
126 rec.via = [pk]
127 index.set(rk, out.length)
128 out.push(rec)
129 added.push(rk)
130 changed = true
131 } else {
132 notAdded.add(rk)
133 }
134 }
135 }
136
137 // 3. Prune (A12), only after a complete reply.
138 const drop = new Set<number>()
139 if (complete) {
140 // A `via` entry stays only while its PR is live and the body of that PR still lists the ref.
141 const listed = new Map(livePrs.map(pr => [prKey(pr), new Set((refsByPr.get(prKey(pr)) ?? []).map(keyOf))]))
142 for (let i = 0; i < out.length; i++) {
143 const rec = out[i]!
144 let sources = rec.sources
145 let via = rec.via
146 if (sources.includes('auto') && !live.has(rec.key)) sources = sources.filter(s => s !== 'auto')
147 if (via) {
148 const kept = via.filter(k => listed.get(k)?.has(rec.key) === true)
149 if (kept.length !== via.length) via = kept.length > 0 ? kept : undefined
150 }
151 if (sources.includes('pr-body') && (via === undefined || via.length === 0))
152 sources = sources.filter(s => s !== 'pr-body')
153 if (sources === rec.sources && via === rec.via) continue
154 changed = true
155 if (sources.length === 0) {
156 drop.add(i)
157 continue
158 }
159 const next: ItemRecord = { ...rec, sources }
160 if (via === undefined) delete next.via
161 else next.via = via
162 out[i] = next
163 }
164 }
165
166 // 4. Cap on the non-auto items only.
167 const kept = drop.size > 0 ? out.filter((_, i) => !drop.has(i)) : out
168 const capped = enforceCap(kept, LIMITS.maxItems)
169 if (capped !== kept) changed = true
170
171 // 5. Quiet step: the input list itself.
172 return { list: changed ? capped : (list as ItemRecord[]), added, untracked: notAdded.size }
173}
174
175/** Removes `auto`, `pr-body` and `via` from the given keys. A record left with no source goes. */
176export function dropAuto(list: readonly ItemRecord[], keys: ReadonlySet<string>): ItemRecord[] {
177 let changed = false
178 const out: ItemRecord[] = []
179 for (const rec of list) {
180 if (!keys.has(rec.key) || (!rec.sources.some(isAuto) && rec.via === undefined)) {
181 out.push(rec)
182 continue
183 }
184 changed = true
185 const sources = rec.sources.filter(s => !isAuto(s))
186 if (sources.length === 0) continue
187 const next: ItemRecord = { ...rec, sources }
188 delete next.via
189 out.push(next)
190 }
191 return changed ? out : (list as ItemRecord[])
192}
193hooks/model/keys.ts 45 lines1// Item keys and the auto-only test (spec 6.2, A9). No state, no I/O. Scan, model and ui import these from here,
2// so none of them has to pull in `registry.ts` or `discovered.ts` for a key.
3
4import type { ArtifactOrigin, DiscoveredPr, ItemRecord, Ref, Source } from '../contracts'
5import { AUTO_SOURCES } from '../contracts'
6import { normalizeUrl } from '../scan/links'
7
8const withoutFragment = (url: string): string => {
9 const i = url.indexOf('#')
10 return i < 0 ? url : url.slice(0, i)
11}
12
13export function keyOf(ref: Ref): string {
14 switch (ref.category) {
15 case 'github':
16 return ref.owner && ref.repo
17 ? `gh:${ref.host.toLowerCase()}/${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`
18 : `gh:?#${ref.number}`
19 case 'jira':
20 return `jira:${ref.site.toLowerCase()}/${ref.issueKey}`
21 case 'artifact':
22 return `art:${ref.uuid}`
23 case 'web':
24 return `web:${withoutFragment(normalizeUrl(ref.url))}`
25 }
26}
27
28/** The item key of a Markdown file, `file:<path>`, where `path` is where the file landed (issue #28). */
29export const fileKey = (path: string): string => `file:${path}`
30
31/** The item key of the PR, `gh:<host>/<owner>/<repo>#<n>`. */
32export function prKey(pr: DiscoveredPr): string {
33 return keyOf({ category: 'github', host: pr.host, owner: pr.owner, repo: pr.repo, number: pr.number, type: 'pr' })
34}
35
36/** Every source is an auto source: no conversation mention backs the item. */
37export function isAutoOnly(rec: ItemRecord): boolean {
38 return rec.sources.length > 0 && rec.sources.every(s => AUTO_SOURCES.has(s))
39}
40
41/** An artifact is `published` once the session's Artifact tool wrote it, else `mentioned`. */
42export function artifactOrigin(rec: { sources: readonly Source[] }): ArtifactOrigin {
43 return rec.sources.includes('publish') ? 'published' : 'mentioned'
44}
45hooks/model/registry.ts 441 lines1// Pure functions over lists of ItemRecord: keys, conversion, merge, dedupe, caps, re-key (spec 6.2, 10).
2// No input list or record is mutated.
3
4import type {
5 ArtifactRecord,
6 ArtifactRef,
7 FileActivity,
8 FileRecord,
9 GitHubRecord,
10 GitHubRef,
11 HydratedItem,
12 ItemRecord,
13 JiraRecord,
14 JiraRef,
15 Ref,
16 RepoRef,
17 Sighting,
18 Source,
19 Tools,
20 WebRecord,
21 WebRef,
22} from '../contracts'
23import { AUTO_SOURCES, LIMITS } from '../contracts'
24import { artifactIdOf, parseArtifactUrl } from '../scan/artifact-url'
25import { hostPath } from '../scan/refs'
26import { ArtifactItem } from './artifact-item'
27import { FileItem } from './file-item'
28import { GitHubItem } from './github-item'
29import { cloneNodes, JiraItem } from './jira-item'
30import { fileKey, keyOf } from './keys'
31import { WebLink } from './web-link'
32
33// ---------------------------------------------------------------- keys
34
35// ---------------------------------------------------------------- record from ref
36
37const githubUrl = (host: string, owner: string, repo: string, type: string, n: number): string =>
38 `https://${host}/${owner}/${repo}/${type === 'pr' ? 'pull' : 'issues'}/${n}`
39
40function githubRecord(ref: GitHubRef, s: Sighting): GitHubRecord {
41 const base = { sources: [s.source], firstSeen: s.at, lastSeen: s.at, seenCount: 1 }
42 const host = ref.host.toLowerCase()
43 if (!ref.owner || !ref.repo) {
44 return {
45 key: keyOf(ref),
46 category: 'github',
47 url: ref.url ?? '',
48 label: `#${ref.number}`,
49 ...base,
50 host,
51 number: ref.number,
52 type: ref.type,
53 }
54 }
55 const owner = ref.owner.toLowerCase()
56 const repo = ref.repo.toLowerCase()
57 return {
58 key: keyOf(ref),
59 category: 'github',
60 url: ref.url ?? githubUrl(host, owner, repo, ref.type, ref.number),
61 label: `${owner}/${repo}#${ref.number}`,
62 ...base,
63 host,
64 owner,
65 repo,
66 number: ref.number,
67 type: ref.type,
68 }
69}
70
71function jiraRecord(ref: JiraRef, s: Sighting): JiraRecord {
72 return {
73 key: keyOf(ref),
74 category: 'jira',
75 url: ref.url,
76 label: ref.issueKey,
77 sources: [s.source],
78 firstSeen: s.at,
79 lastSeen: s.at,
80 seenCount: 1,
81 site: ref.site,
82 issueKey: ref.issueKey,
83 trackOnly: ref.trackOnly,
84 children: [],
85 }
86}
87
88function artifactRecord(ref: ArtifactRef, s: Sighting): ArtifactRecord {
89 return {
90 key: keyOf(ref),
91 category: 'artifact',
92 url: ref.url,
93 label: ref.label,
94 sources: [s.source],
95 firstSeen: s.at,
96 lastSeen: s.at,
97 seenCount: 1,
98 }
99}
100
101function webRecord(ref: WebRef, s: Sighting): WebRecord {
102 return {
103 key: keyOf(ref),
104 category: 'web',
105 url: ref.url,
106 label: ref.label,
107 sources: [s.source],
108 firstSeen: s.at,
109 lastSeen: s.at,
110 seenCount: 1,
111 }
112}
113
114export function recordFromRef(ref: Ref, s: Sighting): ItemRecord {
115 switch (ref.category) {
116 case 'github':
117 return githubRecord(ref, s)
118 case 'jira':
119 return jiraRecord(ref, s)
120 case 'artifact':
121 return artifactRecord(ref, s)
122 case 'web':
123 return webRecord(ref, s)
124 }
125}
126
127// ---------------------------------------------------------------- helpers
128
129function cloneRecord<R extends ItemRecord>(rec: R): R {
130 const out = { ...rec, sources: [...rec.sources] } as R
131 if (rec.error) out.error = { ...rec.error }
132 if (rec.via) out.via = [...rec.via]
133 if (out.category === 'jira') out.children = cloneNodes((rec as JiraRecord).children)
134 return out
135}
136
137const unionSources = (a: readonly Source[], b: readonly Source[]): Source[] => [...new Set([...a, ...b])]
138
139const hasAutoSource = (rec: ItemRecord): boolean => rec.sources.some(s => AUTO_SOURCES.has(s))
140
141// ---------------------------------------------------------------- artifact names (design study on #29, G6)
142
143/** `rank`: 3 a publish title on a sighting, 2 a stored name of a published item, 1 any other name, 0 the id. */
144type Named = { url: string; label: string; published: boolean; rank: number }
145
146function named(rec: ArtifactRecord): Named {
147 const published = rec.sources.includes('publish')
148 const rank = rec.label === artifactIdOf(rec.url) ? 0 : published ? 2 : 1
149 return { url: rec.url, label: rec.label, published, rank }
150}
151
152/**
153 * Name and URL of one artifact seen twice. A published side gives the URL. The better name wins and a tie keeps
154 * `a`, so a mention never renames a published item, while the title of a new publish does (a rename).
155 * An id label follows the URL it came from.
156 */
157function joinNames(out: ArtifactRecord, a: Named, b: Named): void {
158 out.url = b.published && !a.published ? b.url : a.url
159 const best = b.rank > a.rank ? b : a
160 out.label = best.rank === 0 ? artifactIdOf(out.url) : best.label
161}
162
163/**
164 * Drops the records with the smallest lastSeen until `cap` is met. Keys in `keep` never go.
165 * Only records without an auto source count against `cap`, and only they are evicted (spec A11):
166 * auto-only items have their own cap, `maxAutoItems`, enforced at the source.
167 */
168export function enforceCap(list: ItemRecord[], cap: number, keep: ReadonlySet<string> = new Set()): ItemRecord[] {
169 const excess = list.filter(rec => !hasAutoSource(rec)).length - cap
170 if (excess <= 0) return list
171 const victims = list
172 .map((rec, i) => ({ rec, i }))
173 .filter(({ rec }) => !hasAutoSource(rec) && !keep.has(rec.key))
174 .sort((x, y) => x.rec.lastSeen - y.rec.lastSeen || x.i - y.i)
175 .slice(0, excess)
176 .map(({ i }) => i)
177 const drop = new Set(victims)
178 return list.filter((_, i) => !drop.has(i))
179}
180
181/**
182 * Folds sightings of `b` into `a`: union of sources (a first), sum of seenCount, min firstSeen, max lastSeen.
183 * Status fields come from the record with the newer `refreshedAt`. A tie keeps `a`.
184 */
185function fold(a: ItemRecord, b: ItemRecord): ItemRecord {
186 const winner = (b.refreshedAt ?? -Infinity) > (a.refreshedAt ?? -Infinity) ? b : a
187 const loser = winner === a ? b : a
188 const out = cloneRecord(winner)
189 if (out.category === 'artifact' && winner.category === 'artifact' && loser.category === 'artifact') {
190 joinNames(out, named(winner), named(loser))
191 if (loser.deleted) out.deleted = true
192 }
193 out.sources = unionSources(a.sources, b.sources)
194 const via = [...new Set([...(a.via ?? []), ...(b.via ?? [])])]
195 if (via.length > 0) out.via = via
196 else delete out.via
197 out.seenCount = a.seenCount + b.seenCount
198 out.firstSeen = Math.min(a.firstSeen, b.firstSeen)
199 out.lastSeen = Math.max(a.lastSeen, b.lastSeen)
200 if (out.category === 'github' && out.type === 'unknown' && loser.category === 'github') out.type = loser.type
201 return out
202}
203
204// ---------------------------------------------------------------- merge
205
206export function mergeRefs(
207 list: readonly ItemRecord[],
208 refs: readonly Ref[],
209 s: Sighting,
210 cap: number = LIMITS.maxItems,
211): { list: ItemRecord[]; added: string[] } {
212 const out: ItemRecord[] = list.map(cloneRecord)
213 const index = new Map<string, number>(out.map((r, i) => [r.key, i]))
214 const added: string[] = []
215 for (const ref of refs) {
216 const key = keyOf(ref)
217 const at = index.get(key)
218 if (at === undefined) {
219 index.set(key, out.length)
220 out.push(recordFromRef(ref, s))
221 added.push(key)
222 continue
223 }
224 const rec = out[at]!
225 if (rec.category === 'artifact' && ref.category === 'artifact') {
226 const rank = ref.titled ? 3 : ref.label === artifactIdOf(ref.url) ? 0 : 1
227 joinNames(rec, named(rec), { url: ref.url, label: ref.label, published: s.source === 'publish', rank })
228 }
229 if (!rec.sources.includes(s.source)) rec.sources.push(s.source)
230 rec.seenCount += 1
231 rec.lastSeen = s.at
232 if (rec.category === 'github' && ref.category === 'github' && rec.type === 'unknown') rec.type = ref.type
233 }
234 return { list: enforceCap(out, cap, new Set(added)), added }
235}
236
237/** One sighting of a Markdown file: `path` is where it landed, `label` the absolute path as spelled (issue #28). */
238export function recordFromFile(
239 hit: { path: string; label: string; kind: FileActivity; bySubagent: boolean },
240 s: Sighting,
241): FileRecord {
242 return {
243 key: fileKey(hit.path),
244 category: 'file',
245 url: hit.path,
246 label: hit.label,
247 created: hit.kind === 'created',
248 edits: hit.kind === 'edited' ? 1 : 0,
249 views: hit.kind === 'viewed' ? 1 : 0,
250 mentions: hit.kind === 'mentioned' ? 1 : 0,
251 ...(hit.bySubagent ? { bySubagent: true as const } : {}),
252 sources: [s.source],
253 firstSeen: s.at,
254 lastSeen: s.at,
255 seenCount: 1,
256 }
257}
258
259/** `fold` for two sightings of one file: the counters add up, and `created` and `bySubagent` never go back. */
260function foldFile(a: FileRecord, b: FileRecord): FileRecord {
261 const out = fold(a, b) as FileRecord // no file refreshes, so `a` wins the tie: its label and url stay
262 out.created = a.created || b.created
263 out.edits = a.edits + b.edits
264 out.views = a.views + b.views
265 out.mentions = a.mentions + b.mentions
266 if (a.bySubagent || b.bySubagent) out.bySubagent = true
267 return out
268}
269
270/**
271 * Adds file rows, or folds each into the row with the same key. The cap drops the smallest lastSeen, never a row
272 * this call added. No rows: the list itself comes back (A22).
273 */
274export function mergeFiles(
275 list: readonly ItemRecord[],
276 recs: readonly FileRecord[],
277 cap: number = LIMITS.maxItems,
278): ItemRecord[] {
279 if (recs.length === 0) return list as ItemRecord[]
280 const out: ItemRecord[] = list.map(cloneRecord)
281 const index = new Map<string, number>(out.map((r, i) => [r.key, i]))
282 const added = new Set<string>()
283 for (const rec of recs) {
284 const at = index.get(rec.key)
285 if (at === undefined) {
286 index.set(rec.key, out.length)
287 out.push(cloneRecord(rec))
288 added.add(rec.key)
289 continue
290 }
291 const old = out[at]!
292 if (old.category === 'file') out[at] = foldFile(old, rec)
293 }
294 return enforceCap(out, cap, added)
295}
296
297/** Status fields a refresh writes, per category. */
298const STATUS_FIELDS = {
299 github: ['title', 'state', 'stateReason', 'isDraft', 'reviewDecision', 'checks'],
300 jira: ['summary', 'issueType', 'status', 'statusCategory'],
301 artifact: [],
302 web: [],
303 file: [],
304} as const
305
306export function mergeResults(list: readonly ItemRecord[], refreshed: readonly ItemRecord[]): ItemRecord[] {
307 const byKey = new Map(refreshed.map(r => [r.key, r]))
308 const removed = new Set(refreshed.filter(r => r.error?.kind === 'not-found').map(r => r.key))
309 return list.flatMap(rec => {
310 if (removed.has(rec.key)) return []
311 const fresh = byKey.get(rec.key)
312 const out = cloneRecord(rec)
313 if (!fresh) return out
314 const dst = out as unknown as Record<string, unknown>
315 const src = fresh as unknown as Record<string, unknown>
316 for (const f of STATUS_FIELDS[out.category]) if (src[f] !== undefined) dst[f] = src[f]
317 if (
318 out.category === 'github' &&
319 fresh.category === 'github' &&
320 (out.type === 'unknown' || fresh.type !== 'unknown')
321 )
322 out.type = fresh.type
323 if (out.category === 'jira' && fresh.category === 'jira') out.children = cloneNodes(fresh.children)
324 if (fresh.refreshedAt !== undefined) out.refreshedAt = fresh.refreshedAt
325 if (fresh.error) out.error = { ...fresh.error }
326 else delete out.error
327 return [out]
328 })
329}
330
331// ---------------------------------------------------------------- bare #N
332
333export function resolveBareRefs(list: readonly ItemRecord[], repo: RepoRef): ItemRecord[] {
334 const host = repo.host.toLowerCase()
335 const owner = repo.owner.toLowerCase()
336 const name = repo.repo.toLowerCase()
337 const out: (ItemRecord | null)[] = list.map(cloneRecord)
338 const index = new Map<string, number>()
339 out.forEach((r, i) => {
340 if (r && !r.key.startsWith('gh:?#')) index.set(r.key, i)
341 })
342 out.forEach((rec, i) => {
343 if (rec?.category !== 'github' || !rec.key.startsWith('gh:?#')) return
344 const key = `gh:${host}/${owner}/${name}#${rec.number}`
345 const moved: GitHubRecord = {
346 ...rec,
347 key,
348 host,
349 owner,
350 repo: name,
351 label: `${owner}/${name}#${rec.number}`,
352 url: githubUrl(host, owner, name, rec.type, rec.number),
353 }
354 const at = index.get(key)
355 if (at === undefined) {
356 out[i] = moved
357 index.set(key, i)
358 } else {
359 out[at] = fold(out[at]!, moved)
360 out[i] = null
361 }
362 })
363 return out.filter((r): r is ItemRecord => r !== null)
364}
365
366// ---------------------------------------------------------------- artifacts
367
368/** Marks the artifact row `key` deleted (R12). The input list comes back when no row has the key or it is marked. */
369export function markArtifactDeleted(list: ItemRecord[], key: string): ItemRecord[] {
370 const at = list.findIndex(r => r.key === key)
371 const rec = list[at]
372 if (rec?.category !== 'artifact' || rec.deleted) return list
373 const out = [...list]
374 out[at] = { ...cloneRecord(rec), deleted: true }
375 return out
376}
377
378function artifactFromWeb(rec: WebRecord): ArtifactRecord | null {
379 // A web label without a Markdown label is host and path. The artifact row shows its id instead.
380 const ref = parseArtifactUrl(rec.url, rec.label === hostPath(rec.url) ? undefined : rec.label)
381 if (!ref) return null
382 return { ...cloneRecord(rec), key: keyOf(ref), category: 'artifact', url: ref.url, label: ref.label }
383}
384
385/**
386 * Rows that an older version stored as web links move to Artifacts (R13), folded into a twin with the same key.
387 * The input list comes back when no row moves.
388 */
389export function rekeyArtifacts(list: ItemRecord[]): ItemRecord[] {
390 const moved = list.map(rec => (rec.category === 'web' ? artifactFromWeb(rec) : null))
391 if (moved.every(m => m === null)) return list
392 const out: ItemRecord[] = []
393 const index = new Map<string, number>()
394 list.forEach((rec, i) => {
395 const next = moved[i] ?? cloneRecord(rec)
396 const at = index.get(next.key)
397 if (at === undefined) {
398 index.set(next.key, out.length)
399 out.push(next)
400 } else out[at] = fold(out[at]!, next)
401 })
402 return out
403}
404
405/** Merge for `/clear` carry-over: fold records with the same key, then cap. */
406export function unionRecords(
407 a: readonly ItemRecord[],
408 b: readonly ItemRecord[],
409 cap: number = LIMITS.maxItems,
410): ItemRecord[] {
411 const out: ItemRecord[] = []
412 const index = new Map<string, number>()
413 for (const rec of [...a, ...b]) {
414 const at = index.get(rec.key)
415 if (at === undefined) {
416 index.set(rec.key, out.length)
417 out.push(cloneRecord(rec))
418 } else {
419 out[at] = fold(out[at]!, rec)
420 }
421 }
422 return enforceCap(out, cap)
423}
424
425// ---------------------------------------------------------------- classes
426
427export function hydrate(rec: ItemRecord, tools: Tools): HydratedItem {
428 switch (rec.category) {
429 case 'github':
430 return new GitHubItem(rec, tools.gh)
431 case 'jira':
432 return new JiraItem(rec, tools.acli)
433 case 'artifact':
434 return new ArtifactItem(rec)
435 case 'web':
436 return new WebLink(rec)
437 case 'file':
438 return new FileItem(rec)
439 }
440}
441