@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +39 -2
  3. package/docs/README.md +5 -4
  4. package/docs/reference/blocks.json +127 -1
  5. package/docs/reference/config.md +2 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +7 -0
  8. package/skill/SKILL.md +2 -0
  9. package/skill/references/adapters/claude-code.md +14 -0
  10. package/skill/references/autopilot.md +48 -0
  11. package/skill/references/code.md +1 -1
  12. package/skill/references/decisions.md +3 -1
  13. package/skill/references/harden.md +1 -1
  14. package/skill/references/ledger.md +2 -0
  15. package/skill/references/plan.md +6 -4
  16. package/skill/references/review.md +2 -2
  17. package/skill/references/rules-core.md +4 -1
  18. package/skill/scripts/forge +1 -1
  19. package/skill/templates/plan.md +4 -3
  20. package/src/autopilot/actions.mjs +491 -0
  21. package/src/autopilot/binnacle.mjs +494 -0
  22. package/src/autopilot/delegate.mjs +422 -0
  23. package/src/autopilot/grant.mjs +354 -0
  24. package/src/autopilot/limits.mjs +848 -0
  25. package/src/autopilot/scopes.mjs +95 -0
  26. package/src/cli/author.mjs +7 -5
  27. package/src/cli/autopilot.mjs +500 -0
  28. package/src/cli/block.mjs +40 -8
  29. package/src/cli/facts.mjs +7 -6
  30. package/src/cli/jev.mjs +10 -6
  31. package/src/cli/logs.mjs +4 -142
  32. package/src/cli/plan.mjs +18 -7
  33. package/src/cli/run.mjs +20 -61
  34. package/src/cli/s2.mjs +5 -4
  35. package/src/cli/spawn.mjs +5 -4
  36. package/src/config/closed-book.mjs +20 -3
  37. package/src/config/load.mjs +113 -1
  38. package/src/doctor/probes.mjs +34 -11
  39. package/src/engines/builders/claude.mjs +3 -3
  40. package/src/engines/builders/codex.mjs +6 -5
  41. package/src/engines/builders/grok.mjs +3 -3
  42. package/src/engines/builders/validate-params.mjs +3 -3
  43. package/src/ledger/report.mjs +2 -0
  44. package/src/review/fixloop.mjs +69 -5
  45. package/src/review/gate-check.mjs +156 -6
  46. package/src/session/facts.mjs +291 -73
  47. package/src/session/plan-check.mjs +75 -6
  48. package/src/session/spawn.mjs +68 -8
  49. package/src/state/run.mjs +8 -1
  50. package/src/util/breadcrumbs.mjs +1 -0
  51. package/src/util/forbidden.mjs +10 -0
  52. package/src/util/gh.mjs +175 -0
  53. package/src/worker/engine.mjs +8 -4
  54. 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
- const waived = (/** @type {string} */ file, /** @type {string} */ finding) => waivers.some((r) => matches(r, file, finding));
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)) || waived(late.file, late.finding)) continue;
85
- refusals.push({ code: 'late_unruled', file: late.file, finding: late.finding, detail: `late finding ${late.finding} in ${late.file} has no ruling` });
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
- if (!waived(cap.file, finding)) refusals.push({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived` });
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
- if (!waived(/** @type {string} */ (r.file), PROOF_FINDING)) refusals.push(r);
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