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
@@ -8,6 +8,7 @@
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { resolve } from 'node:path';
10
10
 
11
+ import { CLI } from '../name.mjs';
11
12
  import { tokens } from './context.mjs';
12
13
 
13
14
  const RULE_PATH_RE =
@@ -53,14 +54,14 @@ export async function measureSession(transcriptPath) {
53
54
  return {
54
55
  ok: false,
55
56
  error: '--session requires a local transcript path',
56
- remedy: 'pass a local JSONL or JSON transcript path: simt tokens --session <path>',
57
+ remedy: `pass a local JSONL or JSON transcript path: ${CLI} tokens --session <path>`,
57
58
  };
58
59
  }
59
60
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(transcriptPath.trim())) {
60
61
  return {
61
62
  ok: false,
62
63
  error: `${transcriptPath}: remote URLs are refused; session transcripts are read locally only (ADR-075)`,
63
- remedy: 'pass a local file path on this machine: simt tokens --session ./session.jsonl',
64
+ remedy: `pass a local file path on this machine: ${CLI} tokens --session ./session.jsonl`,
64
65
  };
65
66
  }
66
67
 
@@ -0,0 +1,37 @@
1
+ // The runtime floor, in one place both entry points read (review 0003 Q54).
2
+ //
3
+ // `package.json` declares `engines.node` and `pyproject.toml` declares a python
4
+ // version; both are advisory to the installer, so an older runtime reached the CLI
5
+ // and failed somewhere inside it — a syntax error, a missing API — instead of the one
6
+ // sentence that says what to install. This module is deliberately tiny and has no
7
+ // imports: the bootstrap that calls it must not load the tool that cannot run.
8
+
9
+ /** The oldest Node this package runs on. Mirrors `package.json` `engines.node`. */
10
+ export const MIN_NODE = '22.20.0';
11
+
12
+ /**
13
+ * Whether `current` is at least `minimum`, compared component by component.
14
+ *
15
+ * A string compare is wrong ("9.0.0" > "22.20.0"), and `process.versions.node` may
16
+ * carry a pre-release suffix that `Number()` would turn into NaN, so the numeric
17
+ * prefix of each component is what counts.
18
+ *
19
+ * @param {string} current a version such as `22.20.0` or `23.1.0-rc.1`
20
+ * @param {string} [minimum]
21
+ * @returns {boolean}
22
+ */
23
+ export function satisfiesMinimum(current, minimum = MIN_NODE) {
24
+ const parse = (value) =>
25
+ String(value)
26
+ .split('.')
27
+ .map((part) => Number.parseInt(part, 10))
28
+ .map((part) => (Number.isFinite(part) ? part : 0));
29
+ const have = parse(current);
30
+ const want = parse(minimum);
31
+ for (let i = 0; i < Math.max(have.length, want.length); i += 1) {
32
+ const a = have[i] ?? 0;
33
+ const b = want[i] ?? 0;
34
+ if (a !== b) return a > b;
35
+ }
36
+ return true;
37
+ }
@@ -92,7 +92,27 @@ export function writeMode(flags) {
92
92
  * Truncation is always announced with an explicit line, never a silent ellipsis:
93
93
  * an agent that cannot tell a truncated answer from a complete one will guess.
94
94
  */
95
- export function emit(text, { maxBytes = 0, stream = process.stdout } = {}) {
95
+ /**
96
+ * Answer one command's result on the surface the caller asked for.
97
+ *
98
+ * Every command that has both a document and a text report was choosing between them by
99
+ * hand, in the same five lines, in more than a dozen files - and a command that chose
100
+ * slightly differently printed a report where a caller had asked for JSON, which is how a
101
+ * script gets prose (review 0003 Q63).
102
+ *
103
+ * The renderer is a thunk because the surface is decided first: a command asked for JSON
104
+ * must not pay to render text nobody reads, and a caller cannot forget the branch.
105
+ */
106
+ export function present(payload, renderText, flags) {
107
+ if (isJson(flags)) {
108
+ emitJson(payload);
109
+ return 'json';
110
+ }
111
+ emit(renderText());
112
+ return 'text';
113
+ }
114
+
115
+ export function emit(text, { maxBytes = maxBytesDefault, stream = process.stdout } = {}) {
96
116
  const body = text.endsWith('\n') ? text : `${text}\n`;
97
117
  const bytes = Buffer.byteLength(body);
98
118
  if (maxBytes > 0 && bytes > maxBytes) {
@@ -131,6 +151,19 @@ export const SCHEMA_VERSION = 1;
131
151
  */
132
152
  let prettyDefault = process.stdout.isTTY === true;
133
153
 
154
+ // The byte ceiling is one flag with one reader: the envelope. Commands used to pass
155
+ // `flags.maxBytes ?? 0` at seventy-eight call sites, which is seventy-eight chances to pass
156
+ // a different number, or to forget and answer past the ceiling the caller set (review 0003
157
+ // Q65). A ceiling of zero or a value that is not a number is no ceiling, the same way the
158
+ // flag itself is read.
159
+ let maxBytesDefault = 0;
160
+
161
+ /** Set the byte ceiling every emit and every document inherits, from `--max-bytes`. */
162
+ export function maxBytesLimit(bytes) {
163
+ const value = Number(bytes);
164
+ maxBytesDefault = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
165
+ }
166
+
134
167
  export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
135
168
  prettyDefault = pretty;
136
169
  }
@@ -203,8 +236,83 @@ function fitJsonArrays(payload, maxBytes, pretty) {
203
236
  return null;
204
237
  }
205
238
 
206
- export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = 0 } = {}) {
207
- const payload = withSchemaVersion(value);
239
+ /**
240
+ * Warnings a run accumulated, carried into the JSON document it prints.
241
+ *
242
+ * The same reason stderr is separate from stdout: a caller reading a document cannot
243
+ * see a note printed beside it. A command that ignored half of what it was asked still
244
+ * has to succeed or fail the same way, so the note belongs IN the result, and the text
245
+ * mode keeps the stderr line it already had (review 0003 Q62).
246
+ */
247
+ let accumulated = [];
248
+
249
+ // The path the dispatcher resolved. A command should not restate it: the ones that did
250
+ // restated it wrongly (`plugin` printed `plugin claude`, `sync` printed `sync --check`),
251
+ // and a document whose `command` disagrees with the argv that produced it is worse than
252
+ // one with no `command` at all (review 0003 Q65).
253
+ let canonicalCommand = null;
254
+
255
+ /** Set the command path every JSON document will carry, or clear it with null. */
256
+ export function jsonCommand(path) {
257
+ canonicalCommand = path ?? null;
258
+ }
259
+
260
+ // A document cannot name the exit code before the command has returned one. The
261
+ // dispatcher captures what a command emits, then writes it once with the code attached;
262
+ // writing first is how `ok: false` shipped alongside exit 0 (review 0003 Q65).
263
+ let captured = null;
264
+
265
+ /** Collect documents instead of writing them, so the dispatcher can add the exit code. */
266
+ export function jsonCaptureStart() {
267
+ captured = [];
268
+ }
269
+
270
+ /** The captured documents and a reset, or an empty list when nothing was captured. */
271
+ export function jsonCaptureTake() {
272
+ const docs = captured;
273
+ captured = null;
274
+ return docs;
275
+ }
276
+
277
+ /** Record a warning for the document this run prints. */
278
+ export function warn(message) {
279
+ accumulated = [...accumulated, message];
280
+ }
281
+
282
+ /**
283
+ * Drop what a previous run in this process recorded.
284
+ *
285
+ * The collector is per process and a process can dispatch more than once - the MCP
286
+ * server and the tests both do. Without this, one command's notice appeared in the
287
+ * next command's document, which parity between the two surfaces caught (review 0003
288
+ * Q62).
289
+ */
290
+ export function clearWarnings() {
291
+ accumulated = [];
292
+ }
293
+
294
+ /** The warnings this run has recorded, in order. */
295
+ export function warnings() {
296
+ return accumulated;
297
+ }
298
+
299
+ export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = maxBytesDefault } = {}) {
300
+ // `notices`, not `warnings`: `status` already reports a warning COUNT under that key,
301
+ // and a document cannot mean two things by one name. The collision is named here
302
+ // rather than bent into either shape (review 0003 Q62).
303
+ const carried =
304
+ accumulated.length > 0 && value !== null && typeof value === 'object' && !Array.isArray(value) && value.notices === undefined
305
+ ? { ...value, notices: accumulated }
306
+ : value;
307
+ const stamped =
308
+ canonicalCommand !== null && carried !== null && typeof carried === 'object' && !Array.isArray(carried)
309
+ ? { ...carried, command: canonicalCommand }
310
+ : carried;
311
+ if (captured !== null) {
312
+ captured.push({ value: stamped });
313
+ return true;
314
+ }
315
+ const payload = withSchemaVersion(stamped);
208
316
  const text = `${stringifyJson(payload, pretty)}\n`;
209
317
  // JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
210
318
  // true. Arrays shrink with a `truncated` record naming what lost how much, and a
@@ -511,6 +511,23 @@ export async function publishPack(dir, { apply = false } = {}) {
511
511
  return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
512
512
  }
513
513
 
514
+ // A preview is a preview. The registry read below is the one network call in this
515
+ // command, and running it to print a plan made `pack publish` — a preview by default
516
+ // — a network operation that failed offline without `--yes` (review 0003 Q58).
517
+ if (!apply) {
518
+ return {
519
+ pack: linted.pack,
520
+ ok: true,
521
+ published: false,
522
+ skipped: false,
523
+ planned: [
524
+ `npm view ${pkg}@${pack.version} (check whether it is already live)`,
525
+ `npm publish (in ${dir})`,
526
+ ],
527
+ findings,
528
+ };
529
+ }
530
+
514
531
  // Idempotent, the way the release twin is: a version that is already live is
515
532
  // skipped rather than republished, so a re-run after an interruption continues
516
533
  // where it stopped.
@@ -534,9 +551,6 @@ export async function publishPack(dir, { apply = false } = {}) {
534
551
  }
535
552
 
536
553
  const planned = [`npm publish (in ${dir})`];
537
- if (!apply) {
538
- return { pack: linted.pack, ok: true, published: false, skipped: false, planned, findings };
539
- }
540
554
  try {
541
555
  await exec('npm', ['publish'], { cwd: dir });
542
556
  } catch (error) {
package/src/packs.mjs CHANGED
@@ -72,8 +72,26 @@ export async function packDigest(dir, { exclude = [] } = {}) {
72
72
  }
73
73
  for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
74
74
  const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
75
- if (entry.isDirectory()) await walk(join(d, entry.name), rel);
76
- else if (entry.isFile() && !excluded.has(rel)) files.push(rel);
75
+ if (entry.isDirectory()) {
76
+ await walk(join(d, entry.name), rel);
77
+ } else if (entry.isSymbolicLink()) {
78
+ // A link is not vendored bytes, and the loader follows it: the old walk skipped
79
+ // links while the projection read through them, so a rule reached by a link was
80
+ // projected without being covered by the pin (review 0003 Q58). A link to a
81
+ // file is hashed through its target, because `readFile` below follows it. A link
82
+ // to a directory is refused: recursing would let a pack's digest describe a tree
83
+ // it does not carry.
84
+ const target = await stat(join(d, entry.name)).catch(() => null);
85
+ if (target?.isDirectory()) {
86
+ const error = new Error(
87
+ `${rel} is a symbolic link to a directory; a pack is vendored bytes, and its digest cannot cover a tree it does not carry`,
88
+ );
89
+ error.code = 'PACK_SYMLINK';
90
+ error.path = rel;
91
+ throw error;
92
+ }
93
+ if (!excluded.has(rel)) files.push(rel);
94
+ } else if (entry.isFile() && !excluded.has(rel)) files.push(rel);
77
95
  }
78
96
  };
79
97
  await walk(dir);
@@ -17,6 +17,11 @@ import { LOCK_FILE, LOCK_LEGACY } from '../name.mjs';
17
17
  import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
18
18
  import { readManifest } from './manifest.mjs';
19
19
 
20
+ /** The lock's own format. Bumped only when a reader must interpret an old file
21
+ * differently; an absent field means 1, because every lock written before this
22
+ * field existed was written by the only format there has been. */
23
+ const LOCK_VERSION = 1;
24
+
20
25
  /** Manifest keys that change what the region contains. Anything else in the file is
21
26
  * the repository's own notes, and hashing it would make a comment fail the lock. */
22
27
  const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
@@ -39,7 +44,7 @@ export async function computeLock(root, flags = {}, manifest = undefined) {
39
44
  const outputs = createHash('sha256').update(desired.body).digest('hex');
40
45
  const hasVariants =
41
46
  variants && typeof variants === 'object' && Object.keys(variants).length > 0;
42
- return hasVariants ? { inputs, outputs, variants } : { inputs, outputs };
47
+ return hasVariants ? { version: LOCK_VERSION, inputs, outputs, variants } : { version: LOCK_VERSION, inputs, outputs };
43
48
  }
44
49
 
45
50
  /**
@@ -71,6 +76,11 @@ export async function readLock(root) {
71
76
  if (text === null) return { lock: null, error: null };
72
77
  try {
73
78
  const lock = JSON.parse(text);
79
+ // Absent means 1; a version this build does not know is a lock it cannot judge,
80
+ // and reading it anyway would report drift for a format change.
81
+ if (lock.version !== undefined && (!Number.isInteger(lock.version) || lock.version < 1)) {
82
+ return { lock: null, error: 'version must be an integer of at least 1' };
83
+ }
74
84
  if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
75
85
  return { lock: null, error: 'must be an object with inputs and outputs' };
76
86
  }
@@ -32,31 +32,17 @@ import { runScript } from '../ecosystem/node-runner.mjs';
32
32
  // and the manifest name is used a dozen times below in remedy strings that name this
33
33
  // file by name.
34
34
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
35
+ import { GENERATED_KEYS_OF, KEYS, loadManifest, manifestPath } from '../manifest/schema.mjs';
35
36
  import { CLI } from '../name.mjs';
36
37
  import { packageVersion } from '../package-version.mjs';
37
38
  export const MANIFEST = MANIFEST_FILE;
38
39
 
40
+ // Still exported from here because this is where the manifest lived: every caller that
41
+ // resolved its path through this module keeps working, and the implementation moved.
42
+ export { manifestPath };
43
+
39
44
  export { MANIFEST_LEGACY };
40
45
 
41
- /**
42
- * The manifest a repository has, which is not necessarily the one this tool would write.
43
- *
44
- * The file was renamed from `simt-dev.json` to `simt.json`. Every read site used to say
45
- * `join(root, MANIFEST)` and would have reported an adopted repository as unadopted the
46
- * day the constant moved - and "no manifest" means "never adopted", which is the answer
47
- * that offers to re-adopt a repo that is already done. Fourteen repositories carry the
48
- * old name on disk.
49
- *
50
- * So the old name is read as a fallback and the new one is written. `writeManifest`
51
- * renames rather than creating a second file, which is how the window closes: the first
52
- * `simt init` or `simt sync` in an adopted repo retires the old name by moving it.
53
- */
54
- export async function manifestPath(root) {
55
- const next = join(root, MANIFEST);
56
- if (existsSync(next)) return next;
57
- const previous = join(root, MANIFEST_LEGACY);
58
- return existsSync(previous) ? previous : next;
59
- }
60
46
 
61
47
 
62
48
  /**
@@ -433,7 +419,7 @@ function ecosystemCommands(facts = [], capability = null) {
433
419
  */
434
420
  export function buildManifest({ root, stack = [], sha, version, existing = null, capability = null, surfaces = undefined, facts = [] }) {
435
421
  const gates = defaultGates(stack, facts);
436
- const limits = defaultLimits(stack);
422
+ const limits = defaultLimits();
437
423
 
438
424
  // A re-init must never silently weaken a repo's own declaration: existing
439
425
  // limits and gates win over defaults, and only the projection stamp is refreshed.
@@ -745,7 +731,10 @@ export function declaredShared(manifest) {
745
731
  return { level, rules };
746
732
  }
747
733
 
748
- export const GENERATED_KEYS = new Set(['$comment', 'projection']);
734
+ // Derived from the table rather than restated: a key this tool writes is a `tool` key
735
+ // marked generated, and a second list here is the drift this module exists to end.
736
+ export const GENERATED_KEYS = GENERATED_KEYS_OF;
737
+
749
738
 
750
739
  // The sub-keys of `projection` this tool writes. `projection` is a generated key, but
751
740
  // not wholesale: the repository owns sub-keys this writer does not emit (`place` is the
@@ -892,20 +881,17 @@ export function manifestText(manifest) {
892
881
  return `${JSON.stringify(manifest, null, 2)}\n`;
893
882
  }
894
883
 
895
- /** @returns {{manifest: object|null, exists: boolean, error: string|null}} */
884
+ /**
885
+ * The manifest in the shape the callers already read, now answered by the one loader
886
+ * (review 0003 Q69). `error` is the unreadable-file case only: a manifest with a value
887
+ * the schema cannot use is still a manifest, and refusing it here would turn every
888
+ * reader into a second validator.
889
+ *
890
+ * @returns {Promise<{manifest: object|null, exists: boolean, error: string|null}>}
891
+ */
896
892
  export async function readManifest(root) {
897
- const path = await manifestPath(root);
898
- let text;
899
- try {
900
- text = await readFile(path, 'utf8');
901
- } catch {
902
- return { manifest: null, exists: false, error: null };
903
- }
904
- try {
905
- return { manifest: JSON.parse(text), exists: true, error: null };
906
- } catch (error) {
907
- return { manifest: null, exists: true, error: `unparseable: ${error.message}` };
908
- }
893
+ const { manifest, exists, problems } = await loadManifest(root);
894
+ return { manifest, exists, error: exists && manifest === null ? (problems[0] ?? 'unparseable') : null };
909
895
  }
910
896
 
911
897
  export async function writeManifest(root, manifest, journal = null) {
@@ -27,10 +27,11 @@ import { dirname, join, resolve } from 'node:path';
27
27
 
28
28
  import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
29
29
  import { loadMdcRules, renderMdc } from './mdc.mjs';
30
- import { foldsRules, frontmatterBlock, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
30
+ import { foldsRules, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
31
31
  import { ruleTargets } from './targets.mjs';
32
32
  import { compose } from './markers.mjs';
33
- import { declaredBudget, declaredShared, manifestPath, readManifest } from './manifest.mjs';
33
+ import { declaredBudget, declaredShared, readManifest } from './manifest.mjs';
34
+ import { loadManifest } from '../manifest/schema.mjs';
34
35
  import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
35
36
  import { packToBudget } from '../kernel/budget.mjs';
36
37
  import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
@@ -255,7 +256,7 @@ export function isEligible(rule, signals, optedIn) {
255
256
  export async function readDeclared(root) {
256
257
  let parsed = {};
257
258
  try {
258
- parsed = JSON.parse(await readFile(await manifestPath(root), 'utf8')) ?? {};
259
+ parsed = (await loadManifest(root)).manifest ?? {};
259
260
  } catch {
260
261
  parsed = {};
261
262
  }
package/src/repo-root.mjs CHANGED
@@ -50,6 +50,25 @@ export function repoRoot(cwd) {
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * The directory a command works on: `--root`, else the first positional when the command
55
+ * takes one, else the current directory.
56
+ *
57
+ * This precedence is a user-visible contract, not an implementation detail: it decides
58
+ * which directory `--root .` and a bare positional mean, and thirty-four commands had
59
+ * their own copy of it. The copies drifted - some resolved the answer, some did not; some
60
+ * read `args[0]`, some `positional[0]` - so the same argv could name two directories
61
+ * depending on which command read it (review 0003 Q65).
62
+ *
63
+ * The answer is returned verbatim, not resolved: a caller that wants an absolute path
64
+ * says so, and a command that reports the root it was given still reports what the reader
65
+ * typed. `repoRoot` answers the different question of which repository that directory
66
+ * belongs to.
67
+ */
68
+ export function commandRoot(flags = {}, positional = []) {
69
+ return flags.root ?? positional[0] ?? process.cwd();
70
+ }
71
+
53
72
  /**
54
73
  * Whether git on this repository compares paths case-insensitively, which is
55
74
  * the one filesystem fact a path comparison must honour: on a case-insensitive
@@ -19,10 +19,11 @@
19
19
 
20
20
  import { readdir, readFile, stat } from 'node:fs/promises';
21
21
  import { spawn } from 'node:child_process';
22
- import { basename, join } from 'node:path';
22
+ import { join } from 'node:path';
23
23
 
24
24
  import { matchesGlob } from './resolve.mjs';
25
25
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
26
+ import { loadManifest } from '../manifest/schema.mjs';
26
27
 
27
28
  /** tag -> human-readable marker(s) that imply it. First match wins per tag. */
28
29
  const MARKERS = [
@@ -225,6 +226,12 @@ export async function fingerprint(root) {
225
226
  }
226
227
  const names = new Set(entries);
227
228
 
229
+ // One walk answers every `glob` fact. Each candidate used to walk the tree again, so
230
+ // a repository with seven globs paid for seven identical walks - the same directories,
231
+ // in the same order, for the same answer (review 0003 Q67).
232
+ let nested = null;
233
+ const nestedPaths = async () => (nested ??= await listNested(root));
234
+
228
235
  for (const [tag, markers] of MARKERS) {
229
236
  const hit = markers.find((m) => names.has(m));
230
237
  if (hit) {
@@ -249,7 +256,7 @@ export async function fingerprint(root) {
249
256
  break;
250
257
  }
251
258
  if (fact.kind === 'glob') {
252
- const hit = (await findNested(root, candidate))[0];
259
+ const hit = findNestedIn(await nestedPaths(), candidate)[0];
253
260
  if (hit) {
254
261
  facts.add(fact.name);
255
262
  factEvidence[fact.name] ??= hit;
@@ -320,9 +327,9 @@ export async function fingerprint(root) {
320
327
  : names.has(MANIFEST_LEGACY)
321
328
  ? MANIFEST_LEGACY
322
329
  : null;
323
- const manifest = manifestName === null
324
- ? null
325
- : await readManifest(join(root, manifestName));
330
+ // One reader, so a file that does not parse is a missing declaration rather than an
331
+ // object whose only key is "error" pretending to be this repo manifest (Q69).
332
+ const manifest = manifestName === null ? null : (await loadManifest(root)).manifest;
326
333
 
327
334
  const stackList = [...stack].sort();
328
335
  const factList = [...facts].sort();
@@ -339,14 +346,6 @@ export async function fingerprint(root) {
339
346
  };
340
347
  }
341
348
 
342
- async function readManifest(path) {
343
- try {
344
- return JSON.parse(await readFile(path, 'utf8'));
345
- } catch {
346
- return { error: `${basename(path)} unparseable` };
347
- }
348
- }
349
-
350
349
  async function isDir(path) {
351
350
  try {
352
351
  return (await stat(path)).isDirectory();
@@ -364,7 +363,7 @@ async function isDir(path) {
364
363
  * are skipped for the reason the extension sample skips them: detection is not a
365
364
  * scan of vendored output.
366
365
  */
367
- async function findNested(root, pattern) {
366
+ async function listNested(root) {
368
367
  const found = [];
369
368
  const walk = async (dir, prefix, depth) => {
370
369
  let items;
@@ -378,7 +377,7 @@ async function findNested(root, pattern) {
378
377
  const rel = prefix ? `${prefix}/${item.name}` : item.name;
379
378
  if (item.isDirectory()) {
380
379
  if (depth < GLOB_DEPTH) await walk(join(dir, item.name), rel, depth + 1);
381
- } else if (matchesGlob(rel, pattern)) {
380
+ } else {
382
381
  found.push(rel);
383
382
  }
384
383
  }
@@ -387,6 +386,11 @@ async function findNested(root, pattern) {
387
386
  return found.sort();
388
387
  }
389
388
 
389
+ /** The paths in the one walk that match a shape. */
390
+ function findNestedIn(paths, pattern) {
391
+ return paths.filter((rel) => matchesGlob(rel, pattern)).sort();
392
+ }
393
+
390
394
  /**
391
395
  * Which extensions this repository is written in.
392
396
  *
@@ -33,6 +33,7 @@ import { digestOf, promptHash, proposalFrontmatter } from '../distill/engine.mjs
33
33
  const exec = promisify(execFile);
34
34
 
35
35
  import { LOCAL_DIR } from '../state.mjs';
36
+ import { commandRoot } from '../repo-root.mjs';
36
37
 
37
38
  // Locked on-disk beneath machine-local state, like the distilled cache (R38) —
38
39
  // proposals keyed by content digest, never projected and never committed.
@@ -216,7 +217,7 @@ export async function reviewTarget(args, flags, { write = true, commands = [] }
216
217
  if (args.length > 0) {
217
218
  return fail(`unknown review argument ${args[0]}`, EXIT.USAGE, `usage: ${CLI} review [--local]`);
218
219
  }
219
- const root = resolve(flags.root ?? process.cwd());
220
+ const root = resolve(commandRoot(flags));
220
221
  const listed = await tracked(root);
221
222
  if (listed.error) return listed.error;
222
223
 
package/src/toolwords.mjs CHANGED
@@ -23,6 +23,7 @@ import { LESSON_KEYS } from './memory/lessons.mjs';
23
23
  import { DECISION_KEYS } from './memory/decide.mjs';
24
24
  import { PROMPT_WORDS } from './mcp/prompts.mjs';
25
25
  import { AGENT_STEM, SKILL_STEM } from './agents/render.mjs';
26
+ import { CORPUS_KIND } from './bench/corpus.mjs';
26
27
 
27
28
  /**
28
29
  * `simt-` names that belong to this tool and never to a repository.
@@ -50,6 +51,8 @@ export const TOOL_WORDS = new Set([
50
51
  AGENT_STEM,
51
52
  // The companion skill the agent reads on demand; its stem is the directory name.
52
53
  SKILL_STEM,
54
+ // The kind stamp this tool writes into every sealed bench corpus document it exports.
55
+ CORPUS_KIND,
53
56
  ]);
54
57
 
55
58
  /**
package/src/workspace.mjs CHANGED
@@ -38,7 +38,8 @@ export const PACKAGE_ROOT = resolve(HERE, '..');
38
38
  // other LOCKED on-disk names, and a second copy here is a name that can drift from the
39
39
  // list of names that are allowed to move. Imported as well as re-exported, because a
40
40
  // bare `export { X } from` does not bind X here and every use below would fail.
41
- import { CONFIG_DIR } from './name.mjs';
41
+ import { CONFIG_DIR, MANIFEST_FILE } from './name.mjs';
42
+ import { manifestPath, readManifest } from './projection/manifest.mjs';
42
43
  export { CONFIG_DIR };
43
44
  export const CONFIG_FILE = 'config.json';
44
45
 
@@ -133,14 +134,25 @@ async function looksLikeWorkspace(dir) {
133
134
  /**
134
135
  * Resolve the workspace.
135
136
  *
136
- * Precedence, highest first: `--workspace`, `SIMT_DEV_WORKSPACE`, the user config file, then the
137
- * sibling heuristic of this package - and that last one only if it actually contains a git
138
- * repository.
137
+ * Precedence, highest first (ADR-119): `--workspace`, the repository's own manifest
138
+ * declaration, `SIMT_DEV_WORKSPACE` - a value that names a directory is honoured only
139
+ * where the manifest declares a workspace, because the variable is a pointer INTO a
140
+ * workspace the repo opted into, not one imposed on a repo that never declared one;
141
+ * a value that declines is honoured from anywhere, because a decline points at
142
+ * nothing - the manifest's value, the user config file, then the sibling heuristic
143
+ * of this package, which is accepted only if it actually contains a git repository.
139
144
  *
145
+ * A relative manifest value resolves against the manifest's directory, not the
146
+ * process's, so `workspace: ".."` means the same thing from any subdirectory.
147
+ *
148
+ * @param {{repoRoot?: string}} [options] `repoRoot` names the repository whose manifest
149
+ * declares the workspace; it defaults to `process.cwd()`, which is the repository
150
+ * for the way every command is run.
140
151
  * @returns {Promise<{root: string|null, source: string, detail: string, searched: string[], error: string|null}>}
141
152
  */
142
153
  export async function resolveWorkspace(flags = {}, options = {}) {
143
154
  const env = options.env ?? process.env;
155
+ const repoRoot = options.repoRoot ?? process.cwd();
144
156
  const searched = configPaths(options);
145
157
 
146
158
  // The config file is read FIRST, before precedence is applied, and a parse failure
@@ -169,16 +181,49 @@ export async function resolveWorkspace(flags = {}, options = {}) {
169
181
  );
170
182
  }
171
183
 
184
+ // The repository's own declaration (ADR-119). It is read before the environment
185
+ // variable because it is what the variable is allowed to point into: a repo that
186
+ // declared no workspace is a single repo with no workspace, and an environment
187
+ // variable cannot turn it into a member of a workspace nobody reviewed.
188
+ const manifest = await readManifest(repoRoot);
189
+ if (manifest.error) {
190
+ return {
191
+ root: null,
192
+ source: 'repo',
193
+ detail: await manifestPath(repoRoot),
194
+ searched,
195
+ error: manifest.error,
196
+ };
197
+ }
198
+ let repoValue = null;
199
+ if (manifest.exists && typeof manifest.manifest.workspace === 'string' && manifest.manifest.workspace.trim() !== '') {
200
+ const off = disabled(manifest.manifest.workspace, 'repo', `${MANIFEST_FILE} workspace`, searched);
201
+ if (off) return off; // the repo declined; the environment variable cannot enforce one
202
+ repoValue = manifest.manifest.workspace;
203
+ }
204
+
172
205
  if (env.SIMT_DEV_WORKSPACE) {
173
- return (
174
- disabled(env.SIMT_DEV_WORKSPACE, 'env', 'SIMT_DEV_WORKSPACE', searched) ?? {
206
+ const off = disabled(env.SIMT_DEV_WORKSPACE, 'env', 'SIMT_DEV_WORKSPACE', searched);
207
+ if (off) return off; // a decline points at nothing, so it needs no declaration to work
208
+ if (repoValue !== null) {
209
+ return {
175
210
  root: absolute(env.SIMT_DEV_WORKSPACE),
176
211
  source: 'env',
177
212
  detail: 'SIMT_DEV_WORKSPACE',
178
213
  searched,
179
214
  error: null,
180
- }
181
- );
215
+ };
216
+ }
217
+ }
218
+
219
+ if (repoValue !== null) {
220
+ return {
221
+ root: absolute(repoValue, repoRoot),
222
+ source: 'repo',
223
+ detail: `${MANIFEST_FILE} workspace`,
224
+ searched,
225
+ error: null,
226
+ };
182
227
  }
183
228
 
184
229
  if (user.exists && typeof user.config.workspace === 'string' && user.config.workspace.length > 0) {
@@ -245,11 +290,11 @@ export const WORKSPACE_REMEDY = (() => {
245
290
  return `--workspace <path>, the SIMT_DEV_WORKSPACE environment variable, or ${where}`;
246
291
  })();
247
292
 
248
- function absolute(p) {
293
+ function absolute(p, base = process.cwd()) {
249
294
  const expanded = p.startsWith('~/')
250
295
  ? join(homedir(), p.slice(2))
251
296
  : p;
252
- return isAbsolute(expanded) ? expanded : resolve(expanded);
297
+ return isAbsolute(expanded) ? expanded : resolve(base, expanded);
253
298
  }
254
299
 
255
300
  /**