@codedology/code-forge 0.14.0 → 0.15.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.
- package/CHANGELOG.md +28 -0
- package/README.md +136 -438
- package/docs/README.md +1 -1
- package/docs/reference/blocks.json +420 -1
- package/docs/reference/config.md +7 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +22 -0
- package/scripts/first-use.mjs +11 -5
- package/skill/SKILL.md +2 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +25 -1
- package/skill/references/review.md +10 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +235 -44
- package/src/cli/gates.mjs +36 -6
- package/src/cli/ledger.mjs +21 -2
- package/src/cli/report.mjs +14 -0
- package/src/cli/review-file.mjs +56 -16
- package/src/cli/run.mjs +44 -7
- package/src/cli/status.mjs +7 -3
- package/src/cli/upgrade.mjs +95 -15
- package/src/config/validate.mjs +13 -1
- package/src/gates/acceptance.mjs +7 -3
- package/src/gates/expand.mjs +25 -2
- package/src/gates/run.mjs +38 -8
- package/src/install/link.mjs +12 -6
- package/src/ledger/report.mjs +29 -7
- package/src/review/accept-hit.mjs +22 -4
- package/src/review/engine.mjs +30 -10
- package/src/review/finding.schema.json +2 -1
- package/src/review/fixloop.mjs +79 -14
- package/src/review/gate-check.mjs +22 -3
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +162 -20
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +14 -0
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +60 -4
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +5 -2
package/src/cli/ledger.mjs
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* coder one. A later call for the same PR replaces the earlier row in every reader.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
23
24
|
import { buildCalibration } from '../ledger/calibration.mjs';
|
|
25
|
+
import { ledgerDir, ledgerPath } from '../ledger/paths.mjs';
|
|
24
26
|
import { recordCiOutcome, reviewedEntriesFromRows, scanGitAndRecord } from '../ledger/outcome.mjs';
|
|
25
27
|
import { OUTSIDE_REVIEW_EVENT, OUTSIDE_REVIEW_USAGE, outsideReviewRecordedLine, parseOutsideReviewArgs } from '../ledger/outside-review.mjs';
|
|
26
28
|
import { roundUsd, runSpend } from '../ledger/spend.mjs';
|
|
@@ -62,6 +64,23 @@ function parseNonNegativeInt(raw) {
|
|
|
62
64
|
return Number.parseInt(raw, 10);
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/** Readers distinguish an absent ledger from an existing empty file. @param {string} slug */
|
|
68
|
+
async function readExistingRows(slug) {
|
|
69
|
+
try {
|
|
70
|
+
await stat(ledgerPath(slug));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code !== 'ENOENT') throw error;
|
|
73
|
+
const entries = await readdir(ledgerDir(), { withFileTypes: true }).catch((e) => {
|
|
74
|
+
if (e.code === 'ENOENT') return [];
|
|
75
|
+
throw e;
|
|
76
|
+
});
|
|
77
|
+
const slugs = [...new Set(entries.filter((e) => e.isFile() && /^[a-z0-9][a-z0-9-]*(?:\.\d+)?\.jsonl$/.test(e.name))
|
|
78
|
+
.map((e) => e.name.replace(/(?:\.\d+)?\.jsonl$/, '')))].sort();
|
|
79
|
+
throw new Error(`no ledger for slug "${slug}"; known slugs: ${slugs.join(', ') || 'none'}`);
|
|
80
|
+
}
|
|
81
|
+
return readAllRows(slug);
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* @param {string[]} args
|
|
67
86
|
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
|
|
@@ -89,7 +108,7 @@ export async function runLedger(args, deps = {}) {
|
|
|
89
108
|
err('ledger calibration: --question requires a value\n');
|
|
90
109
|
return 2;
|
|
91
110
|
}
|
|
92
|
-
const rows = await
|
|
111
|
+
const rows = await readExistingRows(slug);
|
|
93
112
|
const answers = rows.filter((r) => r.event === 'decision' && typeof r.confidence === 'number');
|
|
94
113
|
const outcomes = rows.filter((r) => r.event === 'outcome' && r.decision_id);
|
|
95
114
|
const { buckets, unbacked, unmapped } = buildCalibration(answers, outcomes, { questionId: question.value });
|
|
@@ -161,7 +180,7 @@ export async function runLedger(args, deps = {}) {
|
|
|
161
180
|
err(`ledger tail: --n/-n must be a non-negative integer, got "${nArg}"\n`);
|
|
162
181
|
return 2;
|
|
163
182
|
}
|
|
164
|
-
const rows = await
|
|
183
|
+
const rows = await readExistingRows(slug);
|
|
165
184
|
for (const row of n === 0 ? [] : rows.slice(-n)) {
|
|
166
185
|
out(`${JSON.stringify(row)}\n`);
|
|
167
186
|
}
|
package/src/cli/report.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* printed as text lines under its header (`outside review: none`, or one line per PR).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
9
10
|
import { writeSafe } from '../util/redact.mjs';
|
|
11
|
+
import { ledgerDir, ledgerPath } from '../ledger/paths.mjs';
|
|
10
12
|
import { buildReport, buildRunModes, buildSpend } from '../ledger/report.mjs';
|
|
11
13
|
import { exportReportSections } from '../ledger/export.mjs';
|
|
12
14
|
import { outsideReviewLines } from '../ledger/outside-review.mjs';
|
|
@@ -46,6 +48,18 @@ export async function runReport(args, deps = {}) {
|
|
|
46
48
|
// try/catch below turns that, and any other I/O failure, into a clean CLI message instead of a
|
|
47
49
|
// raw stack trace that skips the `writeSafe` redaction path.
|
|
48
50
|
try {
|
|
51
|
+
try {
|
|
52
|
+
await stat(ledgerPath(slug));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code !== 'ENOENT') throw error;
|
|
55
|
+
const entries = await readdir(ledgerDir(), { withFileTypes: true }).catch((e) => {
|
|
56
|
+
if (e.code === 'ENOENT') return [];
|
|
57
|
+
throw e;
|
|
58
|
+
});
|
|
59
|
+
const slugs = [...new Set(entries.filter((e) => e.isFile() && /^[a-z0-9][a-z0-9-]*(?:\.\d+)?\.jsonl$/.test(e.name))
|
|
60
|
+
.map((e) => e.name.replace(/(?:\.\d+)?\.jsonl$/, '')))].sort();
|
|
61
|
+
throw new Error(`no ledger for slug "${slug}"; known slugs: ${slugs.join(', ') || 'none'}`);
|
|
62
|
+
}
|
|
49
63
|
const rows = await readAllRows(slug);
|
|
50
64
|
const { sections } = buildReport(rows);
|
|
51
65
|
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -73,12 +73,14 @@ import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dro
|
|
|
73
73
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
74
74
|
import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
|
|
75
75
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
76
|
+
import { budgetFor } from '../review/packet.mjs';
|
|
76
77
|
import { ownsFile } from '../state/registry.mjs';
|
|
77
78
|
import { readAllRows } from '../ledger/write.mjs';
|
|
78
79
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
79
80
|
import { StateError } from '../state/paths.mjs';
|
|
80
81
|
import { assertRunId } from '../state/paths.mjs';
|
|
81
82
|
import { readRun } from '../state/run.mjs';
|
|
83
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
82
84
|
import { blockTree } from '../proof/worktree.mjs';
|
|
83
85
|
import { writeSafe } from '../util/redact.mjs';
|
|
84
86
|
|
|
@@ -132,6 +134,18 @@ function resultOfMarker(repoRoot, text) {
|
|
|
132
134
|
/** B75: the refused reasons that are not a verdict on the content (`--fresh` re-runs them). */
|
|
133
135
|
const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* A split is reusable only under the budget it measured; absent metadata fails closed.
|
|
139
|
+
* @param {Record<string, any> | null} result @param {Record<string, any> | null} cfg
|
|
140
|
+
* @returns {{old: number, current: number} | null}
|
|
141
|
+
*/
|
|
142
|
+
function splitBudgetChange(result, cfg) {
|
|
143
|
+
if (!cfg || !result || (result.status !== 'split_required' && !(result.status === 'stopped' && result.stopped === 'split_required'))) return null;
|
|
144
|
+
if (!['quick', 'full', 'A', 'B', 'recheck'].includes(result.lens) || !Number.isFinite(result.budget) || result.budget <= 0) return null;
|
|
145
|
+
const current = budgetFor(cfg, result.lens === 'quick' ? 'quick_in' : 'full_in');
|
|
146
|
+
return result.budget !== current ? { old: result.budget, current } : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
/**
|
|
136
150
|
* B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
|
|
137
151
|
* result that never counted as a review — `stale`, `unavailable`, round-1 `split_required`, or
|
|
@@ -141,9 +155,10 @@ const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
|
|
|
141
155
|
* ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
|
|
142
156
|
* result).
|
|
143
157
|
* @param {string} text - the done marker's text @param {Record<string, any> | null} result
|
|
158
|
+
* @param {Record<string, any> | null} [cfg] - the reloaded config, when readable.
|
|
144
159
|
* @returns {'rerun' | 'tampered' | 'counted'}
|
|
145
160
|
*/
|
|
146
|
-
export function freshVerdict(text, result) {
|
|
161
|
+
export function freshVerdict(text, result, cfg = null) {
|
|
147
162
|
let marker;
|
|
148
163
|
try {
|
|
149
164
|
marker = JSON.parse(text);
|
|
@@ -151,6 +166,7 @@ export function freshVerdict(text, result) {
|
|
|
151
166
|
return 'counted';
|
|
152
167
|
}
|
|
153
168
|
if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
|
|
169
|
+
if (splitBudgetChange(result, cfg)) return 'rerun';
|
|
154
170
|
if (result.status === 'stale' || result.status === 'unavailable' || result.status === 'split_required') return 'rerun';
|
|
155
171
|
if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
|
|
156
172
|
if (result.reason === 'review-state-tampered') return 'tampered';
|
|
@@ -168,10 +184,10 @@ const RETRY_STATUSES = new Set(['stale', 'unavailable', 'split_required']);
|
|
|
168
184
|
|
|
169
185
|
/**
|
|
170
186
|
* Classify in place and use the same byte-checked claim for fresh and automatic retries.
|
|
171
|
-
* @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>}} opts
|
|
172
|
-
* @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept'}}
|
|
187
|
+
* @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>, cfg?: Record<string, any> | null}} opts
|
|
188
|
+
* @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept', budgetChange?: {old: number, current: number} | null}}
|
|
173
189
|
*/
|
|
174
|
-
function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses }) {
|
|
190
|
+
function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses, cfg = null }) {
|
|
175
191
|
const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
|
|
176
192
|
/** @type {'rerun' | 'tampered' | 'counted'} */
|
|
177
193
|
let verdict = 'counted';
|
|
@@ -189,7 +205,7 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
|
|
|
189
205
|
// classified IN PLACE: a refusal never moves, renames or touches the marker
|
|
190
206
|
const text = bytes.toString('utf8');
|
|
191
207
|
const result = resultOfMarker(repoRoot, text);
|
|
192
|
-
verdict = freshVerdict(text, result);
|
|
208
|
+
verdict = freshVerdict(text, result, cfg);
|
|
193
209
|
if (statuses && !statuses.has(result?.status)) verdict = 'counted';
|
|
194
210
|
if (verdict !== 'rerun') break;
|
|
195
211
|
/** @type {unknown} */
|
|
@@ -208,7 +224,7 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
|
|
|
208
224
|
if (outcome === 'kept') break;
|
|
209
225
|
// an allow-list of outcomes: anything else is refused and nothing is queued
|
|
210
226
|
if (outcome !== 'dropped' && outcome !== 'gone') throw new WorkerError('fresh-claim-unknown', `the claim of the cached result of ${file} gave an unknown outcome; nothing was queued`);
|
|
211
|
-
return { verdict, outcome };
|
|
227
|
+
return { verdict, outcome, budgetChange: outcome === 'dropped' ? splitBudgetChange(result, cfg) : null };
|
|
212
228
|
}
|
|
213
229
|
return { verdict, outcome: 'kept' };
|
|
214
230
|
}
|
|
@@ -223,14 +239,15 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
|
|
|
223
239
|
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
224
240
|
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
225
241
|
* B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
|
|
226
|
-
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
|
|
242
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean, cfg?: Record<string, any> | null, stderr: {write: (s: string) => unknown}}} opts
|
|
227
243
|
* @returns {number} the exit code.
|
|
228
244
|
*/
|
|
229
|
-
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
|
|
245
|
+
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false, cfg = null, stderr }) {
|
|
230
246
|
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
231
|
-
const { verdict, outcome } = dropFreshDone({ repoRoot, ticket, file, dropDone });
|
|
247
|
+
const { verdict, outcome, budgetChange } = dropFreshDone({ repoRoot, ticket, file, dropDone, cfg });
|
|
232
248
|
if (outcome === 'dropped' || outcome === 'gone') {
|
|
233
249
|
const queued = enqueue({ repoRoot, run, block, file, ...where });
|
|
250
|
+
if (budgetChange) writeSafe(stderr, `split_required at budget ${budgetChange.old}; budget is now ${budgetChange.current}: reviewing again\n`);
|
|
234
251
|
say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
|
|
235
252
|
return 0;
|
|
236
253
|
}
|
|
@@ -417,6 +434,17 @@ function blockTicketMarker(repoRoot, ticket) {
|
|
|
417
434
|
throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
|
|
418
435
|
}
|
|
419
436
|
|
|
437
|
+
/** Progress only: legacy reviewed markers encode approval by absence of open_findings.
|
|
438
|
+
* @param {Record<string, any> | null} marker
|
|
439
|
+
* @returns {'approved' | 'findings' | 'stopped' | 'split_required' | 'unavailable' | null}
|
|
440
|
+
*/
|
|
441
|
+
function blockTicketOutcome(marker) {
|
|
442
|
+
if (!marker) return null;
|
|
443
|
+
if (marker.status === 'reviewed') return marker.open_findings === true || marker.approved === false ? 'findings' : 'approved';
|
|
444
|
+
if (marker.status === 'stopped' || marker.status === 'split_required') return marker.status;
|
|
445
|
+
return 'unavailable';
|
|
446
|
+
}
|
|
447
|
+
|
|
420
448
|
/**
|
|
421
449
|
* A block wait uses unsigned results only to classify retries; it never reads the signing key.
|
|
422
450
|
* Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
|
|
@@ -443,15 +471,15 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
443
471
|
if (!present && previous.length > 0) {
|
|
444
472
|
const newest = previous[previous.length - 1];
|
|
445
473
|
const marker = blockTicketMarker(repoRoot, newest.ticket);
|
|
446
|
-
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
|
|
474
|
+
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker) };
|
|
447
475
|
if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
|
|
448
476
|
if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
|
|
449
477
|
deliveredHere.add(newest.ticket);
|
|
450
|
-
return { file, ticket: newest.ticket, status: 'findings' };
|
|
478
|
+
return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker) };
|
|
451
479
|
}
|
|
452
480
|
}
|
|
453
481
|
}
|
|
454
|
-
if (!present &&
|
|
482
|
+
if (!present && live) {
|
|
455
483
|
enqueue(opts);
|
|
456
484
|
present = { ...previous[previous.length - 1], ticket };
|
|
457
485
|
}
|
|
@@ -460,12 +488,17 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
460
488
|
dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
|
|
461
489
|
marker = blockTicketMarker(repoRoot, ticket);
|
|
462
490
|
}
|
|
463
|
-
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending' };
|
|
491
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker) };
|
|
464
492
|
});
|
|
465
493
|
if (downPolls >= 2) {
|
|
466
494
|
say({ block, files, done: false, status: 'worker_down' });
|
|
467
495
|
return 3;
|
|
468
496
|
}
|
|
497
|
+
const split = files.find((f) => f.outcome === 'split_required');
|
|
498
|
+
if (live && split) {
|
|
499
|
+
say({ block, files, done: false, reason: 'split_required', next: `The owner raises review.budgets for ${split.file}, runs run reload, then review-file ${split.file} --fresh.` });
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
469
502
|
if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
|
|
470
503
|
say({ block, files, done: true });
|
|
471
504
|
return 0;
|
|
@@ -541,10 +574,17 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
541
574
|
const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
|
|
542
575
|
assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
|
|
543
576
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
544
|
-
|
|
577
|
+
let cfg = null;
|
|
578
|
+
try {
|
|
579
|
+
cfg = configInForce(await readRun(worker.run));
|
|
580
|
+
} catch {
|
|
581
|
+
// No readable snapshot: retain the existing cache and --fresh rules.
|
|
582
|
+
}
|
|
583
|
+
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
545
584
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
546
|
-
|
|
547
|
-
|
|
585
|
+
const cached = queued.status === 'done' ? doneResult(repoRoot, queued.ticket) : null;
|
|
586
|
+
if (cached && (['unavailable', 'split_required'].includes(cached.status) || splitBudgetChange(cached, cfg))) {
|
|
587
|
+
return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
548
588
|
}
|
|
549
589
|
say(queued);
|
|
550
590
|
return 0;
|
package/src/cli/run.mjs
CHANGED
|
@@ -31,14 +31,18 @@
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { reloadFailureText, reloadWorkspace } from '../autopilot/limits.mjs';
|
|
33
33
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
|
|
34
|
+
import { reviewWorkers } from '../config/validate.mjs';
|
|
34
35
|
import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
|
|
35
36
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
36
37
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
37
38
|
import { StateError } from '../state/paths.mjs';
|
|
39
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
38
40
|
import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
|
|
39
41
|
import { writeSafe } from '../util/redact.mjs';
|
|
42
|
+
import { withLock } from '../util/file-locks.mjs';
|
|
40
43
|
import { sweepRoots, tmpBase } from '../util/tmp.mjs';
|
|
41
44
|
import { launchWorker, repinWorker } from '../worker/loop.mjs';
|
|
45
|
+
import { liveWorker, workerRegistry } from '../worker/queue.mjs';
|
|
42
46
|
import { repoRootOf } from '../worker/ticket.mjs';
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -69,15 +73,18 @@ const ledgerWriter = (slug) => (/** @type {Record<string, any>} */ row) => appen
|
|
|
69
73
|
* A worker that `run start` launched but could not pin must not outlive the command as an orphan:
|
|
70
74
|
* stop it through the pinned-worker stopper (pid > 1, start time re-checked right before the
|
|
71
75
|
* signal, so a recycled pid is never signalled) and name the pid on stderr. `startedAt` is the
|
|
72
|
-
* start time read right after the launch; null means the process was already gone.
|
|
73
|
-
*
|
|
76
|
+
* start time read right after the launch; null means the process was already gone. When that
|
|
77
|
+
* probe threw (`undefined`), use the OS probe to obtain and re-check the identity for cleanup.
|
|
78
|
+
* @param {{pid: number, startedAt: string | null | undefined}} launched
|
|
74
79
|
* @param {{stopWorker: StopWorker, probe?: import('../state/run.mjs').StartTimeProbe, err: (s: string) => void}} deps
|
|
75
80
|
*/
|
|
76
81
|
async function stopLaunched({ pid, startedAt }, { stopWorker, probe, err }) {
|
|
77
82
|
/** @type {{stopped: boolean, reason?: string}} */
|
|
78
83
|
let outcome;
|
|
79
84
|
try {
|
|
80
|
-
|
|
85
|
+
const cleanupProbe = startedAt === undefined ? processStartTime : probe;
|
|
86
|
+
if (startedAt === undefined) startedAt = await processStartTime(pid);
|
|
87
|
+
outcome = startedAt === null ? { stopped: false, reason: 'not running' } : await stopWorker({ pid, started_at: startedAt }, { ...(cleanupProbe ? { probe: cleanupProbe } : {}) });
|
|
81
88
|
} catch (thrown) {
|
|
82
89
|
outcome = { stopped: false, reason: thrown?.message ?? String(thrown) };
|
|
83
90
|
}
|
|
@@ -107,13 +114,42 @@ export async function runRun(args, deps = {}) {
|
|
|
107
114
|
return 2;
|
|
108
115
|
}
|
|
109
116
|
try {
|
|
110
|
-
const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach'] });
|
|
117
|
+
const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach', 'add-worker'] });
|
|
111
118
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
112
119
|
const workerPid = intFlag(flags['worker-pid'], 'worker-pid');
|
|
113
120
|
const mode = typeof flags.mode === 'string' ? flags.mode : undefined;
|
|
114
|
-
if (mode !== undefined && (sub !== 'start' || flags.reattach)) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
|
|
121
|
+
if (mode !== undefined && (sub !== 'start' || flags.reattach || flags['add-worker'])) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
|
|
115
122
|
if (mode !== undefined && !RUN_MODES.includes(mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
|
|
116
123
|
|
|
124
|
+
if (flags['add-worker']) {
|
|
125
|
+
if (sub !== 'start' || !runId || flags.reattach || workerPid !== undefined) throw new StateError('usage', 'run start --add-worker needs --run and cannot use --reattach or --worker-pid');
|
|
126
|
+
if (!startWorker) throw new StateError('usage', 'run start --add-worker needs a worker launcher');
|
|
127
|
+
return await withLock(`worker-add:${runId}`, async () => {
|
|
128
|
+
const record = await readRun(runId);
|
|
129
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
130
|
+
const repoRoot = await repoRootOf(record.workspace);
|
|
131
|
+
const loaded = record.config ? { ok: true, config: configInForce(record) } : await loadProjectConfig(record.workspace);
|
|
132
|
+
if (!loaded.ok) throw new StateError('config', 'the project config cannot be loaded');
|
|
133
|
+
const limit = reviewWorkers(loaded.config);
|
|
134
|
+
const entries = workerRegistry(repoRoot, { prune: true });
|
|
135
|
+
const legacy = liveWorker(repoRoot);
|
|
136
|
+
const count = entries.filter((e) => e.live).length + (legacy && !entries.some((e) => e.worker?.pid === legacy.pid && e.worker.start_time === legacy.start_time) ? 1 : 0);
|
|
137
|
+
if (count >= limit) throw new StateError('worker-running', `review.workers limit ${limit} reached`);
|
|
138
|
+
const launched = await startWorker({ runId, workspace: record.workspace });
|
|
139
|
+
/** @type {string | null | undefined} */
|
|
140
|
+
let startedAt;
|
|
141
|
+
try {
|
|
142
|
+
startedAt = await (probe ?? processStartTime)(launched.pid);
|
|
143
|
+
await repinWorker({ runId, repoRoot: launched.repoRoot, expectPid: launched.pid, add: true, writeRow: ledgerWriter(record.project), ...(probe ? { probe } : {}) });
|
|
144
|
+
} catch (thrown) {
|
|
145
|
+
await stopLaunched({ pid: launched.pid, startedAt }, { stopWorker: stopWorker ?? stopPinnedWorker, probe, err });
|
|
146
|
+
throw thrown;
|
|
147
|
+
}
|
|
148
|
+
out(`run ${runId}: added worker pid ${launched.pid} (review.workers limit ${limit})\n`);
|
|
149
|
+
return 0;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
117
153
|
if (sub === 'start' && flags.reattach) {
|
|
118
154
|
if (!runId) throw new StateError('usage', 'run start --reattach needs --run');
|
|
119
155
|
const { project, workspace } = await readRun(runId);
|
|
@@ -159,7 +195,8 @@ export async function runRun(args, deps = {}) {
|
|
|
159
195
|
const record = await readRun(runId);
|
|
160
196
|
const { run_id, status, engine, blocks, orphans } = record;
|
|
161
197
|
const spend = await spendOf(record);
|
|
162
|
-
|
|
198
|
+
const awaiting = Object.keys(blocks).filter((id) => blocks[id].status === 'awaiting_human_check').sort();
|
|
199
|
+
out(`${JSON.stringify({ run_id, status, engine, worker_pinned: Boolean(record.worker), blocks, orphans, ...spend, ...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) }, null, 2)}\n`);
|
|
163
200
|
// on stderr, so stdout stays one JSON document
|
|
164
201
|
const n = spend.unknown_usd_sessions;
|
|
165
202
|
if (n > 0) err(`${n} session${n === 1 ? ' has' : 's have'} no known price; the budget does not count ${n === 1 ? 'it' : 'them'}\n`);
|
|
@@ -221,7 +258,7 @@ async function reloadVerb(runId, { out, err }) {
|
|
|
221
258
|
}
|
|
222
259
|
|
|
223
260
|
const USAGE =
|
|
224
|
-
'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
|
|
261
|
+
'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --add-worker --run <id> | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
|
|
225
262
|
|
|
226
263
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
227
264
|
export default async function run(args) {
|
package/src/cli/status.mjs
CHANGED
|
@@ -65,7 +65,7 @@ const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
|
|
|
65
65
|
const STATUS_MAX_LINES = 10;
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* @typedef {import('../state/brief-status.mjs').RunStatus} RunStatus
|
|
68
|
+
* @typedef {import('../state/brief-status.mjs').RunStatus & {awaiting_human_check?: string[]}} RunStatus
|
|
69
69
|
*/
|
|
70
70
|
|
|
71
71
|
/** @param {string} p @returns {Promise<string>} */
|
|
@@ -108,7 +108,9 @@ async function statusOf(record) {
|
|
|
108
108
|
if (list) list.push(r);
|
|
109
109
|
else byBlock.set(r.block, [r]);
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
const awaiting = Object.keys(record.blocks ?? {}).filter((id) => record.blocks[id].status === 'awaiting_human_check').sort();
|
|
112
|
+
return { ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
|
|
113
|
+
...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
/**
|
|
@@ -152,12 +154,14 @@ export function runLines(s) {
|
|
|
152
154
|
const spent = s.spent_usd === null ? '$?' : `${usd(s.spent_usd)}${(s.unknown_usd_sessions ?? 0) > 0 ? '+?' : ''}`;
|
|
153
155
|
const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
|
|
154
156
|
const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
|
|
155
|
-
const
|
|
157
|
+
const awaiting = s.awaiting_human_check?.length ? `awaiting_human_check: ${JSON.stringify(s.awaiting_human_check)}` : null;
|
|
158
|
+
const room = STATUS_MAX_LINES - 1 - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
|
|
156
159
|
const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
|
|
157
160
|
for (const b of shown) {
|
|
158
161
|
lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
|
|
159
162
|
}
|
|
160
163
|
if (shown.length < s.blocks.length) lines.push(`… +${s.blocks.length - shown.length} more blocks`);
|
|
164
|
+
if (awaiting !== null) lines.push(awaiting);
|
|
161
165
|
if (tally !== null) lines.push(tally);
|
|
162
166
|
return lines;
|
|
163
167
|
}
|
package/src/cli/upgrade.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge upgrade [--source <path>]` — re-points every recorded install at a skill source
|
|
2
|
+
* `code-forge upgrade [--source <path>] [--plain]` — re-points every recorded install at a skill source
|
|
3
3
|
* (plan §10.3 acceptance: "`upgrade` re-points all 3 recorded links"; research/installer-patterns.md
|
|
4
4
|
* point 8, "a self `upgrade` command"). A symlink record is re-created to point at the new
|
|
5
5
|
* source; a copy record is re-copied from it. With no `--source`, defaults to this running
|
|
@@ -8,33 +8,93 @@
|
|
|
8
8
|
* (tests, or a power user working from a git checkout of the skill).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
12
13
|
import os from 'node:os';
|
|
13
14
|
import path from 'node:path';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
15
17
|
import { writeSafe } from '../util/redact.mjs';
|
|
16
|
-
import { installsPath, upgradeAll } from '../install/link.mjs';
|
|
18
|
+
import { installsPath, readInstalls, upgradeAll } from '../install/link.mjs';
|
|
17
19
|
|
|
18
|
-
const USAGE = 'usage: code-forge upgrade [--source <path>]\n';
|
|
20
|
+
const USAGE = 'usage: code-forge upgrade [--source <path>] [--plain]\n';
|
|
21
|
+
const LOGO = ' ___ __ __ ___ ___ __ __ __ ___\n| | | | \\ |__ __ |__ | | |__/ | _ |__\n|___ |__| |__/ |___ | |__| | \\ |__] |___\n';
|
|
22
|
+
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
23
|
+
const CHANGELOG_PATH = fileURLToPath(new URL('../../CHANGELOG.md', import.meta.url));
|
|
19
24
|
|
|
20
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
26
|
/** This package's own skill directory: `<package root>/skill`, i.e. two levels above `src/cli/`. */
|
|
22
27
|
export const DEFAULT_SKILL_SOURCE = path.join(__dirname, '..', '..', 'skill');
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
|
-
* Strict parse:
|
|
30
|
+
* Strict parse: optional source and plain flags, each at most once, in either order.
|
|
26
31
|
* @param {string[]} args
|
|
27
|
-
* @returns {{source?: string} | null}
|
|
32
|
+
* @returns {{source?: string, plain?: boolean} | null}
|
|
28
33
|
*/
|
|
29
34
|
export function parseUpgradeArgs(args) {
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
/** @type {{source?: string, plain?: boolean}} */
|
|
36
|
+
const parsed = {};
|
|
37
|
+
for (let i = 0; i < args.length; i++) {
|
|
38
|
+
if (args[i] === '--plain' && !parsed.plain) {
|
|
39
|
+
parsed.plain = true;
|
|
40
|
+
} else if (args[i] === '--source' && parsed.source === undefined) {
|
|
41
|
+
const value = args[++i];
|
|
42
|
+
if (!value || value.startsWith('--')) return null;
|
|
43
|
+
parsed.source = value;
|
|
44
|
+
} else {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
32
47
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Semver ordering, including numeric prerelease identifiers (rc.2 precedes rc.10).
|
|
52
|
+
* @param {string} a @param {string} b @returns {number}
|
|
53
|
+
*/
|
|
54
|
+
function compareVersions(a, b) {
|
|
55
|
+
const [coreA, preA] = a.split('+')[0].split(/-(.*)/);
|
|
56
|
+
const [coreB, preB] = b.split('+')[0].split(/-(.*)/);
|
|
57
|
+
const coreOrder = coreA.localeCompare(coreB, 'en', { numeric: true });
|
|
58
|
+
if (coreOrder) return coreOrder;
|
|
59
|
+
if (preA === undefined) return preB === undefined ? 0 : 1;
|
|
60
|
+
if (preB === undefined) return -1;
|
|
61
|
+
const idsA = preA.split('.');
|
|
62
|
+
const idsB = preB.split('.');
|
|
63
|
+
for (let i = 0; i < Math.max(idsA.length, idsB.length); i++) {
|
|
64
|
+
if (idsA[i] === undefined) return -1;
|
|
65
|
+
if (idsB[i] === undefined) return 1;
|
|
66
|
+
if (idsA[i] === idsB[i]) continue;
|
|
67
|
+
const numericA = /^\d+$/.test(idsA[i]);
|
|
68
|
+
const numericB = /^\d+$/.test(idsB[i]);
|
|
69
|
+
if (numericA !== numericB) return numericA ? -1 : 1;
|
|
70
|
+
return numericA ? Number(idsA[i]) - Number(idsB[i]) : (idsA[i] < idsB[i] ? -1 : 1);
|
|
36
71
|
}
|
|
37
|
-
return
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read only bullets in the exact release section; unavailable notes are a silent omission.
|
|
76
|
+
* @param {string} file @param {string} version @returns {Promise<string[]>}
|
|
77
|
+
*/
|
|
78
|
+
async function releaseBullets(file, version) {
|
|
79
|
+
let text;
|
|
80
|
+
try {
|
|
81
|
+
text = await readFile(file, 'utf8');
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const bullets = [];
|
|
86
|
+
let inRelease = false;
|
|
87
|
+
for (const line of text.split(/\r?\n/)) {
|
|
88
|
+
if (/^##\s/.test(line)) {
|
|
89
|
+
if (inRelease) break;
|
|
90
|
+
inRelease = /^## \[([^\]]+)\](?:\s|$)/.exec(line)?.[1] === version;
|
|
91
|
+
} else if (inRelease && line.startsWith('- ')) {
|
|
92
|
+
const chars = Array.from(stripVTControlCharacters(line.slice(2)));
|
|
93
|
+
bullets.push(chars.length > 100 ? `${chars.slice(0, 97).join('')}...` : chars.join(''));
|
|
94
|
+
if (bullets.length === 5) break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return bullets;
|
|
38
98
|
}
|
|
39
99
|
|
|
40
100
|
/**
|
|
@@ -44,6 +104,8 @@ export function parseUpgradeArgs(args) {
|
|
|
44
104
|
* @param {{write: (s: string) => unknown}} [deps.stderr]
|
|
45
105
|
* @param {string} [deps.home]
|
|
46
106
|
* @param {string} [deps.defaultSource]
|
|
107
|
+
* @param {string} [deps.packageVersion] - running package version, injectable for tests.
|
|
108
|
+
* @param {string} [deps.changelogPath] - packaged release notes, injectable for tests.
|
|
47
109
|
* @param {string} [deps.cwd] - a relative `--source` is resolved against this, never against the
|
|
48
110
|
* link's own directory (which `symlink()` would otherwise silently use).
|
|
49
111
|
* @returns {Promise<number>}
|
|
@@ -55,11 +117,13 @@ export async function runUpgrade(
|
|
|
55
117
|
stderr = process.stderr,
|
|
56
118
|
home = os.homedir(),
|
|
57
119
|
defaultSource = DEFAULT_SKILL_SOURCE,
|
|
120
|
+
packageVersion = PACKAGE_VERSION,
|
|
121
|
+
changelogPath = CHANGELOG_PATH,
|
|
58
122
|
cwd = process.cwd(),
|
|
59
123
|
} = {},
|
|
60
124
|
) {
|
|
61
|
-
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
62
|
-
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
125
|
+
const out = (/** @type {string} */ s) => writeSafe(stdout, stripVTControlCharacters(s));
|
|
126
|
+
const err = (/** @type {string} */ s) => writeSafe(stderr, stripVTControlCharacters(s));
|
|
63
127
|
|
|
64
128
|
const parsed = parseUpgradeArgs(args);
|
|
65
129
|
if (!parsed) {
|
|
@@ -84,13 +148,29 @@ export async function runUpgrade(
|
|
|
84
148
|
return 1;
|
|
85
149
|
}
|
|
86
150
|
|
|
87
|
-
const
|
|
151
|
+
const installsFile = installsPath(home);
|
|
152
|
+
const previous = await readInstalls(installsFile);
|
|
153
|
+
const updated = await upgradeAll({ installsFile, source: resolvedSource, version: packageVersion });
|
|
154
|
+
if (!parsed.plain) {
|
|
155
|
+
const versions = previous.map((r) => r.version).filter((v) => v !== undefined).sort(compareVersions);
|
|
156
|
+
const oldest = versions[0];
|
|
157
|
+
const change = oldest === undefined ? 'first versioned upgrade'
|
|
158
|
+
: oldest === packageVersion ? `already on ${packageVersion}` : `${oldest} -> ${packageVersion}`;
|
|
159
|
+
out(`${LOGO}code-forge ${packageVersion}\n${change}\n`);
|
|
160
|
+
}
|
|
88
161
|
for (const record of updated) {
|
|
89
162
|
out(`upgraded ${record.harness} (${record.scope}) -> ${record.source}\n`);
|
|
90
163
|
}
|
|
91
164
|
if (updated.length === 0) {
|
|
92
165
|
out('nothing to upgrade\n');
|
|
93
166
|
}
|
|
167
|
+
if (!parsed.plain) {
|
|
168
|
+
const notesPath = path.resolve(changelogPath);
|
|
169
|
+
const bullets = await releaseBullets(notesPath, packageVersion);
|
|
170
|
+
if (bullets.length > 0) {
|
|
171
|
+
out(`What's new in ${packageVersion}:\n${bullets.join('\n')}\nFull notes: ${notesPath}\n`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
94
174
|
return 0;
|
|
95
175
|
}
|
|
96
176
|
|
package/src/config/validate.mjs
CHANGED
|
@@ -513,7 +513,12 @@ function gateMessages(key, value) {
|
|
|
513
513
|
}
|
|
514
514
|
|
|
515
515
|
function checkGatesNotArgvArray(cfg) {
|
|
516
|
-
|
|
516
|
+
const messages = GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]));
|
|
517
|
+
if (Object.hasOwn(cfg?.gates ?? {}, 'repeat_new_tests')) {
|
|
518
|
+
const n = cfg.gates.repeat_new_tests;
|
|
519
|
+
if (!Number.isInteger(n) || n < 2 || n > 10) messages.push('gates.repeat_new_tests must be an integer 2..10');
|
|
520
|
+
}
|
|
521
|
+
return messages.map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message }));
|
|
517
522
|
}
|
|
518
523
|
|
|
519
524
|
// ── 10. secret-looking-value (ERROR) ────────────────────────────────────────
|
|
@@ -1169,3 +1174,10 @@ export { schema as configSchema };
|
|
|
1169
1174
|
export function reviewFileLoop(cfg) {
|
|
1170
1175
|
return cfg?.review?.file_loop ?? 'wait';
|
|
1171
1176
|
}
|
|
1177
|
+
|
|
1178
|
+
/** Maximum workers for a validated config (the schema restricts it to 1..4).
|
|
1179
|
+
* @param {Record<string, any>} cfg @returns {number}
|
|
1180
|
+
*/
|
|
1181
|
+
export function reviewWorkers(cfg) {
|
|
1182
|
+
return cfg?.review?.workers ?? 1;
|
|
1183
|
+
}
|
package/src/gates/acceptance.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Acceptance-clause coverage (plan §4.6 row 4, O17): "every clause names ≥ 1 test id; each named
|
|
2
|
+
* Acceptance-clause coverage (plan §4.6 row 4, O17): "every code clause names ≥ 1 test id; each named
|
|
3
3
|
* test exists in the tree and passed in this gate run (parsed from the runner output)." This is
|
|
4
4
|
* DETERMINISTIC — S1's `scope` question (§3.2) judges only whether hunks OUTSIDE the named tests
|
|
5
5
|
* are unrelated; whether a named test id actually passed is a fact, never a model's opinion.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* @typedef {{clause: string, tests
|
|
15
|
+
* @typedef {{clause: string, kind?: string, tests?: string[]}} AcceptanceClause
|
|
16
16
|
* @typedef {{ok: true} | {ok: false, reason: string, clause: string, test: string}} AcceptanceResult
|
|
17
17
|
*/
|
|
18
18
|
|
|
@@ -57,7 +57,7 @@ export function parseTapPassed(output) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* @param {ReadonlyArray<AcceptanceClause>} clauses -
|
|
60
|
+
* @param {ReadonlyArray<AcceptanceClause>} clauses - code clauses name ≥ 1 test id; human checks name none.
|
|
61
61
|
* @param {Set<string> | ReadonlyArray<string>} passed - test ids that passed in THIS gate run.
|
|
62
62
|
* @param {Set<string> | ReadonlyArray<string>} [failed] - test ids that FAILED in this gate run.
|
|
63
63
|
* `parseTapPassed` flattens every file's/describe-block's TAP subtests into one global name set,
|
|
@@ -77,6 +77,10 @@ export function checkAcceptance(clauses, passed, failed = new Set()) {
|
|
|
77
77
|
for (const entry of clauses) {
|
|
78
78
|
const clause = entry?.clause;
|
|
79
79
|
const tests = entry?.tests;
|
|
80
|
+
if (entry?.kind === 'human_check' && typeof clause === 'string' && clause.length > 0) {
|
|
81
|
+
if (Object.hasOwn(entry, 'tests')) return { ok: false, reason: 'human_check clause must not name tests', clause, test: '' };
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
80
84
|
if (typeof clause !== 'string' || clause.length === 0 || !Array.isArray(tests) || tests.length === 0) {
|
|
81
85
|
return { ok: false, reason: 'clause names no test id', clause: String(clause ?? ''), test: '' };
|
|
82
86
|
}
|