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.
- package/CHANGELOG.md +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- 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
- 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
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- 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
- 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
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- 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
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- 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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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')
|
|
450
|
+
emit(lines.join('\n'));
|
|
358
451
|
return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
359
452
|
}
|
|
360
453
|
|