@lyupro/codex-bridge 0.6.9 → 0.6.11

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 (88) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +15 -4
  3. package/cli/dispatcher-model-check.mjs +59 -0
  4. package/cli/doctor-host-side.mjs +2 -1
  5. package/cli/doctor-installation.mjs +111 -111
  6. package/cli/doctor.mjs +13 -5
  7. package/cli/handback-witness-check.mjs +24 -18
  8. package/cli/hosts.mjs +2 -10
  9. package/cli/install-owners.mjs +1 -4
  10. package/cli/install-record.mjs +2 -1
  11. package/cli/inventory-transition.mjs +1 -1
  12. package/cli/lifecycle-lock-inspect.mjs +3 -2
  13. package/cli/lifecycle-lock.mjs +19 -132
  14. package/cli/manifest.mjs +6 -6
  15. package/cli/read.mjs +20 -0
  16. package/cli/settings-merge.mjs +3 -29
  17. package/package.json +2 -2
  18. package/scripts/home-write-inventory.mjs +12 -2
  19. package/src/agents/codex-advisor.md +37 -121
  20. package/src/agents/codex-build.md +27 -135
  21. package/src/agents/codex-review.md +19 -111
  22. package/src/agents/codex-scout.md +21 -130
  23. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  24. package/src/home/hooks/live-runs.mjs +0 -41
  25. package/src/home/hooks/order-gate.mjs +80 -47
  26. package/src/home/hooks/reply-guard.mjs +52 -65
  27. package/src/home/hooks/reply-identity.mjs +64 -0
  28. package/src/home/hooks/reply-parser.mjs +1 -1
  29. package/src/home/hooks/reply-verdicts.mjs +31 -27
  30. package/src/home/hooks/reply-witness.mjs +48 -0
  31. package/src/home/lib/claude-layout.mjs +14 -0
  32. package/src/home/lib/command-path.mjs +32 -0
  33. package/src/home/lib/dispatcher-call.mjs +171 -0
  34. package/src/home/lib/dispatcher-command.mjs +18 -54
  35. package/src/home/lib/dispatcher-gate.mjs +4 -0
  36. package/src/home/lib/dispatcher-model.mjs +112 -0
  37. package/src/home/lib/dispatcher-order.mjs +21 -22
  38. package/src/home/lib/dispatcher-pin.mjs +60 -0
  39. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  40. package/src/home/lib/dispatcher-stop.mjs +11 -2
  41. package/src/home/lib/handback-witness.mjs +137 -48
  42. package/src/home/lib/home-registry.mjs +7 -0
  43. package/src/home/lib/install-owner-roots.mjs +53 -0
  44. package/src/home/lib/kernel-lock.mjs +171 -0
  45. package/src/home/lib/meta/chain.mjs +19 -8
  46. package/src/home/lib/meta/context-only.mjs +43 -0
  47. package/src/home/lib/meta/events.mjs +14 -2
  48. package/src/home/lib/meta/launch-rows.mjs +23 -0
  49. package/src/home/lib/meta/paths.mjs +1 -1
  50. package/src/home/lib/meta/reply.mjs +39 -4
  51. package/src/home/lib/meta/run-state.mjs +3 -0
  52. package/src/home/lib/meta/verdict.mjs +16 -8
  53. package/src/home/lib/model-family.mjs +38 -0
  54. package/src/home/lib/observation-ledger.mjs +145 -0
  55. package/src/home/lib/order-schema.mjs +204 -0
  56. package/src/home/lib/order-values.mjs +46 -0
  57. package/src/home/lib/run-codex.mjs +6 -3
  58. package/src/home/lib/runner/advise-carry.mjs +2 -1
  59. package/src/home/lib/runner/args.mjs +140 -220
  60. package/src/home/lib/runner/attach.mjs +24 -17
  61. package/src/home/lib/runner/build-evidence.mjs +29 -0
  62. package/src/home/lib/runner/codex-availability.mjs +50 -0
  63. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  64. package/src/home/lib/runner/continuation.mjs +174 -140
  65. package/src/home/lib/runner/exit-codes.mjs +13 -0
  66. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  67. package/src/home/lib/runner/flag-scan.mjs +165 -0
  68. package/src/home/lib/runner/git-state.mjs +0 -33
  69. package/src/home/lib/runner/launcher.mjs +237 -277
  70. package/src/home/lib/runner/order-claim.mjs +84 -0
  71. package/src/home/lib/runner/order-options.mjs +30 -0
  72. package/src/home/lib/runner/order-owner.mjs +19 -0
  73. package/src/home/lib/runner/pass-gate.mjs +111 -0
  74. package/src/home/lib/runner/preflight.mjs +26 -28
  75. package/src/home/lib/runner/prompts.mjs +7 -2
  76. package/src/home/lib/runner/question-kind.mjs +41 -0
  77. package/src/home/lib/runner/run-env.mjs +2 -1
  78. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  79. package/src/home/lib/runner/scope-check.mjs +31 -12
  80. package/src/home/lib/runner/task-input.mjs +34 -12
  81. package/src/home/lib/runner/worker-admission.mjs +43 -0
  82. package/src/home/lib/runner/worker.mjs +19 -18
  83. package/src/home/lib/subagent-model-env.mjs +44 -0
  84. package/src/home/lib/task-header.mjs +158 -0
  85. package/src/home/lib/untyped-stop.mjs +30 -0
  86. package/src/home/lib/write-meta.mjs +17 -5
  87. package/src/home/lib/required-inputs.mjs +0 -261
  88. /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
@@ -1,140 +1,174 @@
1
- /**
2
- * Owns the zero-quota decision about whether a requested continuation was ordered.
3
- *
4
- * The 2026-08-05 incident showed why the runner must not infer a second pass from a verdict:
5
- * only the orchestrator read that verdict and can name the run and reason it is willing to fund.
6
- */
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
10
- import { readJson } from '../write-meta.mjs';
11
-
12
- // One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
13
- // disagree about whether two spellings name the same folder.
14
- export const sameRun = (runsRootPath, left, right) => {
15
- const a = path.resolve(runsRootPath, left);
16
- const b = path.resolve(runsRootPath, right);
17
- return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
18
- };
19
-
20
- function namedRunDirectory(runsRootPath, run) {
21
- if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
22
- const directory = path.join(runsRootPath, run);
23
- try {
24
- return fs.statSync(directory).isDirectory() ? directory : null;
25
- } catch {
26
- return null;
27
- }
28
- }
29
-
30
- function normalizeGrant(grant) {
31
- if (typeof grant === 'string') return parseContinuationGrant(grant);
32
- if (!grant || typeof grant !== 'object') return null;
33
- const run = String(grant.run ?? '').trim();
34
- const reason = String(grant.reason ?? '').trim();
35
- return run && reason ? { run, reason } : null;
36
- }
37
-
38
- const grantExample = `Example: \`continue: ${CONTINUATION_INPUT.example}\`.`;
39
- const grantAction =
40
- 'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
41
-
42
- // The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that a refusal must
43
- // carry the repair line; otherwise a typo forces an unnecessary directory listing and retry.
44
- function lastRunOutcome(runsRootPath, chain) {
45
- const run = chain[chain.length - 1];
46
- if (!run) return null;
47
- const status = readJson(path.join(runsRootPath, run, 'status.json')) || {};
48
- const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
49
- const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
50
- const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
51
- return { run, status: outcomeStatus, reason: outcomeReason };
52
- }
53
-
54
- function lastRunHints(runsRootPath, chain, grantReason) {
55
- const last = lastRunOutcome(runsRootPath, chain);
56
- if (!last) {
57
- return 'Last run: none. Outcome: none. Ready grant line: none (there is no run to continue).';
58
- }
59
- const reason = String(grantReason ?? last.reason).trim() || last.reason;
60
- return `Last run: ${last.run}. Outcome: ${last.status} — ${last.reason}. ` +
61
- `Ready grant line: continue: ${last.run} — ${reason}.`;
62
- }
63
-
64
- /**
65
- * Applies the one-continuation limit and all continuation safety gates before a folder is created.
66
- *
67
- * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
68
- * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
69
- * repeat that renamed itself — but counting continuations that way makes the escape hatch
70
- * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
71
- * id lands in the same chain through the task hash. A task would then be refused with --continue
72
- * for having spent its continuation and refused without it for already having runs — permanently
73
- * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
74
- */
75
- export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
76
- const continuation = normalizeGrant(grant);
77
- if (!isContinue) {
78
- if (!continuation) return null;
79
- return (
80
- '--continue is required when the task text contains a `continue:` grant. ' +
81
- `${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
82
- 'The submitted grant was not rewritten. The run folder was not created; quota was not spent.'
83
- );
84
- }
85
-
86
- if (!continuation) {
87
- return (
88
- '--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
89
- `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
90
- );
91
- }
92
-
93
- const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
94
- if (!namedDirectory) {
95
- return (
96
- `--continue is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
97
- `exist as a run folder in this project's runs directory ${runsRootPath}. ` +
98
- `${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
99
- `${grantExample} ${grantAction} The submitted grant was not rewritten. ` +
100
- 'The run folder was not created; quota was not spent.'
101
- );
102
- }
103
-
104
- const last = chain[chain.length - 1];
105
- // Continuing the last run appends a later run, so the old grant stops matching by itself; this
106
- // incident needs no counter or new state to make an orchestrator grant single-use.
107
- if (!last || !sameRun(runsRootPath, continuation.run, last)) {
108
- return (
109
- `--continue is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
110
- `the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
111
- 'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
112
- `state is used. ${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
113
- );
114
- }
115
-
116
- const wanted = String(orderId ?? '').trim();
117
- const ofThisOrder = chain.filter(
118
- (run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
119
- );
120
- if (ofThisOrder.length === 0) return null;
121
- if (ofThisOrder.length > 1) {
122
- const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
123
- return (
124
- `--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
125
- 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
126
- 'created; quota was not spent.'
127
- );
128
- }
129
- const previous = path.join(runsRootPath, ofThisOrder[0]);
130
- const status = readJson(path.join(previous, 'status.json'));
131
- const meta = readJson(path.join(previous, 'meta.json'));
132
- if (!meta?.status || status?.state === 'running') {
133
- return (
134
- `--continue is refused: previous run ${previous} has no finished verdict and may still ` +
135
- 'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
136
- 'not created; quota was not spent.'
137
- );
138
- }
139
- return null;
140
- }
1
+ /**
2
+ * Owns the zero-quota decision about whether a requested continuation was ordered.
3
+ *
4
+ * The 2026-08-05 incident showed why the runner must not infer a second pass from a verdict:
5
+ * only the orchestrator read that verdict and can name the run and reason it is willing to fund.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { CONTINUATION_ORDER_INPUT, orderInputName } from '../order-schema.mjs';
10
+ import { readJson } from '../write-meta.mjs';
11
+ import { workerMayBeAlive } from '../meta/run-liveness.mjs';
12
+
13
+ // One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
14
+ // disagree about whether two spellings name the same folder.
15
+ export const sameRun = (runsRootPath, left, right) => {
16
+ const a = path.resolve(runsRootPath, left);
17
+ const b = path.resolve(runsRootPath, right);
18
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
19
+ };
20
+
21
+ function namedRunDirectory(runsRootPath, run) {
22
+ if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
23
+ const directory = path.join(runsRootPath, run);
24
+ try {
25
+ return fs.statSync(directory).isDirectory() ? directory : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function normalizeGrant(grant) {
32
+ if (!grant || typeof grant !== 'object') return null;
33
+ const run = String(grant.run ?? '').trim();
34
+ const reason = String(grant.reason ?? '').trim();
35
+ return run && reason ? { run, reason } : null;
36
+ }
37
+
38
+ const grantExample = `Example: \`continue: ${CONTINUATION_ORDER_INPUT.example}\`.`;
39
+ const grantAction =
40
+ 'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
41
+
42
+ // The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that a refusal must
43
+ // carry the repair line; otherwise a typo forces an unnecessary directory listing and retry.
44
+ function lastRunOutcome(runsRootPath, chain) {
45
+ const run = chain[chain.length - 1];
46
+ if (!run) return null;
47
+ const status = readJson(path.join(runsRootPath, run, 'status.json')) || {};
48
+ const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
49
+ const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
50
+ const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
51
+ return { run, status: outcomeStatus, reason: outcomeReason, verdict: meta.status, orderId: status.order_id };
52
+ }
53
+
54
+ export function readyGrantLines(runsRootPath, chain, grantReason) {
55
+ const last = lastRunOutcome(runsRootPath, chain);
56
+ if (!last) return 'Ready grant line: none (there is no run to continue).';
57
+ const reason = String(grantReason ?? last.reason).trim() || last.reason;
58
+ const continuation = `Ready grant line: continue: ${last.run} — ${reason}.`;
59
+ // Plan_75 D1: the TradeForge failed advise spent its continuation, so show the same-pass repair.
60
+ // A run without order_id cannot be retried (retryRefusal step d), so it gets no retry line.
61
+ const orderId = String(last.orderId ?? '').trim();
62
+ if (!last.verdict || last.verdict === 'OK' || !orderId) return continuation;
63
+ return continuation +
64
+ ` Ready retry line: retry: ${last.run} — ${last.reason} (repeats that pass under order ${orderId}).`;
65
+ }
66
+
67
+ function lastRunHints(runsRootPath, chain, grantReason) {
68
+ const last = lastRunOutcome(runsRootPath, chain);
69
+ if (!last) {
70
+ return 'Last run: none. Outcome: none. Ready grant line: none (there is no run to continue).';
71
+ }
72
+ return `Last run: ${last.run}. Outcome: ${last.status} — ${last.reason}. ` +
73
+ readyGrantLines(runsRootPath, chain, grantReason);
74
+ }
75
+
76
+ /**
77
+ * Applies the one-continuation limit and all continuation safety gates before a folder is created.
78
+ *
79
+ * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
80
+ * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
81
+ * repeat that renamed itself — but counting continuations that way makes the escape hatch
82
+ * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
83
+ * id lands in the same chain through the task hash. A task would then be refused with a grant
84
+ * for having spent its continuation and refused without one for already having runs — permanently
85
+ * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
86
+ */
87
+ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
88
+ const continuation = normalizeGrant(grant);
89
+ // Plan_63 D9: only header grant objects authorize a pass; a string is no grant.
90
+ if (!isContinue || !continuation) return null;
91
+
92
+ const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
93
+ if (!namedDirectory) {
94
+ return (
95
+ `${orderInputName('continue')} is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
96
+ `exist as a run folder in this project's runs directory ${runsRootPath}. ` +
97
+ `${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
98
+ `${grantExample} ${grantAction} The submitted grant was not rewritten. ` +
99
+ 'The run folder was not created; quota was not spent.'
100
+ );
101
+ }
102
+
103
+ const last = chain[chain.length - 1];
104
+ // Continuing the last run appends a later run, so the old grant stops matching by itself; this
105
+ // incident needs no counter or new state to make an orchestrator grant single-use.
106
+ if (!last || !sameRun(runsRootPath, continuation.run, last)) {
107
+ return (
108
+ `${orderInputName('continue')} is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
109
+ `the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
110
+ 'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
111
+ `state is used. ${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
112
+ `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
113
+ );
114
+ }
115
+
116
+ const wanted = String(orderId ?? '').trim();
117
+ // Plan_75 D1, TradeForge capacity incident: a retry spends no new pass, even after an OK verdict.
118
+ const ofThisOrder = chain.filter((run) => {
119
+ const status = readJson(path.join(runsRootPath, run, 'status.json'));
120
+ return String(status?.order_id ?? '').trim() === wanted && !String(status?.retry_of ?? '').trim();
121
+ });
122
+ if (ofThisOrder.length === 0) return null;
123
+ if (ofThisOrder.length > 1) {
124
+ const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
125
+ return (
126
+ `${orderInputName('continue')} is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
127
+ `${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
128
+ 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
129
+ 'created; quota was not spent.'
130
+ );
131
+ }
132
+ const previous = path.join(runsRootPath, ofThisOrder[0]);
133
+ const status = readJson(path.join(previous, 'status.json'));
134
+ const meta = readJson(path.join(previous, 'meta.json'));
135
+ if (!meta?.status || status?.state === 'running') {
136
+ return (
137
+ `${orderInputName('continue')} is refused: previous run ${previous} has no finished verdict and may still ` +
138
+ `be editing the worktree. Repeat without ${orderInputName('continue')} to attach to it. ` +
139
+ `${lastRunHints(runsRootPath, chain, continuation.reason)} The run folder was ` +
140
+ 'not created; quota was not spent.'
141
+ );
142
+ }
143
+ return null;
144
+ }
145
+
146
+ export function retryRefusal(runsRootPath, chain, isContinue, orderId, grant, liveness = workerMayBeAlive) {
147
+ const refuse = (message) => `${message} The run folder was not created; quota was not spent.`;
148
+ const runDir = namedRunDirectory(runsRootPath, grant?.run);
149
+ if (!runDir) {
150
+ return refuse(`Retry is refused: ${grant?.run || 'none'} is not a bare existing run folder in ${runsRootPath}.`);
151
+ }
152
+ const last = chain[chain.length - 1];
153
+ // Plan_75 D1: appending the TradeForge retry makes this grant single-use without a new counter.
154
+ if (!last || !sameRun(runsRootPath, grant.run, last)) {
155
+ return refuse(`Retry is refused: grant ${grant.run} is not the LAST run of this task's chain; a retry is single-use.`);
156
+ }
157
+ const status = readJson(path.join(runDir, 'status.json'));
158
+ const wanted = String(orderId ?? '').trim();
159
+ const previousOrder = String(status?.order_id ?? '').trim();
160
+ if (!previousOrder || previousOrder !== wanted) {
161
+ return refuse(`Retry is refused: run ${grant.run} order_id ${previousOrder || '(missing)'} differs from order ${wanted}. ` +
162
+ 'A retry repeats its own order; a run without order_id predates orders — start a new order.');
163
+ }
164
+ const meta = readJson(path.join(runDir, 'meta.json'));
165
+ // Plan_75 D1: a verdict alone must not let a retry overlap the failed advise's live writer.
166
+ if (!meta?.status || liveness({ runDir, status })) {
167
+ return refuse(`Retry is refused: run ${grant.run} has no finished verdict or its worker may still be alive and writing. ` +
168
+ `Repeat without ${orderInputName('retry')} to attach to it.`);
169
+ }
170
+ if (meta.status === 'OK') {
171
+ return refuse(`Retry is refused: run ${grant.run} ended OK. A retry repeats a failed pass; use continue: for the next pass.`);
172
+ }
173
+ return null;
174
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Owns every exit code the runner returns.
3
+ * Plan_60 D2a: a new status took code 4, already the released --no-wait outcome, because the codes
4
+ * were literals scattered over six modules.
5
+ */
6
+ export const EXIT = Object.freeze({
7
+ OK: 0, // the run's artifacts confirm the contract
8
+ FAIL: 1, // the run failed, or a pre-flight refusal: the order was right, the host or tree was not
9
+ USAGE: 2, // argument error: the order itself must be rewritten
10
+ LIMIT: 3, // ChatGPT quota exhausted; not a task failure
11
+ PENDING: 4, // --no-wait only: the run is still in progress, or no run exists (a call outcome, not a status)
12
+ UNAVAILABLE: 5, // Codex missing or signed out; hand the task to the next executor
13
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Records dirty files' start bytes so Plan_60 D3a can distinguish this run's flags from earlier uncommitted work.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { headSha, runsPrefixInside } from './git-state.mjs';
7
+ import { diffNames, listUntrackedPaths } from './git-paths.mjs';
8
+ import { readJsonFileSync } from '../json-file.mjs';
9
+
10
+ export const FLAG_BASELINE_VERSION = 1;
11
+ export const FLAG_BASELINE_MANIFEST = 'flags-baseline.json';
12
+ export const FLAG_BASELINE_DIR = 'flags-baseline';
13
+ export const FLAG_BASELINE_LIMITS = Object.freeze({
14
+ perFileBytes: 1024 * 1024,
15
+ perRunBytes: 32 * 1024 * 1024,
16
+ });
17
+
18
+ export function captureFlagBaseline({ runDir, repoRoot, isGitRepo, limits = FLAG_BASELINE_LIMITS }) {
19
+ const manifest = {
20
+ version: FLAG_BASELINE_VERSION,
21
+ head: '',
22
+ complete: false,
23
+ reason: '',
24
+ limits: { ...limits },
25
+ files: [],
26
+ };
27
+ if (!isGitRepo) {
28
+ manifest.reason = 'not a git repository';
29
+ } else {
30
+ const head = headSha(repoRoot);
31
+ // rev-parse prints the literal HEAD on an unborn branch; it is not a start commit.
32
+ manifest.head = head === 'HEAD' ? '' : head;
33
+ if (!manifest.head) {
34
+ manifest.reason = 'no start commit';
35
+ } else {
36
+ const dirty = diffNames(repoRoot, 'HEAD');
37
+ const untracked = listUntrackedPaths(repoRoot);
38
+ if (dirty === null || untracked === null) {
39
+ manifest.reason = 'git listing failed';
40
+ } else {
41
+ const tracked = new Set(dirty);
42
+ const skip = runsPrefixInside(repoRoot);
43
+ const names = [...new Set([...dirty, ...untracked])]
44
+ .filter((name) => !(skip && `${name}/`.startsWith(skip)))
45
+ .sort();
46
+ let total = 0;
47
+ for (const [index, name] of names.entries()) {
48
+ const entry = { path: name, tracked: tracked.has(name), state: 'unreadable' };
49
+ manifest.files.push(entry);
50
+ const full = path.join(repoRoot, name);
51
+ let content;
52
+ try {
53
+ const stat = fs.lstatSync(full);
54
+ if (!stat.isFile()) continue;
55
+ if (stat.size > limits.perFileBytes) {
56
+ entry.state = 'truncated';
57
+ continue;
58
+ }
59
+ if (total + stat.size > limits.perRunBytes) {
60
+ entry.state = 'over-run-cap';
61
+ continue;
62
+ }
63
+ content = fs.readFileSync(full);
64
+ } catch (error) {
65
+ if (error.code === 'ENOENT') entry.state = entry.tracked ? 'deleted' : 'absent';
66
+ continue;
67
+ }
68
+ // Recheck actual bytes: the file may have grown since its metadata was read.
69
+ if (content.length > limits.perFileBytes) {
70
+ entry.state = 'truncated';
71
+ } else if (total + content.length > limits.perRunBytes) {
72
+ entry.state = 'over-run-cap';
73
+ } else if (content.subarray(0, 8000).includes(0)) {
74
+ entry.state = 'binary';
75
+ } else {
76
+ const copyDir = path.join(runDir, FLAG_BASELINE_DIR);
77
+ fs.mkdirSync(copyDir, { recursive: true });
78
+ const copy = `${index}.bin`;
79
+ fs.writeFileSync(path.join(copyDir, copy), content);
80
+ Object.assign(entry, { state: 'copied', copy, bytes: content.length });
81
+ total += content.length;
82
+ }
83
+ }
84
+ manifest.complete = manifest.files.every((entry) =>
85
+ ['copied', 'deleted', 'absent'].includes(entry.state));
86
+ if (!manifest.complete) manifest.reason = 'some start contents are unknown';
87
+ }
88
+ }
89
+ }
90
+ fs.writeFileSync(path.join(runDir, FLAG_BASELINE_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
91
+ return manifest;
92
+ }
93
+
94
+ export function readFlagBaseline(runDir) {
95
+ try {
96
+ const manifest = readJsonFileSync(path.join(runDir, FLAG_BASELINE_MANIFEST));
97
+ return manifest?.version === FLAG_BASELINE_VERSION ? manifest : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export function removeFlagBaselineCopies(runDir) {
104
+ fs.rmSync(path.join(runDir, FLAG_BASELINE_DIR), { recursive: true, force: true });
105
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Judges added lines against start bytes because Plan_60 D3a must not blame earlier dirty work or TODO test data.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { readFlagBaseline, removeFlagBaselineCopies, FLAG_BASELINE_DIR } from './flag-baseline.mjs';
9
+ import { runSnapshotChanges } from '../meta/run-snapshots.mjs';
10
+
11
+ const STUB = /test\.(skip|only)|it\.(skip|only)|describe\.(skip|only)|NotImplemented/;
12
+ const MARKER_PREFIX = /(?:^[ \t]*|\/\/[ \t]*|\/\*+[ \t]*|#[ \t]*|<!--[ \t]*|--[ \t]*|^[ \t]*\*[ \t]*)$/;
13
+ const LIST_PREFIX = /^[ \t]*(?:[-+*]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?$/;
14
+
15
+ export function isFlagLine(text) {
16
+ if (STUB.test(text)) return true;
17
+ for (const match of text.matchAll(/(?:TODO|FIXME)(?![A-Za-z0-9_])/g)) {
18
+ const prefix = text.slice(0, match.index);
19
+ if (MARKER_PREFIX.test(prefix) || LIST_PREFIX.test(prefix)) return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ function addedLines(diff) {
25
+ const added = [];
26
+ let inside = false;
27
+ let number = 0;
28
+ for (const line of diff.split('\n')) {
29
+ if (line.startsWith('diff ')) {
30
+ inside = false;
31
+ continue;
32
+ }
33
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
34
+ if (hunk) {
35
+ inside = true;
36
+ number = Number(hunk[1]);
37
+ } else if (inside && line.startsWith('+')) {
38
+ added.push({ number, text: line.slice(1) });
39
+ number += 1;
40
+ } else if (inside && line.startsWith(' ')) {
41
+ number += 1;
42
+ }
43
+ }
44
+ return added;
45
+ }
46
+
47
+ function compareFile(baseFile, currentFile) {
48
+ const result = spawnSync('git', [
49
+ '-c', 'core.quotepath=false', 'diff', '--no-index', '--no-color', '--no-ext-diff', '--no-textconv',
50
+ '--ignore-cr-at-eol', '--diff-algorithm=myers', '-U0', '--', baseFile, currentFile,
51
+ ], { windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 });
52
+ if (result.error || ![0, 1].includes(result.status)) return null;
53
+ return result.status === 0 ? [] : addedLines(result.stdout.toString('utf8'));
54
+ }
55
+
56
+ // `git show <head>:<path>` exits non-zero both for a path the start commit lacks (a new file: every line is added)
57
+ // and for a start commit nobody can read; only a readable commit lets the first case accuse anyone.
58
+ function headReadable(repoRoot, head) {
59
+ const result = spawnSync('git', ['-C', repoRoot, 'cat-file', '-e', `${head}^{commit}`], {
60
+ windowsHide: true, timeout: 30_000,
61
+ });
62
+ return !result.error && result.status === 0;
63
+ }
64
+
65
+ function scanPath({ name, entry, baseline, headKnown, runDir, repoRoot, gaps }) {
66
+ const currentFile = path.join(repoRoot, name);
67
+ let current;
68
+ try {
69
+ const stat = fs.lstatSync(currentFile);
70
+ if (!stat.isFile()) return [];
71
+ if (stat.size > baseline.limits.perFileBytes) {
72
+ gaps.push(`${name}: end content over the limit`);
73
+ return [];
74
+ }
75
+ current = fs.readFileSync(currentFile);
76
+ } catch (error) {
77
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') gaps.push(`${name}: end content unreadable`);
78
+ return [];
79
+ }
80
+ if (current.length > baseline.limits.perFileBytes) {
81
+ gaps.push(`${name}: end content over the limit`);
82
+ return [];
83
+ }
84
+ if (current.subarray(0, 8000).includes(0)) return [];
85
+
86
+ let base = Buffer.alloc(0);
87
+ let baseFile;
88
+ let temporary = false;
89
+ if (entry) {
90
+ if (entry.state === 'copied') {
91
+ baseFile = path.join(runDir, FLAG_BASELINE_DIR, entry.copy);
92
+ try {
93
+ base = fs.readFileSync(baseFile);
94
+ } catch {
95
+ gaps.push(`${name}: start content unreadable`);
96
+ return [];
97
+ }
98
+ } else if (!['deleted', 'absent'].includes(entry.state)) {
99
+ gaps.push(`${name}: start content ${entry.state}`);
100
+ return [];
101
+ }
102
+ } else if (!headKnown) {
103
+ gaps.push(`${name}: start commit unreadable`);
104
+ return [];
105
+ } else {
106
+ const result = spawnSync('git', ['-C', repoRoot, 'show', `${baseline.head}:${name}`], {
107
+ windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024,
108
+ });
109
+ if (result.error || result.status === null) {
110
+ gaps.push(`${name}: start content unreadable`);
111
+ return [];
112
+ }
113
+ if (result.status === 0) base = result.stdout;
114
+ if (base.length) {
115
+ const copyDir = path.join(runDir, FLAG_BASELINE_DIR);
116
+ fs.mkdirSync(copyDir, { recursive: true });
117
+ baseFile = path.join(copyDir, `head-${randomUUID()}.bin`);
118
+ fs.writeFileSync(baseFile, base, { flag: 'wx' });
119
+ temporary = true;
120
+ }
121
+ }
122
+
123
+ if (!base.length) {
124
+ return current.toString('utf8').split('\n').map((text, index) => ({ number: index + 1, text }));
125
+ }
126
+ let added;
127
+ try {
128
+ added = compareFile(baseFile, currentFile);
129
+ } finally {
130
+ if (temporary) fs.unlinkSync(baseFile);
131
+ }
132
+ if (added === null) {
133
+ gaps.push(`${name}: diff failed`);
134
+ return [];
135
+ }
136
+ return added;
137
+ }
138
+
139
+ export function scanRunFlags({ runDir, repoRoot }) {
140
+ const gaps = [];
141
+ const hits = [];
142
+ try {
143
+ const baseline = readFlagBaseline(runDir);
144
+ if (!baseline) {
145
+ gaps.push('no flag baseline');
146
+ } else if (!baseline.complete && baseline.files.length === 0) {
147
+ gaps.push(`flag baseline incomplete: ${baseline.reason}`);
148
+ } else {
149
+ const entries = new Map(baseline.files.map((entry) => [entry.path, entry]));
150
+ const comparison = runSnapshotChanges(runDir);
151
+ if (!comparison.ok) gaps.push('snapshots not comparable');
152
+ const names = [...new Set([...entries.keys(), ...(comparison.ok ? comparison.changed : [])])].sort();
153
+ const headKnown = headReadable(repoRoot, baseline.head);
154
+ for (const name of names) {
155
+ const added = scanPath({ name, entry: entries.get(name), baseline, headKnown, runDir, repoRoot, gaps });
156
+ for (const line of added) {
157
+ if (hits.length < 20 && isFlagLine(line.text)) hits.push(`${name}:${line.number}: ${line.text.trim()}\n`);
158
+ }
159
+ }
160
+ }
161
+ } finally {
162
+ removeFlagBaselineCopies(runDir);
163
+ }
164
+ return { text: hits.join(''), coverage: { complete: gaps.length === 0, gaps } };
165
+ }
@@ -94,39 +94,6 @@ export const headSha = (repo) => (git(repo, ['rev-parse', 'HEAD']).stdout || '')
94
94
  export const branchName = (repo) =>
95
95
  (git(repo, ['symbolic-ref', '--short', '-q', 'HEAD']).stdout || '').trim();
96
96
 
97
- const FAKE_DONE_RE =
98
- /TODO|FIXME|test\.(skip|only)|it\.(skip|only)|describe\.(skip|only)|NotImplemented/;
99
-
100
- /**
101
- * Traces of fake completion. `git diff` carries nothing for untracked files, so a brand
102
- * new file full of TODOs would otherwise pass as "Flags: none" — the one case the check
103
- * exists for.
104
- */
105
- export function findFakeDone(repo) {
106
- const skip = runsPrefixInside(repo);
107
- const hits = (git(repo, ['diff', '-U0']).stdout || '')
108
- .split(/\r?\n/)
109
- .filter((l) => l.startsWith('+') && FAKE_DONE_RE.test(l));
110
- for (const file of (listUntrackedPaths(repo) ?? [])
111
- // Same reason as in worktreeSnapshot: inside ~/.claude the run folder is part of the
112
- // worktree, and task.md spells out the very words this scans for ("do not leave TODOs,
113
- // test.skip"). A run flagged itself for quoting its own instructions.
114
- .filter((file) => !(skip && `${file}/`.startsWith(skip)))) {
115
- const full = path.join(repo, file);
116
- try {
117
- if (fs.statSync(full).size > 1024 * 1024) continue;
118
- fs.readFileSync(full, 'utf8')
119
- .split(/\r?\n/)
120
- .forEach((l, i) => {
121
- if (FAKE_DONE_RE.test(l)) hits.push(`${file}:${i + 1}: ${l.trim()}`);
122
- });
123
- } catch {
124
- // Binary or unreadable: nothing to flag.
125
- }
126
- }
127
- return hits.length ? `${hits.slice(0, 20).join('\n')}\n` : '';
128
- }
129
-
130
97
  /** What exactly is under review, resolved from git rather than from wording. */
131
98
  export function reviewScope(repo, changeset) {
132
99
  if (changeset.startsWith('base:')) {