simt-dev 0.6.2 → 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.
- package/CHANGELOG.md +48 -1
- package/README.md +44 -15
- package/bin/simt.mjs +19 -6
- 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/README.md +44 -0
- package/package.json +1 -1
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/stacks/android.md +7 -8
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/jvm.md +1 -1
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/src/agenthooks/decide.mjs +101 -10
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/shell.mjs +523 -0
- package/src/argv.mjs +54 -9
- package/src/commands/agenthook.mjs +25 -3
- package/src/commands/ci.mjs +28 -3
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +14 -1
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/explain.mjs +52 -0
- package/src/commands/help.mjs +66 -17
- package/src/commands/hooks.mjs +208 -60
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/index.mjs +54 -100
- package/src/commands/init.mjs +82 -12
- package/src/commands/lease.mjs +136 -0
- package/src/commands/lessons.mjs +12 -0
- 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 +16 -1
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +3 -0
- package/src/commands/rules.mjs +145 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +293 -205
- package/src/commands/skillsinstall.mjs +100 -30
- package/src/commands/slice.mjs +353 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +110 -15
- package/src/commands/undo.mjs +106 -0
- package/src/commands/upgrade.mjs +70 -8
- package/src/completions.mjs +191 -0
- package/src/diff.mjs +254 -0
- package/src/explain.mjs +258 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +14 -5
- package/src/hooks/dispatcher.mjs +110 -0
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +14 -0
- package/src/ignore.mjs +133 -0
- package/src/index/persist.mjs +66 -9
- package/src/journal.mjs +326 -0
- package/src/lease.mjs +113 -0
- package/src/mcp/server.mjs +15 -1
- package/src/mcp/tools.mjs +296 -256
- package/src/memory/decide.mjs +6 -1
- package/src/memory/lessons.mjs +12 -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 +6 -0
- package/src/plugins.mjs +27 -6
- package/src/prbody.mjs +251 -0
- package/src/projection/manifest.mjs +13 -2
- package/src/projection/shared.mjs +4 -1
- package/src/secrets.mjs +231 -0
- package/src/status.mjs +152 -0
package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
simt-affects: ["src/agenthooks/shell.mjs", "src/agenthooks/policy.mjs", "src/agenthooks/decide.mjs"]
|
|
3
|
+
simt-supersedes: []
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ADR-104 — the pre-tool-use policy reads the command the way a shell reads it
|
|
7
|
+
|
|
8
|
+
Status: **accepted** · 2026-10-28
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
The two command policies — push (ADR-001) and rewrite (ADR-017) — matched the
|
|
13
|
+
payload's command string with a regular expression. Review 0003 (Q01) verified
|
|
14
|
+
against the shipped binary that an agent routes around both of them: `git -C .
|
|
15
|
+
push`, `git --no-pager push`, `sh -c 'git push'`, `$(git push)`, `(git push)`,
|
|
16
|
+
`git push;echo ok`, the alias `git p`, `git send-pack`, `git commit -n`,
|
|
17
|
+
`--amen`, `--no-verif`, `git reset --har`, `git -C . commit --amend`,
|
|
18
|
+
`git pull --rebase`, and with `SIMT_ALLOW=push`, `git push -fu` and
|
|
19
|
+
`git push origin +main`. Three acts no policy named at all: `git -c
|
|
20
|
+
core.hooksPath=/dev/null commit`, `git config core.hooksPath …` and
|
|
21
|
+
`rm -rf .simt-hooks` — the repository's gates going away without a policy ever
|
|
22
|
+
seeing them. And two false positives: `echo --no-verify` and `grep -r "git
|
|
23
|
+
rebase" docs` were denied because a flag or a phrase sat in a command that was
|
|
24
|
+
not git.
|
|
25
|
+
|
|
26
|
+
Every bypass is the same act spelled another way, and a policy that reads text
|
|
27
|
+
cannot tell them apart. The decision is made on the act.
|
|
28
|
+
|
|
29
|
+
## Ruling
|
|
30
|
+
|
|
31
|
+
1. **The command is normalised before a policy matches it.**
|
|
32
|
+
`src/agenthooks/shell.mjs` tokenises the command the way POSIX sh tokenises
|
|
33
|
+
it — quotes, escapes, `; && || | &`, newlines, subshells, `$()` and
|
|
34
|
+
backticks — descends into the wrappers a shell executes for you (`sh|bash|
|
|
35
|
+
zsh|dash -c`, `eval`, `env`, `command`, `exec`, `xargs`, `nice`, `time`,
|
|
36
|
+
`sudo`), and reduces every git invocation to the fact a policy can read:
|
|
37
|
+
which subcommand, with which options, in which repository. Long options are
|
|
38
|
+
resolved by git's unique-prefix rule (`--amen` is `--amend`), short clusters
|
|
39
|
+
are expanded (`-fu` is `-f -u`), and a leading `+` on a refspec is force.
|
|
40
|
+
The policies match that fact, not the text.
|
|
41
|
+
2. **A command the tokenizer cannot read is judged by the row's regex, never by
|
|
42
|
+
nothing.** Unbalanced quotes and an unclosed `$()` mark the command as
|
|
43
|
+
unparseable, and the pre-normaliser regex runs on it, so a parse failure
|
|
44
|
+
weakens nothing. Facts that were never normalised — a test that hands
|
|
45
|
+
`decide` a bare command string — take the same path. The push and rewrite
|
|
46
|
+
ids and messages are unchanged, so an adopted repository reads the same
|
|
47
|
+
denial it read before.
|
|
48
|
+
3. **A third command policy, `hooks-disable`, names the act the other two
|
|
49
|
+
cannot:** removing or rerouting the git hooks a repository's gates run in —
|
|
50
|
+
`git -c core.hooksPath=…`, `git config … core.hooksPath`, and `rm` of the
|
|
51
|
+
hooks directory. It carries no regex fallback because it did not exist
|
|
52
|
+
before this ruling: a command that cannot be tokenised is judged exactly as
|
|
53
|
+
it was judged before the row was written.
|
|
54
|
+
4. **The author's aliases are resolved with one `git config --get-regexp
|
|
55
|
+
'^alias\.'` per command, and only when a fact's subcommand is not one git
|
|
56
|
+
ships.** A value that starts with `!` is a shell command and is read the
|
|
57
|
+
way any other command string is; an alias that resolves to nothing the
|
|
58
|
+
table can read is left as-is, which is the same position the pre-lookup
|
|
59
|
+
policy found it in. A git that is missing or cannot answer yields no
|
|
60
|
+
aliases, and the facts stand where the pre-lookup policy would have left
|
|
61
|
+
them.
|
|
62
|
+
|
|
63
|
+
## Consequences
|
|
64
|
+
|
|
65
|
+
- The false positives go with the text: `echo --no-verify` and `grep -r "git
|
|
66
|
+
rebase" docs` carry no git fact, so the rewrite policy does not fire on
|
|
67
|
+
them.
|
|
68
|
+
- Every force spelling — `--force`, `-f` in a cluster, `+refspec` — is both a
|
|
69
|
+
push and a rewrite, so lifting `push` no longer carries a force-push
|
|
70
|
+
through, in any of the spellings the regex let past.
|
|
71
|
+
- The decision stays pure and fast: normalisation is a fact-gathering step in
|
|
72
|
+
`factsFor`, and `decide` reads facts. The latency test, which measures
|
|
73
|
+
`decide` over prebuilt facts, is unchanged.
|
|
74
|
+
- A command that names only builtin subcommands pays nothing for the lookup;
|
|
75
|
+
one that names an alias pays a single `git config` spawn with a one-second
|
|
76
|
+
ceiling.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
simt-affects: ["src/output/index.mjs"]
|
|
3
|
+
simt-supersedes: []
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ADR-105 — the JSON ceiling shrinks arrays and never cuts bytes
|
|
7
|
+
|
|
8
|
+
Status: **accepted** · 2026-10-29 · filed as review 0003 Q10
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
ADR-070 ruling 5 gave `emitJson` the byte ceiling: a document over `--max-bytes`
|
|
13
|
+
was cut at the byte count with a `TRUNCATED` line after it, the same announcement
|
|
14
|
+
the text renderer prints. Compact JSON has no newlines, so the cut landed
|
|
15
|
+
mid-token and the command exited 0: `why --json --max-bytes 120` answered with a
|
|
16
|
+
document no parser accepts and a status that called it a success.
|
|
17
|
+
|
|
18
|
+
## Ruling
|
|
19
|
+
|
|
20
|
+
1. **JSON is never byte-truncated.** `emitJson` shrinks the payload to fit: the
|
|
21
|
+
longest arrays empty first, and the document carries
|
|
22
|
+
`truncated: { field, omitted, limit }` (or `fields: [{ field, omitted }]`
|
|
23
|
+
when several lists shrank) naming exactly which list lost how many items.
|
|
24
|
+
2. **A ceiling no trimming can fit is a `MAX_BYTES` error envelope, not a cut.**
|
|
25
|
+
`{ ok: false, error: { code: 'MAX_BYTES', message, remedy } }` with
|
|
26
|
+
`truncated: { omitted, limit }`, still one parseable document. The remedy
|
|
27
|
+
reads `rerun with --max-bytes 0 for the whole document` — the same ceiling
|
|
28
|
+
the text renderer names, in the same words.
|
|
29
|
+
3. **The text ceiling is unchanged.** `emit` still cuts lines and announces the
|
|
30
|
+
omitted bytes; prose has line boundaries worth cutting at, JSON does not.
|
|
31
|
+
|
|
32
|
+
## Consequences
|
|
33
|
+
|
|
34
|
+
- Every `--json --max-bytes` answer parses, trimmed or enveloped. A reader can
|
|
35
|
+
trust that a document on stdout is the whole answer when no `truncated` key
|
|
36
|
+
rides with it.
|
|
37
|
+
- `truncated` is additive: readers that ignore unknown fields see today's shape
|
|
38
|
+
with shorter lists, so no schema bump.
|
|
39
|
+
|
|
40
|
+
## Rejected
|
|
41
|
+
|
|
42
|
+
- **Cutting at a newline for JSON too.** Compact output has none and pretty
|
|
43
|
+
output's newlines are indentation, not item boundaries; either cut still
|
|
44
|
+
breaks the parse the caller was promised.
|
package/docs/decisions/README.md
CHANGED
|
@@ -68,6 +68,50 @@ record's status line — never by editing the ruling in place.
|
|
|
68
68
|
| [058](ADR-058-one-tool-three-channels-and-the-order-is-forced.md) | One tool, three channels, and the order is forced | accepted |
|
|
69
69
|
| [059](ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md) | The version a repository runs is the one it pinned, and the gate refuses otherwise | accepted |
|
|
70
70
|
| [060](ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md) | A repository with nothing to run is adopted and says so; a gate is verified where it runs | accepted |
|
|
71
|
+
| [061](ADR-061-the-skill-registry-is-workspace-data.md) | The skill registry is workspace data, and the package ships none of it | **accepted** · 2026-10-12 · filed as S61 |
|
|
72
|
+
| [062](ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md) | A fresh adoption seeds the canonical gates it can run, and never more | **accepted** · 2026-10-13 · filed as S40 |
|
|
73
|
+
| [063](ADR-063-the-adoption-escape-hatch-is-opt-in.md) | The adoption escape hatch is opt-in, and the default writes in place | **accepted** · 2026-10-13 · filed after a consumer asked why every adoption branched |
|
|
74
|
+
| [064](ADR-064-the-context-meter-counts-what-the-tool-wrote.md) | The context meter counts what the tool wrote, and says what it did not | **accepted** · 2026-10-13 · filed after review 0002 S38 |
|
|
75
|
+
| [065](ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md) | A pack is one shared rule set, vendored into the repository and pinned by digest | **accepted** · 2026-10-13 · filed as S39 |
|
|
76
|
+
| [066](ADR-066-a-monorepo-projects-once-per-package.md) | A monorepo projects once per package | **accepted** · 2026-10-13 · filed as S41 |
|
|
77
|
+
| [067](ADR-067-which-harnesses-read-agents-md-is-a-column.md) | Which harnesses read AGENTS.md is a column, and a row without a document gets none | **accepted** · 2026-10-13 · filed as S42 |
|
|
78
|
+
| [068](ADR-068-the-mcp-revision-list-cites-what-was-read.md) | The revision list is a claim about revisions that were read | **accepted** · 2026-10-13 · filed as S43 |
|
|
79
|
+
| [069](ADR-069-the-conformance-lint-checks-the-published-specification.md) | The conformance lint checks the published specification | **accepted** · 2026-10-13 · filed as S44 |
|
|
80
|
+
| [070](ADR-070-the-json-payload-carries-its-own-version.md) | The JSON payload carries its own version | **accepted** · 2026-10-13 · filed as S51 |
|
|
81
|
+
| [071](ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md) | A rule that can be narrowed is written narrow | **accepted** · 2026-10-14 · filed as S50 |
|
|
82
|
+
| [072](ADR-072-the-budget-is-a-declaration-beside-the-gates.md) | The budget is a declaration beside the gates, not a number inside the projection | **accepted** · 2026-10-14 · filed as S38 |
|
|
83
|
+
| [073](ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md) | A kernel is a smaller projection the repository asks for, not one it is given | **accepted** · 2026-10-14 · filed as S38 (R07) · ruling 6 narrowed by ADR-084 |
|
|
84
|
+
| [074](ADR-074-a-rule-and-a-decision-are-readable-by-uri.md) | A rule and a decision are addressable, so they are readable by URI | **accepted** · 2026-10-14 · filed as S43 |
|
|
85
|
+
| [075](ADR-075-nothing-leaves-the-machine.md) | Nothing leaves the machine: no telemetry, local transcripts, loopback models by default | **accepted** · 2026-10-14 · filed as D7 (R06) |
|
|
86
|
+
| [076](ADR-076-a-change-to-what-agents-read-is-measured.md) | A change to what agents read is measured before it ships, or labelled unmeasured in its record | **accepted** · 2026-10-15 · filed as D1 (R01) |
|
|
87
|
+
| [077](ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md) | The command card leads the region, and is derived from committed evidence only | **accepted** · 2026-10-16 · filed as R10 (plan 0003) · ruling 1 narrowed by ADR-086 |
|
|
88
|
+
| [078](ADR-078-a-line-the-repository-already-proves-does-not-ship.md) | A line the repository already proves does not ship, and the author says so | **accepted** · 2026-10-16 · filed as R09 (plan 0003) |
|
|
89
|
+
| [079](ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md) | The rules are enforced at the tool call, from one policy table every harness renders | **accepted** · 2026-10-16 · filed as R15 (plan 0003) |
|
|
90
|
+
| [080](ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md) | The tool's own names are not repositories, and neither reader decides by shape | **accepted** · 2026-10-16 · settles what `rules why` calls a repository |
|
|
91
|
+
| [081](ADR-081-a-tracker-is-read-through-the-repositorys-template.md) | A tracker is read through the repository's template | **accepted** · 2026-10-16 · rulings 1 and 2 superseded by ADR-082 |
|
|
92
|
+
| [082](ADR-082-the-tracker-override-is-a-manifest-key.md) | The tracker override is a manifest key, and init keeps what it did not write | **accepted** · 2026-10-16 · supersedes ADR-081 rulings 1 and 2 |
|
|
93
|
+
| [083](ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md) | A budget is met by cutting the kernel, and the hard constraints are the floor | **accepted** · 2026-10-17 · filed as R08 (plan 0003) · ruling 7 narrowed by ADR-084 |
|
|
94
|
+
| [084](ADR-084-a-workspace-carries-its-repeated-rules-once.md) | A workspace carries its repeated rules once, and the region does not read the reader's home | **accepted** · 2026-10-17 · filed as R11 (plan 0003) |
|
|
95
|
+
| [085](ADR-085-the-region-ends-with-the-names-it-kept-out.md) | The region ends with the names of what it kept out | **accepted** · 2026-10-18 · filed as R12 (plan 0003) |
|
|
96
|
+
| [086](ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md) | The stable prefix comes first, and the lock records both digests | **accepted** · 2026-10-18 · filed as R13 (plan 0003) |
|
|
97
|
+
| [087](ADR-087-a-run-is-evidence-for-one-tree.md) | A run is evidence for one tree | **accepted** · 2026-10-18 · filed as R17 (plan 0003) |
|
|
98
|
+
| [088](ADR-088-a-marked-line-is-enforced-by-a-checker.md) | A marked line is enforced by a checker | **accepted** · 2026-10-18 · filed as R19 (plan 0003) · ruling 3 narrowed by ADR-090 |
|
|
99
|
+
| [089](ADR-089-the-local-index-is-a-cache-of-git-blobs.md) | The local index is a cache of git blobs | **accepted** · 2026-10-18 · filed as R20 (plan 0003) |
|
|
100
|
+
| [090](ADR-090-an-optional-adapter-is-off-until-named.md) | An optional adapter is off until a repository names it | **accepted** · 2026-10-18 · filed as R21 (plan 0003) |
|
|
101
|
+
| [091](ADR-091-the-map-ranks-files-a-task-mentions.md) | The map ranks the files a task mentions, and the files next to them | **accepted** · 2026-10-18 · filed as R22 (plan 0003) |
|
|
102
|
+
| [092](ADR-092-tests-are-imports-conventions-and-coverage.md) | A test is an import, a conventional neighbour, or a coverage record | **accepted** · 2026-10-18 · filed as R24 (plan 0003) |
|
|
103
|
+
| [093](ADR-093-why-names-what-governs-a-file.md) | why names what governs a file | **accepted** · 2026-10-18 · filed as R25 (plan 0003) |
|
|
104
|
+
| [094](ADR-094-a-change-runs-the-tests-that-import-it.md) | a change runs the tests that import it | **accepted** · 2026-10-18 · filed as R16 (plan 0003) |
|
|
105
|
+
| [095](ADR-095-gate-output-keeps-the-failures.md) | gate output keeps the failures | **accepted** · 2026-10-18 · filed as R18 (plan 0003) |
|
|
106
|
+
| [096](ADR-096-impact-names-who-a-change-reaches.md) | impact names who a change reaches | **accepted** · 2026-10-18 · filed as R23 (plan 0003) |
|
|
107
|
+
| [097](ADR-097-the-channels-publish-by-hand-and-the-version-rides-in-as-an-input.md) | the channels publish by hand, and the version rides in as an input | **accepted** · 2026-10-25 · ruling 3's Homebrew precondition is superseded by [ADR-098](ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md), which also restates what each channel now waits on |
|
|
108
|
+
| [098](ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md) | The fourth channel is one file, and it needs no toolchain | **accepted** · 2026-10-26 · supersedes ADR-058 ruling 5 · narrows ADR-058 ruling 7 and ADR-097 ruling 3 · supersedes ADR-059 ruling 3 · ruling 3's cross-host byte-identicality narrowed by [ADR-099](ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md) |
|
|
109
|
+
| [099](ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md) | the release verifies served bytes, and the formula mirrors them | **accepted** · 2026-10-27 · narrows ADR-098 ruling 3 |
|
|
110
|
+
| [100](ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md) | a staged diff is the last gate before history, and it runs inside the tool | **accepted** · 2026-10-27 |
|
|
111
|
+
| [101](ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md) | a file lease belongs to one session, and the pre-edit hook refuses another's edit | **accepted** · 2026-10-28 |
|
|
112
|
+
| [102](ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md) | the two rows review 0002 left queued are closed by ruling, not by authoring | **accepted** · 2026-10-28 |
|
|
113
|
+
| [103](ADR-103-co-projected-duplicates-merge-down.md) | duplicates stand alone, fingerprints generalize, references spell out | **accepted** · 2026-10-28 |
|
|
114
|
+
| [104](ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md) | the pre-tool-use policy reads the command the way a shell reads it | **accepted** · 2026-10-28 |
|
|
71
115
|
|
|
72
116
|
ADR-001 to ADR-010 resolve the contradictions found across the 16 repos in the 2026-10-05
|
|
73
117
|
audit; each record names the repos whose text it replaces. ADR-011 and ADR-012 are the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simt-dev",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "An intelligence amplifier for AI coding agents: rules and dual gates adopted by one command, plus local retrieval, task briefs, benches and repo memory — deterministic by default, with local-model adapters that propose and never select.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Said Elimam",
|
|
@@ -27,18 +27,17 @@ simt-substitutions:
|
|
|
27
27
|
# neither, declares that. A worker that serves derivatives and calls no API has no
|
|
28
28
|
# API base URL to set, so it declares only its own custom-domain binding.
|
|
29
29
|
- key: previewApiBaseUrlNote
|
|
30
|
-
default: "- Point the preview build at the preview API
|
|
30
|
+
default: "- Point the preview build at the preview API: set the preview environment's API base URL to the preview API URL."
|
|
31
31
|
describe: build-config instruction, when this repo actually has a preview API base URL to set
|
|
32
32
|
- key: googleOnlyNote
|
|
33
|
-
default: "- Production and public browser flows
|
|
33
|
+
default: "- Production and public browser flows use the production identity only."
|
|
34
34
|
describe: the identity policy this repo enforces in production
|
|
35
35
|
---
|
|
36
36
|
|
|
37
37
|
# Preview-Only Authenticated Postflight
|
|
38
38
|
|
|
39
39
|
Authenticated remote postflight and release checks use only {{previewWorkers}}, deployed
|
|
40
|
-
with
|
|
41
|
-
identity (`POSTFLIGHT_PASSWORD_UID`).
|
|
40
|
+
with a preview-only auth provider policy and a secret-bound password identity.
|
|
42
41
|
|
|
43
42
|
## Requirements
|
|
44
43
|
|
|
@@ -52,8 +51,8 @@ identity (`POSTFLIGHT_PASSWORD_UID`).
|
|
|
52
51
|
## Forbidden
|
|
53
52
|
|
|
54
53
|
- Authenticated postflight against production domains ({{productionDomains}}).
|
|
55
|
-
- Using real
|
|
56
|
-
- Demanding, pasting, or storing production
|
|
54
|
+
- Using real user accounts for feature slice verification.
|
|
55
|
+
- Demanding, pasting, or storing production OAuth tokens in chat or tickets.
|
|
57
56
|
|
|
58
57
|
## Allowed Without Account
|
|
59
58
|
|
package/rules/stacks/android.md
CHANGED
|
@@ -27,17 +27,17 @@ this profile; see `jvm`.
|
|
|
27
27
|
|
|
28
28
|
## Modules and layers
|
|
29
29
|
|
|
30
|
-
-
|
|
30
|
+
- The core domain module depends on no other internal module. Enforce it; do not add an
|
|
31
31
|
import that breaks the direction.
|
|
32
32
|
- Layer suffixes: `*Repository`, `*ViewModel`, `*UiState`, `*Screen`, `*Service`.
|
|
33
|
-
- Feature modules never
|
|
34
|
-
|
|
35
|
-
surfaces, and
|
|
36
|
-
|
|
33
|
+
- Feature modules never present a raw `Dialog` window (`androidx.compose.ui.window.Dialog`,
|
|
34
|
+
M3 `AlertDialog`, M3 `ModalBottomSheet`) that bypasses the shared modal policy.
|
|
35
|
+
Modals go through the design system's dialog surfaces, and the repo's check fails
|
|
36
|
+
the build on a bypass.
|
|
37
37
|
|
|
38
38
|
## Accessibility
|
|
39
39
|
|
|
40
|
-
- Every user-facing string is a resource, with a
|
|
40
|
+
- Every user-facing string is a resource, with a content description where an
|
|
41
41
|
icon or image carries meaning. Decorative images declare `contentDescription = null`
|
|
42
42
|
explicitly.
|
|
43
43
|
- Touch targets meet the platform minimum, and the minimum is asserted rather than
|
|
@@ -61,5 +61,4 @@ this profile; see `jvm`.
|
|
|
61
61
|
|
|
62
62
|
- Unit tests for view models, repositories and mappers; instrumentation tests for UI.
|
|
63
63
|
- `Dispatchers.setMain` in a `@BeforeTest`, restored in `@AfterTest`.
|
|
64
|
-
- Assert behaviour, not implementation. Never weaken
|
|
65
|
-
check or a fixture to match current behaviour.
|
|
64
|
+
- Assert behaviour, not implementation — including accessibility checks and fixtures. Never weaken one to match current behaviour.
|
|
@@ -41,7 +41,7 @@ Applies to: `cloudflare-workers` — a `wrangler.json`, `wrangler.jsonc` or
|
|
|
41
41
|
- Validate at the edge with the schema layer. Authorization is server-authoritative; a
|
|
42
42
|
client-side role, claim, cache entry or URL parameter grants nothing.
|
|
43
43
|
- `Request`/`Response` are globals. Do not pass them through as values into domain code.
|
|
44
|
-
- Caching defaults to `private, no-store`. Never cache
|
|
44
|
+
- Caching defaults to `private, no-store`. Never cache tokens, account data, admin
|
|
45
45
|
decisions or authenticated errors.
|
|
46
46
|
- Streaming is the default for large bodies; do not buffer without a stated reason.
|
|
47
47
|
|
package/rules/stacks/jvm.md
CHANGED
|
@@ -26,7 +26,7 @@ rules live in the `android` profile, which selects on `has-android-manifest`.
|
|
|
26
26
|
|
|
27
27
|
## Modules and layers
|
|
28
28
|
|
|
29
|
-
-
|
|
29
|
+
- The core domain module depends on no other internal module. Enforce it; do not add an
|
|
30
30
|
import that breaks the direction.
|
|
31
31
|
- Layer suffixes: `*Repository`, `*Service`, `*Mapper`, `*Controller`. A class named
|
|
32
32
|
`*Repository` that talks to HTTP is not a repository.
|
|
@@ -10,11 +10,9 @@ simt-priority: convention
|
|
|
10
10
|
simt-scope: ["app.json", "metro.config.js", "ios/**", "android/**"]
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
# Stack profile: React
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
UI is rendered in a browser gets the `react-web` profile instead; see that profile for
|
|
17
|
-
the rules this one does not repeat.
|
|
13
|
+
# Stack profile: React NativeApplies to: `react-native` — a `react-native` or `expo` dependency. A repository whose
|
|
14
|
+
UI is rendered in a browser follows the browser rules instead; nothing here assumes a
|
|
15
|
+
DOM, a stylesheet cascade, or URL-shared state.
|
|
18
16
|
|
|
19
17
|
## What does not apply here
|
|
20
18
|
|
|
@@ -17,8 +17,10 @@ UI is rendered in a browser.
|
|
|
17
17
|
|
|
18
18
|
## Types
|
|
19
19
|
|
|
20
|
-
- `strict` plus
|
|
21
|
-
|
|
20
|
+
- `strict` plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
|
|
21
|
+
`noImplicitOverride`, `noFallthroughCasesInSwitch`, `noImplicitReturns`,
|
|
22
|
+
`isolatedModules`. Generated API DTOs only; never hand-write a duplicate of a
|
|
23
|
+
type the API already produces.
|
|
22
24
|
- No `any`, no unsafe casts, no non-null boundary bypasses, no floating promises, no
|
|
23
25
|
`console.*`.
|
|
24
26
|
|
|
@@ -39,10 +41,9 @@ UI is rendered in a browser.
|
|
|
39
41
|
|
|
40
42
|
## RTL and localisation
|
|
41
43
|
|
|
42
|
-
-
|
|
43
|
-
map symbols, media and universal status meaning do not mirror.
|
|
44
|
-
- Every user-visible string exists in every supported locale in the same change.
|
|
45
|
-
locale touches tests, which assert locale parity.
|
|
44
|
+
- RTL locales mirror application chrome only. North, coordinate order, geographic pan and
|
|
45
|
+
zoom, map symbols, media and universal status meaning do not mirror.
|
|
46
|
+
- Every user-visible string exists in every supported locale in the same change.
|
|
46
47
|
|
|
47
48
|
## Accessibility
|
|
48
49
|
|
|
@@ -58,6 +59,5 @@ UI is rendered in a browser.
|
|
|
58
59
|
- Assert behaviour, not implementation. Never weaken an assertion, an accessibility check or a
|
|
59
60
|
fixture to match current behaviour.
|
|
60
61
|
- No arbitrary sleeps. No `.only`. No committed `.skip`. No flaky reruns.
|
|
61
|
-
- Inject a
|
|
62
|
-
|
|
63
|
-
artifacts.
|
|
62
|
+
- Inject sentinel values (a token, an id, a coordinate, a URL) and prove their absence
|
|
63
|
+
from unauthorised UI, storage, cache, telemetry, URL history and artifacts.
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Pure, so the whole decision is testable without a harness: hand it the facts a
|
|
4
4
|
// renderer would extract and an environment, and it answers. The filesystem is
|
|
5
|
-
// consulted in exactly
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// consulted in `factsFor` for exactly the facts a payload cannot carry — whether a
|
|
6
|
+
// file holds bytes this tool generates, who has leased it (ADR-101), and what the
|
|
7
|
+
// author's git aliases expand to (ADR-104) — and nothing else decides from disk.
|
|
8
8
|
//
|
|
9
9
|
// Two properties are deliberate and both are about an author standing still:
|
|
10
10
|
//
|
|
@@ -19,11 +19,14 @@
|
|
|
19
19
|
// understand this" into "blocked" makes the hook a false-positive machine, and a hook
|
|
20
20
|
// that blocks real work is uninstalled the same afternoon.
|
|
21
21
|
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
22
23
|
import { readFile } from 'node:fs/promises';
|
|
23
24
|
import { join, relative, resolve } from 'node:path';
|
|
24
25
|
|
|
25
26
|
import { parse } from '../projection/markers.mjs';
|
|
26
|
-
import {
|
|
27
|
+
import { activeLeases, leasePathFor, readLeases } from '../lease.mjs';
|
|
28
|
+
import { ALLOW_ENV, EVENTS, POLICY_IDS, SESSION_ENV, policiesFor } from './policy.mjs';
|
|
29
|
+
import { applyAliases, commandFacts, GIT_BUILTINS } from './shell.mjs';
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* The policies this session has lifted, and the names in `SIMT_ALLOW` that are not
|
|
@@ -42,14 +45,39 @@ export function overrides(env = {}) {
|
|
|
42
45
|
};
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Whether one row applies to the facts. A row with no matcher reads the facts itself.
|
|
50
|
+
*
|
|
51
|
+
* `env` is only consulted by `lease`, which is the one row asking who is doing this
|
|
52
|
+
* rather than what is being done — and the one row with a fact the payload never
|
|
53
|
+
* carries: an unreadable claim file denies a write, but a shell command with no file
|
|
54
|
+
* fact is not an edit (ADR-101 ruling 4).
|
|
55
|
+
*
|
|
56
|
+
* A command row is judged on the normalised facts when they exist: the shell tokenised,
|
|
57
|
+
* wrappers unwrapped, each git invocation reduced to subcommand and options (ADR-104).
|
|
58
|
+
* When the command could not be tokenised, or the facts were never normalised at all —
|
|
59
|
+
* a test that hands `decide` a bare command string — the row's regex runs instead, so
|
|
60
|
+
* a parse failure and a pre-normalised fact are both judged the way they were before
|
|
61
|
+
* the normaliser existed.
|
|
62
|
+
*/
|
|
63
|
+
function applies(row, facts, env) {
|
|
47
64
|
if (row.inspects === 'command') {
|
|
48
|
-
|
|
65
|
+
if (typeof facts.command !== 'string') return false;
|
|
66
|
+
if (facts.shell !== null && facts.shell !== undefined && facts.shell.parsed === true) {
|
|
67
|
+
return row.match(facts);
|
|
68
|
+
}
|
|
69
|
+
return row.regex !== null && row.regex.test(facts.command);
|
|
49
70
|
}
|
|
50
71
|
if (row.inspects === 'write') {
|
|
51
72
|
return (facts.writes ?? []).some((w) => w.generated !== null && w.generated !== undefined);
|
|
52
73
|
}
|
|
74
|
+
if (row.inspects === 'lease') {
|
|
75
|
+
const writes = facts.writes ?? [];
|
|
76
|
+
if (writes.length === 0) return false;
|
|
77
|
+
if (facts.leaseError) return true;
|
|
78
|
+
const session = typeof env[SESSION_ENV] === 'string' ? env[SESSION_ENV] : '';
|
|
79
|
+
return writes.some((w) => w.lease && w.lease.session !== session);
|
|
80
|
+
}
|
|
53
81
|
return false;
|
|
54
82
|
}
|
|
55
83
|
|
|
@@ -62,7 +90,7 @@ function applies(row, facts) {
|
|
|
62
90
|
*/
|
|
63
91
|
export function decide({ event = EVENTS[0], facts = {}, env = {} } = {}) {
|
|
64
92
|
const { ids: lifted, unknown } = overrides(env);
|
|
65
|
-
const matched = policiesFor(event).filter((p) => applies(p, facts));
|
|
93
|
+
const matched = policiesFor(event).filter((p) => applies(p, facts, env));
|
|
66
94
|
const blocked = matched.filter((p) => !lifted.has(p.id));
|
|
67
95
|
return {
|
|
68
96
|
event,
|
|
@@ -70,7 +98,14 @@ export function decide({ event = EVENTS[0], facts = {}, env = {} } = {}) {
|
|
|
70
98
|
blocked: blocked.map((p) => p.id),
|
|
71
99
|
lifted: matched.filter((p) => lifted.has(p.id)).map((p) => p.id),
|
|
72
100
|
unknown,
|
|
73
|
-
|
|
101
|
+
// A row may carry a `detail` for the parts of its message that are only knowable
|
|
102
|
+
// at decision time (the lease holder's name and expiry, ADR-101 ruling 5). Every
|
|
103
|
+
// row without one renders exactly as before, override hint included.
|
|
104
|
+
messages: blocked.map((p) => {
|
|
105
|
+
const detail = typeof p.detail === 'function' ? p.detail(facts, env) : null;
|
|
106
|
+
const body = detail ? `${p.message} ${detail}` : p.message;
|
|
107
|
+
return `${body} (override: ${ALLOW_ENV}=${p.id})`;
|
|
108
|
+
}),
|
|
74
109
|
adrs: blocked.map((p) => p.adr),
|
|
75
110
|
};
|
|
76
111
|
}
|
|
@@ -114,10 +149,66 @@ export async function generatedBytes(root, file) {
|
|
|
114
149
|
return null;
|
|
115
150
|
}
|
|
116
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The author's git aliases, name to value, as `git config` spells them.
|
|
154
|
+
*
|
|
155
|
+
* One spawn for the whole command, and only when a fact needs it: the lookup reads the
|
|
156
|
+
* configuration the agent's own `git p` would read, and a git that is missing or cannot
|
|
157
|
+
* answer yields no aliases, which leaves the facts exactly where the pre-lookup policy
|
|
158
|
+
* would have found them.
|
|
159
|
+
*/
|
|
160
|
+
function gitAliases(root) {
|
|
161
|
+
const result = spawnSync('git', ['-C', root, 'config', '--get-regexp', '^alias\\.'], {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
timeout: 1000,
|
|
164
|
+
});
|
|
165
|
+
const aliases = new Map();
|
|
166
|
+
if (result.status !== 0) return aliases;
|
|
167
|
+
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
168
|
+
const m = /^alias\.(\S+)\s+(.*)$/.exec(line);
|
|
169
|
+
if (m !== null) aliases.set(m[1], m[2]);
|
|
170
|
+
}
|
|
171
|
+
return aliases;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The normalised command facts the table asks about: the shell tokenised, wrappers
|
|
176
|
+
* unwrapped, each git or rm invocation reduced to what it runs.
|
|
177
|
+
*
|
|
178
|
+
* A subcommand git does not ship may be the author's alias, and what it expands to is
|
|
179
|
+
* read from their git configuration - the one step in this path that leaves the
|
|
180
|
+
* module. A command that cannot be tokenised is marked, not dropped: the policies fall
|
|
181
|
+
* back to their regex rather than seeing nothing (ADR-104).
|
|
182
|
+
*/
|
|
183
|
+
async function normalizeCommand(root, command) {
|
|
184
|
+
const parsed = commandFacts(command); // non-null: `command` is a string here
|
|
185
|
+
if (parsed === null || !parsed.parsed) return { parsed: false };
|
|
186
|
+
if (parsed.facts.some((f) => f.kind === 'git' && !GIT_BUILTINS.has(f.sub))) {
|
|
187
|
+
parsed.facts = applyAliases(parsed.facts, gitAliases(root));
|
|
188
|
+
}
|
|
189
|
+
return parsed;
|
|
190
|
+
}
|
|
191
|
+
|
|
117
192
|
/** Turn a renderer's extraction into the facts the table asks about. */
|
|
118
193
|
export async function factsFor(root, { command = null, writes = [] } = {}) {
|
|
119
194
|
const resolved = await Promise.all(
|
|
120
195
|
writes.filter(Boolean).map(async (path) => ({ path, generated: await generatedBytes(root, path) })),
|
|
121
196
|
);
|
|
122
|
-
|
|
197
|
+
// Shell facts, read only when the payload carried a command: the same "pay for what
|
|
198
|
+
// you use" rule as the lease facts below, which a payload with no command cannot touch.
|
|
199
|
+
const shell = typeof command === 'string' ? await normalizeCommand(root, command) : null;
|
|
200
|
+
// Lease facts, read only when the payload named a file to write: the claim file is
|
|
201
|
+
// irrelevant to a shell command, and a payload with no file pays nothing for a
|
|
202
|
+
// feature it cannot touch. Expired claims are dropped here because the hook runs
|
|
203
|
+
// now, so a TTL needs no sweeper; ownership is left whole because `decide` is the
|
|
204
|
+
// one that knows which session is asking (ADR-101 ruling 3).
|
|
205
|
+
if (resolved.length === 0) return { command, writes: resolved, leaseError: null, shell };
|
|
206
|
+
const read = await readLeases(root);
|
|
207
|
+
if (read.error !== null) return { command, writes: resolved, leaseError: read.error, shell };
|
|
208
|
+
const live = new Map(activeLeases(read).map((l) => [l.path, l]));
|
|
209
|
+
for (const w of resolved) {
|
|
210
|
+
const rel = leasePathFor(root, w.path);
|
|
211
|
+
w.lease = rel === null ? null : live.get(rel) ?? null;
|
|
212
|
+
}
|
|
213
|
+
return { command, writes: resolved, leaseError: null, shell };
|
|
123
214
|
}
|
|
@@ -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.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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:
|
|
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. */
|