@izkac/forgekit 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 (143) hide show
  1. package/bin/forge.mjs +100 -0
  2. package/bin/forgekit.mjs +84 -0
  3. package/bin/review.mjs +82 -0
  4. package/package.json +46 -0
  5. package/scripts/prepack.mjs +78 -0
  6. package/scripts/run-tests.mjs +43 -0
  7. package/src/adr.mjs +236 -0
  8. package/src/adr.test.mjs +170 -0
  9. package/src/change.mjs +234 -0
  10. package/src/change.test.mjs +83 -0
  11. package/src/cleanup-sessions.mjs +70 -0
  12. package/src/config.mjs +103 -0
  13. package/src/defer.mjs +75 -0
  14. package/src/doctor.mjs +341 -0
  15. package/src/doctor.test.mjs +114 -0
  16. package/src/init.mjs +575 -0
  17. package/src/install.mjs +777 -0
  18. package/src/install.test.mjs +104 -0
  19. package/src/integrity-check.mjs +58 -0
  20. package/src/integrity.mjs +317 -0
  21. package/src/integrity.test.mjs +296 -0
  22. package/src/lib/workspaces.mjs +55 -0
  23. package/src/lib.mjs +138 -0
  24. package/src/models.defaults.json +41 -0
  25. package/src/new-session.mjs +82 -0
  26. package/src/openspec-overlays/README.md +19 -0
  27. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -0
  28. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -0
  29. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -0
  30. package/src/paths.mjs +92 -0
  31. package/src/plan-engine.mjs +260 -0
  32. package/src/plan-engine.test.mjs +245 -0
  33. package/src/preferences.defaults.json +78 -0
  34. package/src/preferences.mjs +438 -0
  35. package/src/preferences.test.mjs +174 -0
  36. package/src/record-evidence.mjs +204 -0
  37. package/src/record-evidence.test.mjs +260 -0
  38. package/src/resolve-model.mjs +312 -0
  39. package/src/resolve-model.test.mjs +194 -0
  40. package/src/review/carryforward.mjs +413 -0
  41. package/src/review/carryforward.test.mjs +587 -0
  42. package/src/review/cli.test.mjs +117 -0
  43. package/src/review/export.mjs +172 -0
  44. package/src/review/export.test.mjs +197 -0
  45. package/src/review/fixtures/valid-review.json +42 -0
  46. package/src/review/lib.mjs +894 -0
  47. package/src/review/lib.test.mjs +266 -0
  48. package/src/review/merge-tentative.mjs +292 -0
  49. package/src/review/merge-tentative.test.mjs +363 -0
  50. package/src/review/new-review.mjs +200 -0
  51. package/src/review/render.mjs +108 -0
  52. package/src/review/schema-consistency.test.mjs +83 -0
  53. package/src/review/schema.json +196 -0
  54. package/src/review/signals.mjs +144 -0
  55. package/src/review/signals.test.mjs +62 -0
  56. package/src/score-cli.mjs +68 -0
  57. package/src/score.mjs +489 -0
  58. package/src/score.test.mjs +253 -0
  59. package/src/session-reminder.mjs +168 -0
  60. package/src/session-status.mjs +70 -0
  61. package/src/set-models.mjs +186 -0
  62. package/src/set-phase.mjs +177 -0
  63. package/src/set-phase.test.mjs +317 -0
  64. package/src/set-prefs.mjs +294 -0
  65. package/src/spine.mjs +91 -0
  66. package/src/triage-prompt.mjs +175 -0
  67. package/src/triage-prompt.test.mjs +50 -0
  68. package/src/vendor-openspec-overlays.mjs +176 -0
  69. package/src/vendor-openspec-overlays.test.mjs +62 -0
  70. package/vendor/skills/archive-to-adr/SKILL.md +149 -0
  71. package/vendor/skills/forge/SKILL.md +136 -0
  72. package/vendor/skills/forge/phases/brainstorm.md +23 -0
  73. package/vendor/skills/forge/phases/finish.md +87 -0
  74. package/vendor/skills/forge/phases/implement.md +76 -0
  75. package/vendor/skills/forge/phases/plan-openspec.md +40 -0
  76. package/vendor/skills/forge/phases/plan-specs.md +97 -0
  77. package/vendor/skills/forge/phases/review.md +25 -0
  78. package/vendor/skills/forge/phases/verify.md +120 -0
  79. package/vendor/skills/forge/references/forge-layout.md +85 -0
  80. package/vendor/skills/forge/references/pace.md +115 -0
  81. package/vendor/skills/forge/references/plan-routing.md +51 -0
  82. package/vendor/skills/forge/references/runtime-integrity.md +157 -0
  83. package/vendor/skills/forge/references/substantial-work.md +37 -0
  84. package/vendor/skills/forge/references/tdd-core.md +29 -0
  85. package/vendor/skills/forge/references/test-evidence.md +30 -0
  86. package/vendor/skills/forge/references/test-strategy.md +68 -0
  87. package/vendor/skills/forge/skills/NOTICE.md +17 -0
  88. package/vendor/skills/forge/skills/brainstorming/SKILL.md +120 -0
  89. package/vendor/skills/forge/skills/requesting-code-review/SKILL.md +67 -0
  90. package/vendor/skills/forge/skills/requesting-code-review/code-reviewer.md +146 -0
  91. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -0
  92. package/vendor/skills/forge/skills/systematic-debugging/SKILL.md +234 -0
  93. package/vendor/skills/forge/skills/systematic-debugging/condition-based-waiting.md +115 -0
  94. package/vendor/skills/forge/skills/systematic-debugging/defense-in-depth.md +122 -0
  95. package/vendor/skills/forge/skills/systematic-debugging/find-polluter.sh +63 -0
  96. package/vendor/skills/forge/skills/systematic-debugging/root-cause-tracing.md +169 -0
  97. package/vendor/skills/forge/skills/test-driven-development/SKILL.md +290 -0
  98. package/vendor/skills/forge/skills/test-driven-development/testing-anti-patterns.md +299 -0
  99. package/vendor/skills/forge/skills/verification-before-completion/SKILL.md +59 -0
  100. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +53 -0
  101. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -0
  102. package/vendor/skills/forge/subagents/task-reviewer-prompt.md +61 -0
  103. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -0
  104. package/vendor/skills/thorough-code-review/SKILL.md +290 -0
  105. package/vendor/skills/thorough-code-review/examples/accepted-risks-janus.md +32 -0
  106. package/vendor/skills/thorough-code-review/examples.md +133 -0
  107. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -0
  108. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -0
  109. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -0
  110. package/vendor/skills/thorough-code-review/reference/phase1c-coverage.md +44 -0
  111. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -0
  112. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -0
  113. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -0
  114. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -0
  115. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -0
  116. package/vendor/templates/adr/README.md +7 -0
  117. package/vendor/templates/adr/decisions.md +141 -0
  118. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -0
  119. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -0
  120. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -0
  121. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -0
  122. package/vendor/templates/project/claude/commands/forge-apply.md +75 -0
  123. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -0
  124. package/vendor/templates/project/claude/commands/forge-build.md +17 -0
  125. package/vendor/templates/project/claude/commands/forge-plan.md +12 -0
  126. package/vendor/templates/project/claude/commands/forge-skip.md +14 -0
  127. package/vendor/templates/project/claude/commands/forge-status.md +16 -0
  128. package/vendor/templates/project/claude/commands/forge.md +16 -0
  129. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -0
  130. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -0
  131. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -0
  132. package/vendor/templates/project/claude/rules/forge.md +16 -0
  133. package/vendor/templates/project/codex/rules/forge.md +10 -0
  134. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -0
  135. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -0
  136. package/vendor/templates/project/cursor/commands/forge-build.md +17 -0
  137. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -0
  138. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -0
  139. package/vendor/templates/project/cursor/commands/forge-status.md +16 -0
  140. package/vendor/templates/project/cursor/commands/forge.md +16 -0
  141. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -0
  142. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -0
  143. package/vendor/templates/project/cursor/rules/forge.mdc +21 -0
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Carry verified findings forward from a parent review into a newer report.
4
+ *
5
+ * A finding is carried only when the file it points at is unchanged between
6
+ * the parent review's `scope.head_sha` and the current HEAD (and the file
7
+ * actually exists at that SHA) — otherwise the finding is skipped with a
8
+ * printed reason, because a re-verify against changed code is required.
9
+ *
10
+ * Carried findings get a fresh sequential F-### id continuing the target's
11
+ * numbering, and their `verdict_reason` is prefixed with the provenance
12
+ * (`Carried forward from <parent review_id> (unchanged since <sha8>): ...`).
13
+ * The target's summary verdict tallies are recomputed and the result must
14
+ * pass {@link validateReport} before anything is written.
15
+ *
16
+ * Usage:
17
+ * review carryforward --parent <reviewId|path> [options]
18
+ *
19
+ * Options:
20
+ * --parent <id|path> Parent review id (resolved to .reviews/<id>-review.json)
21
+ * or an explicit JSON path (required)
22
+ * --file <path> Target review JSON (default: newest *-review.json in
23
+ * .reviews/ that is not the parent and not kind=reverify)
24
+ * --dry-run Print the carry/skip plan, write nothing
25
+ * --reviews-dir <dir> Reviews directory (default: .reviews)
26
+ * --repo <dir> Git repo to diff against (default: cwd)
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { execFileSync } from 'node:child_process';
32
+ import { pathToFileURL } from 'node:url';
33
+ import { REVIEWS_DIR, ALL_VERDICTS, countByVerdict, validateReport } from './lib.mjs';
34
+
35
+ /**
36
+ * @param {string[]} argv
37
+ */
38
+ export function parseArgs(argv) {
39
+ const opts = {
40
+ parent: null,
41
+ file: null,
42
+ dryRun: false,
43
+ reviewsDir: REVIEWS_DIR,
44
+ repo: null,
45
+ help: false,
46
+ };
47
+
48
+ for (let i = 0; i < argv.length; i += 1) {
49
+ const arg = argv[i];
50
+ if (arg === '--parent') opts.parent = argv[++i];
51
+ else if (arg === '--file') opts.file = argv[++i];
52
+ else if (arg === '--dry-run') opts.dryRun = true;
53
+ else if (arg === '--reviews-dir') opts.reviewsDir = argv[++i];
54
+ else if (arg === '--repo') opts.repo = argv[++i];
55
+ else if (arg === '--help' || arg === '-h') opts.help = true;
56
+ else throw new Error(`unknown argument: ${arg}`);
57
+ }
58
+
59
+ return opts;
60
+ }
61
+
62
+ /**
63
+ * Extract the file path from a finding location by stripping a trailing
64
+ * `:line` or `:start-end` suffix.
65
+ *
66
+ * @param {string} location
67
+ * @returns {string}
68
+ */
69
+ export function locationFile(location) {
70
+ return String(location).replace(/:\d+(-\d+)?$/, '');
71
+ }
72
+
73
+ /**
74
+ * Next sequential F-### id continuing the given findings' numbering
75
+ * (dup-### ids are ignored).
76
+ *
77
+ * @param {Array<{ id?: string }>} findings
78
+ * @returns {string}
79
+ */
80
+ export function nextFindingId(findings) {
81
+ let max = 0;
82
+ for (const f of findings) {
83
+ const m = /^F-(\d{3})$/.exec(f.id ?? '');
84
+ if (m) max = Math.max(max, Number(m[1]));
85
+ }
86
+ return `F-${String(max + 1).padStart(3, '0')}`;
87
+ }
88
+
89
+ /**
90
+ * Copy a parent finding into a carried finding: fresh id, provenance-prefixed
91
+ * verdict_reason, everything else preserved.
92
+ *
93
+ * @param {Record<string, unknown>} finding
94
+ * @param {{ id: string, parentReviewId: string, shortSha: string }} ctx
95
+ * @returns {Record<string, unknown>}
96
+ */
97
+ export function buildCarriedFinding(finding, ctx) {
98
+ /** @type {Record<string, unknown>} */
99
+ const carried = {
100
+ id: ctx.id,
101
+ lens: finding.lens,
102
+ location: finding.location,
103
+ claim: finding.claim,
104
+ severity: finding.severity,
105
+ verdict: finding.verdict,
106
+ verdict_reason:
107
+ `Carried forward from ${ctx.parentReviewId} (unchanged since ${ctx.shortSha}): ` +
108
+ `${finding.verdict_reason ?? ''}`,
109
+ };
110
+ for (const key of ['title', 'evidence', 'original_severity', 'phase1_confidence']) {
111
+ if (finding[key] !== undefined) carried[key] = finding[key];
112
+ }
113
+ return carried;
114
+ }
115
+
116
+ /**
117
+ * Recompute the target's summary verdict tallies from its findings so it
118
+ * reconciles with lib.mjs `validateSummary`: every verdict with a non-zero
119
+ * tally (or an already-present key) is set to the actual count, and
120
+ * `tentative_count` is bumped to at least the findings count. Non-count
121
+ * summary fields (headline, top_actions, ...) are preserved.
122
+ *
123
+ * @param {Record<string, unknown>} report Mutated in place.
124
+ */
125
+ export function recomputeSummary(report) {
126
+ const summary = /** @type {Record<string, unknown>} */ (report.summary ?? {});
127
+ const counts = countByVerdict(report);
128
+ for (const verdict of ALL_VERDICTS) {
129
+ if (counts[verdict] > 0 || summary[verdict] !== undefined) {
130
+ summary[verdict] = counts[verdict];
131
+ }
132
+ }
133
+ const findings = /** @type {unknown[]} */ (report.findings ?? []);
134
+ const tentative = typeof summary.tentative_count === 'number' ? summary.tentative_count : 0;
135
+ summary.tentative_count = Math.max(tentative, findings.length);
136
+ report.summary = summary;
137
+ }
138
+
139
+ /**
140
+ * Decide, per parent finding with a verdict, whether it carries into the
141
+ * target or is skipped (and why). Pure apart from the injected git ops.
142
+ *
143
+ * @param {Record<string, unknown>} parentReport
144
+ * @param {Record<string, unknown>} targetReport
145
+ * @param {{
146
+ * fileChanged: (sha: string, file: string) => boolean,
147
+ * fileExistsAt: (sha: string, file: string) => boolean,
148
+ * }} gitOps
149
+ * @returns {{
150
+ * carries: Array<{ parentId: string, finding: Record<string, unknown> }>,
151
+ * skips: Array<{ parentId: string, reason: string }>,
152
+ * shortSha: string | null,
153
+ * }}
154
+ */
155
+ export function planCarries(parentReport, targetReport, gitOps) {
156
+ const scope = /** @type {{ head_sha?: unknown }} */ (parentReport.scope ?? {});
157
+ const headSha = typeof scope.head_sha === 'string' && scope.head_sha ? scope.head_sha : null;
158
+ const shortSha = headSha ? headSha.slice(0, 8) : null;
159
+
160
+ const parentFindings = /** @type {Array<Record<string, unknown>>} */ (
161
+ parentReport.findings ?? []
162
+ );
163
+ const targetFindings = /** @type {Array<Record<string, unknown>>} */ (
164
+ targetReport.findings ?? []
165
+ );
166
+ const present = new Set(targetFindings.map((f) => `${f.location}\u0000${f.claim}`));
167
+
168
+ /** @type {Array<{ parentId: string, finding: Record<string, unknown> }>} */
169
+ const carries = [];
170
+ /** @type {Array<{ parentId: string, reason: string }>} */
171
+ const skips = [];
172
+ const numbered = [...targetFindings];
173
+
174
+ for (const finding of parentFindings) {
175
+ if (finding.verdict === undefined) continue;
176
+ const parentId = /** @type {string} */ (finding.id ?? '(no id)');
177
+
178
+ if (!headSha) {
179
+ skips.push({ parentId, reason: 'parent report has no scope.head_sha' });
180
+ continue;
181
+ }
182
+ if (present.has(`${finding.location}\u0000${finding.claim}`)) {
183
+ skips.push({ parentId, reason: 'already present in target (same location and claim)' });
184
+ continue;
185
+ }
186
+
187
+ const file = locationFile(/** @type {string} */ (finding.location ?? ''));
188
+ let changed;
189
+ try {
190
+ changed = gitOps.fileChanged(headSha, file);
191
+ } catch (err) {
192
+ skips.push({ parentId, reason: `git error: ${/** @type {Error} */ (err).message}` });
193
+ continue;
194
+ }
195
+ if (changed) {
196
+ skips.push({ parentId, reason: `${file} changed since ${shortSha}` });
197
+ continue;
198
+ }
199
+ if (!gitOps.fileExistsAt(headSha, file)) {
200
+ skips.push({ parentId, reason: `${file} does not exist at parent head_sha ${shortSha}` });
201
+ continue;
202
+ }
203
+
204
+ const carried = buildCarriedFinding(finding, {
205
+ id: nextFindingId(numbered),
206
+ parentReviewId: /** @type {string} */ (parentReport.review_id ?? '(unknown)'),
207
+ shortSha: /** @type {string} */ (shortSha),
208
+ });
209
+ numbered.push(carried);
210
+ present.add(`${carried.location}\u0000${carried.claim}`);
211
+ carries.push({ parentId, finding: carried });
212
+ }
213
+
214
+ return { carries, skips, shortSha };
215
+ }
216
+
217
+ /**
218
+ * Resolve `--parent` — a review id maps to `<reviewsDir>/<id>-review.json`,
219
+ * anything that exists as a path is used verbatim.
220
+ *
221
+ * @param {string} parent
222
+ * @param {string} reviewsDir
223
+ * @param {string} cwd
224
+ * @returns {string | null}
225
+ */
226
+ function resolveParentPath(parent, reviewsDir, cwd) {
227
+ const asPath = path.isAbsolute(parent) ? parent : path.resolve(cwd, parent);
228
+ if (fs.existsSync(asPath) && fs.statSync(asPath).isFile()) return asPath;
229
+ const asId = path.join(reviewsDir, `${parent}-review.json`);
230
+ if (fs.existsSync(asId)) return asId;
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * Default target: newest `*-review.json` in the reviews dir that is not the
236
+ * parent and whose `kind` is not `reverify`.
237
+ *
238
+ * @param {string} reviewsDir
239
+ * @param {string} parentPath
240
+ * @returns {string | null}
241
+ */
242
+ function findDefaultTarget(reviewsDir, parentPath) {
243
+ if (!fs.existsSync(reviewsDir)) return null;
244
+ const candidates = fs
245
+ .readdirSync(reviewsDir)
246
+ .filter((name) => name.endsWith('-review.json'))
247
+ .map((name) => path.join(reviewsDir, name))
248
+ .filter((full) => path.resolve(full) !== path.resolve(parentPath))
249
+ .filter((full) => {
250
+ try {
251
+ const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
252
+ return parsed?.kind !== 'reverify';
253
+ } catch {
254
+ return false;
255
+ }
256
+ })
257
+ .map((full) => ({ full, mtime: fs.statSync(full).mtimeMs }))
258
+ .sort((a, b) => b.mtime - a.mtime);
259
+ return candidates[0]?.full ?? null;
260
+ }
261
+
262
+ /**
263
+ * @param {string} repo
264
+ * @returns {{ fileChanged: (sha: string, file: string) => boolean, fileExistsAt: (sha: string, file: string) => boolean }}
265
+ */
266
+ function makeGitOps(repo) {
267
+ return {
268
+ fileChanged(sha, file) {
269
+ // <sha> vs working tree (not <sha>..HEAD): uncommitted edits must block a carry
270
+ // in this repo's commit-late workflow.
271
+ const out = execFileSync('git', ['diff', '--name-only', sha, '--', file], {
272
+ cwd: repo,
273
+ encoding: 'utf8',
274
+ });
275
+ return out.trim() !== '';
276
+ },
277
+ fileExistsAt(sha, file) {
278
+ try {
279
+ execFileSync('git', ['cat-file', '-e', `${sha}:${file}`], { cwd: repo, stdio: 'ignore' });
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ },
285
+ };
286
+ }
287
+
288
+ /**
289
+ * @param {string} jsonPath
290
+ * @returns {{ ok: true, report: Record<string, unknown> } | { ok: false, message: string }}
291
+ */
292
+ function loadReport(jsonPath) {
293
+ if (!fs.existsSync(jsonPath)) {
294
+ return { ok: false, message: `file not found: ${jsonPath}` };
295
+ }
296
+ try {
297
+ return { ok: true, report: JSON.parse(fs.readFileSync(jsonPath, 'utf8')) };
298
+ } catch (err) {
299
+ return { ok: false, message: `invalid JSON in ${jsonPath}: ${/** @type {Error} */ (err).message}` };
300
+ }
301
+ }
302
+
303
+ /**
304
+ * @param {ReturnType<typeof parseArgs>} opts
305
+ * @param {string} [cwd]
306
+ * @returns {{ exitCode: number; message: string }}
307
+ */
308
+ export function runCarryforward(opts, cwd = process.cwd()) {
309
+ if (!opts.parent) {
310
+ return { exitCode: 1, message: '--parent <reviewId|path> is required' };
311
+ }
312
+
313
+ const reviewsDir = path.resolve(cwd, opts.reviewsDir ?? REVIEWS_DIR);
314
+ const repo = opts.repo ? path.resolve(cwd, opts.repo) : cwd;
315
+
316
+ const parentPath = resolveParentPath(opts.parent, reviewsDir, cwd);
317
+ if (!parentPath) {
318
+ return {
319
+ exitCode: 1,
320
+ message: `parent review not found: ${opts.parent} (looked for a file and for ${path.join(reviewsDir, `${opts.parent}-review.json`)})`,
321
+ };
322
+ }
323
+ const parentLoaded = loadReport(parentPath);
324
+ if (!parentLoaded.ok) return { exitCode: 1, message: parentLoaded.message };
325
+ const parentReport = parentLoaded.report;
326
+
327
+ const targetPath = opts.file
328
+ ? path.isAbsolute(opts.file)
329
+ ? opts.file
330
+ : path.resolve(cwd, opts.file)
331
+ : findDefaultTarget(reviewsDir, parentPath);
332
+ if (!targetPath) {
333
+ return { exitCode: 1, message: `no target review JSON found in ${reviewsDir}` };
334
+ }
335
+ const targetLoaded = loadReport(targetPath);
336
+ if (!targetLoaded.ok) return { exitCode: 1, message: targetLoaded.message };
337
+ const targetReport = targetLoaded.report;
338
+
339
+ const plan = planCarries(parentReport, targetReport, makeGitOps(repo));
340
+
341
+ const lines = [`parent: ${parentPath}`, `target: ${targetPath}`, ''];
342
+ for (const c of plan.carries) {
343
+ lines.push(`CARRY ${c.parentId} -> ${c.finding.id} ${c.finding.location}`);
344
+ }
345
+ for (const s of plan.skips) {
346
+ lines.push(`SKIP ${s.parentId} ${s.reason}`);
347
+ }
348
+ lines.push('');
349
+ lines.push(`${plan.carries.length} carried, ${plan.skips.length} skipped`);
350
+
351
+ if (opts.dryRun) {
352
+ lines.push('(dry-run — nothing written)');
353
+ return { exitCode: 0, message: lines.join('\n') };
354
+ }
355
+
356
+ if (plan.carries.length > 0) {
357
+ const findings = /** @type {Array<Record<string, unknown>>} */ (targetReport.findings ?? []);
358
+ targetReport.findings = [...findings, ...plan.carries.map((c) => c.finding)];
359
+ recomputeSummary(targetReport);
360
+
361
+ const validation = validateReport(targetReport);
362
+ if (!validation.ok) {
363
+ return {
364
+ exitCode: 1,
365
+ message: `refusing to write ${targetPath} — validation failed:\n${validation.errors.map((e) => ` - ${e}`).join('\n')}`,
366
+ };
367
+ }
368
+
369
+ fs.writeFileSync(targetPath, `${JSON.stringify(targetReport, null, 2)}\n`, 'utf8');
370
+ lines.push(`wrote: ${targetPath}`);
371
+ }
372
+
373
+ return { exitCode: 0, message: lines.join('\n') };
374
+ }
375
+
376
+ function printHelp() {
377
+ console.log(`Usage: review carryforward --parent <reviewId|path> [options]
378
+
379
+ Carry verified findings forward from a parent review into a newer report,
380
+ for findings whose files are unchanged since the parent's head SHA.
381
+
382
+ Options:
383
+ --parent <id|path> Parent review id (resolves to .reviews/<id>-review.json)
384
+ or an explicit JSON path (required)
385
+ --file <path> Target review JSON (default: newest *-review.json in
386
+ .reviews/ that is not the parent and not kind=reverify)
387
+ --dry-run Print the carry/skip plan, write nothing
388
+ -h, --help Show this help
389
+ `);
390
+ }
391
+
392
+ function main() {
393
+ try {
394
+ const opts = parseArgs(process.argv.slice(2));
395
+ if (opts.help) {
396
+ printHelp();
397
+ process.exit(0);
398
+ }
399
+ const result = runCarryforward(opts);
400
+ console.log(result.message);
401
+ process.exit(result.exitCode);
402
+ } catch (err) {
403
+ console.error(/** @type {Error} */ (err).message);
404
+ process.exit(1);
405
+ }
406
+ }
407
+
408
+ const isMain =
409
+ process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
410
+
411
+ if (isMain) {
412
+ main();
413
+ }