mandrel 2.14.0 → 2.16.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/.agents/audit-checklists/navigability.md +1 -1
- package/.agents/docs/workflows.md +4 -4
- package/.agents/scripts/acceptance-eval.js +18 -1
- package/.agents/scripts/agents-bootstrap-github.js +22 -1
- package/.agents/scripts/apply-quality-bootstrap.js +6 -0
- package/.agents/scripts/audit-labels-bootstrap.js +15 -1
- package/.agents/scripts/audit-to-stories.js +26 -1
- package/.agents/scripts/boot-sweep.js +4 -1
- package/.agents/scripts/bootstrap.js +1 -0
- package/.agents/scripts/check-arch-cycles.js +20 -0
- package/.agents/scripts/check-baselines.js +8 -2
- package/.agents/scripts/check-context-budget.js +40 -5
- package/.agents/scripts/check-dead-exports.js +21 -0
- package/.agents/scripts/check-doc-links.js +12 -1
- package/.agents/scripts/check-lifecycle-doc-drift.js +9 -0
- package/.agents/scripts/check-workflow-citations.js +332 -0
- package/.agents/scripts/deliver-light.js +32 -3
- package/.agents/scripts/deliver-recover.js +4 -1
- package/.agents/scripts/diagnose-friction.js +17 -1
- package/.agents/scripts/diagnose.js +20 -14
- package/.agents/scripts/drain-pending-cleanup.js +20 -1
- package/.agents/scripts/evidence-gate.js +20 -1
- package/.agents/scripts/generate-config-docs.js +14 -1
- package/.agents/scripts/generate-lifecycle-docs.js +14 -1
- package/.agents/scripts/generate-workflows-doc.js +14 -1
- package/.agents/scripts/git-cleanup.js +32 -1
- package/.agents/scripts/lib/cli-usage.js +174 -0
- package/.agents/scripts/lib/cli-utils.js +12 -0
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/doc-tiers.js +53 -10
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +205 -20
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +24 -15
- package/.agents/scripts/lib/workflow-closure.js +431 -0
- package/.agents/scripts/mandrel-update-preflight.js +9 -0
- package/.agents/scripts/nav-registry-diff.js +13 -0
- package/.agents/scripts/plan-context.js +21 -2
- package/.agents/scripts/plan-critics.js +10 -0
- package/.agents/scripts/plan-persist.js +33 -1
- package/.agents/scripts/plan-run-epilogue.js +12 -1
- package/.agents/scripts/quality-preview.js +17 -1
- package/.agents/scripts/resolve-doc-tiers.js +13 -0
- package/.agents/scripts/resolve-stories.js +1 -0
- package/.agents/scripts/resync-status-column.js +4 -1
- package/.agents/scripts/signals-view.js +11 -0
- package/.agents/scripts/single-story-close.js +24 -0
- package/.agents/scripts/single-story-confirm-merge.js +16 -0
- package/.agents/scripts/single-story-init.js +21 -1
- package/.agents/scripts/stories-wave-tick.js +1 -0
- package/.agents/scripts/sync-agentrc.js +16 -4
- package/.agents/scripts/update-ticket-state.js +23 -2
- package/.agents/workflows/audit-navigability.md +2 -2
- package/.agents/workflows/audit-to-stories.md +1 -1
- package/.agents/workflows/deliver.md +80 -81
- package/.agents/workflows/git-cleanup.md +9 -14
- package/.agents/workflows/helpers/acceptance-self-eval.md +14 -13
- package/.agents/workflows/helpers/audit-lens-core.md +2 -2
- package/.agents/workflows/helpers/code-review.md +11 -11
- package/.agents/workflows/helpers/deliver-digest.md +7 -8
- package/.agents/workflows/helpers/deliver-light.md +222 -0
- package/.agents/workflows/helpers/deliver-reference.md +46 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +55 -63
- package/.agents/workflows/helpers/deliver-story.md +22 -22
- package/.agents/workflows/helpers/mandrel-sync-config.md +2 -2
- package/.agents/workflows/helpers/parallel-tooling.md +1 -2
- package/.agents/workflows/helpers/plan-reference.md +96 -17
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
- package/.agents/workflows/mandrel-update.md +6 -6
- package/.agents/workflows/plan.md +90 -85
- package/.agents/workflows/prototype.md +104 -0
- package/docs/CHANGELOG.md +32 -0
- package/package.json +2 -1
- package/.agents/workflows/deliver-light.md +0 -148
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow read-tier closure resolver (Story #4752).
|
|
3
|
+
*
|
|
4
|
+
* `.agents/workflows/**` is the largest body of instruction Mandrel ships, and
|
|
5
|
+
* until this module it sat in none of the five doc read-tiers `doc-tiers.js`
|
|
6
|
+
* resolves — measured only by a per-file spine ceiling that is satisfied by
|
|
7
|
+
* moving prose into a linked helper. Workflows are a **graph**, not a flat set,
|
|
8
|
+
* so this module resolves each entry point's transitive markdown-link closure
|
|
9
|
+
* into two numbers:
|
|
10
|
+
*
|
|
11
|
+
* - **mandatory closure** — the entry point plus the transitive closure of
|
|
12
|
+
* its `mandatoryReads:` frontmatter edges. This is what a session is
|
|
13
|
+
* *forced* to read, and it is what `check-context-budget.js` ratchets.
|
|
14
|
+
* - **reachable closure** — the entry point plus every workflow markdown file
|
|
15
|
+
* transitively linked from it. Recorded as a drift signal, never gated.
|
|
16
|
+
*
|
|
17
|
+
* **The marker is source-side and per-edge.** A workflow declares the reads it
|
|
18
|
+
* requires in its own frontmatter (`mandatoryReads: [path, …]`, flow or block
|
|
19
|
+
* style, resolved relative to the declaring file). Tier is not intrinsic to a
|
|
20
|
+
* helper — the same file is mandatory from one workflow and on-demand from
|
|
21
|
+
* another — so it cannot live in the target. The key is optional: an absent
|
|
22
|
+
* key means zero mandatory edges and is never an error. Every reachable link
|
|
23
|
+
* not named in `mandatoryReads` is classified on-demand.
|
|
24
|
+
*
|
|
25
|
+
* **Entry points** are the workflows a session can be invoked on: every
|
|
26
|
+
* top-level `.agents/workflows/*.md`, plus any `helpers/*.md` whose H1 declares
|
|
27
|
+
* a slash command named after the file itself (`helpers/deliver-story.md` →
|
|
28
|
+
* `# /deliver-story …`) — a command-shaped helper is invoked directly, so it
|
|
29
|
+
* owns a closure of its own. Plain helpers and appendices are reachable, never
|
|
30
|
+
* entry points; counting every file as an entry point would collapse the
|
|
31
|
+
* mandatory/on-demand distinction into "all workflow bytes".
|
|
32
|
+
*
|
|
33
|
+
* **Failure modes are loud** — a ratchet that silently shrinks its own closure
|
|
34
|
+
* is worse than none:
|
|
35
|
+
* - a `mandatoryReads` entry that does not resolve to a workflow markdown
|
|
36
|
+
* file throws, naming the declaring workflow and the offending path;
|
|
37
|
+
* - a cycle among `mandatoryReads` edges throws, naming the cycle.
|
|
38
|
+
* The **reachable** walk is deliberately cycle-*tolerant* rather than fatal:
|
|
39
|
+
* bidirectional prose cross-references are normal and correct authoring (a
|
|
40
|
+
* spine points at its digest, the digest points back at the spine), so that
|
|
41
|
+
* walk terminates via a visited set and counts each file exactly once — it
|
|
42
|
+
* neither loops nor truncates. Only the gated mandatory graph, where a loop is
|
|
43
|
+
* a genuine authoring error, fails closed.
|
|
44
|
+
*
|
|
45
|
+
* The walk is confined to `.agents/workflows/**`: links out to
|
|
46
|
+
* `.agents/rules/**` or `.agents/skills/**` are neither followed nor recorded,
|
|
47
|
+
* because those are already tiered as flat sets by `doc-tiers.js` and
|
|
48
|
+
* following them would double-count them.
|
|
49
|
+
*
|
|
50
|
+
* Security (security-baseline § Data Leakage & Logging): every value returned
|
|
51
|
+
* or thrown is a repo-relative path or a byte count — never file contents.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import nodeFs from 'node:fs';
|
|
55
|
+
import path from 'node:path';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Repo-relative root of the workflow tree. The closure never escapes it.
|
|
59
|
+
* @type {string}
|
|
60
|
+
*/
|
|
61
|
+
const WORKFLOWS_ROOT = '.agents/workflows';
|
|
62
|
+
|
|
63
|
+
/** Path-segment count of a top-level workflow (`.agents/workflows/x.md`). */
|
|
64
|
+
const TOP_LEVEL_DEPTH = 3;
|
|
65
|
+
|
|
66
|
+
// All RegExp instances are built via the constructor (rather than literal
|
|
67
|
+
// `/.../`) so the maintainability engine's AST walker (typhonjs-escomplex) can
|
|
68
|
+
// score this file — it crashes on `RegExpLiteral` nodes. Same workaround as
|
|
69
|
+
// lib/audit-suite/frontmatter.js.
|
|
70
|
+
// biome-ignore-start lint/complexity/useRegexLiterals: typhonjs-escomplex MI workaround
|
|
71
|
+
const FRONTMATTER_RE = new RegExp(String.raw`^---\r?\n([\s\S]*?)\r?\n---`);
|
|
72
|
+
const NEWLINE_RE = new RegExp(String.raw`\r?\n`);
|
|
73
|
+
const MANDATORY_KEY_RE = new RegExp(String.raw`^mandatoryReads\s*:(.*)$`);
|
|
74
|
+
const BLOCK_ITEM_RE = new RegExp(String.raw`^\s*-\s*(.+)$`);
|
|
75
|
+
const MD_LINK_RE = new RegExp(String.raw`\]\(\s*([^)\s]+)`, 'g');
|
|
76
|
+
const COMMAND_H1_RE = new RegExp(String.raw`^#\s+/([A-Za-z0-9._-]+)`, 'm');
|
|
77
|
+
// biome-ignore-end lint/complexity/useRegexLiterals: typhonjs-escomplex MI workaround
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Default fs surface — the same injectable subset `doc-tiers.js` uses.
|
|
81
|
+
* @typedef {{
|
|
82
|
+
* readdirSync: (p: string, o?: object) => any[],
|
|
83
|
+
* readFileSync: (p: string, enc: string) => string,
|
|
84
|
+
* statSync: (p: string) => { size: number },
|
|
85
|
+
* }} FsLike
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A loaded workflow document.
|
|
90
|
+
* @typedef {{ rel: string, bytes: number, source: string }} WorkflowDoc
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Strip surrounding quotes and whitespace from a scalar YAML value.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} value
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
function unquote(value) {
|
|
100
|
+
const t = String(value ?? '').trim();
|
|
101
|
+
const quoted =
|
|
102
|
+
t.length >= 2 &&
|
|
103
|
+
((t.startsWith('"') && t.endsWith('"')) ||
|
|
104
|
+
(t.startsWith("'") && t.endsWith("'")));
|
|
105
|
+
return quoted ? t.slice(1, -1).trim() : t;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Convert a path to posix separators.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} p
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function toPosix(p) {
|
|
115
|
+
return p.split(path.sep).join('/');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Return the raw frontmatter block of a markdown source (`''` when absent).
|
|
120
|
+
*
|
|
121
|
+
* @param {string} source
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
function frontmatterBlock(source) {
|
|
125
|
+
const m = FRONTMATTER_RE.exec(String(source ?? ''));
|
|
126
|
+
return m ? m[1] : '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Collect a YAML block-sequence (`- item`) starting at `start`. Blank lines are
|
|
131
|
+
* skipped; the first non-item, non-blank line ends the sequence.
|
|
132
|
+
*
|
|
133
|
+
* @param {string[]} lines
|
|
134
|
+
* @param {number} start
|
|
135
|
+
* @returns {string[]}
|
|
136
|
+
*/
|
|
137
|
+
function blockSequence(lines, start) {
|
|
138
|
+
const out = [];
|
|
139
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
140
|
+
const item = BLOCK_ITEM_RE.exec(lines[i]);
|
|
141
|
+
if (item) {
|
|
142
|
+
const value = unquote(item[1]);
|
|
143
|
+
if (value) out.push(value);
|
|
144
|
+
} else if (lines[i].trim() !== '') {
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Split a YAML flow sequence (`[a.md, b.md]`) into its scalar items.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} inline
|
|
155
|
+
* @returns {string[]}
|
|
156
|
+
*/
|
|
157
|
+
function flowSequence(inline) {
|
|
158
|
+
const body = inline.replace('[', '').replace(']', '');
|
|
159
|
+
return body
|
|
160
|
+
.split(',')
|
|
161
|
+
.map(unquote)
|
|
162
|
+
.filter((v) => v.length > 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse the optional `mandatoryReads:` frontmatter list from a workflow source.
|
|
167
|
+
* Supports the flow (`[a.md, b.md]`), block (`- a.md` lines), and single-scalar
|
|
168
|
+
* forms. An absent key resolves to `[]` — zero mandatory edges, never an error.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} source
|
|
171
|
+
* @returns {string[]} raw specifiers, relative to the declaring file
|
|
172
|
+
*/
|
|
173
|
+
function parseMandatoryReads(source) {
|
|
174
|
+
const lines = frontmatterBlock(source).split(NEWLINE_RE);
|
|
175
|
+
const idx = lines.findIndex((line) => MANDATORY_KEY_RE.test(line));
|
|
176
|
+
if (idx < 0) return [];
|
|
177
|
+
const inline = unquote(MANDATORY_KEY_RE.exec(lines[idx])[1]);
|
|
178
|
+
if (inline.startsWith('[')) return flowSequence(inline);
|
|
179
|
+
if (inline.length > 0) return [inline];
|
|
180
|
+
return blockSequence(lines, idx + 1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Harvest every markdown link target in a source, anchors stripped.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} source
|
|
187
|
+
* @returns {string[]}
|
|
188
|
+
*/
|
|
189
|
+
function parseLinkTargets(source) {
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const m of String(source ?? '').matchAll(MD_LINK_RE)) {
|
|
192
|
+
const target = m[1].split('#')[0].trim();
|
|
193
|
+
if (target.length > 0) out.push(target);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve a link/`mandatoryReads` specifier declared in `fromRel` to a known
|
|
200
|
+
* workflow doc, or `null` when it is external, non-markdown, or outside the
|
|
201
|
+
* workflow tree.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} fromRel repo-relative posix path of the declaring file
|
|
204
|
+
* @param {string} spec
|
|
205
|
+
* @param {Map<string, WorkflowDoc>} docs
|
|
206
|
+
* @returns {string | null}
|
|
207
|
+
*/
|
|
208
|
+
function resolveSpec(fromRel, spec, docs) {
|
|
209
|
+
if (!spec.endsWith('.md')) return null;
|
|
210
|
+
if (spec.includes('://') || spec.startsWith('mailto:')) return null;
|
|
211
|
+
const rel = path.posix.join(path.posix.dirname(fromRel), spec);
|
|
212
|
+
return docs.has(rel) ? rel : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Resolve a workflow's declared mandatory edges. Throws when an entry does not
|
|
217
|
+
* resolve to a workflow markdown file — a mandatory read pointing at nothing is
|
|
218
|
+
* a silent hole in the ratchet, so it fails loudly, naming both the declaring
|
|
219
|
+
* workflow and the offending path.
|
|
220
|
+
*
|
|
221
|
+
* @param {WorkflowDoc} doc
|
|
222
|
+
* @param {Map<string, WorkflowDoc>} docs
|
|
223
|
+
* @returns {string[]}
|
|
224
|
+
*/
|
|
225
|
+
function mandatoryEdges(doc, docs) {
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const spec of parseMandatoryReads(doc.source)) {
|
|
228
|
+
const rel = resolveSpec(doc.rel, spec, docs);
|
|
229
|
+
if (!rel) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`[workflow-closure] ${doc.rel} declares mandatoryReads "${spec}", which does not resolve to a markdown file under ${WORKFLOWS_ROOT}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
out.push(rel);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Depth-first walk of the mandatory-edge graph. Cycle-fatal: a `mandatoryReads`
|
|
241
|
+
* loop is an authoring error, so it throws naming the cycle rather than looping
|
|
242
|
+
* or silently truncating the closure.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} rel
|
|
245
|
+
* @param {Map<string, WorkflowDoc>} docs
|
|
246
|
+
* @param {string[]} stack in-progress DFS path
|
|
247
|
+
* @param {Set<string>} seen finished nodes
|
|
248
|
+
* @returns {Set<string>} `seen`
|
|
249
|
+
*/
|
|
250
|
+
function walkMandatory(rel, docs, stack, seen) {
|
|
251
|
+
if (stack.includes(rel)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`[workflow-closure] mandatoryReads cycle: ${[...stack, rel].join(' -> ')}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (seen.has(rel)) return seen;
|
|
257
|
+
seen.add(rel);
|
|
258
|
+
stack.push(rel);
|
|
259
|
+
for (const next of mandatoryEdges(docs.get(rel), docs)) {
|
|
260
|
+
walkMandatory(next, docs, stack, seen);
|
|
261
|
+
}
|
|
262
|
+
stack.pop();
|
|
263
|
+
return seen;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Breadth-first walk of the markdown-link graph. Cycle-tolerant by design:
|
|
268
|
+
* bidirectional cross-references between a spine and its helper are correct
|
|
269
|
+
* authoring, so the visited set makes the walk terminate with each file counted
|
|
270
|
+
* exactly once.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} rel
|
|
273
|
+
* @param {Map<string, WorkflowDoc>} docs
|
|
274
|
+
* @returns {Set<string>}
|
|
275
|
+
*/
|
|
276
|
+
function walkReachable(rel, docs) {
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
const queue = [rel];
|
|
279
|
+
while (queue.length > 0) {
|
|
280
|
+
const current = queue.shift();
|
|
281
|
+
if (seen.has(current)) continue;
|
|
282
|
+
seen.add(current);
|
|
283
|
+
for (const spec of parseLinkTargets(docs.get(current).source)) {
|
|
284
|
+
const next = resolveSpec(current, spec, docs);
|
|
285
|
+
if (next && !seen.has(next)) queue.push(next);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return seen;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Recursively collect repo-relative posix paths of every `.md` file under an
|
|
293
|
+
* absolute directory. An unreadable directory yields nothing (silent skip).
|
|
294
|
+
*
|
|
295
|
+
* @param {FsLike} fs
|
|
296
|
+
* @param {string} dirAbs
|
|
297
|
+
* @param {string} root
|
|
298
|
+
* @param {string[]} out
|
|
299
|
+
* @returns {string[]} `out`
|
|
300
|
+
*/
|
|
301
|
+
function listMarkdown(fs, dirAbs, root, out) {
|
|
302
|
+
let dirents;
|
|
303
|
+
try {
|
|
304
|
+
dirents = fs.readdirSync(dirAbs, { withFileTypes: true });
|
|
305
|
+
} catch {
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
for (const dirent of dirents) {
|
|
309
|
+
const abs = path.join(dirAbs, dirent.name);
|
|
310
|
+
if (dirent.isDirectory()) listMarkdown(fs, abs, root, out);
|
|
311
|
+
else if (dirent.name.endsWith('.md'))
|
|
312
|
+
out.push(toPosix(path.relative(root, abs)));
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Load every workflow markdown file into a `rel -> { rel, bytes, source }` map.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} root absolute repo root
|
|
321
|
+
* @param {FsLike} fs
|
|
322
|
+
* @returns {Map<string, WorkflowDoc>}
|
|
323
|
+
*/
|
|
324
|
+
function loadDocs(root, fs) {
|
|
325
|
+
const dirAbs = path.resolve(root, WORKFLOWS_ROOT);
|
|
326
|
+
const docs = new Map();
|
|
327
|
+
for (const rel of listMarkdown(fs, dirAbs, root, []).sort()) {
|
|
328
|
+
const abs = path.resolve(root, rel);
|
|
329
|
+
try {
|
|
330
|
+
docs.set(rel, {
|
|
331
|
+
rel,
|
|
332
|
+
bytes: fs.statSync(abs).size,
|
|
333
|
+
source: fs.readFileSync(abs, 'utf8'),
|
|
334
|
+
});
|
|
335
|
+
} catch {
|
|
336
|
+
// Unreadable file — skipped silently, like the sibling tier resolvers.
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return docs;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* True when a workflow is invocable in its own right: a top-level workflow, or
|
|
344
|
+
* a helper whose H1 declares a slash command **named after the file itself**
|
|
345
|
+
* (`helpers/deliver-story.md` → `# /deliver-story …`). The self-naming test is
|
|
346
|
+
* what separates an invocable helper from an appendix that merely titles itself
|
|
347
|
+
* after the command it documents (`helpers/deliver-reference.md` →
|
|
348
|
+
* `# /deliver — reference appendix`), which is read on demand, never invoked.
|
|
349
|
+
*
|
|
350
|
+
* @param {WorkflowDoc} doc
|
|
351
|
+
* @returns {boolean}
|
|
352
|
+
*/
|
|
353
|
+
function isEntryPoint(doc) {
|
|
354
|
+
if (doc.rel.split('/').length === TOP_LEVEL_DEPTH) return true;
|
|
355
|
+
const h1 = COMMAND_H1_RE.exec(doc.source);
|
|
356
|
+
return h1 ? h1[1] === path.posix.basename(doc.rel, '.md') : false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Materialize a path set as sorted `{ path, bytes }` entries.
|
|
361
|
+
*
|
|
362
|
+
* @param {Iterable<string>} rels
|
|
363
|
+
* @param {Map<string, WorkflowDoc>} docs
|
|
364
|
+
* @returns {Array<{ path: string, bytes: number }>}
|
|
365
|
+
*/
|
|
366
|
+
function toEntries(rels, docs) {
|
|
367
|
+
return [...rels]
|
|
368
|
+
.map((rel) => ({ path: rel, bytes: docs.get(rel)?.bytes ?? 0 }))
|
|
369
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Sum the on-disk bytes of a path set.
|
|
374
|
+
*
|
|
375
|
+
* @param {Iterable<string>} rels
|
|
376
|
+
* @param {Map<string, WorkflowDoc>} docs
|
|
377
|
+
* @returns {number}
|
|
378
|
+
*/
|
|
379
|
+
function sumBytes(rels, docs) {
|
|
380
|
+
let total = 0;
|
|
381
|
+
for (const rel of rels) total += docs.get(rel)?.bytes ?? 0;
|
|
382
|
+
return total;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve the workflow tier: every entry point's transitive markdown-link
|
|
387
|
+
* closure, partitioned into the gated mandatory set and the recorded on-demand
|
|
388
|
+
* remainder. Returns empty collections when `.agents/workflows` is absent — an
|
|
389
|
+
* entry point resolving empty is skipped silently.
|
|
390
|
+
*
|
|
391
|
+
* @param {string} root absolute repo root
|
|
392
|
+
* @param {{ fs?: FsLike }} [opts]
|
|
393
|
+
* @returns {{
|
|
394
|
+
* entryPoints: Array<{ path: string, mandatoryBytes: number, reachableBytes: number }>,
|
|
395
|
+
* mandatoryFiles: Array<{ path: string, bytes: number }>,
|
|
396
|
+
* onDemandFiles: Array<{ path: string, bytes: number }>,
|
|
397
|
+
* mandatoryTotalBytes: number,
|
|
398
|
+
* reachableTotalBytes: number,
|
|
399
|
+
* }}
|
|
400
|
+
* @throws {Error} on an unresolvable `mandatoryReads` entry or a mandatory cycle
|
|
401
|
+
*/
|
|
402
|
+
export function resolveWorkflowClosures(root, { fs = nodeFs } = {}) {
|
|
403
|
+
const docs = loadDocs(root, fs);
|
|
404
|
+
const entryPoints = [];
|
|
405
|
+
const mandatoryUnion = new Set();
|
|
406
|
+
const reachableUnion = new Set();
|
|
407
|
+
|
|
408
|
+
for (const doc of docs.values()) {
|
|
409
|
+
if (!isEntryPoint(doc)) continue;
|
|
410
|
+
const mandatory = walkMandatory(doc.rel, docs, [], new Set());
|
|
411
|
+
const reachable = walkReachable(doc.rel, docs);
|
|
412
|
+
for (const rel of mandatory) mandatoryUnion.add(rel);
|
|
413
|
+
for (const rel of reachable) reachableUnion.add(rel);
|
|
414
|
+
entryPoints.push({
|
|
415
|
+
path: doc.rel,
|
|
416
|
+
mandatoryBytes: sumBytes(mandatory, docs),
|
|
417
|
+
reachableBytes: sumBytes(reachable, docs),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const onDemandUnion = [...reachableUnion].filter(
|
|
422
|
+
(rel) => !mandatoryUnion.has(rel),
|
|
423
|
+
);
|
|
424
|
+
return {
|
|
425
|
+
entryPoints: entryPoints.sort((a, b) => a.path.localeCompare(b.path)),
|
|
426
|
+
mandatoryFiles: toEntries(mandatoryUnion, docs),
|
|
427
|
+
onDemandFiles: toEntries(onDemandUnion, docs),
|
|
428
|
+
mandatoryTotalBytes: sumBytes(mandatoryUnion, docs),
|
|
429
|
+
reachableTotalBytes: sumBytes(reachableUnion, docs),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
@@ -232,4 +232,13 @@ async function main() {
|
|
|
232
232
|
runAsCli(import.meta.url, main, {
|
|
233
233
|
source: 'mandrel-update-preflight',
|
|
234
234
|
propagateExitCode: true,
|
|
235
|
+
usage: {
|
|
236
|
+
invocation: 'node .agents/scripts/mandrel-update-preflight.js',
|
|
237
|
+
summary:
|
|
238
|
+
'First-run guard for /mandrel-update: hard-stops on a non-consumer repo, warns on a dirty git index and on being offline.',
|
|
239
|
+
flags: [],
|
|
240
|
+
notes: [
|
|
241
|
+
'Exit codes:\n 0 safe to update (warnings may be present)\n 1 blocker — do not update',
|
|
242
|
+
],
|
|
243
|
+
},
|
|
235
244
|
});
|
|
@@ -446,4 +446,17 @@ export { main };
|
|
|
446
446
|
runAsCli(import.meta.url, main, {
|
|
447
447
|
source: 'nav-registry-diff',
|
|
448
448
|
propagateExitCode: true,
|
|
449
|
+
usage: {
|
|
450
|
+
invocation:
|
|
451
|
+
'node .agents/scripts/nav-registry-diff.js --routes <file> --nav <file> [--refs <file>] [--json] [--strict]',
|
|
452
|
+
summary:
|
|
453
|
+
'Diff a route inventory against the nav registry: report routes with no nav door and nav hrefs pointing nowhere.',
|
|
454
|
+
flags: [
|
|
455
|
+
['--routes <file>', 'JSON array of route records (required).'],
|
|
456
|
+
['--nav <file>', 'JSON array of nav entries (required).'],
|
|
457
|
+
['--refs <file>', 'JSON array of additional href references.'],
|
|
458
|
+
['--json', 'Emit the diff as JSON instead of a text report.'],
|
|
459
|
+
['--strict', 'Exit non-zero on any finding.'],
|
|
460
|
+
],
|
|
461
|
+
},
|
|
449
462
|
});
|
|
@@ -167,7 +167,9 @@ export async function emitPlanContext({
|
|
|
167
167
|
// Advisory only (Story #4722): signals, no route — the planner owns
|
|
168
168
|
// the trivial-vs-standard verdict and persist validates it by shape.
|
|
169
169
|
// The nested `deliverLightSuggestion` is the recorded plan-side routing
|
|
170
|
-
// handshake (Story #4741 AC-6)
|
|
170
|
+
// handshake (Story #4741 AC-6) and `uiSurface` the recorded /prototype
|
|
171
|
+
// offer — advisory, never an automatic reroute. Both ride the digest
|
|
172
|
+
// because with `--out` the digest is the only thing the planner reads.
|
|
171
173
|
complexitySignals: envelope.complexitySignals
|
|
172
174
|
? {
|
|
173
175
|
artifactCount: envelope.complexitySignals.artifactCount,
|
|
@@ -176,6 +178,7 @@ export async function emitPlanContext({
|
|
|
176
178
|
envelope.complexitySignals.sensitivePathClasses,
|
|
177
179
|
deliverLightSuggestion:
|
|
178
180
|
envelope.complexitySignals.deliverLightSuggestion ?? null,
|
|
181
|
+
uiSurface: envelope.complexitySignals.uiSurface ?? null,
|
|
179
182
|
}
|
|
180
183
|
: null,
|
|
181
184
|
amends: envelope.amends ? { id: envelope.amends.id } : null,
|
|
@@ -339,4 +342,20 @@ async function main() {
|
|
|
339
342
|
);
|
|
340
343
|
}
|
|
341
344
|
|
|
342
|
-
runAsCli(import.meta.url, main, {
|
|
345
|
+
runAsCli(import.meta.url, main, {
|
|
346
|
+
source: 'plan-context',
|
|
347
|
+
usage: {
|
|
348
|
+
invocation:
|
|
349
|
+
'node .agents/scripts/plan-context.js (--seed "<text>" | --seed-file <path> | --tickets <ids> | --amends <id>) [--out <path>] [--pretty]',
|
|
350
|
+
summary:
|
|
351
|
+
'Build the /plan authoring-context envelope on stdout. Exactly one entry form must be supplied.',
|
|
352
|
+
flags: [
|
|
353
|
+
['--seed "<text>"', 'Inline seed prose.'],
|
|
354
|
+
['--seed-file <path>', 'Seed document to read.'],
|
|
355
|
+
['--tickets <ids>', 'Comma-separated existing ticket ids to re-plan.'],
|
|
356
|
+
['--amends <id>', 'Amend the Spec of an existing Story.'],
|
|
357
|
+
['--out <path>', 'Write the envelope to a file instead of stdout.'],
|
|
358
|
+
['--pretty', 'Pretty-print the JSON envelope.'],
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
});
|
|
@@ -312,4 +312,14 @@ async function main() {
|
|
|
312
312
|
runAsCli(import.meta.url, main, {
|
|
313
313
|
source: 'plan-critics',
|
|
314
314
|
propagateExitCode: true,
|
|
315
|
+
usage: {
|
|
316
|
+
invocation:
|
|
317
|
+
'node .agents/scripts/plan-critics.js --stories <file> [--tech-spec <file>]',
|
|
318
|
+
summary:
|
|
319
|
+
'Score an authored plan draft with the maker-blind critics and print the verdict JSON on stdout.',
|
|
320
|
+
flags: [
|
|
321
|
+
['--stories <file>', 'Authored stories.json (required).'],
|
|
322
|
+
['--tech-spec <file>', 'Optional companion techspec.md.'],
|
|
323
|
+
],
|
|
324
|
+
},
|
|
315
325
|
});
|
|
@@ -438,4 +438,36 @@ async function main() {
|
|
|
438
438
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
-
runAsCli(import.meta.url, main, {
|
|
441
|
+
runAsCli(import.meta.url, main, {
|
|
442
|
+
source: 'plan-persist',
|
|
443
|
+
usage: {
|
|
444
|
+
invocation:
|
|
445
|
+
'node .agents/scripts/plan-persist.js --stories <file> [--tech-spec <file>] [--dry-run] [options]',
|
|
446
|
+
summary:
|
|
447
|
+
'Validate an authored plan and persist it as GitHub Stories. Prints the result envelope as JSON on stdout.',
|
|
448
|
+
flags: [
|
|
449
|
+
['--stories <file>', 'Authored stories.json (required).'],
|
|
450
|
+
['--tech-spec <file>', 'Optional companion techspec.md.'],
|
|
451
|
+
['--plan-dir <dir>', 'Directory holding the plan artifacts.'],
|
|
452
|
+
[
|
|
453
|
+
'--plan-context <file>',
|
|
454
|
+
'The plan-context envelope this draft was authored against.',
|
|
455
|
+
],
|
|
456
|
+
['--plan-acceptance <file>', 'Acceptance artifact to attach.'],
|
|
457
|
+
['--source-tickets <ids>', 'Ticket ids this plan supersedes.'],
|
|
458
|
+
[
|
|
459
|
+
'--route-downgrade-reason <text>',
|
|
460
|
+
'Why the authored route was downgraded.',
|
|
461
|
+
],
|
|
462
|
+
['--dry-run', 'Validate and report; create nothing.'],
|
|
463
|
+
['--chain-on-clean', 'Persist immediately when the dry run is clean.'],
|
|
464
|
+
['--no-close-superseded', 'Leave superseded source tickets open.'],
|
|
465
|
+
[
|
|
466
|
+
'--force-review',
|
|
467
|
+
'Require the review gate even when it would be skipped.',
|
|
468
|
+
],
|
|
469
|
+
['--allow-over-budget', 'Permit a Spec over the context budget.'],
|
|
470
|
+
['--allow-large-fan-out', 'Permit a Story count above the fan-out gate.'],
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
});
|
|
@@ -139,4 +139,15 @@ function warnOnEmptyRollup(result) {
|
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
await runAsCli(import.meta.url, main
|
|
142
|
+
await runAsCli(import.meta.url, main, {
|
|
143
|
+
usage: {
|
|
144
|
+
invocation:
|
|
145
|
+
'node .agents/scripts/plan-run-epilogue.js --stories <id,id,...> [--cwd <path>]',
|
|
146
|
+
summary:
|
|
147
|
+
'Close out a delivery run: roll up the delivered Stories’ signals and report the run’s loop health.',
|
|
148
|
+
flags: [
|
|
149
|
+
['--stories <ids>', 'Comma-separated delivered Story ids (required).'],
|
|
150
|
+
['--cwd <path>', 'Repository root (default: process cwd).'],
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
});
|
|
@@ -29,6 +29,22 @@ import {
|
|
|
29
29
|
runCrapPreview,
|
|
30
30
|
runMaintainabilityPreview,
|
|
31
31
|
} from './lib/baselines/preview-gates.js';
|
|
32
|
+
import { respondToHelp } from './lib/cli-usage.js';
|
|
33
|
+
|
|
34
|
+
const USAGE = {
|
|
35
|
+
invocation:
|
|
36
|
+
'node .agents/scripts/quality-preview.js [--staged | --changed-since <ref>] [--json]',
|
|
37
|
+
summary:
|
|
38
|
+
'Preview the per-file maintainability and CRAP deltas for the change set, and exit non-zero on any threshold violation.',
|
|
39
|
+
flags: [
|
|
40
|
+
['--staged', 'Score the git index only (the pre-commit-hook scope).'],
|
|
41
|
+
[
|
|
42
|
+
'--changed-since <ref>',
|
|
43
|
+
'Score the diff against <ref> (default: HEAD). Last occurrence wins.',
|
|
44
|
+
],
|
|
45
|
+
['--json', 'Emit both gate envelopes plus the merged table as JSON.'],
|
|
46
|
+
],
|
|
47
|
+
};
|
|
32
48
|
|
|
33
49
|
/**
|
|
34
50
|
* Parse `--changed-since <ref>` from argv. Defaults to `HEAD` when the flag is
|
|
@@ -327,7 +343,7 @@ const isDirect = (() => {
|
|
|
327
343
|
}
|
|
328
344
|
})();
|
|
329
345
|
|
|
330
|
-
if (isDirect) {
|
|
346
|
+
if (isDirect && !respondToHelp(process.argv.slice(2), USAGE)) {
|
|
331
347
|
runCli().then(({ exitCode }) => {
|
|
332
348
|
process.exit(exitCode);
|
|
333
349
|
});
|
|
@@ -80,4 +80,17 @@ runAsCli(import.meta.url, main, {
|
|
|
80
80
|
source: 'resolve-doc-tiers',
|
|
81
81
|
propagateExitCode: true,
|
|
82
82
|
errorPrefix: '[resolve-doc-tiers] ❌ Fatal error',
|
|
83
|
+
usage: {
|
|
84
|
+
invocation:
|
|
85
|
+
'node .agents/scripts/resolve-doc-tiers.js [--root <dir>] [--json]',
|
|
86
|
+
summary:
|
|
87
|
+
'Print the resolved documentation tiers (always-loaded vs on-demand) as JSON.',
|
|
88
|
+
flags: [
|
|
89
|
+
[
|
|
90
|
+
'--root <dir>',
|
|
91
|
+
'Repository root to resolve against (default: project root).',
|
|
92
|
+
],
|
|
93
|
+
['--json', 'Accepted for symmetry; output is always JSON.'],
|
|
94
|
+
],
|
|
95
|
+
},
|
|
83
96
|
});
|
|
@@ -178,4 +178,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
178
178
|
process.stdout.write(`${JSON.stringify({ ticketId, ...result })}\n`);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
runAsCli(import.meta.url, main, {
|
|
181
|
+
runAsCli(import.meta.url, main, {
|
|
182
|
+
source: 'resync-status-column',
|
|
183
|
+
usage: HELP,
|
|
184
|
+
});
|
|
@@ -290,6 +290,17 @@ runAsCli(
|
|
|
290
290
|
},
|
|
291
291
|
{
|
|
292
292
|
source: 'signals-view',
|
|
293
|
+
usage: {
|
|
294
|
+
invocation:
|
|
295
|
+
'node .agents/scripts/signals-view.js <run-id> [--story <id>] [--temp-root <path>]',
|
|
296
|
+
summary:
|
|
297
|
+
'Render a run’s signal ledger as a tree, optionally narrowed to one Story.',
|
|
298
|
+
flags: [
|
|
299
|
+
['<run-id>', 'Run id whose signals are read (required positional).'],
|
|
300
|
+
['--story <id>', 'Show only signals emitted for this Story.'],
|
|
301
|
+
['--temp-root <path>', 'Temp root holding the signal ledgers.'],
|
|
302
|
+
],
|
|
303
|
+
},
|
|
293
304
|
onError(err) {
|
|
294
305
|
println(`signals-view: unexpected error: ${err?.message ?? err}`);
|
|
295
306
|
process.exit(1);
|