simt-dev 0.2.0__py3-none-any.whl

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 (138) hide show
  1. python/simt_dev/__init__.py +32 -0
  2. python/simt_dev/cli.py +225 -0
  3. simt_dev/__init__.py +32 -0
  4. simt_dev/cli.py +225 -0
  5. simt_dev/vendored/CHANGELOG.md +166 -0
  6. simt_dev/vendored/bin/simt-dev.mjs +28 -0
  7. simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  8. simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  9. simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
  10. simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  11. simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
  12. simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
  13. simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
  14. simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  15. simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
  16. simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
  17. simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
  18. simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  19. simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  20. simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  21. simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  22. simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  23. simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  24. simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  25. simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  26. simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  27. simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  28. simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  29. simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  30. simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  31. simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  32. simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  33. simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  34. simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  35. simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  36. simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  37. simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  38. simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  39. simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  40. simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  41. simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  42. simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  43. simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  44. simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  45. simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  46. simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  47. simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  48. simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  49. simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  50. simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  51. simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  52. simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  53. simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  54. simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  55. simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  56. simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  57. simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  58. simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  59. simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  60. simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  61. simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  62. simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  63. simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  64. simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  65. simt_dev/vendored/docs/decisions/README.md +530 -0
  66. simt_dev/vendored/graph/skills.graph.json +1622 -0
  67. simt_dev/vendored/package.json +43 -0
  68. simt_dev/vendored/rules/core.md +76 -0
  69. simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
  70. simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
  71. simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
  72. simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
  73. simt_dev/vendored/rules/stacks/android.md +63 -0
  74. simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
  75. simt_dev/vendored/rules/stacks/connectiq.md +62 -0
  76. simt_dev/vendored/rules/stacks/container.md +31 -0
  77. simt_dev/vendored/rules/stacks/jvm.md +54 -0
  78. simt_dev/vendored/rules/stacks/node.md +45 -0
  79. simt_dev/vendored/rules/stacks/python.md +48 -0
  80. simt_dev/vendored/rules/stacks/react-native.md +58 -0
  81. simt_dev/vendored/rules/stacks/react-web.md +61 -0
  82. simt_dev/vendored/rules/stacks/typescript.md +21 -0
  83. simt_dev/vendored/rules/stacks/uv.md +32 -0
  84. simt_dev/vendored/src/argv.mjs +116 -0
  85. simt_dev/vendored/src/ci/dependabot.mjs +268 -0
  86. simt_dev/vendored/src/ci/workflow.mjs +360 -0
  87. simt_dev/vendored/src/commands/ci.mjs +285 -0
  88. simt_dev/vendored/src/commands/compare.mjs +195 -0
  89. simt_dev/vendored/src/commands/graph.mjs +73 -0
  90. simt_dev/vendored/src/commands/help.mjs +108 -0
  91. simt_dev/vendored/src/commands/hooks.mjs +471 -0
  92. simt_dev/vendored/src/commands/index.mjs +241 -0
  93. simt_dev/vendored/src/commands/init.mjs +632 -0
  94. simt_dev/vendored/src/commands/mcp.mjs +63 -0
  95. simt_dev/vendored/src/commands/rules.mjs +374 -0
  96. simt_dev/vendored/src/commands/skills.mjs +647 -0
  97. simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
  98. simt_dev/vendored/src/commands/sync.mjs +331 -0
  99. simt_dev/vendored/src/commands/upgrade.mjs +202 -0
  100. simt_dev/vendored/src/compare/discover.mjs +212 -0
  101. simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
  102. simt_dev/vendored/src/compare/row.mjs +299 -0
  103. simt_dev/vendored/src/decisions/index.mjs +536 -0
  104. simt_dev/vendored/src/graph/load.mjs +541 -0
  105. simt_dev/vendored/src/hooks/capability.mjs +129 -0
  106. simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
  107. simt_dev/vendored/src/hooks/gates.mjs +144 -0
  108. simt_dev/vendored/src/hooks/resolve.mjs +194 -0
  109. simt_dev/vendored/src/hooks/shim.mjs +140 -0
  110. simt_dev/vendored/src/hooks/where.mjs +118 -0
  111. simt_dev/vendored/src/identity.mjs +218 -0
  112. simt_dev/vendored/src/locate.mjs +75 -0
  113. simt_dev/vendored/src/mcp/server.mjs +167 -0
  114. simt_dev/vendored/src/mcp/tools.mjs +410 -0
  115. simt_dev/vendored/src/name.mjs +95 -0
  116. simt_dev/vendored/src/output/index.mjs +96 -0
  117. simt_dev/vendored/src/projection/baselines.mjs +209 -0
  118. simt_dev/vendored/src/projection/conflicts.mjs +205 -0
  119. simt_dev/vendored/src/projection/manifest.mjs +478 -0
  120. simt_dev/vendored/src/projection/markers.mjs +349 -0
  121. simt_dev/vendored/src/projection/mdc.mjs +327 -0
  122. simt_dev/vendored/src/projection/project.mjs +504 -0
  123. simt_dev/vendored/src/projection/rules.mjs +640 -0
  124. simt_dev/vendored/src/projection/surfaces.mjs +398 -0
  125. simt_dev/vendored/src/projection/targets.mjs +221 -0
  126. simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
  127. simt_dev/vendored/src/resolve/near.mjs +61 -0
  128. simt_dev/vendored/src/resolve/resolve.mjs +457 -0
  129. simt_dev/vendored/src/resolve/source.mjs +105 -0
  130. simt_dev/vendored/src/toolchain.mjs +202 -0
  131. simt_dev/vendored/src/tsconfig.mjs +314 -0
  132. simt_dev/vendored/src/upgrade/plan.mjs +203 -0
  133. simt_dev/vendored/src/workspace.mjs +289 -0
  134. simt_dev-0.2.0.dist-info/METADATA +150 -0
  135. simt_dev-0.2.0.dist-info/RECORD +138 -0
  136. simt_dev-0.2.0.dist-info/WHEEL +4 -0
  137. simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
  138. simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,471 @@
1
+ // simt-dev hooks install | status | run
2
+ //
3
+ // Three commands with one shared body. `install` writes the shims and points
4
+ // core.hooksPath at them; `status` reports what is installed and what would
5
+ // run; `run` is the entry point the shim itself execs.
6
+ //
7
+ // Installing is the only mutating command, it is idempotent, it previews unless
8
+ // told to write, and it refuses to clobber an existing core.hooksPath without
9
+ // --yes. Repositories in the wild use incompatible conventions, including a
10
+ // hooksPath pointing at a directory whose pre-push is 0 bytes, so overwriting
11
+ // silently would be the worst available outcome. Others keep their hook in the
12
+ // DEFAULT location, which core.hooksPath being unset does not reveal, so a
13
+ // foreign hook is found by asking git rather than by reading config.
14
+
15
+ import { execFile } from 'node:child_process';
16
+ import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
17
+ import { isAbsolute, join, relative } from 'node:path';
18
+ import { promisify } from 'node:util';
19
+
20
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
21
+ import { MANIFEST, readManifest } from '../projection/manifest.mjs';
22
+ import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
23
+ import { readHooksPath, readHooksState } from '../compare/hooksstate.mjs';
24
+ import { GATES } from '../hooks/gates.mjs';
25
+ import { knownGates } from '../hooks/resolve.mjs';
26
+ import { runGates } from '../hooks/dispatcher.mjs';
27
+ import { CLI } from '../name.mjs';
28
+ import { MANIFEST_FILE } from '../name.mjs';
29
+
30
+ const exec = promisify(execFile);
31
+
32
+ export async function hooksCommand(sub, args, flags) {
33
+ const root = flags.root ?? args[0] ?? process.cwd();
34
+ if (sub === 'install') return installHooks(root, flags);
35
+ if (sub === 'status') return hooksStatus(root, flags);
36
+ if (sub === 'run') return runHook(args, flags);
37
+ return fail(`unknown hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} hooks status`);
38
+ }
39
+
40
+ /** The shim execs this; the event name is baked into the shim, so it is arg 0. */
41
+ async function runHook(args, flags) {
42
+ const event = args[0];
43
+ const root = flags.root ?? process.cwd();
44
+ if (!EVENTS.includes(event)) {
45
+ return fail(`unknown hook event ${event}`, EXIT.USAGE, `known events: ${EVENTS.join(', ')}`);
46
+ }
47
+ const { manifest, exists, error } = await readManifest(root);
48
+ if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
49
+ if (!exists) {
50
+ return fail(`${root} has no ${MANIFEST_FILE}; the hook cannot know its gate list`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
51
+ }
52
+
53
+ const result = await runGates(event, { root, manifest });
54
+ if (result.error) return fail(result.error, EXIT.USAGE);
55
+
56
+ if (isJson(flags)) {
57
+ emitJson({ ok: !result.failed, command: 'hooks run', root, ...result });
58
+ return result.failed ? EXIT.FAIL : EXIT.OK;
59
+ }
60
+
61
+ for (const n of result.notes ?? []) note(`note ${n}`);
62
+ const lines = [`hooks ${event}`, `gates ${(manifest.gates[GATE_KEY[event]] ?? []).join(', ') || '(none declared)'}`];
63
+ for (const g of result.gates) {
64
+ lines.push(`${g.status.toUpperCase().padEnd(8)} ${g.name.padEnd(20)} ${g.detail}${g.adr ? ` [${g.adr}]` : ''}`);
65
+ if (g.remedy) lines.push(` remedy: ${g.remedy}`);
66
+ if (g.output) lines.push(...g.output.split('\n').map((l) => ` ${l}`));
67
+ }
68
+ lines.push(result.failed ? 'BLOCKED at least one gate failed' : 'PASSED');
69
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
70
+ return result.failed ? EXIT.FAIL : EXIT.OK;
71
+ }
72
+
73
+ const GATE_KEY = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
74
+
75
+ function gateCount(manifest, event) {
76
+ return (manifest?.gates?.[GATE_KEY[event]] ?? []).length;
77
+ }
78
+
79
+ /**
80
+ * The path to write into a chained shim: relative to the repository's top level
81
+ * wherever the hook lives inside it.
82
+ *
83
+ * Absolute is the fallback, not the default. A hook path baked in absolute stops
84
+ * resolving the first time the repository is cloned or moved somewhere else, and the
85
+ * failure is silent in the worst way - the shim still runs, still exits non-zero, and
86
+ * blocks every commit for a reason that reads like a broken gate rather than a moved
87
+ * checkout. `.git/hooks` is inside the top level in a normal repository, which is the
88
+ * common case here; a linked worktree shares its parent's `.git` and is the one shape
89
+ * that has to fall back.
90
+ *
91
+ * Both sides are resolved through `realpath` first, which is not a nicety. macOS
92
+ * hands out `/var/...` and git reports `/private/var/...` for the same directory, so
93
+ * compared naively a hook inside the repository looks like it lives outside it, and
94
+ * the first run bakes in an absolute path that the second run cannot reproduce - a
95
+ * shim rewritten with identical intent on every install, forever. The question being
96
+ * asked is "same file", not "same spelling".
97
+ *
98
+ * @param {string} root repository root
99
+ * @param {string} target absolute, or relative to `root` when read back off a shim
100
+ * @returns {Promise<string>} relative to `root` where possible, else absolute
101
+ */
102
+ async function relativeTarget(root, target) {
103
+ const real = async (p) => realpath(p).catch(() => p);
104
+ const resolved = await real(isAbsolute(target) ? target : join(root, target));
105
+ const rel = relative(await real(root), resolved);
106
+ return rel === '' || rel.startsWith('..') ? resolved : rel;
107
+ }
108
+
109
+ /**
110
+ * Whether a missing shim for this event is a defect.
111
+ *
112
+ * A repo that declares no gate for an event has no reason to want a shim for it,
113
+ * so its absence is the correct end state. A repo with NO manifest is different:
114
+ * we do not know what it wants, so we cannot rule the shim out - absence of
115
+ * knowledge is not a decision.
116
+ */
117
+ function wantsShim(manifest, exists, event) {
118
+ if (!exists) return true;
119
+ return gateCount(manifest, event) > 0;
120
+ }
121
+
122
+ async function installHooks(root, flags) {
123
+ const { manifest, exists, error } = await readManifest(root);
124
+ if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
125
+ if (!exists) {
126
+ return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
127
+ }
128
+
129
+ const state = await readHooksState(root, { declaresGate: (e) => wantsShim(manifest, exists, e) });
130
+ const current = state.current;
131
+ const wants = HOOKS_DIR;
132
+
133
+ // Read through `writeMode` like every other writer, and only now: this command
134
+ // used to consult `flags.yes` and `flags.dryRun` directly and write when neither
135
+ // said otherwise, which made it the last writer still writing by default after
136
+ // ADR-035 and ADR-036 had settled that none of them may. Review 0002's S24
137
+ // listed it among the writers that "all preview first"; that was wrong, and the
138
+ // migration script in the same review tells an operator to run `hooks install`
139
+ // for a plan and then `--yes` - advice that printed a plan and wrote anyway.
140
+ const mode = writeMode(flags);
141
+
142
+ // Only install a shim for an event that declares at least one gate. A shim with
143
+ // an empty gate list enforces nothing, yet merely existing it REPLACES whatever
144
+ // git was running - so installing one over a working hand-written hook is a
145
+ // downgrade dressed as an upgrade. `init` already says this in prose; install
146
+ // has to agree, or the two commands describe different tools.
147
+ const wanted = EVENTS.filter((e) => gateCount(manifest, e) > 0);
148
+ if (wanted.length === 0) {
149
+ if (isJson(flags)) {
150
+ emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
151
+ return EXIT.FAIL;
152
+ }
153
+ emit(
154
+ [
155
+ `repo ${root}`,
156
+ '',
157
+ `BLOCKED ${root} declares no gate it can run, so there is nothing to enforce.`,
158
+ ' Installing shims anyway would stop running whatever git runs now',
159
+ ' and replace it with a gate that checks nothing.',
160
+ `REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
161
+ ].join('\n'),
162
+ { maxBytes: flags.maxBytes ?? 0 },
163
+ );
164
+ return EXIT.FAIL;
165
+ }
166
+
167
+ // Foreign hooks on events this tool does NOT gate. Routing replaces the whole
168
+ // directory git reads from, so these have to be carried across explicitly or they
169
+ // stop running - and they stop running silently, which is why they are planned here
170
+ // rather than left to the operator to notice (ADR-018, as amended).
171
+ //
172
+ // "Does not gate" is a fact about THIS repository: `gateCount` asks what its manifest
173
+ // declares for that event, so an event outside the known vocabulary (post-commit) and
174
+ // an event the repo simply leaves empty (a pre-push with no gates) are both chained,
175
+ // while an event the repo does gate is left to the manifest's gates.commands - there
176
+ // the hook runs as a gate and its verdict appears in the report beside the others,
177
+ // rather than after the gates passed where an author cannot see it.
178
+ //
179
+ // Keying this on the event vocabulary instead of on the manifest cost a repository
180
+ // git-lfs hook: pre-push is a gated event in general, but this repo declares no
181
+ // pre-push gate, so nothing replaces its hook and routing would have dropped it.
182
+ const chained = [
183
+ // Already chained by an earlier install, read back off disk: re-rendered from the
184
+ // file's own contents so the bytes cannot drift on a second run.
185
+ ...(await Promise.all(
186
+ state.chained.map(async (c) => ({ event: c.event, target: await relativeTarget(root, c.target) })),
187
+ )),
188
+ // Foreign hooks on events THIS repo has no gate for.
189
+ //
190
+ // Per repository, not per event vocabulary - and the difference is not academic.
191
+ // declares no pre-push gate, and its pre-push is git-lfs: routing without a
192
+ // shim for that event means git finds no pre-push hook at all and LFS objects stop
193
+ // being uploaded, silently, with nothing to notice it. An event this repo gates is
194
+ // different: the dispatcher replaces the hook there by design, and the hook's real
195
+ // place is gates.commands.
196
+ ...(await Promise.all(
197
+ state.foreign
198
+ .filter((f) => gateCount(manifest, f.event) === 0)
199
+ .map(async (f) => ({ event: f.event, target: await relativeTarget(root, f.path) })),
200
+ )),
201
+ ].reduce((all, c) => (all.some((x) => x.event === c.event) ? all : [...all, c]), []);
202
+
203
+ const plan = [];
204
+ for (const event of wanted) {
205
+ const path = join(root, fileFor(event));
206
+ const source = renderShim(event);
207
+ const existing = await readIfExists(path);
208
+ if (existing === source) {
209
+ plan.push({ file: fileFor(event), action: 'unchanged' });
210
+ } else {
211
+ plan.push({ file: fileFor(event), action: existing === null ? 'created' : 'updated' });
212
+ }
213
+ }
214
+ for (const c of chained) {
215
+ const path = join(root, fileFor(c.event));
216
+ const source = renderChainedShim(c.event, c.target);
217
+ const existing = await readIfExists(path);
218
+ plan.push({
219
+ file: fileFor(c.event),
220
+ action: existing === source ? 'unchanged' : existing === null ? 'created' : 'updated',
221
+ // Labelled in the plan because a file that merely re-runs a hook and a file that
222
+ // enforces gates are not the same artefact, and the reader approving this write
223
+ // is entitled to know which one they are looking at.
224
+ note: `chained: runs ${c.target}`,
225
+ });
226
+ }
227
+ const pathAction = current === wants ? 'unchanged' : current === null ? 'created' : 'updated';
228
+
229
+ // Refuse to take over routing git is already using without explicit consent.
230
+ // Two cases, and the second one is the one that bites: core.hooksPath already
231
+ // pointing elsewhere (a hooksPath whose pre-push is 0 bytes), and core.hooksPath
232
+ // being unset while a hook sits in the default location (4 KB of real logic in
233
+ // .git/hooks/pre-commit). Reading unset as
234
+ // "no hooks" is what let install replace a working gate with no warning.
235
+ const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
236
+ if (takeover && !flags.yes) {
237
+ const detail = state.foreign.length > 0
238
+ ? state.foreign.map((f) => ` git runs ${f.event} at ${f.path} (${f.bytes} bytes)`)
239
+ : [` existing ${HOOKS_PATH_KEY} = ${current}`];
240
+ if (isJson(flags)) {
241
+ emitJson({ ok: false, command: 'hooks install', root, currentHooksPath: current, wouldSet: wants, takeover: true, foreign: state.foreign, plan, filesWritten: false });
242
+ return EXIT.FAIL;
243
+ }
244
+ emit(
245
+ [
246
+ `repo ${root}`,
247
+ ...detail,
248
+ `plan would set ${HOOKS_PATH_KEY} = ${wants}`,
249
+ '',
250
+ `BLOCKED ${root} already runs git hooks that ${CLI} did not write.`,
251
+ ' Taking over replaces that routing. Inspect the existing hooks first,',
252
+ ' then re-run with --yes to accept, or set core.hooksPath yourself.',
253
+ ].join('\n'),
254
+ { maxBytes: flags.maxBytes ?? 0 },
255
+ );
256
+ return EXIT.FAIL;
257
+ }
258
+
259
+ if (!mode.apply) {
260
+ if (isJson(flags)) {
261
+ emitJson({ ok: true, command: 'hooks install', root, dryRun: true, currentHooksPath: current, wouldSet: wants, plan, filesWritten: false });
262
+ return EXIT.OK;
263
+ }
264
+ emit(
265
+ renderPlan(
266
+ root,
267
+ current,
268
+ wants,
269
+ pathAction,
270
+ plan,
271
+ mode.dryRun
272
+ ? 'DRY-RUN no files written'
273
+ : `PLAN nothing written; apply with: ${CLI} hooks install --root ${root} --yes`,
274
+ manifest,
275
+ ),
276
+ { maxBytes: flags.maxBytes ?? 0 },
277
+ );
278
+ return EXIT.OK;
279
+ }
280
+
281
+ await mkdir(join(root, HOOKS_DIR), { recursive: true });
282
+ for (const event of wanted) {
283
+ const path = join(root, fileFor(event));
284
+ await writeFile(path, renderShim(event));
285
+ await chmod(path, 0o755);
286
+ }
287
+ for (const c of chained) {
288
+ const path = join(root, fileFor(c.event));
289
+ await writeFile(path, renderChainedShim(c.event, c.target));
290
+ await chmod(path, 0o755);
291
+ }
292
+ await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
293
+
294
+ const notes = [];
295
+ const chainedEvents = new Set(chained.map((c) => c.event));
296
+ for (const f of state.foreign) {
297
+ // Two different facts, and the old note reported only one of them. A hook that was
298
+ // carried across keeps running and is named here as such; a gate-type hook was not
299
+ // chained and genuinely stopped running. Saying "stopped running" about both would
300
+ // be wrong about the first, and it would push an operator to restore a hook that is
301
+ // already being run for them.
302
+ notes.push(
303
+ chainedEvents.has(f.event)
304
+ ? `chained ${f.event}: ${fileFor(f.event)} runs ${f.path} (${f.bytes} bytes), so it keeps running`
305
+ : `stopped running ${f.event} at ${f.path} (${f.bytes} bytes); the file is still on disk, untouched - declare it in ${MANIFEST_FILE} gates.commands to have the dispatcher run it`,
306
+ );
307
+ }
308
+ if (state.foreign.length === 0 && takeover) {
309
+ notes.push(`replaced an existing core.${HOOKS_PATH_KEY} (${current}); the old hooks are still on disk, untouched`);
310
+ }
311
+
312
+ if (isJson(flags)) {
313
+ emitJson({ ok: true, command: 'hooks install', root, hooksPath: wants, previousHooksPath: current, plan, filesWritten: true, notes });
314
+ return EXIT.OK;
315
+ }
316
+ emit(renderPlan(root, current, wants, pathAction, plan, 'WROTE shims and set core.hooksPath', manifest), { maxBytes: flags.maxBytes ?? 0 });
317
+ for (const n of notes) note(`note ${n}`);
318
+ note(`note never committed or pushed (ADR-001); review, then commit yourself`);
319
+ return EXIT.OK;
320
+ }
321
+
322
+ function renderPlan(root, current, wants, pathAction, plan, headline, manifest) {
323
+ const lines = [`repo ${root}`, `${HOOKS_PATH_KEY.padEnd(15)} ${pathAction} ${current ?? '(unset)'} -> ${wants}`, ''];
324
+ for (const p of plan) lines.push(`${p.action.toUpperCase().padEnd(9)} ${p.file}${p.note ? ` (${p.note})` : ''}`);
325
+ lines.push('', headline);
326
+ lines.push(`gates preCommit = ${describeGates(manifest, 'preCommit')}`);
327
+ lines.push(` prePush = ${describeGates(manifest, 'prePush')}`);
328
+ return lines.join('\n');
329
+ }
330
+
331
+ function describeGates(manifest, key) {
332
+ const list = manifest?.gates?.[key];
333
+ if (!Array.isArray(list) || list.length === 0) return '(none declared)';
334
+ return list.map((name) => {
335
+ const gate = GATES[name];
336
+ return gate ? name : `${name} (UNKNOWN - the hook will fail)`;
337
+ }).join(', ');
338
+ }
339
+
340
+ async function hooksStatus(root, flags) {
341
+ const { manifest, exists, error } = await readManifest(root);
342
+ if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
343
+
344
+ const state = await readHooksState(root, { declaresGate: (e) => wantsShim(manifest, exists, e) });
345
+ const current = state.current;
346
+ const installed = state.installed;
347
+ const commands = exists ? (manifest?.gates?.commands ?? {}) : {};
348
+
349
+ // Collected before the JSON branch so both surfaces answer the same question.
350
+ // This used to be computed only for the text report, and the JSON branch wrote
351
+ // `ok: true` and returned EXIT.OK regardless: a `--json` caller asking whether
352
+ // its hooks are wired up was told yes while the same command in a terminal
353
+ // exited 1. Under `--json` the document IS the report, so a problem that the
354
+ // text path lists has to be in the document and has to move the exit code.
355
+ const problems = collectProblems({ exists, current, installed, manifest, foreign: state.foreign });
356
+ const code = problems.length > 0 ? EXIT.FAIL : EXIT.OK;
357
+
358
+ if (isJson(flags)) {
359
+ emitJson({
360
+ ok: code === EXIT.OK,
361
+ command: 'hooks status',
362
+ root,
363
+ hooksPath: current,
364
+ expectedHooksPath: HOOKS_DIR,
365
+ manifestPresent: exists,
366
+ installed,
367
+ // The machine-readable surface has to carry this too. An agent reading the
368
+ // JSON never sees the text block, and a fact that exists only in prose is a
369
+ // fact the tool did not actually report.
370
+ hooksDir: state.liveDir,
371
+ foreign: state.foreign,
372
+ gates: exists
373
+ ? {
374
+ preCommit: manifest?.gates?.preCommit ?? [],
375
+ prePush: manifest?.gates?.prePush ?? [],
376
+ commands,
377
+ }
378
+ : null,
379
+ problems,
380
+ });
381
+ return code;
382
+ }
383
+
384
+ const lines = [
385
+ `repo ${root}`,
386
+ `hooks ${current ?? '(unset)'} ${current === HOOKS_DIR ? '(simt-dev)' : current === null ? `(git default: ${state.liveDir})` : '(another routing)'}`,
387
+ `manifest ${exists ? `${MANIFEST_FILE} present` : `${MANIFEST_FILE} MISSING - run: ${CLI} init`}`,
388
+ '',
389
+ ];
390
+ for (const i of installed) {
391
+ lines.push(`${(i.present ? 'PRESENT ' : 'ABSENT ')} ${i.event.padEnd(12)} ${i.present ? `executable=${i.executable}` : 'not installed'}${i.stale ? ' STALE (content differs from this simt-dev)' : ''}`);
392
+ }
393
+ // An unset core.hooksPath means git falls back to .git/hooks, so "not
394
+ // installed" above can sit next to a hook git is running right now. Say both.
395
+ if (state.foreign.length > 0) {
396
+ lines.push('', 'git runs these, and simt-dev did not write them:');
397
+ for (const f of state.foreign) lines.push(` ${f.event.padEnd(12)} ${f.path} ${f.bytes} bytes`);
398
+ lines.push('installing the shims stops running these; inspect them first.');
399
+ }
400
+ if (exists) {
401
+ lines.push('', 'would run:');
402
+ for (const key of ['preCommit', 'prePush']) {
403
+ for (const name of manifest?.gates?.[key] ?? []) {
404
+ const known = GATES[name];
405
+ // A declared command wins for every kind, so the status must say so too —
406
+ // printing "run script" for a gate the dispatcher will shell out to `./gradlew`
407
+ // for is the same one-question-two-answers defect ADR-018 was written about.
408
+ const declared = typeof commands[name] === 'string' && commands[name].trim() !== '';
409
+ const shape = !known
410
+ ? 'UNKNOWN GATE - this hook will fail'
411
+ : declared
412
+ ? `run ${commands[name]}`
413
+ : `run ${known.kind}`;
414
+ lines.push(` ${key.padEnd(10)} ${name.padEnd(20)} ${shape}${known ? ` [${known.adr}]` : ''}`);
415
+ }
416
+ }
417
+ const undeclared = [...Object.keys(commands)].filter(
418
+ (c) => ![...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])].includes(c),
419
+ );
420
+ if (undeclared.length > 0) {
421
+ lines.push('', `declared but never run: ${undeclared.join(', ')}`);
422
+ }
423
+ const unknown = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])].filter((n) => !GATES[n]);
424
+ if (unknown.length > 0) {
425
+ lines.push('', `unknown gates (the hook will fail): ${unknown.join(', ')}`);
426
+ lines.push(`known gates: ${knownGates().join(', ')}`);
427
+ }
428
+ }
429
+ if (problems.length > 0) {
430
+ lines.push('', 'problems:');
431
+ for (const p of problems) lines.push(` ${p}`);
432
+ }
433
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
434
+ return code;
435
+ }
436
+
437
+ function collectProblems({ exists, current, installed, manifest, foreign = [] }) {
438
+ const problems = [];
439
+ if (!exists) problems.push(`no ${MANIFEST_FILE}; gates are undeclared`);
440
+ if (current !== HOOKS_DIR) problems.push(`core.hooksPath is ${current ?? '(unset)'}, expected ${HOOKS_DIR}`);
441
+ for (const i of installed) {
442
+ // A repo that declares no gate for an event has no reason to want a shim
443
+ // for it, so its absence is the correct end state rather than a defect.
444
+ if (!wantsShim(manifest, exists, i.event)) continue;
445
+ if (!i.present) problems.push(`${i.event} not installed`);
446
+ else if (!i.executable) problems.push(`${i.event} is not executable`);
447
+ else if (i.stale) problems.push(`${i.event} differs from this simt-dev; reinstall`);
448
+ }
449
+ for (const f of foreign) {
450
+ problems.push(`git runs ${f.event} at ${f.path} (${f.bytes} bytes), which is not a simt-dev shim`);
451
+ }
452
+ if (exists) {
453
+ for (const key of ['preCommit', 'prePush']) {
454
+ for (const name of manifest?.gates?.[key] ?? []) {
455
+ if (!GATES[name]) problems.push(`gate "${name}" in ${key} is not a known gate`);
456
+ else if (GATES[name].kind === 'command' && !manifest?.gates?.commands?.[name]) {
457
+ problems.push(`gate "${name}" in ${key} is a command gate with no declared command; the hook will fail`);
458
+ }
459
+ }
460
+ }
461
+ }
462
+ return problems;
463
+ }
464
+
465
+ async function readIfExists(path) {
466
+ try {
467
+ return await readFile(path, 'utf8');
468
+ } catch {
469
+ return null;
470
+ }
471
+ }