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.
Files changed (75) hide show
  1. package/.agents/audit-checklists/navigability.md +1 -1
  2. package/.agents/docs/workflows.md +4 -4
  3. package/.agents/scripts/acceptance-eval.js +18 -1
  4. package/.agents/scripts/agents-bootstrap-github.js +22 -1
  5. package/.agents/scripts/apply-quality-bootstrap.js +6 -0
  6. package/.agents/scripts/audit-labels-bootstrap.js +15 -1
  7. package/.agents/scripts/audit-to-stories.js +26 -1
  8. package/.agents/scripts/boot-sweep.js +4 -1
  9. package/.agents/scripts/bootstrap.js +1 -0
  10. package/.agents/scripts/check-arch-cycles.js +20 -0
  11. package/.agents/scripts/check-baselines.js +8 -2
  12. package/.agents/scripts/check-context-budget.js +40 -5
  13. package/.agents/scripts/check-dead-exports.js +21 -0
  14. package/.agents/scripts/check-doc-links.js +12 -1
  15. package/.agents/scripts/check-lifecycle-doc-drift.js +9 -0
  16. package/.agents/scripts/check-workflow-citations.js +332 -0
  17. package/.agents/scripts/deliver-light.js +32 -3
  18. package/.agents/scripts/deliver-recover.js +4 -1
  19. package/.agents/scripts/diagnose-friction.js +17 -1
  20. package/.agents/scripts/diagnose.js +20 -14
  21. package/.agents/scripts/drain-pending-cleanup.js +20 -1
  22. package/.agents/scripts/evidence-gate.js +20 -1
  23. package/.agents/scripts/generate-config-docs.js +14 -1
  24. package/.agents/scripts/generate-lifecycle-docs.js +14 -1
  25. package/.agents/scripts/generate-workflows-doc.js +14 -1
  26. package/.agents/scripts/git-cleanup.js +32 -1
  27. package/.agents/scripts/lib/cli-usage.js +174 -0
  28. package/.agents/scripts/lib/cli-utils.js +12 -0
  29. package/.agents/scripts/lib/close-validation/process.js +61 -15
  30. package/.agents/scripts/lib/doc-tiers.js +53 -10
  31. package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
  32. package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
  33. package/.agents/scripts/lib/orchestration/plan-context.js +205 -20
  34. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
  35. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +24 -15
  36. package/.agents/scripts/lib/workflow-closure.js +431 -0
  37. package/.agents/scripts/mandrel-update-preflight.js +9 -0
  38. package/.agents/scripts/nav-registry-diff.js +13 -0
  39. package/.agents/scripts/plan-context.js +21 -2
  40. package/.agents/scripts/plan-critics.js +10 -0
  41. package/.agents/scripts/plan-persist.js +33 -1
  42. package/.agents/scripts/plan-run-epilogue.js +12 -1
  43. package/.agents/scripts/quality-preview.js +17 -1
  44. package/.agents/scripts/resolve-doc-tiers.js +13 -0
  45. package/.agents/scripts/resolve-stories.js +1 -0
  46. package/.agents/scripts/resync-status-column.js +4 -1
  47. package/.agents/scripts/signals-view.js +11 -0
  48. package/.agents/scripts/single-story-close.js +24 -0
  49. package/.agents/scripts/single-story-confirm-merge.js +16 -0
  50. package/.agents/scripts/single-story-init.js +21 -1
  51. package/.agents/scripts/stories-wave-tick.js +1 -0
  52. package/.agents/scripts/sync-agentrc.js +16 -4
  53. package/.agents/scripts/update-ticket-state.js +23 -2
  54. package/.agents/workflows/audit-navigability.md +2 -2
  55. package/.agents/workflows/audit-to-stories.md +1 -1
  56. package/.agents/workflows/deliver.md +80 -81
  57. package/.agents/workflows/git-cleanup.md +9 -14
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -13
  59. package/.agents/workflows/helpers/audit-lens-core.md +2 -2
  60. package/.agents/workflows/helpers/code-review.md +11 -11
  61. package/.agents/workflows/helpers/deliver-digest.md +7 -8
  62. package/.agents/workflows/helpers/deliver-light.md +222 -0
  63. package/.agents/workflows/helpers/deliver-reference.md +46 -14
  64. package/.agents/workflows/helpers/deliver-story-reference.md +55 -63
  65. package/.agents/workflows/helpers/deliver-story.md +22 -22
  66. package/.agents/workflows/helpers/mandrel-sync-config.md +2 -2
  67. package/.agents/workflows/helpers/parallel-tooling.md +1 -2
  68. package/.agents/workflows/helpers/plan-reference.md +96 -17
  69. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
  70. package/.agents/workflows/mandrel-update.md +6 -6
  71. package/.agents/workflows/plan.md +90 -85
  72. package/.agents/workflows/prototype.md +104 -0
  73. package/docs/CHANGELOG.md +32 -0
  74. package/package.json +2 -1
  75. package/.agents/workflows/deliver-light.md +0 -148
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `.agents/scripts/lib/cli-usage.js` — the one implementation of `--help` for
3
+ * every top-level script under `.agents/scripts/`.
4
+ *
5
+ * ## Why
6
+ *
7
+ * A script's flag contract used to live in the workflow prose that invoked it
8
+ * (`deliver-story.md` Step 0 restating `single-story-init.js`'s `--dry-run` /
9
+ * `--steal`, and so on). Two homes for one contract is a drift class: the
10
+ * script changes, the prose does not, and the next agent runs a flag that no
11
+ * longer exists. Moving the enumeration into the script's own `--help` gives
12
+ * the contract a single home that ships with the code that implements it.
13
+ *
14
+ * ## Contract
15
+ *
16
+ * `--help` (or `-h`) is a **query**, never an error path:
17
+ *
18
+ * - writes non-empty text to **stdout** and exits **0**;
19
+ * - performs no GitHub write, acquires no lease, mutates no working tree.
20
+ *
21
+ * stdout — not `Logger.info` — because help output must survive
22
+ * `AGENT_LOG_LEVEL=silent` and carry no `[Orchestrator]` decoration; this is
23
+ * the same `process.stdout.write` carve-out that machine-parsable envelopes
24
+ * use (see `tests/enforcement/no-console.test.js`).
25
+ *
26
+ * The short-circuit itself lives in `runAsCli` (`lib/cli-utils.js`), which
27
+ * fires this module **before** the script's `main` runs — so the "no side
28
+ * effects" half of the contract holds structurally rather than by each
29
+ * script remembering to check first.
30
+ *
31
+ * ## Usage
32
+ *
33
+ * runAsCli(import.meta.url, main, {
34
+ * source: 'my-script',
35
+ * usage: {
36
+ * invocation: 'node .agents/scripts/my-script.js --story <id> [--json]',
37
+ * summary: 'One line on what the script does.',
38
+ * flags: [
39
+ * ['--story <id>', 'GitHub issue number of the Story (required).'],
40
+ * ['--json', 'Emit the envelope as JSON instead of prose.'],
41
+ * ],
42
+ * },
43
+ * });
44
+ *
45
+ * A pre-rendered string is accepted too, so a script that already shipped a
46
+ * hand-written `HELP` block adopts the shared gate without its observable
47
+ * text changing (`usage: HELP`).
48
+ */
49
+
50
+ /** Tokens that request help. */
51
+ export const HELP_FLAGS = Object.freeze(['--help', '-h']);
52
+
53
+ /** Left column width for the flag table; longer flags wrap to their own line. */
54
+ const FLAG_COLUMN = 22;
55
+
56
+ /**
57
+ * Did the caller ask for help? Scans up to the `--` end-of-flags separator so
58
+ * a positional literally named `--help` after `--` is not mistaken for a
59
+ * request.
60
+ *
61
+ * @param {string[]} argv Argument vector without the node/script entries.
62
+ * @returns {boolean}
63
+ */
64
+ export function wantsHelp(argv = []) {
65
+ if (!Array.isArray(argv)) return false;
66
+ for (const token of argv) {
67
+ if (token === '--') return false;
68
+ if (HELP_FLAGS.includes(token)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Normalize one flag entry into `{ flag, description }`. Accepts a
75
+ * `[flag, description]` pair or an object, so call sites can use whichever
76
+ * reads better next to their option table.
77
+ *
78
+ * @param {[string, string]|{flag: string, description?: string}} entry
79
+ * @returns {{ flag: string, description: string }}
80
+ */
81
+ function normalizeFlag(entry) {
82
+ if (Array.isArray(entry)) {
83
+ return {
84
+ flag: String(entry[0] ?? ''),
85
+ description: String(entry[1] ?? ''),
86
+ };
87
+ }
88
+ return {
89
+ flag: String(entry?.flag ?? ''),
90
+ description: String(entry?.description ?? ''),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Render one ` --flag description` row, wrapping onto a second line when
96
+ * the flag itself is wider than the column.
97
+ *
98
+ * @param {{ flag: string, description: string }} row
99
+ * @returns {string}
100
+ */
101
+ function renderFlagRow({ flag, description }) {
102
+ if (!description) return ` ${flag}`;
103
+ if (flag.length >= FLAG_COLUMN) {
104
+ return ` ${flag}\n ${' '.repeat(FLAG_COLUMN)}${description}`;
105
+ }
106
+ return ` ${flag.padEnd(FLAG_COLUMN)}${description}`;
107
+ }
108
+
109
+ /**
110
+ * Append the `--help` row unless the spec already documents it, so every
111
+ * rendered block is self-describing without each call site repeating it.
112
+ *
113
+ * @param {Array<{ flag: string, description: string }>} rows
114
+ * @returns {Array<{ flag: string, description: string }>}
115
+ */
116
+ function withHelpRow(rows) {
117
+ const documented = rows.some((r) => r.flag.split(/[\s,]/)[0] === '--help');
118
+ if (documented) return rows;
119
+ return [...rows, { flag: '--help', description: 'Show this message.' }];
120
+ }
121
+
122
+ /**
123
+ * Render a usage spec into the text `--help` prints.
124
+ *
125
+ * @param {{
126
+ * invocation: string,
127
+ * summary?: string,
128
+ * flags?: Array<[string, string]|{flag: string, description?: string}>,
129
+ * notes?: string[],
130
+ * }} spec
131
+ * @returns {string} Text block, newline-terminated.
132
+ */
133
+ export function formatUsage(spec) {
134
+ const rows = withHelpRow((spec?.flags ?? []).map(normalizeFlag));
135
+ const blocks = [`Usage: ${spec?.invocation ?? ''}`.trim()];
136
+ if (spec?.summary) blocks.push(spec.summary);
137
+ blocks.push(['Flags:', ...rows.map(renderFlagRow)].join('\n'));
138
+ for (const note of spec?.notes ?? []) blocks.push(note);
139
+ return `${blocks.join('\n\n')}\n`;
140
+ }
141
+
142
+ /**
143
+ * Coerce either accepted `usage` shape — a spec object or a pre-rendered
144
+ * string — into the text to print.
145
+ *
146
+ * @param {object|string} usage
147
+ * @returns {string}
148
+ */
149
+ export function renderUsage(usage) {
150
+ if (typeof usage === 'string') {
151
+ return usage.endsWith('\n') ? usage : `${usage}\n`;
152
+ }
153
+ return formatUsage(usage);
154
+ }
155
+
156
+ /**
157
+ * Answer a help request. Returns `true` when help was requested (and printed),
158
+ * `false` when the caller should carry on with its normal path.
159
+ *
160
+ * Never throws on a missing/blank `usage`: a script whose spec renders empty
161
+ * still owes the contract non-empty stdout, so a minimal line is emitted
162
+ * rather than a silent exit that reads as a broken CLI.
163
+ *
164
+ * @param {string[]} argv
165
+ * @param {object|string} usage
166
+ * @param {{ write: (s: string) => void }} [out] Defaults to `process.stdout`.
167
+ * @returns {boolean}
168
+ */
169
+ export function respondToHelp(argv, usage, out = process.stdout) {
170
+ if (!wantsHelp(argv)) return false;
171
+ const text = renderUsage(usage ?? '');
172
+ out.write(text.trim().length > 0 ? text : 'Usage: (no flags documented)\n');
173
+ return true;
174
+ }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import path from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { respondToHelp } from './cli-usage.js';
19
20
  import { formatCliError } from './error-redactor.js';
20
21
 
21
22
  /**
@@ -37,12 +38,21 @@ export function isDirectInvocation(importMetaUrl) {
37
38
  * `main` is funnelled through either the caller-supplied `onError` callback
38
39
  * or the default handler (prefixed stderr line + `process.exit(exitCode)`).
39
40
  *
41
+ * A `usage` option makes the script self-describing: when the argv carries
42
+ * `--help` / `-h`, the rendered usage block goes to stdout and `main` is
43
+ * **never invoked**. That ordering is the whole point — it makes "`--help`
44
+ * performs no GitHub write, acquires no lease, and mutates no working tree"
45
+ * structurally true for every adopting script, rather than something each
46
+ * `main` has to remember to check before its first side effect.
47
+ *
40
48
  * @param {string} importMetaUrl Caller's `import.meta.url`.
41
49
  * @param {() => Promise<unknown>} main The CLI's main function.
42
50
  * @param {object} [options]
43
51
  * @param {string} [options.source='CLI'] Prefix used in the default error message.
44
52
  * @param {number} [options.exitCode=1] Exit code used by the default error handler.
45
53
  * @param {(err: Error) => void} [options.onError] Full override of the error handler.
54
+ * @param {object|string} [options.usage] Usage spec (or pre-rendered
55
+ * text) printed for `--help`; see `lib/cli-usage.js`.
46
56
  */
47
57
  export function runAsCli(importMetaUrl, main, options = {}) {
48
58
  if (!isDirectInvocation(importMetaUrl)) return;
@@ -52,7 +62,9 @@ export function runAsCli(importMetaUrl, main, options = {}) {
52
62
  onError,
53
63
  propagateExitCode = false,
54
64
  errorPrefix,
65
+ usage,
55
66
  } = options;
67
+ if (usage && respondToHelp(process.argv.slice(2), usage)) return;
56
68
  const promise = main();
57
69
  if (propagateExitCode) {
58
70
  promise.then((code) => process.exit(code ?? 0));
@@ -11,22 +11,48 @@ import { spawn } from 'node:child_process';
11
11
  * Pipe a child stream's output line-by-line through `emit`, prepending
12
12
  * `prefix` to each line. Tail bytes without a trailing newline flush on
13
13
  * `end` so the operator never loses the last line of a gate's output.
14
+ *
15
+ * ## The drain must stay cheap (Story #4766)
16
+ *
17
+ * This handler runs on the reader side of the child's stdout/stderr pipe.
18
+ * Every microsecond spent here is a microsecond the pipe is not being read,
19
+ * and once the OS pipe buffer fills, the child's own write blocks — or, on a
20
+ * non-blocking pipe, fails outright with `EAGAIN`. A gate child is not
21
+ * obliged to survive that: Biome's `biome_console` `.unwrap()`s the error and
22
+ * aborts the process with exit 101, so a green lint verdict presents as a
23
+ * failed close. Two consequences bind everything on this path:
24
+ *
25
+ * 1. Splitting is O(chunk), not O(chunk × lines) — the scan advances a
26
+ * `start` index instead of re-slicing the buffer once per line, so a
27
+ * 64KB chunk carrying 500 lines does not copy 16MB.
28
+ * 2. **`emit` MUST NOT block.** A synchronous per-line file write is
29
+ * exactly the stall this path cannot afford; the close path's capture
30
+ * sink (`single-story-close/gate-log.js`) buffers to an async stream for
31
+ * that reason.
14
32
  */
15
33
  function pipePrefixed(stream, prefix, emit) {
16
34
  let buf = '';
17
35
  stream.setEncoding('utf8');
18
36
  stream.on('data', (chunk) => {
19
37
  buf += chunk;
20
- while (true) {
21
- const nl = buf.indexOf('\n');
22
- if (nl === -1) break;
23
- emit(prefix + buf.slice(0, nl));
24
- buf = buf.slice(nl + 1);
38
+ let start = 0;
39
+ let nl = buf.indexOf('\n', start);
40
+ while (nl !== -1) {
41
+ emit(prefix + buf.slice(start, nl));
42
+ start = nl + 1;
43
+ nl = buf.indexOf('\n', start);
25
44
  }
45
+ if (start > 0) buf = buf.slice(start);
26
46
  });
27
47
  stream.on('end', () => {
28
- if (buf.length > 0) emit(prefix + buf);
48
+ if (buf.length > 0) {
49
+ emit(prefix + buf);
50
+ buf = '';
51
+ }
29
52
  });
53
+ // A pipe-level error (EIO on a vanished child) must not become an
54
+ // unhandled 'error' event that takes the whole close down.
55
+ stream.on('error', () => {});
30
56
  }
31
57
 
32
58
  /** Wire the AbortSignal so an abort kills the child. Returns the cleanup fn. */
@@ -68,6 +94,18 @@ export function gateExitCode(code, sig) {
68
94
  const BIOME_NO_FILES_PROCESSED =
69
95
  'No files were processed in the specified paths';
70
96
 
97
+ /**
98
+ * How many trailing gate lines the "No files were processed" probe retains.
99
+ *
100
+ * The marker only ever appears when biome processed nothing, and in that case
101
+ * its whole output is a handful of lines — so a bounded tail always contains
102
+ * it when it is there at all. Retaining a tail rather than the full transcript
103
+ * keeps the drain path's per-line work O(1) in the volume of gate output
104
+ * (Story #4766): the previous `captured += line` grew a string without limit,
105
+ * on the one gate (biome/format) whose output is the loudest.
106
+ */
107
+ const MARKER_PROBE_TAIL_LINES = 32;
108
+
71
109
  /**
72
110
  * Whether biome's combined gate output carries the "No files were processed"
73
111
  * marker. Pure function — no I/O. Exported for unit coverage (Story #4292).
@@ -85,8 +123,11 @@ export function isBiomeNoFilesProcessed(output) {
85
123
  * Default async gate runner — used by `runCloseValidation` when no `runner`
86
124
  * is injected. Spawns the gate via `child_process.spawn`, prefixes every
87
125
  * stdout/stderr line with `[gate-name] ` (so concurrent gates don't bleed
88
- * into each other in the operator's terminal), and resolves only when the
89
- * child exits.
126
+ * into each other in the operator's terminal), and resolves only once the
127
+ * child has exited and both stdio pipes are drained.
128
+ *
129
+ * `opts.log` is the drain sink and **must not block** — see `pipePrefixed`
130
+ * above for what a synchronous per-line write costs the child (Story #4766).
90
131
  *
91
132
  * Honours `opts.signal`: a TERM is delivered to the child the moment the
92
133
  * signal fires, so a sibling gate's failure aborts the rest of the wave
@@ -119,13 +160,14 @@ export function defaultGateRunner(cmd, args, opts = {}) {
119
160
  const prefix = gateName ? `[${gateName}] ` : '';
120
161
  const emit =
121
162
  typeof log === 'function' ? log : (m) => process.stdout.write(`${m}\n`);
122
- // Capture the combined output only when we may need to inspect it for the
123
- // biome "No files were processed" marker — otherwise the stream is purely
124
- // piped through to the operator (no retained buffer).
125
- let captured = '';
163
+ // Retain a bounded tail only when we may need to inspect it for the biome
164
+ // "No files were processed" marker — otherwise the stream is purely piped
165
+ // through to the operator (no retained buffer).
166
+ const recent = [];
126
167
  const tap = tolerateNoFilesProcessed
127
168
  ? (line) => {
128
- captured += `${line}\n`;
169
+ recent.push(line);
170
+ if (recent.length > MARKER_PROBE_TAIL_LINES) recent.shift();
129
171
  emit(line);
130
172
  }
131
173
  : emit;
@@ -133,13 +175,17 @@ export function defaultGateRunner(cmd, args, opts = {}) {
133
175
  pipePrefixed(child.stderr, prefix, tap);
134
176
  const detach = attachGateAbortHandler(child, signal);
135
177
  return new Promise((resolve) => {
136
- child.on('exit', (code, sig) => {
178
+ // 'close', not 'exit' (Story #4766): 'close' fires only once the child has
179
+ // exited AND both stdio pipes have been fully drained and closed, so no
180
+ // gate ever reports its status while lines are still in flight. Resolving
181
+ // on 'exit' raced the tail of a high-volume gate's output.
182
+ child.on('close', (code, sig) => {
137
183
  detach();
138
184
  const status = gateExitCode(code, sig);
139
185
  if (
140
186
  status !== 0 &&
141
187
  tolerateNoFilesProcessed &&
142
- isBiomeNoFilesProcessed(captured)
188
+ isBiomeNoFilesProcessed(recent.join('\n'))
143
189
  ) {
144
190
  emit(
145
191
  `${prefix}↳ biome processed zero files (all changed paths are config-ignored); treating as a clean skip`,
@@ -29,11 +29,19 @@
29
29
  * converted spawn boots on **instead of** the always-loaded
30
30
  * closure, so it is budgeted independently (per-file ≤8KB
31
31
  * ceiling gated by `check-context-budget.js`).
32
+ * - `workflow` / — the `.agents/workflows/**` read-tier (Story #4752),
33
+ * `workflowOnDemand` resolved as each entry point's transitive
34
+ * markdown-link closure by
35
+ * [`workflow-closure.js`](workflow-closure.js): the files
36
+ * an entry point's `mandatoryReads:` frontmatter forces
37
+ * you to read (`workflow`, gated) versus the reachable
38
+ * remainder (`workflowOnDemand`, recorded only).
32
39
  *
33
40
  * A file that could appear in more than one tier is kept in its **highest**
34
41
  * tier only (alwaysLoaded > mandatoryRead > digestVisible > onDemand), so the
35
42
  * arrays partition the doc set with no double-counting. `agentBoot` is disjoint
36
- * from the read-tiers (it lives under `.agents/agents/`, not the doc/rules set).
43
+ * from the read-tiers (it lives under `.agents/agents/`, not the doc/rules set),
44
+ * and so are the workflow tiers (confined to `.agents/workflows/`).
37
45
  *
38
46
  * The closure is discovered by parsing `@`-import references and following
39
47
  * them recursively (cycle-safe via a visited set). A candidate `@`-token only
@@ -47,6 +55,7 @@
47
55
 
48
56
  import nodeFs from 'node:fs';
49
57
  import path from 'node:path';
58
+ import { resolveWorkflowClosures } from './workflow-closure.js';
50
59
 
51
60
  /**
52
61
  * Basename of the always-loaded entry document (the root of the closure).
@@ -212,18 +221,31 @@ export function docsContextPaths(config) {
212
221
  }
213
222
 
214
223
  /**
215
- * Resolve the four documentation read-tiers, each entry `{ path, bytes }`,
224
+ * Resolve the documentation read-tiers, each entry `{ path, bytes }`,
216
225
  * partitioned so no path appears in more than one tier (highest tier wins).
226
+ * `workflowClosure` rides alongside the tiers as the per-entry-point workflow
227
+ * measurement (Story #4752) — its `reachableTotalBytes` is a recorded drift
228
+ * signal, never a gate.
217
229
  *
218
230
  * @param {object} config resolved config (`resolveConfig()` output)
219
231
  * @param {{ root?: string, fs?: FsLike }} [opts]
220
- * @returns {{ tiers: {
221
- * alwaysLoaded: Array<{ path: string, bytes: number }>,
222
- * mandatoryRead: Array<{ path: string, bytes: number }>,
223
- * digestVisible: Array<{ path: string, bytes: number }>,
224
- * onDemand: Array<{ path: string, bytes: number }>,
225
- * agentBoot: Array<{ path: string, bytes: number }>,
226
- * } }}
232
+ * @returns {{
233
+ * tiers: {
234
+ * alwaysLoaded: Array<{ path: string, bytes: number }>,
235
+ * mandatoryRead: Array<{ path: string, bytes: number }>,
236
+ * digestVisible: Array<{ path: string, bytes: number }>,
237
+ * onDemand: Array<{ path: string, bytes: number }>,
238
+ * agentBoot: Array<{ path: string, bytes: number }>,
239
+ * workflow: Array<{ path: string, bytes: number }>,
240
+ * workflowOnDemand: Array<{ path: string, bytes: number }>,
241
+ * },
242
+ * workflowClosure: {
243
+ * mandatoryTotalBytes: number,
244
+ * reachableTotalBytes: number,
245
+ * entryPoints: Array<{ path: string, mandatoryBytes: number, reachableBytes: number }>,
246
+ * },
247
+ * }}
248
+ * @throws {Error} on an unresolvable `mandatoryReads` entry or a mandatory cycle
227
249
  */
228
250
  export function resolveDocTiers(
229
251
  config,
@@ -265,8 +287,29 @@ export function resolveDocTiers(
265
287
  // are standalone system prompts, disjoint from the doc read-tiers.
266
288
  const agentBoot = collect(listAgentDefs(root, fs));
267
289
 
290
+ // 6. workflow: each entry point's transitive markdown-link closure (#4752),
291
+ // split into the gated mandatory set and the recorded on-demand remainder.
292
+ // The walk is confined to `.agents/workflows/**`, and `collect` still
293
+ // de-dupes, so no path is counted against two tiers.
294
+ const closure = resolveWorkflowClosures(root, { fs });
295
+ const workflow = collect(closure.mandatoryFiles.map((e) => e.path));
296
+ const workflowOnDemand = collect(closure.onDemandFiles.map((e) => e.path));
297
+
268
298
  return {
269
- tiers: { alwaysLoaded, mandatoryRead, digestVisible, onDemand, agentBoot },
299
+ tiers: {
300
+ alwaysLoaded,
301
+ mandatoryRead,
302
+ digestVisible,
303
+ onDemand,
304
+ agentBoot,
305
+ workflow,
306
+ workflowOnDemand,
307
+ },
308
+ workflowClosure: {
309
+ mandatoryTotalBytes: closure.mandatoryTotalBytes,
310
+ reachableTotalBytes: closure.reachableTotalBytes,
311
+ entryPoints: closure.entryPoints,
312
+ },
270
313
  };
271
314
  }
272
315