@codedology/code-forge 0.15.2 → 0.15.4

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 (79) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +162 -1
  5. package/docs/reference/config.md +38 -15
  6. package/native/README.md +10 -0
  7. package/native/bin/darwin-arm64/code-forge-native +0 -0
  8. package/native/bin/darwin-x64/code-forge-native +0 -0
  9. package/native/bin/linux-arm64/code-forge-native +0 -0
  10. package/native/bin/linux-x64/code-forge-native +0 -0
  11. package/package.json +4 -1
  12. package/schema/code-forge.schema.json +83 -3
  13. package/schema/outcome.schema.json +45 -0
  14. package/schema/pipeline.schema.json +30 -0
  15. package/scripts/first-use.mjs +8 -0
  16. package/scripts/gen-config-doc.mjs +1 -1
  17. package/skill/references/autopilot.md +6 -1
  18. package/skill/references/code.md +52 -0
  19. package/skill/references/ledger.md +13 -1
  20. package/skill/references/plan.md +5 -1
  21. package/skill/references/review.md +46 -8
  22. package/skill/scripts/forge +1 -1
  23. package/src/cli/block.mjs +427 -38
  24. package/src/cli/context.mjs +17 -7
  25. package/src/cli/doctor.mjs +32 -1
  26. package/src/cli/gates.mjs +76 -10
  27. package/src/cli/help.mjs +5 -0
  28. package/src/cli/native-status.mjs +13 -0
  29. package/src/cli/outcome.mjs +87 -0
  30. package/src/cli/pipeline.mjs +61 -0
  31. package/src/cli/proof.mjs +10 -7
  32. package/src/cli/report.mjs +53 -6
  33. package/src/cli/review-file.mjs +278 -41
  34. package/src/cli/run.mjs +3 -2
  35. package/src/cli/status.mjs +43 -9
  36. package/src/cli/sweep.mjs +28 -0
  37. package/src/cli/validate.mjs +6 -0
  38. package/src/config/validate.mjs +83 -1
  39. package/src/context/pack.mjs +53 -3
  40. package/src/doctor/index.mjs +11 -0
  41. package/src/doctor/local.mjs +22 -4
  42. package/src/doctor/worker-probe.mjs +1 -1
  43. package/src/engines/builders/codex.mjs +27 -7
  44. package/src/engines/builders/grok.mjs +11 -5
  45. package/src/engines/codex-home.mjs +18 -15
  46. package/src/engines/sentinel.mjs +11 -0
  47. package/src/gates/detect.mjs +6 -3
  48. package/src/gates/run.mjs +295 -27
  49. package/src/ledger/export.mjs +1 -1
  50. package/src/ledger/report.mjs +143 -5
  51. package/src/outcome/outcome.mjs +108 -0
  52. package/src/pipeline/pipeline.mjs +112 -0
  53. package/src/proof/red-green.mjs +41 -13
  54. package/src/proof/runners.mjs +28 -4
  55. package/src/proof/worktree.mjs +25 -15
  56. package/src/review/agent-waiver.mjs +113 -12
  57. package/src/review/engine.mjs +25 -5
  58. package/src/review/gate-check.mjs +52 -10
  59. package/src/review/lenses/full.md +1 -0
  60. package/src/review/lenses/quick.md +1 -0
  61. package/src/review/model-stats.mjs +125 -0
  62. package/src/review/packet.mjs +88 -9
  63. package/src/review/related.mjs +112 -0
  64. package/src/review/reopen.mjs +42 -16
  65. package/src/review/route.mjs +58 -4
  66. package/src/review/session-retry.mjs +59 -0
  67. package/src/session/facts.mjs +19 -7
  68. package/src/session/plan-check.mjs +4 -1
  69. package/src/session/spawn.mjs +54 -3
  70. package/src/state/block.mjs +139 -49
  71. package/src/state/config-snapshot.mjs +13 -3
  72. package/src/state/run.mjs +3 -2
  73. package/src/util/exec.mjs +48 -31
  74. package/src/util/native.mjs +131 -0
  75. package/src/util/reaper.mjs +238 -8
  76. package/src/worker/engine.mjs +7 -3
  77. package/src/worker/loop.mjs +211 -30
  78. package/src/worker/queue.mjs +107 -24
  79. package/src/worker/watch.mjs +18 -7
@@ -66,7 +66,9 @@
66
66
  import { Ajv2020 } from 'ajv/dist/2020.js';
67
67
  import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
68
68
  import { redact } from '../util/redact.mjs';
69
+ import { packageVersion } from '../util/error-log.mjs';
69
70
  import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
71
+ import { BLOCK_KINDS } from '../decide/escalation.mjs';
70
72
  import { checkEffort, displayEffort } from '../engines/efforts.mjs';
71
73
  import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
72
74
  import { isKnownId, resolveLevel } from './known-ids.mjs';
@@ -75,6 +77,7 @@ import { isKnownId, resolveLevel } from './known-ids.mjs';
75
77
  import { ROUTE_LEVELS, ruleProvider } from '../review/route.mjs';
76
78
  import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES, sandboxDirRefusals, SANDBOX_DIR_REFUSED } from '../engines/sandbox-dirs.mjs';
77
79
  import { tmpBase } from '../util/tmp.mjs';
80
+ import { expandBraces, globMatch } from '../state/registry.mjs';
78
81
  import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
79
82
 
80
83
  /**
@@ -211,7 +214,7 @@ function runSchemaValidation(cfg) {
211
214
  if (err.keyword === 'propertyNames' && typeof err.params?.propertyName === 'string') {
212
215
  detail = `property name "${safeSegment(err.params.propertyName)}" is not allowed (propertyNames)`;
213
216
  } else if (err.keyword === 'additionalProperties' && typeof err.params?.additionalProperty === 'string') {
214
- detail = `must NOT have additional property "${safeSegment(err.params.additionalProperty)}"`;
217
+ detail = `must NOT have additional property "${safeSegment(err.params.additionalProperty)}" (unknown key; if it comes from a newer code-forge, upgrade: this is code-forge ${packageVersion()})`;
215
218
  }
216
219
  return {
217
220
  rule: 'schema',
@@ -512,12 +515,76 @@ function gateMessages(key, value) {
512
515
  return messages;
513
516
  }
514
517
 
518
+ /** Custom gate refusals are also checked before running commands or reading evidence.
519
+ * @param {unknown} custom @returns {string[]}
520
+ */
521
+ export function customGateMessages(custom) {
522
+ if (custom === undefined) return [];
523
+ if (!Array.isArray(custom)) return ['gates.custom must be an array of at most 20 named object gates'];
524
+ const messages = custom.length > 20 ? ['gates.custom must contain at most 20 entries'] : [];
525
+ const names = new Set();
526
+ for (const [i, value] of custom.entries()) {
527
+ const key = `custom[${i}]`;
528
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
529
+ messages.push(`gates.${key} must be a named object gate`);
530
+ continue;
531
+ }
532
+ const { name, evidence, ...command } = value;
533
+ const label = `${key}${typeof name === 'string' ? ` (${safeSegment(name)})` : ''}`;
534
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(name)) messages.push(`gates.${label}.name must match ^[a-z][a-z0-9_-]{0,31}$`);
535
+ if (GATE_KEYS.includes(name)) messages.push(`gates.${label}.name must not be a fixed gate name`);
536
+ if (names.has(name)) messages.push(`gates.${label}.name must be unique`);
537
+ names.add(name);
538
+ messages.push(...gateMessages(label, command));
539
+ if (Array.isArray(command.argv) && command.argv.some((arg) => typeof arg === 'string' && [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].some((token) => arg.includes(token)))) {
540
+ messages.push(`gates.${label}.argv: {test_files} and {changed_files} are refused in custom gates`);
541
+ }
542
+ if (evidence !== undefined) {
543
+ if (!Array.isArray(evidence)) messages.push(`gates.${label}.evidence must be an array of safe relative paths or simple globs`);
544
+ else for (const [j, pattern] of evidence.entries()) {
545
+ // Backslashes are refused as well so a Windows absolute/traversal path is never
546
+ // interpreted differently by validation and the POSIX evidence matcher.
547
+ if (typeof pattern !== 'string' || pattern.length === 0 || pattern.startsWith('/') || /[\\\0]/.test(pattern) || pattern.split('/').some((segment) => segment === '..' || /^[A-Za-z]:/.test(segment))) {
548
+ messages.push(`gates.${label}.evidence[${j}] must be a non-empty relative path or glob without absolute paths or a .. segment`);
549
+ }
550
+ }
551
+ }
552
+ }
553
+ return messages;
554
+ }
555
+
556
+ /** Gate names in a kind set must resolve to an available configured or detected gate.
557
+ * @param {Record<string, any> | undefined} gates @returns {string[]}
558
+ */
559
+ export function kindGateMessages(gates) {
560
+ const custom = new Set(Array.isArray(gates?.custom) ? gates.custom.map((gate) => gate?.name) : []);
561
+ const messages = [];
562
+ for (const kind of BLOCK_KINDS) {
563
+ const names = gates?.kinds?.[kind];
564
+ if (!Array.isArray(names)) continue; // The schema handles invalid shapes.
565
+ for (const name of names) {
566
+ if (typeof name !== 'string') continue;
567
+ const label = `gates.kinds.${kind}: ${safeSegment(name)}`;
568
+ const fixed = GATE_KEYS.some((key) => key === name);
569
+ if (!fixed && !custom.has(name)) messages.push(`${label} is an unknown gate name`);
570
+ else if (fixed && gates[name] === null) messages.push(`${label} is a null fixed gate`);
571
+ }
572
+ }
573
+ return messages;
574
+ }
575
+
515
576
  function checkGatesNotArgvArray(cfg) {
516
577
  const messages = GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]));
578
+ messages.push(...customGateMessages(cfg?.gates?.custom));
579
+ messages.push(...kindGateMessages(cfg?.gates));
517
580
  if (Object.hasOwn(cfg?.gates ?? {}, 'repeat_new_tests')) {
518
581
  const n = cfg.gates.repeat_new_tests;
519
582
  if (!Number.isInteger(n) || n < 2 || n > 10) messages.push('gates.repeat_new_tests must be an integer 2..10');
520
583
  }
584
+ if (Object.hasOwn(cfg?.gates ?? {}, 'timeout_s')) {
585
+ const n = cfg.gates.timeout_s;
586
+ if (!Number.isInteger(n) || n < 30 || n > 7200) messages.push('gates.timeout_s must be an integer 30..7200');
587
+ }
521
588
  return messages.map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message }));
522
589
  }
523
590
 
@@ -1181,3 +1248,18 @@ export function reviewFileLoop(cfg) {
1181
1248
  export function reviewWorkers(cfg) {
1182
1249
  return cfg?.review?.workers ?? 1;
1183
1250
  }
1251
+
1252
+ /** Common generated lock files, matched at the root and at any depth. */
1253
+ export const DEFAULT_REVIEW_EXCLUDE = Object.freeze([
1254
+ 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb',
1255
+ 'composer.lock', 'Cargo.lock', 'Gemfile.lock', 'poetry.lock', 'uv.lock', 'go.sum',
1256
+ 'Package.resolved', 'Podfile.lock', 'pubspec.lock', 'mix.lock', 'flake.lock',
1257
+ ].map((name) => `**/${name}`));
1258
+
1259
+ /** The first matching repo-relative rule, or null. A supplied list replaces the defaults.
1260
+ * @param {Record<string, any> | null} cfg @param {string} file @returns {string | null}
1261
+ */
1262
+ export function reviewExcludeRule(cfg, file) {
1263
+ const rules = cfg?.review?.exclude ?? DEFAULT_REVIEW_EXCLUDE;
1264
+ return rules.find((rule) => expandBraces(rule).some((pattern) => globMatch(file, pattern))) ?? null;
1265
+ }
@@ -11,12 +11,13 @@
11
11
  * require_coder_tests: false B63b: close needs the coder's `TESTS … exit=0` line
12
12
  *
13
13
  * `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
14
- * owned files, test files when the block has them, worktree path and branch when it has one), the
14
+ * owned files, test files when the block has them, worktree path and branch when it has one,
15
+ * code-forge command and version when supplied), the
15
16
  * commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), the
16
- * built-in proof rule, each rules file, then the lessons file — every file section headed by its
17
+ * built-in proof rule, block brief and acceptance, each rules file, then the lessons file — every file section headed by its
17
18
  * source path (an empty file: the heading alone). Over `max_lines` the lessons are cut first, then the
18
19
  * rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
19
- * header, commands and proof rule are never cut; all count toward the budget.
20
+ * header, commands, proof rule, brief and acceptance are never cut; all count toward the budget.
20
21
  *
21
22
  * `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
22
23
  * lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
@@ -238,6 +239,41 @@ const argvToken = (token) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token) ? token : `
238
239
  /** @param {string[]} argv @returns {string} */
239
240
  export const argvText = (argv) => argv.map(argvToken).join(' ');
240
241
 
242
+ /**
243
+ * Read a block's saved brief from the main tree with the same path checks as other context.
244
+ * Older blocks and blocks opened without --brief have no brief.
245
+ * @param {string} repoRoot @param {Record<string, any>} block
246
+ * @returns {{path: string, text: string} | null}
247
+ */
248
+ export function readBlockBrief(repoRoot, block) {
249
+ if (typeof block.brief?.path !== 'string') return null;
250
+ return { path: block.brief.path, text: readLines(repoRoot, block.brief.path, 'block.brief').join('\n') };
251
+ }
252
+
253
+ /**
254
+ * The task sections shared by packs and coder prompts. A supplied prompt suppresses sections
255
+ * whose contents it already carries, independently of their Markdown formatting.
256
+ * @param {{brief?: {path: string, text: string} | null, acceptance?: import('../gates/acceptance.mjs').AcceptanceClause[]}} block
257
+ * @param {string} [prompt]
258
+ * @returns {string[]}
259
+ */
260
+ export function blockTaskLines(block, prompt = '') {
261
+ const normalized = prompt.replace(/\s+/g, ' ').trim();
262
+ const contains = (/** @type {string} */ text) => normalized.includes(text.replace(/\s+/g, ' ').trim());
263
+ const lines = [];
264
+ if (block.brief && (!prompt || !contains(block.brief.text))) lines.push(`## brief: ${block.brief.path}`, ...splitLines(block.brief.text));
265
+ const missing = (block.acceptance ?? []).filter((clause) => !prompt || !contains(clause.clause)
266
+ || (clause.kind === 'human_check' ? !contains('human_check') : !clause.tests.every(contains)));
267
+ if (missing.length > 0) {
268
+ lines.push('## acceptance');
269
+ for (const clause of missing) {
270
+ lines.push(`- ${clause.clause}`);
271
+ lines.push(clause.kind === 'human_check' ? ' kind: human_check' : ` tests: ${clause.tests.join(', ')}`);
272
+ }
273
+ }
274
+ return lines.length === 0 ? [] : splitLines(/** @type {string} */ (redact(lines.join('\n'))));
275
+ }
276
+
241
277
  /**
242
278
  * @typedef {object} PackBlock
243
279
  * @property {string} run
@@ -246,6 +282,9 @@ export const argvText = (argv) => argv.map(argvToken).join(' ');
246
282
  * @property {string[]} [owned]
247
283
  * @property {string[] | null} [tests]
248
284
  * @property {{path: string, branch: string} | null} [worktree]
285
+ * @property {{node: string, bin: string, version: string} | null} [codeForge]
286
+ * @property {{path: string, text: string} | null} [brief]
287
+ * @property {import('../gates/acceptance.mjs').AcceptanceClause[]} [acceptance]
249
288
  */
250
289
 
251
290
  /**
@@ -259,6 +298,7 @@ export function assemblePack({ settings, sources, block }) {
259
298
  /** @type {Array<[string, unknown]>} */
260
299
  const values = [['level', block.level], ...(block.owned ?? []).map((f) => /** @type {[string, unknown]} */ (['owned file', f])), ...(block.tests ?? []).map((f) => /** @type {[string, unknown]} */ (['test file', f]))];
261
300
  if (block.worktree) values.push(['worktree path', block.worktree.path], ['worktree branch', block.worktree.branch]);
301
+ if (block.codeForge) values.push(['code-forge node', block.codeForge.node], ['code-forge bin', block.codeForge.bin], ['code-forge version', block.codeForge.version]);
262
302
  for (const [label, v] of values) {
263
303
  if (typeof v === 'string' && CONTROL.test(v)) throw new ContextError('control-character', `block ${block.block}: ${label} ${JSON.stringify(v)} contains a control character`);
264
304
  }
@@ -269,6 +309,14 @@ export function assemblePack({ settings, sources, block }) {
269
309
  for (const f of block.tests) header.push(`- ${f}`);
270
310
  }
271
311
  if (block.worktree) header.push(`worktree: ${block.worktree.path}`, `branch: ${block.worktree.branch}`);
312
+ if (block.codeForge) {
313
+ header.push(
314
+ '## code-forge',
315
+ `run code-forge verbs with exactly: ${argvText([block.codeForge.node, block.codeForge.bin])}`,
316
+ `version: ${block.codeForge.version}`,
317
+ 'do not use another code-forge from PATH',
318
+ );
319
+ }
272
320
  if (settings !== null) {
273
321
  header.push('## commands (never run by code-forge)');
274
322
  const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
@@ -278,6 +326,8 @@ export function assemblePack({ settings, sources, block }) {
278
326
  }
279
327
 
280
328
  header.push(...splitLines(PROOF_RULE));
329
+ // The actual task is protected from the optional rules/lessons line budget too.
330
+ header.push(...blockTaskLines(block));
281
331
 
282
332
  /** @type {Array<{heading: string, body: string[], dropped: number}>} */
283
333
  const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
@@ -11,6 +11,8 @@ import { mkdirSync, rmSync } from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
+ import { readAllRows } from '../ledger/write.mjs';
15
+ import { modelStats, reviewerLines } from '../review/model-stats.mjs';
14
16
  import { newRunId } from '../state/paths.mjs';
15
17
  import { runRoot } from '../util/tmp.mjs';
16
18
  import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkLocks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
@@ -53,6 +55,15 @@ export async function runDoctor(opts = {}, deps = {}) {
53
55
  const keys = await checkKeys(cfg, { env, store: deps.store, opRead: deps.opRead });
54
56
  /** @type {Row[]} */
55
57
  const rows = [...checked.rows, ...links, ...keys.rows];
58
+ if (cfg?.project?.slug) {
59
+ try {
60
+ const lines = reviewerLines(modelStats(await readAllRows(cfg.project.slug), cfg.review?.adaptive));
61
+ rows.push(...lines.map((line, i) => {
62
+ const at = line.indexOf(': ');
63
+ return row(`reviewer.${i}`, 'INFO', line.slice(0, at), line.slice(at + 2));
64
+ }));
65
+ } catch { /* a corrupt ledger is diagnosed by the existing ledger check */ }
66
+ }
56
67
  if (opts.quick) return rows;
57
68
 
58
69
  if (cfg) {
@@ -24,7 +24,7 @@ import { snapshotFor } from '../state/config-snapshot.mjs';
24
24
  import { assertRunId, runsDir } from '../state/paths.mjs';
25
25
  import { readRun } from '../state/run.mjs';
26
26
  import { loadKey } from '../state/signer.mjs';
27
- import { listEntries } from '../util/reaper.mjs';
27
+ import { listEntries, sweepProcesses } from '../util/reaper.mjs';
28
28
  import { listBadLocks } from '../util/file-locks.mjs';
29
29
  import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
30
30
  import { parallelTickets } from '../worker/loop.mjs';
@@ -257,14 +257,15 @@ export const LISTED_MAX = 10;
257
257
  * @returns {Promise<Row[]>}
258
258
  */
259
259
  export async function checkReviewState(cfg) {
260
+ // This awaited local-check slot also supplies process hygiene and memory rows. All are
261
+ // observational; doctor never applies a sweep.
262
+ const rows = await checkSweep(cfg);
260
263
  let names = [];
261
264
  try {
262
265
  names = readdirSync(runsDir()).filter((n) => n.endsWith('.json'));
263
266
  } catch {
264
- return [];
267
+ return rows;
265
268
  }
266
- /** @type {Row[]} */
267
- const rows = [];
268
269
  /** @type {Map<string, Array<Record<string, any>>>} the ledger rows per project, read once */
269
270
  const ledgers = new Map();
270
271
  for (const name of names.sort()) {
@@ -319,6 +320,23 @@ export async function checkReviewState(cfg) {
319
320
  return rows;
320
321
  }
321
322
 
323
+ /** Read-only process hygiene and RSS rows. @param {Record<string,any>|null} cfg @returns {Promise<Row[]>} */
324
+ export async function checkSweep(cfg) {
325
+ try {
326
+ const report = await sweepProcesses({ root: typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined });
327
+ const workers = report.items.filter((p) => p.kind === 'worker').length;
328
+ const orphans = report.items.filter((p) => p.kind === 'orphan').length;
329
+ const zombies = report.zombies.reduce((n, group) => n + group.count, 0);
330
+ const parents = report.zombies.map((group) => `parent ${group.ppid} ${group.command}`).join('; ');
331
+ const memory = report.memory;
332
+ return [row('sweep', workers + orphans + zombies ? 'WARN' : 'OK', 'sweep',
333
+ `${workers} stale workers, ${orphans} orphans, ${zombies} zombies${parents ? ` (${parents})` : ''}`),
334
+ row('memory', 'INFO', 'memory', `code-forge ${memory.codeForgeMb.toFixed(1)} MB, children ${memory.childrenMb.toFixed(1)} MB, orphans ${memory.orphansMb.toFixed(1)} MB`)];
335
+ } catch {
336
+ return [row('sweep', 'WARN', 'sweep', 'process inspection unavailable'), row('memory', 'INFO', 'memory', 'process inspection unavailable')];
337
+ }
338
+ }
339
+
322
340
  /**
323
341
  * B44: the `review concurrency` INFO row — how many review tickets run at once and each provider's
324
342
  * session slots, from the effective config (defaults when a key is unset). Never throws.
@@ -69,7 +69,7 @@ function removeRun(runId) {
69
69
  } catch {
70
70
  return;
71
71
  }
72
- for (const name of names) if (name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
72
+ for (const name of names) if (name === runId || name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
73
73
  }
74
74
 
75
75
  /**
@@ -60,6 +60,7 @@
60
60
  import os from 'node:os';
61
61
  import path from 'node:path';
62
62
  import { randomBytes } from 'node:crypto';
63
+ import { execFileSync } from 'node:child_process';
63
64
  import { mergeForbidden, renderForCodex } from '../../util/forbidden.mjs';
64
65
  import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } from '../codex-home.mjs';
65
66
  import { hasAnyRenderedRule } from '../render-rules.mjs';
@@ -129,7 +130,7 @@ export const VALID_EFFORTS = PROVIDER_EFFORTS.openai;
129
130
  * `coder.sandbox` config value. Absent ⇒ today's argv. `toolchain` adds one `--add-dir <abs>`
130
131
  * per checked `add_dirs` entry; `full` builds `-s danger-full-access`. A refused entry throws
131
132
  * a `SandboxDirError` (code `sandbox-dir-refused`) naming the key path, never the value.
132
- * @property {string} [runRoot] - B93, coder role only: the run temp root, refused as an `add_dirs` tree.
133
+ * @property {string} [runRoot] - coder role only: owns the session home and is refused as an `add_dirs` tree.
133
134
  */
134
135
 
135
136
  /**
@@ -166,7 +167,8 @@ function assertValidEffort(effort) {
166
167
  * The Codex coder argv itself — pure, no I/O, no rules file (B4.2). `buildCoderArgv` uses it, and
167
168
  * the doctor's flags probe builds it with placeholder values for its static conflict check.
168
169
  * B93: `profile` `full` swaps `-s workspace-write` for `-s danger-full-access`; `addDirs` (already
169
- * checked and resolved, `toolchain` only) follow `-s workspace-write` as `--add-dir <abs>` pairs.
170
+ * checked and resolved) follow `-s workspace-write` as `--add-dir <abs>` pairs, including the
171
+ * private Git directory and shared objects/refs/logs needed to commit from a linked worktree.
170
172
  * The tmp exclusions stay in every profile: under `danger-full-access` Codex ignores the
171
173
  * `sandbox_workspace_write.*` keys, and one argv shape keeps the forbidden-list check the same.
172
174
  * @param {{model: string, effort?: string, cwd: string, outPath: string, promptPath: string, profile?: string, addDirs?: ReadonlyArray<string>}} p
@@ -180,11 +182,11 @@ export function coderFlagArgv({ model, effort, cwd, outPath, promptPath, profile
180
182
  argv.push('-s', 'danger-full-access');
181
183
  } else {
182
184
  argv.push('-s', 'workspace-write');
183
- if (profile === 'toolchain') for (const dir of addDirs) argv.push('--add-dir', dir);
185
+ for (const dir of addDirs) argv.push('--add-dir', dir);
184
186
  }
185
187
  // INVARIANT (B4.1 fix round 1): the session CODEX_HOME lives under the run temp root, so the
186
- // sandbox must not make $TMPDIR or /tmp writable — the project cwd is then the only writable
187
- // root, and a home inside the cwd is refused below. See `../codex-home.mjs`.
188
+ // sandbox must not make $TMPDIR or /tmp writable. The project cwd and added directories are
189
+ // writable roots; a home inside the cwd is refused below. See `../codex-home.mjs`.
188
190
  argv.push(...SANDBOX_TMP_EXCLUSIONS);
189
191
  // B4.2: NO `--approve-for-me` (Codex 0.155.1 refuses it next to `-s`); see CODER_APPROVAL_POLICY.
190
192
  argv.push(...CODER_APPROVAL_POLICY);
@@ -215,6 +217,23 @@ function coderSandbox(sandbox, extra) {
215
217
  return { profile, addDirs: profile === 'toolchain' ? checked.dirs : [] };
216
218
  }
217
219
 
220
+ /**
221
+ * A linked worktree needs its private gitdir and the shared objects/refs/logs writable.
222
+ * Never grant the common dir itself: its config and hooks can execute code on the host.
223
+ * Normal checkouts (equal Git dirs), non-repositories and placeholder cwds need no extra root.
224
+ * @param {string} cwd @returns {string[]}
225
+ */
226
+ function worktreeGitDirs(cwd) {
227
+ try {
228
+ const [gitDir, commonDir] = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], {
229
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
230
+ }).trim().split('\n');
231
+ return gitDir !== commonDir ? [gitDir, ...['objects', 'refs', 'logs'].map((name) => path.join(commonDir, name))] : [];
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+
218
237
  /**
219
238
  * @param {CodexBuildParams} params
220
239
  * @returns {{cli: "codex", role: "coder", argv: string[], cwd: string, outPath: string, env: {CODEX_HOME: string}, forbiddenRendered: ReadonlyArray<RenderedCodexEntry>, rulesFile: {fileName: string, path: string, content: string, count: number}}}
@@ -226,7 +245,7 @@ function buildCoderArgv(params) {
226
245
  if (!hasAnyRenderedRule(renderedForbidden)) {
227
246
  throw new Error('buildCodexArgv: coder role requires a non-empty forbidden-list render (got 0 usable patterns)');
228
247
  }
229
- const codexHome = params.codexHome ?? defaultCodexHome();
248
+ const codexHome = params.codexHome ?? defaultCodexHome(params.runRoot);
230
249
  // Fix round 4: a non-absolute home is refused here, before any I/O, and the containment check
231
250
  // below then runs EVERY time (no `isAbsolute` short-circuit that could skip it).
232
251
  if (typeof codexHome !== 'string' || !path.isAbsolute(codexHome)) {
@@ -241,8 +260,9 @@ function buildCoderArgv(params) {
241
260
  // B93 (fix round 1): the sandbox dirs are checked only after the home is known to be absolute
242
261
  // and outside the cwd, so the home they are checked against is the one the session will use.
243
262
  const sandbox = coderSandbox(params.sandbox, [codexHome, params.runRoot, tmpBase()]);
263
+ if (sandbox.profile !== 'full') sandbox.addDirs = [...new Set([...sandbox.addDirs, ...worktreeGitDirs(cwd)])];
244
264
  const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath, ...sandbox });
245
- const home = prepareCodexHome(codexHome, renderedForbidden);
265
+ const home = prepareCodexHome(codexHome, renderedForbidden, { runRoot: params.runRoot });
246
266
  return {
247
267
  cli: CLI,
248
268
  role: 'coder',
@@ -17,7 +17,7 @@
17
17
  * swallow risk the way `claude.mjs`'s BLOCKER fix had to guard against, since each `--deny`
18
18
  * occurrence's value slot is exactly one token wide.
19
19
  *
20
- * **Return contract (fix round 3):** `{cli, role, argv, cwd, forbiddenRendered?}` — never a
20
+ * **Return contract (fix round 3):** `{cli, role, argv, cwd, env, forbiddenRendered?}` — never a
21
21
  * `stdinFile`. Unlike Claude/Codex closed-book runs, Grok's `--prompt-file <PATH>` ("Single-turn
22
22
  * prompt from a file", pinned help) makes the CLI itself read the packet and send its CONTENT as
23
23
  * the prompt, so even a closed-book run with every tool denied receives the packet text; the
@@ -41,6 +41,12 @@ import { assertBaseParams } from './validate-params.mjs';
41
41
 
42
42
  export const CLI = 'grok';
43
43
 
44
+ /** Disable automatic loading of Cursor and Claude compatibility MCP servers for every role. */
45
+ export const GROK_NO_COMPAT_MCP_ENV = Object.freeze({
46
+ GROK_CURSOR_MCPS_ENABLED: 'false',
47
+ GROK_CLAUDE_MCPS_ENABLED: 'false',
48
+ });
49
+
44
50
  /**
45
51
  * Built-in tool names the closed-book command denies wholesale via `--disallowed-tools` (in
46
52
  * addition to the wildcard `--deny "*"`, per plan §5.2). Not independently verified against a
@@ -78,7 +84,7 @@ function assertValidEffort(effort) {
78
84
 
79
85
  /**
80
86
  * @param {GrokBuildParams} params
81
- * @returns {{cli: "grok", role: "coder", argv: string[], cwd: string, forbiddenRendered: ReadonlyArray<RenderedGrokEntry>}}
87
+ * @returns {{cli: "grok", role: "coder", argv: string[], cwd: string, env: typeof GROK_NO_COMPAT_MCP_ENV, forbiddenRendered: ReadonlyArray<RenderedGrokEntry>}}
82
88
  */
83
89
  function buildCoderArgv(params) {
84
90
  // B4.2: the coder's deny list is `mergeForbidden()` (FORBIDDEN + the coder-only entries). A
@@ -95,12 +101,12 @@ function buildCoderArgv(params) {
95
101
  for (const rule of flattenRuleStrings(renderedForbidden)) {
96
102
  argv.push('--deny', rule);
97
103
  }
98
- return { cli: CLI, role: 'coder', argv, cwd, forbiddenRendered: renderedForbidden };
104
+ return { cli: CLI, role: 'coder', argv, cwd, env: GROK_NO_COMPAT_MCP_ENV, forbiddenRendered: renderedForbidden };
99
105
  }
100
106
 
101
107
  /**
102
108
  * @param {GrokBuildParams} params
103
- * @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts"|"delegate", argv: string[], cwd: string}}
109
+ * @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts"|"delegate", argv: string[], cwd: string, env: typeof GROK_NO_COMPAT_MCP_ENV}}
104
110
  */
105
111
  function buildClosedBookArgv(params) {
106
112
  const { model, effort, promptPath, cwd, schema, systemPromptText } = params;
@@ -119,7 +125,7 @@ function buildClosedBookArgv(params) {
119
125
  argv.push('--permission-mode', 'dontAsk');
120
126
  argv.push('--cwd', cwd);
121
127
  if (systemPromptText !== undefined) argv.push('--system-prompt-override', systemPromptText);
122
- return { cli: CLI, role, argv, cwd };
128
+ return { cli: CLI, role, argv, cwd, env: GROK_NO_COMPAT_MCP_ENV };
123
129
  }
124
130
 
125
131
  /**
@@ -30,7 +30,8 @@
30
30
  * under the run temp root; the builder runs the coder with `-s workspace-write` PLUS
31
31
  * `-c sandbox_workspace_write.exclude_tmpdir_env_var=true` and
32
32
  * `-c sandbox_workspace_write.exclude_slash_tmp=true` (both keys are in the 0.155.1 binary), so the
33
- * only writable root is the project `-C <cwd>` — and a home inside that cwd is refused. On top, the
33
+ * writable roots are the project `-C <cwd>` and added sandbox directories (including a linked
34
+ * worktree's common Git directory) — and a home inside that cwd is refused. On top, the
34
35
  * rules file is 0444. The directories stay 0700, not 0555: a 0555 directory makes the run-root
35
36
  * sweep's `rmSync(…, {recursive, force})` fail (ENOTEMPTY, checked on this Mac), which would leave
36
37
  * the whole run root — the auth copy included — behind. Tradeoff of the tmp exclusion: the coder's
@@ -113,29 +114,30 @@ export function renderCodexRules(rendered) {
113
114
  /** The name of the run-root directory every session home lives in. */
114
115
  export const CODEX_HOMES_DIR_NAME = 'codex-homes';
115
116
 
116
- /** @returns {string} `<currentRunRoot()>/codex-homes`, the one parent of every session home. */
117
- export function sessionCodexHomesDir() {
118
- return path.join(currentRunRoot(), CODEX_HOMES_DIR_NAME);
117
+ /** @param {string} [runRoot] @returns {string} `<runRoot>/codex-homes`, the parent of the run's session homes. */
118
+ export function sessionCodexHomesDir(runRoot = currentRunRoot()) {
119
+ return path.join(runRoot, CODEX_HOMES_DIR_NAME);
119
120
  }
120
121
 
121
122
  /**
122
- * Whether `candidate` is STRICTLY inside `<currentRunRoot()>/codex-homes/` (both sides
123
+ * Whether `candidate` is STRICTLY inside `<runRoot>/codex-homes/` (both sides
123
124
  * symlink-resolved through their nearest existing ancestor). The directory itself, the run root,
124
125
  * the real Codex home, `$HOME` and any sibling are all outside.
125
- * @param {string} candidate @returns {boolean}
126
+ * @param {string} candidate @param {string} [runRoot] @returns {boolean}
126
127
  */
127
- export function isSessionCodexHome(candidate) {
128
+ export function isSessionCodexHome(candidate, runRoot = currentRunRoot()) {
128
129
  if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return false;
129
- const rel = path.relative(resolveExisting(sessionCodexHomesDir()), resolveExisting(candidate));
130
+ const rel = path.relative(resolveExisting(sessionCodexHomesDir(runRoot)), resolveExisting(candidate));
130
131
  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
131
132
  }
132
133
 
133
134
  /**
134
135
  * A fresh per-session Codex home path under the run temp root.
136
+ * @param {string} [runRoot]
135
137
  * @returns {string}
136
138
  */
137
- export function defaultCodexHome() {
138
- return path.join(sessionCodexHomesDir(), `coder-${randomBytes(6).toString('hex')}`);
139
+ export function defaultCodexHome(runRoot = currentRunRoot()) {
140
+ return path.join(sessionCodexHomesDir(runRoot), `coder-${randomBytes(6).toString('hex')}`);
139
141
  }
140
142
 
141
143
  /**
@@ -143,7 +145,7 @@ export function defaultCodexHome() {
143
145
  * `auth.json` (0600) when it exists.
144
146
  * @param {string} codexHome - absolute; never the real Codex home or inside it.
145
147
  * @param {ReadonlyArray<CodexRenderedEntry>} rendered
146
- * @param {{env?: NodeJS.ProcessEnv}} [opts]
148
+ * @param {{env?: NodeJS.ProcessEnv, runRoot?: string}} [opts]
147
149
  * @returns {{codexHome: string, rulesPath: string, count: number, content: string}}
148
150
  * @throws {Error} when `codexHome` is relative, is (or is inside) the real Codex home, or the
149
151
  * render yields 0 rules.
@@ -159,7 +161,7 @@ export function prepareCodexHome(codexHome, rendered, opts = {}) {
159
161
  // Fix round 4: an ANCESTOR of the real home (`$HOME`, `/`) is not "inside" it, yet a home there
160
162
  // would put `rules/` and an auth copy where the user lives. The only place a session home may be
161
163
  // is strictly inside `<run root>/codex-homes/` — the same confinement `removeCodexHome` enforces.
162
- if (!isSessionCodexHome(codexHome)) {
164
+ if (!isSessionCodexHome(codexHome, opts.runRoot)) {
163
165
  throw new Error(`prepareCodexHome: codexHome must be strictly inside <run root>/${CODEX_HOMES_DIR_NAME}/`);
164
166
  }
165
167
  const { content, count } = renderCodexRules(rendered);
@@ -215,15 +217,16 @@ function writeFreshFile(dest, bytes, mode) {
215
217
 
216
218
  /**
217
219
  * Remove a session home (the auth copy with it). The ONLY recursive delete of this module, and it
218
- * is confined (fix round 3): `codexHome` must be strictly inside `<currentRunRoot()>/codex-homes/`
220
+ * is confined (fix round 3): `codexHome` must be strictly inside `<runRoot>/codex-homes/`
219
221
  * after realpath resolution, or this throws and deletes nothing — the real `~/.codex`, `$HOME`,
220
222
  * the run root itself and any sibling directory are refused. No-op when the (confined) path does
221
223
  * not exist.
222
224
  * @param {string} codexHome
225
+ * @param {string} [runRoot] - the same run root used to prepare the home.
223
226
  * @throws {Error} when `codexHome` is not strictly inside the run root's `codex-homes/`.
224
227
  */
225
- export function removeCodexHome(codexHome) {
226
- if (!isSessionCodexHome(codexHome)) {
228
+ export function removeCodexHome(codexHome, runRoot = currentRunRoot()) {
229
+ if (!isSessionCodexHome(codexHome, runRoot)) {
227
230
  throw new Error(`removeCodexHome: refusing to delete a path outside <run root>/${CODEX_HOMES_DIR_NAME}/`);
228
231
  }
229
232
  rmSync(resolveExisting(codexHome), { recursive: true, force: true });
@@ -53,6 +53,17 @@ export function parseSentinel(output, blockId) {
53
53
  if (typeof output !== 'string') {
54
54
  throw new TypeError('parseSentinel: output must be a string');
55
55
  }
56
+ // CLI reports can contain the answer as a JSON string or as a string field in an envelope.
57
+ // Decode real JSON only: replacing literal \\n would also change ordinary prose and paths.
58
+ try {
59
+ const decoded = JSON.parse(output);
60
+ if (typeof decoded === 'string') output = decoded;
61
+ else if (decoded !== null && typeof decoded === 'object' && !Array.isArray(decoded)) {
62
+ output = Object.values(decoded).filter((value) => typeof value === 'string').join('\n');
63
+ }
64
+ } catch {
65
+ // A plain transcript (or malformed JSON) keeps the existing whole-line parser.
66
+ }
56
67
  /** @type {SentinelResult | null} */
57
68
  let last = null;
58
69
  /** @type {TestsLine | null} */
@@ -20,7 +20,9 @@ import path from 'node:path';
20
20
  * `run.mjs` treats non-empty stdout as red too, since a bare exit-code check can never fail.
21
21
  * B60: a configured gate may be `{argv, env}` — `env` adds variables to the gate's environment
22
22
  * (the values are never printed, `run.mjs`).
23
- * @typedef {string[] | {argv: string[], failOnStdout?: true, env?: Record<string, string>}} GateCommand
23
+ * `formatter` marks detected defaults that require an explicit Git file set. Configured
24
+ * commands have no such marker and retain their own arguments and scope.
25
+ * @typedef {string[] | {argv: string[], failOnStdout?: true, env?: Record<string, string>, formatter?: 'pint'|'prettier'}} GateCommand
24
26
  * @typedef {GateCommand | null} Gate
25
27
  */
26
28
  /**
@@ -69,7 +71,8 @@ function detectPhp(root) {
69
71
  if (runner === null) return null;
70
72
  const test = has(root, 'artisan') ? ['php', 'artisan', 'test', '--compact'] : [`vendor/bin/${runner}`];
71
73
  const types = has(root, 'phpstan.neon') || has(root, 'phpstan.neon.dist') ? ['vendor/bin/phpstan'] : null;
72
- const format = has(root, 'pint.json') || has(root, 'vendor/bin/pint') ? ['vendor/bin/pint', '--test'] : null;
74
+ const format = has(root, 'pint.json') || has(root, 'vendor/bin/pint')
75
+ ? { argv: ['vendor/bin/pint', '--test'], formatter: /** @type {const} */ ('pint') } : null;
73
76
  return { stack: `php-${runner}`, test, lint: null, types, format };
74
77
  }
75
78
 
@@ -123,7 +126,7 @@ function detectNode(root) {
123
126
  let format = null;
124
127
  if (typeof scripts['format:check'] === 'string') format = [pm, 'run', 'format:check'];
125
128
  else if (has(root, '.prettierrc') || has(root, '.prettierrc.json') || has(root, '.prettierrc.js') || hasDependency(pkg, 'prettier')) {
126
- format = ['npx', 'prettier', '--check', '.'];
129
+ format = { argv: ['npx', 'prettier', '--check', '--ignore-unknown'], formatter: /** @type {const} */ ('prettier') };
127
130
  }
128
131
 
129
132
  return { stack: 'node', test, lint, types, format };