@codedology/code-forge 0.7.7 → 0.8.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 (59) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +50 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +157 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +33 -4
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +4 -2
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +18 -1
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +292 -33
  18. package/src/cli/context.mjs +175 -0
  19. package/src/cli/gates.mjs +125 -15
  20. package/src/cli/ledger.mjs +46 -2
  21. package/src/cli/proof.mjs +62 -16
  22. package/src/cli/report.mjs +7 -4
  23. package/src/cli/review-file.mjs +61 -6
  24. package/src/cli/status.mjs +212 -0
  25. package/src/config/validate.mjs +45 -11
  26. package/src/context/pack.mjs +319 -0
  27. package/src/doctor/local.mjs +2 -1
  28. package/src/engines/sentinel.mjs +27 -6
  29. package/src/gates/detect.mjs +21 -8
  30. package/src/gates/expand.mjs +202 -0
  31. package/src/gates/owned-count.mjs +66 -0
  32. package/src/gates/run.mjs +49 -12
  33. package/src/install/wizard/answers.mjs +34 -5
  34. package/src/install/wizard/flags.mjs +8 -1
  35. package/src/install/wizard/project-file.mjs +2 -0
  36. package/src/install/wizard/run.mjs +6 -0
  37. package/src/install/wizard/steps.mjs +24 -4
  38. package/src/install/wizard/summary.mjs +13 -5
  39. package/src/ledger/export.mjs +1 -1
  40. package/src/ledger/outside-review.mjs +164 -0
  41. package/src/ledger/report.mjs +17 -5
  42. package/src/proof/export.mjs +93 -10
  43. package/src/proof/lock.mjs +28 -5
  44. package/src/proof/worktree.mjs +482 -0
  45. package/src/review/gate-check.mjs +9 -5
  46. package/src/review/open-findings.mjs +59 -0
  47. package/src/state/block-end.mjs +290 -0
  48. package/src/state/block.mjs +103 -39
  49. package/src/state/brief-status.mjs +131 -0
  50. package/src/state/config-snapshot.mjs +51 -0
  51. package/src/state/registry.mjs +35 -4
  52. package/src/state/run.mjs +10 -1
  53. package/src/util/breadcrumbs.mjs +1 -0
  54. package/src/util/forbidden.mjs +36 -1
  55. package/src/util/gate-token.mjs +8 -0
  56. package/src/worker/engine.mjs +157 -17
  57. package/src/worker/loop.mjs +26 -7
  58. package/src/worker/queue.mjs +36 -10
  59. package/src/worker/ticket.mjs +46 -6
@@ -0,0 +1,66 @@
1
+ /**
2
+ * B60: how many FILES a block owns, for `gates.full_suite_threshold_files`.
3
+ *
4
+ * The owned entries are resolved against the workspace's tracked and new (not ignored) files
5
+ * (`git ls-files -z --cached --others --exclude-standard`, argv only) with the B57
6
+ * ownership rule (`ownsFile`, state/registry.mjs): a directory entry counts every such file below it, a glob counts its matches, a file counts once. Each file is counted
7
+ * once however many entries cover it. An entry that covers none of them (a path the block will
8
+ * create) counts as 1. Outside a git repository every entry counts as 1; any other git failure
9
+ * (a timeout, a spawn error, git failing inside a repository) gives `Infinity`, so the full suite
10
+ * runs (`reason: threshold`) rather than a guess.
11
+ *
12
+ * {@link countOwnedFiles} is the pure rule; {@link ownedFileCount} reads the files from git.
13
+ */
14
+ import os from 'node:os';
15
+ import { ownsFile } from '../state/registry.mjs';
16
+ import { exec } from '../util/exec.mjs';
17
+
18
+ const GIT_TIMEOUT_MS = 30000;
19
+
20
+ /**
21
+ * The number of files `owned` covers among `files`, each counted once, plus 1 for every entry
22
+ * that covers none of them.
23
+ * @param {ReadonlyArray<string>} owned
24
+ * @param {ReadonlyArray<string>} tracked - the workspace's files
25
+ * @param {Record<string, string>} [kinds] - the block's `owned_kinds`
26
+ * @returns {number}
27
+ */
28
+ export function countOwnedFiles(owned, tracked, kinds) {
29
+ const covered = new Set();
30
+ let notThere = 0;
31
+ for (const entry of owned) {
32
+ const hits = tracked.filter((file) => ownsFile([entry], file, kinds));
33
+ if (hits.length === 0) notThere += 1;
34
+ for (const file of hits) covered.add(file);
35
+ }
36
+ return covered.size + notThere;
37
+ }
38
+
39
+ /** `process.env` minus every inherited `GIT_*`, with no system/global config, messages in English. */
40
+ function gitEnv() {
41
+ /** @type {NodeJS.ProcessEnv} */
42
+ const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
43
+ for (const [key, value] of Object.entries(process.env)) {
44
+ if (!key.startsWith('GIT_')) env[key] = value;
45
+ }
46
+ env.LC_ALL = 'C'; // the "not a git repository" message is matched below
47
+ return env;
48
+ }
49
+
50
+ /** The files `git ls-files` lists: tracked (`--cached`) and new, not ignored (`--others --exclude-standard`). */
51
+ const LS_FILES_ARGV = Object.freeze(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard']);
52
+
53
+ /**
54
+ * {@link countOwnedFiles} over the tracked and new (not ignored) files of the workspace at `cwd`.
55
+ * Not a git repository: every entry counts 1. Any other failure: `Infinity` (the full suite runs).
56
+ * @param {string} cwd
57
+ * @param {ReadonlyArray<string>} owned
58
+ * @param {Record<string, string>} [kinds]
59
+ * @returns {Promise<number>}
60
+ */
61
+ export async function ownedFileCount(cwd, owned, kinds) {
62
+ const res = await exec([...LS_FILES_ARGV], { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
63
+ if (res.result === 'ok') return countOwnedFiles(owned, res.stdout.split('\0').filter((f) => f.length > 0), kinds);
64
+ if (!res.timedOut && res.code === 128 && /not a git repository/i.test(res.stderr)) return countOwnedFiles(owned, [], kinds);
65
+ return Number.POSITIVE_INFINITY;
66
+ }
package/src/gates/run.mjs CHANGED
@@ -7,10 +7,13 @@
7
7
  */
8
8
 
9
9
  import { exec } from '../util/exec.mjs';
10
+ import { registerSecret } from '../util/redact.mjs';
11
+ import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
10
12
 
11
13
  /** @typedef {import('./detect.mjs').Gate} Gate */
12
14
  /** @typedef {import('./detect.mjs').GateCommand} GateCommand */
13
15
  /** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
16
+ /** @typedef {import('./expand.mjs').TestScope} TestScope */
14
17
 
15
18
  /**
16
19
  * @typedef {object} GateResult
@@ -22,26 +25,35 @@ import { exec } from '../util/exec.mjs';
22
25
  * @property {number | null} code
23
26
  * @property {string} stdout
24
27
  * @property {string} stderr
28
+ * @property {'related'|'full'} [scope] - B60: only on a gate whose argv has the `{test_files}` item.
29
+ * @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
30
+ * @property {'threshold'|'no_tests'} [reason] - B60: with `scope: full`, why the item was dropped.
31
+ * @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
32
+ * NAMES, sorted (the values are never in a result).
25
33
  */
26
34
 
27
35
  /** Fixed run order — always all four, regardless of an earlier one's result. */
28
36
  const GATE_ORDER = /** @type {const} */ (['test', 'lint', 'types', 'format']);
29
37
 
30
38
  /**
31
- * A gate command is either a plain argv array or `{argv, failOnStdout: true}` (detect.mjs's
32
- * `GateCommand`, e.g. `gofmt -l` which always exits 0). Normalized here so the run loop below has
33
- * one shape to deal with.
39
+ * A gate command is either a plain argv array or `{argv, failOnStdout?, env?}` (detect.mjs's
40
+ * `GateCommand`: `failOnStdout` for `gofmt -l`, which always exits 0; B60 `env` from the config).
41
+ * Normalized here so the run loop below has one shape to deal with.
34
42
  * @param {GateCommand} command
35
- * @returns {{argv: string[], failOnStdout: boolean}}
43
+ * @returns {{argv: string[], failOnStdout: boolean, env: Record<string, string> | null}}
36
44
  */
37
45
  function normalizeCommand(command) {
38
- if (Array.isArray(command)) return { argv: command, failOnStdout: false };
39
- return { argv: command.argv, failOnStdout: command.failOnStdout === true };
46
+ if (Array.isArray(command)) return { argv: command, failOnStdout: false, env: null };
47
+ return { argv: command.argv, failOnStdout: command.failOnStdout === true, env: envOf(command) }; // `env: {}` is no env
40
48
  }
41
49
 
42
50
  /**
51
+ * B60: `opts.scope` is the block's {@link TestScope} (`testScope`, gates/expand.mjs); a gate argv
52
+ * item that is exactly `{test_files}` expands to its files, or is dropped for a full run. Left out,
53
+ * no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
54
+ * variables added to the inherited environment; a gate with neither runs exactly as before B60.
43
55
  * @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
44
- * @param {{cwd: string, timeoutMs?: number}} opts
56
+ * @param {{cwd: string, timeoutMs?: number, scope?: TestScope}} opts
45
57
  * @returns {Promise<{results: GateResult[], allOk: boolean}>}
46
58
  */
47
59
  export async function runGates(gates, opts) {
@@ -49,24 +61,49 @@ export async function runGates(gates, opts) {
49
61
  throw new TypeError('runGates: gates must be an object with test/lint/types/format');
50
62
  }
51
63
  const { cwd, timeoutMs = 300000 } = opts ?? {};
64
+ const scope = opts?.scope ?? NO_BLOCK_SCOPE; // null falls back too
52
65
  if (typeof cwd !== 'string' || cwd.length === 0) {
53
66
  throw new TypeError('runGates: opts.cwd must be a non-empty string');
54
67
  }
55
68
 
69
+ // B60: gate env is not a secret store. Its values are never put in a result (only `env_keys`);
70
+ // a value of 8+ characters is also masked in the child's own output (a shorter one would mask
71
+ // unrelated text — `MODE=test` vs `npm test`)
72
+ for (const value of maskableEnvValues(gates)) registerSecret(value);
73
+ // Every gate is normalized and expanded BEFORE any runs, so a bad gate throws with nothing run.
74
+ // B60: config validation refuses a gate whose only item is {test_files}; the empty-argv check
75
+ // is the guard for one that reaches here anyway: it names the gate, not an opaque spawn error.
76
+ const planned = GATE_ORDER.map((gate) => {
77
+ const command = gates[gate] ?? null;
78
+ if (command === null) return { gate, command: null };
79
+ const normalized = normalizeCommand(command);
80
+ const { argv, scoped } = expandArgv(normalized.argv, scope);
81
+ if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once '{test_files}' is dropped for the full suite — put the test command before it`);
82
+ return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
83
+ });
56
84
  /** @type {GateResult[]} */
57
85
  const results = [];
58
- for (const gate of GATE_ORDER) {
59
- const command = gates[gate] ?? null;
86
+ for (const { gate, command } of planned) {
60
87
  if (command === null) {
61
88
  results.push({ gate, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: '' });
62
89
  continue;
63
90
  }
64
- const { argv, failOnStdout } = normalizeCommand(command);
91
+ const { argv, scoped, failOnStdout, env } = command;
65
92
  // Each command is run and its exit status read on its own — never `&&`-chained with the
66
93
  // next, so a red gate is reported red without swallowing the gates that follow it.
67
- const res = await exec(argv, { cwd, timeoutMs });
94
+ const res = await exec(argv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
68
95
  const ok = res.result === 'ok' && (!failOnStdout || res.stdout.trim().length === 0);
69
- results.push({ gate, argv, skipped: false, ok, code: res.code, stdout: res.stdout, stderr: res.stderr });
96
+ results.push({
97
+ gate,
98
+ argv,
99
+ skipped: false,
100
+ ok,
101
+ code: res.code,
102
+ stdout: res.stdout,
103
+ stderr: res.stderr,
104
+ ...(scoped ? scopeFields(scope) : {}),
105
+ ...(env ? { env_keys: Object.keys(env).sort() } : {}),
106
+ });
70
107
  }
71
108
  return { results, allOk: results.every((r) => r.ok) };
72
109
  }
@@ -58,8 +58,9 @@ export function linkDirsFromManifests(cwd) {
58
58
 
59
59
  /**
60
60
  * The proof profile is decided from the project, not from the gate detector's stack name alone:
61
- * `detectGates` (B5) names only `php-pest`, so a Laravel app on PHPUnit reaches here as `node`
62
- * (its Vite `package.json`) or `unknown` — `composer.json` + `artisan` is Laravel either way.
61
+ * `detectGates` names a Laravel app `php-pest` or `php-phpunit` only once its runner is installed
62
+ * (`vendor/bin/…`); before that it reaches here as `node` (its Vite `package.json`) or `unknown` —
63
+ * `composer.json` + `artisan` is Laravel either way.
63
64
  * @param {string} stack - `detectGates().stack`
64
65
  * @param {string} cwd
65
66
  * @returns {string} the proof profile: `laravel`, `php`, `node`, `python`, `rust`, `go` or `unknown`.
@@ -274,6 +275,11 @@ function gateArgv(gate) {
274
275
  return null;
275
276
  }
276
277
 
278
+ /**
279
+ * A gate as the config holds it: an argv, `{argv, env}` (B60, kept from an existing config), or none.
280
+ * @typedef {string[] | {argv: string[], env?: Record<string, string>} | null} GateValue
281
+ */
282
+
277
283
  /**
278
284
  * @typedef {object} Answers
279
285
  * @property {'current'|'recommended'} tools
@@ -289,9 +295,13 @@ function gateArgv(gate) {
289
295
  * @property {{mode: 'auto'} | {mode: 'ref', ref: string} | {mode: 'none'}} jev
290
296
  * @property {string} engine
291
297
  * @property {number|null} solo_project
292
- * @property {{test: string[]|null, lint: string[]|null, types: string[]|null, format: string[]|null}} gates
298
+ * @property {{test: GateValue, lint: GateValue, types: GateValue, format: GateValue}} gates
293
299
  * @property {{high: string[], isolation: string, link_dirs: string[], copy_untracked: string[]}} proof
294
300
  * @property {boolean} doctor
301
+ * @property {boolean} coder_may_push - `autonomy.coder_may_push` (B65; default false).
302
+ * @property {boolean} coder_may_open_draft_pr - `autonomy.coder_may_open_draft_pr` (B65; default
303
+ * false). `resolveAnswers` sets it false whenever `coder_may_push` is false, except for an
304
+ * explicit `--coder-may-open-draft-pr yes`, which `runInit` refuses (exit 2): a draft PR needs a push.
295
305
  */
296
306
 
297
307
  /**
@@ -300,10 +310,15 @@ function gateArgv(gate) {
300
310
  * @typedef {{gates: Record<string, 'flag'|'existing'|'detected'>, proof: Record<string, 'flag'|'existing'|'detected'|'default'>}} Origins
301
311
  */
302
312
 
303
- /** The answer keys in step order. */
313
+ /**
314
+ * The answer keys in the order they are settled: `askAnswers` asks the two autonomy keys last of
315
+ * its questions (after `solo_project`); gates and proof come later, in the settings step, and
316
+ * `doctor` is never asked (only `--skip-doctor` sets it).
317
+ */
304
318
  export const ANSWER_KEYS = Object.freeze([
305
319
  'tools', 'yes_tools', 'harnesses', 'scope', 'method', 'provider', 'levels', 'refresh_models',
306
- 'multimodel', 'second_provider', 'jev', 'engine', 'solo_project', 'gates', 'proof', 'doctor',
320
+ 'multimodel', 'second_provider', 'jev', 'engine', 'solo_project', 'coder_may_push',
321
+ 'coder_may_open_draft_pr', 'gates', 'proof', 'doctor',
307
322
  ]);
308
323
 
309
324
  /**
@@ -382,6 +397,20 @@ export function resolveAnswers(ctx, existing, user, given) {
382
397
  set('solo_project', user?.solo?.project_id, () => null);
383
398
  if (lastFlag('solo_project')) override('solo_project', lastFlag('solo_project').value);
384
399
 
400
+ // B65: push and draft-PR autonomy default to no; a re-run proposes the existing booleans.
401
+ const exAutonomy = ex.autonomy && typeof ex.autonomy === 'object' ? ex.autonomy : {};
402
+ /** @param {unknown} b @returns {boolean|undefined} */
403
+ const bool = (b) => (typeof b === 'boolean' ? b : undefined);
404
+ set('coder_may_push', bool(exAutonomy.coder_may_push), () => false);
405
+ const pushFlag = lastFlag('coder_may_push');
406
+ if (pushFlag) override('coder_may_push', pushFlag.value);
407
+ set('coder_may_open_draft_pr', bool(exAutonomy.coder_may_open_draft_pr), () => false);
408
+ const draftFlag = lastFlag('coder_may_open_draft_pr');
409
+ if (draftFlag) override('coder_may_open_draft_pr', draftFlag.value);
410
+ // A draft PR needs a push: with push no, a kept draft-PR yes follows to no. Only an explicit
411
+ // `--coder-may-open-draft-pr yes` stays true here — `runInit` refuses that combination.
412
+ if (!v.coder_may_push && sources.coder_may_open_draft_pr !== 'flag') v.coder_may_open_draft_pr = false;
413
+
385
414
  // Gates and proof are never asked (B24): each value comes from a flag, else the existing
386
415
  // config, else the project. `origins` records which, per gate and per proof key. A gate the
387
416
  // existing config left blank is filled when the project now shows one; a set gate is kept.
@@ -6,7 +6,8 @@
6
6
  * **Q16 = cut (Ricardo, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
7
7
  * v1.2 `--no-nightly` flag is gone and `--proof` takes only the keys the schema still has
8
8
  * (`isolation`, `high`, `link_dirs`, `copy_untracked`). With `-g` and `-p` counted as two flags
9
- * (two tokens that each set the scope answer) there are 19 answer flags.
9
+ * (two tokens that each set the scope answer) there are 21 answer flags: B65 added
10
+ * `--coder-may-push` and `--coder-may-open-draft-pr` (`yes`/`no`, default no).
10
11
  *
11
12
  * Parsing is strict: an unknown flag, a missing value, a repeated single-value flag or a bad value
12
13
  * is a usage error. Error messages name the flag, never the value given (a user can paste a key
@@ -46,6 +47,8 @@ export const ANSWER_FLAGS = Object.freeze({
46
47
  '--gate': 'gates',
47
48
  '--proof': 'proof',
48
49
  '--skip-doctor': 'doctor',
50
+ '--coder-may-push': 'coder_may_push',
51
+ '--coder-may-open-draft-pr': 'coder_may_open_draft_pr',
49
52
  });
50
53
 
51
54
  const BOOLEAN_FLAGS = new Set(['-g', '-p', '--copy', '--refresh-models', '--no-jev', '--skip-doctor', '--no-interaction']);
@@ -190,6 +193,10 @@ function convert(flag, value) {
190
193
  if (argv.length === 0) throw new UsageError('--gate: empty command');
191
194
  return { name, argv };
192
195
  }
196
+ case '--coder-may-push':
197
+ case '--coder-may-open-draft-pr':
198
+ if (value !== 'yes' && value !== 'no') throw new UsageError(`${flag} must be yes or no`);
199
+ return value === 'yes';
193
200
  case '--proof': {
194
201
  const [key, raw] = keyValue('--proof', value);
195
202
  if (!PROOF_KEYS.includes(key)) throw new UsageError(`--proof: key must be one of ${PROOF_KEYS.join(', ')}`);
@@ -55,6 +55,8 @@ export function generatedConfig(a, ctx, jevRef) {
55
55
  isolation: a.proof.isolation,
56
56
  export: { link_dirs: [...a.proof.link_dirs], copy_untracked: [...a.proof.copy_untracked] },
57
57
  };
58
+ // B65: always both keys, also when both are false, so the file states the choice
59
+ cfg.autonomy = { coder_may_push: a.coder_may_push === true, coder_may_open_draft_pr: a.coder_may_push === true && a.coder_may_open_draft_pr === true };
58
60
  return cfg;
59
61
  }
60
62
 
@@ -59,6 +59,7 @@ export const USAGE = [
59
59
  ' [--harness a,b] [-g|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]…',
60
60
  ' [--refresh-models] [--multimodel on|off] [--second-provider P]',
61
61
  ' [--jev-ref <item-id|link|op://…> | --jev-env NAME | --no-jev] [--engine auto|solo|harness] [--solo-project N]',
62
+ ' [--coder-may-push yes|no] [--coder-may-open-draft-pr yes|no]',
62
63
  ' [--gate name=cmd]… [--proof isolation=export|lock | high=a,b | link_dirs=a,b | copy_untracked=a,b]… [--skip-doctor]',
63
64
  ].join('\n');
64
65
 
@@ -230,6 +231,11 @@ export async function runInit(args, deps = {}) {
230
231
  ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
231
232
  await askAnswers(values, sources, ctx, ui);
232
233
  }
234
+ // B65: a draft PR needs a push. `resolveAnswers` and `askAnswers` turn a kept draft-PR yes to
235
+ // no when push is no, so a yes left here came from the flag: refused.
236
+ if (values.coder_may_open_draft_pr && !values.coder_may_push) {
237
+ throw new InitFailure(2, '--coder-may-open-draft-pr yes needs autonomy.coder_may_push yes — a draft PR needs a push; pass --coder-may-push yes or drop the flag. Nothing written.');
238
+ }
233
239
 
234
240
  // Gates and proof are never asked (B24): the summary says what was found and what is blank.
235
241
  // A person sees it now, before anything is written; agent mode gets it in the JSON line.
@@ -62,6 +62,8 @@ export const HINTS = Object.freeze({
62
62
  judge: 'The judge settles disagreements between the two reviewers.',
63
63
  engine: 'auto uses Solo when present, otherwise the subagents of the current harness.',
64
64
  solo_project: 'The Solo project that coder processes run in; leave blank for none.',
65
+ coder_may_push: 'No: the coder hands back its branch and you (or the orchestrator) push it.',
66
+ coder_may_open_draft_pr: 'Only after an independent review passes; marking it ready and merging stay with you.',
65
67
  jev: 'Jev is the small fast classifier behind System 1; skip uses rules only.',
66
68
  env: 'That variable must hold the key whenever code-forge runs.',
67
69
  paste: 'The key goes to the OS key store; the config keeps only a reference.',
@@ -69,6 +71,10 @@ export const HINTS = Object.freeze({
69
71
  apply: 'Only the listed keys change; your other edits stay.',
70
72
  });
71
73
 
74
+ /** B65: the two autonomy questions, asked last among the answers (default no). */
75
+ export const PUSH_QUESTION = 'May a coder push its block branch on its own?';
76
+ export const DRAFT_PR_QUESTION = 'May a coder open a draft PR on its own?';
77
+
72
78
  /** The one-line hints of the "Customize now" questions, in the order they are asked (B24). */
73
79
  export const SETTING_HINTS = Object.freeze({
74
80
  test: 'the command that runs your tests; the block cannot close if it fails (blank = no test gate)',
@@ -205,6 +211,16 @@ export async function askAnswers(values, sources, ctx, ui) {
205
211
  const typed = await answer(ui, ui.text({ message: withHint('Solo project id (blank: none)', HINTS.solo_project), initialValue: values.solo_project === null ? '' : String(values.solo_project) }));
206
212
  values.solo_project = /^[1-9]\d{0,9}$/.test(String(typed).trim()) ? Number(String(typed).trim()) : null;
207
213
  }
214
+ // B65: push first; a draft PR needs a push, so with push no the draft-PR question is not asked
215
+ // and its answer is no (a `--coder-may-open-draft-pr yes` flag is then refused by `runInit`).
216
+ if (open('coder_may_push')) {
217
+ values.coder_may_push = (await answer(ui, ui.confirm({ message: withHint(PUSH_QUESTION, HINTS.coder_may_push), initialValue: values.coder_may_push === true }))) === true;
218
+ }
219
+ if (values.coder_may_push && open('coder_may_open_draft_pr')) {
220
+ values.coder_may_open_draft_pr = (await answer(ui, ui.confirm({ message: withHint(DRAFT_PR_QUESTION, HINTS.coder_may_open_draft_pr), initialValue: values.coder_may_open_draft_pr === true }))) === true;
221
+ } else if (open('coder_may_open_draft_pr')) {
222
+ values.coder_may_open_draft_pr = false;
223
+ }
208
224
  return values;
209
225
  }
210
226
 
@@ -240,16 +256,20 @@ export async function askProjectSettings(values, origins, ui) {
240
256
  const changed = [];
241
257
  for (const name of GATE_NAMES) {
242
258
  if (origins.gates[name] === 'flag') continue;
243
- const gates = /** @type {Record<string, string[]|null>} */ (values.gates);
259
+ const gates = /** @type {Record<string, import('./answers.mjs').GateValue>} */ (values.gates);
244
260
  const current = gates[name];
261
+ // B60: a gate kept as {argv, env} is edited by its argv; its env is kept (and never shown).
262
+ const currentArgv = /** @type {string[] | null} */ (Array.isArray(current) || !current ? current : current.argv);
245
263
  const typed = await answer(ui, ui.text({
246
264
  message: withHint(`Gate ${name} (blank or "none": no gate)`, SETTING_HINTS[/** @type {keyof typeof SETTING_HINTS} */ (name)]),
247
- initialValue: current ? current.join(' ') : '',
265
+ initialValue: currentArgv ? currentArgv.join(' ') : '',
248
266
  }));
249
267
  const text = String(typed ?? '').trim();
268
+ // blank or "none" turns the gate off: a gate kept as {argv, env} then loses its env too
250
269
  const next = text === '' || text === 'none' ? null : shellWords(text);
251
- if (JSON.stringify(next) !== JSON.stringify(current)) changed.push(`gates.${name}`);
252
- gates[name] = next;
270
+ if (JSON.stringify(next) === JSON.stringify(currentArgv)) continue;
271
+ changed.push(`gates.${name}`);
272
+ gates[name] = next && current && !Array.isArray(current) ? { ...current, argv: next } : next;
253
273
  }
254
274
  const proof = values.proof;
255
275
  /** @param {'high'|'link_dirs'|'copy_untracked'} key @param {string} message @param {string} path */
@@ -46,7 +46,10 @@ const STACK_MARKER = Object.freeze({ rust: 'Cargo.toml', python: 'pyproject.toml
46
46
 
47
47
  /**
48
48
  * @typedef {object} Setting
49
- * @property {unknown} value - what is written to the config.
49
+ * @property {unknown} value - the value shown (a gate: its argv). The config file is written from
50
+ * `values.gates`, not from here, so a `{argv, env}` gate keeps its env.
51
+ * @property {string[]} [env_keys] - B60: a gate given as `{argv, env}` with a non-empty env: the
52
+ * variable NAMES, sorted (never the values).
50
53
  * @property {'flag'|'existing'|'detected'|'default'} source
51
54
  * @property {string} note - where it came from, or why it is blank, in the summary's words.
52
55
  */
@@ -115,7 +118,12 @@ export function summarizeSettings(values, origins, ctx) {
115
118
 
116
119
  for (const name of GATE_NAMES) {
117
120
  const key = SETTING_KEYS[/** @type {keyof typeof SETTING_KEYS} */ (name)];
118
- const argv = values.gates[/** @type {keyof typeof values.gates} */ (name)];
121
+ const command = values.gates[/** @type {keyof typeof values.gates} */ (name)];
122
+ // B60: a gate given as {argv, env} is summarised by its argv and its env NAMES, never the values;
123
+ // an empty env counts as none
124
+ const envKeys = command && !Array.isArray(command) && command.env && Object.keys(command.env).length > 0 ? Object.keys(command.env).sort() : null;
125
+ const envNote = envKeys ? ` (env: ${envKeys.join(', ')})` : '';
126
+ const argv = /** @type {string[] | null} */ (Array.isArray(command) || !command ? command : command.argv);
119
127
  const source = origins.gates[name] ?? 'detected';
120
128
  let note;
121
129
  if (argv === null || argv === undefined || argv.length === 0) {
@@ -123,12 +131,12 @@ export function summarizeSettings(values, origins, ctx) {
123
131
  else if (source === 'existing') note = 'empty in .code-forge.yml';
124
132
  else note = GATE_MISSING[/** @type {keyof typeof GATE_MISSING} */ (name)];
125
133
  blank.push(key);
126
- lines.push(` ${name}: blank — ${note}`);
134
+ lines.push(` ${name}: blank${envNote} — ${note}`);
127
135
  } else {
128
136
  note = source === 'detected' ? gateEvidence(ctx.detected.stack, name, argv, ctx.cwd) : keptFrom(source, '--gate');
129
- lines.push(` ${name}: ${shown(argv)} (from ${note})`);
137
+ lines.push(` ${name}: ${shown(argv)}${envNote} (from ${note})`);
130
138
  }
131
- settings[key] = { value: argv, source, note };
139
+ settings[key] = { value: argv, source, note, ...(envKeys ? { env_keys: envKeys } : {}) };
132
140
  }
133
141
 
134
142
  const found = new Map(linkDirsFromManifests(ctx.cwd).map((e) => [e.dir, e.manifests]));
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `report --export <dir>` — writes one JSON file per report section (plan §6.2: "13 sections").
2
+ * `report --export <dir>` — writes one JSON file per report section (plan §6.2; 14 sections since B66).
3
3
  */
4
4
 
5
5
  import { mkdir, writeFile } from 'node:fs/promises';
@@ -0,0 +1,164 @@
1
+ /**
2
+ * B66 (issue #13 item 8): outside review rounds per PR, next to what code-forge spent on the PR's
3
+ * blocks. Pure helpers, no I/O:
4
+ *
5
+ * - `parseOutsideReviewArgs` reads `ledger outside-review --run <r> --pr <n> --rounds <n>
6
+ * --findings <n> [--link <url>] [--block <id>]…` (the verb in `src/cli/ledger.mjs` does the
7
+ * run lookup, the block check, the redaction and the signed write);
8
+ * - `buildOutsideReview` builds the `outside_review` report section from the ledger rows and the
9
+ * `cost_per_block` entries (`src/ledger/report.mjs` passes them in, so the PR cost is computed
10
+ * by the SAME function the `cost_per_block` section uses);
11
+ * - `outsideReviewLines` renders the section as terminal text.
12
+ *
13
+ * Row: `outside_review {run, pr, rounds, findings, link (or null), blocks: [ids]}`. A later row for
14
+ * the same PR replaces the earlier one in every reader (the latest row in ledger order wins); the
15
+ * ledger itself stays append-only.
16
+ */
17
+
18
+ import { roundUsd } from './spend.mjs';
19
+
20
+ export const OUTSIDE_REVIEW_EVENT = 'outside_review';
21
+
22
+ export const OUTSIDE_REVIEW_USAGE =
23
+ 'usage: code-forge ledger outside-review --run <r> --pr <n> --rounds <n> --findings <n> [--link <url>] [--block <id>]...';
24
+
25
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
26
+ const POSITIVE_INT = /^[1-9]\d*$/;
27
+ const NON_NEGATIVE_INT = /^(0|[1-9]\d*)$/;
28
+ const LINK = /^https?:\/\/\S+$/;
29
+ const MAX_LINK_CHARS = 2048;
30
+
31
+ /** @param {unknown} n @returns {boolean} */
32
+ const isCount = (n) => Number.isSafeInteger(n) && /** @type {number} */ (n) >= 0;
33
+
34
+ const SINGLE_FLAGS = new Set(['--run', '--pr', '--rounds', '--findings', '--link']);
35
+
36
+ /**
37
+ * @typedef {{run: string, pr: number, rounds: number, findings: number, link: string | null, blocks: string[]}} OutsideReviewArgs
38
+ */
39
+
40
+ /**
41
+ * @param {string} raw @param {RegExp} shape @returns {number | null}
42
+ */
43
+ function parseShapedInt(raw, shape) {
44
+ if (!shape.test(raw)) return null;
45
+ const n = Number(raw);
46
+ return Number.isSafeInteger(n) ? n : null;
47
+ }
48
+
49
+ /**
50
+ * Parse the verb's flags (everything after `outside-review`). Every flag but `--block` is given at
51
+ * most once; `--block` may repeat (duplicates collapsed, first-seen order kept). Any other token,
52
+ * a flag with no value, a missing required flag or a value of the wrong shape is a usage error.
53
+ * The link is returned as given — the caller redacts it once the run key is registered.
54
+ * @param {string[]} args
55
+ * @returns {{ok: true, value: OutsideReviewArgs} | {ok: false, message: string}}
56
+ */
57
+ export function parseOutsideReviewArgs(args) {
58
+ /** @type {Record<string, string>} */
59
+ const single = {};
60
+ /** @type {string[]} */
61
+ const blocks = [];
62
+ for (let i = 0; i < args.length; i += 2) {
63
+ const flag = args[i];
64
+ const value = args[i + 1];
65
+ // the position only, never the token: a stray value (a loose URL) may carry a secret
66
+ if (flag !== '--block' && !SINGLE_FLAGS.has(flag)) return { ok: false, message: `unknown argument at position ${i + 1}` };
67
+ if (value === undefined || value.startsWith('--')) return { ok: false, message: `${flag} requires a value` };
68
+ if (flag === '--block') {
69
+ if (!blocks.includes(value)) blocks.push(value);
70
+ continue;
71
+ }
72
+ if (Object.hasOwn(single, flag)) return { ok: false, message: `${flag} is given more than once` };
73
+ single[flag] = value;
74
+ }
75
+ for (const flag of ['--run', '--pr', '--rounds', '--findings']) {
76
+ if (!Object.hasOwn(single, flag)) return { ok: false, message: `${flag} is required` };
77
+ }
78
+ const run = single['--run'];
79
+ if (!RUN_ID.test(run)) return { ok: false, message: '--run must be a run id (letters, digits, ".", "_", "-")' };
80
+ const pr = parseShapedInt(single['--pr'], POSITIVE_INT);
81
+ if (pr === null) return { ok: false, message: '--pr must be a positive integer' };
82
+ const rounds = parseShapedInt(single['--rounds'], NON_NEGATIVE_INT);
83
+ if (rounds === null) return { ok: false, message: '--rounds must be a non-negative integer' };
84
+ const findings = parseShapedInt(single['--findings'], NON_NEGATIVE_INT);
85
+ if (findings === null) return { ok: false, message: '--findings must be a non-negative integer' };
86
+ let link = null;
87
+ if (Object.hasOwn(single, '--link')) {
88
+ link = single['--link'];
89
+ if (link.length > MAX_LINK_CHARS) return { ok: false, message: `--link is longer than ${MAX_LINK_CHARS} characters` };
90
+ if (!LINK.test(link)) return { ok: false, message: '--link must be an https:// or http:// URL with no whitespace' };
91
+ }
92
+ return { ok: true, value: { run, pr, rounds, findings, link, blocks } };
93
+ }
94
+
95
+ /** @param {string[]} blocks @returns {string} */
96
+ const blockList = (blocks) => (blocks.length > 0 ? blocks.join(', ') : '-');
97
+
98
+ /**
99
+ * The verb's one stdout line.
100
+ * @param {{pr: number, rounds: number, findings: number, blocks: string[]}} row @returns {string}
101
+ */
102
+ export function outsideReviewRecordedLine({ pr, rounds, findings, blocks }) {
103
+ return `outside_review pr ${pr} · rounds ${rounds} · findings ${findings} · blocks ${blockList(blocks)}\n`;
104
+ }
105
+
106
+ /**
107
+ * @typedef {{pr: number, rounds: number, findings: number, link: string | null, blocks: string[], code_forge_usd: number, unknown_usd_sessions: number}} OutsideReviewEntry
108
+ */
109
+
110
+ /**
111
+ * The `outside_review` report section: one entry per PR (the latest `outside_review` row for it,
112
+ * by ledger order), sorted by PR number ascending. `code_forge_usd` sums `totalUsd` of the
113
+ * `cost_per_block` entries of the row's run whose block the row names; `unknown_usd_sessions` sums
114
+ * their `unknownUsdSessions` (those sessions count 0 in the dollars). `[]` with no rows.
115
+ * @param {Array<Record<string, any>>} rows - shadow rows already removed.
116
+ * @param {Array<{run: string | null, block: string, totalUsd: number, unknownUsdSessions: number}>} costEntries - the `cost_per_block` section.
117
+ * @returns {OutsideReviewEntry[]}
118
+ */
119
+ export function buildOutsideReview(rows, costEntries) {
120
+ /** @type {Map<number, Record<string, any>>} */
121
+ const latest = new Map();
122
+ for (const row of rows) {
123
+ if (row.event !== OUTSIDE_REVIEW_EVENT || !Number.isSafeInteger(row.pr) || row.pr <= 0) continue;
124
+ // a row whose counts are not 0-or-more safe integers is skipped (the verb never writes one)
125
+ if (!isCount(row.rounds) || !isCount(row.findings)) continue;
126
+ latest.set(row.pr, row);
127
+ }
128
+ return [...latest.values()]
129
+ .sort((a, b) => a.pr - b.pr)
130
+ .map((row) => {
131
+ const blocks = Array.isArray(row.blocks) ? row.blocks.filter((b) => typeof b === 'string') : [];
132
+ const run = typeof row.run === 'string' ? row.run : null;
133
+ // a row with no string run matches no cost entry (never the null-run entries): 0 and 0 unknown
134
+ const mine = run === null ? [] : costEntries.filter((e) => e.run === run && blocks.includes(e.block));
135
+ const usd = mine.reduce((sum, e) => sum + e.totalUsd, 0);
136
+ return {
137
+ pr: row.pr,
138
+ rounds: row.rounds,
139
+ findings: row.findings,
140
+ link: typeof row.link === 'string' ? row.link : null,
141
+ blocks,
142
+ // rounded as `cost_per_block` rounds its dollars (4 decimals)
143
+ code_forge_usd: roundUsd(usd),
144
+ unknown_usd_sessions: mine.reduce((sum, e) => sum + e.unknownUsdSessions, 0),
145
+ };
146
+ });
147
+ }
148
+
149
+ /** @param {number} n @param {string} word @returns {string} */
150
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
151
+
152
+ /**
153
+ * Terminal text of the section: `outside review: none` with no entry, else one line per PR.
154
+ * @param {OutsideReviewEntry[]} section @returns {string}
155
+ */
156
+ export function outsideReviewLines(section) {
157
+ if (section.length === 0) return 'outside review: none\n';
158
+ return section
159
+ .map((e) => {
160
+ const unknown = e.unknown_usd_sessions > 0 ? ` (+${e.unknown_usd_sessions} unknown)` : '';
161
+ return `PR #${e.pr} · ${plural(e.rounds, 'outside round')} · ${plural(e.findings, 'finding')} · code-forge $${e.code_forge_usd.toFixed(2)}${unknown} · ${blockList(e.blocks)}\n`;
162
+ })
163
+ .join('');
164
+ }