simt-dev 0.6.0 → 0.6.3

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 (79) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
  6. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  7. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  8. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  9. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  10. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  11. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  12. package/docs/decisions/README.md +44 -0
  13. package/package.json +1 -1
  14. package/rules/mdc/preview-postflight.mdc +5 -6
  15. package/rules/stacks/android.md +7 -8
  16. package/rules/stacks/cloudflare-workers.md +1 -1
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/jvm.md +1 -1
  19. package/rules/stacks/react-native.md +3 -5
  20. package/rules/stacks/react-web.md +9 -9
  21. package/src/agenthooks/decide.mjs +101 -10
  22. package/src/agenthooks/policy.mjs +81 -5
  23. package/src/agenthooks/shell.mjs +523 -0
  24. package/src/argv.mjs +54 -9
  25. package/src/ci/workflow.mjs +7 -5
  26. package/src/commands/agenthook.mjs +25 -3
  27. package/src/commands/ci.mjs +28 -3
  28. package/src/commands/completions.mjs +27 -0
  29. package/src/commands/decide.mjs +14 -1
  30. package/src/commands/doctor.mjs +367 -0
  31. package/src/commands/explain.mjs +52 -0
  32. package/src/commands/help.mjs +66 -17
  33. package/src/commands/hooks.mjs +208 -60
  34. package/src/commands/ignore.mjs +152 -0
  35. package/src/commands/index.mjs +54 -100
  36. package/src/commands/init.mjs +82 -12
  37. package/src/commands/lease.mjs +136 -0
  38. package/src/commands/lessons.mjs +12 -0
  39. package/src/commands/mcp.mjs +2 -2
  40. package/src/commands/pack.mjs +9 -0
  41. package/src/commands/plan.mjs +13 -1
  42. package/src/commands/plugin.mjs +16 -1
  43. package/src/commands/prbody.mjs +45 -0
  44. package/src/commands/repoindex.mjs +3 -0
  45. package/src/commands/rules.mjs +145 -105
  46. package/src/commands/shared.mjs +36 -5
  47. package/src/commands/skills.mjs +293 -205
  48. package/src/commands/skillsinstall.mjs +100 -30
  49. package/src/commands/slice.mjs +353 -0
  50. package/src/commands/status.mjs +62 -0
  51. package/src/commands/sync.mjs +110 -15
  52. package/src/commands/undo.mjs +106 -0
  53. package/src/commands/upgrade.mjs +70 -8
  54. package/src/completions.mjs +191 -0
  55. package/src/diff.mjs +254 -0
  56. package/src/explain.mjs +258 -0
  57. package/src/federation/config.mjs +44 -0
  58. package/src/graph/load.mjs +14 -5
  59. package/src/hooks/dispatcher.mjs +110 -0
  60. package/src/hooks/gates.mjs +14 -3
  61. package/src/hooks/resolve.mjs +14 -0
  62. package/src/ignore.mjs +133 -0
  63. package/src/index/persist.mjs +66 -9
  64. package/src/journal.mjs +326 -0
  65. package/src/lease.mjs +113 -0
  66. package/src/mcp/server.mjs +15 -1
  67. package/src/mcp/tools.mjs +296 -256
  68. package/src/memory/decide.mjs +6 -1
  69. package/src/memory/lessons.mjs +12 -0
  70. package/src/output/index.mjs +82 -12
  71. package/src/package-version.mjs +49 -1
  72. package/src/packs/manage.mjs +13 -4
  73. package/src/plan/index.mjs +6 -0
  74. package/src/plugins.mjs +27 -6
  75. package/src/prbody.mjs +251 -0
  76. package/src/projection/manifest.mjs +13 -2
  77. package/src/projection/shared.mjs +4 -1
  78. package/src/secrets.mjs +231 -0
  79. package/src/status.mjs +152 -0
@@ -20,6 +20,7 @@
20
20
  // they can ask about.
21
21
 
22
22
  import { CLI } from '../name.mjs';
23
+ import { HOOKS_DIR, HOOKS_PATH_KEY } from '../hooks/shim.mjs';
23
24
 
24
25
  /**
25
26
  * The events an agent-time hook can answer, normalised across harnesses.
@@ -35,12 +36,29 @@ export const EVENTS = ['pre-tool-use', 'prompt-submit', 'post-tool-use'];
35
36
  /** The environment variable that lifts named policies for one session. */
36
37
  export const ALLOW_ENV = 'SIMT_ALLOW';
37
38
 
39
+ /**
40
+ * The environment variable that names the session a claim was filed under and the
41
+ * session an edit is asked for (R54).
42
+ *
43
+ * One name in both places on purpose: a denial is only explainable if the session the
44
+ * hook protected is the same name `simt lease` recorded, and two spellings would drift
45
+ * into a refusal nobody can reconcile with a claim that exists.
46
+ */
47
+ export const SESSION_ENV = 'SIMT_SESSION';
48
+
38
49
  /**
39
50
  * One row per policy.
40
51
  *
41
- * `inspects` names the fact the matcher is handed, and `match` the condition. The
42
- * `generated` row has no matcher because what it asks — does this file hold bytes this
43
- * tool generates — is answered by reading the file, not by the text of a payload.
52
+ * `inspects` names the fact the matcher is handed, and `match` the condition. A command
53
+ * row carries two matchers because the facts come in two states: `match` reads the
54
+ * normalised command - the shell tokenised, wrappers unwrapped, each git invocation
55
+ * reduced to subcommand and options (ADR-104) - and `regex` is the fallback that runs
56
+ * when the command cannot be tokenised at all, so a parse failure never weakens a
57
+ * policy. Facts that were never normalised - a test that hands `decide` a bare command
58
+ * string - also take the fallback, which is why it stays in the table rather than
59
+ * beside the tokenizer. The `generated` row has no matcher because what it asks — does
60
+ * this file hold bytes this tool generates — is answered by reading the file, not by
61
+ * the text of a payload.
44
62
  *
45
63
  * Order matters where two policies match one command: `git push --force` is both a
46
64
  * push and a rewrite, so both are reported and both have to be lifted before it runs.
@@ -51,7 +69,8 @@ const POLICIES = [
51
69
  adr: 'ADR-001',
52
70
  event: 'pre-tool-use',
53
71
  inspects: 'command',
54
- match: /(?:^|[\s;&|(])git\s+push(?:\s|$)/,
72
+ match: (facts) => facts.shell.facts.some((f) => f.kind === 'git' && (f.sub === 'push' || f.sub === 'send-pack')),
73
+ regex: /(?:^|[\s;&|(])git\s+push(?:\s|$)/,
55
74
  message: `Push needs explicit approval (ADR-001); ask the user.`,
56
75
  },
57
76
  {
@@ -59,12 +78,50 @@ const POLICIES = [
59
78
  adr: 'ADR-017',
60
79
  event: 'pre-tool-use',
61
80
  inspects: 'command',
62
- match:
81
+ match: (facts) =>
82
+ facts.shell.facts.some((f) => {
83
+ if (f.kind !== 'git') return false;
84
+ if (f.opts.has('--no-verify')) return true;
85
+ if (f.sub === 'commit' && (f.opts.has('--amend') || f.opts.has('n'))) return true; // -n is --no-verify
86
+ if (f.sub === 'reset' && f.opts.has('--hard')) return true;
87
+ if (f.sub === 'rebase') return true;
88
+ if (f.sub === 'pull' && f.opts.has('--rebase')) return true; // pull --rebase rebases
89
+ return (
90
+ (f.sub === 'push' || f.sub === 'send-pack') &&
91
+ (f.opts.has('--force') || f.opts.has('f') || f.refspecs.some((r) => r.startsWith('+')))
92
+ );
93
+ }),
94
+ regex:
63
95
  /--no-verify|git\s+commit\b[^;&|]*\s--amend|git\s+reset\s+--hard|git\s+rebase\b|git\s+push\b[^;&|]*(?:--force\b|\s-f\b)/,
64
96
  message:
65
97
  `Never amend, rebase, reset --hard, force-push or --no-verify unless asked; ` +
66
98
  `each is the escape that turns the declared gates into a suggestion (ADR-017).`,
67
99
  },
100
+ {
101
+ id: 'hooks-disable',
102
+ adr: 'ADR-104',
103
+ event: 'pre-tool-use',
104
+ inspects: 'command',
105
+ // No regex fallback: before ADR-104 this policy did not exist, so a command that
106
+ // cannot be tokenised is judged exactly as it was judged before this row was written.
107
+ regex: null,
108
+ match: (facts) => {
109
+ const dir = (a) => a.replace(/^\.\/+/, '').replace(/\/+$/, '');
110
+ const key = HOOKS_PATH_KEY.toLowerCase(); // config keys are case-insensitive in git
111
+ return facts.shell.facts.some((f) => {
112
+ if (f.kind === 'git') {
113
+ if (f.globals.c.some((s) => s.toLowerCase().startsWith(`${key}=`))) return true;
114
+ if (f.sub === 'config' && f.refspecs.some((r) => r.toLowerCase() === key || r.toLowerCase().startsWith(`${key}.`))) return true;
115
+ return false;
116
+ }
117
+ if (f.kind === 'rm') return f.args.some((a) => dir(a) === HOOKS_DIR || dir(a).startsWith(`${HOOKS_DIR}/`));
118
+ return false;
119
+ });
120
+ },
121
+ message:
122
+ `That removes or reroutes the git hooks this repository's gates run in, so the gates would stop ` +
123
+ `running without anyone saying so; if it is deliberate, run \`${CLI} hooks install --yes\` afterwards (ADR-104).`,
124
+ },
68
125
  {
69
126
  id: 'generated',
70
127
  adr: 'ADR-036',
@@ -75,6 +132,25 @@ const POLICIES = [
75
132
  `That file carries bytes this tool generates, so an edit there is overwritten on the next ` +
76
133
  `write; edit the source the region comes from, then run \`${CLI} sync --yes\` (ADR-036).`,
77
134
  },
135
+ {
136
+ id: 'lease',
137
+ adr: 'ADR-101',
138
+ event: 'pre-tool-use',
139
+ inspects: 'lease',
140
+ match: null,
141
+ message: `That path is leased by another session (ADR-101); wait for its TTL or ask the holder to release it.`,
142
+ // The only row whose message is not a constant: what the blocked session needs to
143
+ // hear — which path, whose, until when — is read from the claim file at decision
144
+ // time, and a static row cannot carry a timestamp it has never seen.
145
+ detail: (facts, env) => {
146
+ if (facts.leaseError) return `${facts.leaseError}; fix or remove it so the claims are readable`;
147
+ const mine = env[SESSION_ENV] ?? '';
148
+ const held = (facts.writes ?? [])
149
+ .filter((w) => w.lease && w.lease.session !== mine)
150
+ .map((w) => `${w.path} held by ${w.lease.session} until ${new Date(w.lease.expiresAt).toISOString()}`);
151
+ return held.length > 0 ? `held: ${held.join('; ')}` : '';
152
+ },
153
+ },
78
154
  ];
79
155
 
80
156
  /** The policy ids, in table order: the vocabulary an override is checked against. */
@@ -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
+ }