simt-dev 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. 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
  17. 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
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. 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
  22. 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
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
package/src/prbody.mjs ADDED
@@ -0,0 +1,253 @@
1
+ // The `simt pr-body` engine (plan 0003 R55).
2
+ //
3
+ // A pull request body assembled from what the tool already knows, rather than
4
+ // what its author remembers to write: the evidence block from the ledger (R17),
5
+ // the decision records the diff touches, the gate state from the last check,
6
+ // and the residual risk in ADR-008's verdict vocabulary. Read-only throughout:
7
+ // it names the tree, reads the ledger, reads the check cache and diffs names,
8
+ // and writes nothing, so it needs no preview, no `--yes` and no journal entry.
9
+ //
10
+ // The base defaults to the branch's upstream fork point (`origin/main`, then
11
+ // `main`), and `--base` names anything else. When no base resolves, the body
12
+ // falls back to the working-tree diff against HEAD: a report that refused
13
+ // without a base would be useless on a repository with a single branch.
14
+
15
+ import { execFile } from 'node:child_process';
16
+ import { readFile, readdir } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { promisify } from 'node:util';
19
+
20
+ import { readLedger, treeId } from './ledger/index.mjs';
21
+ import { readCheckCache } from './status.mjs';
22
+ import { readManifest } from './projection/manifest.mjs';
23
+ import { splitFrontmatter } from './memory/decide.mjs';
24
+ import { decisionsDir } from './decisions/dir.mjs';
25
+
26
+ const exec = promisify(execFile);
27
+
28
+ /**
29
+ * The ref the body diffs against: the override when given, else the first
30
+ * candidate that shares history with HEAD. Returns the ref, or null when the
31
+ * repository has a single branch and the body should diff the worktree.
32
+ */
33
+ async function resolveBase(root, override = null) {
34
+ if (override !== null && override !== undefined && String(override) !== '') {
35
+ try {
36
+ await exec('git', ['rev-parse', '--verify', '--quiet', `${override}^{commit}`], { cwd: root });
37
+ return { base: String(override), error: null };
38
+ } catch {
39
+ return { base: null, error: `unknown base ${override}` };
40
+ }
41
+ }
42
+ for (const candidate of ['origin/main', 'main', 'origin/master', 'master']) {
43
+ try {
44
+ await exec('git', ['merge-base', 'HEAD', candidate], { cwd: root });
45
+ return { base: candidate, error: null };
46
+ } catch {
47
+ // Not present or shares no history; the next candidate may.
48
+ }
49
+ }
50
+ return { base: null, error: null };
51
+ }
52
+
53
+ /**
54
+ * Every path the pull request would carry: the branch range when there is a
55
+ * base, plus whatever the worktree adds on top. Sorted, deduplicated, and
56
+ * without `.simt/` state: the ledger and the check cache prove the body, they
57
+ * are not its content.
58
+ */
59
+ async function changedPaths(root, base) {
60
+ const paths = new Set();
61
+ const add = (stdout) => {
62
+ for (const line of String(stdout ?? '').split('\n')) {
63
+ const trimmed = line.trim();
64
+ if (trimmed !== '' && !trimmed.startsWith('.simt/')) paths.add(trimmed);
65
+ }
66
+ };
67
+ if (base !== null) {
68
+ try {
69
+ const { stdout } = await exec('git', ['diff', '--name-only', `${base}...HEAD`], {
70
+ cwd: root,
71
+ maxBuffer: 16 << 20,
72
+ });
73
+ add(stdout);
74
+ } catch {
75
+ // An unborn HEAD or an unreadable range contributes nothing; the worktree
76
+ // reads below still answer.
77
+ }
78
+ }
79
+ for (const args of [
80
+ ['diff', '--name-only', 'HEAD'],
81
+ ['ls-files', '--others', '--exclude-standard'],
82
+ ]) {
83
+ try {
84
+ const { stdout } = await exec('git', args, { cwd: root, maxBuffer: 16 << 20 });
85
+ add(stdout);
86
+ } catch {
87
+ // Not a repository, or no commit yet: the body reports the absence.
88
+ }
89
+ }
90
+ return [...paths].sort();
91
+ }
92
+
93
+ /**
94
+ * The decision records the diff touches: one entry per `docs/decisions/*.md`
95
+ * in the changed paths, carrying the id from the filename and the title and
96
+ * status from the file itself. Sorted by id; a file that will not parse is
97
+ * reported with what it has rather than dropping the row.
98
+ */
99
+ async function changedDecisions(root, paths) {
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'));
104
+ try {
105
+ await readdir(absDir);
106
+ } catch {
107
+ return [];
108
+ }
109
+ const out = [];
110
+ for (const name of names.sort()) {
111
+ const id = /^ADR-\d{3,}/i.exec(name.slice(relDir.length + 1))?.[0].toUpperCase() ?? name;
112
+ let title = id;
113
+ let status = 'unknown';
114
+ try {
115
+ const text = await readFile(join(root, name), 'utf8');
116
+ const { body } = splitFrontmatter(text);
117
+ const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
118
+ title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim() || id;
119
+ status = /^Status:\s*(.+)$/im.exec(body)?.[1].trim() ?? 'unknown';
120
+ } catch {
121
+ // An unreadable record is still a changed decision; the body says so
122
+ // rather than silently omitting a file the diff names.
123
+ }
124
+ out.push({ path: name, id, title, status });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Assemble the whole body. Never throws for a missing ledger, cache or base:
131
+ * each absence is a line in the report, because a PR body whose evidence is
132
+ * missing is precisely the case the residual-risk section exists to say out loud.
133
+ */
134
+ export async function buildPrBody(root, { base = null } = {}) {
135
+ const resolved = await resolveBase(root, base);
136
+ if (resolved.error !== null) return { error: resolved.error };
137
+
138
+ let tree = null;
139
+ let treeError = null;
140
+ try {
141
+ tree = await treeId(root);
142
+ } catch (error) {
143
+ treeError = String(error.message ?? error);
144
+ }
145
+
146
+ let entries = [];
147
+ let ledgerErrors = [];
148
+ try {
149
+ const ledger = await readLedger(root);
150
+ entries = tree === null ? [] : ledger.entries.filter((e) => e.tree === tree);
151
+ ledgerErrors = ledger.errors;
152
+ } catch (error) {
153
+ ledgerErrors = [String(error.message ?? error)];
154
+ }
155
+
156
+ const cache = await readCheckCache(root).catch(() => null);
157
+ const { manifest } = await readManifest(root).catch(() => ({ manifest: null }));
158
+ const declared = [
159
+ ...(manifest?.gates?.preCommit ?? []),
160
+ ...(manifest?.gates?.prePush ?? []),
161
+ ];
162
+ const evidenceMode = manifest?.gates?.evidence;
163
+
164
+ const paths = await changedPaths(root, resolved.base);
165
+ const decisions = await changedDecisions(root, paths);
166
+
167
+ const passing = entries.filter((e) => e.exit === 0);
168
+ const unreviewed = [];
169
+ if (tree === null) unreviewed.push(`the tree could not be named (${treeError ?? 'not a git repository'})`);
170
+ else if (passing.length === 0) unreviewed.push('no passing run is recorded for this tree');
171
+ if (cache === null) unreviewed.push('gate state unmeasured (no check has run)');
172
+ for (const error of ledgerErrors) unreviewed.push(`ledger unreadable: ${error}`);
173
+
174
+ // ADR-008's vocabulary, exactly: BLOCK for a known failure, APPROVE WITH
175
+ // RESIDUAL RISK when the review budget ran out before something was read,
176
+ // APPROVE only when everything was. The constraint travels with the verdict:
177
+ // a residual-risk body never converts a known failure into an approval.
178
+ const blocking = cache?.blocking ?? 0;
179
+ const verdict = blocking > 0 ? 'BLOCK' : unreviewed.length > 0 ? 'APPROVE WITH RESIDUAL RISK' : 'APPROVE';
180
+
181
+ return {
182
+ error: null,
183
+ base: resolved.base,
184
+ tree,
185
+ evidence: { entries, errors: ledgerErrors },
186
+ decisions,
187
+ paths,
188
+ gates: {
189
+ declared,
190
+ evidenceMode: evidenceMode ?? null,
191
+ run: cache === null ? null : { gatesRun: cache.gatesRun, gatesTotal: cache.gatesTotal },
192
+ rulesOk: cache?.rulesOk ?? null,
193
+ hooksOk: cache?.hooksOk ?? null,
194
+ blocking,
195
+ warnings: cache?.warnings ?? 0,
196
+ cached: cache !== null,
197
+ },
198
+ verdict,
199
+ unreviewed,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * The markdown body: four sections in a fixed order — evidence, changed
205
+ * decisions, gates, residual risk — so a snapshot can pin it byte for byte.
206
+ */
207
+ export function renderPrBody(report) {
208
+ const lines = [];
209
+ lines.push('## Evidence');
210
+ lines.push('');
211
+ if (report.tree === null) {
212
+ lines.push('No tree recorded: run this inside a git repository.');
213
+ } else if (report.evidence.entries.length === 0) {
214
+ lines.push(`tree ${report.tree}`);
215
+ lines.push('No run is recorded for this tree.');
216
+ } else {
217
+ lines.push(`tree ${report.tree}`);
218
+ lines.push('');
219
+ for (const e of report.evidence.entries) {
220
+ lines.push(`- ${e.exit === 0 ? 'pass' : 'fail'} \`${e.cmd}\` (exit ${e.exit}, ${e.summary})`);
221
+ }
222
+ }
223
+ for (const error of report.evidence.errors) lines.push(`- ledger error: ${error}`);
224
+ lines.push('');
225
+ lines.push('## Changed decisions');
226
+ lines.push('');
227
+ if (report.decisions.length === 0) {
228
+ lines.push(`None${report.base === null ? ' (worktree diff against HEAD)' : ` (range ${report.base}...HEAD, plus worktree)`}.`);
229
+ } else {
230
+ for (const d of report.decisions) lines.push(`- ${d.id} — ${d.title} (${d.status})`);
231
+ }
232
+ lines.push('');
233
+ lines.push('## Gates');
234
+ lines.push('');
235
+ if (report.gates.cached) {
236
+ const g = report.gates;
237
+ lines.push(`- rules: ${g.rulesOk ? 'ok' : 'failing'}, hooks: ${g.hooksOk ? 'ok' : 'failing'}`);
238
+ lines.push(`- gates run: ${g.run.gatesRun}/${g.run.gatesTotal}, blocking: ${g.blocking}, warnings: ${g.warnings}`);
239
+ if (g.declared.length > 0) lines.push(`- declared: ${g.declared.join(', ')}`);
240
+ } else {
241
+ lines.push('No check has run: gate state unmeasured.');
242
+ }
243
+ lines.push('');
244
+ lines.push('## Residual risk (ADR-008)');
245
+ lines.push('');
246
+ lines.push(`Verdict: ${report.verdict}`);
247
+ if (report.unreviewed.length > 0) {
248
+ lines.push('');
249
+ lines.push('Not reviewed:');
250
+ for (const item of report.unreviewed) lines.push(`- ${item}`);
251
+ }
252
+ return `${lines.join('\n')}\n`;
253
+ }
@@ -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,11 +13,10 @@ 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
-
21
20
  /** Manifest keys that change what the region contains. Anything else in the file is
22
21
  * the repository's own notes, and hashing it would make a comment fail the lock. */
23
22
  const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
@@ -28,12 +27,15 @@ const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
28
27
  * @param {string} root
29
28
  * @param {object} [flags] the same flags `sync` rendered with, so the output is the
30
29
  * body that was written and not a body for a different set of surfaces
30
+ * @param {object} [manifest] the manifest to digest, in place of the one on disk. A
31
+ * writer that is about to write a manifest (a fresh `init`) passes it here so the
32
+ * lock records the state the repo will have, not the state it has (Q29).
31
33
  */
32
- export async function computeLock(root, flags = {}) {
34
+ export async function computeLock(root, flags = {}, manifest = undefined) {
33
35
  const variants =
34
36
  flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
35
37
  const desired = await desiredRegion(root, { ...flags, variants });
36
- const inputs = await inputDigest(root, variants);
38
+ const inputs = await inputDigest(root, variants, manifest);
37
39
  const outputs = createHash('sha256').update(desired.body).digest('hex');
38
40
  const hasVariants =
39
41
  variants && typeof variants === 'object' && Object.keys(variants).length > 0;
@@ -41,7 +43,7 @@ export async function computeLock(root, flags = {}) {
41
43
  }
42
44
 
43
45
  /**
44
- * Record locked variant winners in `simt-dev.lock.json` alongside updated digests.
46
+ * Record locked variant winners in `simt.lock.json` alongside updated digests.
45
47
  */
46
48
  export async function writeLockVariants(root, nextVariants, flags = {}) {
47
49
  const existing = await readLockedVariants(root);
@@ -53,15 +55,20 @@ export async function writeLockVariants(root, nextVariants, flags = {}) {
53
55
 
54
56
  /** Read a lock, or null when the file is not there. A file that does not parse is an
55
57
  * error the caller reports, not a missing lock: absence and damage are different
56
- * repairs. */
58
+ * repairs. A lock under the legacy name is a lock (ADR-110): it is read until the
59
+ * next write migrates it, and a permission failure on the new name is not an excuse
60
+ * to fall back to an older file. */
57
61
  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;
62
+ let text = null;
63
+ for (const name of [LOCK_FILE, LOCK_LEGACY]) {
64
+ try {
65
+ text = await readFile(join(root, name), 'utf8');
66
+ break;
67
+ } catch (error) {
68
+ if (error.code !== 'ENOENT') throw error;
69
+ }
64
70
  }
71
+ if (text === null) return { lock: null, error: null };
65
72
  try {
66
73
  const lock = JSON.parse(text);
67
74
  if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
@@ -79,13 +86,13 @@ export async function readLock(root) {
79
86
  }
80
87
  }
81
88
 
82
- async function inputDigest(root, variants = {}) {
89
+ async function inputDigest(root, variants = {}, manifest = undefined) {
83
90
  const hash = createHash('sha256');
84
91
  hash.update(`provenance:${await provenanceDigest()}\n`);
85
92
  const { stack, facts } = await fingerprint(root);
86
93
  hash.update(`signals:${JSON.stringify({ stack, facts })}\n`);
87
- const { manifest } = await readManifest(root);
88
94
  const owned = {};
95
+ if (manifest === undefined) manifest = (await readManifest(root)).manifest;
89
96
  if (manifest && typeof manifest === 'object') {
90
97
  for (const key of MANIFEST_KEYS) {
91
98
  if (manifest[key] !== undefined) owned[key] = manifest[key];