mandrel 2.13.0 → 2.15.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 -5
- package/.agents/schemas/story-deliver-terminal.schema.json +60 -6
- 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 +71 -9
- 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/doc-tiers.js +53 -10
- package/.agents/scripts/lib/orchestration/plan-context.js +16 -11
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +122 -6
- 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 +17 -1
- 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 +196 -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 +72 -14
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
- package/.agents/workflows/mandrel-update.md +6 -6
- package/.agents/workflows/plan.md +86 -83
- package/docs/CHANGELOG.md +22 -0
- package/package.json +2 -1
- package/.agents/workflows/deliver-light.md +0 -117
|
@@ -129,4 +129,35 @@ async function main() {
|
|
|
129
129
|
process.exit(exitCode);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
runAsCli(import.meta.url, main, {
|
|
132
|
+
runAsCli(import.meta.url, main, {
|
|
133
|
+
source: 'git-cleanup',
|
|
134
|
+
usage: {
|
|
135
|
+
invocation:
|
|
136
|
+
'node .agents/scripts/git-cleanup.js [--execute] [--yes] [--json] [phase flags] [filters]',
|
|
137
|
+
summary:
|
|
138
|
+
'Tidy the local checkout in four phases — fast-forward the base branch, prune stale remote refs, reap merged branches, triage stashes. Dry-run unless --execute.',
|
|
139
|
+
flags: [
|
|
140
|
+
['--execute', 'Perform the mutations (default is a dry run).'],
|
|
141
|
+
['--dry-run', 'Force a dry run even alongside --execute.'],
|
|
142
|
+
['--yes', 'Skip the interactive confirmation prompts.'],
|
|
143
|
+
['--json', 'Emit the plan/result envelope as JSON.'],
|
|
144
|
+
['--remote', 'Also delete the matching remote branches.'],
|
|
145
|
+
['--fast-forward-main', 'Run only the fast-forward-base phase.'],
|
|
146
|
+
['--prune-remotes', 'Run only the prune-remotes phase.'],
|
|
147
|
+
['--branches', 'Run only the merged-branch reap phase.'],
|
|
148
|
+
['--stashes', 'Run only the stash-triage phase.'],
|
|
149
|
+
[
|
|
150
|
+
'--include <glob>',
|
|
151
|
+
'Only consider branches matching the glob (repeatable).',
|
|
152
|
+
],
|
|
153
|
+
[
|
|
154
|
+
'--exclude <glob>',
|
|
155
|
+
'Never consider branches matching the glob (repeatable).',
|
|
156
|
+
],
|
|
157
|
+
['--drop-stashes <ref>', 'Stash ref approved for dropping (repeatable).'],
|
|
158
|
+
['--base <branch>', 'Base branch (default: project.baseBranch).'],
|
|
159
|
+
['--cwd <path>', 'Repository root (default: process cwd).'],
|
|
160
|
+
],
|
|
161
|
+
notes: ['With no phase flag, every phase runs in order.'],
|
|
162
|
+
},
|
|
163
|
+
});
|
|
@@ -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));
|
|
@@ -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
|
|
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 {{
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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: {
|
|
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
|
|
|
@@ -356,7 +356,7 @@ function resolveRiskHeuristics(config = {}) {
|
|
|
356
356
|
|
|
357
357
|
/**
|
|
358
358
|
* Ceilings a seed's advisory complexity signals must fit for the plan
|
|
359
|
-
* workflow to **suggest**
|
|
359
|
+
* workflow to **suggest** the light path at Gate #1 (Story #4741 R3 plan-side
|
|
360
360
|
* handshake). Framework constants, not operator knobs — mirroring the
|
|
361
361
|
* conservative intent of `complexity-gate.js`'s `STORY_SHAPE_CEILINGS`
|
|
362
362
|
* (small, mostly-additive, non-sensitive) but read against the *seed-time*
|
|
@@ -364,9 +364,14 @@ function resolveRiskHeuristics(config = {}) {
|
|
|
364
364
|
*
|
|
365
365
|
* The suggestion is **advisory only and never an automatic reroute**: it
|
|
366
366
|
* surfaces at Gate #1 for the operator to decide, and under `--yes` it is
|
|
367
|
-
* recorded on the envelope while planning proceeds unchanged.
|
|
368
|
-
*
|
|
369
|
-
*
|
|
367
|
+
* recorded on the envelope while planning proceeds unchanged.
|
|
368
|
+
*
|
|
369
|
+
* These ceilings are deliberately NOT the ones the light path itself applies
|
|
370
|
+
* (Story #4760). A confirmed suggestion routes into
|
|
371
|
+
* `workflows/helpers/deliver-light.md`, whose gate re-judges the *predicted
|
|
372
|
+
* shape* against `STORY_SHAPE_CEILINGS`. Two checks at two different stages:
|
|
373
|
+
* this one screens a seed, that one decides. Collapsing them would make a
|
|
374
|
+
* confirm a bypass.
|
|
370
375
|
*
|
|
371
376
|
* - `maxArtifacts` — enumerated seed items (one artifact each).
|
|
372
377
|
* - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
|
|
@@ -381,9 +386,9 @@ const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
|
|
|
381
386
|
});
|
|
382
387
|
|
|
383
388
|
/**
|
|
384
|
-
* Derive the advisory
|
|
385
|
-
*
|
|
386
|
-
*
|
|
389
|
+
* Derive the advisory light-path suggestion from a seed's complexity signals
|
|
390
|
+
* (Story #4741 AC-6). Pure and total: a malformed / missing signal bag fails
|
|
391
|
+
* conservative (not suggested), never throws.
|
|
387
392
|
*
|
|
388
393
|
* `automatic: false` is part of the contract — the suggestion is surfaced for
|
|
389
394
|
* the operator, never a silent reroute of a non-interactive run.
|
|
@@ -435,16 +440,16 @@ export function buildDeliverLightSuggestion(complexitySignals) {
|
|
|
435
440
|
ceilings,
|
|
436
441
|
reasons: suggested
|
|
437
442
|
? [
|
|
438
|
-
`seed fits the
|
|
443
|
+
`seed fits the light-path ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
|
|
439
444
|
'no risk-heuristic hits, no sensitive-path classes) — the operator ' +
|
|
440
|
-
'may prefer /deliver
|
|
445
|
+
'may prefer /deliver for this scope',
|
|
441
446
|
]
|
|
442
447
|
: reasons,
|
|
443
448
|
};
|
|
444
449
|
}
|
|
445
450
|
|
|
446
451
|
/**
|
|
447
|
-
* Attach the advisory
|
|
452
|
+
* Attach the advisory light-path suggestion to a complexity-signals bag
|
|
448
453
|
* as a **nested** field (Story #4741). Nesting — rather than a new top-level
|
|
449
454
|
* envelope key — keeps every existing per-mode envelope key set byte-stable
|
|
450
455
|
* (AC-5): the suggestion is derived from the signals it rides on.
|
|
@@ -920,7 +925,7 @@ function extractPriorArtifacts(priorBody) {
|
|
|
920
925
|
/**
|
|
921
926
|
* Build the amendment (delta) envelope — `plan-context --amends #<id>`
|
|
922
927
|
* (Story #4741 AC-4, R3-A). The heavy-amendment counterpart to routing a
|
|
923
|
-
* light amendment through
|
|
928
|
+
* light amendment through the light path: instead of re-interrogating the
|
|
924
929
|
* repo from scratch (`buildAuthoringContext`'s codebase snapshot and the BDD /
|
|
925
930
|
* memory / feedback probes), the envelope composes a DELTA from what already
|
|
926
931
|
* exists — the prior Story's body, its acceptance criteria (the real
|
|
@@ -61,6 +61,15 @@ export const TERMINAL_EXIT_CODES = Object.freeze({
|
|
|
61
61
|
pending: 3,
|
|
62
62
|
blocked: 1,
|
|
63
63
|
failed: 1,
|
|
64
|
+
/**
|
|
65
|
+
* `escalated` reuses **2**, the code `/deliver-light` already documents for
|
|
66
|
+
* "the gate did not proceed light" — the escalation is that outcome made
|
|
67
|
+
* terminal, not a new one, so giving it a fresh code would fork a vocabulary
|
|
68
|
+
* callers already branch on. It stays distinct from `landed` (nothing was
|
|
69
|
+
* delivered) and from `blocked`/`failed` (nothing is wrong — the work simply
|
|
70
|
+
* belongs to `/plan`).
|
|
71
|
+
*/
|
|
72
|
+
escalated: 2,
|
|
64
73
|
});
|
|
65
74
|
|
|
66
75
|
export const TERMINAL_STATUSES = Object.freeze([
|
|
@@ -68,13 +77,38 @@ export const TERMINAL_STATUSES = Object.freeze([
|
|
|
68
77
|
'pending',
|
|
69
78
|
'blocked',
|
|
70
79
|
'failed',
|
|
80
|
+
'escalated',
|
|
71
81
|
]);
|
|
72
82
|
|
|
83
|
+
/** Cap on the prompt echoed into an escalation's `/plan` next command. */
|
|
84
|
+
const PLAN_PROMPT_MAX = 200;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render an operator prompt safe to sit inside the double quotes of the
|
|
88
|
+
* `/plan "<prompt>"` next command: collapse newlines (a next command is one
|
|
89
|
+
* line by contract), escape backslashes and double quotes so the quoting
|
|
90
|
+
* cannot be broken out of, and cap the length so a long prompt does not turn
|
|
91
|
+
* the envelope into a transcript. Total — a non-string yields the empty
|
|
92
|
+
* string, which the escalation builder rejects rather than emitting.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} prompt
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function quoteForPlan(prompt) {
|
|
98
|
+
const text =
|
|
99
|
+
typeof prompt === 'string' ? prompt.replace(/\s+/g, ' ').trim() : '';
|
|
100
|
+
const capped =
|
|
101
|
+
text.length > PLAN_PROMPT_MAX
|
|
102
|
+
? `${text.slice(0, PLAN_PROMPT_MAX - 1).trimEnd()}…`
|
|
103
|
+
: text;
|
|
104
|
+
return capped.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
/**
|
|
74
108
|
* The shared next-command vocabulary. Every producer of a "what now?"
|
|
75
|
-
* answer — the `pending` terminal envelope
|
|
76
|
-
* builds its command from here, so the
|
|
77
|
-
* naming different commands for the same observed state.
|
|
109
|
+
* answer — the `pending` terminal envelope, an `escalated` one, and
|
|
110
|
+
* `deliver-recover.js` — builds its command from here, so the surfaces never
|
|
111
|
+
* drift into naming different commands for the same observed state.
|
|
78
112
|
*/
|
|
79
113
|
export const NEXT_COMMANDS = Object.freeze({
|
|
80
114
|
/**
|
|
@@ -104,6 +138,18 @@ export const NEXT_COMMANDS = Object.freeze({
|
|
|
104
138
|
/** Probe a stranded Story and print its single next command. */
|
|
105
139
|
recover: (storyId) =>
|
|
106
140
|
`node .agents/scripts/deliver-recover.js --story ${storyId}`,
|
|
141
|
+
/**
|
|
142
|
+
* Hand over-scope work to `/plan` — the next command of an `escalated`
|
|
143
|
+
* terminal (Story #4746).
|
|
144
|
+
*
|
|
145
|
+
* The only entry here that is a slash command rather than a script, and
|
|
146
|
+
* deliberately so: the other entries resume a Story that exists, while this
|
|
147
|
+
* one names work that has no Story yet and needs planning before it can have
|
|
148
|
+
* one. It is quoted for a shell but addressed to a **fresh session** — see
|
|
149
|
+
* the workflow's escalation section for why running it in the escalating
|
|
150
|
+
* session is forbidden.
|
|
151
|
+
*/
|
|
152
|
+
escalateToPlan: (prompt) => `/plan "${quoteForPlan(prompt)}"`,
|
|
107
153
|
});
|
|
108
154
|
|
|
109
155
|
/** @type {Function|null} */
|
|
@@ -165,8 +211,9 @@ function compact(obj) {
|
|
|
165
211
|
* this replaces.
|
|
166
212
|
*
|
|
167
213
|
* @param {object} args
|
|
168
|
-
* @param {number} args.storyId
|
|
169
|
-
*
|
|
214
|
+
* @param {number|null} args.storyId `null` only for an `escalated` terminal,
|
|
215
|
+
* which by construction never authored a Story.
|
|
216
|
+
* @param {'landed'|'pending'|'blocked'|'failed'|'escalated'} args.status
|
|
170
217
|
* @param {string} args.phase
|
|
171
218
|
* @param {string} [args.storyBranch]
|
|
172
219
|
* @param {string} [args.baseBranch]
|
|
@@ -175,6 +222,7 @@ function compact(obj) {
|
|
|
175
222
|
* @param {object|null} [args.tail]
|
|
176
223
|
* @param {object|null} [args.blocked]
|
|
177
224
|
* @param {object|null} [args.failure]
|
|
225
|
+
* @param {object|null} [args.escalation]
|
|
178
226
|
* @param {string|null} [args.nextCommand]
|
|
179
227
|
* @param {number} args.elapsedSeconds
|
|
180
228
|
* @param {object|null} [args.waitBudget]
|
|
@@ -192,6 +240,7 @@ export function buildTerminalEnvelope({
|
|
|
192
240
|
tail,
|
|
193
241
|
blocked,
|
|
194
242
|
failure,
|
|
243
|
+
escalation,
|
|
195
244
|
nextCommand,
|
|
196
245
|
elapsedSeconds = 0,
|
|
197
246
|
waitBudget,
|
|
@@ -199,7 +248,10 @@ export function buildTerminalEnvelope({
|
|
|
199
248
|
}) {
|
|
200
249
|
const envelope = compact({
|
|
201
250
|
kind: TERMINAL_ENVELOPE_KIND,
|
|
202
|
-
|
|
251
|
+
// `Number(null)` is 0, which would quietly satisfy nothing and confuse
|
|
252
|
+
// everything — a nullish storyId stays null and lets the schema decide
|
|
253
|
+
// whether this status is allowed to omit one.
|
|
254
|
+
storyId: storyId === null || storyId === undefined ? null : Number(storyId),
|
|
203
255
|
status,
|
|
204
256
|
phase,
|
|
205
257
|
storyBranch: storyBranch ?? null,
|
|
@@ -209,6 +261,7 @@ export function buildTerminalEnvelope({
|
|
|
209
261
|
tail: tail ?? null,
|
|
210
262
|
blocked: blocked ?? null,
|
|
211
263
|
failure: failure ?? null,
|
|
264
|
+
escalation: escalation ?? null,
|
|
212
265
|
nextCommand: nextCommand ?? null,
|
|
213
266
|
elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
|
|
214
267
|
waitBudget: waitBudget ?? null,
|
|
@@ -225,6 +278,69 @@ export function buildTerminalEnvelope({
|
|
|
225
278
|
return envelope;
|
|
226
279
|
}
|
|
227
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Build the `escalated` terminal — the one envelope emitted before a Story
|
|
283
|
+
* exists (Story #4746).
|
|
284
|
+
*
|
|
285
|
+
* `/deliver-light`'s suitability gate already decided correctly when it
|
|
286
|
+
* overrode a `lite` self-verdict on shape; what it lacked was an outcome a
|
|
287
|
+
* session could not walk past. A mandrel-bench 2.13.0 light-arm run did
|
|
288
|
+
* exactly that — it read the gate's `escalate-plan`, then invoked `/plan`
|
|
289
|
+
* in the same session and delivered. The continuation was not harmless:
|
|
290
|
+
* planning inside a session already framed as small work authored ONE Story
|
|
291
|
+
* against the scenario's 3-5 contract, where a fresh `/plan` session on the
|
|
292
|
+
* identical seed authored four. Escalation silently produced the very
|
|
293
|
+
* under-decomposition the guard exists to prevent.
|
|
294
|
+
*
|
|
295
|
+
* So the outcome is a validated envelope with its own exit code, naming the
|
|
296
|
+
* `/plan` command that owns the work, and asserting per artifact that nothing
|
|
297
|
+
* was started. Every guarantee here is enforced by the schema rather than by
|
|
298
|
+
* prose: `storyId` must be null, `escalation.created.*` are pinned `false`.
|
|
299
|
+
*
|
|
300
|
+
* @param {{
|
|
301
|
+
* prompt: string,
|
|
302
|
+
* reasons?: string[],
|
|
303
|
+
* elapsedSeconds?: number,
|
|
304
|
+
* timestamp?: string,
|
|
305
|
+
* }} args
|
|
306
|
+
* @returns {object} The validated `escalated` envelope.
|
|
307
|
+
*/
|
|
308
|
+
export function buildEscalationTerminal({
|
|
309
|
+
prompt,
|
|
310
|
+
reasons,
|
|
311
|
+
elapsedSeconds = 0,
|
|
312
|
+
timestamp,
|
|
313
|
+
}) {
|
|
314
|
+
const quoted = quoteForPlan(prompt);
|
|
315
|
+
if (quoted === '') {
|
|
316
|
+
// An escalation whose next command is `/plan ""` hands the operator
|
|
317
|
+
// nothing — the same walk-past-able non-outcome in envelope clothing.
|
|
318
|
+
throw new TypeError(
|
|
319
|
+
'buildEscalationTerminal: a non-empty prompt is required — the escalated terminal exists to name the /plan invocation that owns the work',
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const recorded = (Array.isArray(reasons) ? reasons : []).filter(
|
|
323
|
+
(r) => typeof r === 'string' && r.trim() !== '',
|
|
324
|
+
);
|
|
325
|
+
return buildTerminalEnvelope({
|
|
326
|
+
storyId: null,
|
|
327
|
+
status: 'escalated',
|
|
328
|
+
phase: 'suitability-gate',
|
|
329
|
+
escalation: {
|
|
330
|
+
reasons:
|
|
331
|
+
recorded.length > 0
|
|
332
|
+
? recorded
|
|
333
|
+
: ['predicted scope exceeds the light ceilings — escalate to /plan'],
|
|
334
|
+
// Not computed from anything: the escalation path returns before the
|
|
335
|
+
// receipt/init call sites, so these are the assertion that it did.
|
|
336
|
+
created: { receiptStory: false, storyBranch: false, worktree: false },
|
|
337
|
+
},
|
|
338
|
+
nextCommand: NEXT_COMMANDS.escalateToPlan(prompt),
|
|
339
|
+
elapsedSeconds,
|
|
340
|
+
...(timestamp === undefined ? {} : { timestamp }),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
228
344
|
/**
|
|
229
345
|
* Resolve the process exit code for a terminal envelope.
|
|
230
346
|
*
|