@sabaiway/agent-workflow-kit 4.3.0 → 4.5.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 +82 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +58 -7
- package/references/modes/migrate-adr-store.md +4 -2
- package/references/modes/recommendations.md +2 -0
- package/references/modes/status.md +1 -1
- package/references/modes/upgrade.md +2 -2
- package/references/modes/velocity.md +3 -2
- package/references/scripts/archive-decisions.mjs +14 -3
- package/references/scripts/archive-decisions.test.mjs +27 -0
- package/references/shared/command-shapes.md +25 -24
- package/tools/family-registry.mjs +78 -12
- package/tools/migrate-adr-store.mjs +177 -8
- package/tools/path-inventory.mjs +516 -0
- package/tools/recommendations.mjs +41 -2
- package/tools/renderers.mjs +8 -2
- package/tools/repo-search.mjs +217 -29
- package/tools/velocity-profile.mjs +6 -2
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// path-inventory.mjs — the promptless path-inventory lane (read-only).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. The «useless approves» corpus keeps recording the same authoring shape: several
|
|
5
|
+
// small read-only questions about paths — does it exist, how big is it, how many lines, what is in
|
|
6
|
+
// that directory, what does this small config say — batched into ONE composed shell with `echo`
|
|
7
|
+
// banners and a defensive `2>/dev/null`, because no single call answers them. The composition is
|
|
8
|
+
// what raises the prompt: a redirect takes the command out of the read-lane before it is even split,
|
|
9
|
+
// `echo` is outside the frozen read-only core, and the banner's quotes are forbidden per segment.
|
|
10
|
+
// The corpus has only ever responded to removing the REASON to compose a shell. This tool is that
|
|
11
|
+
// reason removed for the inventory half, the way repo-search.mjs is for the search half.
|
|
12
|
+
//
|
|
13
|
+
// lane 1 --path <p> repeatable, for a path with no shell-significant byte
|
|
14
|
+
// lane 2 --paths-file <p> the targets' bytes NEVER enter the command string
|
|
15
|
+
//
|
|
16
|
+
// CONTRACT
|
|
17
|
+
// A MISSING path is a RESULT (`exists:false`, exit 0), never a failure. That is the whole point:
|
|
18
|
+
// "does either of these exist" is a question whose interesting answer is "no", and a tool that
|
|
19
|
+
// errors on it sends the caller straight back to a composed shell. Only a CONTAINMENT refusal or a
|
|
20
|
+
// real I/O fault is an error.
|
|
21
|
+
// CONTAINMENT is decided on the REAL path of the nearest EXISTING ancestor, so a target that does
|
|
22
|
+
// not exist is still refused when it lives behind a symlink pointing out of the root — a lexical
|
|
23
|
+
// check passes exactly that case.
|
|
24
|
+
// Symlinks are reported BY TYPE and never followed; a dangling one is reported as a symlink that
|
|
25
|
+
// does not resolve. Binary and special files are reported by type and never decoded.
|
|
26
|
+
// Line count is `wc -l` compatible — newline CHARACTERS — so a final line without one is not
|
|
27
|
+
// counted. A tool that answers a different question than the command it replaces is a trap.
|
|
28
|
+
// Results are DETERMINISTIC: targets in input order, directory entries in code-unit order.
|
|
29
|
+
// Four outcomes, never collapsed: results (0), INCOMPLETE (3, naming the bound that fired),
|
|
30
|
+
// invalid input (2), I/O failure or containment refusal (1). No bound ever truncates silently.
|
|
31
|
+
// Pure reader — no writes, no subprocess, no network. Dependency-free, Node >= 22, no side effects
|
|
32
|
+
// on import (the isDirectRun idiom).
|
|
33
|
+
//
|
|
34
|
+
// THREAT MODEL, stated rather than inherited in silence. Containment is decided on the REAL path of
|
|
35
|
+
// the nearest existing ancestor, and every leaf is opened NO-FOLLOW; the directory listing never
|
|
36
|
+
// follows a symlink and never descends. What is NOT defended against is an adversary mutating the
|
|
37
|
+
// tree BETWEEN the containment check and the read: a directory swapped for a symlink in that window
|
|
38
|
+
// would be traversed, and closing it needs descriptor-relative traversal (`openat` semantics) which
|
|
39
|
+
// dependency-free Node does not expose. This is the SAME boundary `repo-search.mjs:33-40` states for
|
|
40
|
+
// the same reason — both tools inspect a workspace their own agent controls, and concurrent hostile
|
|
41
|
+
// mutation is out of scope. An unstated residual would be the defect; the residual itself is not.
|
|
42
|
+
//
|
|
43
|
+
// The paths-file FORMAT and the two failure classes are imported from repo-search.mjs rather than
|
|
44
|
+
// restated: one definition means the two file lanes cannot drift into classifying the same failure
|
|
45
|
+
// differently, which is the parity a copied helper would only promise.
|
|
46
|
+
|
|
47
|
+
import { openSync, fstatSync, readSync, closeSync, opendirSync, realpathSync, lstatSync, constants } from 'node:fs';
|
|
48
|
+
import { resolve, relative, isAbsolute, sep, dirname } from 'node:path';
|
|
49
|
+
import { pathToFileURL } from 'node:url';
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
UsageError,
|
|
53
|
+
IoError,
|
|
54
|
+
assertNameableTarget,
|
|
55
|
+
requiresDirectory,
|
|
56
|
+
decodeLaneFile,
|
|
57
|
+
parsePathsFile,
|
|
58
|
+
HARD_MAX_TARGETS,
|
|
59
|
+
HARD_MAX_PATHS_FILE_BYTES,
|
|
60
|
+
} from './repo-search.mjs';
|
|
61
|
+
|
|
62
|
+
export { HARD_MAX_TARGETS, HARD_MAX_PATHS_FILE_BYTES };
|
|
63
|
+
|
|
64
|
+
export const EXIT_OK = 0;
|
|
65
|
+
export const EXIT_ERROR = 1;
|
|
66
|
+
export const EXIT_USAGE = 2;
|
|
67
|
+
export const EXIT_INCOMPLETE = 3;
|
|
68
|
+
|
|
69
|
+
// One bound governs every byte this tool reads out of a file: the line count needs the bytes just as
|
|
70
|
+
// much as `--contents` does, so a single named ceiling keeps "why is `lines` null" answerable.
|
|
71
|
+
export const DEFAULT_MAX_CONTENT_BYTES = 1024 * 1024;
|
|
72
|
+
export const HARD_MAX_CONTENT_BYTES = 16 * 1024 * 1024;
|
|
73
|
+
export const DEFAULT_MAX_ENTRIES = 500;
|
|
74
|
+
export const HARD_MAX_ENTRIES = 20000;
|
|
75
|
+
// The AGGREGATE budget, and it is the load-bearing one. Per-file and per-directory ceilings bound a
|
|
76
|
+
// SINGLE target; with up to HARD_MAX_TARGETS of them the run is still unbounded, and every result
|
|
77
|
+
// accumulates in memory before anything is formatted. A cumulative ceiling is what makes the total
|
|
78
|
+
// cost a constant no number of targets can grow.
|
|
79
|
+
export const DEFAULT_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
80
|
+
export const HARD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
81
|
+
export const DEFAULT_MAX_TOTAL_ENTRIES = 20000;
|
|
82
|
+
export const HARD_MAX_TOTAL_ENTRIES = 200000;
|
|
83
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
84
|
+
const NEWLINE = 0x0a;
|
|
85
|
+
|
|
86
|
+
const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
87
|
+
const NONBLOCK = constants.O_NONBLOCK ?? 0;
|
|
88
|
+
const OPEN_FLAGS = constants.O_RDONLY | NOFOLLOW | NONBLOCK;
|
|
89
|
+
|
|
90
|
+
export const countLines = (buf) => {
|
|
91
|
+
let n = 0;
|
|
92
|
+
for (let i = 0; i < buf.length; i += 1) if (buf[i] === NEWLINE) n += 1;
|
|
93
|
+
return n;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Over the WHOLE bounded buffer, not a sniff window: the contract says a binary is never decoded, and
|
|
97
|
+
// a NUL past the window would otherwise be handed back as text in direct contradiction of it. The
|
|
98
|
+
// buffer is already in memory and already bounded, so scanning all of it costs nothing extra.
|
|
99
|
+
const isBinary = (buf) => buf.includes(0);
|
|
100
|
+
|
|
101
|
+
const parseCount = (raw, flag, ceiling) => {
|
|
102
|
+
if (!/^\d{1,15}$/u.test(raw ?? '')) throw new UsageError(`${flag} needs a plain non-negative integer, got: ${raw ?? '(missing)'}`);
|
|
103
|
+
const n = Number(raw);
|
|
104
|
+
if (!Number.isSafeInteger(n)) throw new UsageError(`${flag} is not a safe integer: ${raw}`);
|
|
105
|
+
if (n > ceiling) throw new UsageError(`${flag} exceeds the hard ceiling ${ceiling}: ${raw}`);
|
|
106
|
+
return n;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const parseArgs = (argv) => {
|
|
110
|
+
const opts = {
|
|
111
|
+
paths: [],
|
|
112
|
+
pathsFile: null,
|
|
113
|
+
contents: false,
|
|
114
|
+
maxContentBytes: DEFAULT_MAX_CONTENT_BYTES,
|
|
115
|
+
maxEntries: DEFAULT_MAX_ENTRIES,
|
|
116
|
+
maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES,
|
|
117
|
+
maxTotalEntries: DEFAULT_MAX_TOTAL_ENTRIES,
|
|
118
|
+
json: false,
|
|
119
|
+
};
|
|
120
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
121
|
+
const arg = argv[i];
|
|
122
|
+
const next = () => {
|
|
123
|
+
i += 1;
|
|
124
|
+
if (i >= argv.length) throw new UsageError(`${arg} requires a value`);
|
|
125
|
+
return argv[i];
|
|
126
|
+
};
|
|
127
|
+
if (arg === '--path') {
|
|
128
|
+
const value = next();
|
|
129
|
+
// An empty value passes a bare count check and then resolves to the ROOT — precisely the
|
|
130
|
+
// accidental whole-root walk the "name at least one target" rule exists to prevent.
|
|
131
|
+
if (value === '') throw new UsageError('--path needs a non-empty target');
|
|
132
|
+
opts.paths.push(value);
|
|
133
|
+
}
|
|
134
|
+
else if (arg === '--paths-file') opts.pathsFile = next();
|
|
135
|
+
else if (arg === '--contents') opts.contents = true;
|
|
136
|
+
else if (arg === '--max-content-bytes') opts.maxContentBytes = parseCount(next(), '--max-content-bytes', HARD_MAX_CONTENT_BYTES);
|
|
137
|
+
else if (arg === '--max-entries') opts.maxEntries = parseCount(next(), '--max-entries', HARD_MAX_ENTRIES);
|
|
138
|
+
else if (arg === '--max-total-bytes') opts.maxTotalBytes = parseCount(next(), '--max-total-bytes', HARD_MAX_TOTAL_BYTES);
|
|
139
|
+
else if (arg === '--max-total-entries') opts.maxTotalEntries = parseCount(next(), '--max-total-entries', HARD_MAX_TOTAL_ENTRIES);
|
|
140
|
+
else if (arg === '--json') opts.json = true;
|
|
141
|
+
else throw new UsageError(`unknown argument: ${arg} (see --help)`);
|
|
142
|
+
}
|
|
143
|
+
// No implicit target. A tool that walks the whole root when asked about nothing turns a typo into
|
|
144
|
+
// an unbounded read, and the caller's question was always about NAMED paths.
|
|
145
|
+
if (opts.paths.length === 0 && opts.pathsFile === null) {
|
|
146
|
+
throw new UsageError('name at least one target with --path or --paths-file');
|
|
147
|
+
}
|
|
148
|
+
return opts;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Containment on the REAL path of the nearest EXISTING ancestor. A target that does not exist has no
|
|
152
|
+
// real path of its own, and a lexical check on the parent is exactly what a symlinked ancestor
|
|
153
|
+
// defeats — so walk up until something resolves, and judge THAT.
|
|
154
|
+
export const resolveContained = (realRoot, target) => {
|
|
155
|
+
assertNameableTarget(target);
|
|
156
|
+
const lexical = resolve(realRoot, target);
|
|
157
|
+
// Start the probe at the PARENT, never at the target itself: `realpathSync` on the target would
|
|
158
|
+
// dereference a symlink leaf, and this tool promises to report a symlink BY TYPE and never follow
|
|
159
|
+
// it. Probing the leaf turned a symlink pointing outside the root into a refusal instead of a
|
|
160
|
+
// result — a contract violation the contract itself names. The leaf is still safe: it is `lstat`-ed
|
|
161
|
+
// (which never follows) and opened O_NOFOLLOW (which refuses a symlink outright).
|
|
162
|
+
let probe = lexical === realRoot ? lexical : dirname(lexical);
|
|
163
|
+
for (;;) {
|
|
164
|
+
let real;
|
|
165
|
+
try {
|
|
166
|
+
real = realpathSync(probe);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// ENOTDIR walks up for the same reason ENOENT does: a regular file used as an intermediate
|
|
169
|
+
// component means the rest of the path does not exist, and "does not exist" is an ANSWER here,
|
|
170
|
+
// not a fault. Refusing it would break the tool's central promise on an ordinary typo.
|
|
171
|
+
if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') {
|
|
172
|
+
throw new IoError(`cannot resolve ${target} (${err?.code ?? err?.message ?? err})`);
|
|
173
|
+
}
|
|
174
|
+
const parent = dirname(probe);
|
|
175
|
+
if (parent === probe) throw new IoError(`cannot resolve ${target}`);
|
|
176
|
+
probe = parent;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const rel = relative(realRoot, real);
|
|
180
|
+
if (rel !== '' && (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))) {
|
|
181
|
+
throw new IoError(`target resolves outside the root: ${target}`);
|
|
182
|
+
}
|
|
183
|
+
return lexical;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Exported so the `special` fallback is testable: a FIFO, socket or device cannot be created inside
|
|
188
|
+
// the sandbox this suite runs in (a unix socket `listen` is EPERM), and a test that quietly skips
|
|
189
|
+
// when it cannot build its fixture is a test that checks nothing. These are pure maps from a stats
|
|
190
|
+
// shape to a type name, so a stub exercises exactly the branch a device would.
|
|
191
|
+
export const typeOfStats = (st) => {
|
|
192
|
+
if (st.isSymbolicLink()) return 'symlink';
|
|
193
|
+
if (st.isDirectory()) return 'directory';
|
|
194
|
+
if (st.isFile()) return 'file';
|
|
195
|
+
return 'special';
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const typeOfDirent = (entry) => {
|
|
199
|
+
if (entry.isSymbolicLink()) return 'symlink';
|
|
200
|
+
if (entry.isDirectory()) return 'directory';
|
|
201
|
+
if (entry.isFile()) return 'file';
|
|
202
|
+
return 'special';
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Open → fstat the DESCRIPTOR → read bounded. O_NOFOLLOW refuses a symlinked leaf at open time and
|
|
206
|
+
// O_NONBLOCK means a special file that slipped in returns instead of hanging; fstat-ing the
|
|
207
|
+
// descriptor that was actually opened is what a swap between check and read cannot defeat.
|
|
208
|
+
// Returns a TAGGED outcome, never a bare null. An open that failed, a node that turned out not to be
|
|
209
|
+
// a regular file, and a read that stopped short are three different facts, and collapsing any of them
|
|
210
|
+
// into "here is your answer, lines are just null" is the silent failure this project forbids: the
|
|
211
|
+
// caller cannot tell "empty" from "unreadable" from "half-read".
|
|
212
|
+
const READ_OK = 'ok';
|
|
213
|
+
const READ_UNREADABLE = 'unreadable';
|
|
214
|
+
const READ_OVER_BOUND = 'over-bound';
|
|
215
|
+
const READ_SHORT = 'short';
|
|
216
|
+
|
|
217
|
+
// `io` is injectable so the SHORT-READ branch has a test. A read that stops early is a real race (the
|
|
218
|
+
// file shrinks between the fstat and the read) that no test can stage honestly on a real filesystem,
|
|
219
|
+
// and an untested defensive branch is indistinguishable from a wrong one.
|
|
220
|
+
export const readBounded = (abs, maxBytes, io = {}) => {
|
|
221
|
+
const open = io.open ?? openSync;
|
|
222
|
+
const fstat = io.fstat ?? fstatSync;
|
|
223
|
+
const read = io.read ?? readSync;
|
|
224
|
+
const close = io.close ?? closeSync;
|
|
225
|
+
let fd;
|
|
226
|
+
try {
|
|
227
|
+
fd = open(abs, OPEN_FLAGS);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return { kind: READ_UNREADABLE, detail: err?.code ?? String(err) };
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const st = fstat(fd);
|
|
233
|
+
if (!st.isFile()) return { kind: READ_UNREADABLE, detail: 'not a regular file' };
|
|
234
|
+
const size = Number(st.size);
|
|
235
|
+
if (size > maxBytes) return { kind: READ_OVER_BOUND, size };
|
|
236
|
+
const buf = Buffer.allocUnsafe(size);
|
|
237
|
+
let got = 0;
|
|
238
|
+
for (;;) {
|
|
239
|
+
if (got >= size) break;
|
|
240
|
+
const n = read(fd, buf, got, size - got, got);
|
|
241
|
+
if (n <= 0) break;
|
|
242
|
+
got += n;
|
|
243
|
+
}
|
|
244
|
+
if (got !== size) return { kind: READ_SHORT, got, want: size };
|
|
245
|
+
// The size travels WITH the buffer: the caller must report and budget the size the read was
|
|
246
|
+
// actually sized by, never an earlier stat of the path. A file that grew in between would
|
|
247
|
+
// otherwise be published at the wrong size, charged the wrong amount, and returned as complete.
|
|
248
|
+
return { kind: READ_OK, buf, size };
|
|
249
|
+
} finally {
|
|
250
|
+
close(fd);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const noteBound = (state, bound, detail) => {
|
|
255
|
+
state.incomplete = state.incomplete ?? { bound, detail };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Returns the entries AND the bound that cut them short, if any: a directory truncated by one
|
|
259
|
+
// ceiling while a later target hits a different one must still be able to say which one truncated IT.
|
|
260
|
+
const listDirectory = (abs, opts, state, rel) => {
|
|
261
|
+
const entries = [];
|
|
262
|
+
let bound = null;
|
|
263
|
+
const dir = opendirSync(abs);
|
|
264
|
+
try {
|
|
265
|
+
for (;;) {
|
|
266
|
+
const entry = dir.readSync();
|
|
267
|
+
if (entry === null) break;
|
|
268
|
+
if (entries.length >= opts.maxEntries) {
|
|
269
|
+
bound = '--max-entries';
|
|
270
|
+
noteBound(state, bound, `${rel} holds more than ${opts.maxEntries} entries`);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
if (state.entries >= opts.maxTotalEntries) {
|
|
274
|
+
bound = '--max-total-entries';
|
|
275
|
+
noteBound(state, bound, `the run reached ${opts.maxTotalEntries} listed entries at ${rel}`);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
state.entries += 1;
|
|
279
|
+
entries.push({ name: entry.name, type: typeOfDirent(entry) });
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
dir.closeSync();
|
|
283
|
+
}
|
|
284
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
285
|
+
return { entries, bound };
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const inspectTarget = (realRoot, rel, opts, state) => {
|
|
289
|
+
const abs = resolveContained(realRoot, rel);
|
|
290
|
+
let st;
|
|
291
|
+
try {
|
|
292
|
+
st = lstatSync(abs);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
// ENOTDIR is the same answer as ENOENT: a component along the way is a regular file, so the path
|
|
295
|
+
// is not there. Anything else is a genuine fault and stays one.
|
|
296
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return { path: rel, exists: false };
|
|
297
|
+
throw new IoError(`cannot stat ${rel} (${err?.code ?? err?.message ?? err})`);
|
|
298
|
+
}
|
|
299
|
+
const type = typeOfStats(st);
|
|
300
|
+
// A trailing separator or `.` asserts a directory; the OS answers ENOTDIR when it is not one, and
|
|
301
|
+
// ENOTDIR is "not there" everywhere else in this tool.
|
|
302
|
+
if (requiresDirectory(rel) && type !== 'directory') return { path: rel, exists: false };
|
|
303
|
+
if (type === 'symlink') {
|
|
304
|
+
// Only "the target is not there" means it does not resolve. A permission error, an I/O error or a
|
|
305
|
+
// symlink LOOP are real faults, and reporting them as `(dangling)` with a clean exit would hide a
|
|
306
|
+
// failure behind a normal-looking answer — the silent-failure class this tool refuses elsewhere.
|
|
307
|
+
let resolves = true;
|
|
308
|
+
try {
|
|
309
|
+
realpathSync(abs);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') {
|
|
312
|
+
throw new IoError(`cannot resolve the symlink ${rel} (${err?.code ?? err?.message ?? err})`);
|
|
313
|
+
}
|
|
314
|
+
resolves = false;
|
|
315
|
+
}
|
|
316
|
+
return { path: rel, exists: true, type, resolves };
|
|
317
|
+
}
|
|
318
|
+
if (type === 'directory') {
|
|
319
|
+
const listed = listDirectory(abs, opts, state, rel);
|
|
320
|
+
return {
|
|
321
|
+
path: rel,
|
|
322
|
+
exists: true,
|
|
323
|
+
type,
|
|
324
|
+
entries: listed.entries,
|
|
325
|
+
...(listed.bound === null ? {} : { withheld: listed.bound }),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (type !== 'file') return { path: rel, exists: true, type, bytes: Number(st.size) };
|
|
329
|
+
|
|
330
|
+
const size = Number(st.size);
|
|
331
|
+
const base = { path: rel, exists: true, type, bytes: size, readable: true };
|
|
332
|
+
// The bound that withheld THIS entry is recorded on the entry itself. `incomplete` keeps only the
|
|
333
|
+
// first event for the run, so a later target hitting a different ceiling would otherwise come back
|
|
334
|
+
// unread with no stated reason of its own.
|
|
335
|
+
const withheld = (bound, detail, observedBytes = size) => {
|
|
336
|
+
noteBound(state, bound, detail);
|
|
337
|
+
return { ...base, bytes: observedBytes, withheld: bound, lines: null, binary: null, ...(opts.contents ? { contents: null } : {}) };
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// The read is bounded by whichever ceiling binds FIRST — the per-file one or what is LEFT of the
|
|
341
|
+
// run's budget. Checking the aggregate only afterwards would let every target read a full
|
|
342
|
+
// `--max-content-bytes` before being rejected, so the aggregate ceiling would bound the accounting
|
|
343
|
+
// and not the work, which is the opposite of what it is for.
|
|
344
|
+
const remaining = Math.max(0, opts.maxTotalBytes - state.bytes);
|
|
345
|
+
const limit = Math.min(opts.maxContentBytes, remaining);
|
|
346
|
+
const read = readBounded(abs, limit, opts.io);
|
|
347
|
+
// An unreadable file and a truncated read are I/O FAILURES, not bounds. The exit-code contract
|
|
348
|
+
// reserves 3 for a ceiling that fired and 1 for an I/O failure, and a failed `opendir` already
|
|
349
|
+
// exits 1 — reporting these as "incomplete" put a real fault under a code that means "bounded".
|
|
350
|
+
if (read.kind === READ_UNREADABLE) throw new IoError(`cannot read ${rel} (${read.detail})`);
|
|
351
|
+
if (read.kind === READ_SHORT) throw new IoError(`${rel} yielded ${read.got} of ${read.want} byte(s) — the file changed under the read`);
|
|
352
|
+
if (read.kind === READ_OVER_BOUND) {
|
|
353
|
+
// Which ceiling actually bound it is decided by the descriptor's size, so a file rejected because
|
|
354
|
+
// the RUN had no budget left is never blamed on the per-file limit.
|
|
355
|
+
// `bytes` comes from the DESCRIPTOR here too: the entry must not publish an earlier `lstat` size
|
|
356
|
+
// while the reason beside it quotes a different one.
|
|
357
|
+
// Blame the ceiling that FORMED the limit. Comparing against the per-file bound alone would name
|
|
358
|
+
// it even when the run's remaining budget was the smaller of the two and did the actual cutting.
|
|
359
|
+
return limit === opts.maxContentBytes
|
|
360
|
+
? withheld('--max-content-bytes', `${rel} is ${read.size} byte(s), above ${opts.maxContentBytes}`, read.size)
|
|
361
|
+
: withheld('--max-total-bytes', `${rel} needs ${read.size} byte(s) and the run has ${remaining} left of ${opts.maxTotalBytes}`, read.size);
|
|
362
|
+
}
|
|
363
|
+
state.bytes += read.size;
|
|
364
|
+
const binary = isBinary(read.buf);
|
|
365
|
+
return {
|
|
366
|
+
...base,
|
|
367
|
+
bytes: read.size,
|
|
368
|
+
lines: binary ? null : countLines(read.buf),
|
|
369
|
+
binary,
|
|
370
|
+
...(opts.contents ? { contents: binary ? null : read.buf.toString('utf8') } : {}),
|
|
371
|
+
};
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export const inventory = ({
|
|
375
|
+
root,
|
|
376
|
+
paths,
|
|
377
|
+
contents = false,
|
|
378
|
+
maxContentBytes = DEFAULT_MAX_CONTENT_BYTES,
|
|
379
|
+
maxEntries = DEFAULT_MAX_ENTRIES,
|
|
380
|
+
maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES,
|
|
381
|
+
maxTotalEntries = DEFAULT_MAX_TOTAL_ENTRIES,
|
|
382
|
+
io = {},
|
|
383
|
+
}) => {
|
|
384
|
+
const state = { incomplete: null, bytes: 0, entries: 0 };
|
|
385
|
+
const opts = { contents, maxContentBytes, maxEntries, maxTotalBytes, maxTotalEntries, io };
|
|
386
|
+
const results = paths.map((rel) => inspectTarget(root, rel, opts, state));
|
|
387
|
+
return { results, incomplete: state.incomplete };
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Control and ANSI bytes reach the terminal through a NAME the tool did not choose. Escaping them is
|
|
391
|
+
// not cosmetic: a report about a hostile filename must not be a way to drive the reader's terminal.
|
|
392
|
+
export const escapeForDisplay = (text) =>
|
|
393
|
+
[...text].map((ch) => {
|
|
394
|
+
const code = ch.codePointAt(0);
|
|
395
|
+
if (code < 0x20 || code === 0x7f) return `\\x${code.toString(16).padStart(2, '0')}`;
|
|
396
|
+
return ch;
|
|
397
|
+
}).join('');
|
|
398
|
+
|
|
399
|
+
// The bound that withheld an entry is printed WITH that entry: a run-level `incomplete` line names
|
|
400
|
+
// only the first event, so a reader of the human shape would otherwise see `unread` with no reason.
|
|
401
|
+
const withheldSuffix = (entry) => (entry.withheld === undefined ? '' : ` [withheld: ${entry.withheld}]`);
|
|
402
|
+
|
|
403
|
+
const formatEntry = (entry) => {
|
|
404
|
+
const name = escapeForDisplay(entry.path);
|
|
405
|
+
if (!entry.exists) return `${name}: absent`;
|
|
406
|
+
if (entry.type === 'symlink') return `${name}: symlink${entry.resolves ? '' : ' (dangling)'}`;
|
|
407
|
+
if (entry.type === 'directory') {
|
|
408
|
+
const head = `${name}: directory, ${entry.entries.length} entr(ies)${withheldSuffix(entry)}`;
|
|
409
|
+
return [head, ...entry.entries.map((e) => ` ${escapeForDisplay(e.name)}${e.type === 'directory' ? '/' : ''}`)].join('\n');
|
|
410
|
+
}
|
|
411
|
+
if (entry.type !== 'file') return `${name}: ${entry.type}, ${entry.bytes} byte(s)`;
|
|
412
|
+
const lines = entry.lines === null ? (entry.binary ? 'binary' : 'unread') : `${entry.lines} line(s)`;
|
|
413
|
+
const head = `${name}: file, ${entry.bytes} byte(s), ${lines}${withheldSuffix(entry)}`;
|
|
414
|
+
if (entry.contents === undefined || entry.contents === null) return head;
|
|
415
|
+
return [head, ...entry.contents.split('\n').map((l) => ` ${escapeForDisplay(l)}`)].join('\n');
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const formatResult = (result) => {
|
|
419
|
+
const lines = result.results.map(formatEntry);
|
|
420
|
+
if (result.incomplete) lines.push(` ⚠ INCOMPLETE (${result.incomplete.bound}): ${escapeForDisplay(result.incomplete.detail)}`);
|
|
421
|
+
return lines.join('\n');
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const HELP = `path-inventory — read-only facts about named paths, without composing a shell.
|
|
425
|
+
|
|
426
|
+
Usage:
|
|
427
|
+
node path-inventory.mjs --path <p> [--path <p>]... [--contents] [--json]
|
|
428
|
+
node path-inventory.mjs --paths-file <path> [--contents] [--json]
|
|
429
|
+
|
|
430
|
+
Answers, for each named target: does it exist, what type is it, how many bytes, how many lines
|
|
431
|
+
(wc -l compatible), what a directory holds (one level, sorted), and with --contents what a small
|
|
432
|
+
text file says. A MISSING path is a normal result, not an error.
|
|
433
|
+
|
|
434
|
+
--paths-file is the lane for targets carrying shell-significant bytes (\`>\`, \`$(\`, a backtick):
|
|
435
|
+
one target per line, their bytes never enter the command string. Write it with your host's
|
|
436
|
+
file-write tool; this tool never writes.
|
|
437
|
+
|
|
438
|
+
Symlinks are reported by type and never followed. Binary and special files are reported by type and
|
|
439
|
+
never decoded.
|
|
440
|
+
|
|
441
|
+
Bounds — a bound that fires is NAMED on the run AND on the entry it withheld, never a silent
|
|
442
|
+
truncation:
|
|
443
|
+
--max-content-bytes <n> per file, for the line count and --contents
|
|
444
|
+
--max-entries <n> per directory listing
|
|
445
|
+
--max-total-bytes <n> the whole run, across every target
|
|
446
|
+
--max-total-entries <n> the whole run, across every listing
|
|
447
|
+
|
|
448
|
+
A target must NAME EXACTLY ONE filesystem object: no empty value, no NUL byte, and no ".."
|
|
449
|
+
component (resolve() collapses it before the filesystem sees it). A trailing "/" or "/." is NOT
|
|
450
|
+
rejected — it ASSERTS the target is a directory, exactly as it does to the OS: it holds for a real
|
|
451
|
+
directory, and anything else answers exists:false. Awkward-but-unambiguous names — edge whitespace,
|
|
452
|
+
backticks, control bytes — are supported, and --paths-file is the lane for the ones a command string
|
|
453
|
+
cannot carry.
|
|
454
|
+
|
|
455
|
+
Exit codes: 0 answered · 1 I/O failure or a containment refusal · 2 usage / invalid input ·
|
|
456
|
+
3 answered but INCOMPLETE (a bound fired; the bound is named). A path that does not exist is a
|
|
457
|
+
RESULT, not a failure — an unreadable one that DOES exist is an I/O failure.`;
|
|
458
|
+
|
|
459
|
+
const readLaneFile = (root, rel, maxBytes) => {
|
|
460
|
+
const abs = resolveContained(root, rel);
|
|
461
|
+
const read = readBounded(abs, maxBytes);
|
|
462
|
+
if (read.kind !== READ_OK) {
|
|
463
|
+
throw new IoError(`cannot read --paths-file ${rel} as a regular file within ${maxBytes} byte(s) (${read.kind})`);
|
|
464
|
+
}
|
|
465
|
+
return decodeLaneFile(read.buf, '--paths-file');
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
export const main = (argv, ctx = {}) => {
|
|
469
|
+
try {
|
|
470
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: EXIT_OK, stdout: HELP, stderr: '', result: null };
|
|
471
|
+
const root = realpathSync(resolve(ctx.cwd ?? process.cwd()));
|
|
472
|
+
const opts = parseArgs(argv);
|
|
473
|
+
const named = [...opts.paths];
|
|
474
|
+
if (opts.pathsFile !== null) {
|
|
475
|
+
named.push(...parsePathsFile(readLaneFile(root, opts.pathsFile, HARD_MAX_PATHS_FILE_BYTES)));
|
|
476
|
+
}
|
|
477
|
+
// Dedupe across the UNION, not only within each lane. Here it is more than tidiness: a duplicate
|
|
478
|
+
// target would be READ twice and charged twice against the run's aggregate byte budget.
|
|
479
|
+
const paths = [...new Set(named)];
|
|
480
|
+
// EVERY target is validated BEFORE any of them is inspected. Validating lazily means an invalid
|
|
481
|
+
// target late in the list is refused only if the run gets that far — so whether the invocation is
|
|
482
|
+
// accepted would depend on how much work happened first. A refusal must not depend on scheduling.
|
|
483
|
+
for (const target of paths) assertNameableTarget(target);
|
|
484
|
+
if (paths.length > HARD_MAX_TARGETS) {
|
|
485
|
+
throw new UsageError(`more than the ceiling of ${HARD_MAX_TARGETS} targets`);
|
|
486
|
+
}
|
|
487
|
+
const result = inventory({
|
|
488
|
+
root,
|
|
489
|
+
paths,
|
|
490
|
+
contents: opts.contents,
|
|
491
|
+
maxContentBytes: opts.maxContentBytes,
|
|
492
|
+
maxEntries: opts.maxEntries,
|
|
493
|
+
maxTotalBytes: opts.maxTotalBytes,
|
|
494
|
+
maxTotalEntries: opts.maxTotalEntries,
|
|
495
|
+
});
|
|
496
|
+
const stdout = opts.json ? JSON.stringify(result, null, 2) : formatResult(result);
|
|
497
|
+
return { code: result.incomplete ? EXIT_INCOMPLETE : EXIT_OK, stdout, stderr: '', result };
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// Errors carry the caller's own path, so they are escaped exactly like the success output. A
|
|
500
|
+
// refusal that hands a hostile filename's control bytes straight to the terminal would make the
|
|
501
|
+
// safe path the only safe one, which is the wrong half to protect.
|
|
502
|
+
const say = (message) => `path-inventory: ${escapeForDisplay(String(message))}`;
|
|
503
|
+
if (err instanceof UsageError) return { code: EXIT_USAGE, stdout: '', stderr: say(err.message), result: null };
|
|
504
|
+
if (err instanceof IoError) return { code: EXIT_ERROR, stdout: '', stderr: say(err.message), result: null };
|
|
505
|
+
return { code: EXIT_ERROR, stdout: '', stderr: say(err?.message ?? err), result: null };
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const emitResult = (r) => {
|
|
510
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
511
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
512
|
+
process.exitCode = r.code;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
516
|
+
if (isDirectRun) emitResult(main(process.argv.slice(2)));
|
|
@@ -51,7 +51,7 @@ import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-confi
|
|
|
51
51
|
import { deriveDoctorPlan } from './autonomy-doctor.mjs';
|
|
52
52
|
import { detectBackends, findOnPath } from './detect-backends.mjs';
|
|
53
53
|
import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
|
|
54
|
-
import { surveyFamily, surveyGateHook } from './family-registry.mjs';
|
|
54
|
+
import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
|
|
55
55
|
import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
|
|
56
56
|
import { shellQuoteArg } from './review-state.mjs';
|
|
57
57
|
import { isFinalCapableDeclaration } from './run-gates.mjs';
|
|
@@ -108,6 +108,7 @@ export const SEVERITIES = Object.freeze({
|
|
|
108
108
|
'state-block': SEVERITY_OPTIONAL,
|
|
109
109
|
agents: SEVERITY_OPTIONAL,
|
|
110
110
|
'family-freshness': SEVERITY_ATTENTION,
|
|
111
|
+
'adr-store-migration': SEVERITY_ATTENTION,
|
|
111
112
|
'sandbox-masks': SEVERITY_OPTIONAL,
|
|
112
113
|
'sandbox-lane': SEVERITY_OPTIONAL,
|
|
113
114
|
'worktrees-dir': SEVERITY_OPTIONAL,
|
|
@@ -165,6 +166,7 @@ export const WHATS = Object.freeze({
|
|
|
165
166
|
'state-block': 'nothing checks the closing state block — a turn that ends on «nothing needed from you», or on a promise it never started, passes unseen',
|
|
166
167
|
agents: '{n} read-only subagent(s) not placed (Claude Code) — no shell-free vehicle for that work; the apply PREVIEWS first',
|
|
167
168
|
'family-freshness': '{parts}',
|
|
169
|
+
'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
|
|
168
170
|
'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
|
|
169
171
|
'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
|
|
170
172
|
'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
|
|
@@ -218,6 +220,7 @@ export const BENEFITS = Object.freeze({
|
|
|
218
220
|
'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
|
|
219
221
|
agents: 'cost and quiet — mechanical work runs on a cheap model, and no vehicle has a shell, so a read-only fan-out cannot flood you with prompts',
|
|
220
222
|
'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
|
|
223
|
+
'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
|
|
221
224
|
'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
|
|
222
225
|
'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
|
|
223
226
|
'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
|
|
@@ -254,6 +257,7 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
|
|
|
254
257
|
{ id: 'sandbox-masks', mode: 'sandbox-masks', advisorKey: 'sandbox-masks' },
|
|
255
258
|
{ id: 'worktrees-dir', mode: 'worktrees', advisorKey: 'worktrees-dir' },
|
|
256
259
|
{ id: 'family-freshness', mode: 'upgrade', advisorKey: 'family-freshness' },
|
|
260
|
+
{ id: 'adr-store-migration', mode: 'migrate-adr-store', advisorKey: 'adr-store-migration' },
|
|
257
261
|
{ id: 'review-recipe', mode: 'set-recipe', advisorKey: 'review-recipe' },
|
|
258
262
|
// The execute slot is a DISTINCT opt-in from the review slot, and the same probe reports both —
|
|
259
263
|
// which is why the review-recipe benefit is worded for either slot rather than for review alone.
|
|
@@ -788,7 +792,7 @@ const readReadLaneToggle = (root, deps) => {
|
|
|
788
792
|
// D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
|
|
789
793
|
// at the consent moment; the static contract test asserts EXACT bidirectional coverage
|
|
790
794
|
// (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
|
|
791
|
-
export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir']);
|
|
795
|
+
export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration']);
|
|
792
796
|
|
|
793
797
|
const probeSandboxLane = ({ root, deps, add, skip }) => {
|
|
794
798
|
try {
|
|
@@ -965,6 +969,40 @@ const probeWorktreesDir = ({ root, deps, add, skip }) => {
|
|
|
965
969
|
}
|
|
966
970
|
};
|
|
967
971
|
|
|
972
|
+
// The ADR-store crossing. Until now this mode declared it had NO advisor capability, on the argument
|
|
973
|
+
// that status and upgrade already report the old layout — but they only reported the MONOLITH shape,
|
|
974
|
+
// so a project whose deployed rotator merely predates the store was told nothing by anything.
|
|
975
|
+
//
|
|
976
|
+
// Honest scope: the advisor is the deterministic section every `upgrade` run ends with, so this is
|
|
977
|
+
// NOT a new door for someone who never runs status or upgrade — it MECHANIZES the upgrade door.
|
|
978
|
+
//
|
|
979
|
+
// It reads the STRICT layout survey deliberately: the lenient one turns every fs failure into
|
|
980
|
+
// "no ADR layout here", which would print «flow optimal» over a layout the probe could not read. A
|
|
981
|
+
// failure must become a STATED SKIP, never an absence.
|
|
982
|
+
// Each shape states a fact about THIS tree that holds whether or not a store directory exists —
|
|
983
|
+
// `old-unrotated` also covers a tree whose store is already there but whose rotation script is not,
|
|
984
|
+
// and saying "the store is not in place" there would be false.
|
|
985
|
+
const ADR_LAYOUT_SHAPES = Object.freeze({
|
|
986
|
+
old: 'a legacy archive file is still on disk and must be exploded into the per-file store',
|
|
987
|
+
'old-unrotated': 'the deployed rotation script predates the store and keeps writing the retired layout',
|
|
988
|
+
});
|
|
989
|
+
export const probeAdrStore = ({ root, deps, add, skip }) => {
|
|
990
|
+
try {
|
|
991
|
+
const shape = ADR_LAYOUT_SHAPES[surveyAdrLayoutStrict(root, deps)];
|
|
992
|
+
if (!shape) return; // migrated, or no ADR substrate at all — nothing to offer
|
|
993
|
+
// HAND-APPLY, not the standard lane: the consent flow executes the apply slot against the
|
|
994
|
+
// confirmation given BEFORE the preview, and this crossing requires informed consent AFTER its
|
|
995
|
+
// dry-run. A runnable one-liner here would auto-run a tree-mutating migration on stale consent.
|
|
996
|
+
add(
|
|
997
|
+
'adr-store-migration',
|
|
998
|
+
fillTemplate(WHATS['adr-store-migration'], { shape }),
|
|
999
|
+
`HAND-APPLY: node ${q(toolPath('migrate-adr-store.mjs'))} --dry-run --cwd ${q(root)} — then re-run with --apply ONLY after showing the plan and getting fresh consent`,
|
|
1000
|
+
);
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
skip('adr-store-migration', err);
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
968
1006
|
// ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
|
|
969
1007
|
const PROBES = Object.freeze([
|
|
970
1008
|
probeVelocityItems,
|
|
@@ -977,6 +1015,7 @@ const PROBES = Object.freeze([
|
|
|
977
1015
|
probeStateBlockHook,
|
|
978
1016
|
probeCheapAgents,
|
|
979
1017
|
probeFamilyFreshness,
|
|
1018
|
+
probeAdrStore,
|
|
980
1019
|
probeMasksItem,
|
|
981
1020
|
probeSandboxLane,
|
|
982
1021
|
probeWorktreesDir,
|
package/tools/renderers.mjs
CHANGED
|
@@ -15,6 +15,10 @@ const READINESS_COL = 14;
|
|
|
15
15
|
const STAMP_COL = 26;
|
|
16
16
|
const SETTINGS_COL = 14;
|
|
17
17
|
|
|
18
|
+
// The ADR-layout tokens that carry an action for the user. Kept as a list, not a chain of equality
|
|
19
|
+
// checks, so a future token joins the render by joining this line.
|
|
20
|
+
const ACTIONABLE_ADR_LAYOUTS = Object.freeze(['old', 'old-unrotated']);
|
|
21
|
+
|
|
18
22
|
const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
|
|
19
23
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
20
24
|
export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
|
|
@@ -86,8 +90,10 @@ const renderProject = (vm, { color }) => {
|
|
|
86
90
|
}
|
|
87
91
|
for (const s of p.deployStamps) lines.push(` ${pad(s.display, STAMP_COL)}${s.version ?? '—'}`);
|
|
88
92
|
lines.push(` ${pad('docs/ai present', STAMP_COL)}${p.docsAi ? 'yes' : 'no'}`);
|
|
89
|
-
// Only
|
|
90
|
-
|
|
93
|
+
// Only an ACTIONABLE layout renders a line — a migrated/none store needs no note (AD-051). Both
|
|
94
|
+
// actionable tokens render the SAME line: 'old' (a monolith on disk) and 'old-unrotated' (an
|
|
95
|
+
// old-scheme rotator that never rotated) differ only in the discriminator, never in the remedy.
|
|
96
|
+
if (ACTIONABLE_ADR_LAYOUTS.includes(p.adrLayout)) {
|
|
91
97
|
lines.push(` ${pad('ADR store', STAMP_COL)}old layout — run /agent-workflow-kit migrate-adr-store`);
|
|
92
98
|
}
|
|
93
99
|
if (p.visibility) {
|