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.
Files changed (76) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  5. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  6. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  7. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  8. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  9. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  10. package/docs/decisions/README.md +44 -0
  11. package/package.json +1 -1
  12. package/rules/mdc/preview-postflight.mdc +5 -6
  13. package/rules/stacks/android.md +7 -8
  14. package/rules/stacks/cloudflare-workers.md +1 -1
  15. package/rules/stacks/container.md +2 -0
  16. package/rules/stacks/jvm.md +1 -1
  17. package/rules/stacks/react-native.md +3 -5
  18. package/rules/stacks/react-web.md +9 -9
  19. package/src/agenthooks/decide.mjs +101 -10
  20. package/src/agenthooks/policy.mjs +81 -5
  21. package/src/agenthooks/shell.mjs +523 -0
  22. package/src/argv.mjs +54 -9
  23. package/src/commands/agenthook.mjs +25 -3
  24. package/src/commands/ci.mjs +28 -3
  25. package/src/commands/completions.mjs +27 -0
  26. package/src/commands/decide.mjs +14 -1
  27. package/src/commands/doctor.mjs +367 -0
  28. package/src/commands/explain.mjs +52 -0
  29. package/src/commands/help.mjs +66 -17
  30. package/src/commands/hooks.mjs +208 -60
  31. package/src/commands/ignore.mjs +152 -0
  32. package/src/commands/index.mjs +54 -100
  33. package/src/commands/init.mjs +82 -12
  34. package/src/commands/lease.mjs +136 -0
  35. package/src/commands/lessons.mjs +12 -0
  36. package/src/commands/mcp.mjs +2 -2
  37. package/src/commands/pack.mjs +9 -0
  38. package/src/commands/plan.mjs +13 -1
  39. package/src/commands/plugin.mjs +16 -1
  40. package/src/commands/prbody.mjs +45 -0
  41. package/src/commands/repoindex.mjs +3 -0
  42. package/src/commands/rules.mjs +145 -105
  43. package/src/commands/shared.mjs +36 -5
  44. package/src/commands/skills.mjs +293 -205
  45. package/src/commands/skillsinstall.mjs +100 -30
  46. package/src/commands/slice.mjs +353 -0
  47. package/src/commands/status.mjs +62 -0
  48. package/src/commands/sync.mjs +110 -15
  49. package/src/commands/undo.mjs +106 -0
  50. package/src/commands/upgrade.mjs +70 -8
  51. package/src/completions.mjs +191 -0
  52. package/src/diff.mjs +254 -0
  53. package/src/explain.mjs +258 -0
  54. package/src/federation/config.mjs +44 -0
  55. package/src/graph/load.mjs +14 -5
  56. package/src/hooks/dispatcher.mjs +110 -0
  57. package/src/hooks/gates.mjs +14 -3
  58. package/src/hooks/resolve.mjs +14 -0
  59. package/src/ignore.mjs +133 -0
  60. package/src/index/persist.mjs +66 -9
  61. package/src/journal.mjs +326 -0
  62. package/src/lease.mjs +113 -0
  63. package/src/mcp/server.mjs +15 -1
  64. package/src/mcp/tools.mjs +296 -256
  65. package/src/memory/decide.mjs +6 -1
  66. package/src/memory/lessons.mjs +12 -0
  67. package/src/output/index.mjs +82 -12
  68. package/src/package-version.mjs +49 -1
  69. package/src/packs/manage.mjs +13 -4
  70. package/src/plan/index.mjs +6 -0
  71. package/src/plugins.mjs +27 -6
  72. package/src/prbody.mjs +251 -0
  73. package/src/projection/manifest.mjs +13 -2
  74. package/src/projection/shared.mjs +4 -1
  75. package/src/secrets.mjs +231 -0
  76. package/src/status.mjs +152 -0
@@ -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.
@@ -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.2",
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 Worker: set `SOCIAL_API_BASE_URL` in the Cloudflare Workers Builds preview environment to the preview API Worker URL."
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 remain Google-only."
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 `AUTH_PROVIDER_POLICY=preview_postflight_password` and a secret-bound Firebase password
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 Social Google accounts for feature slice verification.
56
- - Demanding, pasting, or storing production Google OAuth tokens in chat or tickets.
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
 
@@ -27,17 +27,17 @@ this profile; see `jvm`.
27
27
 
28
28
  ## Modules and layers
29
29
 
30
- - `:core:domain` depends on no other internal module. Enforce it; do not add an
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 import `androidx.compose.ui.window.Dialog`, M3 `AlertDialog`
34
- or M3 `ModalBottomSheet`. The raw `Dialog` window belongs to allowlisted `core:ui`
35
- surfaces, and `checkOverlayModalPolicy` fails `./gradlew check` when a feature
36
- bypasses it.
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 `cd_` content description where an
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 an assertion, an accessibility
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 token, account, block, reveal, admin
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
 
@@ -7,6 +7,8 @@ simt-scope: ["Dockerfile", "*.dockerfile"]
7
7
  simt-fallback: false
8
8
  ---
9
9
 
10
+ # Stack profile: Containers
11
+
10
12
  ## Containers
11
13
 
12
14
  A Dockerfile is the build. If a rule here and the repo's own instructions disagree, the
@@ -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
- - `:core:domain` depends on no other internal module. Enforce it; do not add an
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 Native
14
-
15
- Applies to: `react-native` — a `react-native` or `expo` dependency. A repository whose
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 the full strictness set listed in the Workers profile. Generated API DTOs only;
21
- never hand-write a duplicate of a type the API already produces.
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
- - Arabic mirrors application chrome only. North, coordinate order, geographic pan and zoom,
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. Adding a
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 sentinel token, UID, text, gender, coordinate, URL and private media value, and
62
- prove its absence from unauthorised UI, storage, cache, telemetry, URL history and
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 one place — `generatedBytes`, which asks whether a file being
6
- // written to holds bytes this tool generates — because that is the one fact a payload
7
- // cannot carry.
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 { ALLOW_ENV, EVENTS, POLICY_IDS, policiesFor } from './policy.mjs';
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
- /** Whether one row applies to the facts. A row with no matcher reads the facts itself. */
46
- function applies(row, facts) {
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
- return typeof facts.command === 'string' && row.match !== null && row.match.test(facts.command);
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
- messages: blocked.map((p) => `${p.message} (override: ${ALLOW_ENV}=${p.id})`),
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
- return { command, writes: resolved };
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. 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. */