@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,397 @@
1
+ /**
2
+ * Outcome rows — the ledger's ground truth for calibration (plan §6.3): the block gate (caller 2,
3
+ * a later block), `ledger outcome --pr <n> --ci ...` (caller 3, the CI template), and
4
+ * `--scan-git` (caller 4, C12) all funnel into `recordOutcome`.
5
+ *
6
+ * `scanGitOutcomes` is a PURE git→outcome-row mapper (no ledger I/O), testable without any JSONL
7
+ * file; `scanGitAndRecord` is the thin, IDEMPOTENT wrapper the CLI verb calls.
8
+ *
9
+ * Fix round 1 (2 MAJOR + 5 MINOR): the revert window is measured from each file's OWN review time
10
+ * (`reviewedAt`), not from when the scan happens to run — a global "now - 7 days" window means a
11
+ * revert 6 days after review is missed if the scan runs on day 10, and a revert 30 days after
12
+ * review counts if the scan runs the next day. `scanGitAndRecord` now skips (commit, file) pairs
13
+ * it already recorded, so running `--scan-git` twice does not double-count. `tokens_source`/
14
+ * `cost_source` on outcome rows: no extra code needed here — every row funnels through
15
+ * `write.mjs`'s `appendRow`, whose `normalizeRow` stamps both keys on 100% of rows unconditionally
16
+ * (see `write.test.mjs`); `test/ledger/outcome.test.mjs` asserts this directly for outcome rows too.
17
+ *
18
+ * Fix round 2: every `git` call here strips inherited `GIT_*` env vars (`GIT_DIR`, `GIT_WORK_TREE`,
19
+ * `GIT_INDEX_FILE`, ...) before running — the same class of bug the fixture builder had (fix
20
+ * round 2 MAJOR there), but this is production code: `forge ledger outcome --scan-git` running
21
+ * from inside any process that already has those set (a git hook, a CI step invoked BY git) would
22
+ * otherwise have `cwd` ignored for repository discovery and scan the WRONG repo entirely.
23
+ * (`exec` passes `env` straight to `spawn`, unmerged, so the stripped keys stay stripped.)
24
+ *
25
+ * Fix round 3: a revert-of-a-revert is decided by the `This reverts commit <sha>` CHAIN (odd depth
26
+ * ⇒ reverted, even ⇒ restored), with the subject as fallback — git ≥ 2.43 writes `Reapply "…"`
27
+ * for a revert of a revert, which the old `Revert "Revert` check missed. See `revertDepth`.
28
+ */
29
+
30
+ import { realpath } from 'node:fs/promises';
31
+ import path from 'node:path';
32
+ import { exec } from '../util/exec.mjs';
33
+ import { appendRow, readAllRows } from './write.mjs';
34
+
35
+ const FIELD_SEP = '\x1f';
36
+ const RECORD_SEP = '\x1e';
37
+
38
+ /** `process.env`, minus every `GIT_*` key — see the fix-round-2 note above. */
39
+ function gitEnv() {
40
+ const out = {};
41
+ for (const [key, value] of Object.entries(process.env)) {
42
+ if (!key.startsWith('GIT_')) out[key] = value;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** the body line `git revert` always writes: `This reverts commit <sha>.` (or `<sha> (<subject>, <date>).` with `--reference`). */
48
+ const REVERTS_COMMIT_BODY_RE = /This reverts commit ([0-9a-f]{7,40})\b/i;
49
+ /** Each leading `Revert "` in a subject is one revert level. */
50
+ const REVERT_PREFIX_RE = /^Revert ["“]/i;
51
+ /**
52
+ * Since git 2.43, reverting a commit whose subject is `Revert "X"` writes `Reapply "X"` instead
53
+ * of `Revert "Revert "X""` — so one leading `Reapply "` is TWO revert levels (git 2.50 verified:
54
+ * the chain is `X` → `Revert "X"` → `Reapply "X"` → `Revert "Reapply "X""` → `Reapply "Reapply "X""`).
55
+ */
56
+ const REAPPLY_PREFIX_RE = /^Reapply ["“]/i;
57
+ /** Guard for a pathological or cyclic `This reverts commit` chain. */
58
+ const MAX_REVERT_CHAIN = 64;
59
+
60
+ /**
61
+ * How many revert levels the SUBJECT alone encodes: `Revert "` = 1, `Reapply "` = 2, nested
62
+ * prefixes add up. Odd ⇒ the change is undone; even and > 0 ⇒ it is restored; 0 ⇒ the subject
63
+ * says nothing (a hand-edited message).
64
+ * @param {string} subject @returns {number}
65
+ */
66
+ function subjectRevertDepth(subject) {
67
+ let depth = 0;
68
+ let rest = subject;
69
+ for (;;) {
70
+ if (REVERT_PREFIX_RE.test(rest)) {
71
+ depth += 1;
72
+ rest = rest.replace(REVERT_PREFIX_RE, '');
73
+ } else if (REAPPLY_PREFIX_RE.test(rest)) {
74
+ depth += 2;
75
+ rest = rest.replace(REAPPLY_PREFIX_RE, '');
76
+ } else {
77
+ return depth;
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Exported for direct unit testing (fix round 1): the previous, unanchored `revert.*#\d+` pattern
84
+ * false-positived on subjects like "Prevent revert of cache (#12)" or "don't revert #3", and
85
+ * counted a revert-of-a-revert (which restores the original change) as a fresh "reverted" outcome.
86
+ *
87
+ * Fix round 3: this is the SUBJECT/BODY-only verdict — old-style `Revert "Revert "X""` AND
88
+ * git ≥ 2.43's `Reapply "X"` are both restores (false); a revert of a reapply is a revert again
89
+ * (true). A subject with no revert prefix falls back to the `This reverts commit` body marker.
90
+ * `scanGitOutcomes` goes further and follows the body's sha chain (see `revertDepth`), which also
91
+ * settles a hand-edited subject.
92
+ * @param {{subject: string, body: string}} commit @returns {boolean}
93
+ */
94
+ export function isRevertCommit({ subject, body }) {
95
+ const depth = subjectRevertDepth(subject);
96
+ if (depth > 0) return depth % 2 === 1;
97
+ return REVERTS_COMMIT_BODY_RE.test(body);
98
+ }
99
+
100
+ /**
101
+ * Resolve one commit's subject/body by (possibly abbreviated) sha, or `null` if the repo does not
102
+ * have it (shallow clone, rewritten history).
103
+ * @param {string} sha @param {string} cwd
104
+ * @returns {Promise<{subject: string, body: string} | null>}
105
+ */
106
+ async function lookupCommit(sha, cwd) {
107
+ const res = await exec(['git', 'log', '-1', `--pretty=format:%s${FIELD_SEP}%b`, `${sha}^{commit}`, '--'], { cwd, env: gitEnv() });
108
+ if (res.result !== 'ok') return null;
109
+ const [subject, body] = res.stdout.split(FIELD_SEP);
110
+ return { subject: subject ?? '', body: body ?? '' };
111
+ }
112
+
113
+ /**
114
+ * Revert depth decided by the commit RELATION, not the wording: a commit whose body says
115
+ * `This reverts commit <sha>` is one level deeper than `<sha>` itself. Only where that chain
116
+ * cannot be followed (no body marker, or `<sha>` not in this repo) does the subject decide. So a
117
+ * hand-edited "Restore feature X" that reverts a revert is depth 2 (a restore), not a revert.
118
+ * @param {{subject: string, body: string}} commit
119
+ * @param {(sha: string) => Promise<{subject: string, body: string} | null>} lookup
120
+ * @returns {Promise<number>}
121
+ */
122
+ async function revertDepth(commit, lookup) {
123
+ let levels = 0;
124
+ let current = commit;
125
+ const seen = new Set();
126
+ for (let i = 0; i < MAX_REVERT_CHAIN; i += 1) {
127
+ const match = REVERTS_COMMIT_BODY_RE.exec(current.body);
128
+ if (!match) return levels + subjectRevertDepth(current.subject);
129
+ const sha = match[1].toLowerCase();
130
+ const parent = seen.has(sha) ? null : await lookup(sha);
131
+ seen.add(sha);
132
+ if (!parent) return levels + Math.max(1, subjectRevertDepth(current.subject));
133
+ levels += 1;
134
+ current = parent;
135
+ }
136
+ return levels;
137
+ }
138
+
139
+ /** @param {string} cwd @returns {Promise<string>} the repo root, so relative paths always match `reviewed[].file`. */
140
+ async function repoRoot(cwd) {
141
+ const res = await exec(['git', 'rev-parse', '--show-toplevel'], { cwd, env: gitEnv() });
142
+ if (res.result !== 'ok') throw new Error(`git rev-parse --show-toplevel failed: ${res.stderr || res.error}`);
143
+ return res.stdout.trim();
144
+ }
145
+
146
+ /**
147
+ * @param {string} cwd @param {{since?: Date, until?: Date}} [range] - a coarse pre-filter so a
148
+ * large repo's `git log` output doesn't have to enumerate its whole history through `exec`'s
149
+ * buffer; per-file window checks still happen in JS afterward.
150
+ * @returns {Promise<Array<{hash: string, committedAt: string, subject: string, body: string}>>}
151
+ */
152
+ async function listCommits(cwd, range = {}) {
153
+ const args = ['git', 'log', `--pretty=format:%H${FIELD_SEP}%cI${FIELD_SEP}%s${FIELD_SEP}%b${RECORD_SEP}`];
154
+ if (range.since) args.push(`--since=${range.since.toISOString()}`);
155
+ if (range.until) args.push(`--until=${range.until.toISOString()}`);
156
+ const res = await exec(args, { cwd, env: gitEnv() });
157
+ if (res.result !== 'ok') throw new Error(`git log failed: ${res.stderr || res.error}`);
158
+ return res.stdout
159
+ .split(RECORD_SEP)
160
+ .map((chunk) => chunk.replace(/^\n/, ''))
161
+ .filter((chunk) => chunk.length > 0)
162
+ .map((chunk) => {
163
+ const [hash, committedAt, subject, body] = chunk.split(FIELD_SEP);
164
+ return { hash, committedAt, subject, body: body ?? '' };
165
+ });
166
+ }
167
+
168
+ /**
169
+ * `-c core.quotePath=false` — without it, git C-style-escapes a path containing non-ASCII or
170
+ * special characters (e.g. `src/é.mjs` → `"src/\303\251.mjs"`), which would never match a
171
+ * `reviewed[].file` entry and silently drop that file's revert.
172
+ * @param {string} hash @param {string} cwd @returns {Promise<string[]>}
173
+ */
174
+ async function changedFiles(hash, cwd) {
175
+ const res = await exec(['git', '-c', 'core.quotePath=false', 'diff-tree', '--no-commit-id', '--name-only', '-r', hash], {
176
+ cwd,
177
+ env: gitEnv(),
178
+ });
179
+ if (res.result !== 'ok') throw new Error(`git diff-tree failed for ${hash}: ${res.stderr || res.error}`);
180
+ return res.stdout.split('\n').filter((line) => line.length > 0);
181
+ }
182
+
183
+ /**
184
+ * Group `reviewed` entries by file, KEEPING EVERY REVIEW (fix round 2 — a version that kept only
185
+ * the latest review per file would miss a revert that lands between an EARLIER review and a LATER
186
+ * one: file X reviewed in block A, reverted 3 days later, then reviewed again in block B before
187
+ * any scan runs — the revert now sits before B's `reviewedAt`, and "only the latest review" would
188
+ * silently drop it). Each file's list is sorted ascending by `reviewedAt` so the scan loop can find
189
+ * the latest review STRICTLY BEFORE a given revert commit's time.
190
+ *
191
+ * An entry whose `reviewedAt` does not parse to a finite time is skipped outright — a NaN in the
192
+ * mix would otherwise poison every `Math.min`/`Math.max` over the group into NaN, and
193
+ * `new Date(NaN).toISOString()` throws, crashing the scan over ONE bad ledger row instead of just
194
+ * ignoring it.
195
+ * @param {Array<{file: string, reviewedAt: string|Date, block?: string, run?: string}>} reviewed
196
+ * @returns {Map<string, Array<{reviewedAt: Date, block?: string, run?: string}>>}
197
+ */
198
+ function reviewsByFile(reviewed) {
199
+ /** @type {Map<string, Array<{reviewedAt: Date, block?: string, run?: string}>>} */
200
+ const byFile = new Map();
201
+ for (const entry of reviewed) {
202
+ const reviewedAt = new Date(entry.reviewedAt);
203
+ if (!Number.isFinite(reviewedAt.getTime())) continue;
204
+ const list = byFile.get(entry.file) ?? [];
205
+ list.push({ reviewedAt, block: entry.block, run: entry.run });
206
+ byFile.set(entry.file, list);
207
+ }
208
+ for (const list of byFile.values()) list.sort((a, b) => a.reviewedAt.getTime() - b.reviewedAt.getTime());
209
+ return byFile;
210
+ }
211
+
212
+ /**
213
+ * The latest review in `list` (ascending-sorted by `reviewedAt`) strictly BEFORE `commitTimeMs` —
214
+ * the review a revert at that time is judged against — or `undefined` if none qualifies.
215
+ * @param {Array<{reviewedAt: Date}>} list @param {number} commitTimeMs
216
+ */
217
+ function latestReviewBefore(list, commitTimeMs) {
218
+ let found;
219
+ for (const entry of list) {
220
+ if (entry.reviewedAt.getTime() < commitTimeMs) found = entry;
221
+ else break; // sorted ascending — nothing later in the list can qualify either
222
+ }
223
+ return found;
224
+ }
225
+
226
+ /**
227
+ * `reviewed[].file` as given may be relative to a subdirectory `cwd`, or absolute; git's own
228
+ * output (`diff-tree --name-only`) is always relative to the REPO ROOT. Without normalizing both
229
+ * sides to the same base, a ledger that stores paths relative to `cwd` would never match anything
230
+ * and silently return 0 rows.
231
+ *
232
+ * `root` (from `git rev-parse --show-toplevel`) is ALREADY the real, symlink-resolved path — on
233
+ * macOS, `os.tmpdir()`/a raw `cwd` is typically `/var/folders/...`, a symlink to
234
+ * `/private/var/folders/...`, which is what git actually reports. Comparing an unresolved `cwd`
235
+ * against the resolved `root` with plain string-based `path.relative` produces a bogus `../../...`
236
+ * chain instead of collapsing to the real relative path, so `cwd` is realpath'd here too.
237
+ * @param {string} root @param {string} cwd @param {string} file
238
+ * @returns {Promise<string>}
239
+ */
240
+ async function toRepoRootRelative(root, cwd, file) {
241
+ if (path.isAbsolute(file)) return path.relative(root, await realpathOfNearestAncestor(file));
242
+ const realCwd = await realpath(cwd).catch(() => cwd);
243
+ return path.relative(root, path.resolve(realCwd, file));
244
+ }
245
+
246
+ /**
247
+ * `realpath` of `p`, or — when `p` no longer exists (the revert deleted it) — of its nearest
248
+ * EXISTING ancestor with the missing tail joined back on (fix round 3). Falling back to the raw
249
+ * path instead left macOS's `/var` ↔ `/private/var` symlink unresolved, so a deleted file's revert
250
+ * produced a bogus `../..` path and was silently missed.
251
+ * @param {string} p @returns {Promise<string>}
252
+ */
253
+ async function realpathOfNearestAncestor(p) {
254
+ const tail = [];
255
+ let current = p;
256
+ for (;;) {
257
+ try {
258
+ return path.join(await realpath(current), ...tail);
259
+ } catch {
260
+ const parent = path.dirname(current);
261
+ if (parent === current) return p;
262
+ tail.unshift(path.basename(current));
263
+ current = parent;
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Walk `cwd`'s git log for revert-shaped commits that land within `days` days AFTER a reviewed
270
+ * file's OWN `reviewedAt` (plan §6.3 caller 4, C12) — never a window relative to when the scan
271
+ * happens to run, and never only the file's single latest review (see `reviewsByFile`).
272
+ * @param {{cwd: string, reviewed: Array<{file: string, reviewedAt: string|Date, block?: string, run?: string}>, days?: number, now?: Date}} args
273
+ * @returns {Promise<any[]>} outcome rows found — not yet appended to the ledger.
274
+ */
275
+ export async function scanGitOutcomes({ cwd, reviewed, days = 7, now = new Date() }) {
276
+ if (!Number.isFinite(days) || days <= 0) {
277
+ throw new RangeError(`scanGitOutcomes: days must be a finite number > 0, got ${days}`);
278
+ }
279
+ const root = await repoRoot(cwd);
280
+ const normalized = await Promise.all(reviewed.map(async (entry) => ({ ...entry, file: await toRepoRootRelative(root, cwd, entry.file) })));
281
+ const byFile = reviewsByFile(normalized);
282
+ if (byFile.size === 0) return [];
283
+
284
+ // A loop, not Math.min(...all) — spreading one argument per review.done row overflows the call
285
+ // stack on a large ledger.
286
+ let minMs = Infinity;
287
+ let maxMs = -Infinity;
288
+ for (const list of byFile.values()) {
289
+ for (const r of list) {
290
+ const t = r.reviewedAt.getTime();
291
+ if (t < minMs) minMs = t;
292
+ if (t > maxMs) maxMs = t;
293
+ }
294
+ }
295
+ const windowMs = days * 24 * 60 * 60 * 1000;
296
+ const since = new Date(minMs);
297
+ const until = new Date(Math.min(maxMs + windowMs, now.getTime()));
298
+
299
+ const commits = await listCommits(root, { since, until });
300
+ /** @type {Map<string, Promise<{subject: string, body: string} | null>>} */
301
+ const lookupCache = new Map();
302
+ for (const c of commits) lookupCache.set(c.hash, Promise.resolve(c));
303
+ const lookup = (sha) => {
304
+ const known = sha.length === 40 ? lookupCache.get(sha) : undefined;
305
+ if (known) return known;
306
+ if (!lookupCache.has(sha)) lookupCache.set(sha, lookupCommit(sha, root));
307
+ return /** @type {Promise<{subject: string, body: string} | null>} */ (lookupCache.get(sha));
308
+ };
309
+
310
+ const rows = [];
311
+ for (const commit of commits) {
312
+ // Cheap pre-filter: a commit with neither a revert/reapply subject nor the body marker is not
313
+ // part of any revert chain.
314
+ if (subjectRevertDepth(commit.subject) === 0 && !REVERTS_COMMIT_BODY_RE.test(commit.body)) continue;
315
+ if ((await revertDepth(commit, lookup)) % 2 !== 1) continue; // even ⇒ restores the change
316
+ const committedAtMs = new Date(commit.committedAt).getTime();
317
+ if (committedAtMs > now.getTime()) continue; // future-dated / skewed committer clock
318
+ for (const file of await changedFiles(commit.hash, root)) {
319
+ const list = byFile.get(file);
320
+ if (!list) continue;
321
+ const review = latestReviewBefore(list, committedAtMs);
322
+ if (!review) continue; // no review of this file precedes the revert at all
323
+ if (committedAtMs > review.reviewedAt.getTime() + windowMs) continue; // outside THAT review's window
324
+ rows.push({
325
+ event: 'outcome',
326
+ outcome: 'reverted',
327
+ outcome_source: 'git',
328
+ file,
329
+ commit: commit.hash,
330
+ subject: commit.subject,
331
+ committed_at: commit.committedAt,
332
+ ...(review.block ? { block: review.block } : {}),
333
+ ...(review.run ? { run: review.run } : {}),
334
+ });
335
+ }
336
+ }
337
+ return rows;
338
+ }
339
+
340
+ /** @param {object} outcomeData @param {{slug: string}} opts */
341
+ export const recordOutcome = (outcomeData, opts) => appendRow({ event: 'outcome', ...outcomeData }, opts);
342
+
343
+ /**
344
+ * Scan + persist in one call — what `forge ledger outcome --scan-git` runs. IDEMPOTENT: skips any
345
+ * (commit, file) pair that already has a `git`-sourced outcome row for this slug, so running it
346
+ * twice (a re-triggered CI job, a human re-running it) does not double-count the same revert.
347
+ * @param {{cwd: string, reviewed: Array<{file: string, reviewedAt: string|Date, block?: string, run?: string}>, days?: number, now?: Date, slug: string}} args
348
+ * @returns {Promise<any[]>} the outcome rows actually appended (excludes already-recorded ones).
349
+ */
350
+ export async function scanGitAndRecord({ cwd, reviewed, days, now, slug }) {
351
+ const found = await scanGitOutcomes({ cwd, reviewed, days, now });
352
+ const existing = await readAllRows(slug);
353
+ const already = new Set(
354
+ existing.filter((r) => r.event === 'outcome' && r.outcome_source === 'git').map((r) => `${r.commit}::${r.file}`),
355
+ );
356
+ const written = [];
357
+ for (const row of found) {
358
+ const key = `${row.commit}::${row.file}`;
359
+ if (already.has(key)) continue;
360
+ written.push(await recordOutcome(row, { slug }));
361
+ already.add(key);
362
+ }
363
+ return written;
364
+ }
365
+
366
+ /**
367
+ * Build the `reviewed` argument for `scanGitOutcomes`/`scanGitAndRecord` from a ledger's own rows:
368
+ * EVERY `review.done` row becomes its own entry (fix round 2 — collapsing to one-per-file here
369
+ * would silently undo `scanGitOutcomes`'s "keep every review" fix: a file reviewed twice, with a
370
+ * revert landing between the two reviews, needs BOTH review timestamps present so the revert can
371
+ * be matched to the one that actually preceded it).
372
+ * @param {any[]} rows
373
+ * @returns {Array<{file: string, reviewedAt: string, block?: string}>}
374
+ */
375
+ export function reviewedEntriesFromRows(rows) {
376
+ return rows
377
+ .filter((r) => r.event === 'review.done' && r.file && r.ts)
378
+ .map((r) => ({ file: r.file, reviewedAt: r.ts, ...(r.block ? { block: r.block } : {}) }));
379
+ }
380
+
381
+ const CI_TO_OUTCOME = Object.freeze({ red: 'wrong', green: 'correct', reverted: 'reverted' });
382
+
383
+ /**
384
+ * `forge ledger outcome --pr <n> --ci red|green|reverted` (plan §6.3 caller 3, the CI template).
385
+ * @param {{pr: number, ci: 'red'|'green'|'reverted', slug: string}} args
386
+ */
387
+ export function recordCiOutcome({ pr, ci, slug }) {
388
+ // Object.hasOwn (not `CI_TO_OUTCOME[ci]` truthiness) so `--ci toString`/`--ci constructor` can't
389
+ // walk the prototype chain to a function and slip past the RangeError guard below.
390
+ if (!Object.hasOwn(CI_TO_OUTCOME, ci)) {
391
+ throw new RangeError(`recordCiOutcome: unknown --ci value "${ci}"`);
392
+ }
393
+ if (!Number.isInteger(pr) || pr <= 0) {
394
+ throw new RangeError(`recordCiOutcome: pr must be a positive integer, got ${pr}`);
395
+ }
396
+ return recordOutcome({ outcome: CI_TO_OUTCOME[ci], outcome_source: 'ci', pr }, { slug });
397
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where the ledger lives on disk: `~/.code-forge/ledger/<slug>.jsonl` (plan §1.2, §6).
3
+ * `os.homedir()` re-reads `process.env.HOME` on every call (POSIX), so a test that points `HOME`
4
+ * at a temp dir before calling in gets a fully isolated ledger — nothing here ever touches a
5
+ * real `~/.code-forge/`.
6
+ */
7
+
8
+ import { homedir } from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ /** A project slug: lowercase, digits, hyphens — same shape `project.slug` takes in the schema. */
12
+ const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
13
+
14
+ /** @returns {string} */
15
+ export function ledgerDir() {
16
+ return path.join(homedir(), '.code-forge', 'ledger');
17
+ }
18
+
19
+ /**
20
+ * @param {string} slug
21
+ * @returns {string}
22
+ * @throws {TypeError} on an empty/invalid slug — a bad slug must never resolve to a path outside
23
+ * `ledgerDir()` (e.g. via `..` or `/`), so this rejects anything that isn't a plain slug token.
24
+ */
25
+ export function ledgerPath(slug) {
26
+ if (typeof slug !== 'string' || !SLUG_PATTERN.test(slug)) {
27
+ throw new TypeError(`ledgerPath: slug must match ${SLUG_PATTERN} (got ${JSON.stringify(slug)})`);
28
+ }
29
+ return path.join(ledgerDir(), `${slug}.jsonl`);
30
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 0.1's static cost table (plan §4.10, R4): forecast tokens per review depth, and a $/1K-token
3
+ * price table used ONLY to estimate `cost_usd`. Fixture figures, never a live price feed — no
4
+ * network call ever backs a number here; the 0.2 ledger-averaged forecast replaces this table once
5
+ * ≥ 10 real samples exist per depth (§4.10).
6
+ */
7
+
8
+ /** Estimated total (in+out) review tokens per depth — §4.10's static table, verbatim. */
9
+ export const REVIEW_DEPTH_TOKENS = Object.freeze({
10
+ quick: 7000,
11
+ full: 13000,
12
+ 'a-b-judge': 34000,
13
+ consensus: 30000,
14
+ });
15
+
16
+ /** $ per 1000 tokens (blended in+out) by provider and level — placeholder fixture prices. */
17
+ export const PRICE_PER_1K_TOKENS_USD = Object.freeze({
18
+ anthropic: Object.freeze({ L0: 0.001, L1: 0.003, L2: 0.015, L3: 0.03 }),
19
+ openai: Object.freeze({ L0: 0.001, L1: 0.0025, L2: 0.012, L3: 0.025 }),
20
+ xai: Object.freeze({ L0: 0.001, L1: 0.002, L2: 0.01, L3: 0.02 }),
21
+ });
22
+
23
+ /**
24
+ * @param {{provider: string, level: string, tokensIn?: number, tokensOut?: number}} args
25
+ * @returns {number} an ESTIMATED cost in USD, rounded to 4 decimal places.
26
+ */
27
+ export function estimateCostUsd({ provider, level, tokensIn = 0, tokensOut = 0 }) {
28
+ const table = PRICE_PER_1K_TOKENS_USD[provider];
29
+ if (!table) throw new RangeError(`estimateCostUsd: unknown provider "${provider}"`);
30
+ const perK = table[level];
31
+ if (typeof perK !== 'number') throw new RangeError(`estimateCostUsd: unknown level "${level}" for "${provider}"`);
32
+ // A ledger row's counts come from a JSON.parse'd file, not a type system — a string ("100"+"50"
33
+ // string-concatenates instead of adding), NaN, or a negative count must fail loudly here rather
34
+ // than quietly corrupting every downstream cost total.
35
+ if (!Number.isFinite(tokensIn) || tokensIn < 0) throw new RangeError(`estimateCostUsd: tokensIn must be a finite number >= 0, got ${JSON.stringify(tokensIn)}`);
36
+ if (!Number.isFinite(tokensOut) || tokensOut < 0) throw new RangeError(`estimateCostUsd: tokensOut must be a finite number >= 0, got ${JSON.stringify(tokensOut)}`);
37
+ return Math.round(((tokensIn + tokensOut) / 1000) * perK * 10000) / 10000;
38
+ }
39
+
40
+ /**
41
+ * @param {string} depth - one of `quick`, `full`, `a-b-judge`, `consensus`; anything else throws
42
+ * (a `string`, not the literal union, since a CLI/ledger caller has no compile-time guarantee).
43
+ * @returns {number}
44
+ */
45
+ export function forecastTokensForDepth(depth) {
46
+ const tokens = REVIEW_DEPTH_TOKENS[depth];
47
+ if (typeof tokens !== 'number') throw new RangeError(`forecastTokensForDepth: unknown depth "${depth}"`);
48
+ return tokens;
49
+ }