simt-dev 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +158 -872
  2. package/README.md +19 -133
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  6. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  7. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  8. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  9. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  10. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  11. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  12. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  13. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  14. package/package.json +10 -2
  15. package/rules/core.md +7 -0
  16. package/rules/mdc/slice-commit.mdc +57 -4
  17. package/src/agenthooks/prompt.mjs +5 -2
  18. package/src/agenthooks/shim.mjs +1 -1
  19. package/src/api.mjs +29 -0
  20. package/src/argv.mjs +29 -3
  21. package/src/bench/adapters.json +16 -0
  22. package/src/bench/corpus.mjs +150 -0
  23. package/src/bench/grade.mjs +23 -49
  24. package/src/bench/mine.mjs +199 -35
  25. package/src/bench/probes.mjs +99 -62
  26. package/src/bench/process.mjs +128 -0
  27. package/src/bench/report.mjs +17 -4
  28. package/src/bench/run.mjs +242 -142
  29. package/src/brief/assemble.mjs +38 -3
  30. package/src/ci/workflow.mjs +30 -7
  31. package/src/cli.mjs +45 -0
  32. package/src/commands/agent.mjs +3 -4
  33. package/src/commands/agenthook.mjs +4 -5
  34. package/src/commands/bench.mjs +190 -17
  35. package/src/commands/brief.mjs +5 -4
  36. package/src/commands/ci.mjs +2 -1
  37. package/src/commands/compare.mjs +42 -42
  38. package/src/commands/completions.mjs +2 -6
  39. package/src/commands/decide.mjs +4 -4
  40. package/src/commands/distill.mjs +4 -3
  41. package/src/commands/doctor.mjs +11 -7
  42. package/src/commands/evidence.mjs +6 -9
  43. package/src/commands/explain.mjs +0 -1
  44. package/src/commands/gate.mjs +16 -12
  45. package/src/commands/graph.mjs +2 -3
  46. package/src/commands/harness.mjs +3 -2
  47. package/src/commands/help.mjs +125 -17
  48. package/src/commands/hooks.mjs +100 -30
  49. package/src/commands/ignore.mjs +2 -1
  50. package/src/commands/impact.mjs +4 -7
  51. package/src/commands/index.mjs +273 -188
  52. package/src/commands/init.mjs +2 -1
  53. package/src/commands/lease.mjs +6 -7
  54. package/src/commands/lessons.mjs +10 -9
  55. package/src/commands/local.mjs +3 -3
  56. package/src/commands/map.mjs +14 -9
  57. package/src/commands/mcp.mjs +0 -1
  58. package/src/commands/pack.mjs +9 -6
  59. package/src/commands/plan.mjs +6 -5
  60. package/src/commands/plugin.mjs +2 -2
  61. package/src/commands/prbody.mjs +3 -3
  62. package/src/commands/repoindex.mjs +4 -3
  63. package/src/commands/review.mjs +2 -2
  64. package/src/commands/rules.mjs +33 -16
  65. package/src/commands/shared.mjs +2 -3
  66. package/src/commands/skills.mjs +28 -26
  67. package/src/commands/skillscompile.mjs +4 -5
  68. package/src/commands/skillsinstall.mjs +2 -2
  69. package/src/commands/slice.mjs +21 -10
  70. package/src/commands/spec.mjs +151 -0
  71. package/src/commands/status.mjs +4 -7
  72. package/src/commands/sync.mjs +13 -11
  73. package/src/commands/tests.mjs +6 -9
  74. package/src/commands/tokens.mjs +2 -2
  75. package/src/commands/undo.mjs +5 -15
  76. package/src/commands/upgrade.mjs +7 -3
  77. package/src/commands/why.mjs +9 -8
  78. package/src/compare/row.mjs +0 -2
  79. package/src/context.mjs +64 -0
  80. package/src/decisions/index-doc.mjs +46 -0
  81. package/src/diff.mjs +1 -1
  82. package/src/distill/distill.mjs +14 -1
  83. package/src/distill/engine.mjs +5 -1
  84. package/src/graph/load.mjs +4 -1
  85. package/src/hooks/capability.mjs +3 -3
  86. package/src/hooks/dispatcher.mjs +198 -43
  87. package/src/hooks/gates.mjs +0 -43
  88. package/src/hooks/resolve.mjs +2 -7
  89. package/src/hooks/shim.mjs +5 -2
  90. package/src/identity.mjs +29 -1
  91. package/src/journal.mjs +78 -9
  92. package/src/ledger/index.mjs +30 -1
  93. package/src/local/client.mjs +4 -6
  94. package/src/locate.mjs +33 -9
  95. package/src/manifest/schema.mjs +213 -0
  96. package/src/mcp/resources.mjs +7 -10
  97. package/src/mcp/server.mjs +70 -16
  98. package/src/mcp/tools.mjs +45 -7
  99. package/src/measure/session.mjs +3 -2
  100. package/src/node-version.mjs +37 -0
  101. package/src/output/index.mjs +111 -3
  102. package/src/packs/manage.mjs +17 -3
  103. package/src/packs.mjs +20 -2
  104. package/src/projection/lock.mjs +11 -1
  105. package/src/projection/manifest.mjs +20 -34
  106. package/src/projection/rules.mjs +4 -3
  107. package/src/repo-root.mjs +19 -0
  108. package/src/resolve/fingerprint.mjs +19 -15
  109. package/src/review/critic.mjs +2 -1
  110. package/src/toolwords.mjs +3 -0
  111. package/src/workspace.mjs +55 -10
  112. package/docs/decisions/README.md +0 -600
package/src/bench/run.mjs CHANGED
@@ -1,23 +1,21 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
4
3
  import { tmpdir } from 'node:os';
5
4
  import { dirname, join, resolve, sep } from 'node:path';
6
5
  import { fileURLToPath } from 'node:url';
7
6
 
7
+ import { assembleBrief } from '../brief/assemble.mjs';
8
+ import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
8
9
  import { loadRegistry } from '../graph/load.mjs';
9
10
  import { extractOpenedSkills, matchSkillsForPaths } from '../graph/compile.mjs';
10
- import { matchGlob } from '../index/govern.mjs';
11
- import { formatLessonParagraph, listPromotedLessons } from '../memory/lessons.mjs';
12
11
  import { MANIFEST_FILE, STATE_DIR } from '../name.mjs';
13
12
  import { LOCAL_DIR } from '../state.mjs';
14
13
  import { readManifest } from '../projection/manifest.mjs';
15
14
  import { plan } from '../projection/project.mjs';
16
15
  import { parse as parseRegion } from '../projection/markers.mjs';
17
- import { fingerprint } from '../resolve/fingerprint.mjs';
18
- import { rankSkills, tokenize } from '../resolve/resolve.mjs';
19
16
  import { gradeWorkspace } from './grade.mjs';
20
17
  import { resolveVaultDir } from './mine.mjs';
18
+ import { spawnBounded } from './process.mjs';
21
19
  import { loadTasks } from './tasks.mjs';
22
20
  import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
23
21
  import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
@@ -54,48 +52,80 @@ function sanitizedEnv(extra = {}) {
54
52
  return env;
55
53
  }
56
54
 
57
- function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
58
- return new Promise((resolveOutcome) => {
59
- const child = spawn(cmd, args, {
60
- cwd,
61
- env: sanitizedEnv(env),
62
- stdio: ['pipe', 'pipe', 'pipe'],
63
- });
64
- const out = [];
65
- const err = [];
66
- let timedOut = false;
67
- const timer =
68
- timeoutMs > 0
69
- ? setTimeout(() => {
70
- timedOut = true;
71
- child.kill('SIGKILL');
72
- }, timeoutMs)
73
- : null;
74
-
75
- child.stdout.on('data', (chunk) => out.push(chunk));
76
- child.stderr.on('data', (chunk) => err.push(chunk));
77
- child.on('error', (error) => {
78
- if (timer) clearTimeout(timer);
79
- resolveOutcome({
80
- code: 1,
81
- timedOut,
82
- stdout: Buffer.concat(out),
83
- stderr: Buffer.from(String(error.message ?? error)),
84
- });
85
- });
86
- child.on('close', (code) => {
87
- if (timer) clearTimeout(timer);
88
- resolveOutcome({
89
- code: timedOut ? 124 : (code ?? 1),
90
- timedOut,
91
- stdout: Buffer.concat(out),
92
- stderr: Buffer.concat(err),
93
- });
94
- });
55
+ /**
56
+ * What every benchmarked agent is allowed to inherit from the operator's shell.
57
+ *
58
+ * `PATH` and the locale are what a CLI needs to run at all. Nothing here names a
59
+ * credential or a state directory: those come from the adapter's own `env` list.
60
+ */
61
+ const ENV_BASELINE = [
62
+ 'PATH',
63
+ 'LANG',
64
+ 'LC_ALL',
65
+ 'LC_CTYPE',
66
+ 'TZ',
67
+ 'TERM',
68
+ 'SHELL',
69
+ 'TMPDIR',
70
+ 'TEMP',
71
+ 'TMP',
72
+ 'SystemRoot',
73
+ 'WINDIR',
74
+ 'USER',
75
+ ];
76
+
77
+ /** The isolation levels a run may record; `sandbox` is declared, not implemented. */
78
+ const ISOLATION_LEVELS = new Set(['none', 'env', 'sandbox']);
95
79
 
96
- if (input !== null) child.stdin.end(input);
97
- else child.stdin.end();
98
- });
80
+ /**
81
+ * The environment an arm's agent sees: the baseline, the adapter's declared keys,
82
+ * and nothing else.
83
+ *
84
+ * The agent used to inherit everything the operator had — `SIMT_DEV_VAULT_DIR`,
85
+ * `XDG_CACHE_HOME`, `HOME`, and every credential in the shell. A run that can read
86
+ * the vault can read the hidden tests it is graded against, and a run sharing the
87
+ * operator's HOME writes into their own configuration, so the number it produced
88
+ * described the machine rather than the arm (review 0003 Q49). `home` is the run's
89
+ * own slot, which is removed when the run ends.
90
+ *
91
+ * @param {object} adapter an entry from `adapters.json`
92
+ * @param {{home?: string, extra?: Record<string, string>, base?: NodeJS.ProcessEnv}} [options]
93
+ * @returns {Record<string, string>}
94
+ */
95
+ export function agentEnv(adapter, { home, extra = {}, base = process.env } = {}) {
96
+ const allowed = new Set([...ENV_BASELINE, ...(adapter?.env ?? [])]);
97
+ const env = {};
98
+ for (const key of allowed) {
99
+ if (typeof base[key] === 'string') env[key] = base[key];
100
+ }
101
+ for (const [key, value] of Object.entries(extra ?? {})) {
102
+ if (typeof value === 'string') env[key] = value;
103
+ }
104
+ if (typeof home === 'string' && home.length > 0) env.HOME = home;
105
+ env.SIMT_DEV_WORKSPACE = 'off';
106
+ for (const key of Object.keys(env)) {
107
+ if (key.startsWith('NODE_TEST')) delete env[key];
108
+ }
109
+ return env;
110
+ }
111
+
112
+ /**
113
+ * The isolation an adapter's runs provide, as the record states it.
114
+ *
115
+ * `none` is a built-in that scripts the workspace, `env` is a CLI driven with a
116
+ * scrubbed environment and a slot-local HOME, and `sandbox` is reserved for a
117
+ * real filesystem sandbox — nothing claims it yet, so a record never says it.
118
+ *
119
+ * @param {object} adapter
120
+ * @returns {'none'|'env'|'sandbox'}
121
+ */
122
+ export function adapterIsolation(adapter) {
123
+ const declared = adapter?.isolation;
124
+ return ISOLATION_LEVELS.has(declared) ? declared : 'none';
125
+ }
126
+
127
+ function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
128
+ return spawnBounded(cmd, args, { cwd, env: sanitizedEnv(env), input, timeoutMs });
99
129
  }
100
130
 
101
131
  function getByPath(obj, pathStr) {
@@ -468,55 +498,29 @@ async function applyProjectionMode(workDir, mode) {
468
498
  }
469
499
  }
470
500
 
471
- async function writeTaskBrief(workDir, task) {
472
- const prompt = String(task?.prompt ?? '');
473
- const taskTokens = tokenize(prompt);
474
- const taskTokenSet = new Set(taskTokens);
475
- const [registry, fp, lessons] = await Promise.all([
476
- loadRegistry().catch(() => ({ skills: [] })),
477
- fingerprint(workDir).catch(() => ({ stacks: [], facts: new Set() })),
478
- listPromotedLessons(workDir).catch(() => []),
479
- ]);
480
- const skillsList = Array.isArray(registry.skills)
481
- ? registry.skills
482
- : Object.values(registry.skills ?? {});
483
- const { selected = [] } = rankSkills(skillsList, {
484
- taskTokens,
485
- paths: [],
486
- repoStack: fp.stack ?? [],
487
- });
488
- const ranked = selected.slice(0, 5);
489
-
490
- const taskPaths = Array.isArray(task?.paths) ? task.paths : [];
491
- const matchingLessons = lessons.filter((lesson) => {
492
- const lessonGlobs = Array.isArray(lesson.paths) ? lesson.paths : [];
493
- if (taskPaths.some((p) => lessonGlobs.some((glob) => matchGlob(glob, p)))) return true;
494
- if (lessonGlobs.some((glob) => prompt.includes(glob.replace(/\/\*\*$/, '')))) return true;
495
- const kw = Array.isArray(lesson.keywords) ? lesson.keywords : [];
496
- return kw.some((k) => taskTokenSet.has(String(k).toLowerCase()));
497
- });
498
-
499
- const lines = [
500
- `# Task Brief: ${task?.id ?? 'task'}`,
501
- '',
502
- `Prompt: ${prompt}`,
503
- '',
504
- '## Ranked Skills',
505
- ...(ranked.length > 0
506
- ? ranked.map((r) => `- ${r.skill.name} (score ${r.score}): ${r.skill.description ?? ''}`)
507
- : ['- (none matched)']),
508
- ...(matchingLessons.length > 0
509
- ? [
510
- '',
511
- '## Lessons',
512
- ...matchingLessons.map((l) => `- ${formatLessonParagraph(l)}`),
513
- ]
514
- : []),
515
- '',
516
- ];
517
- const briefPath = join(workDir, LOCAL_DIR, 'brief.md');
518
- await mkdir(dirname(briefPath), { recursive: true });
519
- await writeFile(briefPath, lines.join('\n'), 'utf8');
501
+ /**
502
+ * The `brief` arm's context: the packet `simt brief` delivers, assembled by the same
503
+ * function the command and the prompt-submit hook call.
504
+ *
505
+ * The arm used to re-implement the packet from `registry.skills` — a property the
506
+ * registry does not have, so every run reported "(none matched)" while the real
507
+ * command resolved skills — and wrote it to a file no agent was told to read. An arm
508
+ * meant to measure the packet measured a different artefact (review 0003 Q49).
509
+ */
510
+ async function buildBriefPrefix(workDir, task) {
511
+ // A packet is built from git's answer; a directory that is not a repository has none,
512
+ // and asking would print a failure into a bench log for no measurement.
513
+ if (!existsSync(join(workDir, '.git'))) return '';
514
+ const assembled = await assembleBrief({
515
+ root: workDir,
516
+ task: String(task?.prompt ?? ''),
517
+ paths: '',
518
+ budget: DEFAULT_BRIEF_BYTES,
519
+ build: true,
520
+ federate: false,
521
+ }).catch(() => null);
522
+ if (!assembled || assembled.error || assembled.cold) return '';
523
+ return assembled.packed?.text ?? '';
520
524
  }
521
525
 
522
526
  /**
@@ -525,9 +529,10 @@ async function writeTaskBrief(workDir, task) {
525
529
  * @param {string} workDir
526
530
  * @param {{name: string, kind?: string, base?: string|null, write?: Record<string, string>, remove?: string[], env?: Record<string, string>}} armSpec
527
531
  * @param {object} [task]
528
- * @returns {Promise<{env: Record<string, string>}>}
532
+ * @returns {Promise<{env: Record<string, string>, promptPrefix: string}>}
529
533
  */
530
534
  export async function materialiseArm(workDir, armSpec, task = {}) {
535
+ let promptPrefix = '';
531
536
  const ablate = armSpec.ablate ?? parseAblationArmName(armSpec.name)?.ablate ?? null;
532
537
  const variant = armSpec.variant ?? parseVariantArmName(armSpec.name)?.variant ?? null;
533
538
  const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
@@ -543,7 +548,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
543
548
  await applyProjectionMode(workDir, 'kernel');
544
549
  } else if (base === 'brief') {
545
550
  await applyProjectionMode(workDir, 'full');
546
- await writeTaskBrief(workDir, task);
551
+ promptPrefix = await buildBriefPrefix(workDir, task);
547
552
  }
548
553
 
549
554
  for (const relPath of armSpec.remove ?? []) {
@@ -557,7 +562,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
557
562
  await writeFile(target, String(content), 'utf8');
558
563
  }
559
564
 
560
- return { env: { ...(armSpec.env ?? {}) } };
565
+ return { env: { ...(armSpec.env ?? {}) }, promptPrefix };
561
566
  }
562
567
 
563
568
  /**
@@ -570,7 +575,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
570
575
  * @param {object} armSpec
571
576
  * @param {object} [task]
572
577
  * @param {{parentDir?: string}} [options]
573
- * @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string>}>}
578
+ * @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string>, promptPrefix: string}>}
574
579
  */
575
580
  export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}, { parentDir } = {}) {
576
581
  const slotDir = await mkdtemp(join(parentDir ?? tmpdir(), 'bench-run-'));
@@ -619,12 +624,18 @@ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}
619
624
  }
620
625
  }
621
626
 
622
- const { env } = await materialiseArm(workDir, armSpec, task);
623
-
624
627
  await runCmd('git', ['init', '-q'], { cwd: workDir });
625
628
  await runCmd('git', ['config', 'user.name', 'bench'], { cwd: workDir });
626
629
  await runCmd('git', ['config', 'user.email', 'bench@local'], { cwd: workDir });
627
630
  await runCmd('git', ['config', 'commit.gpgsign', 'false'], { cwd: workDir });
631
+
632
+ // The repository exists before an arm materialises: the `brief` arm assembles its
633
+ // packet from git's answer, which a directory that is not yet a repository cannot give.
634
+ const { env, promptPrefix } = await materialiseArm(workDir, armSpec, task);
635
+ // Assembling a packet writes tool state. It belongs to no arm, and leaving it in the
636
+ // base commit would make the `brief` workspace differ by an index as well as by context.
637
+ await rm(join(workDir, LOCAL_DIR), { recursive: true, force: true }).catch(() => {});
638
+
628
639
  await runCmd('git', ['add', '-A'], { cwd: workDir });
629
640
  const commitRes = await runCmd(
630
641
  'git',
@@ -639,7 +650,7 @@ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}
639
650
  const headRes = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: workDir });
640
651
  const initialHead = headRes.stdout.toString('utf8').trim();
641
652
 
642
- return { slotDir, workDir, initialHead, env };
653
+ return { slotDir, workDir, initialHead, env, promptPrefix };
643
654
  }
644
655
 
645
656
  function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
@@ -657,25 +668,75 @@ function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
657
668
  return args;
658
669
  }
659
670
 
660
- async function executeAdapter(adapter, workDir, task, { vaultRoot, model, maxTurns, env, timeoutMs, slotDir }) {
661
- if (adapter.id === 'noop') return { ...parseAdapterUsage(adapter, ''), transcript: '' };
671
+ /**
672
+ * Apply a patch to a workspace, falling back to a three-way merge.
673
+ *
674
+ * The gold oracle and the regrade path land operator-supplied bytes the same way. They
675
+ * used to disagree (`--binary` on one side only), so a patch that applied under one read
676
+ * as unreproducible under the other (review 0003 Q59).
677
+ */
678
+ async function applyPatch(workDir, patch, { timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
679
+ if (!patch || patch.length === 0) return { code: 0 };
680
+ const applied = await runCmd('git', ['apply', '--binary', '-'], {
681
+ cwd: workDir,
682
+ input: patch,
683
+ timeoutMs,
684
+ });
685
+ if (applied.code === 0) return applied;
686
+ return runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: patch, timeoutMs });
687
+ }
688
+
689
+ export async function executeAdapter(
690
+ adapter,
691
+ workDir,
692
+ task,
693
+ { vaultRoot, model, maxTurns = DEFAULT_MAX_TURNS, env, timeoutMs, slotDir, promptPrefix = '' },
694
+ ) {
695
+ if (adapter.id === 'noop') {
696
+ return {
697
+ ...parseAdapterUsage(adapter, ''),
698
+ transcript: '',
699
+ agent: { exitCode: 0, timedOut: false, wallMs: 0 },
700
+ };
701
+ }
662
702
  if (adapter.id === 'gold') {
703
+ const startedAt = Date.now();
663
704
  const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'));
664
- if (goldPatch.length > 0) {
665
- const applyRes = await runCmd('git', ['apply', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
666
- if (applyRes.code !== 0) {
667
- await runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
668
- }
669
- }
670
- return { ...parseAdapterUsage(adapter, ''), transcript: '' };
705
+ await applyPatch(workDir, goldPatch, { timeoutMs });
706
+ return {
707
+ ...parseAdapterUsage(adapter, ''),
708
+ transcript: '',
709
+ agent: { exitCode: 0, timedOut: false, wallMs: Date.now() - startedAt },
710
+ };
671
711
  }
672
712
 
673
713
  const analyticsLog = join(slotDir, 'analytics.jsonl');
674
- const args = buildAdapterArgs(adapter, { prompt: String(task.prompt ?? ''), turns: maxTurns, model, analyticsLog });
675
- const outcome = await runCmd(adapter.bin, args, { cwd: workDir, env, timeoutMs });
714
+ // The arm's context arrives as a prefix rather than a file the agent must be told to
715
+ // read, so the prompt an adapter receives is the whole thing it was given. A `gold`
716
+ // or `noop` run never reaches this line: it scripts the workspace instead.
717
+ const prompt = [String(promptPrefix ?? ''), String(task.prompt ?? '')]
718
+ .filter((part) => part.length > 0)
719
+ .join('\n\n');
720
+ const args = buildAdapterArgs(adapter, { prompt, turns: maxTurns, model, analyticsLog });
721
+ const outcome = await runCmd(adapter.bin, args, {
722
+ cwd: workDir,
723
+ env: agentEnv(adapter, { home: slotDir, extra: env }),
724
+ timeoutMs,
725
+ });
726
+ // A binary that could not start produced no run. Grading its empty workspace would
727
+ // record an unresolved arm instead of the missing harness it is (review 0003 Q47).
728
+ if (outcome.spawnError) {
729
+ throw new Error(`harness ${adapter.id} could not start ${adapter.bin}: ${outcome.spawnError}`);
730
+ }
676
731
  const stdoutText = outcome.stdout.toString('utf8');
677
732
  const analyticsText = adapter.format === 'analytics-log' ? await readFile(analyticsLog, 'utf8').catch(() => '') : '';
678
- return { ...parseAdapterUsage(adapter, stdoutText, analyticsText), transcript: `${stdoutText}\n${analyticsText}` };
733
+ return {
734
+ ...parseAdapterUsage(adapter, stdoutText, analyticsText),
735
+ transcript: `${stdoutText}\n${analyticsText}`,
736
+ // A run's own exit code, deadline and wall time belong in its record: the old one
737
+ // stored none, so a timed-out run was indistinguishable from a completed one.
738
+ agent: { exitCode: outcome.code, timedOut: outcome.timedOut, wallMs: outcome.wallMs },
739
+ };
679
740
  }
680
741
 
681
742
  // loadTasks lives in tasks.mjs (the advice engine became a second reader, so the loader
@@ -718,9 +779,10 @@ export async function runBench(
718
779
  maxTurns = DEFAULT_MAX_TURNS,
719
780
  concurrency = DEFAULT_CONCURRENCY,
720
781
  cwd = process.cwd(),
782
+ adapters: adapterTable,
721
783
  } = {},
722
784
  ) {
723
- const adapters = await loadAdapters();
785
+ const adapters = adapterTable ?? (await loadAdapters());
724
786
  const adapter = adapters[harness];
725
787
  if (!adapter) {
726
788
  throw new Error(
@@ -747,6 +809,10 @@ export async function runBench(
747
809
  }
748
810
 
749
811
  const resolvedModel = model ?? (adapter.kind === 'builtin' ? adapter.id : 'default');
812
+ // A built-in harness scripts the workspace instead of driving an agent, so its
813
+ // numbers calibrate the grader and are never an arm's measurement (review 0003 Q49).
814
+ const measured = adapter.kind !== 'builtin';
815
+ const isolation = adapterIsolation(adapter);
750
816
  const plannedRuns = [];
751
817
  for (const armSpec of armSpecs) {
752
818
  for (let rep = 1; rep <= reps; rep += 1) {
@@ -779,9 +845,11 @@ export async function runBench(
779
845
  harness,
780
846
  model: resolvedModel,
781
847
  rep: item.rep,
782
- resolved: null,
783
- gatesPassed: null,
784
- usage: emptyUsage().usage,
848
+ measured,
849
+ isolation, status: 'planned',
850
+ resolved: null,
851
+ gatesPassed: null,
852
+ usage: emptyUsage().usage,
785
853
  costUsd: null,
786
854
  diff: { files: 0, outsideBlastRadius: 0 },
787
855
  probe: null,
@@ -804,21 +872,25 @@ export async function runBench(
804
872
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-batch-'));
805
873
  try {
806
874
  const records = await mapPool(plannedRuns, concurrency, async (item) => {
807
- const { slotDir, workDir, initialHead, env } = await prepareIsolatedWorkspace(
808
- root,
809
- item.task.base,
810
- item.armSpec,
811
- item.task,
812
- { parentDir },
813
- );
875
+ let slotDir = null;
814
876
  try {
815
- const { usage, costUsd, transcript } = await executeAdapter(adapter, workDir, item.task, {
877
+ const prepared = await prepareIsolatedWorkspace(
878
+ root,
879
+ item.task.base,
880
+ item.armSpec,
881
+ item.task,
882
+ { parentDir },
883
+ );
884
+ slotDir = prepared.slotDir;
885
+ const { workDir, initialHead, env, promptPrefix } = prepared;
886
+ const { usage, costUsd, transcript, agent } = await executeAdapter(adapter, workDir, item.task, {
816
887
  vaultRoot,
817
888
  model: resolvedModel,
818
889
  maxTurns,
819
890
  env,
820
891
  timeoutMs,
821
892
  slotDir,
893
+ promptPrefix,
822
894
  });
823
895
 
824
896
  const grade = await gradeWorkspace(workDir, item.task, {
@@ -841,8 +913,13 @@ export async function runBench(
841
913
  harness,
842
914
  model: resolvedModel,
843
915
  rep: item.rep,
916
+ measured,
917
+ isolation,
918
+ status: 'ok',
919
+ agent,
844
920
  resolved: grade.resolved,
845
921
  gatesPassed: grade.gatesPassed,
922
+ f2pPassed: grade.f2pPassed ?? [],
846
923
  usage,
847
924
  costUsd,
848
925
  diff: grade.diff,
@@ -857,8 +934,33 @@ export async function runBench(
857
934
  ]);
858
935
 
859
936
  return { ...record, runId: item.runId };
937
+ } catch (error) {
938
+ // One failed run is one exclusion, not a lost batch: the old path exited 3 and
939
+ // left a half-written run behind (review 0003 Q47).
940
+ return {
941
+ schemaVersion: 1,
942
+ runId: item.runId,
943
+ task: item.task.id,
944
+ arm: item.armSpec.name,
945
+ harness,
946
+ model: resolvedModel,
947
+ rep: item.rep,
948
+ measured,
949
+ isolation,
950
+ status: 'error',
951
+ error: String(error?.message ?? error),
952
+ resolved: false,
953
+ gatesPassed: false,
954
+ f2pPassed: [],
955
+ usage: emptyUsage().usage,
956
+ costUsd: null,
957
+ diff: { files: 0, outsideBlastRadius: 0 },
958
+ probe: null,
959
+ };
860
960
  } finally {
861
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
961
+ if (slotDir !== null) {
962
+ await rm(slotDir, { recursive: true, force: true }).catch(() => {});
963
+ }
862
964
  }
863
965
  });
864
966
 
@@ -877,6 +979,13 @@ export async function runBench(
877
979
  }
878
980
  }
879
981
 
982
+ /** Two string lists hold the same members, ignoring order and duplicates. */
983
+ function sameStringSet(a, b) {
984
+ const left = [...new Set(a)].sort();
985
+ const right = [...new Set(b)].sort();
986
+ return left.length === right.length && left.every((value, index) => value === right[index]);
987
+ }
988
+
880
989
  async function resolveRegradeTargets(root, target) {
881
990
  const runsBaseDir = join(root, LOCAL_DIR, 'bench', 'runs');
882
991
  const direct = resolve(root, target);
@@ -935,7 +1044,7 @@ export async function regradeRun(
935
1044
  }
936
1045
 
937
1046
  const tasksDir = join(root, LOCAL_DIR, 'bench', 'runs');
938
- const taskDefsDir = join(root, '.simt', 'bench', 'tasks');
1047
+ const taskDefsDir = join(root, STATE_DIR, 'bench', 'tasks');
939
1048
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-regrade-'));
940
1049
 
941
1050
  try {
@@ -972,20 +1081,7 @@ export async function regradeRun(
972
1081
  { parentDir },
973
1082
  );
974
1083
  try {
975
- if (agentPatch.length > 0) {
976
- const applyRes = await runCmd('git', ['apply', '--binary', '-'], {
977
- cwd: workDir,
978
- input: agentPatch,
979
- timeoutMs,
980
- });
981
- if (applyRes.code !== 0) {
982
- await runCmd('git', ['apply', '--3way', '-'], {
983
- cwd: workDir,
984
- input: agentPatch,
985
- timeoutMs,
986
- });
987
- }
988
- }
1084
+ await applyPatch(workDir, agentPatch, { timeoutMs });
989
1085
 
990
1086
  const grade = await gradeWorkspace(workDir, task, {
991
1087
  vaultRoot,
@@ -993,9 +1089,12 @@ export async function regradeRun(
993
1089
  timeoutMs,
994
1090
  });
995
1091
 
1092
+ // The verdicts alone cannot tell "the same tests passed" from "the same number
1093
+ // of tests passed"; the fail-to-pass set is compared too (review 0003 Q59).
996
1094
  const reproduced =
997
1095
  grade.resolved === stored.resolved &&
998
1096
  grade.gatesPassed === stored.gatesPassed &&
1097
+ sameStringSet(grade.f2pPassed ?? [], stored.f2pPassed ?? []) &&
999
1098
  grade.diff.files === stored.diff?.files &&
1000
1099
  grade.diff.outsideBlastRadius === stored.diff?.outsideBlastRadius;
1001
1100
 
@@ -1003,6 +1102,7 @@ export async function regradeRun(
1003
1102
  ...stored,
1004
1103
  resolved: grade.resolved,
1005
1104
  gatesPassed: grade.gatesPassed,
1105
+ f2pPassed: grade.f2pPassed ?? [],
1006
1106
  diff: grade.diff,
1007
1107
  };
1008
1108
 
@@ -34,7 +34,10 @@
34
34
  // command and the tool leave it on. It is inert either way in a repository that declared no
35
35
  // servers.
36
36
 
37
+ import { join } from 'node:path';
38
+
37
39
  import { CLI, STATE_DIR } from '../name.mjs';
40
+ import { parse as parseRegion } from '../projection/markers.mjs';
38
41
  import { DEFAULT_BRIEF_BYTES, buildPacket, collect } from './packet.mjs';
39
42
  import { classifyTask, cardVerbs } from './kind.mjs';
40
43
  import { briefAdvice } from '../advice/advice.mjs';
@@ -81,22 +84,38 @@ const MAP_FACTOR = 8;
81
84
  * space is never part of a task's scope.
82
85
  */
83
86
 
84
- /** Every file git tracks that carries a generated region, one `git grep` away. */
87
+ /**
88
+ * Every file git tracks that carries a generated region, one `git grep` away.
89
+ *
90
+ * A file that merely mentions the marker is not carrying a region: a rule file, a review
91
+ * and this test all quote `simt:begin`, and warning that they are generated sends a reader
92
+ * to edit a source file that has no generated bytes at all (review 0003 Q52). The grep is
93
+ * only the candidate list; the region parser decides.
94
+ */
85
95
  async function generatedRegions(root) {
86
96
  const { execFile } = await import('node:child_process');
87
97
  const { promisify } = await import('node:util');
98
+ const { readFile } = await import('node:fs/promises');
99
+ let candidates;
88
100
  try {
89
101
  const { stdout } = await promisify(execFile)(
90
102
  'git',
91
103
  ['grep', '-lF', '--', `${'simt'}:begin`],
92
104
  { cwd: root, maxBuffer: 8 << 20 },
93
105
  );
94
- return stdout.split('\n').filter((line) => line.length > 0).sort();
106
+ candidates = stdout.split('\n').filter((line) => line.length > 0).sort();
95
107
  } catch {
96
108
  // No match is exit 1, and a directory that is not a repository fails too. Both mean
97
109
  // the same thing to a reader: no generated region was found to warn about.
98
110
  return [];
99
111
  }
112
+ const carrying = [];
113
+ for (const path of candidates) {
114
+ const text = await readFile(join(root, path), 'utf8').catch(() => null);
115
+ if (text === null) continue;
116
+ if (parseRegion(text, 'html').status === 'ok') carrying.push(path);
117
+ }
118
+ return carrying;
100
119
  }
101
120
 
102
121
  /**
@@ -113,6 +132,9 @@ async function generatedRegions(root) {
113
132
  * @param {boolean} [options.build] whether an empty index may be built here
114
133
  * @param {boolean} [options.federate] whether a declared external server may be asked one
115
134
  * question; a delivered packet turns it off to keep its 300 ms bar
135
+ * @param {string[]} [options.commands] the dispatchable command paths (`COMMANDS`). A
136
+ * decision's `Affects:` token is a command only when the tool has that command, so a
137
+ * packet that does not know them reports every one of them as unresolved
116
138
  * @returns a packet, or `{ error }` carrying the exit code a failure already reported, or
117
139
  * `{ cold: true }` when the index is empty and this caller may not build one
118
140
  */
@@ -124,6 +146,7 @@ export async function assembleBrief({
124
146
  flags = {},
125
147
  build = true,
126
148
  federate = true,
149
+ commands = [],
127
150
  } = {}) {
128
151
  const named = paths !== null;
129
152
  const dirty = (await dirtyPaths(root)).filter((path) => !path.startsWith(`${STATE_DIR}/`));
@@ -187,6 +210,15 @@ export async function assembleBrief({
187
210
  })
188
211
  : null;
189
212
  const ranked = rankFiles({ files, symbols, edges, fts, bm25, embeddings, queryVector, task });
213
+ // An empty match is a different answer from a weak one: the ranking is then the graph
214
+ // hub, which is what a reader who asked about `zebracorn` must not read as a hit
215
+ // (review 0003 Q52).
216
+ const matched = ranked.filter((row) => (row.score ?? 0) > 0).length;
217
+ if (task.trim().length > 0 && matched === 0) {
218
+ packet.add('CAUTIONS', 'matched 0: no file or symbol mentions this task, so FILES is the graph hub rather than a match', {
219
+ why: ['an answer to a graph-shaped question is not an answer to this task'],
220
+ });
221
+ }
190
222
  const rendered = renderMap(ranked, symbols, { level: 'names', budget: budget > 0 ? budget * MAP_FACTOR : 0 });
191
223
  for (const line of rendered.text.split('\n')) {
192
224
  packet.add('FILES', line, { why: ['ranked by the task words against the import graph'] });
@@ -233,7 +265,10 @@ export async function assembleBrief({
233
265
  const records = await decisionRecords(root);
234
266
  const ctx = {
235
267
  files: filePaths,
236
- commands: new Set(card.lines.map((line) => line.command)),
268
+ // The card's lines are shell commands (`npm run test`); a decision's Affects token is
269
+ // a command of this tool. Passing only the card's list made every `simt …` token
270
+ // unresolved — 47 false cautions on this repository (review 0003 Q52).
271
+ commands: new Set([...card.lines.map((line) => line.command), ...commands]),
237
272
  roots: sourceRoots([...filePaths]),
238
273
  };
239
274
  const decisions = new Map();