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
@@ -0,0 +1,205 @@
1
+ // simt agents install | simt agents status
2
+ //
3
+ // Two commands, one subject: the one agent this tool ships, written into each
4
+ // harness's own agents directory. `install` writes the file, and `status`
5
+ // reports what each harness carries and what still has to be installed.
6
+ //
7
+ // An agent is a file the tool owns whole — markdown with a frontmatter the
8
+ // harness documents, or a TOML file for Codex — not a config entry to merge.
9
+ // So the install is a byte comparison: created, unchanged, updated. There is no
10
+ // parse that can fail and no partial state, which is what makes this command
11
+ // shorter than `agent-hooks install`.
12
+
13
+ import { readFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+
16
+ import { commitJournal } from '../journal.mjs';
17
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
18
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
19
+ import { CLI } from '../name.mjs';
20
+ import {
21
+ adapter,
22
+ adapters,
23
+ agentPath,
24
+ agentRows,
25
+ loadAgent,
26
+ planAgents,
27
+ rendered,
28
+ writeAgentPlan,
29
+ } from '../agents/render.mjs';
30
+ import { commandRoot } from '../repo-root.mjs';
31
+
32
+ export async function agentsCommand(sub, args, flags) {
33
+ const root = commandRoot(flags, args);
34
+ if (sub === 'install') return installAgents(root, flags);
35
+ if (sub === 'status') return agentsStatus(root, flags);
36
+ return fail(`unknown agents subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agents status`);
37
+ }
38
+
39
+ // The label for a reader the run did not write, by the state that names why:
40
+ // `none` documents no agents file, `other` is a reader this tool does not write,
41
+ // `skipped` was an adapter the caller did not ask for (ADR-107).
42
+ const OTHER_LABEL = { none: 'NO FILE ', other: 'NOT OURS', skipped: 'SKIPPED ' };
43
+
44
+ async function installAgents(root, flags) {
45
+ const mode = writeMode(flags);
46
+ // `--harness` narrows the install to one reader (ADR-107). The id is validated
47
+ // against the same table the loop reads, so a typo fails before anything is
48
+ // planned; the remedy names the ids the way `agent-hook` does.
49
+ const selected = flags.harness;
50
+ if (selected !== undefined && adapter(selected) === null) {
51
+ return fail(
52
+ `unknown harness \`${selected}\``,
53
+ EXIT.USAGE,
54
+ `--harness <id>, one of: ${adapters().map((a) => a.id).join(', ')}`,
55
+ );
56
+ }
57
+
58
+ const plan = await planAgents(root, { harness: selected });
59
+
60
+ // The readers this tool does not write a file for, reported rather than skipped in
61
+ // silence: a command that writes six files and says nothing about the two harnesses
62
+ // that document no agents file reads as "those are covered too". A narrowed install
63
+ // adds a third state: an adapter that is this tool's but was not requested (ADR-107).
64
+ const others = [];
65
+ for (const r of agentRows()) {
66
+ if (r.path === null) others.push({ id: r.id, path: null, state: 'none', note: r.note });
67
+ else if (adapter(r.id) === null) others.push({ id: r.id, path: r.path, state: 'other', note: r.note });
68
+ else if (selected !== undefined && r.id !== selected) {
69
+ others.push({ id: r.id, path: r.path, state: 'skipped', note: 'not requested; install without --harness to write every harness' });
70
+ }
71
+ }
72
+
73
+ // The `--diff` pairs, read before anything is written so the before side is
74
+ // true in preview and in apply alike (R50).
75
+ const diffs =
76
+ flags.diff === true
77
+ ? await Promise.all(
78
+ plan
79
+ .filter((p) => p.action !== 'unchanged')
80
+ .map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text })),
81
+ )
82
+ : [];
83
+
84
+ if (!mode.apply) {
85
+ if (isJson(flags)) {
86
+ emitJson({
87
+ ok: true,
88
+ command: 'agents install',
89
+ root,
90
+ dryRun: true,
91
+ plan: plan.map(({ text, ...rest }) => rest),
92
+ notWritten: others,
93
+ filesWritten: false,
94
+ ...diffField(diffs, flags),
95
+ });
96
+ return EXIT.OK;
97
+ }
98
+ emit(
99
+ [
100
+ `repo ${root}`,
101
+ ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
102
+ ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no agents file is documented)'}`),
103
+ '',
104
+ `PLAN nothing written; apply with: ${CLI} agents install --yes`,
105
+ ].join('\n'),
106
+ );
107
+ // After the report: the report says what, the diff shows it (R50).
108
+ emitDiffs(diffs, flags);
109
+ return EXIT.OK;
110
+ }
111
+
112
+ // Journaled so `undo` restores this run (R49).
113
+ const journal = [];
114
+ const written = await writeAgentPlan(root, journal, plan);
115
+ await commitJournal(root, 'agents install', journal);
116
+
117
+ if (isJson(flags)) {
118
+ emitJson({
119
+ ok: true,
120
+ command: 'agents install',
121
+ root,
122
+ plan: plan.map(({ text, ...rest }) => rest),
123
+ notWritten: others,
124
+ written,
125
+ filesWritten: true,
126
+ ...diffField(diffs, flags),
127
+ });
128
+ return EXIT.OK;
129
+ }
130
+ emit(
131
+ [
132
+ `repo ${root}`,
133
+ ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
134
+ ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no agents file is documented)'}`),
135
+ '',
136
+ `WROTE ${written.length} agent file(s)`,
137
+ ].join('\n'),
138
+ );
139
+ // After the report: the report says what, the diff shows it (R50).
140
+ emitDiffs(diffs, flags);
141
+ for (const o of others) note(`note ${o.id}: not written - ${o.note}`);
142
+ if (written.length === 0) note('note every documented agents file already carried this agent');
143
+ note(`note never committed or pushed (ADR-001); review, then commit yourself`);
144
+ return EXIT.OK;
145
+ }
146
+
147
+ async function agentsStatus(root, flags) {
148
+ const agent = await loadAgent();
149
+ const rows = [];
150
+ const problems = [];
151
+
152
+ // The file this tool owns whole is compared against what `install` would write
153
+ // right now, so status reads the same bytes install reads: a file that matches
154
+ // the preview is present, anything else is drift an install will rewrite.
155
+ for (const row of adapters()) {
156
+ const path = agentPath(row.id);
157
+ const existing = await readIfExists(join(root, path));
158
+ if (existing === null) {
159
+ rows.push({ id: row.id, path, state: 'absent', note: null });
160
+ continue;
161
+ }
162
+ const present = rendered(row, agent, existing).action === 'unchanged';
163
+ rows.push({
164
+ id: row.id,
165
+ path,
166
+ state: present ? 'present' : 'drift',
167
+ note: present ? null : 'differs from what install would write; run install to restore it',
168
+ });
169
+ }
170
+
171
+ // The readers whose agents are not a file this tool can write — a vendor page
172
+ // that documents none — are reported from the same table that documents them,
173
+ // rather than from a second list of the harnesses this command happens to know.
174
+ for (const r of agentRows()) {
175
+ if (r.path === null) rows.push({ id: r.id, path: null, state: 'none', note: r.note });
176
+ else if (adapter(r.id) === null) rows.push({ id: r.id, path: r.path, state: 'other', note: r.note });
177
+ }
178
+
179
+ if (isJson(flags)) {
180
+ emitJson({ ok: problems.length === 0, command: 'agents status', root, harnesses: rows, problems });
181
+ return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
182
+ }
183
+
184
+ const lines = [`repo ${root}`];
185
+ for (const r of rows) {
186
+ const state = { present: 'PRESENT ', absent: 'ABSENT ', drift: 'DRIFT ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
187
+ lines.push(`${state} ${r.id.padEnd(12)} ${r.path ?? '(no agents file is documented)'}`);
188
+ if (r.state === 'absent') lines.push(` ${' '.repeat(12)} install: ${CLI} agents install --yes`);
189
+ if (r.note) lines.push(` ${' '.repeat(12)} ${r.note}`);
190
+ }
191
+ if (problems.length > 0) {
192
+ lines.push('', 'problems:');
193
+ for (const p of problems) lines.push(` ${p}`);
194
+ }
195
+ emit(lines.join('\n'));
196
+ return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
197
+ }
198
+
199
+ async function readIfExists(path) {
200
+ try {
201
+ return await readFile(path, 'utf8');
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
@@ -22,15 +22,18 @@
22
22
  // tool call. Failures of this command exit 1, which a harness reports as a failed hook,
23
23
  // and that is the right blast radius for a hook that is broken rather than refusing.
24
24
 
25
+ import { existsSync } from 'node:fs';
25
26
  import { mkdir, readFile } from 'node:fs/promises';
26
- import { dirname, join } from 'node:path';
27
+ import { dirname, isAbsolute, join } from 'node:path';
27
28
  import { commitJournal, journaledWriteFile } from '../journal.mjs';
29
+ import { AGENT_HOOK_SHIM, renderAgentHookShim } from '../agenthooks/shim.mjs';
28
30
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
29
31
 
30
32
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
31
- import { CLI } from '../name.mjs';
32
- import { ALLOW_ENV, EVENTS, POLICY_IDS } from '../agenthooks/policy.mjs';
33
+ import { CLI, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
34
+ import { ALLOW_ENV, EVENTS, POLICY_IDS, SESSION_ENV } from '../agenthooks/policy.mjs';
33
35
  import { decide, factsFor } from '../agenthooks/decide.mjs';
36
+ import { repoRoot } from '../repo-root.mjs';
34
37
  import {
35
38
  adapter,
36
39
  adapters,
@@ -41,6 +44,7 @@ import {
41
44
  installedIn,
42
45
  rendered,
43
46
  } from '../agenthooks/render.mjs';
47
+ import { commandRoot } from '../repo-root.mjs';
44
48
 
45
49
  export async function agentHookCommand(args, flags) {
46
50
  const event = args[0];
@@ -74,7 +78,7 @@ export async function agentHookCommand(args, flags) {
74
78
  // dynamic import that only this event opens.
75
79
  if (event === 'prompt-submit') {
76
80
  const { deliverPrompt } = await import('../agenthooks/prompt.mjs');
77
- const delivered = await deliverPrompt(row, payload);
81
+ const delivered = await deliverPrompt(row, payload, { root: flags.root });
78
82
  if (delivered.skipped) {
79
83
  note(`note ${id}: no packet delivered (${delivered.skipped})`);
80
84
  return EXIT.OK;
@@ -97,10 +101,33 @@ export async function agentHookCommand(args, flags) {
97
101
  // `--root` outranks the payload: a packaged hook runs from the plugin directory and the
98
102
  // harness's environment variable names the project, so a payload cwd that is the plugin's
99
103
  // own working directory must not win. The repository-install path passes no flag, and the
100
- // payload's cwd is what the harness reports.
101
- const { root, command, writes } = factsFrom(row, payload, { root: flags.root });
102
- const facts = await factsFor(root, { command, writes });
103
- const decision = decide({ event, facts, env: process.env });
104
+ // payload's cwd is what the harness reports. The answer is then resolved to the
105
+ // repository's top level: a harness reports the directory it happens to be in, and an
106
+ // agent works from subdirectories, so every repository-relative fact (the lease file,
107
+ // `.gitmodules`) must resolve against the root git itself uses, not the cwd (review
108
+ // 0003 Q16).
109
+ const { root: payloadRoot, command, writes, session: payloadSession } = factsFrom(row, payload, { root: flags.root });
110
+ const root = repoRoot(payloadRoot);
111
+ // A relative write path is relative to the directory the harness is in, not to the
112
+ // repository root the facts resolve against: an agent editing `keep.mjs` from `src/`
113
+ // means `src/keep.mjs`, and a path joined to the wrong base is a path the lease file
114
+ // will never meet (review 0003 Q16).
115
+ // Writes are `{path, ...edit}` rows (Q17): the path resolves against the harness
116
+ // cwd, the edit detail travels untouched so the region check sees the old_string.
117
+ const writesAbsolute = writes.map((w) => {
118
+ const path = typeof w === 'string' ? w : w?.path;
119
+ const abs = typeof path === 'string' && isAbsolute(path) ? path : join(payloadRoot, path);
120
+ return typeof w === 'string' ? abs : { ...w, path: abs };
121
+ });
122
+ const facts = await factsFor(root, { command, writes: writesAbsolute });
123
+ // Harness hook processes rarely carry SIMT_SESSION, so the payload's own
124
+ // session id is the fallback: without it an agent is blocked by its own
125
+ // lease (review 0003 Q20). The environment still wins when it names one.
126
+ const session =
127
+ typeof process.env[SESSION_ENV] === 'string' && process.env[SESSION_ENV] !== ''
128
+ ? process.env[SESSION_ENV]
129
+ : (payloadSession ?? '');
130
+ const decision = decide({ event, facts, env: { ...process.env, [SESSION_ENV]: session } });
104
131
 
105
132
  // Both notes are about a conclusion the author is about to draw: a lift that did
106
133
  // nothing because of a typo, and a lift that is the reason the command they expected to
@@ -115,7 +142,11 @@ export async function agentHookCommand(args, flags) {
115
142
  const json = decisionJson(row, decision);
116
143
  if (json === null) return EXIT.OK;
117
144
 
118
- if (root) {
145
+ // A denial is recorded only for a repository that adopted the tool: hook
146
+ // denials in a never-adopted repo are noise about someone else's policy, and
147
+ // that is how an unadopted checkout accumulated 23 candidates nobody would
148
+ // ever promote (review 0003 Q36). The manifest is the adoption mark.
149
+ if (root && (existsSync(join(root, MANIFEST_FILE)) || existsSync(join(root, MANIFEST_LEGACY)))) {
119
150
  try {
120
151
  const { recordHookDenial } = await import('../memory/lessons.mjs');
121
152
  await recordHookDenial(root, {
@@ -137,14 +168,30 @@ export async function agentHookCommand(args, flags) {
137
168
  }
138
169
 
139
170
  export async function agentHooksCommand(sub, args, flags) {
140
- const root = flags.root ?? args[0] ?? process.cwd();
171
+ const root = commandRoot(flags, args);
141
172
  if (sub === 'install') return installHooks(root, flags);
142
173
  if (sub === 'status') return hooksStatus(root, flags);
143
174
  return fail(`unknown agent-hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agent-hooks status`);
144
175
  }
145
176
 
177
+ // The label for a reader the run did not write, by the state that names why:
178
+ // `none` documents no hooks file, `other` is a reader this tool does not write,
179
+ // `skipped` was an adapter the caller did not ask for (ADR-107).
180
+ const OTHER_LABEL = { none: 'NO FILE ', other: 'NOT OURS', skipped: 'SKIPPED ' };
181
+
146
182
  async function installHooks(root, flags) {
147
183
  const mode = writeMode(flags);
184
+ // `--harness` narrows the install to one reader (ADR-107). The id is validated
185
+ // against the same table the loop reads, so a typo fails before anything is
186
+ // planned; the remedy names the ids the way `agent-hook` does.
187
+ const selected = flags.harness;
188
+ if (selected !== undefined && adapter(selected) === null) {
189
+ return fail(
190
+ `unknown harness \`${selected}\``,
191
+ EXIT.USAGE,
192
+ `--harness <id>, one of: ${adapters().map((a) => a.id).join(', ')}`,
193
+ );
194
+ }
148
195
  // The delivery is a second entry in the same config, and a repository asks for it by
149
196
  // declaring a `brief` key rather than by having the entry. Read through a dynamic
150
197
  // import for the same reason the hook delivers through one.
@@ -155,8 +202,9 @@ async function installHooks(root, flags) {
155
202
  listPromotedLessons(root).catch(() => []),
156
203
  ]);
157
204
  const hasLessons = promotedLessons.length > 0;
205
+ const rows = selected === undefined ? adapters() : adapters().filter((r) => r.id === selected);
158
206
  const plan = [];
159
- for (const row of adapters()) {
207
+ for (const row of rows) {
160
208
  const path = hookPath(row.id);
161
209
  const existing = await readIfExists(join(root, path));
162
210
  const result = rendered(row, existing, { prompt: optIn.optedIn, post: hasLessons });
@@ -178,15 +226,28 @@ async function installHooks(root, flags) {
178
226
 
179
227
  // The readers this tool does not write an entry for, reported rather than skipped in
180
228
  // silence: a command that writes four files and says nothing about the fifth harness
181
- // in the repository reads as "that one is covered too".
229
+ // in the repository reads as "that one is covered too". A narrowed install adds a
230
+ // third state: an adapter that is this tool's but was not requested (ADR-107).
182
231
  const others = [];
183
232
  for (const r of hookRows()) {
184
233
  if (r.path === null) others.push({ id: r.id, path: null, state: 'none', note: r.note });
185
234
  else if (adapter(r.id) === null) others.push({ id: r.id, path: r.path, state: 'other', note: r.note });
235
+ else if (selected !== undefined && r.id !== selected) {
236
+ others.push({ id: r.id, path: r.path, state: 'skipped', note: 'not requested; install without --harness to write every harness' });
237
+ }
186
238
  }
187
239
 
188
240
  const refused = plan.filter((p) => p.error);
189
241
 
242
+ // The shim every harness config points at: without it the entries above name a
243
+ // binary no harness has on PATH (review 0003 Q21). Its remedy installs the
244
+ // dependency the same way the git shims' does — a devDependency where the
245
+ // repository has a package.json, the release binary where it does not.
246
+ const shimText = renderAgentHookShim({ npm: (await readIfExists(join(root, 'package.json'))) !== null });
247
+ const shimBefore = await readBefore(join(root, AGENT_HOOK_SHIM));
248
+ const shimAction = shimBefore === null ? 'create' : shimBefore === shimText ? 'unchanged' : 'update';
249
+ const shimRow = { id: 'shim', path: AGENT_HOOK_SHIM, action: shimAction };
250
+
190
251
  // The `--diff` pairs, read before anything is written so the before side is
191
252
  // true in preview and in apply alike (R50).
192
253
  const diffs =
@@ -194,7 +255,8 @@ async function installHooks(root, flags) {
194
255
  ? await Promise.all(
195
256
  plan
196
257
  .filter((p) => p.action !== 'unchanged')
197
- .map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text })),
258
+ .map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text }))
259
+ .concat(shimAction === 'unchanged' ? [] : [{ path: AGENT_HOOK_SHIM, before: shimBefore, after: shimText }]),
198
260
  )
199
261
  : [];
200
262
 
@@ -206,6 +268,7 @@ async function installHooks(root, flags) {
206
268
  root,
207
269
  dryRun: true,
208
270
  plan: plan.map(({ text, ...rest }) => rest),
271
+ shim: shimRow,
209
272
  notWritten: others,
210
273
  filesWritten: false,
211
274
  ...diffField(diffs, flags),
@@ -216,14 +279,14 @@ async function installHooks(root, flags) {
216
279
  [
217
280
  `repo ${root}`,
218
281
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}${p.error ? ` (${p.error})` : ''}`),
219
- ...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
282
+ `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
283
+ ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
220
284
  ...promptLine,
221
285
  '',
222
286
  refused.length > 0
223
287
  ? `REFUSED ${refused.length} config file(s) could not be read; nothing written`
224
288
  : `PLAN nothing written; apply with: ${CLI} agent-hooks install --yes`,
225
289
  ].join('\n'),
226
- { maxBytes: flags.maxBytes ?? 0 },
227
290
  );
228
291
  // After the report: the report says what, the diff shows it (R50).
229
292
  emitDiffs(diffs, flags);
@@ -245,6 +308,14 @@ async function installHooks(root, flags) {
245
308
  await journaledWriteFile(root, journal, join(root, p.path), p.text);
246
309
  written.push(p);
247
310
  }
311
+ // The shim is a file like the configs: unchanged installs leave it alone, and a
312
+ // changed one is journaled so `undo` removes or restores it with the configs.
313
+ let shimWritten = false;
314
+ if (shimAction !== 'unchanged') {
315
+ await mkdir(join(root, dirname(AGENT_HOOK_SHIM)), { recursive: true });
316
+ await journaledWriteFile(root, journal, join(root, AGENT_HOOK_SHIM), shimText, { mode: 0o755 });
317
+ shimWritten = true;
318
+ }
248
319
  await commitJournal(root, 'agent-hooks install', journal);
249
320
 
250
321
  const notes = [];
@@ -260,6 +331,7 @@ async function installHooks(root, flags) {
260
331
  command: 'agent-hooks install',
261
332
  root,
262
333
  plan: plan.map(({ text, ...rest }) => rest),
334
+ shim: { ...shimRow, written: shimWritten },
263
335
  notWritten: others,
264
336
  written: written.map((p) => p.path),
265
337
  filesWritten: true,
@@ -272,12 +344,12 @@ async function installHooks(root, flags) {
272
344
  [
273
345
  `repo ${root}`,
274
346
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
275
- ...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
347
+ `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
348
+ ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
276
349
  ...promptLine,
277
350
  '',
278
- `WROTE ${written.length} hooks file(s); each one runs ${CLI} agent-hook before the tool call`,
351
+ `WROTE ${written.length} hooks file(s)${shimWritten ? ' and the shim' : ''}; each one runs the ${AGENT_HOOK_SHIM} shim before the tool call`,
279
352
  ].join('\n'),
280
- { maxBytes: flags.maxBytes ?? 0 },
281
353
  );
282
354
  // After the report: the report says what, the diff shows it (R50).
283
355
  emitDiffs(diffs, flags);
@@ -290,10 +362,31 @@ async function installHooks(root, flags) {
290
362
  async function hooksStatus(root, flags) {
291
363
  const rows = [];
292
364
  const problems = [];
365
+ // The plugin file this tool owns whole is compared against what `install` would write
366
+ // right now, so status reads the same opt-in install reads: a file that matches the
367
+ // preview is present, anything else is drift an install will rewrite.
368
+ const { promptOptIn } = await import('../agenthooks/prompt.mjs');
369
+ const optIn = await promptOptIn(root);
293
370
 
294
371
  for (const row of adapters()) {
295
372
  const path = hookPath(row.id);
296
373
  const existing = await readIfExists(join(root, path));
374
+ if (row.file) {
375
+ if (existing === null) {
376
+ rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
377
+ } else {
378
+ const present = rendered(row, existing, { prompt: optIn.optedIn }).action === 'unchanged';
379
+ rows.push({
380
+ id: row.id,
381
+ path,
382
+ state: present ? 'present' : 'drift',
383
+ entries: present ? 1 : 0,
384
+ prompt: row.prompt ? (present && optIn.optedIn ? 'present' : 'absent') : null,
385
+ note: present ? null : 'differs from what install would write; run install to restore it',
386
+ });
387
+ }
388
+ continue;
389
+ }
297
390
  if (existing === null) {
298
391
  rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
299
392
  continue;
@@ -341,7 +434,7 @@ async function hooksStatus(root, flags) {
341
434
 
342
435
  const lines = [`repo ${root}`];
343
436
  for (const r of rows) {
344
- const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
437
+ const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', drift: 'DRIFT ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
345
438
  lines.push(`${state} ${r.id.padEnd(12)} ${r.path ?? '(no hooks file is documented)'}`);
346
439
  if (r.state === 'absent') lines.push(` ${' '.repeat(12)} install: ${CLI} agent-hooks install --yes`);
347
440
  // The second event is a separate answer to a separate question: a harness can carry
@@ -354,7 +447,7 @@ async function hooksStatus(root, flags) {
354
447
  lines.push('', 'problems:');
355
448
  for (const p of problems) lines.push(` ${p}`);
356
449
  }
357
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
450
+ emit(lines.join('\n'));
358
451
  return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
359
452
  }
360
453