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,63 @@
1
+ // simt-dev mcp - serve the tool surface over MCP on stdio.
2
+ //
3
+ // Optional by design (plan 0002 section 5): it adds a transport, not a capability, and
4
+ // the CLI stays the primary interface because it works in CI, in a hook and in a bare
5
+ // shell. What this command must NOT become is a second implementation - it wires the same
6
+ // modules the commands use into a line protocol and does nothing else.
7
+ //
8
+ // `--list` prints the surface and exits, without reading stdin. An editor that has
9
+ // misconfigured the command needs to be able to ask what it exposes without hanging, and
10
+ // a command that only speaks when spoken to cannot answer that.
11
+
12
+ import { readFile } from 'node:fs/promises';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ import { EXIT, emit, fail, isJson } from '../output/index.mjs';
17
+ import { TOOLS } from '../mcp/tools.mjs';
18
+ import { PROTOCOL_VERSION, serve } from '../mcp/server.mjs';
19
+ import { CLI } from '../name.mjs';
20
+
21
+ export async function mcpCommand(positional, flags) {
22
+ const { version } = await packageVersion();
23
+
24
+ if (flags.list === true) {
25
+ if (isJson(flags)) {
26
+ emit(`${JSON.stringify({ ok: true, protocolVersion: PROTOCOL_VERSION, version, tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }, null, 2)}\n`);
27
+ return EXIT.OK;
28
+ }
29
+ emit(
30
+ [
31
+ `simt-dev ${version} - MCP surface over stdio (protocol ${PROTOCOL_VERSION})`,
32
+ '',
33
+ `${TOOLS.length} read-only tools:`,
34
+ ...TOOLS.map((t) => ` ${t.name.padEnd(16)} ${t.description.split('.')[0]}`),
35
+ '',
36
+ 'No tool here writes. init, sync, hooks install, skills install and upgrade stay in the',
37
+ 'CLI, because each is meant to be read as a diff by a human before it touches anything.',
38
+ '',
39
+ `serve them with: ${positional[0] ?? `${CLI} mcp`}`,
40
+ ].join('\n'),
41
+ { maxBytes: flags.maxBytes ?? 0 },
42
+ );
43
+ return EXIT.OK;
44
+ }
45
+
46
+ try {
47
+ return await serve(TOOLS, { version });
48
+ } catch (error) {
49
+ return fail(`mcp transport failed: ${error.message}`, EXIT.FAIL, `run: ${CLI} mcp --list to check the surface loads`);
50
+ }
51
+ }
52
+
53
+ async function packageVersion() {
54
+ try {
55
+ const here = dirname(fileURLToPath(import.meta.url));
56
+ const pkg = JSON.parse(await readFile(join(here, '..', '..', 'package.json'), 'utf8'));
57
+ return { version: pkg.version };
58
+ } catch {
59
+ // A missing version must not stop the server: the client already knows which
60
+ // build it launched, and a hard failure here would look like a protocol bug.
61
+ return { version: 'unknown' };
62
+ }
63
+ }
@@ -0,0 +1,374 @@
1
+ // `simt-dev rules` - the rule catalogue and its provenance.
2
+ //
3
+ // A rule is only meaningful if you can answer "why is this here?". Three
4
+ // commands, mirroring the skill surface deliberately: `list` is the catalogue,
5
+ // `show` is one body, `why` is the decision chain behind it.
6
+ //
7
+ // The load-bearing one is `why`. Every rule body cites the ADR that settled it,
8
+ // so the citation can be resolved: title, status, and - the part that matters -
9
+ // whether the record is still in force. ADR-011 was accepted and reversed by
10
+ // ADR-012 the same day; printing 011's ruling without that would hand a reader a
11
+ // decision nobody is following.
12
+ //
13
+ // A rule that cites nothing is a finding, not an empty answer. It is reported as
14
+ // one, because "this rule has no decision behind it" is exactly the fact a
15
+ // reviewer needs and exactly the fact an empty output would hide.
16
+
17
+ import { EXIT, emit, fail, isJson } from '../output/index.mjs';
18
+ import { loadRules, select } from '../projection/project.mjs';
19
+ import { fingerprint } from '../resolve/fingerprint.mjs';
20
+ import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
21
+ import { nearMatches } from '../resolve/near.mjs';
22
+ import { CLI } from '../name.mjs';
23
+
24
+ const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
25
+
26
+ /** `simt-applies` is the string "always" for core and an array for profiles. */
27
+ function appliesLabel(rule) {
28
+ if (rule.applies === 'always') return 'always';
29
+ if (Array.isArray(rule.applies)) return rule.applies.join(',') || '-';
30
+ return '-';
31
+ }
32
+
33
+ /** Sorted for run-to-run diffability: the catalogue is read by agents, not just humans. */
34
+ function sorted(rules) {
35
+ return [...rules].sort((a, b) => a.id.localeCompare(b.id));
36
+ }
37
+
38
+ async function load(root) {
39
+ const rules = await loadRules(root ?? null);
40
+ const broken = rules.filter((r) => r.error);
41
+ return { rules, broken };
42
+ }
43
+
44
+ // --- list ---------------------------------------------------------------------
45
+
46
+ export async function listCommand(args, flags) {
47
+ const root = flags.root ?? null;
48
+ const { rules, broken } = await load(root);
49
+
50
+ // With --root the question becomes "which of these apply to me", which is a
51
+ // different answer from the catalogue and the one an agent usually wants.
52
+ const selection = root ? await selectionFor(rules, root) : null;
53
+
54
+ if (isJson(flags)) {
55
+ emit(
56
+ `${JSON.stringify(
57
+ {
58
+ ok: broken.length === 0,
59
+ command: 'rules list',
60
+ root,
61
+ rules: sorted(rules).map((r) => ({
62
+ id: r.id,
63
+ title: r.title,
64
+ origin: r.origin,
65
+ source: r.source,
66
+ applies: r.applies,
67
+ fallback: r.fallback,
68
+ bytes: r.bytes,
69
+ error: r.error,
70
+ adrs: citations(r.body),
71
+ selected: selection ? selection.selected.has(r.id) : null,
72
+ })),
73
+ notes: selection?.notes ?? [],
74
+ },
75
+ null,
76
+ 2,
77
+ )}\n`,
78
+ { maxBytes: flags.maxBytes ?? 0 },
79
+ );
80
+ return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
81
+ }
82
+
83
+ const total = rules.reduce((n, r) => n + r.bytes, 0);
84
+ const local = rules.filter((r) => r.origin === 'local').length;
85
+ const rows = sorted(rules);
86
+
87
+ // Column widths are measured from the data rather than hardcoded. `simt-applies` is a
88
+ // list of signals whose length is the whole point of the column, so a fixed pad
89
+ // silently pushed the title out of alignment for exactly the profiles a reader
90
+ // most wants to compare. Measured widths are still deterministic for a given
91
+ // rule set, which is what run-to-run diffability needs.
92
+ const widths = {
93
+ id: Math.max(2, ...rows.map((r) => r.id.length)),
94
+ bytes: Math.max(5, ...rows.map((r) => kb(r.bytes).length)),
95
+ origin: Math.max(6, ...rows.map((r) => r.origin.length)),
96
+ applies: Math.max(7, ...rows.map((r) => appliesLabel(r).length)),
97
+ };
98
+
99
+ // The selection marker stays welded to the id: it is a mark on that row, not a
100
+ // column of its own, and `*connectiq` is greppable where `* connectiq` is not.
101
+ const render = (mark, id, bytes, origin, applies, title) =>
102
+ `${mark}${id.padEnd(widths.id)} ${bytes.padStart(widths.bytes)} ${origin.padEnd(widths.origin)} ${applies.padEnd(widths.applies)} ${title}`;
103
+
104
+ const lines = [
105
+ `${rules.length} rules, ${kb(total)}${local > 0 ? `, ${local} local` : ''}${selection ? '' : ' (pass --root to see which apply to a repo)'}`,
106
+ '',
107
+ render(' ', 'id', 'bytes', 'origin', 'applies', 'title'),
108
+ ];
109
+
110
+ for (const rule of rows) {
111
+ const mark = selection && selection.selected.has(rule.id) ? '*' : ' ';
112
+ lines.push(
113
+ render(mark, rule.id, kb(rule.bytes), rule.origin, appliesLabel(rule), rule.title ?? '(no title)'),
114
+ );
115
+ }
116
+
117
+ for (const rule of broken) {
118
+ lines.push('', `BROKEN ${rule.source}: ${rule.error}`);
119
+ }
120
+ if (selection) {
121
+ lines.push('', `* selected for ${root}`);
122
+ for (const note of selection.notes) lines.push(` note ${note}`);
123
+ }
124
+ if (root) {
125
+ const locals = rules.filter((r) => r.origin === 'local');
126
+ if (locals.length > 0) lines.push('', `local profiles come from ${locals.map((r) => r.source).join(', ')}`);
127
+ }
128
+
129
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
130
+ return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
131
+ }
132
+
133
+ async function selectionFor(rules, root) {
134
+ const { stack, facts } = await fingerprint(root);
135
+ const { selected, notes } = select(rules, stack, facts);
136
+ return { selected: new Set(selected.map((r) => r.id)), notes };
137
+ }
138
+
139
+ // --- show ---------------------------------------------------------------------
140
+
141
+ export async function showCommand(args, flags) {
142
+ const id = args[0];
143
+ if (!id) return fail('rules show needs a rule id', EXIT.USAGE, `usage: ${CLI} rules show <id>`);
144
+
145
+ const { rules, broken } = await load(flags.root ?? null);
146
+ const rule = rules.find((r) => r.id === id);
147
+ if (!rule) return unknown(id, rules);
148
+
149
+ const adrs = citations(rule.body);
150
+ if (isJson(flags)) {
151
+ emit(`${JSON.stringify({ ok: !rule.error, ...rule, adrs }, null, 2)}\n`, { maxBytes: flags.maxBytes ?? 0 });
152
+ return rule.error ? EXIT.FAIL : EXIT.OK;
153
+ }
154
+
155
+ emit(
156
+ [
157
+ rule.id,
158
+ rule.title ?? '(no title)',
159
+ '',
160
+ `applies ${appliesLabel(rule)}${rule.fallback ? ' (fallback)' : ''}`,
161
+ `origin ${rule.origin}`,
162
+ `source ${rule.source}`,
163
+ `bytes ${rule.bytes}`,
164
+ `adrs ${adrs.length > 0 ? adrs.join(', ') : 'NONE - no decision is cited by this rule'}`,
165
+ '',
166
+ rule.body,
167
+ ].join('\n'),
168
+ { maxBytes: flags.maxBytes ?? 0 },
169
+ );
170
+ return rule.error ? EXIT.FAIL : EXIT.OK;
171
+ }
172
+
173
+ // --- why ----------------------------------------------------------------------
174
+
175
+ /**
176
+ * Two directions, because both questions get asked:
177
+ * `rules why <rule-id>` -> which decisions settled this rule, and are they in force
178
+ * `rules why <ADR-010>` -> which rules rest on this decision
179
+ */
180
+ export async function whyCommand(args, flags) {
181
+ const what = args[0];
182
+ if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
183
+
184
+ const { rules, broken } = await load(flags.root ?? null);
185
+ const { ok, decisions, errors } = await loadDecisions();
186
+ if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
187
+
188
+ return what.match(/^ADR-\d{3}$/i)
189
+ ? adrDirection(what.toUpperCase(), rules, decisions, flags)
190
+ : ruleDirection(what, rules, decisions, flags, broken);
191
+ }
192
+
193
+ async function ruleDirection(id, rules, decisions, flags, broken) {
194
+ const rule = rules.find((r) => r.id === id);
195
+ if (!rule) return unknown(id, rules);
196
+
197
+ const cited = citations(rule.body);
198
+ const index = byId(decisions);
199
+
200
+ if (isJson(flags)) {
201
+ emit(
202
+ `${JSON.stringify(
203
+ {
204
+ ok: true,
205
+ command: 'rules why',
206
+ rule: rule.id,
207
+ source: rule.source,
208
+ origin: rule.origin,
209
+ adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
210
+ uncited: cited.length === 0,
211
+ },
212
+ null,
213
+ 2,
214
+ )}\n`,
215
+ { maxBytes: flags.maxBytes ?? 0 },
216
+ );
217
+ return EXIT.OK;
218
+ }
219
+
220
+ const lines = [`${rule.id} - ${rule.title ?? '(no title)'}`, '', `source ${rule.source} (${rule.origin})`];
221
+
222
+ if (cited.length === 0) {
223
+ lines.push(
224
+ '',
225
+ 'provenance NONE',
226
+ ' this rule cites no decision, so nothing settles it and nothing can overturn it.',
227
+ ' Either cite the ADR that produced it, or accept that it is this repo\'s own policy.',
228
+ );
229
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
230
+ return EXIT.OK;
231
+ }
232
+
233
+ lines.push('', `settled by ${cited.length} decision${cited.length === 1 ? '' : 's'}`);
234
+ for (const adr of cited) {
235
+ const d = index.get(adr);
236
+ if (!d) {
237
+ lines.push(` ${adr} MISSING - the rule cites it, but this installation has no record of it`);
238
+ continue;
239
+ }
240
+ lines.push(` ${d.id} ${d.title}`);
241
+ lines.push(` ${d.status}`);
242
+ if (d.supersededBy) {
243
+ // Never print a reversed ruling as if it were current.
244
+ lines.push(` SUPERSEDED by ${d.supersededBy} - read that record instead`);
245
+ }
246
+ }
247
+
248
+ // The reverse edge is what makes this useful for maintaining the set: which
249
+ // rules would a change to one decision touch?
250
+ const others = rules.filter((r) => r.id !== rule.id && citations(r.body).some((c) => cited.includes(c)));
251
+ if (others.length > 0) {
252
+ lines.push('', `shares a decision with ${others.map((r) => r.id).join(', ')}`);
253
+ }
254
+
255
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
256
+ return EXIT.OK;
257
+ }
258
+
259
+ async function adrDirection(adr, rules, decisions, flags) {
260
+ const d = decisions.find((x) => x.id === adr);
261
+ if (!d) {
262
+ // Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
263
+ // tree, which resolves to nothing in the repo the reader is standing in. The
264
+ // remedy must be a command they can run.
265
+ return fail(`no decision record ${adr}`, EXIT.USAGE, `list the known decisions with: ${CLI} rules list`);
266
+ }
267
+
268
+ const restates = rules.filter((r) => citations(r.body).includes(d.id));
269
+
270
+ if (isJson(flags)) {
271
+ // The machine payload answers the same question as the text one. A caller reading
272
+ // `adr.summary.ruled` off a superseded record would get the reversal, so the
273
+ // effective ruling is carried alongside rather than instead: the record asked about
274
+ // is still named, and what is in force is named separately.
275
+ const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
276
+ emit(
277
+ `${JSON.stringify(
278
+ {
279
+ ok: true,
280
+ command: 'rules why',
281
+ adr: d,
282
+ supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
283
+ inForce: replacement
284
+ ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
285
+ : { id: d.id, status: d.status, summary: d.summary },
286
+ rules: restates.map((r) => r.id),
287
+ },
288
+ null,
289
+ 2,
290
+ )}\n`,
291
+ { maxBytes: flags.maxBytes ?? 0 },
292
+ );
293
+ return EXIT.OK;
294
+ }
295
+
296
+ const lines = [
297
+ `${d.id} - ${d.title}`,
298
+ '',
299
+ `status ${d.status}`,
300
+ `restates ${restates.length > 0 ? restates.map((r) => r.id).join(', ') : '(no rule cites it)'}`,
301
+ ];
302
+ if (d.supersededBy) lines.push(`SUPERSEDED by ${d.supersededBy} - this record is retained as evidence, not as policy.`);
303
+ if (d.supersedes.length > 0) lines.push(`supersedes ${d.supersedes.join(', ')}`);
304
+ // A partial supersession is named here as well as on the ruling it moved, because the
305
+ // two print different sets: this prints every ruling the record lost, and the
306
+ // annotation prints the ones inside the excerpt below. A ruling past the excerpt
307
+ // exists in the estate but not in this output, and the status line above is the only
308
+ // other place it appears - which is the sentence the parser used to truncate.
309
+ if (d.partial.length > 0) {
310
+ lines.push(
311
+ `partial ${d.partial
312
+ .map((p) => `ruling ${p.ruling ?? '(not numbered)'} ${p.kind} by ${p.by ?? 'an unnamed record'}`)
313
+ .join('; ')}`,
314
+ );
315
+ }
316
+
317
+ // A superseded record must not print its own ruling. ADR-011 ruled that Windows is
318
+ // supported; ADR-012 reversed it the same day. Printing "Windows is a supported v1
319
+ // platform" under a SUPERSEDED banner is a reversed instruction with a warning
320
+ // attached, and an agent that skims the ruling and not the banner acts on the
321
+ // reversal. This is the failure the decision index's own header names: a provenance
322
+ // feature that proves provenance but not currency.
323
+ const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
324
+ const effective = replacement ?? d;
325
+
326
+ if (replacement) {
327
+ lines.push(
328
+ '',
329
+ `instead ${replacement.id} - ${replacement.title}`,
330
+ ` status ${replacement.status}`,
331
+ );
332
+ }
333
+
334
+ // The reason the ruling is printed at all, and not a pointer to this command: the
335
+ // reader arrived here from a `(ADR-NNN)` citation in a rule, and the only question
336
+ // that citation raises is "why am I being told this?". A title and a status answer
337
+ // "does it exist and is it current", which is the smaller half.
338
+ //
339
+ // `## Context` is deliberately absent. It is a survey of what particular
340
+ // repositories used to say - true, citable, and following none of it from a repo
341
+ // that owns none of those files.
342
+ if (effective.summary.ruled.length > 0) {
343
+ lines.push('', replacement ? 'ruled now' : 'ruled');
344
+ for (const r of effective.summary.ruled) lines.push(` - ${redactEstate(r)}`);
345
+ }
346
+ if (effective.summary.rejected.length > 0) {
347
+ // The rejected alternatives are the part a reader cannot reconstruct. A ruling is
348
+ // only legible against the option it refused. Estate names in that option are
349
+ // redacted: they name files the reader does not have.
350
+ lines.push('', 'rejected');
351
+ for (const r of effective.summary.rejected) lines.push(` - ${redactEstate(r)}`);
352
+ }
353
+ if (effective.summary.writtenAgainst.length > 0) {
354
+ lines.push(
355
+ '',
356
+ 'written against repositories this record surveyed',
357
+ ' the ruling above is the part that generalises; those files are not yours.',
358
+ );
359
+ }
360
+
361
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
362
+ // A superseded record is not a failure; it is the supersession rule working.
363
+ // The caller asked a question and got a true answer.
364
+ return EXIT.OK;
365
+ }
366
+
367
+ function unknown(id, rules) {
368
+ const near = nearMatches(id, rules.map((r) => r.id));
369
+ return fail(
370
+ `no rule with id "${id}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
371
+ EXIT.USAGE,
372
+ `run: ${CLI} rules list`,
373
+ );
374
+ }