@codedology/code-forge 0.1.0

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 (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Small shared helpers over a forbidden-list RENDER — the shape `renderForClaude`/`renderForGrok`
3
+ * (`{id, rules: string[], enforced}[]`) or `renderForCodex` (`{id, patterns: string[][],
4
+ * enforced}[]`) from `src/util/forbidden.mjs` (B0/B8) return. Used by `builders/claude.mjs` and
5
+ * `builders/grok.mjs` so the "flatten every entry's rules into one argv run" logic exists once,
6
+ * not twice, and by all three coder builders to refuse an empty render (fix round 1, MAJOR: a
7
+ * coder role must always carry a non-empty forbidden list — a renderer regression that returns
8
+ * nothing must be a thrown error, not a silently-empty `--disallowedTools`/rules file).
9
+ *
10
+ * `idsMissingFromArgv` moved OUT of this file in fix round 1 (MINOR, dead-code finding): nothing
11
+ * under `src/` ever called it — only tests did — so it now lives in
12
+ * `test/engines/builders/argv-check.mjs`, alongside the `.every`-not-`.some` fix and two new
13
+ * position-aware helpers the review asked for.
14
+ */
15
+
16
+ /**
17
+ * @typedef {{id: string, rules?: ReadonlyArray<string>, patterns?: ReadonlyArray<ReadonlyArray<string>>, enforced: boolean}} RenderedEntry
18
+ */
19
+
20
+ /**
21
+ * @param {ReadonlyArray<{id: string, rules: ReadonlyArray<string>, enforced: boolean}>} rendered
22
+ * @returns {string[]} every rule string of every entry, in entry order, entry-internal order
23
+ * preserved (an `enforced: false` entry contributes an empty `rules` array, so it drops out
24
+ * naturally rather than needing a separate filter).
25
+ */
26
+ export function flattenRuleStrings(rendered) {
27
+ return rendered.flatMap((entry) => [...entry.rules]);
28
+ }
29
+
30
+ /**
31
+ * Whether a render has AT LEAST ONE usable rule/pattern anywhere across its entries — the guard a
32
+ * coder-role builder throws on when this is false (a coder must always carry a real forbidden
33
+ * list; an empty one is a renderer regression, never a legitimate state). Works for both the
34
+ * Claude/Grok shape (`rules`) and the Codex shape (`patterns`) without the caller needing to know
35
+ * which.
36
+ * @param {ReadonlyArray<RenderedEntry>} rendered
37
+ * @returns {boolean}
38
+ */
39
+ export function hasAnyRenderedRule(rendered) {
40
+ return rendered.some((entry) => (entry.rules?.length ?? 0) > 0 || (entry.patterns?.length ?? 0) > 0);
41
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The subprocess-mode coder sentinel contract (plan §5.2, §5.3, C11) and the `ACK <sha8>
3
+ * lines=<n>` wire receipt.
4
+ *
5
+ * **Sentinel.** A coder prints `===BLOCK <id> COMPLETE===` on success or `===BLOCK <id> FAILED:
6
+ * <reason>===` on failure (§10.7's forecast-exceeded stop line — `===BLOCK <id> FAILED: forecast
7
+ * exceeded, split plan <id>a/<id>b===` — is one concrete instance of the FAILED shape). A coder can
8
+ * print MORE than one of these across a long-running transcript (a retry after a local fix, a
9
+ * revised final report); `parseSentinel` always takes the LAST occurrence, never the first (plan
10
+ * §5.2: "`sentinel.mjs` takes the **last** occurrence"). Both patterns are anchored to the WHOLE
11
+ * trimmed line (`^...$`), so a coder quoting the sentinel text inside ordinary prose ("I will not
12
+ * print ===BLOCK B4 COMPLETE=== yet") never matches — only a line that IS exactly the sentinel does.
13
+ *
14
+ * **ACK.** Before any tool call, a coder replies `ACK <sha8> lines=<n>` to the wire receipt it was
15
+ * sent — the sha of the brief file, truncated to 8 hex characters (matched case-insensitively;
16
+ * compared lowercased), and the brief's line count. `parseAck` returns the LAST matching line (fix
17
+ * round 1, MAJOR): the protocol is "no/wrong ACK within the launch window ⇒ resend once" (§5.2),
18
+ * so when a caller passes the transcript up through a resend, the LAST ACK is the one that reflects
19
+ * whether the resend succeeded — a first-match reading would report the pre-resend failure even
20
+ * after a successful resend. An optional `after` marker lets a caller scope the search to
21
+ * everything strictly after the last occurrence of that marker (e.g. the literal wire-receipt text
22
+ * that was just (re)sent), which also defends against an unrelated earlier line that happens to
23
+ * look like an ACK (an echoed instruction, a quoted example) being mistaken for the real one.
24
+ */
25
+
26
+ /** @typedef {{status: "complete", blockId: string} | {status: "failed", blockId: string, reason: string}} SentinelResult */
27
+
28
+ const COMPLETE_RE = /^===BLOCK\s+(\S+)\s+COMPLETE===$/;
29
+ const FAILED_RE = /^===BLOCK\s+(\S+)\s+FAILED:\s*(.*?)\s*===$/;
30
+
31
+ /**
32
+ * @param {string} output - the full captured transcript (subprocess stdout, or a Solo
33
+ * `get_process_output` join).
34
+ * @param {string} [blockId] - when given, only sentinel lines for THIS block id count — a shared
35
+ * pane/log can interleave another block's lines.
36
+ * @returns {SentinelResult | null} the LAST matching sentinel line, or `null` if none is present.
37
+ */
38
+ export function parseSentinel(output, blockId) {
39
+ if (typeof output !== 'string') {
40
+ throw new TypeError('parseSentinel: output must be a string');
41
+ }
42
+ /** @type {SentinelResult | null} */
43
+ let last = null;
44
+ for (const rawLine of output.split('\n')) {
45
+ const line = rawLine.trim();
46
+ const complete = COMPLETE_RE.exec(line);
47
+ if (complete && (!blockId || complete[1] === blockId)) {
48
+ last = { status: 'complete', blockId: complete[1] };
49
+ continue;
50
+ }
51
+ const failed = FAILED_RE.exec(line);
52
+ if (failed && (!blockId || failed[1] === blockId)) {
53
+ last = { status: 'failed', blockId: failed[1], reason: failed[2] };
54
+ }
55
+ }
56
+ return last;
57
+ }
58
+
59
+ /** @typedef {{sha8: string, lines: number}} ParsedAck */
60
+
61
+ /** Case-insensitive: normalized to lowercase before comparison in {@link verifyAck}. */
62
+ const ACK_RE = /^ACK\s+([0-9a-fA-F]{8})\s+lines=(\d+)\s*$/;
63
+
64
+ /**
65
+ * @param {string} output
66
+ * @param {{after?: string} | null} [opts] - `after`: only text strictly after the LAST occurrence
67
+ * of this marker is searched (the whole `output` is searched when omitted or not found). `opts`
68
+ * may be `null`; `after` must be a non-empty string when given (TypeError otherwise).
69
+ * @returns {ParsedAck | null} the LAST `ACK <sha8> lines=<n>` line in the searched window (fix
70
+ * round 1, MAJOR — was the first).
71
+ */
72
+ export function parseAck(output, opts = {}) {
73
+ if (typeof output !== 'string') {
74
+ throw new TypeError('parseAck: output must be a string');
75
+ }
76
+ const after = opts?.after;
77
+ if (after !== undefined && (typeof after !== 'string' || after.length === 0)) {
78
+ // An empty marker would make `lastIndexOf` return output.length and empty the window.
79
+ throw new TypeError('parseAck: opts.after must be a non-empty string when given');
80
+ }
81
+ let window = output;
82
+ if (after !== undefined) {
83
+ const idx = output.lastIndexOf(after);
84
+ if (idx !== -1) window = output.slice(idx + after.length);
85
+ }
86
+ /** @type {ParsedAck | null} */
87
+ let last = null;
88
+ for (const rawLine of window.split('\n')) {
89
+ const match = ACK_RE.exec(rawLine.trim());
90
+ if (match) {
91
+ last = { sha8: match[1].toLowerCase(), lines: Number(match[2]) };
92
+ }
93
+ }
94
+ return last;
95
+ }
96
+
97
+ /** @typedef {{status: "ok", ack: ParsedAck} | {status: "parked", reason: string}} AckVerification */
98
+
99
+ const SHA8_RE = /^[0-9a-fA-F]{8}$/;
100
+
101
+ /**
102
+ * @param {string} output
103
+ * @param {string} expectedSha8 - exactly 8 hex characters (case-insensitive).
104
+ * @param {number} [expectedLines] - when given, a line-count mismatch also parks (the brief was
105
+ * truncated in flight).
106
+ * @param {{after?: string} | null} [opts] - forwarded to {@link parseAck}.
107
+ * @returns {AckVerification}
108
+ * @throws {TypeError} if `expectedSha8` is not exactly 8 hex characters — a caller passing a full
109
+ * 40-character sha (or any other malformed value) gets a clear refusal here instead of a
110
+ * confusing "no ACK" / "sha mismatch" `parked` result. Also if `expectedLines` is given but is
111
+ * not a non-negative integer (null, NaN, a numeric string), instead of a misleading
112
+ * "ACK lines mismatch" park.
113
+ */
114
+ export function verifyAck(output, expectedSha8, expectedLines, opts = {}) {
115
+ if (typeof expectedSha8 !== 'string' || !SHA8_RE.test(expectedSha8)) {
116
+ throw new TypeError(`verifyAck: expectedSha8 must be exactly 8 hex characters, got ${JSON.stringify(expectedSha8)}`);
117
+ }
118
+ if (expectedLines !== undefined && !(Number.isInteger(expectedLines) && expectedLines >= 0)) {
119
+ throw new TypeError(`verifyAck: expectedLines must be a non-negative integer when given, got ${JSON.stringify(expectedLines)}`);
120
+ }
121
+ const normalizedExpected = expectedSha8.toLowerCase();
122
+ const ack = parseAck(output, opts);
123
+ if (!ack) {
124
+ return { status: 'parked', reason: 'no ACK line found in the searched window' };
125
+ }
126
+ if (ack.sha8 !== normalizedExpected) {
127
+ return { status: 'parked', reason: `ACK sha mismatch: expected ${normalizedExpected}, got ${ack.sha8}` };
128
+ }
129
+ if (expectedLines !== undefined && ack.lines !== expectedLines) {
130
+ return { status: 'parked', reason: `ACK lines mismatch: expected ${expectedLines}, got ${ack.lines}` };
131
+ }
132
+ return { status: 'ok', ack };
133
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Token-usage extraction from a provider CLI's own JSON output (plan §6.1: "Tokens: reported when
3
+ * the CLI's JSON carries usage (`claude -p --output-format json` does `[A2]`; Codex/Grok
4
+ * best-effort `[A14]`), else `estimated`. **Tokens are facts, dollars are estimates.**").
5
+ *
6
+ * **Not independently verified on this Mac** — the plan's own `[A2]`/`[A14]` tags mean the exact
7
+ * JSON field names were not read from a real run for this revision (only `--help` text was; §0.6.8
8
+ * tolerates this for the ledger's `tokens_source` field). This parser is deliberately lenient: it
9
+ * tries the field names each vendor's own API/CLI documentation uses elsewhere, and falls back to
10
+ * `tokensSource: "estimated"` rather than throwing when none match — a shape drift here must never
11
+ * crash a review or a coder run, only degrade the ledger's tokens field, exactly as §6.1 describes.
12
+ *
13
+ * **Fix round 1 (isolated per-file review):**
14
+ * - **Claude**: `input_tokens` alone undercounts a cached run — Anthropic's Messages API (which
15
+ * Claude Code's own JSON result reuses) reports prompt-cache reads/writes as SEPARATE fields
16
+ * (`cache_creation_input_tokens`, `cache_read_input_tokens`), NOT folded into `input_tokens`.
17
+ * `tokensIn` is now their sum, and the individual cache fields are kept on the returned record
18
+ * (`cacheCreationTokens`/`cacheReadTokens`) so a caller doesn't lose the split.
19
+ * - `PARSERS[cli]`/`Object.hasOwn`: the cli lookup is now guarded so `parseUsage('constructor',
20
+ * …)` (and `'toString'`, `'__proto__'`, …) hits the documented "unknown cli" error instead of a
21
+ * prototype-chain member.
22
+ * - Codex/Grok no longer mix naming schemes within one payload: `tokensIn` from `input_tokens` and
23
+ * `tokensOut` from `completion_tokens` (two different vendors' field names) never happens now —
24
+ * one scheme is picked (input_tokens/output_tokens if BOTH present, else prompt_tokens/
25
+ * completion_tokens if BOTH present, else `estimated`).
26
+ * - JSDoc on `parseCodexUsage` now names exactly which Codex `--json` event a caller must pass.
27
+ */
28
+
29
+ /**
30
+ * @typedef {{tokensIn: number, tokensOut: number, tokensSource: "reported", cacheCreationTokens?: number, cacheReadTokens?: number}
31
+ * | {tokensIn: null, tokensOut: null, tokensSource: "estimated"}} UsageResult
32
+ */
33
+
34
+ const ESTIMATED = Object.freeze({ tokensIn: null, tokensOut: null, tokensSource: /** @type {"estimated"} */ ('estimated') });
35
+
36
+ /**
37
+ * @param {unknown} value
38
+ * @returns {value is Record<string, unknown>}
39
+ */
40
+ function isPlainObject(value) {
41
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
42
+ }
43
+
44
+ /**
45
+ * @param {unknown} value
46
+ * @returns {number | undefined}
47
+ */
48
+ function asNonNegativeInt(value) {
49
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
50
+ }
51
+
52
+ /**
53
+ * Claude Code `-p --output-format json` result shape: a top-level `usage` object with
54
+ * `input_tokens`/`output_tokens` (Anthropic Messages API field names, which Claude Code's own JSON
55
+ * result reuses), PLUS `cache_creation_input_tokens`/`cache_read_input_tokens` when prompt caching
56
+ * was used — both optional, defaulting to 0, folded into `tokensIn` and also kept split.
57
+ * @param {unknown} parsed
58
+ * @returns {UsageResult}
59
+ */
60
+ function parseClaudeUsage(parsed) {
61
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.usage)) return ESTIMATED;
62
+ const usage = parsed.usage;
63
+ const baseIn = asNonNegativeInt(usage.input_tokens);
64
+ const tokensOut = asNonNegativeInt(usage.output_tokens);
65
+ if (baseIn === undefined || tokensOut === undefined) return ESTIMATED;
66
+ // Optional cache fields: absent OR `null` is legitimate (no caching happened; the Messages API
67
+ // types both as `integer | null`) and treated as 0 without setting the split field. A
68
+ // present, non-null, malformed value (negative, non-integer) must not be silently ignored — it
69
+ // falls back to the whole result being `estimated`, same as a malformed base field would.
70
+ const hasCacheCreation = usage.cache_creation_input_tokens != null;
71
+ const hasCacheRead = usage.cache_read_input_tokens != null;
72
+ const cacheCreation = hasCacheCreation ? asNonNegativeInt(usage.cache_creation_input_tokens) : 0;
73
+ const cacheRead = hasCacheRead ? asNonNegativeInt(usage.cache_read_input_tokens) : 0;
74
+ if (cacheCreation === undefined || cacheRead === undefined) return ESTIMATED;
75
+ return {
76
+ tokensIn: baseIn + cacheCreation + cacheRead,
77
+ tokensOut,
78
+ tokensSource: 'reported',
79
+ ...(hasCacheCreation ? { cacheCreationTokens: cacheCreation } : {}),
80
+ ...(hasCacheRead ? { cacheReadTokens: cacheRead } : {}),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * @param {Record<string, unknown>} usage
86
+ * @returns {{tokensIn: number, tokensOut: number} | undefined} both fields from ONE naming scheme
87
+ * only — `input_tokens`/`output_tokens` if BOTH are present and valid, else `prompt_tokens`/
88
+ * `completion_tokens` if BOTH are, else `undefined`. Never mixes a field from one scheme with a
89
+ * field from the other (fix round 1, MINOR).
90
+ */
91
+ function oneSchemeTokens(usage) {
92
+ const anthropicIn = asNonNegativeInt(usage.input_tokens);
93
+ const anthropicOut = asNonNegativeInt(usage.output_tokens);
94
+ if (anthropicIn !== undefined && anthropicOut !== undefined) {
95
+ return { tokensIn: anthropicIn, tokensOut: anthropicOut };
96
+ }
97
+ const openaiIn = asNonNegativeInt(usage.prompt_tokens);
98
+ const openaiOut = asNonNegativeInt(usage.completion_tokens);
99
+ if (openaiIn !== undefined && openaiOut !== undefined) {
100
+ return { tokensIn: openaiIn, tokensOut: openaiOut };
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * Codex `--json` prints one JSONL EVENT per line, not a single result object; usage lives on the
107
+ * turn-completion event (the one carrying a `usage` object — Codex's own docs do not name a fixed
108
+ * event-type string this build can pin against yet, `[A14]`). **The caller is responsible for
109
+ * locating and passing that ONE parsed event object** — this function does not scan a list of
110
+ * events or sum usage across turns; a caller with a multi-turn run must do that summation itself
111
+ * before calling this, or accept per-turn `estimated`/`reported` rows.
112
+ * @param {unknown} parsed - one already-located, already-parsed Codex event object.
113
+ * @returns {UsageResult}
114
+ */
115
+ function parseCodexUsage(parsed) {
116
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.usage)) return ESTIMATED;
117
+ const tokens = oneSchemeTokens(parsed.usage);
118
+ return tokens ? { ...tokens, tokensSource: 'reported' } : ESTIMATED;
119
+ }
120
+
121
+ /**
122
+ * Grok `usage` subcommand / JSON output: best-effort, same single-scheme field acceptance as
123
+ * Codex — no verified sample exists for either.
124
+ * @param {unknown} parsed
125
+ * @returns {UsageResult}
126
+ */
127
+ function parseGrokUsage(parsed) {
128
+ return parseCodexUsage(parsed);
129
+ }
130
+
131
+ /** @type {Readonly<Record<"claude"|"codex"|"grok", (parsed: unknown) => UsageResult>>} */
132
+ const PARSERS = Object.freeze({ claude: parseClaudeUsage, codex: parseCodexUsage, grok: parseGrokUsage });
133
+
134
+ /**
135
+ * @param {"claude"|"codex"|"grok"} cli
136
+ * @param {unknown} parsedJSON - already `JSON.parse`d CLI output (this module never parses raw
137
+ * text itself — a malformed JSON string is the caller's problem to report, not silently degrade
138
+ * into "estimated" here).
139
+ * @returns {UsageResult}
140
+ * @throws {Error} for an unknown `cli` — guarded with `Object.hasOwn` so a prototype-chain name
141
+ * (`"constructor"`, `"toString"`, `"__proto__"`, …) hits this same error instead of an unrelated
142
+ * crash or a wrong result (fix round 1, MINOR).
143
+ */
144
+ export function parseUsage(cli, parsedJSON) {
145
+ if (typeof cli !== 'string' || !Object.hasOwn(PARSERS, cli)) {
146
+ throw new Error(`parseUsage: unknown cli "${cli}"`);
147
+ }
148
+ return PARSERS[/** @type {"claude"|"codex"|"grok"} */ (cli)](parsedJSON);
149
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Acceptance-clause coverage (plan §4.6 row 4, O17): "every clause names ≥ 1 test id; each named
3
+ * test exists in the tree and passed in this gate run (parsed from the runner output)." This is
4
+ * DETERMINISTIC — S1's `scope` question (§3.2) judges only whether hunks OUTSIDE the named tests
5
+ * are unrelated; whether a named test id actually passed is a fact, never a model's opinion.
6
+ *
7
+ * `parseTapPassed` reads real TAP (RFC-ish "TAP version 13") output — the default `node --test`
8
+ * reporter on this Mac (verified: `node --test` with no `--test-reporter` flag prints
9
+ * `TAP version 13` / `ok N - <name>` / `not ok N - <name>`, 2026-09-24) — into passed/failed name
10
+ * sets. A project on a different runner supplies its own `passed` set built however fits that
11
+ * runner's output; `checkAcceptance` itself does not care where the set came from.
12
+ */
13
+
14
+ /**
15
+ * @typedef {{clause: string, tests: string[]}} AcceptanceClause
16
+ * @typedef {{ok: true} | {ok: false, reason: string, clause: string, test: string}} AcceptanceResult
17
+ */
18
+
19
+ /** A TAP directive suffix on a test description (`name # SKIP reason`, `name # TODO reason`). */
20
+ const TAP_DIRECTIVE = /^(.*?)\s*#\s*(SKIP|TODO)\b/i;
21
+
22
+ /**
23
+ * @param {string} text - the captured `<name...>` portion of an `ok`/`not ok` TAP line.
24
+ * @returns {{name: string, directive: 'SKIP' | 'TODO' | null}}
25
+ */
26
+ function parseNameAndDirective(text) {
27
+ const m = TAP_DIRECTIVE.exec(text);
28
+ return m ? { name: m[1].trim(), directive: /** @type {'SKIP'|'TODO'} */ (m[2].toUpperCase()) } : { name: text.trim(), directive: null };
29
+ }
30
+
31
+ /**
32
+ * @param {string} output - raw TAP text from a test-runner invocation.
33
+ * @returns {{passed: Set<string>, failed: Set<string>}}
34
+ */
35
+ export function parseTapPassed(output) {
36
+ /** @type {Set<string>} */
37
+ const passed = new Set();
38
+ /** @type {Set<string>} */
39
+ const failed = new Set();
40
+ for (const rawLine of String(output).split('\n')) {
41
+ const line = rawLine.trim();
42
+ const ok = /^ok\s+\d+\s+-\s+(.+)$/.exec(line);
43
+ if (ok) {
44
+ const { name, directive } = parseNameAndDirective(ok[1]);
45
+ // `node --test` reports a skipped or todo test as an "ok" TAP line with a `# SKIP`/`# TODO`
46
+ // directive — it did not actually run to a real pass, so it must NOT satisfy an acceptance
47
+ // clause (O17: "passed in this gate run", not merely "not reported as failed").
48
+ if (directive === null) passed.add(name);
49
+ continue;
50
+ }
51
+ const notOk = /^not ok\s+\d+\s+-\s+(.+)$/.exec(line);
52
+ if (notOk) {
53
+ failed.add(parseNameAndDirective(notOk[1]).name);
54
+ }
55
+ }
56
+ return { passed, failed };
57
+ }
58
+
59
+ /**
60
+ * @param {ReadonlyArray<AcceptanceClause>} clauses - each must name ≥ 1 test id.
61
+ * @param {Set<string> | ReadonlyArray<string>} passed - test ids that passed in THIS gate run.
62
+ * @param {Set<string> | ReadonlyArray<string>} [failed] - test ids that FAILED in this gate run.
63
+ * `parseTapPassed` flattens every file's/describe-block's TAP subtests into one global name set,
64
+ * so two unrelated tests can share a name; when one passes and the other fails, that name lands
65
+ * in BOTH sets. `failed` is checked independently of `passed` so that ambiguity never accepts a
66
+ * clause on the strength of some OTHER test with the same name having passed.
67
+ * @returns {AcceptanceResult} refused (naming the offending clause and test) on the FIRST clause
68
+ * whose acceptance is not fully covered; `{ok: true}` only when every clause's every named test
69
+ * id is in `passed` and NOT in `failed`.
70
+ */
71
+ export function checkAcceptance(clauses, passed, failed = new Set()) {
72
+ if (!Array.isArray(clauses) || clauses.length === 0) {
73
+ return { ok: false, reason: 'no acceptance clauses given', clause: '', test: '' };
74
+ }
75
+ const passedSet = passed instanceof Set ? passed : new Set(passed);
76
+ const failedSet = failed instanceof Set ? failed : new Set(failed);
77
+ for (const entry of clauses) {
78
+ const clause = entry?.clause;
79
+ const tests = entry?.tests;
80
+ if (typeof clause !== 'string' || clause.length === 0 || !Array.isArray(tests) || tests.length === 0) {
81
+ return { ok: false, reason: 'clause names no test id', clause: String(clause ?? ''), test: '' };
82
+ }
83
+ for (const test of tests) {
84
+ if (failedSet.has(test) || !passedSet.has(test)) {
85
+ return {
86
+ ok: false,
87
+ reason: `clause "${clause}" names test "${test}", which did not exist or did not pass in this gate run`,
88
+ clause,
89
+ test,
90
+ };
91
+ }
92
+ }
93
+ }
94
+ return { ok: true };
95
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Gate auto-detection (plan §2.2, `src/gates/detect.mjs`): given a project root, decide which
3
+ * stack it is and produce the four gate commands (`test`, `lint`, `types`, `format`) as argv
4
+ * arrays, or `null` when the stack has no tool for that gate. Detection is pure filesystem
5
+ * evidence (`fs.existsSync`/`readFileSync`) — no `git`, no child process, no network.
6
+ *
7
+ * Evidence is checked in the table's own order (PHP/Pest, then `package.json` scripts, Cargo,
8
+ * pyproject, go.mod); the first stack whose marker file exists wins. A project with no marker
9
+ * file at all is `unknown`: every gate is `null`, and `code` (a later block) refuses to run a
10
+ * block whose `gates.test` is null — a block without a test gate cannot produce evidence.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ /**
17
+ * A gate command is normally a plain argv array (its own exit code decides red/green). Some tools
18
+ * (`gofmt -l`) exit 0 regardless of whether anything is unformatted and instead print the
19
+ * offending file names to stdout — for those, the descriptor carries `failOnStdout: true` so
20
+ * `run.mjs` treats non-empty stdout as red too, since a bare exit-code check can never fail.
21
+ * @typedef {string[] | {argv: string[], failOnStdout: true}} GateCommand
22
+ * @typedef {GateCommand | null} Gate
23
+ */
24
+ /** @typedef {{stack: string, test: Gate, lint: Gate, types: Gate, format: Gate}} DetectedGates */
25
+
26
+ /** @param {string} root @param {string} rel @returns {boolean} */
27
+ function has(root, rel) {
28
+ return existsSync(path.join(root, rel));
29
+ }
30
+
31
+ /**
32
+ * @param {string} root @param {string} rel
33
+ * @returns {Record<string, any> | null} parsed JSON, or null when the file is absent or invalid.
34
+ */
35
+ function readJSON(root, rel) {
36
+ try {
37
+ return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** @param {string} root @param {string} rel @returns {string | null} */
44
+ function readText(root, rel) {
45
+ try {
46
+ return readFileSync(path.join(root, rel), 'utf8');
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // ── PHP / Pest ───────────────────────────────────────────────────────────────
53
+
54
+ /** @param {string} root @returns {DetectedGates | null} */
55
+ function detectPhp(root) {
56
+ if (!has(root, 'composer.json') || !has(root, 'vendor/bin/pest')) return null;
57
+ const test = has(root, 'artisan') ? ['php', 'artisan', 'test', '--compact'] : ['vendor/bin/pest'];
58
+ const types = has(root, 'phpstan.neon') || has(root, 'phpstan.neon.dist') ? ['vendor/bin/phpstan'] : null;
59
+ const format = has(root, 'pint.json') || has(root, 'vendor/bin/pint') ? ['vendor/bin/pint', '--test'] : null;
60
+ return { stack: 'php-pest', test, lint: null, types, format };
61
+ }
62
+
63
+ // ── Node (package.json scripts) ────────────────────────────────────────────
64
+
65
+ /**
66
+ * `pm` resolution order (§2.2): the `packageManager` field, then the lockfile present
67
+ * (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn), else `npm`.
68
+ * @param {string} root @param {Record<string, any>} pkg @returns {string}
69
+ */
70
+ function resolvePackageManager(root, pkg) {
71
+ if (typeof pkg.packageManager === 'string' && pkg.packageManager.length > 0) {
72
+ const name = pkg.packageManager.split('@')[0];
73
+ if (name === 'pnpm' || name === 'yarn' || name === 'npm' || name === 'bun') return name;
74
+ }
75
+ if (has(root, 'pnpm-lock.yaml')) return 'pnpm';
76
+ if (has(root, 'yarn.lock')) return 'yarn';
77
+ return 'npm';
78
+ }
79
+
80
+ /**
81
+ * A `package.json` `devDependencies`/`dependencies` entry named `dep`.
82
+ * @param {Record<string, any>} pkg @param {string} dep @returns {boolean}
83
+ */
84
+ function hasDependency(pkg, dep) {
85
+ return Boolean(pkg.dependencies?.[dep] || pkg.devDependencies?.[dep]);
86
+ }
87
+
88
+ /** @param {string} root @returns {DetectedGates | null} */
89
+ function detectNode(root) {
90
+ const pkg = readJSON(root, 'package.json');
91
+ if (!pkg || typeof pkg !== 'object') return null;
92
+ const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
93
+ if (typeof scripts.test !== 'string') return null;
94
+
95
+ const pm = resolvePackageManager(root, pkg);
96
+ // `bun test` invokes Bun's OWN built-in test runner and ignores the package.json `test` script
97
+ // entirely — `bun run test` is the form that actually runs the configured script, matching
98
+ // every other package manager's `[pm, "test"]` shorthand behaviour.
99
+ const test = pm === 'bun' ? [pm, 'run', 'test'] : [pm, 'test'];
100
+
101
+ let lint = null;
102
+ if (typeof scripts.lint === 'string') lint = [pm, 'run', 'lint'];
103
+ else if (typeof scripts['lint:check'] === 'string') lint = [pm, 'run', 'lint:check'];
104
+
105
+ let types = null;
106
+ if (typeof scripts.typecheck === 'string') types = [pm, 'run', 'typecheck'];
107
+ else if (typeof scripts['vue-tsc'] === 'string') types = [pm, 'run', 'vue-tsc'];
108
+ else if (has(root, 'tsconfig.json')) types = ['npx', 'tsc', '--noEmit'];
109
+
110
+ let format = null;
111
+ if (typeof scripts['format:check'] === 'string') format = [pm, 'run', 'format:check'];
112
+ else if (has(root, '.prettierrc') || has(root, '.prettierrc.json') || has(root, '.prettierrc.js') || hasDependency(pkg, 'prettier')) {
113
+ format = ['npx', 'prettier', '--check', '.'];
114
+ }
115
+
116
+ return { stack: 'node', test, lint, types, format };
117
+ }
118
+
119
+ // ── Cargo ───────────────────────────────────────────────────────────────────
120
+
121
+ /** @param {string} root @returns {DetectedGates | null} */
122
+ function detectRust(root) {
123
+ if (!has(root, 'Cargo.toml')) return null;
124
+ return {
125
+ stack: 'rust',
126
+ test: ['cargo', 'test'],
127
+ lint: ['cargo', 'clippy', '--', '-D', 'warnings'],
128
+ types: null,
129
+ format: ['cargo', 'fmt', '--check'],
130
+ };
131
+ }
132
+
133
+ // ── Python (pyproject.toml) ─────────────────────────────────────────────────
134
+
135
+ /** @param {string} text @returns {boolean} a `[tool.mypy]` section is present. */
136
+ function hasMypySection(text) {
137
+ return /^\[tool\.mypy(\..+)?\]\s*$/m.test(text);
138
+ }
139
+
140
+ /** @param {string} root @returns {DetectedGates | null} */
141
+ function detectPython(root) {
142
+ const text = readText(root, 'pyproject.toml');
143
+ if (text === null) return null;
144
+ return {
145
+ stack: 'python',
146
+ test: ['pytest'],
147
+ lint: ['ruff', 'check'],
148
+ types: hasMypySection(text) ? ['mypy'] : null,
149
+ format: ['ruff', 'format', '--check'],
150
+ };
151
+ }
152
+
153
+ // ── Go ───────────────────────────────────────────────────────────────────────
154
+
155
+ /** @param {string} root @returns {DetectedGates | null} */
156
+ function detectGo(root) {
157
+ if (!has(root, 'go.mod')) return null;
158
+ return {
159
+ stack: 'go',
160
+ test: ['go', 'test', './...'],
161
+ lint: ['go', 'vet', './...'],
162
+ types: null,
163
+ // `gofmt -l` prints the names of unformatted files to stdout but ALWAYS exits 0 — a bare
164
+ // exit-code check can never turn this gate red. `failOnStdout` tells run.mjs to also treat
165
+ // non-empty stdout as a failure.
166
+ format: { argv: ['gofmt', '-l', '.'], failOnStdout: true },
167
+ };
168
+ }
169
+
170
+ /** Detectors in table order — the first stack whose marker evidence exists wins. */
171
+ const DETECTORS = Object.freeze([detectPhp, detectNode, detectRust, detectPython, detectGo]);
172
+
173
+ /**
174
+ * @param {string} root - an absolute path to a project root.
175
+ * @returns {DetectedGates} the detected stack and its four gate commands (argv or null each).
176
+ * `stack: 'unknown'` with every gate `null` when no marker evidence matches.
177
+ */
178
+ export function detectGates(root) {
179
+ if (typeof root !== 'string' || root.length === 0) {
180
+ throw new TypeError('detectGates: root must be a non-empty string');
181
+ }
182
+ for (const detector of DETECTORS) {
183
+ const result = detector(root);
184
+ if (result) return result;
185
+ }
186
+ return { stack: 'unknown', test: null, lint: null, types: null, format: null };
187
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The invariants gate (plan §1.3 `gates.extra.invariants`: a project-configured PATH, unlike
3
+ * `secret_scan`/`safe_edit` which are booleans). A project may keep its own structural checks —
4
+ * comment-only diffs, forbidden patterns, naming conventions — as one executable script; when
5
+ * `gates.extra.invariants` names it, this gate runs it exactly like `run.mjs` runs `test`/`lint`/
6
+ * `types`/`format`: one argv, its own exit status read on its own, never chained.
7
+ *
8
+ * Unset (`undefined`/empty) is a pass-through, not a failure — a project with no invariants script
9
+ * simply has nothing extra to check.
10
+ */
11
+
12
+ import path from 'node:path';
13
+ import { exec } from '../util/exec.mjs';
14
+
15
+ /**
16
+ * @typedef {object} InvariantsResult
17
+ * @property {boolean} skipped - true when no `invariantsPath` was configured.
18
+ * @property {boolean} ok
19
+ * @property {number | null} code
20
+ * @property {string} stdout
21
+ * @property {string} stderr
22
+ */
23
+
24
+ /**
25
+ * @param {{cwd: string, invariantsPath?: string | null, timeoutMs?: number}} opts
26
+ * @returns {Promise<InvariantsResult>}
27
+ */
28
+ export async function runInvariants(opts) {
29
+ const { cwd, invariantsPath, timeoutMs = 120000 } = opts ?? {};
30
+ if (typeof cwd !== 'string' || cwd.length === 0) {
31
+ throw new TypeError('runInvariants: opts.cwd must be a non-empty string');
32
+ }
33
+ if (!invariantsPath) {
34
+ return { skipped: true, ok: true, code: null, stdout: '', stderr: '' };
35
+ }
36
+ // A bare filename (no slash) would otherwise be looked up on PATH instead of the project root,
37
+ // and a relative path would resolve against the CHILD process's cwd only by accident (it
38
+ // happens to be the same as `cwd` here, but that's not guaranteed once this call has a caller
39
+ // with its own cwd) — resolve against the project root explicitly either way.
40
+ const resolvedPath = path.resolve(cwd, invariantsPath);
41
+ const res = await exec([resolvedPath], { cwd, timeoutMs });
42
+ return { skipped: false, ok: res.result === 'ok', code: res.code, stdout: res.stdout, stderr: res.stderr };
43
+ }