@webpieces/repo-workflow-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +31 -0
  2. package/src/agent-worktree-lock.d.ts +21 -0
  3. package/src/agent-worktree-lock.js +77 -0
  4. package/src/agent-worktree-lock.js.map +1 -0
  5. package/src/branch-archiver.d.ts +70 -0
  6. package/src/branch-archiver.js +157 -0
  7. package/src/branch-archiver.js.map +1 -0
  8. package/src/branch-identity.d.ts +6 -0
  9. package/src/branch-identity.js +59 -0
  10. package/src/branch-identity.js.map +1 -0
  11. package/src/branch-mutation-log.d.ts +77 -0
  12. package/src/branch-mutation-log.js +221 -0
  13. package/src/branch-mutation-log.js.map +1 -0
  14. package/src/branch-reaper.d.ts +122 -0
  15. package/src/branch-reaper.js +261 -0
  16. package/src/branch-reaper.js.map +1 -0
  17. package/src/harness-agent-activity.d.ts +154 -0
  18. package/src/harness-agent-activity.js +346 -0
  19. package/src/harness-agent-activity.js.map +1 -0
  20. package/src/index.d.ts +14 -0
  21. package/src/index.js +18 -0
  22. package/src/index.js.map +1 -0
  23. package/src/main-sync-file.d.ts +121 -0
  24. package/src/main-sync-file.js +281 -0
  25. package/src/main-sync-file.js.map +1 -0
  26. package/src/main-sync-status.d.ts +197 -0
  27. package/src/main-sync-status.js +555 -0
  28. package/src/main-sync-status.js.map +1 -0
  29. package/src/merged-branch-verdicts.d.ts +189 -0
  30. package/src/merged-branch-verdicts.js +251 -0
  31. package/src/merged-branch-verdicts.js.map +1 -0
  32. package/src/merged-branches.d.ts +140 -0
  33. package/src/merged-branches.js +521 -0
  34. package/src/merged-branches.js.map +1 -0
  35. package/src/review-identity-stamp.d.ts +82 -0
  36. package/src/review-identity-stamp.js +172 -0
  37. package/src/review-identity-stamp.js.map +1 -0
  38. package/src/worktree-lock-verdicts.d.ts +149 -0
  39. package/src/worktree-lock-verdicts.js +225 -0
  40. package/src/worktree-lock-verdicts.js.map +1 -0
  41. package/src/worktree-reaper.d.ts +116 -0
  42. package/src/worktree-reaper.js +318 -0
  43. package/src/worktree-reaper.js.map +1 -0
  44. package/src/worktrees.d.ts +113 -0
  45. package/src/worktrees.js +260 -0
  46. package/src/worktrees.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branch-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/branch-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { DeletableBranch, MergedBranchesCache } from './merged-branch-verdicts';\nimport { MergedBranchesService } from './merged-branches';\n\n/**\n * The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.\n *\n * WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only\n * ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which\n * reads a raw `-D` as destructive, asks \"may I clean this up?\", and stops. So the verdicts were\n * computed correctly on every hook call and then acted on by nobody, and local branches grew without\n * bound. The fix is not better wording; it is a thing that does the deleting.\n *\n * WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three\n * proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO\n * commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the\n * list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete\n * the branch someone is standing on. On top of that, every delete is logged with the branch's\n * pre-delete SHA and a literal recover command, so no reap is unrecoverable.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One branch and what happened to it.\nexport class ReapedBranch {\n branch: string;\n // The commit the branch pointed at BEFORE deletion — captured first, precisely so a delete is\n // always undoable via `git branch <branch> <sha>`. Empty only if rev-parse itself failed.\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.\n error: string;\n /**\n * The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention\n * policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a\n * sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and\n * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n archiveTag: string = '';\n /**\n * The branch was ALREADY GONE when we reached it — someone else reaped it first.\n *\n * Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's\n * `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the\n * same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:\n * auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them\n * 300ms later and reported \"⚠️ 2 branch(es) could not be deleted — could not archive it first:\n * unknown revision\", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,\n * the branch is gone, archived and logged by the winner.\n *\n * Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n alreadyGone: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says\n * nothing about the branches it did NOT touch reads as \"there was nothing else\", when in fact those\n * are exactly the branches only a human can rule on.\n */\nexport class ReapResult {\n reaped: ReapedBranch[];\n failed: ReapedBranch[];\n spared: DeletableBranch[];\n /**\n * Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket\n * because they belong in neither of the other two: counting them as `reaped` would credit this\n * run with a delete and an archive tag it did not write, and counting them as `failed` is the bug\n * this fixes — a red warning about branches that are, in fact, gone.\n *\n * Optional so every existing `new ReapResult(...)` call site still builds.\n */\n alreadyGone: ReapedBranch[];\n\n constructor(\n reaped: ReapedBranch[],\n failed: ReapedBranch[],\n spared: DeletableBranch[],\n alreadyGone: ReapedBranch[] = [],\n ) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n this.alreadyGone = alreadyGone;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a failed delete can be reported.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchReaper {\n // Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just\n // `new BranchReaper()`, while inversify still injects the singletons when resolved from a\n // container. Mirrors how MergedBranchesService defaults its WorktreeService.\n constructor(\n private readonly mergedBranches: MergedBranchesService = new MergedBranchesService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n ) {}\n\n /**\n * Delete every branch the verdicts call dead, one command at a time.\n *\n * `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running\n * the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute\n * from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed\n * to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a\n * branch may have gained commits since it was written. Deleting never reads the stale file.\n */\n reap(\n repoRoot: string,\n verb: MutationVerb,\n cache: MergedBranchesCache | null = null,\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n // `false`: this is the BRANCH reap, and worktree locks are none of its business. Ignoring one\n // would only widen the WORKTREE verdicts it never reads.\n const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot, false);\n // 'keep' means \"never delete anything\" — the reap becomes a pure report. Everything still lands\n // in `spared` so the human sees exactly what WOULD have been reaped under the other policies.\n if (retention === BRANCH_RETENTION_KEEP) {\n return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);\n }\n return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);\n }\n\n /**\n * Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's\n * classification prompt. Separate from `reap` because these are NOT provably dead: they earned their\n * deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.\n * Archiving still happens first, which is precisely what makes that yes low-stakes.\n */\n reapApproved(\n repoRoot: string,\n verb: MutationVerb,\n approved: DeletableBranch[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n if (retention === BRANCH_RETENTION_KEEP) return new ReapResult([], [], approved);\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of approved) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n return new ReapResult(reaped, failed, [], alreadyGone);\n }\n\n private reapBranches(\n repoRoot: string,\n verb: MutationVerb,\n targets: DeletableBranch[],\n retention: string,\n verdicts: MergedBranchesCache,\n ): ReapResult {\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of targets) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n\n // Only genuine failures stay in the cache's `deletable`. An already-gone branch is not there\n // to delete on the next pass, and leaving it in would make the cap keep counting a ghost.\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep, alreadyGone);\n }\n\n /**\n * One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old\n * fix hint used: git aborts the whole command on the first branch it refuses, which would strand\n * every branch after it in the list. One invocation each means one failure costs exactly one branch.\n *\n * The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not\n * archive is a branch whose only remaining copy is the reflog, and the entire point of this change is\n * to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a\n * branch that survives one more cleanup cycle.\n */\n private deleteOne(\n repoRoot: string,\n verb: MutationVerb,\n entry: DeletableBranch,\n retention: string,\n ): ReapedBranch {\n // SHA first — after the delete there is no branch left to resolve, and the whole point of the\n // audit line is that it records what was destroyed while it still exists.\n const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);\n const sha = resolved.ok ? resolved.out : '';\n\n // It does not resolve ⇒ it is already gone, reaped by whoever won the race (see\n // ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through\n // to the archive, which fails on the same missing ref and calls a successful cleanup a\n // failure. Nothing to archive and nothing to delete: the winner did both, and logged them.\n if (!resolved.ok) return this.alreadyGone(repoRoot, verb, entry);\n\n const archive = retention === BRANCH_RETENTION_ARCHIVE_TAG\n ? this.archiver.archive(repoRoot, entry.branch)\n : new ArchiveResult('', sha, true, '');\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, entry, sha, archive);\n\n const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);\n const result = new ReapedBranch(\n entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);\n result.archiveTag = archive.tag;\n\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.archiveTag = archive.tag;\n event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n return result;\n }\n\n /**\n * The branch vanished between the verdict and the reap — a concurrent reaper got it.\n *\n * Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but\n * as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can\n * say \"already gone\" instead of warning about a branch that is exactly as dead as intended.\n */\n private alreadyGone(repoRoot: string, verb: MutationVerb, entry: DeletableBranch): ReapedBranch {\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.outcome = 'ALREADY GONE (a concurrent reap deleted and archived it first)';\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n const result = new ReapedBranch(\n entry.branch, '', entry.reason, entry.pr, false,\n 'already gone — a concurrent reap deleted and archived it first');\n result.alreadyGone = true;\n return result;\n }\n\n // Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the\n // human sees a branch that survived and why, rather than a silent skip.\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, entry: DeletableBranch, sha: string, archive: ArchiveResult,\n ): ReapedBranch {\n const error = `not deleted — could not archive it first: ${archive.error}`;\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.outcome = `SKIPPED (${error})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);\n }\n\n /**\n * Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap\n * sees the post-reap truth on its very next call instead of continuing to block against branches\n * that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,\n * and still dead.\n */\n private rewriteCache(repoRoot: string, verdicts: MergedBranchesCache, failed: ReapedBranch[]): void {\n const stillDead = new Set(failed.map((entry: ReapedBranch): string => entry.branch));\n const remaining = verdicts.deletable.filter(\n (entry: DeletableBranch): boolean => stillDead.has(entry.branch));\n this.mergedBranches.writeMergedBranches(\n repoRoot,\n new MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees),\n );\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
@@ -0,0 +1,154 @@
1
+ /**
2
+ * THE ONE PLACE THAT KNOWS ANYTHING ABOUT CLAUDE CODE'S ON-DISK STATE.
3
+ *
4
+ * ─── THE RULE THIS CLASS EXISTS UNDER ────────────────────────────────────────────────────────────
5
+ *
6
+ * HARNESS STATE MAY ONLY VETO A REAP. IT MAY NEVER LICENSE ONE.
7
+ *
8
+ * Read that before reading anything else here, because every answer below is shaped by it. The
9
+ * licence to remove a worktree comes SOLELY from evidence wp-cleanup already computes and already
10
+ * gets right: a merged PR, a snapshot of a ref that still holds the work, a ref identical to
11
+ * origin/main, plus a clean `git status --porcelain`. This class answers ONE question — "is somebody
12
+ * plausibly still in there?" — and its only power is to STOP a reap that the branch evidence had
13
+ * already authorised. No answer it can give turns a spared worktree into a reaped one.
14
+ *
15
+ * ─── WHY IT EXISTS ───────────────────────────────────────────────────────────────────────────────
16
+ * `git worktree lock` reasons written by the Claude Code harness carry a pid, and wp-cleanup used to
17
+ * ask the kernel whether that pid was alive. It always is. Subagents are NOT separate OS processes —
18
+ * every agent in a session records the SAME pid, the session process — so "is that pid running?"
19
+ * reduced to "is the editor still open?", which is true by construction for the whole life of a
20
+ * session. Observed in monorepo-nx2: nine worktrees against a cap of five, eight of them reported as
21
+ * "that agent is working in here" with one identical pid, exactly ONE agent actually running, and
22
+ * several of the others' PRs already merged.
23
+ *
24
+ * So a veto has to come from somewhere that knows about AGENTS rather than processes. The harness
25
+ * does, and it writes it down:
26
+ *
27
+ * <config>/projects/<project-slug>/<sessionId>/subagents/agent-<id>.meta.json
28
+ * <config>/projects/<project-slug>/<sessionId>/subagents/agent-<id>.jsonl
29
+ *
30
+ * `<config>` is `$CLAUDE_CONFIG_DIR` when set, else `~/.claude`. `agent-<id>` is ONE token, and it is
31
+ * the same token in three places: the stem of those two filenames, the basename of the `worktreePath`
32
+ * that meta records, and the name in the lock reason `claude agent agent-<id> (pid N …)`. So worktree
33
+ * → agent state is an exact lookup, not a heuristic — and nothing here re-derives or re-prefixes that
34
+ * token, because a filename built from half of it is how a lookup quietly becomes a miss.
35
+ *
36
+ * ─── THE SIGNAL: THE SHAPE OF THE LAST RECORD IN THE AGENT'S OWN TRANSCRIPT ──────────────────────
37
+ * An agent's loop pauses when the model emits text and asks for no tool. That is the definition of
38
+ * the loop, not a correlation with one:
39
+ *
40
+ * last record is `assistant` with NO `tool_use` block → RETURNED — not currently in a tool loop
41
+ * anything else (a `tool_use` block, a `user`/tool_result record) → MID-LOOP
42
+ *
43
+ * Verified on three agents known to have finished (all `assistant` / `['text']`) and on a live one
44
+ * (`assistant` / `['tool_use']`). `thinking` blocks appear alongside `text` and change nothing: the
45
+ * criterion is the ABSENCE of `tool_use`, never "exactly one block". A `user` record's content can be
46
+ * a plain STRING rather than a list of blocks; that is mid-loop too, and must not throw.
47
+ *
48
+ * ─── "RETURNED" IS NOT "DONE FOREVER", WHICH IS WHY IT CANNOT LICENSE A REAP ──────────────────────
49
+ * Measured: a throwaway agent returned with a text-only record at 17:04:20, and at 17:08:11 a `user`
50
+ * record appeared and it ran again, returning a second time at 17:08:13. The harness RE-INVOKES an
51
+ * agent when a background child it started completes, and when a parent sends it a message. It holds
52
+ * its worktree across that gap. So RETURNED means "not in a tool loop right now" and nothing
53
+ * stronger — which is survivable only because of the rule at the top of this file: the reap was
54
+ * already authorised by a merged PR and a clean tree, and an agent that briefly resumes into that
55
+ * situation has nothing to lose.
56
+ *
57
+ * ─── WHY MTIME IS ONLY A TIEBREAKER ──────────────────────────────────────────────────────────────
58
+ * Freshness of the transcript looks like the obvious liveness signal and is NOT one. Measured: one
59
+ * minute after that throwaway agent finished, its mtime was FRESHER than the genuinely live agent's
60
+ * had been moments earlier. And a live agent writes nothing at all while it sits inside one long Bash
61
+ * call — a `wp-build` is ten minutes of silence — so any threshold safe against that also covers
62
+ * every just-finished agent, which is precisely the population wp-cleanup meets. Mtime alone does not
63
+ * fix this bug; it re-times it. It earns its place on exactly one case: telling a mid-loop transcript
64
+ * that is being written RIGHT NOW (a live agent, veto) from one frozen mid-`tool_use` by a killed
65
+ * session (no veto — that is the original "looks live forever" defect).
66
+ *
67
+ * ─── NEGATIVE RESULT — DO NOT RE-TRY THIS ────────────────────────────────────────────────────────
68
+ * The meta records a `toolUseId`, and a `tool_result` for it in the SPAWNER's transcript looks like an
69
+ * exact completion marker. It is NOT one: a backgrounded Agent call gets its "launched successfully"
70
+ * tool_result IMMEDIATELY, so a running agent already has one, indistinguishable from a finished
71
+ * agent's. Measured on a live agent and rejected.
72
+ *
73
+ * ─── EVERY ANSWER FAILS SAFE ─────────────────────────────────────────────────────────────────────
74
+ * A missing config dir, an unreadable file, a meta describing a different worktree, a truncated last
75
+ * line — all of them are UNKNOWN. UNKNOWN withholds the veto, which is only ever safe because the
76
+ * branch evidence had to authorise the reap first.
77
+ *
78
+ * THIS IS UNDOCUMENTED HARNESS INTERNALS, deliberately quarantined in one file so a layout change
79
+ * breaks exactly here and degrades to "cannot tell" everywhere else.
80
+ */
81
+ /** Mid-loop and being written to right now. The ONE answer that vetoes a reap. */
82
+ export declare const AGENT_ACTIVITY_LIVE = "live";
83
+ /** Its transcript ends with the model returning — not in a tool loop. It may still resume. */
84
+ export declare const AGENT_ACTIVITY_RETURNED = "returned";
85
+ /** No usable evidence, or a mid-loop transcript nobody has touched for a long time. */
86
+ export declare const AGENT_ACTIVITY_UNKNOWN = "unknown";
87
+ /**
88
+ * How long a MID-LOOP transcript may sit untouched before its silence stops vetoing a reap.
89
+ *
90
+ * Consulted for a mid-loop transcript only — a returned one is recognised by shape, whatever its
91
+ * mtime — so this number decides exactly one thing: how long an agent that was KILLED inside a tool
92
+ * call goes on vetoing. Generous on purpose, because a live agent writes NOTHING while it is inside
93
+ * one long Bash call and a `wp-build` or `nx run … :ci` is routinely ten minutes of that. A threshold
94
+ * in seconds would drop the veto on a building agent, which is the one direction that costs work;
95
+ * being late costs a directory the next cleanup takes.
96
+ */
97
+ export declare const AGENT_TRANSCRIPT_QUIET_MS: number;
98
+ export declare class AgentActivity {
99
+ /** One of the AGENT_ACTIVITY_* constants. */
100
+ state: string;
101
+ /** Human-readable evidence, printed verbatim into wp-cleanup's spared/overridden reasons. */
102
+ detail: string;
103
+ constructor(state: string, detail: string);
104
+ }
105
+ export declare class HarnessAgentActivityReader {
106
+ /**
107
+ * What the harness says about `agentId`, cross-checked against the worktree we are judging.
108
+ *
109
+ * `worktreePath` is not decoration: it is what makes this a LOOKUP rather than a guess. If the
110
+ * meta we find records a different worktree we have matched the wrong thing, and the answer is
111
+ * UNKNOWN — never an answer about somebody else's directory.
112
+ */
113
+ activityOf(agentId: string, worktreePath: string, now?: number): AgentActivity;
114
+ /**
115
+ * Did the agent RETURN? True only for an `assistant` record whose content asks for no tool.
116
+ *
117
+ * A `user` record (a tool result coming back, or a message sent to the agent) is mid-loop by
118
+ * definition — and its content may be a plain string, which is why the array check comes first. So
119
+ * is an `assistant` record carrying a `tool_use` EVEN ALONGSIDE TEXT: the model narrating before
120
+ * it calls something is not a return.
121
+ */
122
+ private isReturn;
123
+ /**
124
+ * The agent's own transcript, or the reason there is none — each of the three exits phrased for
125
+ * what actually happened. `exists` is asked BEFORE `readMeta` precisely so "not there" and
126
+ * "there and unreadable" stay distinguishable; folding them together is what produced the flat
127
+ * over-claim this replaced.
128
+ */
129
+ private locate;
130
+ /** Every `<config>/projects/<slug>/<session>/subagents` directory that exists right now. */
131
+ private subagentDirs;
132
+ /**
133
+ * Seam: where the harness keeps its per-project state, delegated to the ONE resolver
134
+ * ({@link ClaudeConfigDir}) so this file and the two provenance modules cannot disagree about
135
+ * it — they did, and the divergence hard-blocked every PR on a relocated config dir. Specs point
136
+ * `$CLAUDE_CONFIG_DIR` at a fixture tree, which exercises the same resolution the field uses.
137
+ */
138
+ protected projectsRoots(): string[];
139
+ private minutesAgo;
140
+ private exists;
141
+ private readMeta;
142
+ /**
143
+ * The last complete JSONL record of `transcript`, or null.
144
+ *
145
+ * Reads only the tail, because these files grow without bound and only the final line matters. A
146
+ * window that starts mid-record yields an unparseable fragment and therefore null — "cannot tell"
147
+ * — which is exactly what a truncated or half-written last line should also produce.
148
+ */
149
+ private lastRecord;
150
+ private readTail;
151
+ private readDir;
152
+ private isDirectory;
153
+ private mtimeOf;
154
+ }
@@ -0,0 +1,346 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HarnessAgentActivityReader = exports.AgentActivity = exports.AGENT_TRANSCRIPT_QUIET_MS = exports.AGENT_ACTIVITY_UNKNOWN = exports.AGENT_ACTIVITY_RETURNED = exports.AGENT_ACTIVITY_LIVE = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const inversify_1 = require("inversify");
8
+ const tooling_common_1 = require("@webpieces/tooling-common");
9
+ const to_error_1 = require("@webpieces/tooling-common/to-error");
10
+ /**
11
+ * THE ONE PLACE THAT KNOWS ANYTHING ABOUT CLAUDE CODE'S ON-DISK STATE.
12
+ *
13
+ * ─── THE RULE THIS CLASS EXISTS UNDER ────────────────────────────────────────────────────────────
14
+ *
15
+ * HARNESS STATE MAY ONLY VETO A REAP. IT MAY NEVER LICENSE ONE.
16
+ *
17
+ * Read that before reading anything else here, because every answer below is shaped by it. The
18
+ * licence to remove a worktree comes SOLELY from evidence wp-cleanup already computes and already
19
+ * gets right: a merged PR, a snapshot of a ref that still holds the work, a ref identical to
20
+ * origin/main, plus a clean `git status --porcelain`. This class answers ONE question — "is somebody
21
+ * plausibly still in there?" — and its only power is to STOP a reap that the branch evidence had
22
+ * already authorised. No answer it can give turns a spared worktree into a reaped one.
23
+ *
24
+ * ─── WHY IT EXISTS ───────────────────────────────────────────────────────────────────────────────
25
+ * `git worktree lock` reasons written by the Claude Code harness carry a pid, and wp-cleanup used to
26
+ * ask the kernel whether that pid was alive. It always is. Subagents are NOT separate OS processes —
27
+ * every agent in a session records the SAME pid, the session process — so "is that pid running?"
28
+ * reduced to "is the editor still open?", which is true by construction for the whole life of a
29
+ * session. Observed in monorepo-nx2: nine worktrees against a cap of five, eight of them reported as
30
+ * "that agent is working in here" with one identical pid, exactly ONE agent actually running, and
31
+ * several of the others' PRs already merged.
32
+ *
33
+ * So a veto has to come from somewhere that knows about AGENTS rather than processes. The harness
34
+ * does, and it writes it down:
35
+ *
36
+ * <config>/projects/<project-slug>/<sessionId>/subagents/agent-<id>.meta.json
37
+ * <config>/projects/<project-slug>/<sessionId>/subagents/agent-<id>.jsonl
38
+ *
39
+ * `<config>` is `$CLAUDE_CONFIG_DIR` when set, else `~/.claude`. `agent-<id>` is ONE token, and it is
40
+ * the same token in three places: the stem of those two filenames, the basename of the `worktreePath`
41
+ * that meta records, and the name in the lock reason `claude agent agent-<id> (pid N …)`. So worktree
42
+ * → agent state is an exact lookup, not a heuristic — and nothing here re-derives or re-prefixes that
43
+ * token, because a filename built from half of it is how a lookup quietly becomes a miss.
44
+ *
45
+ * ─── THE SIGNAL: THE SHAPE OF THE LAST RECORD IN THE AGENT'S OWN TRANSCRIPT ──────────────────────
46
+ * An agent's loop pauses when the model emits text and asks for no tool. That is the definition of
47
+ * the loop, not a correlation with one:
48
+ *
49
+ * last record is `assistant` with NO `tool_use` block → RETURNED — not currently in a tool loop
50
+ * anything else (a `tool_use` block, a `user`/tool_result record) → MID-LOOP
51
+ *
52
+ * Verified on three agents known to have finished (all `assistant` / `['text']`) and on a live one
53
+ * (`assistant` / `['tool_use']`). `thinking` blocks appear alongside `text` and change nothing: the
54
+ * criterion is the ABSENCE of `tool_use`, never "exactly one block". A `user` record's content can be
55
+ * a plain STRING rather than a list of blocks; that is mid-loop too, and must not throw.
56
+ *
57
+ * ─── "RETURNED" IS NOT "DONE FOREVER", WHICH IS WHY IT CANNOT LICENSE A REAP ──────────────────────
58
+ * Measured: a throwaway agent returned with a text-only record at 17:04:20, and at 17:08:11 a `user`
59
+ * record appeared and it ran again, returning a second time at 17:08:13. The harness RE-INVOKES an
60
+ * agent when a background child it started completes, and when a parent sends it a message. It holds
61
+ * its worktree across that gap. So RETURNED means "not in a tool loop right now" and nothing
62
+ * stronger — which is survivable only because of the rule at the top of this file: the reap was
63
+ * already authorised by a merged PR and a clean tree, and an agent that briefly resumes into that
64
+ * situation has nothing to lose.
65
+ *
66
+ * ─── WHY MTIME IS ONLY A TIEBREAKER ──────────────────────────────────────────────────────────────
67
+ * Freshness of the transcript looks like the obvious liveness signal and is NOT one. Measured: one
68
+ * minute after that throwaway agent finished, its mtime was FRESHER than the genuinely live agent's
69
+ * had been moments earlier. And a live agent writes nothing at all while it sits inside one long Bash
70
+ * call — a `wp-build` is ten minutes of silence — so any threshold safe against that also covers
71
+ * every just-finished agent, which is precisely the population wp-cleanup meets. Mtime alone does not
72
+ * fix this bug; it re-times it. It earns its place on exactly one case: telling a mid-loop transcript
73
+ * that is being written RIGHT NOW (a live agent, veto) from one frozen mid-`tool_use` by a killed
74
+ * session (no veto — that is the original "looks live forever" defect).
75
+ *
76
+ * ─── NEGATIVE RESULT — DO NOT RE-TRY THIS ────────────────────────────────────────────────────────
77
+ * The meta records a `toolUseId`, and a `tool_result` for it in the SPAWNER's transcript looks like an
78
+ * exact completion marker. It is NOT one: a backgrounded Agent call gets its "launched successfully"
79
+ * tool_result IMMEDIATELY, so a running agent already has one, indistinguishable from a finished
80
+ * agent's. Measured on a live agent and rejected.
81
+ *
82
+ * ─── EVERY ANSWER FAILS SAFE ─────────────────────────────────────────────────────────────────────
83
+ * A missing config dir, an unreadable file, a meta describing a different worktree, a truncated last
84
+ * line — all of them are UNKNOWN. UNKNOWN withholds the veto, which is only ever safe because the
85
+ * branch evidence had to authorise the reap first.
86
+ *
87
+ * THIS IS UNDOCUMENTED HARNESS INTERNALS, deliberately quarantined in one file so a layout change
88
+ * breaks exactly here and degrades to "cannot tell" everywhere else.
89
+ */
90
+ /** Mid-loop and being written to right now. The ONE answer that vetoes a reap. */
91
+ exports.AGENT_ACTIVITY_LIVE = 'live';
92
+ /** Its transcript ends with the model returning — not in a tool loop. It may still resume. */
93
+ exports.AGENT_ACTIVITY_RETURNED = 'returned';
94
+ /** No usable evidence, or a mid-loop transcript nobody has touched for a long time. */
95
+ exports.AGENT_ACTIVITY_UNKNOWN = 'unknown';
96
+ /**
97
+ * How long a MID-LOOP transcript may sit untouched before its silence stops vetoing a reap.
98
+ *
99
+ * Consulted for a mid-loop transcript only — a returned one is recognised by shape, whatever its
100
+ * mtime — so this number decides exactly one thing: how long an agent that was KILLED inside a tool
101
+ * call goes on vetoing. Generous on purpose, because a live agent writes NOTHING while it is inside
102
+ * one long Bash call and a `wp-build` or `nx run … :ci` is routinely ten minutes of that. A threshold
103
+ * in seconds would drop the veto on a building agent, which is the one direction that costs work;
104
+ * being late costs a directory the next cleanup takes.
105
+ */
106
+ exports.AGENT_TRANSCRIPT_QUIET_MS = 45 * 60 * 1000;
107
+ /**
108
+ * How much of the tail of a transcript is read to find its last record.
109
+ *
110
+ * Transcripts are append-only and reach tens of megabytes; the answer lives in the final line. Large
111
+ * enough that a single fat record (a big tool result) still fits whole — and when it does not, the
112
+ * fragment fails to parse and the answer is UNKNOWN.
113
+ */
114
+ const TRANSCRIPT_TAIL_BYTES = 8 * 1024 * 1024;
115
+ // Data-only (per CLAUDE.md, classes for data). What the harness says about one agent, and why.
116
+ class AgentActivity {
117
+ /** One of the AGENT_ACTIVITY_* constants. */
118
+ state;
119
+ /** Human-readable evidence, printed verbatim into wp-cleanup's spared/overridden reasons. */
120
+ detail;
121
+ constructor(state, detail) {
122
+ this.state = state;
123
+ this.detail = detail;
124
+ }
125
+ }
126
+ exports.AgentActivity = AgentActivity;
127
+ /**
128
+ * Data-only. Where an agent's transcript is — or, when it is nowhere, WHICH of the three genuinely
129
+ * different silences we hit.
130
+ *
131
+ * One flat "no state file for that agent id" covered all three, and two of them were untrue: a meta
132
+ * that exists and cannot be PARSED, and a meta that exists and describes SOMEBODY ELSE'S worktree.
133
+ * That string is printed verbatim into wp-cleanup's reason, so it is a message asserting more than
134
+ * the evidence supports — the exact defect this whole file exists to remove, small and on the safe
135
+ * side but the same shape.
136
+ */
137
+ class AgentStateLookup {
138
+ /** The agent's own transcript, or '' when there is none to read. */
139
+ transcript;
140
+ /** When `transcript` is '': the honest reason, printed to a human as-is. */
141
+ detail;
142
+ constructor(transcript, detail) {
143
+ this.transcript = transcript;
144
+ this.detail = detail;
145
+ }
146
+ }
147
+ let HarnessAgentActivityReader = class HarnessAgentActivityReader {
148
+ /**
149
+ * What the harness says about `agentId`, cross-checked against the worktree we are judging.
150
+ *
151
+ * `worktreePath` is not decoration: it is what makes this a LOOKUP rather than a guess. If the
152
+ * meta we find records a different worktree we have matched the wrong thing, and the answer is
153
+ * UNKNOWN — never an answer about somebody else's directory.
154
+ */
155
+ activityOf(agentId, worktreePath, now = Date.now()) {
156
+ const found = this.locate(agentId, worktreePath);
157
+ if (found.transcript === '')
158
+ return new AgentActivity(exports.AGENT_ACTIVITY_UNKNOWN, found.detail);
159
+ const last = this.lastRecord(found.transcript);
160
+ if (last === null) {
161
+ return new AgentActivity(exports.AGENT_ACTIVITY_UNKNOWN, 'its transcript could not be read to the end');
162
+ }
163
+ if (this.isReturn(last)) {
164
+ return new AgentActivity(exports.AGENT_ACTIVITY_RETURNED, 'its transcript ends with that agent returning its answer, so it is not in a tool call');
165
+ }
166
+ const written = this.mtimeOf(found.transcript);
167
+ if (written > 0 && now - written < exports.AGENT_TRANSCRIPT_QUIET_MS) {
168
+ return new AgentActivity(exports.AGENT_ACTIVITY_LIVE, `its transcript ends mid-tool-call and was written ${this.minutesAgo(now, written)}`);
169
+ }
170
+ return new AgentActivity(exports.AGENT_ACTIVITY_UNKNOWN, 'its transcript ends mid-tool-call but nothing has written to it for a long time, so that '
171
+ + 'agent was more likely killed than working');
172
+ }
173
+ /**
174
+ * Did the agent RETURN? True only for an `assistant` record whose content asks for no tool.
175
+ *
176
+ * A `user` record (a tool result coming back, or a message sent to the agent) is mid-loop by
177
+ * definition — and its content may be a plain string, which is why the array check comes first. So
178
+ * is an `assistant` record carrying a `tool_use` EVEN ALONGSIDE TEXT: the model narrating before
179
+ * it calls something is not a return.
180
+ */
181
+ isReturn(record) {
182
+ if ((record.type ?? '') !== 'assistant')
183
+ return false;
184
+ const content = record.message === undefined ? undefined : record.message.content;
185
+ if (!Array.isArray(content) || content.length === 0)
186
+ return false;
187
+ for (const block of content) {
188
+ if ((block.type ?? '') === 'tool_use')
189
+ return false;
190
+ }
191
+ return true;
192
+ }
193
+ /**
194
+ * The agent's own transcript, or the reason there is none — each of the three exits phrased for
195
+ * what actually happened. `exists` is asked BEFORE `readMeta` precisely so "not there" and
196
+ * "there and unreadable" stay distinguishable; folding them together is what produced the flat
197
+ * over-claim this replaced.
198
+ */
199
+ locate(agentId, worktreePath) {
200
+ for (const subagents of this.subagentDirs()) {
201
+ const metaPath = path.join(subagents, `${agentId}.meta.json`);
202
+ if (!this.exists(metaPath))
203
+ continue;
204
+ const meta = this.readMeta(metaPath);
205
+ if (meta === null) {
206
+ return new AgentStateLookup('', `that agent's harness state file (${metaPath}) could not be read`);
207
+ }
208
+ const recorded = meta.worktreePath ?? '';
209
+ if (recorded !== '' && worktreePath !== '' && path.resolve(recorded) !== path.resolve(worktreePath)) {
210
+ return new AgentStateLookup('', `the harness records that agent against a different worktree (${recorded})`);
211
+ }
212
+ return new AgentStateLookup(path.join(subagents, `${agentId}.jsonl`), '');
213
+ }
214
+ return new AgentStateLookup('', 'the Claude Code harness has no state file for that agent id');
215
+ }
216
+ /** Every `<config>/projects/<slug>/<session>/subagents` directory that exists right now. */
217
+ subagentDirs() {
218
+ const out = [];
219
+ for (const root of this.projectsRoots()) {
220
+ for (const project of this.readDir(root)) {
221
+ const projectDir = path.join(root, project);
222
+ for (const session of this.readDir(projectDir)) {
223
+ const subagents = path.join(projectDir, session, 'subagents');
224
+ if (this.isDirectory(subagents))
225
+ out.push(subagents);
226
+ }
227
+ }
228
+ }
229
+ return out;
230
+ }
231
+ /**
232
+ * Seam: where the harness keeps its per-project state, delegated to the ONE resolver
233
+ * ({@link ClaudeConfigDir}) so this file and the two provenance modules cannot disagree about
234
+ * it — they did, and the divergence hard-blocked every PR on a relocated config dir. Specs point
235
+ * `$CLAUDE_CONFIG_DIR` at a fixture tree, which exercises the same resolution the field uses.
236
+ */
237
+ projectsRoots() {
238
+ return tooling_common_1.claudeConfigDir.projectsRoots();
239
+ }
240
+ minutesAgo(now, written) {
241
+ const minutes = Math.max(0, Math.floor((now - written) / 60000));
242
+ return minutes === 1 ? '1 minute ago' : `${String(minutes)} minutes ago`;
243
+ }
244
+ // ── Everything below is a guarded filesystem read: any failure is silence, never a throw. ──
245
+ exists(filePath) {
246
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
247
+ try {
248
+ return fs.existsSync(filePath);
249
+ }
250
+ catch (err) {
251
+ const error = (0, to_error_1.toError)(err);
252
+ void error;
253
+ return false;
254
+ }
255
+ }
256
+ // null means "it is there and this release could not make sense of it" — NOT "it is absent".
257
+ readMeta(metaPath) {
258
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
259
+ try {
260
+ return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
261
+ }
262
+ catch (err) {
263
+ const error = (0, to_error_1.toError)(err);
264
+ void error;
265
+ return null;
266
+ }
267
+ }
268
+ /**
269
+ * The last complete JSONL record of `transcript`, or null.
270
+ *
271
+ * Reads only the tail, because these files grow without bound and only the final line matters. A
272
+ * window that starts mid-record yields an unparseable fragment and therefore null — "cannot tell"
273
+ * — which is exactly what a truncated or half-written last line should also produce.
274
+ */
275
+ lastRecord(transcript) {
276
+ const lines = this.readTail(transcript).split('\n')
277
+ .filter((line) => line.trim() !== '');
278
+ if (lines.length === 0)
279
+ return null;
280
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
281
+ try {
282
+ return JSON.parse(lines[lines.length - 1]);
283
+ }
284
+ catch (err) {
285
+ const error = (0, to_error_1.toError)(err);
286
+ void error;
287
+ return null;
288
+ }
289
+ }
290
+ readTail(filePath) {
291
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
292
+ try {
293
+ const size = fs.statSync(filePath).size;
294
+ const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
295
+ const buffer = Buffer.alloc(length);
296
+ const fd = fs.openSync(filePath, 'r');
297
+ fs.readSync(fd, buffer, 0, length, size - length);
298
+ fs.closeSync(fd);
299
+ return buffer.toString('utf8');
300
+ }
301
+ catch (err) {
302
+ const error = (0, to_error_1.toError)(err);
303
+ void error;
304
+ return '';
305
+ }
306
+ }
307
+ readDir(dirPath) {
308
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
309
+ try {
310
+ return fs.readdirSync(dirPath);
311
+ }
312
+ catch (err) {
313
+ const error = (0, to_error_1.toError)(err);
314
+ void error;
315
+ return [];
316
+ }
317
+ }
318
+ isDirectory(dirPath) {
319
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
320
+ try {
321
+ return fs.statSync(dirPath).isDirectory();
322
+ }
323
+ catch (err) {
324
+ const error = (0, to_error_1.toError)(err);
325
+ void error;
326
+ return false;
327
+ }
328
+ }
329
+ // 0 when the file is absent or unreadable — which reads as "no evidence", not "written long ago".
330
+ mtimeOf(filePath) {
331
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
332
+ try {
333
+ return fs.statSync(filePath).mtimeMs;
334
+ }
335
+ catch (err) {
336
+ const error = (0, to_error_1.toError)(err);
337
+ void error;
338
+ return 0;
339
+ }
340
+ }
341
+ };
342
+ exports.HarnessAgentActivityReader = HarnessAgentActivityReader;
343
+ exports.HarnessAgentActivityReader = HarnessAgentActivityReader = tslib_1.__decorate([
344
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
345
+ ], HarnessAgentActivityReader);
346
+ //# sourceMappingURL=harness-agent-activity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"harness-agent-activity.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/harness-agent-activity.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { claudeConfigDir } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\n\n/**\n * THE ONE PLACE THAT KNOWS ANYTHING ABOUT CLAUDE CODE'S ON-DISK STATE.\n *\n * ─── THE RULE THIS CLASS EXISTS UNDER ────────────────────────────────────────────────────────────\n *\n * HARNESS STATE MAY ONLY VETO A REAP. IT MAY NEVER LICENSE ONE.\n *\n * Read that before reading anything else here, because every answer below is shaped by it. The\n * licence to remove a worktree comes SOLELY from evidence wp-cleanup already computes and already\n * gets right: a merged PR, a snapshot of a ref that still holds the work, a ref identical to\n * origin/main, plus a clean `git status --porcelain`. This class answers ONE question — \"is somebody\n * plausibly still in there?\" — and its only power is to STOP a reap that the branch evidence had\n * already authorised. No answer it can give turns a spared worktree into a reaped one.\n *\n * ─── WHY IT EXISTS ───────────────────────────────────────────────────────────────────────────────\n * `git worktree lock` reasons written by the Claude Code harness carry a pid, and wp-cleanup used to\n * ask the kernel whether that pid was alive. It always is. Subagents are NOT separate OS processes —\n * every agent in a session records the SAME pid, the session process — so \"is that pid running?\"\n * reduced to \"is the editor still open?\", which is true by construction for the whole life of a\n * session. Observed in monorepo-nx2: nine worktrees against a cap of five, eight of them reported as\n * \"that agent is working in here\" with one identical pid, exactly ONE agent actually running, and\n * several of the others' PRs already merged.\n *\n * So a veto has to come from somewhere that knows about AGENTS rather than processes. The harness\n * does, and it writes it down:\n *\n * <config>/projects/<project-slug>/<sessionId>/subagents/agent-<id>.meta.json\n * <config>/projects/<project-slug>/<sessionId>/subagents/agent-<id>.jsonl\n *\n * `<config>` is `$CLAUDE_CONFIG_DIR` when set, else `~/.claude`. `agent-<id>` is ONE token, and it is\n * the same token in three places: the stem of those two filenames, the basename of the `worktreePath`\n * that meta records, and the name in the lock reason `claude agent agent-<id> (pid N …)`. So worktree\n * → agent state is an exact lookup, not a heuristic — and nothing here re-derives or re-prefixes that\n * token, because a filename built from half of it is how a lookup quietly becomes a miss.\n *\n * ─── THE SIGNAL: THE SHAPE OF THE LAST RECORD IN THE AGENT'S OWN TRANSCRIPT ──────────────────────\n * An agent's loop pauses when the model emits text and asks for no tool. That is the definition of\n * the loop, not a correlation with one:\n *\n * last record is `assistant` with NO `tool_use` block → RETURNED — not currently in a tool loop\n * anything else (a `tool_use` block, a `user`/tool_result record) → MID-LOOP\n *\n * Verified on three agents known to have finished (all `assistant` / `['text']`) and on a live one\n * (`assistant` / `['tool_use']`). `thinking` blocks appear alongside `text` and change nothing: the\n * criterion is the ABSENCE of `tool_use`, never \"exactly one block\". A `user` record's content can be\n * a plain STRING rather than a list of blocks; that is mid-loop too, and must not throw.\n *\n * ─── \"RETURNED\" IS NOT \"DONE FOREVER\", WHICH IS WHY IT CANNOT LICENSE A REAP ──────────────────────\n * Measured: a throwaway agent returned with a text-only record at 17:04:20, and at 17:08:11 a `user`\n * record appeared and it ran again, returning a second time at 17:08:13. The harness RE-INVOKES an\n * agent when a background child it started completes, and when a parent sends it a message. It holds\n * its worktree across that gap. So RETURNED means \"not in a tool loop right now\" and nothing\n * stronger — which is survivable only because of the rule at the top of this file: the reap was\n * already authorised by a merged PR and a clean tree, and an agent that briefly resumes into that\n * situation has nothing to lose.\n *\n * ─── WHY MTIME IS ONLY A TIEBREAKER ──────────────────────────────────────────────────────────────\n * Freshness of the transcript looks like the obvious liveness signal and is NOT one. Measured: one\n * minute after that throwaway agent finished, its mtime was FRESHER than the genuinely live agent's\n * had been moments earlier. And a live agent writes nothing at all while it sits inside one long Bash\n * call — a `wp-build` is ten minutes of silence — so any threshold safe against that also covers\n * every just-finished agent, which is precisely the population wp-cleanup meets. Mtime alone does not\n * fix this bug; it re-times it. It earns its place on exactly one case: telling a mid-loop transcript\n * that is being written RIGHT NOW (a live agent, veto) from one frozen mid-`tool_use` by a killed\n * session (no veto — that is the original \"looks live forever\" defect).\n *\n * ─── NEGATIVE RESULT — DO NOT RE-TRY THIS ────────────────────────────────────────────────────────\n * The meta records a `toolUseId`, and a `tool_result` for it in the SPAWNER's transcript looks like an\n * exact completion marker. It is NOT one: a backgrounded Agent call gets its \"launched successfully\"\n * tool_result IMMEDIATELY, so a running agent already has one, indistinguishable from a finished\n * agent's. Measured on a live agent and rejected.\n *\n * ─── EVERY ANSWER FAILS SAFE ─────────────────────────────────────────────────────────────────────\n * A missing config dir, an unreadable file, a meta describing a different worktree, a truncated last\n * line — all of them are UNKNOWN. UNKNOWN withholds the veto, which is only ever safe because the\n * branch evidence had to authorise the reap first.\n *\n * THIS IS UNDOCUMENTED HARNESS INTERNALS, deliberately quarantined in one file so a layout change\n * breaks exactly here and degrades to \"cannot tell\" everywhere else.\n */\n\n/** Mid-loop and being written to right now. The ONE answer that vetoes a reap. */\nexport const AGENT_ACTIVITY_LIVE = 'live';\n/** Its transcript ends with the model returning — not in a tool loop. It may still resume. */\nexport const AGENT_ACTIVITY_RETURNED = 'returned';\n/** No usable evidence, or a mid-loop transcript nobody has touched for a long time. */\nexport const AGENT_ACTIVITY_UNKNOWN = 'unknown';\n\n/**\n * How long a MID-LOOP transcript may sit untouched before its silence stops vetoing a reap.\n *\n * Consulted for a mid-loop transcript only — a returned one is recognised by shape, whatever its\n * mtime — so this number decides exactly one thing: how long an agent that was KILLED inside a tool\n * call goes on vetoing. Generous on purpose, because a live agent writes NOTHING while it is inside\n * one long Bash call and a `wp-build` or `nx run … :ci` is routinely ten minutes of that. A threshold\n * in seconds would drop the veto on a building agent, which is the one direction that costs work;\n * being late costs a directory the next cleanup takes.\n */\nexport const AGENT_TRANSCRIPT_QUIET_MS = 45 * 60 * 1000;\n\n/**\n * How much of the tail of a transcript is read to find its last record.\n *\n * Transcripts are append-only and reach tens of megabytes; the answer lives in the final line. Large\n * enough that a single fat record (a big tool result) still fits whole — and when it does not, the\n * fragment fails to parse and the answer is UNKNOWN.\n */\nconst TRANSCRIPT_TAIL_BYTES = 8 * 1024 * 1024;\n\n// Data-only (per CLAUDE.md, classes for data). What the harness says about one agent, and why.\nexport class AgentActivity {\n /** One of the AGENT_ACTIVITY_* constants. */\n state: string;\n /** Human-readable evidence, printed verbatim into wp-cleanup's spared/overridden reasons. */\n detail: string;\n\n constructor(state: string, detail: string) {\n this.state = state;\n this.detail = detail;\n }\n}\n\n/**\n * Data-only. Where an agent's transcript is — or, when it is nowhere, WHICH of the three genuinely\n * different silences we hit.\n *\n * One flat \"no state file for that agent id\" covered all three, and two of them were untrue: a meta\n * that exists and cannot be PARSED, and a meta that exists and describes SOMEBODY ELSE'S worktree.\n * That string is printed verbatim into wp-cleanup's reason, so it is a message asserting more than\n * the evidence supports — the exact defect this whole file exists to remove, small and on the safe\n * side but the same shape.\n */\nclass AgentStateLookup {\n /** The agent's own transcript, or '' when there is none to read. */\n transcript: string;\n /** When `transcript` is '': the honest reason, printed to a human as-is. */\n detail: string;\n\n constructor(transcript: string, detail: string) {\n this.transcript = transcript;\n this.detail = detail;\n }\n}\n\n// Raw JSON shapes for the cast at the parse boundary — the convention merged-branches.ts already uses\n// for files it revives. Every field optional: this is somebody else's format.\ninterface RawAgentMeta {\n worktreePath?: string;\n}\n\ninterface RawContentBlock {\n type?: string;\n}\n\ninterface RawTranscriptMessage {\n // A `user` record's content is sometimes a plain string rather than a list of blocks.\n content?: RawContentBlock[] | string;\n}\n\ninterface RawTranscriptRecord {\n type?: string;\n message?: RawTranscriptMessage;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class HarnessAgentActivityReader {\n /**\n * What the harness says about `agentId`, cross-checked against the worktree we are judging.\n *\n * `worktreePath` is not decoration: it is what makes this a LOOKUP rather than a guess. If the\n * meta we find records a different worktree we have matched the wrong thing, and the answer is\n * UNKNOWN — never an answer about somebody else's directory.\n */\n activityOf(agentId: string, worktreePath: string, now: number = Date.now()): AgentActivity {\n const found = this.locate(agentId, worktreePath);\n if (found.transcript === '') return new AgentActivity(AGENT_ACTIVITY_UNKNOWN, found.detail);\n const last = this.lastRecord(found.transcript);\n if (last === null) {\n return new AgentActivity(AGENT_ACTIVITY_UNKNOWN, 'its transcript could not be read to the end');\n }\n if (this.isReturn(last)) {\n return new AgentActivity(AGENT_ACTIVITY_RETURNED,\n 'its transcript ends with that agent returning its answer, so it is not in a tool call');\n }\n const written = this.mtimeOf(found.transcript);\n if (written > 0 && now - written < AGENT_TRANSCRIPT_QUIET_MS) {\n return new AgentActivity(AGENT_ACTIVITY_LIVE,\n `its transcript ends mid-tool-call and was written ${this.minutesAgo(now, written)}`);\n }\n return new AgentActivity(AGENT_ACTIVITY_UNKNOWN,\n 'its transcript ends mid-tool-call but nothing has written to it for a long time, so that '\n + 'agent was more likely killed than working');\n }\n\n /**\n * Did the agent RETURN? True only for an `assistant` record whose content asks for no tool.\n *\n * A `user` record (a tool result coming back, or a message sent to the agent) is mid-loop by\n * definition — and its content may be a plain string, which is why the array check comes first. So\n * is an `assistant` record carrying a `tool_use` EVEN ALONGSIDE TEXT: the model narrating before\n * it calls something is not a return.\n */\n private isReturn(record: RawTranscriptRecord): boolean {\n if ((record.type ?? '') !== 'assistant') return false;\n const content = record.message === undefined ? undefined : record.message.content;\n if (!Array.isArray(content) || content.length === 0) return false;\n for (const block of content) {\n if ((block.type ?? '') === 'tool_use') return false;\n }\n return true;\n }\n\n /**\n * The agent's own transcript, or the reason there is none — each of the three exits phrased for\n * what actually happened. `exists` is asked BEFORE `readMeta` precisely so \"not there\" and\n * \"there and unreadable\" stay distinguishable; folding them together is what produced the flat\n * over-claim this replaced.\n */\n private locate(agentId: string, worktreePath: string): AgentStateLookup {\n for (const subagents of this.subagentDirs()) {\n const metaPath = path.join(subagents, `${agentId}.meta.json`);\n if (!this.exists(metaPath)) continue;\n const meta = this.readMeta(metaPath);\n if (meta === null) {\n return new AgentStateLookup('',\n `that agent's harness state file (${metaPath}) could not be read`);\n }\n const recorded = meta.worktreePath ?? '';\n if (recorded !== '' && worktreePath !== '' && path.resolve(recorded) !== path.resolve(worktreePath)) {\n return new AgentStateLookup('',\n `the harness records that agent against a different worktree (${recorded})`);\n }\n return new AgentStateLookup(path.join(subagents, `${agentId}.jsonl`), '');\n }\n return new AgentStateLookup('', 'the Claude Code harness has no state file for that agent id');\n }\n\n /** Every `<config>/projects/<slug>/<session>/subagents` directory that exists right now. */\n private subagentDirs(): string[] {\n const out: string[] = [];\n for (const root of this.projectsRoots()) {\n for (const project of this.readDir(root)) {\n const projectDir = path.join(root, project);\n for (const session of this.readDir(projectDir)) {\n const subagents = path.join(projectDir, session, 'subagents');\n if (this.isDirectory(subagents)) out.push(subagents);\n }\n }\n }\n return out;\n }\n\n /**\n * Seam: where the harness keeps its per-project state, delegated to the ONE resolver\n * ({@link ClaudeConfigDir}) so this file and the two provenance modules cannot disagree about\n * it — they did, and the divergence hard-blocked every PR on a relocated config dir. Specs point\n * `$CLAUDE_CONFIG_DIR` at a fixture tree, which exercises the same resolution the field uses.\n */\n protected projectsRoots(): string[] {\n return claudeConfigDir.projectsRoots();\n }\n\n private minutesAgo(now: number, written: number): string {\n const minutes = Math.max(0, Math.floor((now - written) / 60000));\n return minutes === 1 ? '1 minute ago' : `${String(minutes)} minutes ago`;\n }\n\n // ── Everything below is a guarded filesystem read: any failure is silence, never a throw. ──\n\n private exists(filePath: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(filePath);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n // null means \"it is there and this release could not make sense of it\" — NOT \"it is absent\".\n private readMeta(metaPath: string): RawAgentMeta | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(fs.readFileSync(metaPath, 'utf8')) as RawAgentMeta;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * The last complete JSONL record of `transcript`, or null.\n *\n * Reads only the tail, because these files grow without bound and only the final line matters. A\n * window that starts mid-record yields an unparseable fragment and therefore null — \"cannot tell\"\n * — which is exactly what a truncated or half-written last line should also produce.\n */\n private lastRecord(transcript: string): RawTranscriptRecord | null {\n const lines = this.readTail(transcript).split('\\n')\n .filter((line: string): boolean => line.trim() !== '');\n if (lines.length === 0) return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(lines[lines.length - 1]) as RawTranscriptRecord;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n private readTail(filePath: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const size = fs.statSync(filePath).size;\n const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);\n const buffer = Buffer.alloc(length);\n const fd = fs.openSync(filePath, 'r');\n fs.readSync(fd, buffer, 0, length, size - length);\n fs.closeSync(fd);\n return buffer.toString('utf8');\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '';\n }\n }\n\n private readDir(dirPath: string): string[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.readdirSync(dirPath);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return [];\n }\n }\n\n private isDirectory(dirPath: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.statSync(dirPath).isDirectory();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n // 0 when the file is absent or unreadable — which reads as \"no evidence\", not \"written long ago\".\n private mtimeOf(filePath: string): number {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.statSync(filePath).mtimeMs;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return 0;\n }\n }\n}\n"]}
package/src/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from './main-sync-file';
2
+ export * from './main-sync-status';
3
+ export * from './merged-branches';
4
+ export * from './merged-branch-verdicts';
5
+ export * from './worktrees';
6
+ export * from './branch-reaper';
7
+ export * from './worktree-reaper';
8
+ export * from './agent-worktree-lock';
9
+ export * from './worktree-lock-verdicts';
10
+ export * from './harness-agent-activity';
11
+ export * from './branch-archiver';
12
+ export * from './branch-mutation-log';
13
+ export * from './review-identity-stamp';
14
+ export * from './branch-identity';