SLOPSHOPPER

artifact-tracking

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…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · artifact-tracking
│ ┃ Tracking ✕ › fix the failing auth test and add an audit log call │ ┃ No tracked items yet. │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Refresh ] Esc close ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /track │ ⎿ artifact-tracking: No tracked items yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tracking
No tracked items yet. [ Refresh ] Esc close
README

artifact-tracking

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/.

Load it

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

What it captures

RowTaken
Your prompt (typed, bridge, -p)Every pattern below.
Your slash commandThe text inside <command-args>.
Claude's reply, also from subagentsEvery pattern below.
Tool outputExplicit GitHub issue and PR URLs and Jira URLs only. No bare #N, no web links.
Artifact tool call, also from subagentsA 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 callsA .md path: see Markdown files.
File tools, Bash and agent.spawnOther 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:

  • Bash is read only for 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.
  • A relative path in a file-tool call of a subagent is ignored.

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.

PatternItem
https://github.com/o/r/issues/N, /pull/NGitHub issue or PR
o/r#NGitHub item, type found by gh
#N outside code and URLsGitHub item of the session repo
https://<site>.atlassian.net/browse/KEY-1, …?selectedIssue=KEY-1Jira work item
/browse/KEY-1 on another hostJira 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 URLWeb 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.

Commands

CommandAction
/trackOpens the pane and starts a refresh of the due items. Prints a short list.
/track refreshForgets "not logged in" and "not found", closes open circuit breakers, refreshes every item, waits, and prints the list. Use it in claude -p.
/track clearRemoves 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.

Markdown files

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
CellCounts
pathRelative to the session root when the file is below it, else absolute.
createdA successful Write of type create made the file. It stays set.
mentionedYour prompt, your command arguments or Claude's reply named the path, and the file exists. Shown when the file was not created.
edited NSuccessful Edit calls and Write calls of type update.
viewed NSuccessful Read calls. A Read alone never adds a row: it counts only on a row that a write, an edit or a mention made.
subagentA subagent wrote, edited or read the file.

An empty cell means that activity did not happen. The /track text leaves empty cells out.

Rules:

  • Only .md paths count, in any case. A failed, denied or staged call adds nothing.
  • A relative path joins the cwd: the spawn cwd of a subagent, else the session cwd. ~/ in a mention joins HOME.
  • A row is keyed by where the file lands (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.
  • Mentions: at most 20 paths per prompt or reply, each checked with one stat. A URL, a missing file and tool output are not mentions. A trailing line number (a.md:42) or anchor (a.md#L7) is cut.
  • Ignored paths, fixed: a .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.
  • File rows count against the 500-item session cap like other items. The oldest row goes first.
  • The 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.
  • A resume restores file rows from the stored copy, then from the 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.
  • Paths only. The mod never reads or stores file contents.

Auto tracking

The mod also tracks the work around the session, with no mention:

SectionContent
WorktreesThe 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.
AgentsThe main loop, its subagents and its workflow agents, each with branch and worktree. An ended agent stays, dimmed.
Pull requestsPRs 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.

Requirements

  • Claude Code with mods (2.1.295 or newer).
  • Optional: 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.
  • Optional: acli 1.3.19 or newer, logged in. It refreshes Jira items on its active site.
  • Without a tool, its section says 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.

Persistence

  • Items live in $.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.

Known limits

  • False positives. rank #1 counts as issue 1 of the session repo. The scanner cannot tell it from fixed in #1.
  • Jira sites. 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.
  • GitHub Enterprise. A GHE issue URL is a web link, unless the host is the host of the session repo and gh is logged in to it.
  • Other github.com URLs (repo pages, commits, files) are web links.
  • Bare #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.
  • Artifact tool. claude -p does not offer it, so nothing is published there. A mentioned artifact URL is still tracked.
  • Artifacts after --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.
  • Older versions stored artifact URLs as web links. A session moves them to Artifacts when it loads its list.
  • A narrow terminal. The pane may wait until you widen the terminal. The mod shows a toast.
  • Out of scope: page titles of web links, switching Jira sites, Jira levels above Epic, click capture, write actions on GitHub or Jira, and surfaces other than the terminal.

Develop

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 .
FolderContent
hooks/register.tsxHooks, 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).

CI

.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.

Release

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.

  1. In a PR, bump version in .claude-plugin/plugin.json. Use semver. Merge the PR.
  2. On main, in a clean and current checkout, run make release-dry. Check the tag name.
  3. Run make release. It runs make test, then claude plugin tag --push.

The tag push runs .github/workflows/release.yml. The workflow:

  • checks that the tag equals <name>--v<version> of plugin.json and that the tagged commit is on main,
  • runs claude plugin validate --strict on the plugin and the marketplace, then make test,
  • runs make dist: artifact-tracking-<version>.zip (the plugin folder at the tag) and SHA256SUMS,
  • signs a build provenance attestation for the zip,
  • creates the GitHub release with both files and notes generated from the merged PRs. A version with a - 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

Make targets

Run make from the repo root. make help lists the targets. Set PLUGIN=<dir> to use another plugin folder.

TargetAction
initChecks 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.
buildRuns 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.
typecheckRuns tsc only. The typecheck pre-commit hook calls it.
lintRuns every pre-commit hook on all files.
hooksInstalls the pre-commit, commit-msg and pre-push git hooks.
verifyThe gate before every push: lint with auto-fix, then test, e2e-unit and test-scripts. Fails on untracked files, because the hooks skip them.
testRuns build, then claude plugin test. check is an alias.
test-scriptsTests scripts/dev/install-local-watch.sh. Needs no claude.
distBuilds dist/<name>-<version>.zip from the committed plugin folder at HEAD, and dist/SHA256SUMS. Uncommitted edits are not in the zip.
releaseOn a clean main equal to origin/main: runs test, then claude plugin tag --push. See Release.
release-dryPrints the tag that release would create.
installLinks the mod into $CLAUDE_CONFIG_DIR/skills/. Without that variable, it uses ~/.claude/skills/.
uninstallRemoves that link. It removes only a link that points at this plugin.
install-localCopies 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-localRemoves that copy and its line in .git/info/exclude.
cleanDeletes .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:

  • It is a copy, not a link. A symlinked hooks folder fails with "Path escapes plugin directory". So the copy must be refreshed after each code change. The .envrc does that, see below. Run /reload-plugins to load the new copy.
  • It is named artifact-tracking-dev. A second plugin named artifact-tracking is refused while the released one is installed.
  • It owns its state as 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).
  • The watcher copies again when a file under plugins/artifact-tracking/ changes, also when claude edits it.
  • When no process of yours (shell, 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).

Source 48 files
hooks/register.tsx 1625 lines
1// 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 lines
1// 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}
51
hooks/auto/events.ts 95 lines
1// 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}
95
hooks/auto/ledger.ts 192 lines
1// 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}
192
hooks/auto/round.ts 289 lines
1// 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}
289
hooks/auto/scheduler.ts 99 lines
1// 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}
99
hooks/capture/artifact-tool.ts 57 lines
1// 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}
57
hooks/contracts.ts 288 lines
1// 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 }
288
hooks/json.ts 18 lines
1// 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}
18
hooks/model/discovered.ts 193 lines
1// 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}
193
hooks/model/keys.ts 45 lines
1// 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}
45
hooks/model/registry.ts 441 lines
1// 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