@codedology/code-forge 0.3.2 → 0.5.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 (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +88 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +236 -1
  6. package/docs/reference/config.md +13 -1
  7. package/package.json +6 -2
  8. package/schema/code-forge.schema.json +26 -4
  9. package/skill/SKILL.md +8 -7
  10. package/skill/references/adapters/claude-code.md +5 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +10 -2
  17. package/skill/references/plan.md +12 -3
  18. package/skill/references/review.md +13 -6
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/facts.mjs +16 -1
  25. package/src/cli/jev.mjs +114 -17
  26. package/src/cli/ledger.mjs +99 -8
  27. package/src/cli/logs.mjs +193 -58
  28. package/src/cli/plan.mjs +27 -11
  29. package/src/cli/report.mjs +15 -1
  30. package/src/cli/resolve.mjs +16 -1
  31. package/src/cli/review-file.mjs +10 -5
  32. package/src/cli/review.mjs +40 -9
  33. package/src/cli/run.mjs +109 -8
  34. package/src/config/closed-book.mjs +75 -0
  35. package/src/config/validate.mjs +128 -7
  36. package/src/decide/escalation.mjs +112 -11
  37. package/src/decide/questions.mjs +27 -4
  38. package/src/doctor/index.mjs +2 -1
  39. package/src/doctor/local.mjs +15 -0
  40. package/src/doctor/probes.mjs +48 -6
  41. package/src/engines/builders/claude.mjs +4 -1
  42. package/src/engines/builders/codex.mjs +26 -11
  43. package/src/engines/builders/grok.mjs +3 -3
  44. package/src/engines/efforts.mjs +109 -0
  45. package/src/engines/sentinel.mjs +34 -0
  46. package/src/keys/onepassword.mjs +3 -0
  47. package/src/ledger/report.mjs +66 -29
  48. package/src/ledger/spend.mjs +288 -0
  49. package/src/review/engine.mjs +57 -16
  50. package/src/review/fixloop.mjs +29 -6
  51. package/src/review/session-retry.mjs +69 -0
  52. package/src/review/triage.mjs +26 -1
  53. package/src/review/validate-review.mjs +6 -3
  54. package/src/session/author.mjs +6 -2
  55. package/src/session/facts.mjs +218 -53
  56. package/src/session/plan-check.mjs +71 -6
  57. package/src/session/plan-sections.mjs +98 -0
  58. package/src/session/spawn.mjs +373 -20
  59. package/src/state/block.mjs +13 -4
  60. package/src/state/config-snapshot.mjs +150 -0
  61. package/src/state/run.mjs +86 -0
  62. package/src/util/breadcrumbs.mjs +145 -0
  63. package/src/util/error-hint.mjs +3 -2
  64. package/src/util/error-kind.mjs +2 -2
  65. package/src/util/error-log.mjs +109 -18
  66. package/src/util/forbidden.mjs +3 -1
  67. package/src/util/known-fixes.json +1 -0
  68. package/src/util/known-fixes.mjs +153 -0
  69. package/src/util/locks.mjs +104 -0
  70. package/src/worker/engine.mjs +122 -40
  71. package/src/worker/loop.mjs +326 -78
  72. package/src/worker/queue.mjs +68 -7
package/src/cli/facts.mjs CHANGED
@@ -16,7 +16,7 @@ import { buildFacts, FactsError } from '../session/facts.mjs';
16
16
  import { runRootFor, SessionError } from '../session/spawn.mjs';
17
17
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
18
18
  import { StateError } from '../state/paths.mjs';
19
- import { writeSafe } from '../util/redact.mjs';
19
+ import { redact, writeSafe } from '../util/redact.mjs';
20
20
  import { slugFor } from './run.mjs';
21
21
  import { exitCodeFor } from './spawn.mjs';
22
22
 
@@ -34,6 +34,19 @@ function readable(file, { fileOnly = false } = {}) {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * One stderr line for a downgraded row: the id must be `F<n>` (else `F?`), the reason is redacted
39
+ * and every control character (newlines included) becomes a space, so a row is always one line.
40
+ * @param {{fact_id: string, reason: string}} d
41
+ * @returns {string}
42
+ */
43
+ export function downgradeLine(d) {
44
+ const id = /^F\d+$/.test(String(d.fact_id)) ? d.fact_id : 'F?';
45
+ // eslint-disable-next-line no-control-regex
46
+ const reason = redact(String(d.reason).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' '));
47
+ return `facts: ${id} not counted — ${reason}\n`;
48
+ }
49
+
37
50
  const USAGE = 'usage: code-forge facts --brief <file> [--sources <path>...] [--out <file>] [--run <id>] [--timeout <seconds>]\n';
38
51
 
39
52
  /**
@@ -107,6 +120,8 @@ export async function runFactsVerb(args, deps = {}) {
107
120
  return exitCodeFor(result);
108
121
  }
109
122
  const facts = result.facts ?? [];
123
+ // B39: a row whose check broke a rule is kept as UNVERIFIABLE (not counted); say so, exit 0
124
+ for (const d of result.downgraded ?? []) writeSafe(stderr, downgradeLine(d));
110
125
  writeSafe(stdout, `${JSON.stringify({ out: outPath, claims: result.claims.length, verified: facts.filter((f) => f.tag === 'VERIFIED').length })}\n`);
111
126
  return 0;
112
127
  } catch (err) {
package/src/cli/jev.mjs CHANGED
@@ -5,6 +5,19 @@
5
5
  * `src/decide/**` directly rather than shelling out to this verb).
6
6
  *
7
7
  * jev ask <id> --state <file.json> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>]
8
+ * [--block <id>] [--plan <file>] [--rules]
9
+ *
10
+ * B36: `--block` records the block the answer is for (and `--plan` the plan file's base name) on
11
+ * the decision row — `plan check` refuses a block whose level is not the lane recorded this way.
12
+ * `--rules` answers `lane`, `risk` or `security_sensitive` with the deterministic fallback rules
13
+ * (`decide/fallback-rules.mjs`, for a run without Jev): the state file is then an object carrying
14
+ * all six fallback facts (`filesChanged`, `linesAdded` numbers; `touchesMigration`,
15
+ * `touchesPolicyOrMiddleware`, `pathFloorHit` booleans; `keywordsFound` a string list) — a missing
16
+ * or mistyped one is a usage error naming it, with nothing recorded. `system1.disable` still
17
+ * applies. No key is resolved, no request is sent, and the row says `source: 'rules'`.
18
+ * The ledger slug is `--slug`, else `project.slug`, else the directory-name slug `run` and
19
+ * `author` use (`slugFor`; B36 — it used to fall back to `code-forge`). An unknown flag is a usage
20
+ * error.
8
21
  *
9
22
  * Resolves the Jev key through B2's chain (`resolveKey`, earlier batch — the seam rule allows a
10
23
  * Wave 1 module to import a block in an earlier batch statically), asks Jev, runs the answer
@@ -23,24 +36,74 @@ import path from 'node:path';
23
36
  import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../decide/questions.mjs';
24
37
  import { askJev as realAskJev } from '../decide/jev-client.mjs';
25
38
  import { decide } from '../decide/thresholds.mjs';
39
+ import { resolveDispatchQuestionsByRules } from '../decide/fallback-rules.mjs';
26
40
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
27
41
  import { appendRow as realAppendRow } from '../ledger/write.mjs';
28
42
  import { writeSafe } from '../util/redact.mjs';
43
+ import { slugFor } from './run.mjs';
44
+
45
+ const USAGE = 'usage: code-forge jev ask <question-id> --state <file> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>] [--block <id>] [--plan <file>] [--rules]\n';
46
+
47
+ /** The block id shape the run record uses (`state/block.mjs`). */
48
+ const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
49
+
50
+ /** Flags that take a value, and the one boolean flag. */
51
+ const VALUE_FLAGS = Object.freeze(['--state', '--cwd', '--slug', '--key-ref', '--block', '--plan']);
52
+ const BOOLEAN_FLAGS = Object.freeze(['--rules']);
53
+
54
+ /**
55
+ * Strict flag walk over everything after `ask <id>`: a value flag takes the next token (never one
56
+ * that starts with `--`, so a bare `--slug --cwd x` never reads `--cwd` as the slug), a boolean
57
+ * flag takes none, and an unknown token, a repeated flag or a missing value is a usage error.
58
+ * @param {string[]} rest
59
+ * @returns {{ok: true, values: Record<string, string>, booleans: Set<string>} | {ok: false}}
60
+ */
61
+ function parseAskFlags(rest) {
62
+ /** @type {Record<string, string>} */
63
+ const values = {};
64
+ const booleans = new Set();
65
+ for (let i = 0; i < rest.length; i += 1) {
66
+ const arg = rest[i];
67
+ if (BOOLEAN_FLAGS.includes(arg)) {
68
+ if (booleans.has(arg)) return { ok: false };
69
+ booleans.add(arg);
70
+ continue;
71
+ }
72
+ if (!VALUE_FLAGS.includes(arg) || Object.hasOwn(values, arg)) return { ok: false };
73
+ const next = rest[i + 1];
74
+ if (next === undefined || next.startsWith('--')) return { ok: false };
75
+ values[arg] = next;
76
+ i += 1;
77
+ }
78
+ return { ok: true, values, booleans };
79
+ }
29
80
 
30
- const USAGE = 'usage: code-forge jev ask <question-id> --state <file> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>]\n';
81
+ /** The six fallback facts `--rules` needs, each with its type (`decide/fallback-rules.mjs`). */
82
+ const RULE_FACTS = Object.freeze({
83
+ filesChanged: 'number',
84
+ linesAdded: 'number',
85
+ touchesMigration: 'boolean',
86
+ touchesPolicyOrMiddleware: 'boolean',
87
+ pathFloorHit: 'boolean',
88
+ keywordsFound: 'string[]',
89
+ });
31
90
 
32
91
  /**
33
- * @param {string[]} args @param {string} flag
34
- * @returns {{ok: true, value: string|undefined} | {ok: false}} same "flag given with no usable
35
- * value is a usage error" contract as `ledger.mjs`'s `readOptionalFlag` — a bare `--slug --cwd x`
36
- * must never silently read `--cwd` as `--slug`'s value.
92
+ * @param {unknown} state
93
+ * @returns {string[]} the fallback facts that are missing or of the wrong type (all six when the
94
+ * state is not a plain object).
37
95
  */
38
- function readOptionalFlag(args, flag) {
39
- const i = args.indexOf(flag);
40
- if (i < 0) return { ok: true, value: undefined };
41
- const next = args[i + 1];
42
- if (next === undefined || next.startsWith('--')) return { ok: false };
43
- return { ok: true, value: next };
96
+ function badRuleFacts(state) {
97
+ if (state === null || typeof state !== 'object' || Array.isArray(state)) return Object.keys(RULE_FACTS);
98
+ const facts = /** @type {Record<string, unknown>} */ (state);
99
+ return Object.entries(RULE_FACTS)
100
+ .filter(([name, type]) => {
101
+ const v = facts[name];
102
+ if (type === 'number') return typeof v !== 'number' || !Number.isFinite(v) || v < 0;
103
+ if (type === 'boolean') return typeof v !== 'boolean';
104
+ return !Array.isArray(v) || !v.every((k) => typeof k === 'string');
105
+ })
106
+ .map(([name]) => name);
44
107
  }
45
108
 
46
109
  /**
@@ -82,14 +145,31 @@ export async function runJev(args, deps = {}) {
82
145
  return 2;
83
146
  }
84
147
 
85
- const stateFlag = readOptionalFlag(args, '--state');
86
- const cwdFlag = readOptionalFlag(args, '--cwd');
87
- const slugFlag = readOptionalFlag(args, '--slug');
88
- const keyRefFlag = readOptionalFlag(args, '--key-ref');
89
- if (!stateFlag.ok || !cwdFlag.ok || !slugFlag.ok || !keyRefFlag.ok || !stateFlag.value) {
148
+ const parsed = parseAskFlags(args.slice(2));
149
+ if (!parsed.ok || parsed.values['--state'] === undefined) {
90
150
  err(USAGE);
91
151
  return 2;
92
152
  }
153
+ const stateFlag = { value: parsed.values['--state'] };
154
+ const cwdFlag = { value: parsed.values['--cwd'] };
155
+ const slugFlag = { value: parsed.values['--slug'] };
156
+ const keyRefFlag = { value: parsed.values['--key-ref'] };
157
+ const blockFlag = { value: parsed.values['--block'] };
158
+ const planFlag = { value: parsed.values['--plan'] };
159
+ if (blockFlag.value !== undefined && !BLOCK_ID.test(blockFlag.value)) {
160
+ err('jev ask: --block must be a block id (letters, digits, . _ -; at most 32 characters)\n');
161
+ return 2;
162
+ }
163
+ const rules = parsed.booleans.has('--rules');
164
+ /** @type {Record<string, unknown>} */
165
+ const scope = {
166
+ ...(blockFlag.value !== undefined ? { block: blockFlag.value } : {}),
167
+ ...(planFlag.value !== undefined ? { plan: path.basename(planFlag.value) } : {}),
168
+ };
169
+ if (rules && !['lane', 'risk', 'security_sensitive'].includes(id)) {
170
+ err(`jev ask: no rule answers "${id}" (--rules covers lane, risk, security_sensitive)\n`);
171
+ return 2;
172
+ }
93
173
  const cwd = cwdFlag.value ?? process.cwd();
94
174
 
95
175
  let state;
@@ -110,11 +190,28 @@ export async function runJev(args, deps = {}) {
110
190
  return 1;
111
191
  }
112
192
 
193
+ // the same ledger as the rest of the tool: --slug, else project.slug, else the directory name
194
+ const slug = slugFlag.value ?? slugFor(cfg, cwd);
195
+
113
196
  if (isQuestionDisabled(id, cfg)) {
114
197
  err(`jev ask: question "${id}" is disabled by system1.disable\n`);
115
198
  return 1;
116
199
  }
117
200
 
201
+ if (rules) {
202
+ const bad = badRuleFacts(state);
203
+ if (bad.length > 0) {
204
+ err(`jev ask: --rules needs the fallback facts in the --state object; missing or wrong type: ${bad.join(', ')}\n`);
205
+ return 2;
206
+ }
207
+ const byRules = resolveDispatchQuestionsByRules(state);
208
+ const value = byRules[/** @type {'lane'|'risk'|'security_sensitive'} */ (id)].value;
209
+ const decisionId = makeId();
210
+ await write({ event: 'decision', decision_id: decisionId, question: id, answer: value, source: 'rules', ...scope }, { slug });
211
+ out(`${JSON.stringify({ decision_id: decisionId, question: id, answer: value, source: 'rules', ...scope }, null, 2)}\n`);
212
+ return 0;
213
+ }
214
+
118
215
  const keyStore = store ?? (await createDefaultKeyStore(env));
119
216
  const keyRef = keyRefFlag.value ?? cfg?.keys?.jev;
120
217
  let resolved;
@@ -146,7 +243,6 @@ export async function runJev(args, deps = {}) {
146
243
  return 1;
147
244
  }
148
245
  const decision = decide(id, answer, cfg);
149
- const slug = slugFlag.value ?? cfg?.project?.slug ?? 'code-forge';
150
246
  const decisionId = makeId();
151
247
 
152
248
  await write(
@@ -159,6 +255,7 @@ export async function runJev(args, deps = {}) {
159
255
  confidence: decision.confidence,
160
256
  margin: decision.margin,
161
257
  source: 'jev',
258
+ ...scope,
162
259
  tokens_in: result.usage?.input_tokens,
163
260
  tokens_out: result.usage?.output_tokens,
164
261
  },
@@ -6,13 +6,20 @@
6
6
  * ledger calibration --slug <slug> [--question <id>]
7
7
  * ledger outcome --scan-git --slug <slug> [--cwd <dir>] [--days <n>]
8
8
  * ledger outcome --pr <n> --ci red|green|reverted --slug <slug>
9
- * ledger tail --slug <slug> [--n <count>]
9
+ * ledger tail --slug <slug> [--n <count> | -n <count>]
10
+ * ledger add coder --run <r> --block <b> --usd <n> [--note "..."]
11
+ *
12
+ * B33: `ledger add coder` records manual/cloud coder spend (a session code-forge never saw) as a
13
+ * signed `session` row (`role: coder, manual: true, usd, cost_source: manual`) in the run's
14
+ * ledger, so `report` and the `budget.usd` check count it. The slug is the run record's project.
10
15
  */
11
16
 
12
17
  import { writeSafe } from '../util/redact.mjs';
13
18
  import { buildCalibration } from '../ledger/calibration.mjs';
14
19
  import { recordCiOutcome, reviewedEntriesFromRows, scanGitAndRecord } from '../ledger/outcome.mjs';
15
- import { readAllRows } from '../ledger/write.mjs';
20
+ import { roundUsd, runSpend } from '../ledger/spend.mjs';
21
+ import { appendRow, readAllRows } from '../ledger/write.mjs';
22
+ import { readRun, writeSigned } from '../state/run.mjs';
16
23
 
17
24
  /**
18
25
  * @param {string[]} args @param {string} flag
@@ -56,9 +63,10 @@ export async function runLedger(args, deps = {}) {
56
63
  const { stdout = process.stdout, stderr = process.stderr } = deps;
57
64
  const out = (/** @type {string} */ s) => writeSafe(stdout, s);
58
65
  const err = (/** @type {string} */ s) => writeSafe(stderr, s);
59
- const usage = () => err('usage: code-forge ledger calibration|outcome|tail --slug <slug> [...]\n');
66
+ const usage = () => err('usage: code-forge ledger calibration|outcome|tail --slug <slug> [...] (tail: [--n|-n <count>]) | add coder --run <r> --block <b> --usd <n> [--note "..."]\n');
60
67
 
61
68
  const [sub] = args;
69
+ if (sub === 'add') return addSpend(args.slice(1), { out, err });
62
70
  const slug = flagValue(args, '--slug');
63
71
  if (!slug) {
64
72
  usage();
@@ -126,15 +134,22 @@ export async function runLedger(args, deps = {}) {
126
134
  }
127
135
 
128
136
  if (sub === 'tail') {
129
- const nFlag = readOptionalFlag(args, '--n');
130
- if (!nFlag.ok) {
131
- err('ledger tail: --n requires a value\n');
137
+ // `-n` is the spelling everyone types from `tail(1)`; before B31 it was not recognised, so
138
+ // `ledger tail -n 3` silently printed the default 20 rows (issue #2, field note 8).
139
+ const longN = readOptionalFlag(args, '--n');
140
+ const shortN = readOptionalFlag(args, '-n');
141
+ if (!longN.ok || !shortN.ok) {
142
+ err(`ledger tail: ${longN.ok ? '-n' : '--n'} requires a value\n`);
143
+ return 2;
144
+ }
145
+ if (longN.value !== undefined && shortN.value !== undefined) {
146
+ err('ledger tail: give the count once, as --n <count> or -n <count>\n');
132
147
  return 2;
133
148
  }
134
- const nArg = nFlag.value ?? '20';
149
+ const nArg = longN.value ?? shortN.value ?? '20';
135
150
  const n = parseNonNegativeInt(nArg);
136
151
  if (n === null) {
137
- err(`ledger tail: --n must be a non-negative integer, got "${nArg}"\n`);
152
+ err(`ledger tail: --n/-n must be a non-negative integer, got "${nArg}"\n`);
138
153
  return 2;
139
154
  }
140
155
  const rows = await readAllRows(slug);
@@ -154,6 +169,82 @@ export async function runLedger(args, deps = {}) {
154
169
  return 2;
155
170
  }
156
171
 
172
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
173
+ const MAX_NOTE_CHARS = 500;
174
+
175
+ /**
176
+ * `ledger add coder --run <r> --block <b> --usd <n> [--note "..."]` (B33).
177
+ * @param {string[]} args - after `add`.
178
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io
179
+ * @returns {Promise<number>}
180
+ */
181
+ async function addSpend(args, { out, err }) {
182
+ const usage = () => err('usage: code-forge ledger add coder --run <r> --block <b> --usd <n> [--note "..."]\n');
183
+ const [role] = args;
184
+ const run = flagValue(args, '--run');
185
+ const block = flagValue(args, '--block');
186
+ const usdArg = flagValue(args, '--usd');
187
+ if (role !== 'coder' || !run || !block || usdArg === undefined) {
188
+ usage();
189
+ return 2;
190
+ }
191
+ const note = readOptionalFlag(args, '--note');
192
+ if (!note.ok) {
193
+ err('ledger add: --note requires a value\n');
194
+ return 2;
195
+ }
196
+ if (!ID.test(run)) {
197
+ err('ledger add: --run must be a run id (letters, digits, ".", "_", "-")\n');
198
+ return 2;
199
+ }
200
+ if (!ID.test(block)) {
201
+ err('ledger add: --block must be a block id (letters, digits, ".", "_", "-")\n');
202
+ return 2;
203
+ }
204
+ // rounded to the ledger's 4 decimals FIRST, then checked: 0.00001 rounds to 0 and is refused
205
+ const usd = /^\d+(\.\d+)?$/.test(usdArg) ? roundUsd(Number(usdArg)) : Number.NaN;
206
+ if (!Number.isFinite(usd) || usd <= 0) {
207
+ err(`ledger add: --usd must be a number > 0 (at 4 decimals), got "${usdArg}"\n`);
208
+ return 2;
209
+ }
210
+ if (note.value !== undefined && note.value.length > MAX_NOTE_CHARS) {
211
+ err(`ledger add: --note is longer than ${MAX_NOTE_CHARS} characters\n`);
212
+ return 2;
213
+ }
214
+ try {
215
+ const { project } = await readRun(run);
216
+ const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project });
217
+ const row = {
218
+ event: 'session',
219
+ role: 'coder',
220
+ manual: true,
221
+ block,
222
+ provider: null,
223
+ level: null,
224
+ tokens_in: null,
225
+ tokens_out: null,
226
+ usd,
227
+ cost_source: 'manual',
228
+ ...(note.value !== undefined ? { note: note.value } : {}),
229
+ };
230
+ await writeSigned(run, writeRow, row);
231
+ // the row is written: from here on nothing may throw or change the exit code
232
+ /** @type {number | null} */
233
+ let total = null;
234
+ try {
235
+ const spent = runSpend(await readAllRows(project), run).usd;
236
+ total = Number.isFinite(spent) ? spent : null;
237
+ } catch {
238
+ total = null;
239
+ }
240
+ out(`recorded coder spend $${usd.toFixed(4)} for run ${run} block ${block} · run total ${total === null ? 'unknown' : `$${total.toFixed(4)}`}\n`);
241
+ return 0;
242
+ } catch (thrown) {
243
+ err(`ledger add: ${thrown?.message ?? String(thrown)}\n`);
244
+ return 1;
245
+ }
246
+ }
247
+
157
248
  /** @param {string[]} args @returns {Promise<number>} */
158
249
  export default async function ledger(args) {
159
250
  return runLedger(args);