simt-dev 0.6.3 → 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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  13. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  18. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. 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
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -20,10 +20,11 @@
20
20
  // the file was read. Status and dependency checks never touch it: a 1 MB
21
21
  // section that belongs to a later slice is not copied into the index.
22
22
 
23
- import { readdir, readFile, writeFile } from 'node:fs/promises';
23
+ import { readdir, readFile } from 'node:fs/promises';
24
24
  import { join } from 'node:path';
25
25
 
26
26
  import { CLI } from '../name.mjs';
27
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
27
28
  import { loadFormat } from './template.mjs';
28
29
 
29
30
  function clean(cell) {
@@ -100,10 +101,16 @@ function sliceKey(path, id) {
100
101
  * empty cell is the pipe that closes it. A further empty cell is real: the
101
102
  * evidence column is often blank, and stripping every trailing empty would
102
103
  * delete it and shift the state into the wrong column.
104
+ *
105
+ * A pipe inside a code span is content, not a separator: an evidence cell may
106
+ * carry a command line with a pipe in it, and splitting there shifts every
107
+ * later cell left by one (review 0003 Q25). Backticks toggle the span; a
108
+ * `\|` escape is content in either mode.
103
109
  */
104
110
  function splitRow(line) {
105
111
  const cells = [];
106
112
  let current = '';
113
+ let inCode = false;
107
114
  for (let i = 0; i < line.length; i += 1) {
108
115
  const char = line[i];
109
116
  if (char === '\\' && i + 1 < line.length) {
@@ -111,7 +118,12 @@ function splitRow(line) {
111
118
  i += 1;
112
119
  continue;
113
120
  }
114
- if (char === '|') {
121
+ if (char === '`') {
122
+ inCode = !inCode;
123
+ current += char;
124
+ continue;
125
+ }
126
+ if (char === '|' && !inCode) {
115
127
  cells.push(current);
116
128
  current = '';
117
129
  continue;
@@ -596,9 +608,16 @@ export async function planDone(root, id, { sha = null, test = null, state = null
596
608
  return { root, id, error: `${id} is no longer on line ${slice.line} of ${slice.docName}`, remedy: `run: ${CLI} plan status` };
597
609
  }
598
610
 
599
- const cells = splitRow(found.lines[found.index]);
600
- const width = Math.max(slice.columnCount, slice.stateIdx + 1, slice.evidenceIdx + 1, cells.length);
601
- while (cells.length < width) cells.push('');
611
+ const original = found.lines[found.index];
612
+ const cells = splitRow(original);
613
+ if (cells.length !== slice.columnCount) {
614
+ return {
615
+ root,
616
+ id,
617
+ error: `the row for ${id} has ${cells.length} cells; the table declares ${slice.columnCount}`,
618
+ remedy: `fix the row in ${slice.docName} — an unescaped pipe splits it — and retry`,
619
+ };
620
+ }
602
621
  const from = cells[slice.stateIdx] ?? '';
603
622
  cells[slice.stateIdx] = replaceState(from, display);
604
623
 
@@ -613,14 +632,18 @@ export async function planDone(root, id, { sha = null, test = null, state = null
613
632
  evidenceNote = 'no evidence column in this tracker; the sha was not recorded';
614
633
  }
615
634
 
616
- const rebuilt = `| ${cells.join(' | ')} |`;
617
- const original = found.lines[found.index];
635
+ // The rebuilt line keeps the original's ending: a CRLF file must not gain
636
+ // one LF-terminated line among its CRLF lines (review 0003 Q25).
637
+ const eol = original.endsWith('\r') ? '\r' : '';
638
+ const rebuilt = `| ${cells.join(' | ')} |${eol}`;
618
639
  const changedCells = splitRow(original).join('\0') !== cells.join('\0');
619
640
  const newLine = changedCells ? rebuilt : original;
620
641
  const wouldChange = newLine !== original;
621
642
  if (wouldChange && !dryRun) {
622
643
  found.lines[found.index] = newLine;
623
- await writeFile(slice.docPath, found.lines.join('\n'), 'utf8');
644
+ const journal = [];
645
+ await journaledWriteFile(root, journal, slice.docPath, found.lines.join('\n'));
646
+ await commitJournal(root, 'plan', journal);
624
647
  }
625
648
 
626
649
  const apply = `run: ${CLI} plan done ${id}${sha ? ` ${sha}` : ''}${display === format.doneWrite ? '' : ` --state ${JSON.stringify(display)}`}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
@@ -23,7 +23,7 @@ const KNOWN = new Set(['$comment', 'name', 'directories', 'id', 'columns', 'stat
23
23
  const ID_KEYS = new Set(['pattern', 'headers']);
24
24
  const COLUMN_KEYS = new Set(['state', 'depends', 'title', 'evidence']);
25
25
  const DIRECTORY_KEYS = new Set(['kind', 'word', 'path']);
26
- const STATE_KEYS = new Set(['id', 'write', 'read', 'satisfies']);
26
+ const STATE_KEYS = new Set(['id', 'write', 'read', 'satisfies', 'role']);
27
27
  const DEPENDENCY_KEYS = new Set(['empty', 'separators', 'notes', 'group']);
28
28
  const WAVE_KEYS = new Set(['label', 'members']);
29
29
  const SECTION_KEYS = new Set(['depth']);
@@ -148,6 +148,7 @@ function compileStates(source, value) {
148
148
  const write = new Map();
149
149
  const known = new Set();
150
150
  const satisfies = new Set();
151
+ const roles = new Map();
151
152
  const order = [];
152
153
  const stateNames = [];
153
154
  for (let i = 0; i < value.length; i += 1) {
@@ -162,6 +163,13 @@ function compileStates(source, value) {
162
163
  }
163
164
  if (!Array.isArray(entry.read) || entry.read.length === 0) return bad(source, `${where}.read must list the words that mean this state`);
164
165
  if (known.has(entry.id)) return bad(source, `state ${entry.id} is listed twice`);
166
+ if (entry.role !== undefined && (typeof entry.role !== 'string' || !/^[a-z][a-z0-9-]*$/.test(entry.role))) {
167
+ return bad(source, `${where}.role must be a lower-case word`);
168
+ }
169
+ if (typeof entry.role === 'string') {
170
+ if (roles.has(entry.role)) return bad(source, `role ${entry.role} is claimed twice`);
171
+ roles.set(entry.role, entry.write.trim());
172
+ }
165
173
  const display = entry.write.trim();
166
174
  const aliases = new Set([entry.id, display.toLowerCase()]);
167
175
  for (const alias of entry.read) {
@@ -182,7 +190,7 @@ function compileStates(source, value) {
182
190
  else if (entry.satisfies != null && entry.satisfies !== false) return bad(source, `${where}.satisfies must be true or false`);
183
191
  }
184
192
  if (satisfies.size === 0) return bad(source, 'one state must have satisfies true, or nothing can ever be ready');
185
- return { read, write, known, satisfies, order, stateNames };
193
+ return { read, write, known, satisfies, roles, order, stateNames };
186
194
  }
187
195
 
188
196
  function compile(body, source, root) {
@@ -262,7 +270,7 @@ function compile(body, source, root) {
262
270
 
263
271
  const byWord = new Map(directories.directories.map((dir) => [dir.word, dir.kind]));
264
272
  const doneWrite = body.states.find((entry) => entry.satisfies === true).write.trim();
265
- const { read, write } = states;
273
+ const { read, write, roles } = states;
266
274
 
267
275
  return {
268
276
  format: {
@@ -273,6 +281,12 @@ function compile(body, source, root) {
273
281
  order: states.order,
274
282
  ready,
275
283
  doneWrite,
284
+ // The display a command moves a row to when work on it starts: the state
285
+ // carrying the `started` role, or null when no state claims it (review
286
+ // 0003 Q23). A null here is not an error — the caller falls back to the
287
+ // in-progress spelling it can read, and `plan done` fails closed on a
288
+ // word the template never defined.
289
+ startedWrite: roles.get('started') ?? null,
276
290
  stateNames: states.stateNames,
277
291
  satisfies: states.satisfies,
278
292
  known: states.known,
@@ -292,9 +306,17 @@ function compile(body, source, root) {
292
306
  headingRe: new RegExp(`^(#{1,${depth}})\\s+(.*\\S)\\s*$`),
293
307
  readState(raw) {
294
308
  const plain = String(raw ?? '').replace(/\*\*/g, '').trim().toLowerCase();
309
+ if (plain === '') return 'unknown';
295
310
  if (read.has(plain)) return read.get(plain);
296
- const token = plain.split(/[\s(]/)[0];
297
- return read.get(token) ?? (token || 'unknown');
311
+ // A cell may carry a note after the state — "In progress (since Monday)".
312
+ // The state is the longest alias that prefixes the text: "in progress"
313
+ // must win over a shorter alias it starts, and a word no state claims
314
+ // is unknown, not a state (review 0003 Q25).
315
+ let best = '';
316
+ for (const alias of read.keys()) {
317
+ if (alias.length > best.length && plain.startsWith(alias)) best = alias;
318
+ }
319
+ return best !== '' ? read.get(best) : 'unknown';
298
320
  },
299
321
  canonical(input) {
300
322
  return write.get(String(input ?? '').trim().toLowerCase()) ?? null;
package/src/plugins.mjs CHANGED
@@ -32,7 +32,7 @@ import { desiredRegion } from './projection/rules.mjs';
32
32
  import { PACKAGE_NAME, BIN_REL } from './name.mjs';
33
33
 
34
34
  /** The kinds `simt plugin` builds, in the order the help line names them. */
35
- export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor'];
35
+ export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor', 'opencode'];
36
36
 
37
37
  /**
38
38
  * The package this code ships in: the runtime a generated package copies. Resolved
@@ -71,7 +71,11 @@ function rootFlag(kind) {
71
71
 
72
72
  /** Where the hooks document lands inside a package, relative to the package root. */
73
73
  function hooksPath(kind) {
74
- return kind === 'cursor' ? join('.cursor', 'hooks.json') : join('hooks', 'hooks.json');
74
+ if (kind === 'cursor') return join('.cursor', 'hooks.json');
75
+ // OpenCode loads a local plugin directory's root `server` file as the plugin, so the
76
+ // generated document is that file at the root rather than a hooks config to merge.
77
+ if (kind === 'opencode') return 'server.js';
78
+ return join('hooks', 'hooks.json');
75
79
  }
76
80
 
77
81
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
package/src/prbody.mjs CHANGED
@@ -21,11 +21,10 @@ import { readLedger, treeId } from './ledger/index.mjs';
21
21
  import { readCheckCache } from './status.mjs';
22
22
  import { readManifest } from './projection/manifest.mjs';
23
23
  import { splitFrontmatter } from './memory/decide.mjs';
24
+ import { decisionsDir } from './decisions/dir.mjs';
24
25
 
25
26
  const exec = promisify(execFile);
26
27
 
27
- const DECISIONS_DIR = join('docs', 'decisions');
28
-
29
28
  /**
30
29
  * The ref the body diffs against: the override when given, else the first
31
30
  * candidate that shares history with HEAD. Returns the ref, or null when the
@@ -98,15 +97,18 @@ async function changedPaths(root, base) {
98
97
  * reported with what it has rather than dropping the row.
99
98
  */
100
99
  async function changedDecisions(root, paths) {
101
- const names = paths.filter((p) => p.startsWith(`${DECISIONS_DIR}/`) && p.toLowerCase().endsWith('.md'));
100
+ // Q55: the prefix is the repository's own decisions directory, not a literal.
101
+ const relDir = await decisionsDir(root);
102
+ const absDir = join(root, relDir);
103
+ const names = paths.filter((p) => p.startsWith(`${relDir}/`) && p.toLowerCase().endsWith('.md'));
102
104
  try {
103
- await readdir(join(root, DECISIONS_DIR));
105
+ await readdir(absDir);
104
106
  } catch {
105
107
  return [];
106
108
  }
107
109
  const out = [];
108
110
  for (const name of names.sort()) {
109
- const id = /^ADR-\d{3,}/i.exec(name.slice(DECISIONS_DIR.length + 1))?.[0].toUpperCase() ?? name;
111
+ const id = /^ADR-\d{3,}/i.exec(name.slice(relDir.length + 1))?.[0].toUpperCase() ?? name;
110
112
  let title = id;
111
113
  let status = 'unknown';
112
114
  try {
@@ -76,7 +76,7 @@ trim_trailing_whitespace = false
76
76
  indent_style = space
77
77
  indent_size = 2
78
78
 
79
- [*.{py,pyi,java,kt,kts}]
79
+ [*.{py,pyi,java,kt,kts,rs}]
80
80
  indent_size = 4
81
81
 
82
82
  [{Makefile,*.mk}]
@@ -25,7 +25,7 @@ const SKILL_DIRS = ['.agents/skills', '.cursor/skills', '.claude/skills', '.open
25
25
  * share a name both stay, because dropping one would hide a trigger.
26
26
  *
27
27
  * @param {string} root
28
- * @returns {Promise<{name: string, trigger: string, dir: string}[]>}
28
+ * @returns {Promise<{name: string, trigger: string, dir: string, file: string}[]>}
29
29
  */
30
30
  export async function installedSkills(root) {
31
31
  const seen = new Set();
@@ -61,7 +61,7 @@ export async function installedSkills(root) {
61
61
  const fallback = entry.isDirectory() ? entry.name : entry.name.replace(/\.md$/, '');
62
62
  const name = typeof meta.name === 'string' && meta.name !== '' ? meta.name : fallback;
63
63
  const trigger = typeof meta.description === 'string' ? meta.description.replace(/\s+/g, ' ').trim() : '';
64
- skills.push({ name, trigger, dir });
64
+ skills.push({ name, trigger, dir, file: key });
65
65
  }
66
66
  }
67
67
  skills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
@@ -146,10 +146,16 @@ export const LOADERS = [
146
146
  nested: 'cumulative up to the project root at session start; files below the workspace load when that area is read',
147
147
  skills: ['.agents/skills', '.opencode/skills', '.claude/skills'],
148
148
  hooks: {
149
- path: '.opencode/plugins/',
149
+ path: '.opencode/plugins/simt-dev.js',
150
150
  verified:
151
151
  'OpenCode v2 plugins docs: direct .ts and .js files and immediate plugin packages are loaded from every discovered .opencode/plugins/ directory, and the plugins array in opencode.json(c)',
152
152
  },
153
+ agents: {
154
+ dir: '.opencode/agents',
155
+ ext: '.md',
156
+ verified:
157
+ 'OpenCode v2 agents docs (opencode.ai/v2/docs/agents, read 2026-10-30): subagents are markdown files with a description frontmatter under .opencode/agents/',
158
+ },
153
159
  mcp: {
154
160
  path: 'opencode.json',
155
161
  key: 'mcp.servers',
@@ -242,6 +248,12 @@ export const LOADERS = [
242
248
  verified:
243
249
  'Claude Code hooks docs, hook locations: project settings at .claude/settings.json and .claude/settings.local.json, user settings at ~/.claude/settings.json',
244
250
  },
251
+ agents: {
252
+ dir: '.claude/agents',
253
+ ext: '.md',
254
+ verified:
255
+ 'Claude Code subagents docs (code.claude.com/docs/en/sub-agents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .claude/agents/',
256
+ },
245
257
  mcp: {
246
258
  path: '.mcp.json',
247
259
  key: null,
@@ -320,6 +332,12 @@ export const LOADERS = [
320
332
  verified:
321
333
  'Codex hooks docs: <repo>/.codex/hooks.json or ~/.codex/hooks.json, or inline [hooks] tables in config.toml',
322
334
  },
335
+ agents: {
336
+ dir: '.codex/agents',
337
+ ext: '.toml',
338
+ verified:
339
+ 'Codex custom agents docs (developers.openai.com/codex/subagents, read 2026-10-30): standalone TOML files under .codex/agents/ defining name, description and developer_instructions',
340
+ },
323
341
  mcp: {
324
342
  path: '.codex/config.toml',
325
343
  key: 'mcp_servers',
@@ -386,6 +404,12 @@ export const LOADERS = [
386
404
  path: '.cursor/hooks.json',
387
405
  verified: 'Cursor agent hooks docs: <project-root>/.cursor/hooks.json and ~/.cursor/hooks.json',
388
406
  },
407
+ agents: {
408
+ dir: '.cursor/agents',
409
+ ext: '.md',
410
+ verified:
411
+ 'Cursor subagents docs (cursor.com/docs/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .cursor/agents/',
412
+ },
389
413
  mcp: {
390
414
  path: '.cursor/mcp.json',
391
415
  key: null,
@@ -470,6 +494,12 @@ export const LOADERS = [
470
494
  path: '.agents/hooks.json',
471
495
  verified: 'Antigravity hooks docs: workspace level .agents/hooks.json, global ~/.gemini/config/hooks.json',
472
496
  },
497
+ agents: {
498
+ dir: '.agents/agents',
499
+ ext: '.md',
500
+ verified:
501
+ 'Google Antigravity subagents docs (antigravity.google/docs/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .agents/agents/',
502
+ },
473
503
  mcp: {
474
504
  path: '.agents/mcp_config.json',
475
505
  key: null,
@@ -541,6 +571,12 @@ export const LOADERS = [
541
571
  verified:
542
572
  'Gemini CLI hooks reference (geminicli.com/docs/hooks/reference/, read 2026-10-26): a project .gemini/settings.json carries a top-level hooks object whose events are arrays of {matcher, hooks:[{type:"command",command,timeout? (milliseconds)}]} — the same shape Claude and Codex use; BeforeTool denies with {decision:"deny",reason}, BeforeAgent and AfterTool inject via hookSpecificOutput.additionalContext',
543
573
  },
574
+ agents: {
575
+ dir: '.gemini/agents',
576
+ ext: '.md',
577
+ verified:
578
+ 'Gemini CLI subagents docs (geminicli.com/docs/core/subagents, read 2026-10-30): project subagents are markdown files with name and description frontmatter under .gemini/agents/',
579
+ },
544
580
  mcp: {
545
581
  path: '.gemini/settings.json',
546
582
  key: 'mcpServers',
@@ -603,6 +639,8 @@ export const LOADERS = [
603
639
  skillsNote: 'no project skill directory is documented for Copilot',
604
640
  hooks: null,
605
641
  hooksNote: 'no hooks file is documented for Copilot in any first-party page checked on 2026-10-14',
642
+ agents: null,
643
+ agentsNote: 'no file-based agents are documented for Copilot in any first-party page checked on 2026-10-30',
606
644
  mcp: {
607
645
  path: '.vscode/mcp.json',
608
646
  key: null,
@@ -707,6 +745,8 @@ export const LOADERS = [
707
745
  hooks: null,
708
746
  hooksNote:
709
747
  'DeepSeek harness hooks docs: the Claude Code and Codex bridges take an explicit configPath, and project-local hooks.json discovery is still marked TODO, so no repository path is loaded by default',
748
+ agents: null,
749
+ agentsNote: 'no file-based agents are documented for the DeepSeek harness in any first-party page checked on 2026-10-30',
710
750
  mcp: {
711
751
  path: null,
712
752
  key: '@deepseek-ai/dsh-mcp-client',
@@ -13,10 +13,14 @@ import { join } from 'node:path';
13
13
 
14
14
  import { readLockedVariants } from '../kernel/variants.mjs';
15
15
  import { fingerprint } from '../resolve/fingerprint.mjs';
16
+ import { LOCK_FILE, LOCK_LEGACY } from '../name.mjs';
16
17
  import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
17
18
  import { readManifest } from './manifest.mjs';
18
19
 
19
- export const LOCK_FILE = 'simt-dev.lock.json';
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;
20
24
 
21
25
  /** Manifest keys that change what the region contains. Anything else in the file is
22
26
  * the repository's own notes, and hashing it would make a comment fail the lock. */
@@ -28,20 +32,23 @@ const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
28
32
  * @param {string} root
29
33
  * @param {object} [flags] the same flags `sync` rendered with, so the output is the
30
34
  * body that was written and not a body for a different set of surfaces
35
+ * @param {object} [manifest] the manifest to digest, in place of the one on disk. A
36
+ * writer that is about to write a manifest (a fresh `init`) passes it here so the
37
+ * lock records the state the repo will have, not the state it has (Q29).
31
38
  */
32
- export async function computeLock(root, flags = {}) {
39
+ export async function computeLock(root, flags = {}, manifest = undefined) {
33
40
  const variants =
34
41
  flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
35
42
  const desired = await desiredRegion(root, { ...flags, variants });
36
- const inputs = await inputDigest(root, variants);
43
+ const inputs = await inputDigest(root, variants, manifest);
37
44
  const outputs = createHash('sha256').update(desired.body).digest('hex');
38
45
  const hasVariants =
39
46
  variants && typeof variants === 'object' && Object.keys(variants).length > 0;
40
- return hasVariants ? { inputs, outputs, variants } : { inputs, outputs };
47
+ return hasVariants ? { version: LOCK_VERSION, inputs, outputs, variants } : { version: LOCK_VERSION, inputs, outputs };
41
48
  }
42
49
 
43
50
  /**
44
- * Record locked variant winners in `simt-dev.lock.json` alongside updated digests.
51
+ * Record locked variant winners in `simt.lock.json` alongside updated digests.
45
52
  */
46
53
  export async function writeLockVariants(root, nextVariants, flags = {}) {
47
54
  const existing = await readLockedVariants(root);
@@ -53,17 +60,27 @@ export async function writeLockVariants(root, nextVariants, flags = {}) {
53
60
 
54
61
  /** Read a lock, or null when the file is not there. A file that does not parse is an
55
62
  * error the caller reports, not a missing lock: absence and damage are different
56
- * repairs. */
63
+ * repairs. A lock under the legacy name is a lock (ADR-110): it is read until the
64
+ * next write migrates it, and a permission failure on the new name is not an excuse
65
+ * to fall back to an older file. */
57
66
  export async function readLock(root) {
58
- let text;
59
- try {
60
- text = await readFile(join(root, LOCK_FILE), 'utf8');
61
- } catch (error) {
62
- if (error.code === 'ENOENT') return { lock: null, error: null };
63
- throw error;
67
+ let text = null;
68
+ for (const name of [LOCK_FILE, LOCK_LEGACY]) {
69
+ try {
70
+ text = await readFile(join(root, name), 'utf8');
71
+ break;
72
+ } catch (error) {
73
+ if (error.code !== 'ENOENT') throw error;
74
+ }
64
75
  }
76
+ if (text === null) return { lock: null, error: null };
65
77
  try {
66
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
+ }
67
84
  if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
68
85
  return { lock: null, error: 'must be an object with inputs and outputs' };
69
86
  }
@@ -79,13 +96,13 @@ export async function readLock(root) {
79
96
  }
80
97
  }
81
98
 
82
- async function inputDigest(root, variants = {}) {
99
+ async function inputDigest(root, variants = {}, manifest = undefined) {
83
100
  const hash = createHash('sha256');
84
101
  hash.update(`provenance:${await provenanceDigest()}\n`);
85
102
  const { stack, facts } = await fingerprint(root);
86
103
  hash.update(`signals:${JSON.stringify({ stack, facts })}\n`);
87
- const { manifest } = await readManifest(root);
88
104
  const owned = {};
105
+ if (manifest === undefined) manifest = (await readManifest(root)).manifest;
89
106
  if (manifest && typeof manifest === 'object') {
90
107
  for (const key of MANIFEST_KEYS) {
91
108
  if (manifest[key] !== undefined) owned[key] = manifest[key];