@codedology/code-forge 0.5.1 → 0.6.1
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 +59 -0
- package/README.md +39 -2
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +127 -1
- package/docs/reference/config.md +2 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +7 -0
- package/skill/SKILL.md +2 -0
- package/skill/references/adapters/claude-code.md +14 -0
- package/skill/references/autopilot.md +48 -0
- package/skill/references/code.md +1 -1
- package/skill/references/decisions.md +3 -1
- package/skill/references/harden.md +1 -1
- package/skill/references/ledger.md +2 -0
- package/skill/references/plan.md +6 -4
- package/skill/references/review.md +2 -2
- package/skill/references/rules-core.md +4 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/plan.md +4 -3
- package/src/autopilot/actions.mjs +491 -0
- package/src/autopilot/binnacle.mjs +494 -0
- package/src/autopilot/delegate.mjs +422 -0
- package/src/autopilot/grant.mjs +354 -0
- package/src/autopilot/limits.mjs +848 -0
- package/src/autopilot/scopes.mjs +95 -0
- package/src/cli/author.mjs +7 -5
- package/src/cli/autopilot.mjs +500 -0
- package/src/cli/block.mjs +40 -8
- package/src/cli/facts.mjs +7 -6
- package/src/cli/jev.mjs +10 -6
- package/src/cli/logs.mjs +4 -142
- package/src/cli/plan.mjs +18 -7
- package/src/cli/run.mjs +20 -61
- package/src/cli/s2.mjs +5 -4
- package/src/cli/spawn.mjs +5 -4
- package/src/config/closed-book.mjs +20 -3
- package/src/config/load.mjs +113 -1
- package/src/doctor/probes.mjs +34 -11
- package/src/engines/builders/claude.mjs +3 -3
- package/src/engines/builders/codex.mjs +6 -5
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/builders/validate-params.mjs +3 -3
- package/src/ledger/report.mjs +2 -0
- package/src/review/fixloop.mjs +69 -5
- package/src/review/gate-check.mjs +156 -6
- package/src/session/facts.mjs +291 -73
- package/src/session/plan-check.mjs +75 -6
- package/src/session/spawn.mjs +68 -8
- package/src/state/run.mjs +8 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/gh.mjs +175 -0
- package/src/worker/engine.mjs +8 -4
- package/src/worker/loop.mjs +42 -0
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
* (`file` + `finding`; a finding id is scoped to one file, so a waiver of `F1` in `a.mjs` never
|
|
18
18
|
* clears `F1` in `b.mjs`); a late finding counts whether or not it is signed (fail closed).
|
|
19
19
|
*
|
|
20
|
+
* B47 (autopilot): a `review.waived {by: autopilot}` row (written by `autopilot waive`) clears a
|
|
21
|
+
* finding ONLY when {@link autopilotWaiverProblem} finds nothing wrong with it: its MAC verifies;
|
|
22
|
+
* the finding is not `proof`; the finding's severity recorded in the block's signed review rows
|
|
23
|
+
* (`review.triage`, `review.late_finding`; {@link recordedSeverity}) is `warning` or `nit` and
|
|
24
|
+
* equals the waiver's `severity`; a signed `autopilot.grant` row of this run with the waiver's
|
|
25
|
+
* `grant_id` exists, its window (`ts` ≤ waiver `ts` < `until`) holds the waiver, and its scopes
|
|
26
|
+
* include `waive:<severity>`; no `autopilot.stop` / `autopilot.expire` row for that grant is
|
|
27
|
+
* dated at or before the waiver (or undated); and its `decision_id` names a signed, acted
|
|
28
|
+
* `autopilot.decision` of the same grant, scope, block, file and finding made at most 30 min
|
|
29
|
+
* before it ({@link decisionProblem}). Otherwise the waiver is ignored and the finding is refused as
|
|
30
|
+
* `autopilot_waiver_invalid` (the detail says why). `by: human` waivers are unchanged.
|
|
31
|
+
*
|
|
20
32
|
* `block waive <id> <finding> --file <path> --reason <text>` (`waiveFinding`) writes a signed
|
|
21
33
|
* `review.waived` row with `by: human`. The orchestrator runs it only after the human said so in chat; the brief
|
|
22
34
|
* never mentions it; for a coder the argv is forbidden (`mergeForbidden()` always carries B12b's
|
|
@@ -34,7 +46,7 @@ import { tierFor } from '../proof/tiers.mjs';
|
|
|
34
46
|
import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
35
47
|
|
|
36
48
|
/**
|
|
37
|
-
* @typedef {{code: 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven', file?: string, finding?: string, detail?: string}} Refusal
|
|
49
|
+
* @typedef {{code: 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven' | 'autopilot_waiver_invalid', file?: string, finding?: string, detail?: string}} Refusal
|
|
38
50
|
*/
|
|
39
51
|
|
|
40
52
|
/** @param {Record<string, any>} row @param {Buffer} key */
|
|
@@ -76,28 +88,166 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
76
88
|
|
|
77
89
|
const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && verified(r, key));
|
|
78
90
|
const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
|
|
91
|
+
const autopilotWaivers = mine.filter((r) => r.event === 'review.waived' && r.by === 'autopilot');
|
|
92
|
+
const runRows = rows.filter((r) => r?.run === runId);
|
|
79
93
|
const rulings = signedOf('review.late_ruling');
|
|
80
94
|
const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
|
|
81
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Whether the finding is waived: a signed human waiver, or an autopilot waiver its grant covered.
|
|
97
|
+
* `invalid`: why the autopilot waivers naming it do not count (null when none names it).
|
|
98
|
+
* @param {string} file @param {string} finding @returns {{waived: boolean, invalid: string | null}}
|
|
99
|
+
*/
|
|
100
|
+
const waiverFor = (file, finding) => {
|
|
101
|
+
if (waivers.some((r) => matches(r, file, finding))) return { waived: true, invalid: null };
|
|
102
|
+
const named = autopilotWaivers.filter((r) => matches(r, file, finding));
|
|
103
|
+
if (named.length === 0) return { waived: false, invalid: null };
|
|
104
|
+
const problems = named.map((w) => autopilotWaiverProblem(w, { blockRows: mine, runRows, allRows: rows, key }));
|
|
105
|
+
if (problems.some((p) => p === null)) return { waived: true, invalid: null };
|
|
106
|
+
return { waived: false, invalid: /** @type {string} */ (problems[0]) };
|
|
107
|
+
};
|
|
108
|
+
/** @param {Refusal} refusal @param {string} finding @param {string | null} invalid @returns {Refusal} */
|
|
109
|
+
const orInvalid = (refusal, finding, invalid) =>
|
|
110
|
+
invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
|
|
82
111
|
|
|
83
112
|
for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
|
|
84
|
-
if (rulings.some((r) => matches(r, late.file, late.finding))
|
|
85
|
-
|
|
113
|
+
if (rulings.some((r) => matches(r, late.file, late.finding))) continue;
|
|
114
|
+
const w = waiverFor(late.file, late.finding);
|
|
115
|
+
if (w.waived) continue;
|
|
116
|
+
refusals.push(orInvalid({ code: 'late_unruled', file: late.file, finding: late.finding, detail: `late finding ${late.finding} in ${late.file} has no ruling` }, late.finding, w.invalid));
|
|
86
117
|
}
|
|
87
118
|
|
|
88
119
|
for (const cap of mine.filter((r) => r.event === 'review.cap')) {
|
|
89
120
|
const hash = hashOf.get(cap.file);
|
|
90
121
|
if (hash !== undefined && approvedFor(cap.file, hash).some((r) => verified(r, key))) continue;
|
|
91
122
|
for (const finding of Array.isArray(cap.open) ? cap.open : []) {
|
|
92
|
-
|
|
123
|
+
const w = waiverFor(cap.file, finding);
|
|
124
|
+
if (!w.waived) refusals.push(orInvalid({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived` }, finding, w.invalid));
|
|
93
125
|
}
|
|
94
126
|
}
|
|
95
127
|
for (const r of checkBlockProof({ runId, files: proofFiles, rows: mine, key, highPaths })) {
|
|
96
|
-
|
|
128
|
+
const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING);
|
|
129
|
+
if (!w.waived) refusals.push(orInvalid(r, PROOF_FINDING, w.invalid));
|
|
97
130
|
}
|
|
98
131
|
return { ok: refusals.length === 0, refusals };
|
|
99
132
|
}
|
|
100
133
|
|
|
134
|
+
/** The finding severities autopilot may waive (B47); anything else is never delegated. */
|
|
135
|
+
export const DELEGABLE_SEVERITIES = Object.freeze(['warning', 'nit']);
|
|
136
|
+
|
|
137
|
+
/** Severity order for {@link recordedSeverity}: the worst recorded one wins. */
|
|
138
|
+
const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker: 3 });
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The severity the block's signed review rows record for one finding of one file (B47): every
|
|
142
|
+
* `review.triage` and `review.late_finding` row with that `block`, `file` and `finding`; the worst
|
|
143
|
+
* one wins. Fails closed: no row ⇒ `not-recorded`; any such row whose MAC does not verify ⇒
|
|
144
|
+
* `unverified`; a severity outside critical/blocker/warning/nit ⇒ `unknown` — each with
|
|
145
|
+
* `severity: null`.
|
|
146
|
+
* @param {Array<Record<string, any>>} rows - rows of ONE run (other runs filtered out by the caller).
|
|
147
|
+
* @param {{block: string, file: string, finding: string, key: Buffer}} where
|
|
148
|
+
* @returns {{severity: string, why: null} | {severity: null, why: 'not-recorded' | 'unverified' | 'unknown'}}
|
|
149
|
+
*/
|
|
150
|
+
export function recordedSeverity(rows, { block, file, finding, key }) {
|
|
151
|
+
const named = rows.filter((r) => (r?.event === 'review.triage' || r?.event === 'review.late_finding') && r.block === block && r.file === file && r.finding === finding);
|
|
152
|
+
if (named.length === 0) return { severity: null, why: 'not-recorded' };
|
|
153
|
+
if (named.some((r) => !verified(r, key))) return { severity: null, why: 'unverified' };
|
|
154
|
+
/** @type {Record<string, number>} */
|
|
155
|
+
const rank = SEVERITY_RANK;
|
|
156
|
+
let worst = '';
|
|
157
|
+
for (const r of named) {
|
|
158
|
+
if (typeof r.severity !== 'string' || !Object.hasOwn(rank, r.severity)) return { severity: null, why: 'unknown' };
|
|
159
|
+
if (worst === '' || rank[r.severity] > rank[worst]) worst = r.severity;
|
|
160
|
+
}
|
|
161
|
+
return { severity: worst, why: null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Why a `review.waived {by: autopilot}` row does NOT clear its finding, or null when it does (the
|
|
166
|
+
* rules are in the module doc). `blockRows`: the block's rows of this run (the review rows the
|
|
167
|
+
* severity is read from); `runRows`: every row of this run (grant, stop and expire rows carry no
|
|
168
|
+
* block); `allRows`: every row given (stop and expire rows are matched by grant id across all of
|
|
169
|
+
* them, whatever their `run`). A stop or expire row counts whether or not it is signed (fail closed).
|
|
170
|
+
* @param {Record<string, any>} waiver
|
|
171
|
+
* @param {{blockRows: Array<Record<string, any>>, runRows: Array<Record<string, any>>, allRows?: Array<Record<string, any>>, key: Buffer}} ctx
|
|
172
|
+
* @returns {string | null}
|
|
173
|
+
*/
|
|
174
|
+
export function autopilotWaiverProblem(waiver, { blockRows, runRows, allRows = runRows, key }) {
|
|
175
|
+
if (!verified(waiver, key)) return 'the waiver has no valid MAC';
|
|
176
|
+
if (waiver.finding === PROOF_FINDING) return 'proof is never delegated';
|
|
177
|
+
const recorded = recordedSeverity(blockRows, { block: waiver.block, file: waiver.file, finding: waiver.finding, key });
|
|
178
|
+
if (recorded.severity === null) return `the finding's severity is ${recorded.why}; critical findings are never delegated`;
|
|
179
|
+
if (!DELEGABLE_SEVERITIES.includes(recorded.severity)) return `the finding is ${recorded.severity}; critical findings are never delegated`;
|
|
180
|
+
if (waiver.severity !== recorded.severity) return `the waiver says ${String(waiver.severity)} but the finding is recorded as ${recorded.severity}`;
|
|
181
|
+
const at = typeof waiver.ts === 'string' ? Date.parse(waiver.ts) : Number.NaN;
|
|
182
|
+
if (!Number.isFinite(at)) return 'the waiver has no time';
|
|
183
|
+
const scope = `waive:${recorded.severity}`;
|
|
184
|
+
return (
|
|
185
|
+
grantCoverProblem(runRows, { grantId: waiver.grant_id, scope, at, key, what: 'the waiver', endRows: allRows }) ??
|
|
186
|
+
decisionProblem(runRows, { decisionId: waiver.decision_id, grantId: waiver.grant_id, scope, subject: { block: waiver.block, file: waiver.file, finding: waiver.finding }, decision: 'waive', at, key })
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A delegate decision allows an action for at most this long after it was made (B47). */
|
|
191
|
+
export const DECISION_MAX_AGE_MS = 30 * 60 * 1000;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Why grant `grantId` did NOT cover `scope` at time `at` (epoch ms), or null when it did (B47;
|
|
195
|
+
* shared by the waiver check and the fix loop's extra rounds): a signed `autopilot.grant` row of
|
|
196
|
+
* the run with that id, whose window (`ts` ≤ at < `until`) holds `at`, whose scopes include the
|
|
197
|
+
* scope and whose deny list does not, and no `autopilot.stop` / `autopilot.expire` row for it at
|
|
198
|
+
* or before `at`. A stop or expire row counts whether or not it is signed, and one with a missing
|
|
199
|
+
* or unparsable `ts` counts as having stopped the grant (fail closed). Stop and expire rows are
|
|
200
|
+
* looked for in `endRows` (default `runRows`) by grant id alone, whatever their `run`.
|
|
201
|
+
* @param {Array<Record<string, any>>} runRows - every row of the run.
|
|
202
|
+
* @param {{grantId: unknown, scope: string, at: number, key: Buffer, what?: string, endRows?: Array<Record<string, any>>}} q - `what` names the action in the message.
|
|
203
|
+
* @returns {string | null}
|
|
204
|
+
*/
|
|
205
|
+
export function grantCoverProblem(runRows, { grantId, scope, at, key, what = 'the action', endRows = runRows }) {
|
|
206
|
+
const id = grantId;
|
|
207
|
+
const grant = runRows.find((r) => r?.event === 'autopilot.grant' && typeof id === 'string' && r.grant_id === id && verified(r, key));
|
|
208
|
+
if (!grant) return `no signed autopilot.grant row for grant ${String(id)} in this run`;
|
|
209
|
+
const from = Date.parse(grant.ts);
|
|
210
|
+
const until = Date.parse(grant.until);
|
|
211
|
+
if (!(Number.isFinite(from) && Number.isFinite(until) && at >= from && at < until)) return `${what} is outside grant ${id}'s window`;
|
|
212
|
+
if (!Array.isArray(grant.scopes) || !grant.scopes.includes(scope) || (Array.isArray(grant.deny) && grant.deny.includes(scope))) return `grant ${id} does not allow ${scope}`;
|
|
213
|
+
const ended = endRows.find((r) => {
|
|
214
|
+
if ((r?.event !== 'autopilot.stop' && r?.event !== 'autopilot.expire') || r.grant_id !== id) return false;
|
|
215
|
+
const ts = typeof r.ts === 'string' ? Date.parse(r.ts) : Number.NaN;
|
|
216
|
+
return !Number.isFinite(ts) || ts <= at;
|
|
217
|
+
});
|
|
218
|
+
if (ended) return `grant ${id} was ${ended.event === 'autopilot.stop' ? 'stopped' : 'expired'} before ${what}`;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Why decision `decisionId` does NOT allow the action, or null when it does (B47): a signed
|
|
224
|
+
* `autopilot.decision` row of the run with that `decision_id`, `acted: true`, the same grant and
|
|
225
|
+
* scope, the fixed option word the action needs as its `decision` (`waive` for a waiver, `allow`
|
|
226
|
+
* for an extra round, the level itself — `L0`/`L1`/`L2` — for a coder level; so an acted "fix" or
|
|
227
|
+
* "deny" never authorises anything), a `subject` naming the same block (and file / finding when
|
|
228
|
+
* the action has them), made at or before `at` and at most {@link DECISION_MAX_AGE_MS} before it.
|
|
229
|
+
* @param {Array<Record<string, any>>} runRows
|
|
230
|
+
* @param {{decisionId: unknown, grantId: unknown, scope: string, subject: {block: string, file?: string, finding?: string}, decision: string, at: number, key: Buffer}} q
|
|
231
|
+
* @returns {string | null}
|
|
232
|
+
*/
|
|
233
|
+
export function decisionProblem(runRows, { decisionId, grantId, scope, subject, decision, at, key }) {
|
|
234
|
+
if (typeof decisionId !== 'string' || decisionId.length === 0) return 'no delegate decision named';
|
|
235
|
+
const row = runRows.find((r) => r?.event === 'autopilot.decision' && r.decision_id === decisionId);
|
|
236
|
+
if (!row) return `no autopilot.decision row ${decisionId} in this run`;
|
|
237
|
+
if (!verified(row, key)) return `decision ${decisionId} has no valid MAC`;
|
|
238
|
+
if (row.acted !== true) return `decision ${decisionId} did not act (it went to the owner)`;
|
|
239
|
+
if (row.grant_id !== grantId) return `decision ${decisionId} is under another grant`;
|
|
240
|
+
if (row.scope !== scope) return `decision ${decisionId} is for ${String(row.scope)}, not ${scope}`;
|
|
241
|
+
if (row.decision !== decision) return `decision ${decisionId} decided ${JSON.stringify(row.decision)}, not ${JSON.stringify(decision)}`;
|
|
242
|
+
const named = row.subject && typeof row.subject === 'object' ? row.subject : {};
|
|
243
|
+
for (const k of /** @type {Array<'block' | 'file' | 'finding'>} */ (['block', 'file', 'finding'])) {
|
|
244
|
+
if (subject[k] !== undefined && named[k] !== subject[k]) return `decision ${decisionId} is about another ${k}`;
|
|
245
|
+
}
|
|
246
|
+
const ts = typeof row.ts === 'string' ? Date.parse(row.ts) : Number.NaN;
|
|
247
|
+
if (!Number.isFinite(ts) || ts > at || at - ts > DECISION_MAX_AGE_MS) return `decision ${decisionId} is older than 30 min (or not yet made)`;
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
101
251
|
/** The finding id a human waives to clear an `unproven` file: `block waive <id> proof --file <path>`. */
|
|
102
252
|
export const PROOF_FINDING = 'proof';
|
|
103
253
|
|