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.
- package/CHANGELOG.md +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.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-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- 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/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- 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 +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- 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 +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- 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 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- 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 +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- 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 +21 -14
- package/src/projection/manifest.mjs +82 -21
- 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 +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// The shell command a hook is about to run, read the way a shell reads it.
|
|
2
|
+
//
|
|
3
|
+
// A policy that greps the payload's command string for `git push` is a policy an agent
|
|
4
|
+
// can route around: the review that found it (0003, Q01) counted twenty-five spellings
|
|
5
|
+
// the regex let through - `git -C . push`, `sh -c 'git push'`, `$(git push)`, `(git push)`,
|
|
6
|
+
// the alias `git p`, `git send-pack`, `--amen`, `+main`. Every one of them is the same
|
|
7
|
+
// act, and a reader of this file should be able to see that the act is what is matched.
|
|
8
|
+
//
|
|
9
|
+
// So the command is tokenised the way POSIX sh tokenises it - quotes, escapes, `; && ||
|
|
10
|
+
// | &`, newlines, subshells, `$()` and backticks - each resulting command is unwrapped
|
|
11
|
+
// through the wrappers a shell will execute for you, and every git invocation is reduced
|
|
12
|
+
// to the fact a policy can read: which subcommand, with which options, in which
|
|
13
|
+
// repository. The match is made against that fact, not against the text.
|
|
14
|
+
//
|
|
15
|
+
// This module is pure: no filesystem, no git. The one lookup that needs the outside
|
|
16
|
+
// world - what the author's `alias.p` expands to - is done by the caller, because it
|
|
17
|
+
// reads a configuration this module does not own.
|
|
18
|
+
|
|
19
|
+
import { basename } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A POSIX shell command list: commands separated by `;`, `&&`, `||`, `|`, `&` or a
|
|
23
|
+
* newline, each a list of words.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} text the command string a payload carried
|
|
26
|
+
* @returns {{ok: true, commands: string[][]} | {ok: false}} `ok` is false when the text
|
|
27
|
+
* cannot be read - unbalanced quotes, an unclosed `$()`, a dangling `(`. A caller that
|
|
28
|
+
* gets false must fall back to something weaker, never to "nothing to see here": a
|
|
29
|
+
* command we cannot parse is not a command we are allowed.
|
|
30
|
+
*/
|
|
31
|
+
function tokenize(text) {
|
|
32
|
+
if (typeof text !== 'string') return { ok: false };
|
|
33
|
+
const commands = [];
|
|
34
|
+
try {
|
|
35
|
+
parseList(text, 0, text.length, commands);
|
|
36
|
+
} catch {
|
|
37
|
+
return { ok: false };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, commands };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read one list of commands from `text[start..end)` into `out`. Throws on unbalance. */
|
|
43
|
+
function parseList(text, start, end, out) {
|
|
44
|
+
let i = start;
|
|
45
|
+
let current = null; // the words of the command being read
|
|
46
|
+
const flush = () => {
|
|
47
|
+
if (current !== null && current.length > 0) out.push(current);
|
|
48
|
+
current = null;
|
|
49
|
+
};
|
|
50
|
+
while (i < end) {
|
|
51
|
+
const c = text[i];
|
|
52
|
+
if (c === ';' || c === '&' || c === '|') {
|
|
53
|
+
flush();
|
|
54
|
+
i += 1;
|
|
55
|
+
if ((c === '&' && text[i] === '&') || (c === '|' && text[i] === '|')) i += 1; // && and ||
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === '\n' || c === '\r') {
|
|
59
|
+
flush();
|
|
60
|
+
i += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (c === '(' || c === '{') {
|
|
64
|
+
flush();
|
|
65
|
+
const close = match(text, i, c, c === '(' ? ')' : '}');
|
|
66
|
+
parseList(text, i + 1, close, out); // a subshell is a list in its own right
|
|
67
|
+
i = close + 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (c === ' ' || c === '\t') {
|
|
71
|
+
i += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const word = readWord(text, i, end, out);
|
|
75
|
+
if (word.word !== null) (current ??= []).push(word.word);
|
|
76
|
+
i = word.next;
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The index of the `close` char that balances the `open` at `from`, through nesting. */
|
|
82
|
+
function match(text, from, open, close) {
|
|
83
|
+
let depth = 0;
|
|
84
|
+
for (let i = from; i < text.length; i += 1) {
|
|
85
|
+
const c = text[i];
|
|
86
|
+
if (c === open) depth += 1;
|
|
87
|
+
else if (c === close) {
|
|
88
|
+
depth -= 1;
|
|
89
|
+
if (depth === 0) return i;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new Error('unbalanced');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read one word from `text` at `i`, stopping at the next separator. A `$()` or a
|
|
97
|
+
* backtick span inside the word is a command of its own: it is tokenised on its own and
|
|
98
|
+
* added to `out`, because the shell will run it, and a policy that reads only the outer
|
|
99
|
+
* word would see `echo` and let `$(git push)` through.
|
|
100
|
+
*
|
|
101
|
+
* @returns {{word: string|null, next: number}} `word` is null when the span produced no
|
|
102
|
+
* word of its own (a bare substitution).
|
|
103
|
+
*/
|
|
104
|
+
function readWord(text, i, end, out) {
|
|
105
|
+
let buf = '';
|
|
106
|
+
while (i < end) {
|
|
107
|
+
const c = text[i];
|
|
108
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') break;
|
|
109
|
+
if (c === ';' || c === '&' || c === '|' || c === '(' || c === '{' || c === ')' || c === '}') break;
|
|
110
|
+
if (c === '\\') {
|
|
111
|
+
buf += text[i + 1] ?? '';
|
|
112
|
+
i += 2;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (c === "'") {
|
|
116
|
+
const close = text.indexOf("'", i + 1);
|
|
117
|
+
if (close === -1 || close > end) throw new Error('unbalanced quote');
|
|
118
|
+
buf += text.slice(i + 1, close);
|
|
119
|
+
i = close + 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === '"') {
|
|
123
|
+
i += 1;
|
|
124
|
+
let closed = false;
|
|
125
|
+
while (i < end) {
|
|
126
|
+
const q = text[i];
|
|
127
|
+
if (q === '"') {
|
|
128
|
+
i += 1;
|
|
129
|
+
closed = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (q === '\\') {
|
|
133
|
+
buf += text[i + 1] ?? '';
|
|
134
|
+
i += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (q === '$' && text[i + 1] === '(') {
|
|
138
|
+
const close = match(text, i + 1, '(', ')');
|
|
139
|
+
const inner = [];
|
|
140
|
+
parseList(text, i + 2, close, inner);
|
|
141
|
+
out.push(...inner);
|
|
142
|
+
i = close + 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (q === '`') {
|
|
146
|
+
const close = text.indexOf('`', i + 1);
|
|
147
|
+
if (close === -1 || close > end) throw new Error('unbalanced backtick');
|
|
148
|
+
const inner = [];
|
|
149
|
+
parseList(text, i + 1, close, inner);
|
|
150
|
+
out.push(...inner);
|
|
151
|
+
i = close + 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
buf += q;
|
|
155
|
+
i += 1;
|
|
156
|
+
}
|
|
157
|
+
if (!closed) throw new Error('unbalanced quote');
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (c === '$' && text[i + 1] === '(') {
|
|
161
|
+
const close = match(text, i + 1, '(', ')');
|
|
162
|
+
const inner = [];
|
|
163
|
+
parseList(text, i + 2, close, inner);
|
|
164
|
+
out.push(...inner);
|
|
165
|
+
i = close + 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (c === '`') {
|
|
169
|
+
const close = text.indexOf('`', i + 1);
|
|
170
|
+
if (close === -1 || close > end) throw new Error('unbalanced backtick');
|
|
171
|
+
const inner = [];
|
|
172
|
+
parseList(text, i + 1, close, inner);
|
|
173
|
+
out.push(...inner);
|
|
174
|
+
i = close + 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
buf += c;
|
|
178
|
+
i += 1;
|
|
179
|
+
}
|
|
180
|
+
return { word: buf === '' ? null : buf, next: i };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The words a wrapper will execute for you, with the wrapper gone.
|
|
185
|
+
*
|
|
186
|
+
* `sh -c 'git push'` runs `git push`; `sudo -u ci git push` runs `git push`. A policy
|
|
187
|
+
* that unwraps none of these sees the wrapper and lets the wrapped command through, so
|
|
188
|
+
* each one is descended into. The loop is bounded because `env env sh -c …` is legal and
|
|
189
|
+
* a command that wraps forever is not a command, it is an attack on this function.
|
|
190
|
+
*
|
|
191
|
+
* @param {string[]} words one tokenised command
|
|
192
|
+
* @returns {string[][]} the commands it will run: the unwrapped one, plus any command
|
|
193
|
+
* string a `-c`/`eval` argument carries, tokenised on its own
|
|
194
|
+
*/
|
|
195
|
+
function unwrap(words) {
|
|
196
|
+
const out = [];
|
|
197
|
+
let w = words;
|
|
198
|
+
for (let depth = 0; depth < 16 && w.length > 0; depth += 1) {
|
|
199
|
+
const name = basename(w[0]);
|
|
200
|
+
if (['sh', 'bash', 'zsh', 'dash'].includes(name)) {
|
|
201
|
+
for (let k = 1; k < w.length; k += 1) {
|
|
202
|
+
const opt = w[k];
|
|
203
|
+
if (!opt.startsWith('-') || opt === '-') break; // the first cluster that is not -c: not this form
|
|
204
|
+
if (opt.includes('c')) { // -c, -lc, -xc: the next word is a command string
|
|
205
|
+
const rest = w.slice(k + 1).join(' ');
|
|
206
|
+
if (rest.trim() !== '') {
|
|
207
|
+
const inner = tokenize(rest);
|
|
208
|
+
if (inner.ok) out.push(...inner.commands);
|
|
209
|
+
}
|
|
210
|
+
return out.length > 0 ? out : [w];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return [w]; // `bash script.sh` runs a file this policy cannot see; nothing to descend into
|
|
214
|
+
}
|
|
215
|
+
if (name === 'eval') {
|
|
216
|
+
const rest = w.slice(1).join(' ');
|
|
217
|
+
if (rest.trim() !== '') {
|
|
218
|
+
const inner = tokenize(rest);
|
|
219
|
+
if (inner.ok) out.push(...inner.commands);
|
|
220
|
+
}
|
|
221
|
+
return out.length > 0 ? out : [w];
|
|
222
|
+
}
|
|
223
|
+
if (name === 'env') {
|
|
224
|
+
let i = 1;
|
|
225
|
+
while (i < w.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]) || w[i] === '-i' || w[i].startsWith('-u'))) {
|
|
226
|
+
i += 1;
|
|
227
|
+
}
|
|
228
|
+
w = w.slice(i);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (name === 'command' || name === 'exec' || name === 'time') {
|
|
232
|
+
w = w.slice(1);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (name === 'xargs') {
|
|
236
|
+
let i = 1;
|
|
237
|
+
while (i < w.length && w[i].startsWith('-')) i += 1; // xargs' own options
|
|
238
|
+
w = w.slice(i);
|
|
239
|
+
continue; // the first remaining word is the command xargs runs
|
|
240
|
+
}
|
|
241
|
+
if (name === 'nice') {
|
|
242
|
+
let i = 1;
|
|
243
|
+
while (i < w.length) {
|
|
244
|
+
if (w[i] === '-n') i += 2; // -n takes a value
|
|
245
|
+
else if (w[i].startsWith('-')) i += 1;
|
|
246
|
+
else break;
|
|
247
|
+
}
|
|
248
|
+
w = w.slice(i);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (name === 'sudo') {
|
|
252
|
+
let i = 1;
|
|
253
|
+
while (i < w.length) {
|
|
254
|
+
if (w[i] === '-u' || w[i] === '-g' || w[i] === '-D' || w[i] === '-C') i += 2; // take a value
|
|
255
|
+
else if (w[i].startsWith('-')) i += 1;
|
|
256
|
+
else break;
|
|
257
|
+
}
|
|
258
|
+
w = w.slice(i);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
out.push(w);
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The long options git accepts before the subcommand, so a global flag is not mistaken for one. */
|
|
268
|
+
const GLOBAL_FLAGS = new Set([
|
|
269
|
+
'no-pager',
|
|
270
|
+
'paginate',
|
|
271
|
+
'git-dir',
|
|
272
|
+
'work-tree',
|
|
273
|
+
'namespace',
|
|
274
|
+
'super-prefix',
|
|
275
|
+
'exec-path',
|
|
276
|
+
'html-path',
|
|
277
|
+
'no-replace-objects',
|
|
278
|
+
'implied-gitdir',
|
|
279
|
+
'bare',
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The subcommands a policy can name, so an alias that is not one of them needs no
|
|
284
|
+
* lookup: `git foobar` runs something this table does not police, and spending a git
|
|
285
|
+
* spawn on it would buy nothing.
|
|
286
|
+
*/
|
|
287
|
+
export const GIT_BUILTINS = new Set([
|
|
288
|
+
'add', 'am', 'apply', 'archimport', 'bisect', 'blame', 'branch', 'bundle', 'checkout',
|
|
289
|
+
'cherry', 'cherry-pick', 'citool', 'clean', 'clone', 'commit', 'commit-graph',
|
|
290
|
+
'count-objects', 'dap', 'diff', 'diff-tree', 'fast-export', 'fast-import',
|
|
291
|
+
'fetch-pack', 'filter-branch', 'format-patch', 'for-each-ref', 'gc', 'get-tar-commit-id',
|
|
292
|
+
'grep', 'hash-object', 'help', 'init', 'interpret-trailers', 'log',
|
|
293
|
+
'ls-files', 'ls-remote', 'ls-tree', 'mailinfo', 'imap-send', 'merge', 'merge-base',
|
|
294
|
+
'mergetool', 'merge-tree', 'mv', 'name-rev', 'notes', 'pack-objects', 'pack-refs',
|
|
295
|
+
'prune', 'prune-packed', 'pull', 'push', 'read-tree', 'rebase', 'remote', 'repack',
|
|
296
|
+
'replace', 'reset', 'restore', 'revert', 'rm', 'run-help', 'send-pack', 'shortlog',
|
|
297
|
+
'show-branch', 'show-index', 'show-ref', 'sparse-checkout', 'stage', 'stash',
|
|
298
|
+
'status', 'strip-stage', 'svn', 'svn-fetch', 'symlink', 'tag', 'teardown-repo',
|
|
299
|
+
'update-index', 'update-ref', 'update-server-info', 'upload-archive', 'upload-pack',
|
|
300
|
+
'verify-commit', 'verify-pack', 'web--browse', 'whatchanged', 'worktree',
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Per-subcommand short options that take a value, so the value is not mistaken for an
|
|
305
|
+
* option of its own. The table is kept small on purpose: a short option it does not know
|
|
306
|
+
* is treated as flag-like, which can only over-report a policy, never under.
|
|
307
|
+
*/
|
|
308
|
+
const SHORT_VALUE = {
|
|
309
|
+
commit: new Set(['m', 'C', 'c', 'F', 'S']),
|
|
310
|
+
reset: new Set(['p', 'W', 'C']),
|
|
311
|
+
push: new Set(['r', 'F']),
|
|
312
|
+
pull: new Set(['s', 'o']),
|
|
313
|
+
add: new Set(['f', 'i', 'p', 'u', 'm']),
|
|
314
|
+
rm: new Set(['f', 'n', 'q', 'r']),
|
|
315
|
+
clean: new Set(['d', 'e', 'i', 'n', 'q', 'x', 'f']),
|
|
316
|
+
checkout: new Set(['b', 'B', 'm', 'o', 'p', 't', 'u', 'w']),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/** Long options that take a value, per subcommand: `--opt value` consumes the next word. */
|
|
320
|
+
const VALUE_LONG = {
|
|
321
|
+
commit: new Set(['message', 'file', 'author', 'date', 'parent', 'gpg-sign']),
|
|
322
|
+
push: new Set(['user', 'receive-pack', 'depth']),
|
|
323
|
+
reset: new Set(['patch', 'combined', 'mode']),
|
|
324
|
+
pull: new Set(['rebase', 'strategy', 'depth']),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** Git's unique-prefix rule: `--amen` is `--amend` because nothing else starts with it. */
|
|
328
|
+
const LONG_OPTIONS = {
|
|
329
|
+
commit: ['amend', 'all', 'allow-empty', 'allow-empty-message', 'author', 'date', 'file',
|
|
330
|
+
'force', 'message', 'no-verify', 'only', 'patch', 'signoff', 'verbose'],
|
|
331
|
+
reset: ['hard', 'mixed', 'soft', 'keep', 'merge', 'cache', 'quiet', 'patch'],
|
|
332
|
+
push: ['all', 'atomic', 'delete', 'dry-run', 'force', 'force-with-lease', 'mirror',
|
|
333
|
+
'no-verify', 'prune', 'set-upstream', 'tags', 'verbose'],
|
|
334
|
+
rebase: ['abort', 'apply', 'continue', 'interactive', 'merge', 'onto', 'skip', 'verbose'],
|
|
335
|
+
pull: ['rebase', 'ff', 'no-ff', 'ff-only', 'no-edit', 'verbose'],
|
|
336
|
+
add: ['all', 'force', 'interactive', 'patch', 'update', 'verbose'],
|
|
337
|
+
rm: ['cached', 'dry-run', 'force', 'ignore-unmatch', 'quiet', 'recursive'],
|
|
338
|
+
clean: ['dry-run', 'force', 'ignore', 'interactive', 'quiet', 'recursive'],
|
|
339
|
+
checkout: ['branch', 'force', 'merge', 'orphan', 'overlay', 'progress', 'quiet',
|
|
340
|
+
'track', 'verbose'],
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/** The unique full option a prefix names, or null when the prefix is ambiguous. */
|
|
344
|
+
function resolveLong(sub, name) {
|
|
345
|
+
const table = LONG_OPTIONS[sub] ?? [];
|
|
346
|
+
if (table.includes(name)) return name; // an exact spelling is never ambiguous
|
|
347
|
+
const hits = table.filter((o) => o.startsWith(name));
|
|
348
|
+
return hits.length === 1 ? hits[0] : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The git fact one invocation reduces to, or null when the command is not a git call.
|
|
353
|
+
*
|
|
354
|
+
* @param {string[]} words one tokenised, unwrapped command
|
|
355
|
+
* @returns {{kind: 'git', globals: {C: string|null, c: string[], gitDir: string|null},
|
|
356
|
+
* sub: string, opts: Set<string>, refspecs: string[]}|null}
|
|
357
|
+
* `opts` holds long options in full (a unique prefix resolved) and short options as
|
|
358
|
+
* single letters, a cluster expanded. `refspecs` are the positional arguments; a
|
|
359
|
+
* leading `+` is force and stays on the string, because it is the act.
|
|
360
|
+
*/
|
|
361
|
+
function parseGit(words) {
|
|
362
|
+
if (words.length === 0 || basename(words[0]) !== 'git') return null;
|
|
363
|
+
const globals = { C: null, c: [], gitDir: null };
|
|
364
|
+
let i = 1;
|
|
365
|
+
while (i < words.length) {
|
|
366
|
+
const w = words[i];
|
|
367
|
+
if (w === '-C') {
|
|
368
|
+
globals.C = words[i + 1] ?? null;
|
|
369
|
+
i += 2;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (w === '-c') {
|
|
373
|
+
globals.c.push(words[i + 1] ?? '');
|
|
374
|
+
i += 2;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (w === '--git-dir' || w.startsWith('--git-dir=')) {
|
|
378
|
+
globals.gitDir = w === '--git-dir' ? words[i + 1] ?? null : w.slice('--git-dir='.length);
|
|
379
|
+
i += w === '--git-dir' ? 2 : 1;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (w.startsWith('--')) {
|
|
383
|
+
const name = w.slice(2).split('=')[0];
|
|
384
|
+
if (!GLOBAL_FLAGS.has(name) && !name.startsWith('no-')) break; // an unknown global flag ends the prefix
|
|
385
|
+
i += 1;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (w.startsWith('-')) {
|
|
389
|
+
i += 1; // a global short flag (-p, -P …): git takes it, so skip it
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
break; // the subcommand
|
|
393
|
+
}
|
|
394
|
+
if (i >= words.length) return null; // `git` alone runs the help; nothing to police
|
|
395
|
+
const sub = words[i];
|
|
396
|
+
i += 1;
|
|
397
|
+
|
|
398
|
+
const opts = new Set();
|
|
399
|
+
const refspecs = [];
|
|
400
|
+
const valueShorts = SHORT_VALUE[sub] ?? new Set();
|
|
401
|
+
let pendingLong = false; // a long option that will take its value from the next word
|
|
402
|
+
while (i < words.length) {
|
|
403
|
+
const w = words[i];
|
|
404
|
+
if (pendingLong && !w.startsWith('-')) {
|
|
405
|
+
i += 1; // the value of --opt: consumed, not an option
|
|
406
|
+
pendingLong = false;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (w.startsWith('--')) {
|
|
410
|
+
const eq = w.indexOf('=');
|
|
411
|
+
const name = eq === -1 ? w.slice(2) : w.slice(2, eq);
|
|
412
|
+
if (name !== '') {
|
|
413
|
+
const full = resolveLong(sub, name);
|
|
414
|
+
const spelling = full !== null ? `--${full}` : `--${name}`;
|
|
415
|
+
opts.add(spelling); // unknown to this table: keep it, it can only over-report
|
|
416
|
+
pendingLong = full !== null && (VALUE_LONG[sub] ?? new Set()).has(full);
|
|
417
|
+
}
|
|
418
|
+
i += 1;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (w.startsWith('-') && w.length > 1) {
|
|
422
|
+
for (const ch of w.slice(1)) {
|
|
423
|
+
opts.add(ch);
|
|
424
|
+
if (valueShorts.has(ch)) break; // the rest of the cluster is that option's value
|
|
425
|
+
}
|
|
426
|
+
i += 1;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (w === '--') {
|
|
430
|
+
pendingLong = false;
|
|
431
|
+
i += 1;
|
|
432
|
+
continue; // everything after is a path or a refspec, never an option
|
|
433
|
+
}
|
|
434
|
+
pendingLong = false;
|
|
435
|
+
refspecs.push(w);
|
|
436
|
+
i += 1;
|
|
437
|
+
}
|
|
438
|
+
return { kind: 'git', globals, sub, opts, refspecs };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The rm fact one invocation reduces to, or null when the command is not an rm call.
|
|
443
|
+
*
|
|
444
|
+
* `rm` is the second command a policy can name: deleting the hooks directory is how a
|
|
445
|
+
* repository's gates go away, and the `hooks-disable` policy reads this fact.
|
|
446
|
+
*/
|
|
447
|
+
function parseRm(words) {
|
|
448
|
+
if (words.length === 0 || basename(words[0]) !== 'rm') return null;
|
|
449
|
+
const opts = new Set();
|
|
450
|
+
const args = [];
|
|
451
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
452
|
+
const w = words[i];
|
|
453
|
+
if (w === '--') {
|
|
454
|
+
args.push(...words.slice(i + 1)); // everything after is a path, never an option
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
if (w.startsWith('--')) {
|
|
458
|
+
opts.add(w.slice(2).split('=')[0]);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (w.startsWith('-') && w.length > 1) {
|
|
462
|
+
for (const ch of w.slice(1)) opts.add(ch); // a cluster: every letter is an option
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
args.push(w);
|
|
466
|
+
}
|
|
467
|
+
return { kind: 'rm', opts, args };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Every fact a policy can name that a command string carries, or null when it is not a
|
|
472
|
+
* string.
|
|
473
|
+
*
|
|
474
|
+
* @param {string} text the command a payload carried
|
|
475
|
+
* @returns {{parsed: true, facts: Array<ReturnType<typeof parseGit>|ReturnType<typeof parseRm>>}|{parsed: false}}
|
|
476
|
+
* one fact per git or rm invocation, including the ones a wrapper or a substitution
|
|
477
|
+
* hides; an invocation whose subcommand is not a builtin (an alias, or nothing) is
|
|
478
|
+
* left as-is - the caller resolves aliases against the author's own configuration,
|
|
479
|
+
* which this module does not read. `parsed` is false when the text cannot be
|
|
480
|
+
* tokenised at all, and a caller that gets it must fall back to something weaker,
|
|
481
|
+
* never to "nothing to see here".
|
|
482
|
+
*/
|
|
483
|
+
export function commandFacts(text) {
|
|
484
|
+
if (typeof text !== 'string') return null;
|
|
485
|
+
const tokens = tokenize(text);
|
|
486
|
+
if (!tokens.ok) return { parsed: false };
|
|
487
|
+
const facts = [];
|
|
488
|
+
for (const words of tokens.commands) {
|
|
489
|
+
for (const unwrapped of unwrap(words)) {
|
|
490
|
+
const fact = parseGit(unwrapped) ?? parseRm(unwrapped);
|
|
491
|
+
if (fact !== null) facts.push(fact);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return { parsed: true, facts };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The facts with each alias subcommand resolved to what the author's git expands it to.
|
|
499
|
+
*
|
|
500
|
+
* `git p` with `alias.p = push` is a push, and the fact has to say so. A value that
|
|
501
|
+
* starts with `!` is a shell command git runs, so it is read the way any other command
|
|
502
|
+
* string is; anything else is a subcommand line. An alias that resolves to nothing this
|
|
503
|
+
* table can read is left as-is: it runs something the policies do not name, which is
|
|
504
|
+
* the same position as before the lookup.
|
|
505
|
+
*
|
|
506
|
+
* @param {Array<ReturnType<typeof parseGit>|ReturnType<typeof parseRm>>} facts
|
|
507
|
+
* @param {Map<string, string>} aliases name to value, as `git config` spells them
|
|
508
|
+
* @returns the facts, with git facts whose sub is an alias replaced by what it runs
|
|
509
|
+
*/
|
|
510
|
+
export function applyAliases(facts, aliases) {
|
|
511
|
+
return facts.flatMap((f) => {
|
|
512
|
+
if (f.kind !== 'git' || !aliases.has(f.sub)) return [f];
|
|
513
|
+
const value = aliases.get(f.sub);
|
|
514
|
+
if (value.startsWith('!')) {
|
|
515
|
+
const inner = commandFacts(value.slice(1));
|
|
516
|
+
return inner !== null && inner.parsed ? inner.facts : [f];
|
|
517
|
+
}
|
|
518
|
+
const tokens = tokenize(value);
|
|
519
|
+
if (!tokens.ok || tokens.commands.length === 0) return [f];
|
|
520
|
+
const resolved = parseGit(['git', ...tokens.commands[0]]);
|
|
521
|
+
return resolved !== null ? [resolved] : [f];
|
|
522
|
+
});
|
|
523
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The agent-hook shim: how a harness runs this tool without it on PATH.
|
|
2
|
+
//
|
|
3
|
+
// A devDependency install puts `simt` in `node_modules/.bin`, not on a
|
|
4
|
+
// harness's PATH. A harness config naming the bare `simt` binary therefore
|
|
5
|
+
// runs nothing on Claude (a non-2 exit is silent: no enforcement) and blocks
|
|
6
|
+
// every tool call on Cursor (its hook fails closed). The fix is a shim beside
|
|
7
|
+
// the git shims, resolved the same way: `.simt-hooks/agent-hook` locates the
|
|
8
|
+
// dispatcher with the shared `renderLocator` block and hands over, so every
|
|
9
|
+
// harness config names the shim and never the bare binary (review 0003 Q21).
|
|
10
|
+
//
|
|
11
|
+
// The shim passes its own arguments through (`agent-hook "$@"`), so one file
|
|
12
|
+
// serves every event and every harness: the config entry decides the event and
|
|
13
|
+
// the `--harness` decides the dialect.
|
|
14
|
+
|
|
15
|
+
import { renderLocator } from '../locate.mjs';
|
|
16
|
+
import { BIN_ENV, CLI, PACKAGE_NAME } from '../name.mjs';
|
|
17
|
+
import { HOOKS_DIR } from '../hooks/shim.mjs';
|
|
18
|
+
import { localInstall } from '../distribute/release.mjs';
|
|
19
|
+
import { packageVersion } from '../package-version.mjs';
|
|
20
|
+
|
|
21
|
+
/** The shim inside the repository, as a harness config names it. */
|
|
22
|
+
export const AGENT_HOOK_SHIM = `${HOOKS_DIR}/agent-hook`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{npm?: boolean, version?: string}} [opts]
|
|
26
|
+
* @returns {string} POSIX sh source, LF-terminated, executable
|
|
27
|
+
*/
|
|
28
|
+
export function renderAgentHookShim({ npm = true, version } = {}) {
|
|
29
|
+
const v = version ?? packageVersion();
|
|
30
|
+
const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
|
|
31
|
+
const D = '$';
|
|
32
|
+
return `#!/bin/sh
|
|
33
|
+
# Generated by simt-dev. Do not edit.
|
|
34
|
+
#
|
|
35
|
+
# The harness entry point: every harness config runs this file, never the bare
|
|
36
|
+
# \`${CLI}\` binary, because a harness does not share your shell's PATH. It only
|
|
37
|
+
# locates the dispatcher and hands over. Reinstall with:
|
|
38
|
+
# ${CLI} agent-hooks install --yes
|
|
39
|
+
#
|
|
40
|
+
# Resolution order is the git shims' order (src/locate.mjs):
|
|
41
|
+
# 1. ${D}SIMT_DEV_BIN explicit override, for CI images and odd checkouts
|
|
42
|
+
# 2. node_modules/ the pinned project dependency
|
|
43
|
+
# 3. .simt/bin/ the release binary this repository downloaded
|
|
44
|
+
# 4. ${CLI} on PATH a global install
|
|
45
|
+
# If none resolves it REFUSES rather than exiting 0.
|
|
46
|
+
|
|
47
|
+
set -eu
|
|
48
|
+
|
|
49
|
+
ROOT=\"${D}(git rev-parse --show-toplevel 2>/dev/null || pwd)\"
|
|
50
|
+
|
|
51
|
+
${renderLocator(`agent-hook \"${D}@\" --root \"${D}ROOT\"`, {
|
|
52
|
+
subject: 'the agent hook',
|
|
53
|
+
remedy: `REMEDY run: ${install}, or set ${BIN_ENV} to the ${CLI} binary, or remove the hook entry to opt out`,
|
|
54
|
+
})}`;
|
|
55
|
+
}
|