simt-dev 0.6.0 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/README.md +44 -15
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
- 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/README.md +44 -0
- package/package.json +1 -1
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/stacks/android.md +7 -8
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/jvm.md +1 -1
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/src/agenthooks/decide.mjs +101 -10
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/shell.mjs +523 -0
- package/src/argv.mjs +54 -9
- package/src/ci/workflow.mjs +7 -5
- package/src/commands/agenthook.mjs +25 -3
- package/src/commands/ci.mjs +28 -3
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +14 -1
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/explain.mjs +52 -0
- package/src/commands/help.mjs +66 -17
- package/src/commands/hooks.mjs +208 -60
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/index.mjs +54 -100
- package/src/commands/init.mjs +82 -12
- package/src/commands/lease.mjs +136 -0
- package/src/commands/lessons.mjs +12 -0
- 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 +16 -1
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +3 -0
- package/src/commands/rules.mjs +145 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +293 -205
- package/src/commands/skillsinstall.mjs +100 -30
- package/src/commands/slice.mjs +353 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +110 -15
- package/src/commands/undo.mjs +106 -0
- package/src/commands/upgrade.mjs +70 -8
- package/src/completions.mjs +191 -0
- package/src/diff.mjs +254 -0
- package/src/explain.mjs +258 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +14 -5
- package/src/hooks/dispatcher.mjs +110 -0
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +14 -0
- package/src/ignore.mjs +133 -0
- package/src/index/persist.mjs +66 -9
- package/src/journal.mjs +326 -0
- package/src/lease.mjs +113 -0
- package/src/mcp/server.mjs +15 -1
- package/src/mcp/tools.mjs +296 -256
- package/src/memory/decide.mjs +6 -1
- package/src/memory/lessons.mjs +12 -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 +6 -0
- package/src/plugins.mjs +27 -6
- package/src/prbody.mjs +251 -0
- package/src/projection/manifest.mjs +13 -2
- package/src/projection/shared.mjs +4 -1
- package/src/secrets.mjs +231 -0
- package/src/status.mjs +152 -0
package/src/commands/plugin.mjs
CHANGED
|
@@ -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,
|
package/src/commands/rules.mjs
CHANGED
|
@@ -87,11 +87,10 @@ async function loadPerSurfaceRules() {
|
|
|
87
87
|
|
|
88
88
|
export async function listCommand(args, flags) {
|
|
89
89
|
const root = flags.root ?? null;
|
|
90
|
-
const { rules, broken } = await load(root);
|
|
91
|
-
|
|
92
90
|
// With --root the question becomes "which of these apply to me", which is a
|
|
93
91
|
// different answer from the catalogue and the one an agent usually wants.
|
|
94
|
-
const
|
|
92
|
+
const { rules, broken, notes } = await rulesCatalogue(root, { selectFor: root });
|
|
93
|
+
const selection = root ? new Set(rules.filter((r) => r.selected).map((r) => r.id)) : null;
|
|
95
94
|
|
|
96
95
|
if (isJson(flags)) {
|
|
97
96
|
emitJson(
|
|
@@ -99,19 +98,8 @@ export async function listCommand(args, flags) {
|
|
|
99
98
|
ok: broken.length === 0,
|
|
100
99
|
command: 'rules list',
|
|
101
100
|
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 ?? [],
|
|
101
|
+
rules,
|
|
102
|
+
notes,
|
|
115
103
|
},
|
|
116
104
|
);
|
|
117
105
|
return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
@@ -145,7 +133,7 @@ export async function listCommand(args, flags) {
|
|
|
145
133
|
];
|
|
146
134
|
|
|
147
135
|
for (const rule of rows) {
|
|
148
|
-
const mark = selection && selection.
|
|
136
|
+
const mark = selection && selection.has(rule.id) ? '*' : ' ';
|
|
149
137
|
lines.push(
|
|
150
138
|
render(mark, rule.id, kb(rule.bytes), rule.origin, appliesLabel(rule), rule.title ?? '(no title)'),
|
|
151
139
|
);
|
|
@@ -156,7 +144,7 @@ export async function listCommand(args, flags) {
|
|
|
156
144
|
}
|
|
157
145
|
if (selection) {
|
|
158
146
|
lines.push('', `* selected for ${root}`);
|
|
159
|
-
for (const note of
|
|
147
|
+
for (const note of notes) lines.push(` note ${note}`);
|
|
160
148
|
}
|
|
161
149
|
if (root) {
|
|
162
150
|
const locals = rules.filter((r) => r.origin === 'local');
|
|
@@ -173,15 +161,128 @@ async function selectionFor(rules, root) {
|
|
|
173
161
|
return { selected: new Set(selected.map((r) => r.id)), notes };
|
|
174
162
|
}
|
|
175
163
|
|
|
164
|
+
// --- cores: one computation for the CLI and the MCP tools (Q15) ----------------
|
|
165
|
+
//
|
|
166
|
+
// `rules_list` and `rules_why` in the MCP surface rebuilt this catalogue from the
|
|
167
|
+
// same modules with narrower loads: the MCP list missed the per-surface rules and
|
|
168
|
+
// the MCP why failed on them, because neither shared the catalogue below. The
|
|
169
|
+
// commands render these facts for terminals; the tools return them as JSON. The
|
|
170
|
+
// CLI keeps its field names; the tools adopt them.
|
|
171
|
+
//
|
|
172
|
+
// A core throws when it cannot answer: the message names the problem, `remedy`
|
|
173
|
+
// names the next command, and `exitCode` is the exit the CLI leaves with.
|
|
174
|
+
function rulesFailure(message, remedy, exitCode) {
|
|
175
|
+
throw Object.assign(new Error(message), { remedy, exitCode });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function loadDecisionsFor(root, all) {
|
|
179
|
+
const localDir = root ? join(root, 'docs', 'decisions') : null;
|
|
180
|
+
const hasLocal = localDir
|
|
181
|
+
? (await readdir(localDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n))
|
|
182
|
+
: false;
|
|
183
|
+
return loadDecisions({
|
|
184
|
+
all,
|
|
185
|
+
...(hasLocal ? { dir: localDir } : {}),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The catalogue both surfaces answer from: every rule, its decisions, and the selection. */
|
|
190
|
+
export async function rulesCatalogue(root, { selectFor = null } = {}) {
|
|
191
|
+
const { rules, broken } = await load(root);
|
|
192
|
+
const selection = selectFor ? await selectionFor(rules, selectFor) : null;
|
|
193
|
+
return {
|
|
194
|
+
rules: sorted(rules).map((r) => ({
|
|
195
|
+
id: r.id,
|
|
196
|
+
title: r.title,
|
|
197
|
+
origin: r.origin,
|
|
198
|
+
source: r.source,
|
|
199
|
+
applies: r.applies,
|
|
200
|
+
optIn: r.optIn ?? false,
|
|
201
|
+
fallback: r.fallback,
|
|
202
|
+
bytes: r.bytes,
|
|
203
|
+
error: r.error,
|
|
204
|
+
adrs: citations(r.body),
|
|
205
|
+
selected: selection ? selection.selected.has(r.id) : null,
|
|
206
|
+
})),
|
|
207
|
+
broken,
|
|
208
|
+
notes: selection?.notes ?? [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The provenance facts for one rule or one decision record, in the shape the
|
|
214
|
+
* JSON documents carry. Text renders from the same facts; nothing re-derives.
|
|
215
|
+
*/
|
|
216
|
+
export async function rulesWhyFacts(root, what, { all = false } = {}) {
|
|
217
|
+
const { rules } = await load(root);
|
|
218
|
+
if (/^ADR-\d{3,}$/i.test(what)) {
|
|
219
|
+
const id = what.toUpperCase();
|
|
220
|
+
const { ok, decisions, errors } = await loadDecisionsFor(root, all);
|
|
221
|
+
if (!ok) {
|
|
222
|
+
rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
|
|
223
|
+
}
|
|
224
|
+
const record = decisions.find((x) => x.id === id);
|
|
225
|
+
// Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
|
|
226
|
+
// tree, which resolves to nothing in the repo the reader is standing in. The
|
|
227
|
+
// remedy must be a command they can run.
|
|
228
|
+
if (!record) rulesFailure(`no decision record ${id}`, `list the known decisions with: ${CLI} rules list`, EXIT.USAGE);
|
|
229
|
+
const replacement = record.supersededBy ? decisions.find((x) => x.id === record.supersededBy) : null;
|
|
230
|
+
return {
|
|
231
|
+
direction: 'decision',
|
|
232
|
+
id,
|
|
233
|
+
adr: record,
|
|
234
|
+
supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
|
|
235
|
+
inForce: replacement
|
|
236
|
+
? { id: replacement.id, status: replacement.status, summary: replacement.summary }
|
|
237
|
+
: { id: record.id, status: record.status, summary: record.summary },
|
|
238
|
+
rules: rules.filter((r) => citations(r.body).includes(id)).map((r) => r.id),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const rule = rules.find((r) => r.id === what);
|
|
242
|
+
if (!rule) {
|
|
243
|
+
const near = nearMatches(what, rules.map((r) => r.id));
|
|
244
|
+
rulesFailure(
|
|
245
|
+
`no rule with id "${what}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
246
|
+
`run: ${CLI} rules list`,
|
|
247
|
+
EXIT.USAGE,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const { ok, decisions, errors } = await loadDecisionsFor(root, all);
|
|
251
|
+
if (!ok) {
|
|
252
|
+
rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
|
|
253
|
+
}
|
|
254
|
+
const index = byId(decisions);
|
|
255
|
+
const cited = citations(rule.body);
|
|
256
|
+
const others = rules.filter((r) => r.id !== rule.id && citations(r.body).some((c) => cited.includes(c)));
|
|
257
|
+
return {
|
|
258
|
+
direction: 'rule',
|
|
259
|
+
id: rule.id,
|
|
260
|
+
rule: rule.id,
|
|
261
|
+
title: rule.title ?? null,
|
|
262
|
+
source: rule.source,
|
|
263
|
+
origin: rule.origin,
|
|
264
|
+
adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
|
|
265
|
+
uncited: cited.length === 0,
|
|
266
|
+
others: others.map((r) => r.id),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
176
270
|
// --- show ---------------------------------------------------------------------
|
|
177
271
|
|
|
178
272
|
export async function showCommand(args, flags) {
|
|
179
273
|
const id = args[0];
|
|
180
274
|
if (!id) return fail('rules show needs a rule id', EXIT.USAGE, `usage: ${CLI} rules show <id>`);
|
|
181
275
|
|
|
182
|
-
const { rules
|
|
276
|
+
const { rules } = await load(flags.root ?? null);
|
|
183
277
|
const rule = rules.find((r) => r.id === id);
|
|
184
|
-
if (!rule)
|
|
278
|
+
if (!rule) {
|
|
279
|
+
const near = nearMatches(id, rules.map((r) => r.id));
|
|
280
|
+
return fail(
|
|
281
|
+
`no rule with id "${id}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
282
|
+
EXIT.USAGE,
|
|
283
|
+
`run: ${CLI} rules list`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
185
286
|
|
|
186
287
|
const adrs = citations(rule.body);
|
|
187
288
|
if (isJson(flags)) {
|
|
@@ -218,47 +319,25 @@ export async function whyCommand(args, flags) {
|
|
|
218
319
|
const what = args[0];
|
|
219
320
|
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
321
|
|
|
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);
|
|
322
|
+
let facts;
|
|
323
|
+
try {
|
|
324
|
+
facts = await rulesWhyFacts(flags.root ?? null, what, { all: flags.all === true });
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
|
|
327
|
+
}
|
|
243
328
|
|
|
244
329
|
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
|
-
);
|
|
330
|
+
emitJson({ ok: true, command: 'rules why', ...facts });
|
|
256
331
|
return EXIT.OK;
|
|
257
332
|
}
|
|
258
333
|
|
|
259
|
-
|
|
334
|
+
return facts.direction === 'decision' ? adrDirection(facts, flags) : ruleDirection(facts, flags);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function ruleDirection(facts, flags) {
|
|
338
|
+
const lines = [`${facts.rule} - ${facts.title ?? '(no title)'}`, '', `source ${facts.source} (${facts.origin})`];
|
|
260
339
|
|
|
261
|
-
if (
|
|
340
|
+
if (facts.uncited) {
|
|
262
341
|
lines.push(
|
|
263
342
|
'',
|
|
264
343
|
'provenance NONE',
|
|
@@ -269,11 +348,10 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
|
|
|
269
348
|
return EXIT.OK;
|
|
270
349
|
}
|
|
271
350
|
|
|
272
|
-
lines.push('', `settled by ${
|
|
273
|
-
for (const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
lines.push(` ${adr} MISSING - the rule cites it, but this installation has no record of it`);
|
|
351
|
+
lines.push('', `settled by ${facts.adrs.length} decision${facts.adrs.length === 1 ? '' : 's'}`);
|
|
352
|
+
for (const d of facts.adrs) {
|
|
353
|
+
if (d.missing) {
|
|
354
|
+
lines.push(` ${d.id} MISSING - the rule cites it, but this installation has no record of it`);
|
|
277
355
|
continue;
|
|
278
356
|
}
|
|
279
357
|
lines.push(` ${d.id} ${d.title}`);
|
|
@@ -286,52 +364,23 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
|
|
|
286
364
|
|
|
287
365
|
// The reverse edge is what makes this useful for maintaining the set: which
|
|
288
366
|
// rules would a change to one decision touch?
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
lines.push('', `shares a decision with ${others.map((r) => r.id).join(', ')}`);
|
|
367
|
+
if (facts.others.length > 0) {
|
|
368
|
+
lines.push('', `shares a decision with ${facts.others.join(', ')}`);
|
|
292
369
|
}
|
|
293
370
|
|
|
294
371
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
295
372
|
return EXIT.OK;
|
|
296
373
|
}
|
|
297
374
|
|
|
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
|
-
}
|
|
375
|
+
function adrDirection(facts, flags) {
|
|
376
|
+
const d = facts.adr;
|
|
377
|
+
const restates = facts.rules;
|
|
329
378
|
|
|
330
379
|
const lines = [
|
|
331
380
|
`${d.id} - ${d.title}`,
|
|
332
381
|
'',
|
|
333
382
|
`status ${d.status}`,
|
|
334
|
-
`restates ${restates.length > 0 ? restates.
|
|
383
|
+
`restates ${restates.length > 0 ? restates.join(', ') : '(no rule cites it)'}`,
|
|
335
384
|
];
|
|
336
385
|
if (d.supersededBy) lines.push(`SUPERSEDED by ${d.supersededBy} - this record is retained as evidence, not as policy.`);
|
|
337
386
|
if (d.supersedes.length > 0) lines.push(`supersedes ${d.supersedes.join(', ')}`);
|
|
@@ -360,7 +409,7 @@ async function adrDirection(adr, rules, decisions, flags) {
|
|
|
360
409
|
// attached, and an agent that skims the ruling and not the banner acts on the
|
|
361
410
|
// reversal. This is the failure the decision index's own header names: a provenance
|
|
362
411
|
// feature that proves provenance but not currency.
|
|
363
|
-
const replacement =
|
|
412
|
+
const replacement = facts.replacement;
|
|
364
413
|
const effective = replacement ?? d;
|
|
365
414
|
|
|
366
415
|
if (replacement) {
|
|
@@ -413,13 +462,4 @@ async function adrDirection(adr, rules, decisions, flags) {
|
|
|
413
462
|
// A superseded record is not a failure; it is the supersession rule working.
|
|
414
463
|
// The caller asked a question and got a true answer.
|
|
415
464
|
return EXIT.OK;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function unknown(id, rules) {
|
|
419
|
-
const near = nearMatches(id, rules.map((r) => r.id));
|
|
420
|
-
return fail(
|
|
421
|
-
`no rule with id "${id}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
422
|
-
EXIT.USAGE,
|
|
423
|
-
`run: ${CLI} rules list`,
|
|
424
|
-
);
|
|
425
465
|
}
|
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
|
|