@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,82 @@
1
+ import { AtomicFile } from '@webpieces/tooling-common';
2
+ import { DotWebpieces } from '@webpieces/tooling-common';
3
+ /** The ONE sanctioned way a reviewer submits a checklist verdict (issue #863). */
4
+ export declare const WRITE_REVIEW_BIN = "wp-write-review";
5
+ /** The flag that names the checklist a `wp-write-review` call submits for. */
6
+ export declare const WRITE_REVIEW_CHECKLIST_FLAG = "--checklist";
7
+ /** Where the stamps live, beneath this worktree's tooling-owned state dir (never the AI-writable one). */
8
+ export declare const REVIEW_STAMP_DIR = "review-stamps";
9
+ /**
10
+ * How long a stamp stays usable. The hook writes it a few milliseconds before the harness starts the very
11
+ * Bash command it stamped, so anything older than this is left over from a call that never reached the bin
12
+ * — and must not be available to lend its identity to a later caller.
13
+ */
14
+ export declare const REVIEW_STAMP_MAX_AGE_MS = 120000;
15
+ /**
16
+ * WHO is running a `pnpm wp-write-review` Bash call, as the HARNESS reported it to the PreToolUse hook.
17
+ * Data-only (per CLAUDE.md), and its field names are the JSON keys on disk.
18
+ *
19
+ * WHY a hook stamp and not the environment (issue #863). A Bash-invoked bin sees only its env, and the env
20
+ * cannot answer "is this a subagent?" in either harness:
21
+ * - Claude Code exports `CLAUDE_CODE_SESSION_ID`, which is the PARENT session's id in a subagent too, and
22
+ * no agent id at all — a reviewer and its coordinator have byte-identical identity env.
23
+ * - Codex exports `CODEX_SESSION_ID` / `CODEX_THREAD_ID`, and nothing documents (or has been measured to
24
+ * show) that a spawned subagent gets a thread id of its own rather than inheriting the coordinator's.
25
+ * The hook payload DOES carry the answer, in both harnesses, on the same key: `agent_id` is empty for the
26
+ * main loop and populated for a subagent. So the hook — which sees every Bash call before it runs — writes
27
+ * that identity here for each checklist the command names, and the bin consumes it.
28
+ *
29
+ * NOT tamper-proof, and not claimed to be: an agent can write this file by hand. It moves forging a verdict
30
+ * from "write one JSON file" (what a coordinator did on two merged PRs) to a deliberate, auditable forgery of
31
+ * tooling state outside the AI-writable dir — the same bar SubagentProvenanceService sets.
32
+ */
33
+ export declare class ReviewIdentityStamp {
34
+ checklistId: string;
35
+ aiType: string;
36
+ sessionId: string;
37
+ agentId: string;
38
+ agentType: string;
39
+ cwd: string;
40
+ stampedAt: string;
41
+ constructor(checklistId: string, aiType: string, sessionId: string, agentId: string, agentType: string, cwd: string, stampedAt: string);
42
+ }
43
+ /**
44
+ * Writes (from the PreToolUse hook) and consumes (from `wp-write-review`) {@link ReviewIdentityStamp}s.
45
+ *
46
+ * Both ends resolve the path from their OWN working directory through {@link DotWebpieces.local}, which is
47
+ * keyed by git's answer for that tree — so the hook's payload cwd and the bin's process cwd land on the same
48
+ * file for the same worktree, and two worktrees never share a stamp.
49
+ *
50
+ * `@injectable(bindingScopeValues.Singleton)` so it is injected by type; {@link reviewIdentityStamps} is the
51
+ * instance the dependency-free hook adapter uses.
52
+ */
53
+ export declare class ReviewIdentityStampService {
54
+ private readonly dotDir;
55
+ private readonly atomicFile;
56
+ constructor(dotDir?: DotWebpieces, atomicFile?: AtomicFile);
57
+ /**
58
+ * The checklist ids a Bash command submits verdicts for, or [] when it does not invoke the bin at all.
59
+ * Every `--checklist <id>` / `--checklist=<id>` is returned, because one reviewer covering several
60
+ * checklists may chain several submissions into one command.
61
+ */
62
+ invokedChecklists(command: string): string[];
63
+ /**
64
+ * True when the stamp names the COORDINATING agent rather than a subagent: no agent id, or an agent id
65
+ * equal to the session id (a harness that fills the main loop's agent id with the session id instead of
66
+ * leaving it empty). The coordinator may never submit a reviewer's verdict — that is the whole point.
67
+ */
68
+ isCoordinator(stamp: ReviewIdentityStamp): boolean;
69
+ stampPath(startDir: string, checklistId: string): string;
70
+ /** Best-effort: a stamp that cannot be written makes the bin refuse, which is the safe direction. */
71
+ write(startDir: string, stamp: ReviewIdentityStamp): void;
72
+ /**
73
+ * Read AND delete the stamp for one checklist. null when there is none, it is unreadable, or it is
74
+ * older than {@link REVIEW_STAMP_MAX_AGE_MS}. Deleted on read so one hook call vouches for one
75
+ * submission; a delete that fails (a sandbox that denies it) is harmless, because age bounds it anyway.
76
+ */
77
+ take(startDir: string, checklistId: string, nowMs?: number): ReviewIdentityStamp | null;
78
+ private read;
79
+ private remove;
80
+ }
81
+ /** The instance the hook adapter uses — it runs without a DI container (see hook-core.ts). */
82
+ export declare const reviewIdentityStamps: ReviewIdentityStampService;
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reviewIdentityStamps = exports.ReviewIdentityStampService = exports.ReviewIdentityStamp = exports.REVIEW_STAMP_MAX_AGE_MS = exports.REVIEW_STAMP_DIR = exports.WRITE_REVIEW_CHECKLIST_FLAG = exports.WRITE_REVIEW_BIN = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const inversify_1 = require("inversify");
7
+ const tooling_common_1 = require("@webpieces/tooling-common");
8
+ const tooling_common_2 = require("@webpieces/tooling-common");
9
+ const to_error_1 = require("@webpieces/tooling-common/to-error");
10
+ /** The ONE sanctioned way a reviewer submits a checklist verdict (issue #863). */
11
+ exports.WRITE_REVIEW_BIN = 'wp-write-review';
12
+ /** The flag that names the checklist a `wp-write-review` call submits for. */
13
+ exports.WRITE_REVIEW_CHECKLIST_FLAG = '--checklist';
14
+ /** Where the stamps live, beneath this worktree's tooling-owned state dir (never the AI-writable one). */
15
+ exports.REVIEW_STAMP_DIR = 'review-stamps';
16
+ /**
17
+ * How long a stamp stays usable. The hook writes it a few milliseconds before the harness starts the very
18
+ * Bash command it stamped, so anything older than this is left over from a call that never reached the bin
19
+ * — and must not be available to lend its identity to a later caller.
20
+ */
21
+ exports.REVIEW_STAMP_MAX_AGE_MS = 120_000;
22
+ /**
23
+ * WHO is running a `pnpm wp-write-review` Bash call, as the HARNESS reported it to the PreToolUse hook.
24
+ * Data-only (per CLAUDE.md), and its field names are the JSON keys on disk.
25
+ *
26
+ * WHY a hook stamp and not the environment (issue #863). A Bash-invoked bin sees only its env, and the env
27
+ * cannot answer "is this a subagent?" in either harness:
28
+ * - Claude Code exports `CLAUDE_CODE_SESSION_ID`, which is the PARENT session's id in a subagent too, and
29
+ * no agent id at all — a reviewer and its coordinator have byte-identical identity env.
30
+ * - Codex exports `CODEX_SESSION_ID` / `CODEX_THREAD_ID`, and nothing documents (or has been measured to
31
+ * show) that a spawned subagent gets a thread id of its own rather than inheriting the coordinator's.
32
+ * The hook payload DOES carry the answer, in both harnesses, on the same key: `agent_id` is empty for the
33
+ * main loop and populated for a subagent. So the hook — which sees every Bash call before it runs — writes
34
+ * that identity here for each checklist the command names, and the bin consumes it.
35
+ *
36
+ * NOT tamper-proof, and not claimed to be: an agent can write this file by hand. It moves forging a verdict
37
+ * from "write one JSON file" (what a coordinator did on two merged PRs) to a deliberate, auditable forgery of
38
+ * tooling state outside the AI-writable dir — the same bar SubagentProvenanceService sets.
39
+ */
40
+ class ReviewIdentityStamp {
41
+ checklistId;
42
+ aiType; // 'claude-code' | 'codex' — the adapter's answer, never the model's
43
+ sessionId;
44
+ agentId; // '' ⇒ the MAIN loop, i.e. the coordinating agent
45
+ agentType;
46
+ cwd;
47
+ stampedAt; // ISO
48
+ // eslint-disable-next-line @typescript-eslint/max-params
49
+ constructor(checklistId, aiType, sessionId, agentId, agentType, cwd, stampedAt) {
50
+ this.checklistId = checklistId;
51
+ this.aiType = aiType;
52
+ this.sessionId = sessionId;
53
+ this.agentId = agentId;
54
+ this.agentType = agentType;
55
+ this.cwd = cwd;
56
+ this.stampedAt = stampedAt;
57
+ }
58
+ }
59
+ exports.ReviewIdentityStamp = ReviewIdentityStamp;
60
+ /**
61
+ * Writes (from the PreToolUse hook) and consumes (from `wp-write-review`) {@link ReviewIdentityStamp}s.
62
+ *
63
+ * Both ends resolve the path from their OWN working directory through {@link DotWebpieces.local}, which is
64
+ * keyed by git's answer for that tree — so the hook's payload cwd and the bin's process cwd land on the same
65
+ * file for the same worktree, and two worktrees never share a stamp.
66
+ *
67
+ * `@injectable(bindingScopeValues.Singleton)` so it is injected by type; {@link reviewIdentityStamps} is the
68
+ * instance the dependency-free hook adapter uses.
69
+ */
70
+ let ReviewIdentityStampService = class ReviewIdentityStampService {
71
+ dotDir;
72
+ atomicFile;
73
+ constructor(dotDir = tooling_common_2.dotWebpieces, atomicFile = new tooling_common_1.AtomicFile()) {
74
+ this.dotDir = dotDir;
75
+ this.atomicFile = atomicFile;
76
+ }
77
+ /**
78
+ * The checklist ids a Bash command submits verdicts for, or [] when it does not invoke the bin at all.
79
+ * Every `--checklist <id>` / `--checklist=<id>` is returned, because one reviewer covering several
80
+ * checklists may chain several submissions into one command.
81
+ */
82
+ invokedChecklists(command) {
83
+ if (!new RegExp(`\\b${exports.WRITE_REVIEW_BIN}\\b`).test(command))
84
+ return [];
85
+ const ids = [];
86
+ const flag = /--checklist(?:=|\s+)(["']?)([A-Za-z0-9._-]+)\1/g;
87
+ let match = flag.exec(command);
88
+ while (match !== null) {
89
+ if (!ids.includes(match[2]))
90
+ ids.push(match[2]);
91
+ match = flag.exec(command);
92
+ }
93
+ return ids;
94
+ }
95
+ /**
96
+ * True when the stamp names the COORDINATING agent rather than a subagent: no agent id, or an agent id
97
+ * equal to the session id (a harness that fills the main loop's agent id with the session id instead of
98
+ * leaving it empty). The coordinator may never submit a reviewer's verdict — that is the whole point.
99
+ */
100
+ isCoordinator(stamp) {
101
+ return stamp.agentId.trim() === '' || stamp.agentId === stamp.sessionId;
102
+ }
103
+ stampPath(startDir, checklistId) {
104
+ return this.dotDir.localFile(startDir, exports.REVIEW_STAMP_DIR, `${checklistId.replace(/[^A-Za-z0-9._-]/g, '_')}.json`);
105
+ }
106
+ /** Best-effort: a stamp that cannot be written makes the bin refuse, which is the safe direction. */
107
+ write(startDir, stamp) {
108
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: the hook must never fail a tool call over its own bookkeeping
109
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
110
+ try {
111
+ this.atomicFile.writeJsonAtomic(this.stampPath(startDir, stamp.checklistId), stamp);
112
+ }
113
+ catch (err) {
114
+ const error = (0, to_error_1.toError)(err);
115
+ void error;
116
+ }
117
+ }
118
+ /**
119
+ * Read AND delete the stamp for one checklist. null when there is none, it is unreadable, or it is
120
+ * older than {@link REVIEW_STAMP_MAX_AGE_MS}. Deleted on read so one hook call vouches for one
121
+ * submission; a delete that fails (a sandbox that denies it) is harmless, because age bounds it anyway.
122
+ */
123
+ take(startDir, checklistId, nowMs = Date.now()) {
124
+ const file = this.stampPath(startDir, checklistId);
125
+ const stamp = this.read(file);
126
+ this.remove(file);
127
+ if (stamp === null || stamp.checklistId !== checklistId)
128
+ return null;
129
+ const age = nowMs - Date.parse(stamp.stampedAt);
130
+ if (!Number.isFinite(age) || age < 0 || age > exports.REVIEW_STAMP_MAX_AGE_MS)
131
+ return null;
132
+ return stamp;
133
+ }
134
+ read(file) {
135
+ if (!fs.existsSync(file))
136
+ return null;
137
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: an unreadable stamp is no stamp
138
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
139
+ try {
140
+ // webpieces-disable no-any-unknown -- opaque parsed JSON, narrowed field-by-field below
141
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
142
+ // webpieces-disable no-any-unknown -- one opaque JSON value, narrowed to a string
143
+ const str = (v) => (typeof v === 'string' ? v : '');
144
+ return new ReviewIdentityStamp(str(raw['checklistId']), str(raw['aiType']), str(raw['sessionId']), str(raw['agentId']), str(raw['agentType']), str(raw['cwd']), str(raw['stampedAt']));
145
+ }
146
+ catch (err) {
147
+ const error = (0, to_error_1.toError)(err);
148
+ void error;
149
+ return null;
150
+ }
151
+ }
152
+ remove(file) {
153
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: a stamp that cannot be deleted still expires
154
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
155
+ try {
156
+ fs.rmSync(file, { force: true });
157
+ }
158
+ catch (err) {
159
+ const error = (0, to_error_1.toError)(err);
160
+ void error;
161
+ }
162
+ }
163
+ };
164
+ exports.ReviewIdentityStampService = ReviewIdentityStampService;
165
+ exports.ReviewIdentityStampService = ReviewIdentityStampService = tslib_1.__decorate([
166
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
167
+ tslib_1.__metadata("design:paramtypes", [tooling_common_2.DotWebpieces,
168
+ tooling_common_1.AtomicFile])
169
+ ], ReviewIdentityStampService);
170
+ /** The instance the hook adapter uses — it runs without a DI container (see hook-core.ts). */
171
+ exports.reviewIdentityStamps = new ReviewIdentityStampService();
172
+ //# sourceMappingURL=review-identity-stamp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review-identity-stamp.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/review-identity-stamp.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AtomicFile } from '@webpieces/tooling-common';\nimport { DotWebpieces, dotWebpieces } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\n\n/** The ONE sanctioned way a reviewer submits a checklist verdict (issue #863). */\nexport const WRITE_REVIEW_BIN = 'wp-write-review';\n\n/** The flag that names the checklist a `wp-write-review` call submits for. */\nexport const WRITE_REVIEW_CHECKLIST_FLAG = '--checklist';\n\n/** Where the stamps live, beneath this worktree's tooling-owned state dir (never the AI-writable one). */\nexport const REVIEW_STAMP_DIR = 'review-stamps';\n\n/**\n * How long a stamp stays usable. The hook writes it a few milliseconds before the harness starts the very\n * Bash command it stamped, so anything older than this is left over from a call that never reached the bin\n * — and must not be available to lend its identity to a later caller.\n */\nexport const REVIEW_STAMP_MAX_AGE_MS = 120_000;\n\n/**\n * WHO is running a `pnpm wp-write-review` Bash call, as the HARNESS reported it to the PreToolUse hook.\n * Data-only (per CLAUDE.md), and its field names are the JSON keys on disk.\n *\n * WHY a hook stamp and not the environment (issue #863). A Bash-invoked bin sees only its env, and the env\n * cannot answer \"is this a subagent?\" in either harness:\n * - Claude Code exports `CLAUDE_CODE_SESSION_ID`, which is the PARENT session's id in a subagent too, and\n * no agent id at all — a reviewer and its coordinator have byte-identical identity env.\n * - Codex exports `CODEX_SESSION_ID` / `CODEX_THREAD_ID`, and nothing documents (or has been measured to\n * show) that a spawned subagent gets a thread id of its own rather than inheriting the coordinator's.\n * The hook payload DOES carry the answer, in both harnesses, on the same key: `agent_id` is empty for the\n * main loop and populated for a subagent. So the hook — which sees every Bash call before it runs — writes\n * that identity here for each checklist the command names, and the bin consumes it.\n *\n * NOT tamper-proof, and not claimed to be: an agent can write this file by hand. It moves forging a verdict\n * from \"write one JSON file\" (what a coordinator did on two merged PRs) to a deliberate, auditable forgery of\n * tooling state outside the AI-writable dir — the same bar SubagentProvenanceService sets.\n */\nexport class ReviewIdentityStamp {\n checklistId: string;\n aiType: string; // 'claude-code' | 'codex' — the adapter's answer, never the model's\n sessionId: string;\n agentId: string; // '' ⇒ the MAIN loop, i.e. the coordinating agent\n agentType: string;\n cwd: string;\n stampedAt: string; // ISO\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(checklistId: string, aiType: string, sessionId: string, agentId: string, agentType: string, cwd: string, stampedAt: string) {\n this.checklistId = checklistId;\n this.aiType = aiType;\n this.sessionId = sessionId;\n this.agentId = agentId;\n this.agentType = agentType;\n this.cwd = cwd;\n this.stampedAt = stampedAt;\n }\n}\n\n/**\n * Writes (from the PreToolUse hook) and consumes (from `wp-write-review`) {@link ReviewIdentityStamp}s.\n *\n * Both ends resolve the path from their OWN working directory through {@link DotWebpieces.local}, which is\n * keyed by git's answer for that tree — so the hook's payload cwd and the bin's process cwd land on the same\n * file for the same worktree, and two worktrees never share a stamp.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type; {@link reviewIdentityStamps} is the\n * instance the dependency-free hook adapter uses.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ReviewIdentityStampService {\n constructor(\n private readonly dotDir: DotWebpieces = dotWebpieces,\n private readonly atomicFile: AtomicFile = new AtomicFile(),\n ) {}\n\n /**\n * The checklist ids a Bash command submits verdicts for, or [] when it does not invoke the bin at all.\n * Every `--checklist <id>` / `--checklist=<id>` is returned, because one reviewer covering several\n * checklists may chain several submissions into one command.\n */\n invokedChecklists(command: string): string[] {\n if (!new RegExp(`\\\\b${WRITE_REVIEW_BIN}\\\\b`).test(command)) return [];\n const ids: string[] = [];\n const flag = /--checklist(?:=|\\s+)([\"']?)([A-Za-z0-9._-]+)\\1/g;\n let match = flag.exec(command);\n while (match !== null) {\n if (!ids.includes(match[2])) ids.push(match[2]);\n match = flag.exec(command);\n }\n return ids;\n }\n\n /**\n * True when the stamp names the COORDINATING agent rather than a subagent: no agent id, or an agent id\n * equal to the session id (a harness that fills the main loop's agent id with the session id instead of\n * leaving it empty). The coordinator may never submit a reviewer's verdict — that is the whole point.\n */\n isCoordinator(stamp: ReviewIdentityStamp): boolean {\n return stamp.agentId.trim() === '' || stamp.agentId === stamp.sessionId;\n }\n\n stampPath(startDir: string, checklistId: string): string {\n return this.dotDir.localFile(startDir, REVIEW_STAMP_DIR, `${checklistId.replace(/[^A-Za-z0-9._-]/g, '_')}.json`);\n }\n\n /** Best-effort: a stamp that cannot be written makes the bin refuse, which is the safe direction. */\n write(startDir: string, stamp: ReviewIdentityStamp): void {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the hook must never fail a tool call over its own bookkeeping\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n this.atomicFile.writeJsonAtomic(this.stampPath(startDir, stamp.checklistId), stamp);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n /**\n * Read AND delete the stamp for one checklist. null when there is none, it is unreadable, or it is\n * older than {@link REVIEW_STAMP_MAX_AGE_MS}. Deleted on read so one hook call vouches for one\n * submission; a delete that fails (a sandbox that denies it) is harmless, because age bounds it anyway.\n */\n take(startDir: string, checklistId: string, nowMs: number = Date.now()): ReviewIdentityStamp | null {\n const file = this.stampPath(startDir, checklistId);\n const stamp = this.read(file);\n this.remove(file);\n if (stamp === null || stamp.checklistId !== checklistId) return null;\n const age = nowMs - Date.parse(stamp.stampedAt);\n if (!Number.isFinite(age) || age < 0 || age > REVIEW_STAMP_MAX_AGE_MS) return null;\n return stamp;\n }\n\n private read(file: string): ReviewIdentityStamp | null {\n if (!fs.existsSync(file)) return null;\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: an unreadable stamp is no stamp\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // webpieces-disable no-any-unknown -- opaque parsed JSON, narrowed field-by-field below\n const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as Record<string, unknown>;\n // webpieces-disable no-any-unknown -- one opaque JSON value, narrowed to a string\n const str = (v: unknown): string => (typeof v === 'string' ? v : '');\n return new ReviewIdentityStamp(\n str(raw['checklistId']), str(raw['aiType']), str(raw['sessionId']), str(raw['agentId']),\n str(raw['agentType']), str(raw['cwd']), str(raw['stampedAt']));\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n private remove(file: string): void {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: a stamp that cannot be deleted still expires\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.rmSync(file, { force: true });\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n/** The instance the hook adapter uses — it runs without a DI container (see hook-core.ts). */\nexport const reviewIdentityStamps = new ReviewIdentityStampService();\n"]}
@@ -0,0 +1,149 @@
1
+ import { AgentWorktreeLock, AgentWorktreeLockReader } from './agent-worktree-lock';
2
+ import { AgentActivity, HarnessAgentActivityReader } from './harness-agent-activity';
3
+ import { DeletableWorktree } from './merged-branch-verdicts';
4
+ import { Worktree, WorktreeService } from './worktrees';
5
+ /**
6
+ * What a `git worktree lock` DOES to a worktree's cleanup verdict.
7
+ *
8
+ * Separate from agent-worktree-lock.ts (which only parses a lock reason) and from
9
+ * harness-agent-activity.ts (which only asks the harness about an agent): this is the POLICY built on
10
+ * top of that evidence — what stops a reap, what a spared lock is allowed to CLAIM about who took it,
11
+ * and what an overridden one has to leave on the verdict so the reaper knows to clear it.
12
+ *
13
+ * ─── WHY THE POLICY CHANGED ──────────────────────────────────────────────────────────────────────
14
+ * It used to be one question, asked of the kernel: is the pid in the lock reason still running? That
15
+ * pid is the Claude Code SESSION process, shared by every subagent, so the answer was yes for the
16
+ * whole life of a session and every agent worktree was spared forever — nine against a cap of five,
17
+ * with one agent running, each carrying the sentence "that agent is working in here" about agents
18
+ * whose PRs had already merged. Both halves were defects: the reap never happened, and the message
19
+ * asserted something nobody knew.
20
+ *
21
+ * ─── THE DECISION TABLE, AND THE ONE RULE BEHIND IT ──────────────────────────────────────────────
22
+ *
23
+ * HARNESS STATE MAY ONLY VETO A REAP. IT MAY NEVER LICENSE ONE.
24
+ *
25
+ * The licence comes solely from the branch/commit evidence wp-cleanup already computes and already
26
+ * gets right. So, for a lock this repo can read as the harness's own:
27
+ *
28
+ * branch evidence does NOT say deletable → SPARE. The harness is not even consulted; it
29
+ * cannot promote a spare into a reap.
30
+ * deletable, but the worktree is DIRTY → SPARE, unconditionally. Uncommitted or
31
+ * untracked files are work no archive tag can
32
+ * bring back. This one overrides everything,
33
+ * including the explicit flag.
34
+ * deletable, clean, harness says the agent is
35
+ * mid-tool-call and writing → SPARE. The veto.
36
+ * deletable, clean, harness says returned /
37
+ * long-stale / cannot tell → judge it on its branch like any unlocked tree,
38
+ * clearing the lock on the way out.
39
+ *
40
+ * `--ignore-stale-locks` moves ONE line of that table: it lets a locked worktree whose branch is not
41
+ * provably dead be classified on its real branch and commit state anyway (so a zero-commit husk gets
42
+ * reaped and a worktree holding unique commits reaches the numbered block a human can answer). It
43
+ * does not touch the dirty rail or the live-agent veto, because neither of those is what the caller
44
+ * is complaining about when they pass it.
45
+ *
46
+ * ─── SCOPE: AGENT LOCKS ONLY ─────────────────────────────────────────────────────────────────────
47
+ * Every override applies ONLY to a lock this file can read as the harness's own. A lock reason naming
48
+ * anything else — a human's "dean is debugging", or nothing at all — is still an instruction from
49
+ * somebody, and wp-cleanup still obeys it.
50
+ */
51
+ /**
52
+ * The exact phrase a spared "we cannot tell" verdict carries, exported so wp-cleanup can spot those
53
+ * worktrees and offer the flag that judges them on their branch instead.
54
+ *
55
+ * ONE spelling, shared: the alternative is the caller re-deriving the same condition from a second
56
+ * copy of the sentence, which is how a message and the code that keys off it drift apart.
57
+ */
58
+ export declare const LOCK_LIVENESS_UNVERIFIABLE = "whether that agent is still running cannot be verified";
59
+ /**
60
+ * The stand-in for "the harness was never asked", used on the paths where asking could not change the
61
+ * answer. It is UNKNOWN, so a caller that consults it anyway still fails safe.
62
+ */
63
+ export declare const HARNESS_NOT_CONSULTED: AgentActivity;
64
+ export declare class LockEvidence {
65
+ /** The lock reason verbatim, straight out of `git worktree list --porcelain`. */
66
+ lockReason: string;
67
+ /** What the harness says about the agent named in that reason, or HARNESS_NOT_CONSULTED. */
68
+ activity: AgentActivity;
69
+ /** Does `git status --porcelain` report anything, or could it not answer? Either way: held. */
70
+ workInFlight: boolean;
71
+ /** Why it is held, in git's terms — printed verbatim, because the two reasons differ for a human. */
72
+ workInFlightReason: string;
73
+ /** Is the branch this worktree holds ALREADY provably dead by the ordinary verdicts? */
74
+ branchDeletable: boolean;
75
+ /** The caller said to treat a standing agent lock as no evidence at all. */
76
+ ignoreStaleAgentLocks: boolean;
77
+ constructor(lockReason: string, activity: AgentActivity, workInFlight: boolean, workInFlightReason: string, branchDeletable: boolean, ignoreStaleAgentLocks: boolean);
78
+ }
79
+ /**
80
+ * Data-only. The one verdict this file produces: spare or override, the sentence that says WHY, and
81
+ * the agent lock the reaper will have to clear if the worktree goes.
82
+ *
83
+ * ONE class rather than a spare-path method and an override-path method, because the two answers are
84
+ * mutually exclusive readings of the same evidence, and two methods is how they drift apart — which
85
+ * is precisely what happened before: the old spared message and the old stale-lock test were computed
86
+ * from the same pid by different code, and only one of them was ever corrected.
87
+ */
88
+ export declare class LockDecision {
89
+ /** True: leave this worktree alone, and `reason` is the whole story. */
90
+ spare: boolean;
91
+ /** Spared: why it was left. Overridden: the clause appended to the branch's own verdict. */
92
+ reason: string;
93
+ /** The agent lock to clear on the way out — null when nothing may be cleared. */
94
+ lock: AgentWorktreeLock | null;
95
+ constructor(spare: boolean, reason: string, lock: AgentWorktreeLock | null);
96
+ }
97
+ export declare class WorktreeLockVerdicts {
98
+ private readonly agentLocks;
99
+ private readonly harness;
100
+ private readonly worktrees;
101
+ constructor(agentLocks?: AgentWorktreeLockReader, harness?: HarnessAgentActivityReader, worktrees?: WorktreeService);
102
+ /**
103
+ * Collect what `decide` rules on — and, just as deliberately, DECLINE to collect the two
104
+ * expensive facts when they cannot change the answer.
105
+ *
106
+ * `git status --porcelain` and a walk of the harness's state tree are gathered only when a reap
107
+ * is otherwise on the table: the lock is one this repo can read as the harness's own, and either
108
+ * the branch is already provably dead or the caller asked for locks to be ignored. That is not an
109
+ * optimisation dressed up as a rule — harness state may only VETO a reap, never license one, so on
110
+ * a path with no reap to veto there is nothing to ask, and asking would invite a later reader to
111
+ * let the answer decide something.
112
+ */
113
+ gather(tree: Worktree, branchDeletable: boolean, ignoreStaleAgentLocks: boolean): LockEvidence;
114
+ /**
115
+ * Spare this locked worktree, or judge it on its branch like any unlocked one — with the sentence
116
+ * that says which piece of evidence decided it.
117
+ *
118
+ * The order below IS the decision table in the class header, in the same order, for the same
119
+ * reasons. Read it there.
120
+ */
121
+ decide(evidence: LockEvidence): LockDecision;
122
+ /**
123
+ * WHY a locked worktree was left alone when the lock is NOT the harness's — reporting what the
124
+ * reason SAYS, never who we imagine wrote it.
125
+ *
126
+ * This used to read `locked by a human — do not touch` for every lock in existence, which named an
127
+ * actor nothing in the evidence identifies: the lock reason is the ONLY thing we have, a human may
128
+ * have locked it, so may some other tool, and asserting either is the same defect whichever way it
129
+ * lands.
130
+ */
131
+ private foreignLockReason;
132
+ /**
133
+ * The sentence that replaced "pid N still running — that agent is working in here".
134
+ *
135
+ * It says only what is known: which agent the reason names, that the pid in it cannot answer the
136
+ * question because it is the shared session process, and that nothing else made the worktree
137
+ * provably dead either. No claim about anybody working — and no claim about the harness, which on
138
+ * this path was deliberately never asked, because its answer could not have changed the outcome.
139
+ */
140
+ private unverifiableReason;
141
+ /**
142
+ * Stamp an overridden verdict with the lock the reap must CLEAR: git refuses to remove a locked
143
+ * worktree, and the reason has to say why a lock is being overridden at all.
144
+ *
145
+ * Applied to every verdict the fall-through can produce, not just the deletable one, because the
146
+ * spared cases are exactly where a human reads the reason and asks "wasn't that locked?".
147
+ */
148
+ annotate(verdict: DeletableWorktree, decision: LockDecision | null): DeletableWorktree;
149
+ }