mandrel 2.15.0 → 2.17.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 (69) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/agentrc.schema.json +6 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/scripts/acceptance-eval.js +52 -12
  8. package/.agents/scripts/audit-to-stories.js +92 -25
  9. package/.agents/scripts/boot-sweep.js +28 -6
  10. package/.agents/scripts/check-baseline-drift.js +138 -0
  11. package/.agents/scripts/coverage-capture.js +74 -25
  12. package/.agents/scripts/deliver-light.js +31 -3
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/process.js +61 -15
  27. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  28. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  29. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  30. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  31. package/.agents/scripts/lib/config/quality.js +40 -0
  32. package/.agents/scripts/lib/coverage-utils.js +92 -9
  33. package/.agents/scripts/lib/crap-engine.js +113 -23
  34. package/.agents/scripts/lib/crap-utils.js +159 -93
  35. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  36. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  37. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  38. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  39. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  40. package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
  41. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  42. package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
  43. package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
  47. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  49. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  50. package/.agents/scripts/lib/stdio-flush.js +71 -0
  51. package/.agents/scripts/lib/transpile.js +133 -6
  52. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  53. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  54. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  55. package/.agents/scripts/nav-registry-diff.js +30 -8
  56. package/.agents/scripts/plan-context.js +4 -1
  57. package/.agents/scripts/plan-run-epilogue.js +27 -11
  58. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  59. package/.agents/scripts/single-story-close.js +9 -92
  60. package/.agents/scripts/update-crap-baseline.js +13 -0
  61. package/.agents/workflows/helpers/deliver-light.md +34 -8
  62. package/.agents/workflows/helpers/plan-reference.md +27 -6
  63. package/.agents/workflows/plan.md +4 -2
  64. package/.agents/workflows/prototype.md +104 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +41 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -27,9 +27,23 @@ const CLI_OPTIONS = {
27
27
 
28
28
  /**
29
29
  * @param {string[]} [argv]
30
+ * @param {{
31
+ * resolveConfigImpl?: typeof resolveConfig,
32
+ * createProviderImpl?: typeof createProvider,
33
+ * runPlanRunEpilogueImpl?: typeof runPlanRunEpilogue,
34
+ * logger?: { info: Function, warn: Function },
35
+ * }} [deps] Injectable seams; every entry defaults to the real
36
+ * implementation (`.agents/rules/test-seams.md` rules 1-2), so the CLI path
37
+ * and every production caller are unchanged.
30
38
  * @returns {Promise<object>}
31
39
  */
32
- export async function main(argv = process.argv.slice(2)) {
40
+ export async function main(argv = process.argv.slice(2), deps = {}) {
41
+ const {
42
+ resolveConfigImpl = resolveConfig,
43
+ createProviderImpl = createProvider,
44
+ runPlanRunEpilogueImpl = runPlanRunEpilogue,
45
+ logger = Logger,
46
+ } = deps;
33
47
  const { values } = parseArgs({
34
48
  args: argv,
35
49
  options: CLI_OPTIONS,
@@ -44,8 +58,8 @@ export async function main(argv = process.argv.slice(2)) {
44
58
  typeof values.cwd === 'string' && values.cwd.trim()
45
59
  ? values.cwd.trim()
46
60
  : process.cwd();
47
- const config = resolveConfig({ cwd });
48
- const provider = createProvider(config);
61
+ const config = resolveConfigImpl({ cwd });
62
+ const provider = createProviderImpl(config);
49
63
 
50
64
  // Story #4540 retired the `--run <planRunId>` label-resolution branch
51
65
  // along with the label itself. The epilogue is keyed on the delivered id
@@ -58,16 +72,16 @@ export async function main(argv = process.argv.slice(2)) {
58
72
 
59
73
  const planRunId = `adhoc-${[...stories].sort((a, b) => a - b).join('-')}`;
60
74
 
61
- const result = await runPlanRunEpilogue({
75
+ const result = await runPlanRunEpilogueImpl({
62
76
  planRunId,
63
77
  stories,
64
78
  provider,
65
79
  config,
66
80
  cwd,
67
81
  });
68
- warnOnUnresolvedBase(result);
69
- warnOnEmptyRollup(result);
70
- Logger.info(JSON.stringify(result));
82
+ warnOnUnresolvedBase(result, logger);
83
+ warnOnEmptyRollup(result, logger);
84
+ logger.info(JSON.stringify(result));
71
85
  if (result.errors?.length) {
72
86
  process.exitCode = 1;
73
87
  }
@@ -84,15 +98,16 @@ export async function main(argv = process.argv.slice(2)) {
84
98
  * roster is still useful.
85
99
  *
86
100
  * @param {object} result - `runPlanRunEpilogue` envelope.
101
+ * @param {{ warn: Function }} [logger]
87
102
  * @returns {void}
88
103
  */
89
- function warnOnUnresolvedBase(result) {
104
+ function warnOnUnresolvedBase(result, logger = Logger) {
90
105
  const roster = (result?.results ?? []).find(
91
106
  (r) => r?.kind === 'audit-roster',
92
107
  );
93
108
  const base = roster?.baseResolution;
94
109
  if (base?.resolved !== false) return;
95
- Logger.warn(
110
+ logger.warn(
96
111
  `⚠️ Combined landed diff unavailable — the pre-run base sha could not be ` +
97
112
  `resolved against \`${base.baseRef}\`: ${base.reason}\n` +
98
113
  ` changedFiles is null (NOT an empty set). Determine the run diff by ` +
@@ -117,14 +132,15 @@ function warnOnUnresolvedBase(result) {
117
132
  * rather than asserting either reading.
118
133
  *
119
134
  * @param {object} result - `runPlanRunEpilogue` envelope.
135
+ * @param {{ warn: Function }} [logger]
120
136
  * @returns {void}
121
137
  */
122
- function warnOnEmptyRollup(result) {
138
+ function warnOnEmptyRollup(result, logger = Logger) {
123
139
  const rollup = (result?.results ?? []).find(
124
140
  (r) => r?.kind === 'follow-up-rollup',
125
141
  );
126
142
  if (!rollup?.emptyRollupSuspect) return;
127
- Logger.warn(
143
+ logger.warn(
128
144
  `⚠️ 0 friction signals across ${rollup.storyCount} Stories — telemetry may not ` +
129
145
  `have fired.\n` +
130
146
  ` An empty roll-up is NOT evidence of a clean run: it is the same output a ` +
@@ -50,24 +50,34 @@ export function parseArgv(argv = []) {
50
50
  * Top-level CLI entry. Exported so tests can drive it against a fixture root
51
51
  * with an injected sink and config.
52
52
  *
53
+ * The optional final `deps` parameter is the module's injectable seam
54
+ * (`.agents/rules/test-seams.md` rules 1-2): every entry defaults to the real
55
+ * implementation, so the CLI path below — and any production caller — needs no
56
+ * configuration change.
57
+ *
53
58
  * @param {{
54
59
  * argv?: string[],
55
60
  * config?: object,
56
61
  * root?: string,
57
62
  * stdout?: { write: (s: string) => void },
58
63
  * }} [opts]
64
+ * @param {{
65
+ * resolveConfigImpl?: typeof resolveConfig,
66
+ * resolveDocTiersImpl?: typeof resolveDocTiers,
67
+ * }} [deps]
59
68
  * @returns {Promise<number>} always 0
60
69
  */
61
- export async function runCli({
62
- argv = process.argv.slice(2),
63
- config,
64
- root,
65
- stdout = process.stdout,
66
- } = {}) {
70
+ export async function runCli(
71
+ { argv = process.argv.slice(2), config, root, stdout = process.stdout } = {},
72
+ {
73
+ resolveConfigImpl = resolveConfig,
74
+ resolveDocTiersImpl = resolveDocTiers,
75
+ } = {},
76
+ ) {
67
77
  const { rootPath } = parseArgv(argv);
68
- const resolvedConfig = config ?? resolveConfig();
78
+ const resolvedConfig = config ?? resolveConfigImpl();
69
79
  const resolvedRoot = root ?? rootPath ?? PROJECT_ROOT;
70
- const result = resolveDocTiers(resolvedConfig, { root: resolvedRoot });
80
+ const result = resolveDocTiersImpl(resolvedConfig, { root: resolvedRoot });
71
81
  stdout.write(`${JSON.stringify(result, null, 2)}\n`);
72
82
  return 0;
73
83
  }
@@ -90,6 +90,10 @@ import { runAsCli } from './lib/cli-utils.js';
90
90
  import { formatCliError } from './lib/error-redactor.js';
91
91
  import { Logger } from './lib/Logger.js';
92
92
  import { emitTerminalFriction } from './lib/observability/runtime-friction.js';
93
+ import {
94
+ failedTerminalFor,
95
+ gatesForFailedPhase,
96
+ } from './lib/orchestration/single-story-close/failed-terminal.js';
93
97
  import { enableAutoMergeWith } from './lib/orchestration/single-story-close/phases/auto-merge.js';
94
98
  import {
95
99
  buildSyncFailureCommentBody,
@@ -102,10 +106,8 @@ import {
102
106
  } from './lib/orchestration/single-story-close/phases/code-review.js';
103
107
  import { ensurePullRequestWith } from './lib/orchestration/single-story-close/phases/pull-request.js';
104
108
  import {
105
- buildTerminalEnvelope,
106
109
  emitTerminalEnvelope,
107
110
  exitCodeForTerminal,
108
- NEXT_COMMANDS,
109
111
  } from './lib/orchestration/story-deliver-terminal.js';
110
112
 
111
113
  // Story #2990 moved the `gh`-spawn boundary into the `lib/gh-exec.js`
@@ -118,9 +120,13 @@ export const enableAutoMerge = enableAutoMergeWith;
118
120
 
119
121
  // Re-export pure helpers verbatim — they don't touch `execFileSync`
120
122
  // or any URL-mocked module, so the phase exports work unmodified.
123
+ // `gatesForFailedPhase` now lives beside the envelope it feeds
124
+ // (`single-story-close/failed-terminal.js`); it is re-exported here so the
125
+ // CLI's public surface is unchanged by that move.
121
126
  export {
122
127
  buildStoryReviewCrossRefBody,
123
128
  buildSyncFailureCommentBody,
129
+ gatesForFailedPhase,
124
130
  handleSyncFailure,
125
131
  parsePrNumber,
126
132
  runStoryScopeReview,
@@ -134,95 +140,6 @@ export async function runSingleStoryClose(opts) {
134
140
  return mod.runSingleStoryClose(opts);
135
141
  }
136
142
 
137
- /**
138
- * The close pipeline's phase order, as `setPhase` walks it. Only used to
139
- * decide whether a gate had already run when a later phase died.
140
- */
141
- const PHASE_ORDER = Object.freeze([
142
- 'init',
143
- 'wrong-tree-guard',
144
- 'close-validation',
145
- 'base-sync',
146
- 'push',
147
- 'pull-request',
148
- 'code-review',
149
- 'auto-merge',
150
- 'confirm-merge',
151
- 'post-land',
152
- 'done',
153
- ]);
154
-
155
- /** Each reported gate and the pipeline phase that decides it. */
156
- const GATE_PHASES = Object.freeze([
157
- ['validation', 'close-validation'],
158
- ['baseSync', 'base-sync'],
159
- ['codeReview', 'code-review'],
160
- ]);
161
-
162
- /**
163
- * Report every gate's outcome for a run that died at `phase`.
164
- *
165
- * The schema's contract: "A gate the run skipped … reports `skipped` rather
166
- * than being omitted, so a missing gate is never mistaken for a passing one."
167
- * The previous shape named only the gate that died and omitted the rest
168
- * entirely — exactly the ambiguity the contract forbids.
169
- *
170
- * Reconstructed from the phase order, which is sound because the pipeline is
171
- * strictly sequential: reaching phase N means every gate before it completed.
172
- * A gate whose phase the run never reached is `skipped`; one the operator
173
- * turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
174
- * not pass — it never ran).
175
- *
176
- * @param {string} phase The phase the run died in.
177
- * @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
178
- * @returns {Record<string, 'passed'|'failed'|'skipped'>}
179
- */
180
- export function gatesForFailedPhase(phase, args = {}) {
181
- const skipped = { validation: args.skipValidation, baseSync: args.skipSync };
182
- const failedAt = PHASE_ORDER.indexOf(phase);
183
- const gates = {};
184
- for (const [gate, gatePhase] of GATE_PHASES) {
185
- const at = PHASE_ORDER.indexOf(gatePhase);
186
- if (gatePhase === phase) gates[gate] = 'failed';
187
- else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
188
- else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
189
- }
190
- return gates;
191
- }
192
-
193
- /**
194
- * Build the `failed` terminal for a phase that crashed.
195
- *
196
- * The runner deliberately throws rather than returning a failure (a red gate
197
- * must not look like a return value), so without this the most common
198
- * non-happy ending — a failing close-validation gate — would emit **no
199
- * envelope at all**, exiting 1 with only a stderr line while the workflow
200
- * docs promise the agent a `failed` envelope naming the phase. Every close
201
- * invocation emits exactly one envelope; this is the path that keeps that
202
- * true when a phase dies.
203
- *
204
- * `err.closePhase` is tagged by the runner's phase tracker.
205
- *
206
- * @param {unknown} err
207
- * @returns {object|null} A validated envelope, or null when even the story id
208
- * is unknown (a usage error — there is nothing to report an envelope about).
209
- */
210
- function failedTerminalFor(err) {
211
- const phase = err?.closePhase ?? 'init';
212
- const args = parseSprintArgs();
213
- const storyId = Number(args.storyId);
214
- if (!Number.isInteger(storyId) || storyId <= 0) return null;
215
- return buildTerminalEnvelope({
216
- storyId,
217
- status: 'failed',
218
- phase,
219
- gates: gatesForFailedPhase(phase, args),
220
- failure: { reason: String(err?.message ?? err) },
221
- nextCommand: NEXT_COMMANDS.recover(storyId),
222
- elapsedSeconds: 0,
223
- });
224
- }
225
-
226
143
  /**
227
144
  * CLI entry — resolves the process exit code from the terminal envelope's
228
145
  * status rather than from a thrown/not-thrown distinction, so `pending`
@@ -234,7 +151,7 @@ async function main() {
234
151
  const outcome = await runSingleStoryClose();
235
152
  return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
236
153
  } catch (err) {
237
- const terminal = failedTerminalFor(err);
154
+ const terminal = failedTerminalFor(err, parseSprintArgs());
238
155
  if (!terminal) throw err;
239
156
  // Mirror runAsCli's default error line (which this catch pre-empts) so the
240
157
  // human-facing failure text is unchanged, then emit the envelope.
@@ -13,6 +13,7 @@ import {
13
13
  } from './lib/config-resolver.js';
14
14
  import { loadCoverage } from './lib/coverage-utils.js';
15
15
  import {
16
+ checkResolutionFloor,
16
17
  resolveEscomplexVersion,
17
18
  resolveTsTranspilerVersion,
18
19
  scanAndScore,
@@ -71,6 +72,7 @@ async function main() {
71
72
  const crap = getQuality(config).crap;
72
73
  const targetDirs = Array.isArray(crap.targetDirs) ? crap.targetDirs : [];
73
74
  const requireCoverage = crap.requireCoverage !== false;
75
+ const minMethodResolutionRate = crap.minMethodResolutionRate ?? 0.75;
74
76
  const coveragePath =
75
77
  args.coveragePath ?? crap.coveragePath ?? 'coverage/coverage-final.json';
76
78
  const baselinePath = args.baselinePath ?? getBaselines(config).crap.path;
@@ -117,6 +119,7 @@ async function main() {
117
119
  scannedFiles,
118
120
  skippedFilesNoCoverage,
119
121
  skippedMethodsNoCoverage,
122
+ resolution,
120
123
  } = await scanAndScore({
121
124
  targetDirs,
122
125
  coverage,
@@ -137,6 +140,16 @@ async function main() {
137
140
  `[CRAP] Skipped ${skippedMethodsNoCoverage} method(s) whose per-method coverage was unresolved.`,
138
141
  );
139
142
  }
143
+ if (resolution) {
144
+ Logger.info(
145
+ `[CRAP] Method resolution: ${resolution.resolvedMethods}/${resolution.joinableMethods} ` +
146
+ `(${(resolution.rate * 100).toFixed(1)}%) in files with coverage.`,
147
+ );
148
+ }
149
+ // Fail closed BEFORE the service persists anything — a thin baseline is
150
+ // never written and then apologised for.
151
+ const refusal = checkResolutionFloor(resolution, minMethodResolutionRate);
152
+ if (refusal) throw new Error(refusal);
140
153
 
141
154
  return (rows ?? []).filter(
142
155
  (r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
@@ -27,15 +27,36 @@ over-scope work silently.
27
27
  Two callers, one gate: whichever door you arrived through, the suitability gate
28
28
  below is the decision. A `/plan` Gate #1 suggestion is a *suggestion* — it is
29
29
  read against seed-time signals (`DELIVER_LIGHT_SUGGESTION_CEILINGS`: artifacts,
30
- risk hits, sensitive-path classes), while the gate here is read against a
31
- predicted *shape* (`STORY_SHAPE_CEILINGS`: `maxChanges`, `maxAcceptance`). They
32
- are deliberately two different checks, so the gate still runs after a confirm.
30
+ risk hits, sensitive-path classes), while the gate here is read against the
31
+ predicted work's *effort and risk* (`STORY_SHAPE_CEILINGS`: change kinds,
32
+ magnitude, uncertainty, deployable span). They are deliberately two different
33
+ checks, so the gate still runs after a confirm.
34
+
35
+ ## Scope by effort, not by artifact count {#scope-by-effort}
36
+
37
+ **Counting the footprint is the wrong axis.** Three identical one-line edits
38
+ across three files is trivial work with a high count; a 200-line rewrite of one
39
+ module is a single change. The axes are therefore effort and risk: distinct
40
+ change **kinds** (N instances of one mechanical edit is one kind at N sites), a
41
+ coarse **magnitude** bucket, and **uncertainty** — is the shape determined by
42
+ the request, or does it still need the design decisions `/plan` exists to
43
+ resolve?
44
+
45
+ Because the predicted footprint is a *declaration* — a guess, and a gameable one
46
+ — this gate is deliberately **coarse**: it rejects clearly-epic work only
47
+ (multiple deployables, a migration plus its consumers, an explicit
48
+ multi-capability enumeration). Size is enforced where ground truth is available:
49
+ the diff backstop in step 4. Do not talk yourself past that one.
50
+
51
+ Sensitivity is the exception and stays absolute: a footprint touching an auth,
52
+ crypto, billing, or migration class routes `full` however small or mechanical.
33
53
 
34
54
  ## Four invariants (do not skip one)
35
55
 
36
56
  1. **Suitability gate.** The prompt's predicted footprint is judged by the
37
- shared shape machinery (`deriveStoryShape` / `deriveChangeLevel`) **and** a
38
- ledgered model verdict with a recorded reason. Both must agree on `lite`.
57
+ shared effort/risk machinery (`deriveStoryShape` / `deriveChangeLevel`)
58
+ **and** a ledgered model verdict with a recorded reason. Both must agree on
59
+ `lite`.
39
60
  2. **Over-scope stops — it never hard-fails.** An over-ceiling prompt STOPS and
40
61
  asks the operator to escalate to `/plan` or proceed light. Under `--yes` it
41
62
  fails closed to an **`escalated` terminal envelope** that ends the session
@@ -50,12 +71,16 @@ are deliberately two different checks, so the gate still runs after a confirm.
50
71
  ## Procedure
51
72
 
52
73
  1. **Predict + gate.** Form the predicted footprint (new files, edited files,
53
- acceptance count) and your ledgered verdict (a recorded reason for `lite`),
54
- then run the gate:
74
+ acceptance count), judge its effort honestly (`--kinds` / `--magnitude` /
75
+ `--uncertainty`, per § Scope by effort), and record your ledgered verdict (a
76
+ recorded reason for `lite`), then run the gate — it documents every flag
77
+ itself, so run it with `--help` rather than guessing:
55
78
 
56
79
  ```bash
57
80
  node .agents/scripts/deliver-light.js --prompt "<prompt>" \
58
81
  --creates <csv> --refactors <csv> --acceptance <n> \
82
+ --kinds <csv> --magnitude trivial|moderate|substantial \
83
+ --uncertainty determined|needs-design \
59
84
  --route lite --reason "<why this is trivial>" [--amends '#<id>'] [--yes]
60
85
  ```
61
86
 
@@ -106,7 +131,8 @@ are deliberately two different checks, so the gate still runs after a confirm.
106
131
 
107
132
  Exit `3` (`blocked: true`) means the landed diff exceeds the light ceilings
108
133
  (file count or a sensitive-path class). STOP, flip `agent::blocked`, and
109
- escalate to `/plan` — do not land.
134
+ escalate to `/plan` — do not land. This is the pass that actually bounds
135
+ size, which is why the prediction gate above can afford to be coarse.
110
136
 
111
137
  5. **Close and land (same engine).** Exactly [`/deliver`](../deliver.md)'s close:
112
138
 
@@ -49,9 +49,9 @@ things make that safe, and both are worth understanding before changing it:
49
49
  suggestion that routed you.
50
50
  2. **The gate still runs.** The suggestion is read against seed-time ceilings
51
51
  (`DELIVER_LIGHT_SUGGESTION_CEILINGS` — artifacts, risk hits, sensitive-path
52
- classes); the light gate is read against a predicted shape
53
- (`STORY_SHAPE_CEILINGS` — `maxChanges`, `maxAcceptance`). Two different
54
- checks on purpose, so a confirm is not a bypass.
52
+ classes); the light gate is read against the predicted work's effort and risk
53
+ (`STORY_SHAPE_CEILINGS` — change kinds, magnitude, uncertainty, deployable
54
+ span). Two different checks on purpose, so a confirm is not a bypass.
55
55
 
56
56
  **When the light gate answers `ask-operator`**, the two ceiling sets disagreed.
57
57
  Resume `/plan` at step 2 (Author) **in this same session** — the interrogation
@@ -63,6 +63,26 @@ is terminal and requires a fresh session. The rule that separates the two, and
63
63
  why it must not be flattened into symmetry:
64
64
  [`deliver-light.md` § Why the two directions differ](deliver-light.md).
65
65
 
66
+ ## Gate #1 → the `/prototype` offer (`uiSurface`)
67
+
68
+ `complexitySignals.uiSurface` is the second advisory Gate #1 offer, and the
69
+ weaker of the two on purpose: it carries **no routing authority and adds no
70
+ gate**. Both halves are derived from observables already in the checkout — the
71
+ `hasWebSurface` applicability predicate the `target: "web"` audit lenses gate
72
+ on, and whether any predicted path matches a web lens `filePattern` registered
73
+ in `audit-rules.json`. There is no configuration key to set: a project with no
74
+ rendered frontend resolves falsey and the offer never fires.
75
+
76
+ When it does fire, **name [`/prototype`](../prototype.md) and stop there.**
77
+ `/plan` must never invoke it — operator invocation is the entire design, because
78
+ the value is a human looking at a layout before its UI acceptance criteria are
79
+ frozen.
80
+
81
+ **Under `--yes` the offer is recorded and planning proceeds** — no reroute, no
82
+ prototype written, no gate raised. This is exactly how `deliverLightSuggestion`
83
+ behaves unattended, and for the same reason: an unattended run has nobody to
84
+ review an artifact, so recording the offer is the whole of the right behaviour.
85
+
66
86
  ## Shape-derived complexity routing (`complexitySignals`)
67
87
 
68
88
  Complexity routes on the **objective shape of the authored work**, never on
@@ -85,9 +105,10 @@ decision:
85
105
  (`full`) stands.
86
106
  - **Persist backstops the claim deterministically.** After authoring, the
87
107
  work has measurable shape, so persist validates the `lite` claim against
88
- each Story's own shape — `changes[]` count, acceptance-criteria count,
89
- creates-vs-refactors mix, glob-free footprint, and sensitive-path classes,
90
- against the framework `STORY_SHAPE_CEILINGS` — and **fails closed to
108
+ each Story's own shape — distinct change kinds, declared magnitude,
109
+ uncertainty, deployable/migration span, glob-free footprint, and
110
+ sensitive-path classes, against the framework `STORY_SHAPE_CEILINGS` (effort
111
+ and risk, never artifact counts) — and **fails closed to
91
112
  `full`** when any Story exceeds them (the refusal is ledgered on the
92
113
  checkpoint too). The lite route is **not** licence to drop a
93
114
  non-negotiable — every decision's `preserves` field enumerates what still
@@ -82,6 +82,9 @@ this envelope, not the raw seed; an `ask-operator` verdict returns here to
82
82
  step 2 with the interrogation intact. Under `--yes` it is recorded and planning
83
83
  proceeds — never auto-downgraded to light.
84
84
 
85
+ A truthy `complexitySignals.uiSurface` marks a UI-touching plan: name
86
+ [`/prototype`](prototype.md) as an operator option — never invoke it here.
87
+
85
88
  ### 2. Author
86
89
 
87
90
  **One-shot authoring.** Start from `stories.template.json`; author
@@ -156,8 +159,7 @@ JSON.
156
159
 
157
160
  In tickets mode persist resolves source ids **envelope-first** and closes each
158
161
  as `not_planned` with a comment (default on;
159
- [detail](helpers/plan-reference.md)). On a stranded persist, re-run the same
160
- command — never hand-delete issues.
162
+ [detail](helpers/plan-reference.md)).
161
163
 
162
164
  ## Constraints
163
165
 
@@ -0,0 +1,104 @@
1
+ ---
2
+ description: >-
3
+ Operator-invoked UI prototype pass. Discovers the consumer's design-system
4
+ SSOT first, then — only after the operator confirms — writes exactly one
5
+ self-contained HTML file under the gitignored workspace-root temp tree, so a
6
+ layout can be reviewed before its UI acceptance criteria are authored.
7
+ ---
8
+
9
+ # /prototype [what to prototype]
10
+
11
+ UI acceptance criteria are otherwise authored blind: "the dashboard shows the
12
+ active runs" says nothing about layout, density, or interaction, so delivery
13
+ resolves those by taste. `/prototype` puts a reviewable artifact in front of the
14
+ operator before the criteria are written.
15
+
16
+ **Operator-invoked only.** `/plan` may report that a plan touches UI and that
17
+ this command exists; it must never run it. No workflow, gate, or script invokes
18
+ `/prototype` — the whole design rests on the operator asking for it.
19
+
20
+ ## Procedure
21
+
22
+ ### Step 0 — Discover the design-system SSOT (first, before anything is drawn)
23
+
24
+ You cannot prototype *in the project's visual language* until you have found the
25
+ language. Locate and read the consumer's sources of truth — the same set
26
+ [`/audit-ux-ui`](audit-ux-ui.md) Step 0 mandates:
27
+
28
+ - **Design tokens / theme** — a `tailwind.config.{js,ts}`, CSS custom properties
29
+ (`:root { --color-*, --space-* }`), a `theme/` / `tokens/` /
30
+ `design-system/` directory, or a CSS-in-JS theme object.
31
+ - **Component roster** — the shared component directory (`components/ui/**`, a
32
+ published design-system package) that raw elements are expected to defer to.
33
+ - **Documented conventions** — `docs/style-guide.md`, plus `docs/web-routes.md`
34
+ when the surface is a route, whenever they exist in the consumer checkout.
35
+
36
+ Report what you found — token names, the component roster, the style-guide rules
37
+ — and draw only against that discovered baseline. **No artifact is drawn until
38
+ this step has run.**
39
+
40
+ ### Step 0a — When no design-system SSOT is discoverable
41
+
42
+ Report the absence explicitly, then emit a **low-fidelity frame**: boxes,
43
+ labels, and hierarchy, in the host's default typography with no colour system.
44
+ Do **not** invent a visual language. A prototype in a palette the project never
45
+ adopted reviews the invention rather than the layout, and the operator cannot
46
+ tell which of the two they are approving.
47
+
48
+ ### Step 1 — Confirm before writing (**hard gate**)
49
+
50
+ Describe the layout you intend — surfaces, hierarchy, states, and which
51
+ discovered tokens and components it reuses — and **STOP**. Nothing is written to
52
+ disk until the operator confirms; never write silently. This is the disk-write
53
+ policy [`core/idea-refinement`](../skills/core/idea-refinement/SKILL.md) already
54
+ applies to its one-pager.
55
+
56
+ ### Step 2 — Write exactly one self-contained HTML file
57
+
58
+ On confirm, write **exactly one** self-contained `.html` file — inline CSS, no
59
+ build step, no fetched external assets — under the **gitignored workspace-root
60
+ temp tree** (`temp/prototypes/<slug>.html`). One file, because a prototype is a
61
+ thing to look at, not a codebase to maintain; self-contained, because it has to
62
+ open from disk with no server and no install.
63
+
64
+ Report the path, and iterate in place on that same file.
65
+
66
+ ### Step 3 — Optional: publish to a host
67
+
68
+ Host publishing is an **optional upgrade of that same file** and never the
69
+ artifact of record — the file under the temp tree stays authoritative. Publish
70
+ only when the operator asks, and keep the two identical by re-publishing the
71
+ file rather than editing a published copy.
72
+
73
+ ### Step 4 — Carry the review through to the Story
74
+
75
+ The **default carry-through is a fold into the Story's `## Spec`.** Delivery
76
+ reads the Story body and never the temp tree, so a layout that exists only as a
77
+ temp artifact is a layout delivery cannot see. Record the reviewed decisions —
78
+ surfaces, hierarchy, states, and the named tokens and components — as contract
79
+ prose in `## Spec`, and turn the observable ones into UI acceptance criteria.
80
+
81
+ **Committing a prototype is opt-in, per Story.** Ask; never default to it. A
82
+ prototype is wrong the moment the real UI ships, and a repository with a
83
+ documentation-freshness gate already carries that failure mode.
84
+
85
+ ## Constraint
86
+
87
+ - **Nothing reaches disk without a confirm.** Step 1 is a hard gate, not a
88
+ courtesy.
89
+ - **One file, under the temp tree.** Never a second artifact, never outside the
90
+ gitignored workspace-root temp tree, and never a committed prototype
91
+ directory by default.
92
+ - **Never invoked automatically.** `/plan` records the offer and proceeds with
93
+ planning; no workflow, gate, or script may call this command.
94
+ - **Read-only over the codebase.** The prototype file is the only write. Do not
95
+ edit application source, tokens, or components to make a prototype render.
96
+ - **Discovered baseline only.** No invented palette, type scale, or component
97
+ vocabulary when the project defines none.
98
+
99
+ ## See also
100
+
101
+ - [`/audit-ux-ui`](audit-ux-ui.md) — the same design-system SSOT discovery,
102
+ applied as a review lens after the UI ships.
103
+ - [`/plan`](plan.md) — where the advisory `complexitySignals.uiSurface` offer
104
+ surfaces. It names this command; it never runs it.
package/README.md CHANGED
@@ -181,10 +181,14 @@ dimensions, run model, and how to benchmark a new version.
181
181
 
182
182
  ## Contributors
183
183
 
184
- Only `.agents/` is distributed to consumersit ships inside the
185
- `mandrel` npm package and is materialized into a consumer's
186
- `./.agents/` directory by `mandrel sync`. Everything else in this
187
- repository is internal development tooling.
184
+ The published `mandrel` package ships three directories `.agents/`, `bin/`,
185
+ and `lib/` (see the `files` array in [`package.json`](package.json)).
186
+ `.agents/` is the payload `mandrel sync` materializes into a consumer's
187
+ `./.agents/` directory; `bin/mandrel.js` and its `lib/` implementation stay
188
+ inside `node_modules/mandrel/` and back the `npx mandrel …` CLI used
189
+ throughout this README. Everything else in this repository — `docs/`,
190
+ `tests/`, `.github/`, the root tooling configs — is internal development
191
+ tooling and is not published.
188
192
 
189
193
  Common commands while developing the framework itself:
190
194
 
@@ -204,8 +208,12 @@ Deeper reference material lives in `docs/` rather than inline here:
204
208
  - [`.agents/docs/workflows.md`](.agents/docs/workflows.md) — slash-command
205
209
  index (auto-generated from the workflow set).
206
210
  - [`docs/CHANGELOG.md`](docs/CHANGELOG.md) — release history.
207
- - [`AGENTS.md`](AGENTS.md) — repository onboarding, the single-package release
208
- topology, PAT / npm-token setup, and major-version policy. Releases are
211
+ - [`AGENTS.md`](AGENTS.md) — the repository-level orientation pointer; it
212
+ links on to [`docs/onboarding.md`](docs/onboarding.md) for the layout,
213
+ commands, and development standards.
214
+ - [`docs/release-operations.md`](docs/release-operations.md) — the Release
215
+ Checklist, the Install Matrix release gate, the single-package release
216
+ topology, PAT / npm-token setup, and the major-version policy. Releases are
209
217
  automated by `release-please`: land Conventional Commits on `main` and it
210
218
  opens a combined `chore: release main` PR that squash-merges itself once
211
219
  CI is green, tags `main`, and publishes `mandrel` to npm.
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.17.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.16.0...mandrel-v2.17.0) (2026-07-26)
6
+
7
+
8
+ ### Added
9
+
10
+ * **crap:** wire the baseline-refresh projection layer and add CRAP freshness + full-scope drift detection ([#4776](https://github.com/dsj1984/mandrel/issues/4776)) ([#4778](https://github.com/dsj1984/mandrel/issues/4778)) ([8c49a12](https://github.com/dsj1984/mandrel/commit/8c49a12be6ca5ecaca60d8f0bc868f47c9a02534))
11
+
12
+
13
+ ### Fixed
14
+
15
+ * **audit-to-stories:** anchor findings to their real primary file (refs [#4781](https://github.com/dsj1984/mandrel/issues/4781)) ([#4782](https://github.com/dsj1984/mandrel/issues/4782)) ([a5794c0](https://github.com/dsj1984/mandrel/commit/a5794c02c3443c39ce116699c311b4cdd8001788))
16
+ * **cli:** drain stdio before exit and settle the audit fan-out per dimension (refs [#4783](https://github.com/dsj1984/mandrel/issues/4783)) ([#4787](https://github.com/dsj1984/mandrel/issues/4787)) ([3422fd5](https://github.com/dsj1984/mandrel/commit/3422fd564773d0e79ac4d16af5bbc5414d06c65b))
17
+ * **crap:** join per-method coverage in original-source coordinates — TS scoring resolves ~5% of methods ([#4775](https://github.com/dsj1984/mandrel/issues/4775)) ([#4777](https://github.com/dsj1984/mandrel/issues/4777)) ([1464c75](https://github.com/dsj1984/mandrel/commit/1464c75c061960aaa9ee1af88175d9706b044c26))
18
+ * **deliver:** keep the terminal envelope alive after the worktree reap (refs [#4784](https://github.com/dsj1984/mandrel/issues/4784)) ([#4791](https://github.com/dsj1984/mandrel/issues/4791)) ([907b7bd](https://github.com/dsj1984/mandrel/commit/907b7bdb951a70a7155e5a9a50afdbea7a81d6a6))
19
+ * **deps:** decouple the js-yaml override from markdownlint-cli2, close Node-engine drift, and re-enable knip dependency rules ([#4784](https://github.com/dsj1984/mandrel/issues/4784)) ([#4788](https://github.com/dsj1984/mandrel/issues/4788)) ([9ed687b](https://github.com/dsj1984/mandrel/commit/9ed687bead90d3e0edf76e7d0e700b0f51e8cb5d))
20
+ * **docs:** re-sync the reference docs to the contracts the code actually implements ([#4785](https://github.com/dsj1984/mandrel/issues/4785)) ([#4790](https://github.com/dsj1984/mandrel/issues/4790)) ([00d9270](https://github.com/dsj1984/mandrel/commit/00d92708b3d3ac0978c073c5a31fd6a80a25767e))
21
+ * **docs:** supersede the stale ADR chain in place and archive the retired pattern history ([#4786](https://github.com/dsj1984/mandrel/issues/4786)) ([#4789](https://github.com/dsj1984/mandrel/issues/4789)) ([5ba2e34](https://github.com/dsj1984/mandrel/commit/5ba2e3486973b87603fd3f2f23f750f18e75e240))
22
+ * **git-cleanup:** report the refs the prune phase actually dropped (refs [#4772](https://github.com/dsj1984/mandrel/issues/4772)) ([#4773](https://github.com/dsj1984/mandrel/issues/4773)) ([16424d5](https://github.com/dsj1984/mandrel/commit/16424d53305549841b6294ceb1d7651baf1f98e7))
23
+
24
+
25
+ ### Changed
26
+
27
+ * **quality:** floor CRAP on methodsAbove20 instead of a fitted max ceiling ([#4779](https://github.com/dsj1984/mandrel/issues/4779)) ([647130b](https://github.com/dsj1984/mandrel/commit/647130b313a85add64978205d4bb300e90e3c9a3)), closes [#4775](https://github.com/dsj1984/mandrel/issues/4775)
28
+
29
+ ## [2.16.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.15.0...mandrel-v2.16.0) (2026-07-25)
30
+
31
+
32
+ ### Added
33
+
34
+ * **workflows:** add operator-invoked /prototype and an advisory uiSurface offer (refs [#4765](https://github.com/dsj1984/mandrel/issues/4765)) ([#4769](https://github.com/dsj1984/mandrel/issues/4769)) ([d3f7173](https://github.com/dsj1984/mandrel/commit/d3f71734ef8573ffa40187b5f4f8a97be6e32041))
35
+
36
+
37
+ ### Fixed
38
+
39
+ * **close-validation:** drain gate output without stalling the child's pipe (refs [#4766](https://github.com/dsj1984/mandrel/issues/4766)) ([#4770](https://github.com/dsj1984/mandrel/issues/4770)) ([fd75550](https://github.com/dsj1984/mandrel/commit/fd75550288411a4c92831ac7c9b946e2cce5a686))
40
+
41
+
42
+ ### Changed
43
+
44
+ * **routing:** scope the light path by effort and risk (refs [#4764](https://github.com/dsj1984/mandrel/issues/4764)) ([#4767](https://github.com/dsj1984/mandrel/issues/4767)) ([c4851ab](https://github.com/dsj1984/mandrel/commit/c4851abe218721145daaeb4353af400223b588e6))
45
+
5
46
  ## [2.15.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.14.0...mandrel-v2.15.0) (2026-07-25)
6
47
 
7
48