simt-dev 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +82 -21
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/commands/plugin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// simt plugin <claude|gemini|cursor> --out <dir> [--yes]
|
|
1
|
+
// simt plugin <claude|gemini|cursor|opencode> --out <dir> [--yes]
|
|
2
2
|
//
|
|
3
3
|
// Build one harness package from the same tables `agent-hooks install` writes into a
|
|
4
4
|
// repository, so a package and a repository install cannot drift apart. A dry run prints
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
|
|
10
10
|
import { CLI } from '../name.mjs';
|
|
11
11
|
import { buildPackage, PLUGIN_KINDS } from '../plugins.mjs';
|
|
12
|
+
import { diffField, emitDiffs } from '../diff.mjs';
|
|
12
13
|
|
|
13
14
|
export async function pluginCommand(args, flags) {
|
|
14
15
|
const kind = args[0];
|
|
@@ -29,6 +30,17 @@ export async function pluginCommand(args, flags) {
|
|
|
29
30
|
const root = flags.root ?? process.cwd();
|
|
30
31
|
const plan = await buildPackage(kind, { root, out, apply });
|
|
31
32
|
|
|
33
|
+
// The `--diff` pairs: the generated files, created or refreshed (R50). The
|
|
34
|
+
// before-sides ride on the plan from `buildPackage`, read before anything was
|
|
35
|
+
// written, so the diff is true in preview and in apply alike.
|
|
36
|
+
const diffs =
|
|
37
|
+
flags.diff === true
|
|
38
|
+
? plan.files.map((f) => ({ path: f.path, before: f.before ?? null, after: f.text }))
|
|
39
|
+
: [];
|
|
40
|
+
// The file bytes travel in `diff`, not in the file list: a payload that
|
|
41
|
+
// carried every generated file twice would be a document nobody diffs.
|
|
42
|
+
const publicFiles = plan.files.map(({ text, before, ...rest }) => rest);
|
|
43
|
+
|
|
32
44
|
if (isJson(flags)) {
|
|
33
45
|
emitJson({
|
|
34
46
|
ok: true,
|
|
@@ -36,8 +48,9 @@ export async function pluginCommand(args, flags) {
|
|
|
36
48
|
out,
|
|
37
49
|
dryRun: !apply,
|
|
38
50
|
runtime: plan.runtime,
|
|
39
|
-
files:
|
|
51
|
+
files: publicFiles,
|
|
40
52
|
notes: plan.notes,
|
|
53
|
+
...diffField(diffs, flags),
|
|
41
54
|
});
|
|
42
55
|
return EXIT.OK;
|
|
43
56
|
}
|
|
@@ -57,5 +70,7 @@ export async function pluginCommand(args, flags) {
|
|
|
57
70
|
].join('\n'),
|
|
58
71
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
59
72
|
);
|
|
73
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
74
|
+
emitDiffs(diffs, flags);
|
|
60
75
|
return EXIT.OK;
|
|
61
76
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `simt pr-body [--base <ref>]` (plan 0003 R55).
|
|
2
|
+
//
|
|
3
|
+
// The report layer over `src/prbody.mjs`: a markdown PR body on stdout, one
|
|
4
|
+
// JSON document under `--json`. Read-only like `evidence` and `review` — it
|
|
5
|
+
// names the tree, reads the ledger and the check cache, and writes nothing,
|
|
6
|
+
// so there is no `--yes`, no `--diff` and no journal entry to record.
|
|
7
|
+
//
|
|
8
|
+
// The exit code never carries the verdict. A report that failed when the body
|
|
9
|
+
// said BLOCK would be a gate by another name (the same reason `review`
|
|
10
|
+
// exits 0 on an unverified reply), and a gate this command never asked to be.
|
|
11
|
+
|
|
12
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
13
|
+
import { CLI } from '../name.mjs';
|
|
14
|
+
import { buildPrBody, renderPrBody } from '../prbody.mjs';
|
|
15
|
+
|
|
16
|
+
export async function prBodyCommand(args, flags) {
|
|
17
|
+
const root = flags.root ?? process.cwd();
|
|
18
|
+
if (args.length > 0) {
|
|
19
|
+
return fail(`pr-body takes no positional arguments`, EXIT.USAGE, `run: ${CLI} pr-body [--base <ref>]`);
|
|
20
|
+
}
|
|
21
|
+
const report = await buildPrBody(root, { base: flags.base ?? null });
|
|
22
|
+
if (report.error !== null) {
|
|
23
|
+
return fail(report.error, EXIT.USAGE, `run: ${CLI} pr-body --base <ref> with a ref git knows`);
|
|
24
|
+
}
|
|
25
|
+
if (isJson(flags)) {
|
|
26
|
+
emitJson(
|
|
27
|
+
{
|
|
28
|
+
ok: true,
|
|
29
|
+
command: 'pr-body',
|
|
30
|
+
base: report.base,
|
|
31
|
+
tree: report.tree,
|
|
32
|
+
evidence: report.evidence,
|
|
33
|
+
decisions: report.decisions,
|
|
34
|
+
paths: report.paths,
|
|
35
|
+
gates: report.gates,
|
|
36
|
+
verdict: report.verdict,
|
|
37
|
+
unreviewed: report.unreviewed,
|
|
38
|
+
},
|
|
39
|
+
{ pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 },
|
|
40
|
+
);
|
|
41
|
+
return EXIT.OK;
|
|
42
|
+
}
|
|
43
|
+
emit(renderPrBody(report), { maxBytes: flags.maxBytes ?? 0 });
|
|
44
|
+
return EXIT.OK;
|
|
45
|
+
}
|
|
@@ -18,6 +18,9 @@ export async function indexCommand(args, flags) {
|
|
|
18
18
|
allowRemote: flags.allowRemote === true,
|
|
19
19
|
});
|
|
20
20
|
} catch (error) {
|
|
21
|
+
if (error?.code === 'INDEX_BUSY') {
|
|
22
|
+
return fail(`could not index this repository (${error.message})`, EXIT.FAIL, 'wait a moment and run the index again');
|
|
23
|
+
}
|
|
21
24
|
return fail(
|
|
22
25
|
`could not index this repository (${error.message})`,
|
|
23
26
|
EXIT.UNSUPPORTED,
|
|
@@ -36,6 +39,7 @@ export async function indexCommand(args, flags) {
|
|
|
36
39
|
`files ${result.files}`,
|
|
37
40
|
`updated ${result.updated}`,
|
|
38
41
|
`removed ${result.removed}`,
|
|
42
|
+
`skipped ${result.skipped ?? 0}`,
|
|
39
43
|
`symbols ${result.symbols}`,
|
|
40
44
|
`edges ${result.edges}`,
|
|
41
45
|
`ms ${result.ms}`,
|
package/src/commands/rules.mjs
CHANGED
|
@@ -20,7 +20,9 @@ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
|
20
20
|
import { loadRules, select } from '../projection/project.mjs';
|
|
21
21
|
import { loadMdcRules } from '../projection/mdc.mjs';
|
|
22
22
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
23
|
-
import { byId, citations,
|
|
23
|
+
import { byId, citations, loadDecisionsCached, redactEstate } from '../decisions/index.mjs';
|
|
24
|
+
import { DECISIONS_DIR } from '../decisions/index.mjs';
|
|
25
|
+
import { decisionsDir } from '../decisions/dir.mjs';
|
|
24
26
|
import { nearMatches } from '../resolve/near.mjs';
|
|
25
27
|
import { CLI } from '../name.mjs';
|
|
26
28
|
|
|
@@ -87,11 +89,10 @@ async function loadPerSurfaceRules() {
|
|
|
87
89
|
|
|
88
90
|
export async function listCommand(args, flags) {
|
|
89
91
|
const root = flags.root ?? null;
|
|
90
|
-
const { rules, broken } = await load(root);
|
|
91
|
-
|
|
92
92
|
// With --root the question becomes "which of these apply to me", which is a
|
|
93
93
|
// different answer from the catalogue and the one an agent usually wants.
|
|
94
|
-
const
|
|
94
|
+
const { rules, broken, notes } = await rulesCatalogue(root, { selectFor: root });
|
|
95
|
+
const selection = root ? new Set(rules.filter((r) => r.selected).map((r) => r.id)) : null;
|
|
95
96
|
|
|
96
97
|
if (isJson(flags)) {
|
|
97
98
|
emitJson(
|
|
@@ -99,19 +100,8 @@ export async function listCommand(args, flags) {
|
|
|
99
100
|
ok: broken.length === 0,
|
|
100
101
|
command: 'rules list',
|
|
101
102
|
root,
|
|
102
|
-
rules
|
|
103
|
-
|
|
104
|
-
title: r.title,
|
|
105
|
-
origin: r.origin,
|
|
106
|
-
source: r.source,
|
|
107
|
-
applies: r.applies,
|
|
108
|
-
fallback: r.fallback,
|
|
109
|
-
bytes: r.bytes,
|
|
110
|
-
error: r.error,
|
|
111
|
-
adrs: citations(r.body),
|
|
112
|
-
selected: selection ? selection.selected.has(r.id) : null,
|
|
113
|
-
})),
|
|
114
|
-
notes: selection?.notes ?? [],
|
|
103
|
+
rules,
|
|
104
|
+
notes,
|
|
115
105
|
},
|
|
116
106
|
);
|
|
117
107
|
return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
@@ -145,7 +135,7 @@ export async function listCommand(args, flags) {
|
|
|
145
135
|
];
|
|
146
136
|
|
|
147
137
|
for (const rule of rows) {
|
|
148
|
-
const mark = selection && selection.
|
|
138
|
+
const mark = selection && selection.has(rule.id) ? '*' : ' ';
|
|
149
139
|
lines.push(
|
|
150
140
|
render(mark, rule.id, kb(rule.bytes), rule.origin, appliesLabel(rule), rule.title ?? '(no title)'),
|
|
151
141
|
);
|
|
@@ -156,7 +146,7 @@ export async function listCommand(args, flags) {
|
|
|
156
146
|
}
|
|
157
147
|
if (selection) {
|
|
158
148
|
lines.push('', `* selected for ${root}`);
|
|
159
|
-
for (const note of
|
|
149
|
+
for (const note of notes) lines.push(` note ${note}`);
|
|
160
150
|
}
|
|
161
151
|
if (root) {
|
|
162
152
|
const locals = rules.filter((r) => r.origin === 'local');
|
|
@@ -173,15 +163,152 @@ async function selectionFor(rules, root) {
|
|
|
173
163
|
return { selected: new Set(selected.map((r) => r.id)), notes };
|
|
174
164
|
}
|
|
175
165
|
|
|
166
|
+
// --- cores: one computation for the CLI and the MCP tools (Q15) ----------------
|
|
167
|
+
//
|
|
168
|
+
// `rules_list` and `rules_why` in the MCP surface rebuilt this catalogue from the
|
|
169
|
+
// same modules with narrower loads: the MCP list missed the per-surface rules and
|
|
170
|
+
// the MCP why failed on them, because neither shared the catalogue below. The
|
|
171
|
+
// commands render these facts for terminals; the tools return them as JSON. The
|
|
172
|
+
// CLI keeps its field names; the tools adopt them.
|
|
173
|
+
//
|
|
174
|
+
// A core throws when it cannot answer: the message names the problem, `remedy`
|
|
175
|
+
// names the next command, and `exitCode` is the exit the CLI leaves with.
|
|
176
|
+
function rulesFailure(message, remedy, exitCode) {
|
|
177
|
+
throw Object.assign(new Error(message), { remedy, exitCode });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The merged decision index: the package's shipped records underlay the
|
|
182
|
+
* repository's own, which win on an id collision (review 0003 Q34).
|
|
183
|
+
*
|
|
184
|
+
* Before this, a consumer with its own ADR-001/002 read `rules why core` as
|
|
185
|
+
* settled by *their* ADR-001 while the package's ADR-003…009 — the ones the
|
|
186
|
+
* shipped rules actually cite — reported MISSING. The overlay keeps both
|
|
187
|
+
* visible: every decision carries which layer it came from, so a same-number
|
|
188
|
+
* collision reads as two records, never as one answer wearing the wrong title.
|
|
189
|
+
*
|
|
190
|
+
* @returns {{ok: boolean, decisions: object[], errors: string[], origins: Record<string,'repo'|'package'>}}
|
|
191
|
+
*/
|
|
192
|
+
async function loadDecisionsFor(root, all) {
|
|
193
|
+
// Q55: one resolution (manifest key, then detection) and the shared cache,
|
|
194
|
+
// so `rules why` answers from the repository's own records without re-parsing
|
|
195
|
+
// every record on every call. No root means the package's own records.
|
|
196
|
+
const pkg = await loadDecisionsCached({ root, dir: DECISIONS_DIR, all });
|
|
197
|
+
if (!root) return { ...pkg, origins: Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package'])) };
|
|
198
|
+
const relDir = await decisionsDir(root);
|
|
199
|
+
const absDir = join(root, relDir);
|
|
200
|
+
const hasLocal = (await readdir(absDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n));
|
|
201
|
+
if (!hasLocal) return { ...pkg, origins: Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package'])) };
|
|
202
|
+
const local = await loadDecisionsCached({ root, dir: absDir, all });
|
|
203
|
+
const merged = new Map(pkg.decisions.map((d) => [d.id, d]));
|
|
204
|
+
const origins = Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package']));
|
|
205
|
+
for (const d of local.decisions) {
|
|
206
|
+
merged.set(d.id, d);
|
|
207
|
+
origins[d.id] = 'repo';
|
|
208
|
+
}
|
|
209
|
+
return { ok: pkg.ok && local.ok, decisions: [...merged.values()], errors: [...pkg.errors, ...local.errors], origins };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The catalogue both surfaces answer from: every rule, its decisions, and the selection. */
|
|
213
|
+
export async function rulesCatalogue(root, { selectFor = null } = {}) {
|
|
214
|
+
const { rules, broken } = await load(root);
|
|
215
|
+
const selection = selectFor ? await selectionFor(rules, selectFor) : null;
|
|
216
|
+
return {
|
|
217
|
+
rules: sorted(rules).map((r) => ({
|
|
218
|
+
id: r.id,
|
|
219
|
+
title: r.title,
|
|
220
|
+
origin: r.origin,
|
|
221
|
+
source: r.source,
|
|
222
|
+
applies: r.applies,
|
|
223
|
+
optIn: r.optIn ?? false,
|
|
224
|
+
fallback: r.fallback,
|
|
225
|
+
bytes: r.bytes,
|
|
226
|
+
error: r.error,
|
|
227
|
+
adrs: citations(r.body),
|
|
228
|
+
selected: selection ? selection.selected.has(r.id) : null,
|
|
229
|
+
})),
|
|
230
|
+
broken,
|
|
231
|
+
notes: selection?.notes ?? [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The provenance facts for one rule or one decision record, in the shape the
|
|
237
|
+
* JSON documents carry. Text renders from the same facts; nothing re-derives.
|
|
238
|
+
*/
|
|
239
|
+
export async function rulesWhyFacts(root, what, { all = false } = {}) {
|
|
240
|
+
const { rules } = await load(root);
|
|
241
|
+
if (/^ADR-\d{3,}$/i.test(what)) {
|
|
242
|
+
const id = what.toUpperCase();
|
|
243
|
+
const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
|
|
244
|
+
if (!ok) {
|
|
245
|
+
rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
|
|
246
|
+
}
|
|
247
|
+
const record = decisions.find((x) => x.id === id) ?? null;
|
|
248
|
+
// Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
|
|
249
|
+
// tree, which resolves to nothing in the repo the reader is standing in. The
|
|
250
|
+
// remedy must be a command they can run (Q34: the decisions list, not the
|
|
251
|
+
// rules list - the reader asked about a decision, not a rule).
|
|
252
|
+
if (!record) rulesFailure(`no decision record ${id}`, `list the known decisions with: ${CLI} rules decisions`, EXIT.USAGE);
|
|
253
|
+
const replacement = record.supersededBy ? decisions.find((x) => x.id === record.supersededBy) : null;
|
|
254
|
+
return {
|
|
255
|
+
direction: 'decision',
|
|
256
|
+
id,
|
|
257
|
+
adr: { ...record, origin: origins[id] ?? 'package' },
|
|
258
|
+
origins,
|
|
259
|
+
supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
|
|
260
|
+
inForce: replacement
|
|
261
|
+
? { id: replacement.id, status: replacement.status, summary: replacement.summary }
|
|
262
|
+
: { id: record.id, status: record.status, summary: record.summary },
|
|
263
|
+
rules: rules.filter((r) => citations(r.body).includes(id)).map((r) => r.id),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const rule = rules.find((r) => r.id === what);
|
|
267
|
+
if (!rule) {
|
|
268
|
+
const near = nearMatches(what, rules.map((r) => r.id));
|
|
269
|
+
rulesFailure(
|
|
270
|
+
`no rule with id "${what}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
271
|
+
`run: ${CLI} rules list`,
|
|
272
|
+
EXIT.USAGE,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
|
|
276
|
+
if (!ok) {
|
|
277
|
+
rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
|
|
278
|
+
}
|
|
279
|
+
const index = byId(decisions);
|
|
280
|
+
const cited = citations(rule.body);
|
|
281
|
+
const others = rules.filter((r) => r.id !== rule.id && citations(r.body).some((c) => cited.includes(c)));
|
|
282
|
+
return {
|
|
283
|
+
direction: 'rule',
|
|
284
|
+
id: rule.id,
|
|
285
|
+
rule: rule.id,
|
|
286
|
+
title: rule.title ?? null,
|
|
287
|
+
source: rule.source,
|
|
288
|
+
origin: rule.origin,
|
|
289
|
+
adrs: cited.map((c) => (index.has(c) ? { ...index.get(c), origin: origins[c] ?? 'package' } : { id: c, missing: true })),
|
|
290
|
+
uncited: cited.length === 0,
|
|
291
|
+
others: others.map((r) => r.id),
|
|
292
|
+
origins,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
176
296
|
// --- show ---------------------------------------------------------------------
|
|
177
297
|
|
|
178
298
|
export async function showCommand(args, flags) {
|
|
179
299
|
const id = args[0];
|
|
180
300
|
if (!id) return fail('rules show needs a rule id', EXIT.USAGE, `usage: ${CLI} rules show <id>`);
|
|
181
301
|
|
|
182
|
-
const { rules
|
|
302
|
+
const { rules } = await load(flags.root ?? null);
|
|
183
303
|
const rule = rules.find((r) => r.id === id);
|
|
184
|
-
if (!rule)
|
|
304
|
+
if (!rule) {
|
|
305
|
+
const near = nearMatches(id, rules.map((r) => r.id));
|
|
306
|
+
return fail(
|
|
307
|
+
`no rule with id "${id}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
308
|
+
EXIT.USAGE,
|
|
309
|
+
`run: ${CLI} rules list`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
185
312
|
|
|
186
313
|
const adrs = citations(rule.body);
|
|
187
314
|
if (isJson(flags)) {
|
|
@@ -218,47 +345,25 @@ export async function whyCommand(args, flags) {
|
|
|
218
345
|
const what = args[0];
|
|
219
346
|
if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
|
|
220
347
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
all: flags.all === true,
|
|
228
|
-
...(hasLocal ? { dir: localDir } : {}),
|
|
229
|
-
});
|
|
230
|
-
if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
|
|
231
|
-
|
|
232
|
-
return what.match(/^ADR-\d{3,}$/i)
|
|
233
|
-
? adrDirection(what.toUpperCase(), rules, decisions, flags)
|
|
234
|
-
: ruleDirection(what, rules, decisions, flags, broken);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
async function ruleDirection(id, rules, decisions, flags, broken) {
|
|
238
|
-
const rule = rules.find((r) => r.id === id);
|
|
239
|
-
if (!rule) return unknown(id, rules);
|
|
240
|
-
|
|
241
|
-
const cited = citations(rule.body);
|
|
242
|
-
const index = byId(decisions);
|
|
348
|
+
let facts;
|
|
349
|
+
try {
|
|
350
|
+
facts = await rulesWhyFacts(flags.root ?? null, what, { all: flags.all === true });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
|
|
353
|
+
}
|
|
243
354
|
|
|
244
355
|
if (isJson(flags)) {
|
|
245
|
-
emitJson(
|
|
246
|
-
{
|
|
247
|
-
ok: true,
|
|
248
|
-
command: 'rules why',
|
|
249
|
-
rule: rule.id,
|
|
250
|
-
source: rule.source,
|
|
251
|
-
origin: rule.origin,
|
|
252
|
-
adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
|
|
253
|
-
uncited: cited.length === 0,
|
|
254
|
-
},
|
|
255
|
-
);
|
|
356
|
+
emitJson({ ok: true, command: 'rules why', ...facts });
|
|
256
357
|
return EXIT.OK;
|
|
257
358
|
}
|
|
258
359
|
|
|
259
|
-
|
|
360
|
+
return facts.direction === 'decision' ? adrDirection(facts, flags) : ruleDirection(facts, flags);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function ruleDirection(facts, flags) {
|
|
364
|
+
const lines = [`${facts.rule} - ${facts.title ?? '(no title)'}`, '', `source ${facts.source} (${facts.origin})`];
|
|
260
365
|
|
|
261
|
-
if (
|
|
366
|
+
if (facts.uncited) {
|
|
262
367
|
lines.push(
|
|
263
368
|
'',
|
|
264
369
|
'provenance NONE',
|
|
@@ -269,14 +374,14 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
|
|
|
269
374
|
return EXIT.OK;
|
|
270
375
|
}
|
|
271
376
|
|
|
272
|
-
lines.push('', `settled by ${
|
|
273
|
-
for (const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
lines.push(`
|
|
377
|
+
lines.push('', `settled by ${facts.adrs.length} decision${facts.adrs.length === 1 ? '' : 's'}`);
|
|
378
|
+
for (const d of facts.adrs) {
|
|
379
|
+
if (d.missing) {
|
|
380
|
+
lines.push(` ${d.id} MISSING - the rule cites it, but neither this repo nor this installation has a record of it`);
|
|
381
|
+
lines.push(` remedy: list the known decisions with: ${CLI} rules decisions`);
|
|
277
382
|
continue;
|
|
278
383
|
}
|
|
279
|
-
lines.push(` ${d.id} ${d.title}`);
|
|
384
|
+
lines.push(` ${d.id} [${d.origin ?? 'package'}] ${d.title}`);
|
|
280
385
|
lines.push(` ${d.status}`);
|
|
281
386
|
if (d.supersededBy) {
|
|
282
387
|
// Never print a reversed ruling as if it were current.
|
|
@@ -286,52 +391,24 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
|
|
|
286
391
|
|
|
287
392
|
// The reverse edge is what makes this useful for maintaining the set: which
|
|
288
393
|
// rules would a change to one decision touch?
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
lines.push('', `shares a decision with ${others.map((r) => r.id).join(', ')}`);
|
|
394
|
+
if (facts.others.length > 0) {
|
|
395
|
+
lines.push('', `shares a decision with ${facts.others.join(', ')}`);
|
|
292
396
|
}
|
|
293
397
|
|
|
294
398
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
295
399
|
return EXIT.OK;
|
|
296
400
|
}
|
|
297
401
|
|
|
298
|
-
|
|
299
|
-
const d =
|
|
300
|
-
|
|
301
|
-
// Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
|
|
302
|
-
// tree, which resolves to nothing in the repo the reader is standing in. The
|
|
303
|
-
// remedy must be a command they can run.
|
|
304
|
-
return fail(`no decision record ${adr}`, EXIT.USAGE, `list the known decisions with: ${CLI} rules list`);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const restates = rules.filter((r) => citations(r.body).includes(d.id));
|
|
308
|
-
|
|
309
|
-
if (isJson(flags)) {
|
|
310
|
-
// The machine payload answers the same question as the text one. A caller reading
|
|
311
|
-
// `adr.summary.ruled` off a superseded record would get the reversal, so the
|
|
312
|
-
// effective ruling is carried alongside rather than instead: the record asked about
|
|
313
|
-
// is still named, and what is in force is named separately.
|
|
314
|
-
const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
|
|
315
|
-
emitJson(
|
|
316
|
-
{
|
|
317
|
-
ok: true,
|
|
318
|
-
command: 'rules why',
|
|
319
|
-
adr: d,
|
|
320
|
-
supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
|
|
321
|
-
inForce: replacement
|
|
322
|
-
? { id: replacement.id, status: replacement.status, summary: replacement.summary }
|
|
323
|
-
: { id: d.id, status: d.status, summary: d.summary },
|
|
324
|
-
rules: restates.map((r) => r.id),
|
|
325
|
-
},
|
|
326
|
-
);
|
|
327
|
-
return EXIT.OK;
|
|
328
|
-
}
|
|
402
|
+
function adrDirection(facts, flags) {
|
|
403
|
+
const d = facts.adr;
|
|
404
|
+
const restates = facts.rules;
|
|
329
405
|
|
|
330
406
|
const lines = [
|
|
331
407
|
`${d.id} - ${d.title}`,
|
|
332
408
|
'',
|
|
409
|
+
`origin ${d.origin ?? 'package'}${d.origin === 'repo' ? ' (this repository overlays the shipped record of the same number)' : ''}`,
|
|
333
410
|
`status ${d.status}`,
|
|
334
|
-
`restates ${restates.length > 0 ? restates.
|
|
411
|
+
`restates ${restates.length > 0 ? restates.join(', ') : '(no rule cites it)'}`,
|
|
335
412
|
];
|
|
336
413
|
if (d.supersededBy) lines.push(`SUPERSEDED by ${d.supersededBy} - this record is retained as evidence, not as policy.`);
|
|
337
414
|
if (d.supersedes.length > 0) lines.push(`supersedes ${d.supersedes.join(', ')}`);
|
|
@@ -360,7 +437,7 @@ async function adrDirection(adr, rules, decisions, flags) {
|
|
|
360
437
|
// attached, and an agent that skims the ruling and not the banner acts on the
|
|
361
438
|
// reversal. This is the failure the decision index's own header names: a provenance
|
|
362
439
|
// feature that proves provenance but not currency.
|
|
363
|
-
const replacement =
|
|
440
|
+
const replacement = facts.replacement;
|
|
364
441
|
const effective = replacement ?? d;
|
|
365
442
|
|
|
366
443
|
if (replacement) {
|
|
@@ -415,11 +492,48 @@ async function adrDirection(adr, rules, decisions, flags) {
|
|
|
415
492
|
return EXIT.OK;
|
|
416
493
|
}
|
|
417
494
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
495
|
+
// --- decisions ------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* `simt rules decisions`: every known decision record, both layers.
|
|
499
|
+
*
|
|
500
|
+
* The shipped records underlay the repository's own, which win on a number
|
|
501
|
+
* collision (review 0003 Q34). The column that matters is `origin`: a reader in
|
|
502
|
+
* a consumer repository must be able to tell the package's ADR-001 from their
|
|
503
|
+
* own, because they are two records that happen to share a number.
|
|
504
|
+
*/
|
|
505
|
+
export async function decisionsCommand(args, flags) {
|
|
506
|
+
const root = flags.root ?? null;
|
|
507
|
+
const { ok, decisions, errors, origins } = await loadDecisionsFor(root, flags.all === true);
|
|
508
|
+
if (!ok) {
|
|
509
|
+
return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable to list them');
|
|
510
|
+
}
|
|
511
|
+
const rows = [...decisions]
|
|
512
|
+
.sort((a, b) => Number(a.id.replace(/^ADR-/i, '')) - Number(b.id.replace(/^ADR-/i, '')))
|
|
513
|
+
.map((d) => ({
|
|
514
|
+
id: d.id,
|
|
515
|
+
title: d.title,
|
|
516
|
+
status: d.status,
|
|
517
|
+
origin: origins[d.id] ?? 'package',
|
|
518
|
+
supersededBy: d.supersededBy ?? null,
|
|
519
|
+
file: d.file,
|
|
520
|
+
}));
|
|
521
|
+
|
|
522
|
+
if (isJson(flags)) {
|
|
523
|
+
emitJson({ ok: true, command: 'rules decisions', root, decisions: rows });
|
|
524
|
+
return EXIT.OK;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const repo = rows.filter((r) => r.origin === 'repo').length;
|
|
528
|
+
const lines = [
|
|
529
|
+
`${rows.length} decisions${root && repo > 0 ? ` (${repo} from this repo overlay the shipped set)` : ''}`,
|
|
530
|
+
'',
|
|
531
|
+
'id origin title',
|
|
532
|
+
];
|
|
533
|
+
for (const r of rows) {
|
|
534
|
+
const head = r.status.length > 80 ? `${r.status.slice(0, 80)}...` : r.status;
|
|
535
|
+
lines.push(`${r.id.padEnd(8)} ${r.origin.padEnd(7)} ${r.title} — ${head}`);
|
|
536
|
+
}
|
|
537
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
538
|
+
return EXIT.OK;
|
|
425
539
|
}
|
package/src/commands/shared.mjs
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
// is `doctor`'s job rather than a third subcommand: a question asked about a repository is
|
|
12
12
|
// answered by the command that already reports on a repository (ADR-084).
|
|
13
13
|
|
|
14
|
-
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
14
|
+
import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
|
|
15
15
|
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
16
16
|
import { targetRoot } from '../workspace.mjs';
|
|
17
|
+
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
17
18
|
import {
|
|
19
|
+
injectShared,
|
|
18
20
|
intersect,
|
|
19
21
|
sharedBody,
|
|
20
22
|
sharedCandidates,
|
|
@@ -25,7 +27,7 @@ import {
|
|
|
25
27
|
sharedTargets,
|
|
26
28
|
writeSharedFile,
|
|
27
29
|
} from '../projection/shared.mjs';
|
|
28
|
-
import { buildManifest, declaredShared, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
30
|
+
import { buildManifest, declaredShared, manifestPath, manifestText, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
29
31
|
import { desiredRegion } from '../projection/rules.mjs';
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -241,7 +243,31 @@ export async function sharedInstallCommand(_args, flags) {
|
|
|
241
243
|
manifests.push({ root, action: 'declared', manifest: built });
|
|
242
244
|
}
|
|
243
245
|
|
|
244
|
-
|
|
246
|
+
// The `--diff` pairs, read before anything is written so the before side is
|
|
247
|
+
// true in preview and in apply alike (R50). Home-directory copies through the
|
|
248
|
+
// same `injectShared` the write applies, manifest declarations through
|
|
249
|
+
// `manifestText`; paste targets carry no file and are printed in full below.
|
|
250
|
+
let diffs = [];
|
|
251
|
+
if (flags.diff === true) {
|
|
252
|
+
for (const t of targets) {
|
|
253
|
+
if (t.path === null) continue;
|
|
254
|
+
const before = await readBefore(t.path);
|
|
255
|
+
diffs.push({ path: t.path, before, after: injectShared(before ?? '', shared, meta).text });
|
|
256
|
+
}
|
|
257
|
+
for (const row of manifests) {
|
|
258
|
+
if (row.action !== 'declared') continue;
|
|
259
|
+
diffs.push({
|
|
260
|
+
path: `${basename(row.root)}/${MANIFEST_FILE}`,
|
|
261
|
+
before: await readBefore(await manifestPath(row.root)),
|
|
262
|
+
after: manifestText(row.manifest),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// `--yes --dry-run` is a rehearsal, not consent: only writeMode decides, so the
|
|
268
|
+
// `--dry-run` promise holds for the one command that writes into `$HOME` (Q09).
|
|
269
|
+
const { dryRun, apply } = writeMode(flags);
|
|
270
|
+
if (!apply) {
|
|
245
271
|
// Nothing above this line writes, and nothing below it runs without the reader's
|
|
246
272
|
// consent — because one of the two writes lands in their home directory and the other
|
|
247
273
|
// edits repositories other people may be reviewing. The states printed here come from
|
|
@@ -259,12 +285,13 @@ export async function sharedInstallCommand(_args, flags) {
|
|
|
259
285
|
emitJson({
|
|
260
286
|
ok: false,
|
|
261
287
|
command: 'shared install',
|
|
262
|
-
mode: 'preview',
|
|
288
|
+
mode: dryRun ? 'dry-run' : 'preview',
|
|
263
289
|
roots,
|
|
264
290
|
shared,
|
|
265
291
|
divergent,
|
|
266
292
|
targets: states,
|
|
267
293
|
manifests: manifests.map(({ root: r, action, detail }) => ({ root: r, action, detail: detail ?? null })),
|
|
294
|
+
...diffField(diffs, flags),
|
|
268
295
|
});
|
|
269
296
|
return EXIT.FAIL;
|
|
270
297
|
}
|
|
@@ -277,10 +304,12 @@ export async function sharedInstallCommand(_args, flags) {
|
|
|
277
304
|
: ` ${s.state.padEnd(9)}${s.path}`,
|
|
278
305
|
),
|
|
279
306
|
`manifest ${manifests.map((m) => `${m.action}:${basename(m.root)}`).join(' ')}`,
|
|
280
|
-
`nothing written; re-run with: ${CLI} shared install --yes`,
|
|
307
|
+
dryRun ? 'dry-run nothing written' : `nothing written; re-run with: ${CLI} shared install --yes`,
|
|
281
308
|
].join('\n'),
|
|
282
309
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
283
310
|
);
|
|
311
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
312
|
+
emitDiffs(diffs, flags);
|
|
284
313
|
return EXIT.FAIL;
|
|
285
314
|
}
|
|
286
315
|
|
|
@@ -326,6 +355,8 @@ export async function sharedInstallCommand(_args, flags) {
|
|
|
326
355
|
}
|
|
327
356
|
lines.push(`verify ${CLI} doctor reports the copy as current, and ${CLI} tokens --repos --root . reports what is left`);
|
|
328
357
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
358
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
359
|
+
emitDiffs(diffs, flags);
|
|
329
360
|
return EXIT.OK;
|
|
330
361
|
}
|
|
331
362
|
|