simt-dev 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +129 -75
  3. package/bin/simt-dev.mjs +21 -2
  4. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  5. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  6. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  7. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  8. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  9. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  10. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  11. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  12. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  13. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  14. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  15. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  16. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  17. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  18. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  19. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  20. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  21. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  22. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  23. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  24. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  25. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  26. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  27. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  28. package/package.json +2 -1
  29. package/rules/core.md +1 -1
  30. package/src/agenthooks/decide.mjs +123 -0
  31. package/src/agenthooks/policy.mjs +78 -0
  32. package/src/agenthooks/render.mjs +240 -0
  33. package/src/argv.mjs +54 -1
  34. package/src/bench/ablate.mjs +630 -0
  35. package/src/bench/adapters.json +149 -0
  36. package/src/bench/grade.mjs +229 -0
  37. package/src/bench/mine.mjs +811 -0
  38. package/src/bench/probes.mjs +1350 -0
  39. package/src/bench/report.mjs +899 -0
  40. package/src/bench/run.mjs +1002 -0
  41. package/src/ci/workflow.mjs +11 -1
  42. package/src/commands/agenthook.mjs +282 -0
  43. package/src/commands/bench.mjs +371 -0
  44. package/src/commands/evidence.mjs +96 -0
  45. package/src/commands/gate.mjs +165 -0
  46. package/src/commands/harness.mjs +156 -5
  47. package/src/commands/help.mjs +89 -1
  48. package/src/commands/hooks.mjs +45 -5
  49. package/src/commands/impact.mjs +205 -0
  50. package/src/commands/index.mjs +60 -1
  51. package/src/commands/map.mjs +91 -0
  52. package/src/commands/plan.mjs +99 -0
  53. package/src/commands/repoindex.mjs +52 -0
  54. package/src/commands/shared.mjs +398 -0
  55. package/src/commands/sync.mjs +72 -3
  56. package/src/commands/tests.mjs +192 -0
  57. package/src/commands/tokens.mjs +61 -1
  58. package/src/commands/why.mjs +177 -0
  59. package/src/decisions/index.mjs +23 -5
  60. package/src/graph/spec.mjs +1 -1
  61. package/src/hooks/dispatcher.mjs +15 -0
  62. package/src/hooks/shim.mjs +31 -0
  63. package/src/index/compact.mjs +228 -0
  64. package/src/index/extract.mjs +331 -0
  65. package/src/index/gate.mjs +101 -0
  66. package/src/index/govern.mjs +222 -0
  67. package/src/index/impact.mjs +186 -0
  68. package/src/index/map.mjs +393 -0
  69. package/src/index/persist.mjs +172 -0
  70. package/src/index/store.mjs +301 -0
  71. package/src/index/tests.mjs +294 -0
  72. package/src/kernel/budget.mjs +100 -0
  73. package/src/kernel/card.mjs +254 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/ledger/index.mjs +112 -0
  77. package/src/mcp/resources.mjs +157 -0
  78. package/src/mcp/server.mjs +37 -6
  79. package/src/mcp/tools.mjs +84 -0
  80. package/src/measure/context.mjs +184 -11
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/plan/index.mjs +650 -0
  84. package/src/plan/template.mjs +331 -0
  85. package/src/projection/disclosure.mjs +92 -0
  86. package/src/projection/loaders.mjs +689 -0
  87. package/src/projection/lock.mjs +93 -0
  88. package/src/projection/manifest.mjs +78 -3
  89. package/src/projection/markers.mjs +56 -17
  90. package/src/projection/project.mjs +24 -17
  91. package/src/projection/rules.mjs +452 -43
  92. package/src/projection/shared.mjs +428 -0
  93. package/src/projection/surfaces.mjs +22 -8
  94. package/src/projection/targets.mjs +2 -1
  95. package/src/toolwords.mjs +71 -0
  96. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,60 @@
1
+ # ADR-091 — The map ranks the files a task mentions, and the files next to them
2
+
3
+ Status: **accepted** · 2026-10-18 · filed as R22 (plan 0003)
4
+
5
+ **Affects:** `src/index/map.mjs`, `src/commands/map.mjs`, `src/commands/bench.mjs`,
6
+ `test/map.test.mjs`
7
+
8
+ ## Context
9
+
10
+ A task names a behaviour. The files that behaviour lives in are a small part of
11
+ the tree, and an agent that reads the tree in path order spends its budget on
12
+ files the task never touches. The index now records imports. What it did not
13
+ do is say which of those files to read first.
14
+
15
+ The mined tasks store a gold patch outside the worktree. The published task
16
+ record counts the files and the lines. It does not list the paths. Recall has
17
+ to be read from the patch, and a machine without that patch cannot invent the
18
+ denominator.
19
+
20
+ ## Rulings
21
+
22
+ 1. **The map is PageRank over import and call edges, personalised to the task.**
23
+ Twenty iterations, damping 0.85. An edge counts in both directions, once.
24
+ A file the task mentions sorts ahead of a file it does not: on this graph
25
+ the file every module imports otherwise occupies the top 10. A term that
26
+ labels too many paths is ignored. A token that contains a digit and names
27
+ one path counts more the more often the task says it, so a citation does
28
+ not rank with the subject. A file that already matched, in a directory of
29
+ at most eight files, is lifted toward its neighbour. Files the task does
30
+ not mention, and that are not one edge away from a file it does, are left
31
+ out. With no task text the whole graph is ranked. Equal ranks break by
32
+ path, ascending. The same graph and the same text return the same order.
33
+
34
+ 2. **The rendering fits a budget, at one of three levels.** `outline` is paths.
35
+ `names` adds the exported symbols. `signatures` adds the line they were
36
+ declared on. The default budget is 2048 bytes. Zero means no ceiling. The
37
+ first file is kept when it alone is over the budget, so a map of a real
38
+ ranking is not an empty page.
39
+
40
+ 3. **recall@10 is the mean, over mined tasks, of the fraction of gold files
41
+ that land in the top 10.** The gold paths are the destination side of the
42
+ patch. A task whose patch is not in the vault is unmeasured, not a miss.
43
+ `simt bench report` prints the figure. It does not fail the cost report
44
+ when the figure cannot be computed.
45
+
46
+ ## Consequences
47
+
48
+ `simt map` reads the index and rebuilds it when the cache is empty. The
49
+ comparison against the mined patches is a property of this checkout's index
50
+ and of the vault on this machine. A checkout with no index, or no vault,
51
+ reports the figure as unmeasured. On the 20 mined tasks available here, mean
52
+ recall@10 is 55.6% of gold files. The bar is 60%, and this ranking does not
53
+ meet it.
54
+
55
+ ## Rejected
56
+
57
+ - **One-directional edges.** Rank would flow only toward the imported file, and
58
+ the test that imports the file the task named would never surface.
59
+ - **A zero when the gold patch is absent.** That number would punish a machine
60
+ for a file the benchmark hides, and it would read as a measurement.
@@ -0,0 +1,60 @@
1
+ # ADR-092 — A test is an import, a conventional neighbour, or a coverage record
2
+
3
+ Status: **accepted** · 2026-10-18 · filed as R24 (plan 0003)
4
+
5
+ **Affects:** `src/index/tests.mjs`, `src/commands/tests.mjs`, `test/mapping.test.mjs`
6
+
7
+ ## Context
8
+
9
+ The index records imports. A test file that imports a source file is evidence
10
+ that the test exercises it, and so is a coverage report that names both. A
11
+ shared commit is weaker: most commits touch a test and several sources that
12
+ the test does not exercise.
13
+
14
+ The line extractor reads one line at a time, so an import whose specifier sits
15
+ on the next line is absent from the stored edges. The mapping has to see that
16
+ specifier or it misses the tests this repository actually writes.
17
+
18
+ ## Rulings
19
+
20
+ 1. **The default answer uses only the precise edges.** Kind `test`, weight 1.
21
+ A test file imports a tracked source file, including a dynamic `import()`.
22
+ Or the conventional source beside the test exists: `foo_test.go` beside
23
+ `foo.go`, `test_foo.py` beside `foo.py`, `FooTest` beside `Foo` in the same
24
+ directory, `foo.test.mjs` beside `foo.mjs`, and `src/test/java` mirrored to
25
+ `src/main/java`. Or a coverage record names both sides. lcov pairs a source
26
+ only when the test name is non-empty. JaCoCo pairs a source file with the
27
+ one class whose stem is that file plus `Test` or `Tests`. A Go profile
28
+ counts a hit, and the test is `stem_test.go` in that directory, or the
29
+ single `*_test.go` there. A file that is not one of those is not an edge.
30
+
31
+ 2. **Co-change is a different kind, and it is off unless asked.** Kind
32
+ `cochange`. A pair counts when it appears in at least two commits. A commit
33
+ with more than five tests or more than fifteen sources is skipped. Each
34
+ test keeps its eight strongest pairs. History is the last 200 non-merge
35
+ commits. `simt tests --cochange` appends those files after the precise
36
+ ones. A git failure leaves the history empty; the import map still answers.
37
+
38
+ 3. **The edges are computed when asked, and not stored.** The index write
39
+ replaces every edge of a changed path. Storing the test map in that write
40
+ would drop the import edges, and reading the commit log inside a reindex
41
+ would miss the one-file time bar. Coverage is read from a fixed list of
42
+ paths. A path that is absent is not a failure.
43
+
44
+ 4. **Precision is the share of predicted tests that the label also names.**
45
+ An empty prediction does not pass. On the 30 hand-labelled files in this
46
+ checkout the figure is 1.0. The bar is 0.8.
47
+
48
+ ## Consequences
49
+
50
+ `simt tests` with no path prints how many test edges and how many co-change
51
+ edges the tree has. `simt tests <path>` lists the tests for that file. A path
52
+ the index does not contain is a usage error. A directory that is not a git
53
+ repository is unsupported.
54
+
55
+ ## Rejected
56
+
57
+ - **A stem that is unique somewhere else in the tree.** The test named for a
58
+ directory is not the test of the one file that happens to share that stem.
59
+ - **Writing the test edges into the index.** The next reindex of a touched
60
+ path would have to know about every edge kind or it would delete them.
@@ -0,0 +1,58 @@
1
+ # ADR-093 — why names what governs a file
2
+
3
+ Status: **accepted** · 2026-10-18 · filed as R25 (plan 0003)
4
+
5
+ **Affects:** `src/index/govern.mjs`, `src/commands/why.mjs`, `test/why.test.mjs`,
6
+ `test/why-fixture.test.mjs`, `test/why-snapshot.test.mjs`
7
+
8
+ ## Context
9
+
10
+ A decision says which files it governs in an Affects line. The line is prose.
11
+ It wraps. It names a command, a path from the repository root, a path from a
12
+ source directory, or a phrase that is not a location at all. A reader who
13
+ greps the word Affects still has to decide which of those a backtick was.
14
+
15
+ Rules, skills, gates and lessons name paths in their own way. The question
16
+ `why` answers is the same for each: does this one govern this file?
17
+
18
+ ## Rulings
19
+
20
+ 1. **The Affects line contributes the tokens in backticks, and nothing else.**
21
+ Continuation lines are part of the paragraph. A blank line, a heading, or
22
+ the next field ends it. The token is resolved in order: a command prefixed
23
+ with the tool name, when that command exists; a path or a glob at the
24
+ repository root; the same path under each directory named src. The tool
25
+ name alone is not a command. A token that is none of these is reported as
26
+ `UNRESOLVED-AFFECTS`, with the record it came from. It is not dropped and
27
+ it is not guessed.
28
+
29
+ 2. **The other governors are the path lists they already publish.** A rule
30
+ governs a file its scope matches. An applies entry governs a file only when
31
+ the entry is itself a path. A skill governs a file its paths match. A
32
+ staged gate governs a file of an extension it formats, and a manifest
33
+ command governs a file it names. A lesson governs a file its trigger names.
34
+ A missing lessons directory, a missing skill registry, or a missing
35
+ manifest is an empty list.
36
+
37
+ 3. **The answer is computed when asked, and not stored.** The file list is
38
+ the paths git tracks. Writing the index to answer the question would change
39
+ the tree the question is about.
40
+
41
+ ## Consequences
42
+
43
+ `simt why <path>` prints one line for each decision, rule, skill, gate and
44
+ lesson that governs that path. `simt why` with no path prints the unresolved
45
+ Affects tokens. A path git does not track is a usage error. A directory that
46
+ is not a git repository is unsupported.
47
+
48
+ On the tree at 4c27514, sixteen Affects lines were the whole set, and the
49
+ only record naming the dispatcher was ADR-017. Records written after that
50
+ commit name it as well, and `why` lists those too.
51
+
52
+ ## Rejected
53
+
54
+ - **Attaching every always-on rule to every file.** The rule governs the
55
+ repository. Listing it on each file would hide the ones that actually name
56
+ the file.
57
+ - **Treating an unresolved token as a path that might exist later.** The
58
+ report is the point. A guess would make the report empty.
@@ -0,0 +1,44 @@
1
+ # ADR-094 — a change runs the tests that import it
2
+
3
+ Status: **accepted** · 2026-10-18 · filed as R16 (plan 0003)
4
+
5
+ **Affects:** `src/index/gate.mjs`, `src/commands/gate.mjs`, `test/gate.test.mjs`
6
+
7
+ ## Context
8
+
9
+ A gate that always runs the whole suite teaches people to skip it. The tests
10
+ that can see a change are the ones that import it, including through another
11
+ file. A name that looks like a test, and a commit that also touched a test,
12
+ are different facts. They are already answered by `simt tests`.
13
+
14
+ The pre-commit hook, the stop hook and an agent call this command. They do
15
+ not need the dispatcher rewritten to do it.
16
+
17
+ ## Rulings
18
+
19
+ 1. **Selection is the import closure.** A test file is selected when an import
20
+ reaches a changed file, at any depth. A changed file that is itself a test
21
+ is selected. A cycle is walked once. A conventional neighbour and a shared
22
+ commit are not imports, so they are not a selection. A file the test-path
23
+ rule already treats as a test counts, including a helper that lives beside
24
+ the tests and imports the change.
25
+ 2. **The command is `simt gate`.** Paths name the change. `--changed` reads
26
+ the diff, staged, unstaged and untracked. No path and no `--changed` is a
27
+ usage error. A clean diff selects nothing and runs nothing. A repository
28
+ that is not git is unsupported.
29
+ 3. **The run is the package's own runner, and only the selected files.** Tests
30
+ are grouped by the package that contains them. The runner is the one that
31
+ package already declares. A package with no runner is unsupported. The
32
+ child's test-runner environment is cleared, so a parent test process cannot
33
+ take the child's result and report a pass.
34
+ 4. **The hand list for the plan's named file is 51 files, and the selection
35
+ finished in under 5 seconds.** Eight other test files do not import it, and
36
+ they are not selected. The set is wide because the shared fixture imports
37
+ the loader that imports that file. Measured at about 40 ms.
38
+
39
+ ## Consequences
40
+
41
+ `simt tests` still answers "which tests exercise this file", including a
42
+ conventional name and, when asked, a shared commit. `simt gate` answers "run
43
+ the tests this change can reach". Compaction of the runner's text is a later
44
+ slice. The dispatcher is unchanged.
@@ -0,0 +1,31 @@
1
+ # ADR-095 — gate output keeps the failures
2
+
3
+ Status: **accepted** · 2026-10-18 · filed as R18 (plan 0003)
4
+
5
+ **Affects:** `src/index/compact.mjs`, `src/commands/gate.mjs`, `test/compact.test.mjs`
6
+
7
+ ## Context
8
+
9
+ A gate that prints every passing test spends the reader's attention on the
10
+ lines that did not fail. A summary that drops a failure is worse: the run
11
+ looks cleaner than it was. The formats this tool's own runners emit are
12
+ known. An arbitrary shell log is not one of them, and guessing at it is how
13
+ a line gets lost.
14
+
15
+ ## Rulings
16
+
17
+ 1. **Compaction applies to a report this tool produced, and only to a format
18
+ it can read.** Those formats are TAP, JUnit XML, vitest or jest JSON, and
19
+ `go test -json`. Pytest's junit file is JUnit XML. A stream that is none
20
+ of these is printed unchanged.
21
+ 2. **Failures come first, and each one is kept.** The name, the assertion
22
+ and the location stay in the printed block. Passing tests become a count.
23
+ A skip becomes a count when there is one.
24
+ 3. **The raw log path is printed.** The log is gitignored. The command does
25
+ not wrap an arbitrary shell command. The gate is the caller.
26
+
27
+ ## Consequences
28
+
29
+ `simt gate` is the command that compacts. Another command that wants the same
30
+ treatment calls the same function. A format that is not in the list stays
31
+ raw until it is added with a golden fixture.
@@ -0,0 +1,38 @@
1
+ # ADR-096 — impact names who a change reaches
2
+
3
+ Status: **accepted** · 2026-10-18 · filed as R23 (plan 0003)
4
+
5
+ **Affects:** `src/index/impact.mjs`, `src/commands/impact.mjs`, `test/impact.test.mjs`
6
+
7
+ ## Context
8
+
9
+ A change has a neighbourhood: the files that import it, the tests of that
10
+ neighbourhood, the decisions and rules that govern the file, and the owner a
11
+ CODEOWNERS line names. In a workspace the neighbourhood crosses a repository
12
+ when another one references a contract this change touches. That is the check
13
+ behind "the producer ships before the consumer".
14
+
15
+ ## Rulings
16
+
17
+ 1. **The argument is a tracked path, or the name of one export.** A private
18
+ symbol is not an export. Several files exporting the same name is a usage
19
+ error that names those files. A name that is neither a path nor an export
20
+ is a usage error. A tree that is not git is unsupported.
21
+ 2. **Importers are the non-test files that import the target, at any depth.**
22
+ The tests are the test files that import that closure. Both lists stop at
23
+ 64. Past that, the report says it was truncated.
24
+ 3. **Governance is what `why` would name for the file, limited to decisions
25
+ and rules.** Owners are the last matching pattern in the first CODEOWNERS
26
+ file that exists. A pattern with no slash matches the file name in any
27
+ directory.
28
+ 4. **A cross-repo consumer is another repository whose text contains a
29
+ contract path this change touches.** A contract is an OpenAPI document or
30
+ a JSON Schema file. The path has to include a directory. No workspace
31
+ means no consumers, and that is not a failure. The scan stops at 64
32
+ references and 2000 files in a repository.
33
+
34
+ ## Consequences
35
+
36
+ `simt impact` answers who is reached. `simt gate` still runs the tests.
37
+ `simt why` still answers governance on its own, including skills, gates and
38
+ lessons, which this report does not repeat.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simt-dev",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Shared agent rules, skills and git hooks for a repository: one command adopts a repo, and every gate it declares is probed before it is written.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Said Elimam",
@@ -20,6 +20,7 @@
20
20
  "bin",
21
21
  "src",
22
22
  "rules",
23
+ "templates",
23
24
  "docs/decisions",
24
25
  "CHANGELOG.md"
25
26
  ],
package/rules/core.md CHANGED
@@ -60,7 +60,7 @@ commands belongs in this repository, not here.
60
60
 
61
61
  - No type-system escapes, no unawaited async work, no debug printing in application code.
62
62
  - 400 lines is a review trigger; 1000 lines is a hard ceiling. Generated files are exempt
63
- when the hook names the generator. (ADR-005)
63
+ when the hook names the generator. (ADR-005) <!-- simt-check: max-file-lines -->
64
64
  - Never weaken CSP, CORS, auth, audit, cache or validation controls to silence a test, warning
65
65
  or alert.
66
66
  - Review verdicts are exactly three: `BLOCK`, `APPROVE WITH RESIDUAL RISK` (must list what was
@@ -0,0 +1,123 @@
1
+ // What a policy decides, given facts and one session's overrides.
2
+ //
3
+ // Pure, so the whole decision is testable without a harness: hand it the facts a
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.
8
+ //
9
+ // Two properties are deliberate and both are about an author standing still:
10
+ //
11
+ // Every matching policy is reported, not the first. `git push --force` is a push and a
12
+ // rewrite; a build that stopped at the first match would let `SIMT_ALLOW=push` lift the
13
+ // approval rule and carry a force-push through, because the rewrite rule was never
14
+ // consulted. Lifting a policy lifts that policy.
15
+ //
16
+ // No fact is not a violation. A payload whose shape we did not read — a harness that
17
+ // renamed a tool, an agent writing through some tool we have never seen — yields no
18
+ // command and no path, and the decision is to say nothing. Turning "we do not
19
+ // understand this" into "blocked" makes the hook a false-positive machine, and a hook
20
+ // that blocks real work is uninstalled the same afternoon.
21
+
22
+ import { readFile } from 'node:fs/promises';
23
+ import { join, relative, resolve } from 'node:path';
24
+
25
+ import { parse } from '../projection/markers.mjs';
26
+ import { ALLOW_ENV, EVENTS, POLICY_IDS, policiesFor } from './policy.mjs';
27
+
28
+ /**
29
+ * The policies this session has lifted, and the names in `SIMT_ALLOW` that are not
30
+ * policies at all.
31
+ *
32
+ * An unknown name is reported rather than ignored: `SIMT_ALLOW=push,rewrit` typed once
33
+ * and relied on would otherwise look like a lift that did nothing, and the author would
34
+ * conclude the hook was broken rather than the spelling.
35
+ */
36
+ export function overrides(env = {}) {
37
+ const raw = env[ALLOW_ENV];
38
+ const named = typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : [];
39
+ return {
40
+ ids: new Set(named.filter((n) => POLICY_IDS.includes(n))),
41
+ unknown: named.filter((n) => !POLICY_IDS.includes(n)),
42
+ };
43
+ }
44
+
45
+ /** Whether one row applies to the facts. A row with no matcher reads the facts itself. */
46
+ function applies(row, facts) {
47
+ if (row.inspects === 'command') {
48
+ return typeof facts.command === 'string' && row.match !== null && row.match.test(facts.command);
49
+ }
50
+ if (row.inspects === 'write') {
51
+ return (facts.writes ?? []).some((w) => w.generated !== null && w.generated !== undefined);
52
+ }
53
+ return false;
54
+ }
55
+
56
+ /**
57
+ * The decision for one event.
58
+ *
59
+ * `blocked` and `lifted` are ids, not rows, because the caller prints them and an id is
60
+ * what a reader types into `SIMT_ALLOW`. The messages carry their own override hint, so
61
+ * a denial is actionable without the reader knowing the variable exists.
62
+ */
63
+ export function decide({ event = EVENTS[0], facts = {}, env = {} } = {}) {
64
+ const { ids: lifted, unknown } = overrides(env);
65
+ const matched = policiesFor(event).filter((p) => applies(p, facts));
66
+ const blocked = matched.filter((p) => !lifted.has(p.id));
67
+ return {
68
+ event,
69
+ decision: blocked.length > 0 ? 'deny' : 'allow',
70
+ blocked: blocked.map((p) => p.id),
71
+ lifted: matched.filter((p) => lifted.has(p.id)).map((p) => p.id),
72
+ unknown,
73
+ messages: blocked.map((p) => `${p.message} (override: ${ALLOW_ENV}=${p.id})`),
74
+ adrs: blocked.map((p) => p.adr),
75
+ };
76
+ }
77
+
78
+ /** Whether this file's bytes are generated: a region this tool writes, or a submodule. */
79
+ function holdsRegion(text) {
80
+ // Both marker styles, because the region is written in whichever one the file's own
81
+ // syntax allows — `<!-- simt:begin -->` in markdown, `# simt:begin` in an
82
+ // .editorconfig. A malformed region is still a region: a begin marker with no end is
83
+ // the file somebody has already been editing by hand.
84
+ return parse(text).status !== 'absent' || parse(text, 'hash').status !== 'absent';
85
+ }
86
+
87
+ /** The submodule paths `.gitmodules` declares, as repository-relative directories. */
88
+ async function submodules(root) {
89
+ const text = await readFile(join(root, '.gitmodules'), 'utf8').catch(() => null);
90
+ if (text === null) return [];
91
+ return text
92
+ .split('\n')
93
+ .map((line) => /^\s*path\s*=\s*(.+?)\s*$/.exec(line))
94
+ .filter(Boolean)
95
+ .map((m) => m[1].replace(/\/+$/, ''));
96
+ }
97
+
98
+ /**
99
+ * Why this path is not the author's to write, or null when it is.
100
+ *
101
+ * The reason is returned rather than a boolean because the two answers are different
102
+ * facts and the author of a fix needs to know which one applies: a region is repaired by
103
+ * editing its source, a submodule by committing in the other repository.
104
+ */
105
+ export async function generatedBytes(root, file) {
106
+ const abs = resolve(root, file);
107
+ const text = await readFile(abs, 'utf8').catch(() => null);
108
+ if (text !== null && holdsRegion(text)) return 'region';
109
+ const rel = relative(resolve(root), abs);
110
+ if (!rel.startsWith('..')) {
111
+ const inside = await submodules(root);
112
+ if (inside.some((dir) => rel === dir || rel.startsWith(`${dir}/`))) return 'submodule';
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Turn a renderer's extraction into the facts the table asks about. */
118
+ export async function factsFor(root, { command = null, writes = [] } = {}) {
119
+ const resolved = await Promise.all(
120
+ writes.filter(Boolean).map(async (path) => ({ path, generated: await generatedBytes(root, path) })),
121
+ );
122
+ return { command, writes: resolved };
123
+ }
@@ -0,0 +1,78 @@
1
+ // The policies an agent-time hook enforces, in one table.
2
+ //
3
+ // One table and four harnesses. The renderers in ./render.mjs map a harness's own
4
+ // payload onto the facts named here and a decision back onto that harness's own JSON,
5
+ // so a policy is written once and cannot be enforced in one harness and forgotten in
6
+ // another — which is the failure the four vendor documents make easy: they agree on
7
+ // nothing, not the config path, not the shape, not the name of the tool that runs a
8
+ // shell command (Bash, Shell, run_command), not the key holding the command, not the
9
+ // JSON the hook answers with. Every one of those differences lives in the renderer
10
+ // beside the vendor page it was read from; the table only knows facts.
11
+ //
12
+ // The facts are deliberately few: the shell command an agent is about to run, and the
13
+ // path it is about to write. A payload the renderer cannot recognise yields no facts,
14
+ // and no fact is not a violation — guessing from a shape we did not read would block
15
+ // real work in a harness whose payload changed under us.
16
+ //
17
+ // The messages are the bytes an author reads while being stopped, so each one names
18
+ // the rule, the record that settled it, and the override that lifts it. An override
19
+ // nobody is told about is a policy the author learns to route around instead of one
20
+ // they can ask about.
21
+
22
+ import { CLI } from '../name.mjs';
23
+
24
+ /** The events an agent-time hook can answer, normalised across harnesses. */
25
+ export const EVENTS = ['pre-tool-use'];
26
+
27
+ /** The environment variable that lifts named policies for one session. */
28
+ export const ALLOW_ENV = 'SIMT_ALLOW';
29
+
30
+ /**
31
+ * One row per policy.
32
+ *
33
+ * `inspects` names the fact the matcher is handed, and `match` the condition. The
34
+ * `generated` row has no matcher because what it asks — does this file hold bytes this
35
+ * tool generates — is answered by reading the file, not by the text of a payload.
36
+ *
37
+ * Order matters where two policies match one command: `git push --force` is both a
38
+ * push and a rewrite, so both are reported and both have to be lifted before it runs.
39
+ */
40
+ const POLICIES = [
41
+ {
42
+ id: 'push',
43
+ adr: 'ADR-001',
44
+ event: 'pre-tool-use',
45
+ inspects: 'command',
46
+ match: /(?:^|[\s;&|(])git\s+push(?:\s|$)/,
47
+ message: `Push needs explicit approval (ADR-001); ask the user.`,
48
+ },
49
+ {
50
+ id: 'rewrite',
51
+ adr: 'ADR-017',
52
+ event: 'pre-tool-use',
53
+ inspects: 'command',
54
+ match:
55
+ /--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)/,
56
+ message:
57
+ `Never amend, rebase, reset --hard, force-push or --no-verify unless asked; ` +
58
+ `each is the escape that turns the declared gates into a suggestion (ADR-017).`,
59
+ },
60
+ {
61
+ id: 'generated',
62
+ adr: 'ADR-036',
63
+ event: 'pre-tool-use',
64
+ inspects: 'write',
65
+ match: null,
66
+ message:
67
+ `That file carries bytes this tool generates, so an edit there is overwritten on the next ` +
68
+ `write; edit the source the region comes from, then run \`${CLI} sync --yes\` (ADR-036).`,
69
+ },
70
+ ];
71
+
72
+ /** The policy ids, in table order: the vocabulary an override is checked against. */
73
+ export const POLICY_IDS = POLICIES.map((p) => p.id);
74
+
75
+ /** The rows for one event, so a renderer and the table cannot disagree about events. */
76
+ export function policiesFor(event) {
77
+ return POLICIES.filter((p) => p.event === event);
78
+ }