mandrel 2.15.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.
@@ -18,6 +18,11 @@
18
18
  */
19
19
 
20
20
  import { readFile } from 'node:fs/promises';
21
+ import { readAuditRulesSync } from '../audit-suite/audit-rules-reader.js';
22
+ import {
23
+ hasWebSurface,
24
+ matchesAnyFilePattern,
25
+ } from '../audit-suite/selector.js';
21
26
  import { getLimits } from '../config-resolver.js';
22
27
  import { findSimilarOpenStories } from '../duplicate-search.js';
23
28
  import { Logger } from '../Logger.js';
@@ -449,18 +454,189 @@ export function buildDeliverLightSuggestion(complexitySignals) {
449
454
  }
450
455
 
451
456
  /**
452
- * Attach the advisory light-path suggestion to a complexity-signals bag
453
- * as a **nested** field (Story #4741). Nesting — rather than a new top-level
454
- * envelope key — keeps every existing per-mode envelope key set byte-stable
455
- * (AC-5): the suggestion is derived from the signals it rides on.
457
+ * The `audit-rules.json` lens `target` value marking a lens applicable only to
458
+ * a project with a rendered frontend.
459
+ */
460
+ const WEB_LENS_TARGET = 'web';
461
+
462
+ /**
463
+ * How many matched UI paths the `uiSurface` signal carries. The signal rides the
464
+ * `--out` stdout digest, which has a ~2KB contract, and a seed can predict up to
465
+ * `MAX_PREDICTED_PATHS` paths — enumerating all of them would let one UI-heavy
466
+ * seed blow that budget. The full count travels beside the sample as
467
+ * `matchedPathCount`, so nothing is silently lost.
468
+ */
469
+ const UI_MATCHED_PATH_SAMPLE = 5;
470
+
471
+ /**
472
+ * Union of the `triggers.filePatterns` globs every `target: "web"` lens
473
+ * registers in `audit-rules.json` — the framework's shipped declaration of
474
+ * "this path is part of a rendered UI surface". Read from the manifest rather
475
+ * than re-listed here: a second copy of the glob set would be a second thing to
476
+ * keep in sync, and the manifest is already the place an operator extends it.
477
+ *
478
+ * @param {{ audits?: Record<string, object> }} rules
479
+ * @returns {string[]} Deduplicated globs, in manifest order.
480
+ */
481
+ function resolveWebFilePatterns(rules) {
482
+ const patterns = new Set();
483
+ for (const entry of Object.values(rules?.audits ?? {})) {
484
+ if (entry?.target !== WEB_LENS_TARGET) continue;
485
+ for (const glob of entry?.triggers?.filePatterns ?? []) {
486
+ if (typeof glob === 'string' && glob !== '') patterns.add(glob);
487
+ }
488
+ }
489
+ return [...patterns];
490
+ }
491
+
492
+ /**
493
+ * Which predicted paths sit on a UI surface, per the web lens globs.
494
+ *
495
+ * An unreadable manifest is **indeterminate**, not "no match": the signal fails
496
+ * OPEN in the same direction {@link hasWebSurface} does, because a spurious
497
+ * mention of an operator-invoked command costs nothing while a missed one costs
498
+ * the whole point of the offer.
499
+ *
500
+ * @param {string[]} predictedPaths
501
+ * @returns {{ matchedPaths: string[], indeterminate: boolean }}
502
+ */
503
+ function resolveWebFootprintMatch(predictedPaths) {
504
+ let patterns;
505
+ try {
506
+ patterns = resolveWebFilePatterns(readAuditRulesSync());
507
+ } catch {
508
+ return { matchedPaths: [], indeterminate: true };
509
+ }
510
+ return {
511
+ matchedPaths: predictedPaths.filter((p) =>
512
+ matchesAnyFilePattern(patterns, [p]),
513
+ ),
514
+ indeterminate: false,
515
+ };
516
+ }
517
+
518
+ /**
519
+ * The one sentence a `uiSurface` signal carries — why the offer fires, or why it
520
+ * does not. Kept in one place so the fired and unfired shapes stay one object.
521
+ *
522
+ * @param {{
523
+ * detected: boolean,
524
+ * webSurface: boolean,
525
+ * indeterminate: boolean,
526
+ * sample: string[],
527
+ * count: number,
528
+ * }} facts
529
+ * @returns {string}
530
+ */
531
+ function uiSurfaceReason({
532
+ detected,
533
+ webSurface,
534
+ indeterminate,
535
+ sample,
536
+ count,
537
+ }) {
538
+ if (!detected) {
539
+ return webSurface
540
+ ? 'no predicted path matches a web lens filePattern — nothing to prototype'
541
+ : 'project has no rendered web surface — nothing to prototype';
542
+ }
543
+ if (indeterminate) {
544
+ return 'web-capable project and the UI-path manifest could not be read — offering /prototype rather than dropping the option';
545
+ }
546
+ const elided = count - sample.length;
547
+ const shown =
548
+ elided > 0 ? `${sample.join(', ')}, +${elided} more` : sample.join(', ');
549
+ return `web-capable project and the predicted footprint touches ${count} UI path(s) (${shown}) — the operator may want /prototype before UI acceptance criteria are authored`;
550
+ }
551
+
552
+ /**
553
+ * Derive the advisory **UI-surface** signal from a seed's predicted footprint.
554
+ *
555
+ * Two observables, both already shipped, ANDed together:
556
+ *
557
+ * 1. the project is web-capable at all (`hasWebSurface` — the same
558
+ * applicability predicate the `target: "web"` audit lenses gate on), and
559
+ * 2. at least one predicted path matches a web lens `filePattern`.
560
+ *
561
+ * No new detection surface and no new `.agentrc.json` key: both halves are
562
+ * derived from the consumer's own checkout, so a frontend-less project — this
563
+ * repository included — resolves falsey and the offer never fires.
564
+ *
565
+ * The signal carries **no routing authority** (`automatic: false`): `/plan`
566
+ * may say that a plan touches UI and that `/prototype` exists, and must never
567
+ * invoke it. Pure over its inputs and total — a malformed signal bag or an
568
+ * unreadable manifest degrades, never throws.
569
+ *
570
+ * @param {{
571
+ * complexitySignals?: object|null,
572
+ * config?: object,
573
+ * cwd?: string,
574
+ * }} [args]
575
+ * @returns {{
576
+ * detected: boolean,
577
+ * automatic: false,
578
+ * advisory: true,
579
+ * webSurface: boolean,
580
+ * matchedPaths: string[],
581
+ * matchedPathCount: number,
582
+ * reasons: string[],
583
+ * }} `matchedPaths` is a bounded sample
584
+ * ({@link UI_MATCHED_PATH_SAMPLE}); `matchedPathCount` is the full total.
585
+ */
586
+ function buildUiSurfaceSignal({ complexitySignals, config, cwd } = {}) {
587
+ const predictedPaths = Array.isArray(complexitySignals?.predictedPaths)
588
+ ? complexitySignals.predictedPaths.filter((p) => typeof p === 'string')
589
+ : [];
590
+ const projectRoot =
591
+ typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd();
592
+
593
+ let webSurface;
594
+ try {
595
+ webSurface = hasWebSurface({ config, projectRoot });
596
+ } catch {
597
+ webSurface = true; // indeterminate ⇒ fail open
598
+ }
599
+
600
+ const { matchedPaths, indeterminate } =
601
+ resolveWebFootprintMatch(predictedPaths);
602
+ const detected = webSurface && (indeterminate || matchedPaths.length > 0);
603
+ const sample = matchedPaths.slice(0, UI_MATCHED_PATH_SAMPLE);
604
+
605
+ return {
606
+ detected,
607
+ automatic: /** @type {const} */ (false),
608
+ advisory: /** @type {const} */ (true),
609
+ webSurface,
610
+ matchedPaths: sample,
611
+ matchedPathCount: matchedPaths.length,
612
+ reasons: [
613
+ uiSurfaceReason({
614
+ detected,
615
+ webSurface,
616
+ indeterminate,
617
+ sample,
618
+ count: matchedPaths.length,
619
+ }),
620
+ ],
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Attach the advisory routing/offer signals to a complexity-signals bag as
626
+ * **nested** fields (Story #4741). Nesting — rather than new top-level envelope
627
+ * keys — keeps every existing per-mode envelope key set byte-stable: both are
628
+ * derived from the signals they ride on.
456
629
  *
457
630
  * @param {object} complexitySignals
458
- * @returns {object} the same signals plus `deliverLightSuggestion`.
631
+ * @param {{ config?: object, cwd?: string }} [context]
632
+ * @returns {object} the same signals plus `deliverLightSuggestion` and
633
+ * `uiSurface`.
459
634
  */
460
- function withDeliverLightSuggestion(complexitySignals) {
635
+ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
461
636
  return {
462
637
  ...complexitySignals,
463
638
  deliverLightSuggestion: buildDeliverLightSuggestion(complexitySignals),
639
+ uiSurface: buildUiSurfaceSignal({ complexitySignals, config, cwd }),
464
640
  };
465
641
  }
466
642
 
@@ -701,14 +877,16 @@ async function buildSeedFileModeEnvelope({
701
877
  // authority. The planner authors the trivial-vs-standard verdict; persist
702
878
  // validates a lite claim against the authored Story's shape. The nested
703
879
  // `deliverLightSuggestion` is the advisory plan-side routing handshake
704
- // (Story #4741 AC-6) never an automatic reroute.
705
- complexitySignals: withDeliverLightSuggestion(
880
+ // (Story #4741 AC-6) and `uiSurface` the advisory /prototype offer —
881
+ // neither is ever an automatic reroute.
882
+ complexitySignals: withAdvisorySignals(
706
883
  buildComplexitySignals({
707
884
  seedText: content,
708
885
  config,
709
886
  riskHeuristics: heuristics,
710
887
  cwd,
711
888
  }),
889
+ { config, cwd },
712
890
  ),
713
891
  duplicates,
714
892
  docsContext,
@@ -863,13 +1041,14 @@ async function buildTicketsModeEnvelope({
863
1041
  mode: 'tickets',
864
1042
  sourceTickets,
865
1043
  seed: { text: seed, path: null },
866
- complexitySignals: withDeliverLightSuggestion(
1044
+ complexitySignals: withAdvisorySignals(
867
1045
  buildComplexitySignals({
868
1046
  seedText: seed,
869
1047
  config,
870
1048
  riskHeuristics: heuristics,
871
1049
  cwd,
872
1050
  }),
1051
+ { config, cwd },
873
1052
  ),
874
1053
  duplicates,
875
1054
  docsContext,
@@ -988,13 +1167,14 @@ async function buildAmendmentModeEnvelope({
988
1167
  },
989
1168
  // The prior body is the seed the delta is authored against.
990
1169
  seed: { text: priorBody, path: null },
991
- complexitySignals: withDeliverLightSuggestion(
1170
+ complexitySignals: withAdvisorySignals(
992
1171
  buildComplexitySignals({
993
1172
  seedText: priorBody,
994
1173
  config,
995
1174
  riskHeuristics: heuristics,
996
1175
  cwd,
997
1176
  }),
1177
+ { config, cwd },
998
1178
  ),
999
1179
  duplicates,
1000
1180
  // No plan temp dir and no from-scratch repo interrogation — the prior
@@ -36,6 +36,24 @@
36
36
  * The sink never throws. A log directory that cannot be written degrades to
37
37
  * inline streaming — losing the size bound is strictly better than losing the
38
38
  * gate output that says why a close failed.
39
+ *
40
+ * ## Why the artifact is written asynchronously (Story #4766)
41
+ *
42
+ * This sink is the `log` callable that `close-validation/process.js` invokes
43
+ * from inside the gate child's stdout/stderr `'data'` handler — once per line.
44
+ * The first cut wrote each line with `fs.writeSync`, which blocks the event
45
+ * loop while the child keeps writing: the OS pipe buffer fills, the child's
46
+ * write fails with `EAGAIN`, and a child that does not tolerate that dies. On
47
+ * a clean `main` `biome ci .` already emits ~625 lines, and it aborts with
48
+ * exit 101 (a `biome_console` panic, not a lint violation) when it happens —
49
+ * so the first gate of any close could die on plumbing while its verdict was
50
+ * green.
51
+ *
52
+ * So the write path buffers into an async stream instead: per-line work is
53
+ * O(1) and never touches a syscall on the drain path. The cost is that the
54
+ * artifact is not on disk the instant a line is logged, which is why the sink
55
+ * exposes {@link GateLogSink#flush} — callers await it before reading,
56
+ * replaying, or naming the artifact as final.
39
57
  */
40
58
 
41
59
  import nodeFs from 'node:fs';
@@ -65,9 +83,9 @@ function logNameFor(storyId) {
65
83
  */
66
84
  class GateLogSink {
67
85
  /**
68
- * @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, emit: (line: string) => void }} args
86
+ * @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, flush?: () => Promise<void>, emit: (line: string) => void }} args
69
87
  */
70
- constructor({ logPath, streamInline, write, emit }) {
88
+ constructor({ logPath, streamInline, write, flush, emit }) {
71
89
  /** Absolute path of the artifact, or `null` when capture is unavailable. */
72
90
  this.logPath = logPath;
73
91
  /** Whether lines are ALSO echoed inline as they arrive. */
@@ -75,6 +93,7 @@ class GateLogSink {
75
93
  /** Number of lines captured so far. */
76
94
  this.lineCount = 0;
77
95
  this._write = write;
96
+ this._flush = flush ?? (() => Promise.resolve());
78
97
  this._emit = emit;
79
98
  this._tail = [];
80
99
  }
@@ -96,6 +115,19 @@ class GateLogSink {
96
115
  };
97
116
  }
98
117
 
118
+ /**
119
+ * Settle the artifact: wait for every buffered line to reach disk and close
120
+ * the file. Idempotent, never throws, and a no-op on the degraded (no
121
+ * artifact) path. Await it before reading {@link GateLogSink#logPath} or
122
+ * handing the path to anyone — the write path is async precisely so it never
123
+ * stalls a gate child's pipe.
124
+ *
125
+ * @returns {Promise<void>}
126
+ */
127
+ flush() {
128
+ return this._flush();
129
+ }
130
+
99
131
  /**
100
132
  * The success-path digest: one line, no gate output. Names the artifact so
101
133
  * the caller can open it on demand rather than carrying it all session.
@@ -129,6 +161,50 @@ class GateLogSink {
129
161
  }
130
162
  }
131
163
 
164
+ /**
165
+ * Wrap an already-open artifact fd in a non-blocking line writer.
166
+ *
167
+ * `write` hands the line to a `fs.WriteStream` — O(1), no syscall on the
168
+ * caller's stack — and `flush` ends the stream, resolving once every buffered
169
+ * line has reached disk (or the stream has errored; a half-written artifact is
170
+ * still better than a dead close). Both are best-effort by construction: the
171
+ * stream's `'error'` is absorbed, so nothing here can abort a close.
172
+ *
173
+ * @param {typeof nodeFs} fs
174
+ * @param {string} logPath
175
+ * @param {number} handle
176
+ * @returns {{ write: (line: string) => void, flush: () => Promise<void> }}
177
+ */
178
+ function createArtifactWriter(fs, logPath, handle) {
179
+ const stream = fs.createWriteStream(logPath, { fd: handle, autoClose: true });
180
+ stream.on('error', () => {
181
+ /* best-effort: a mid-run write failure must not abort the close */
182
+ });
183
+ let ending = null;
184
+ return {
185
+ write: (line) => {
186
+ if (ending) return;
187
+ try {
188
+ stream.write(`${line}\n`);
189
+ } catch {
190
+ /* best-effort: see above */
191
+ }
192
+ },
193
+ flush: () => {
194
+ ending ??= new Promise((resolve) => {
195
+ const settle = () => resolve();
196
+ stream.once('error', settle);
197
+ try {
198
+ stream.end(settle);
199
+ } catch {
200
+ settle();
201
+ }
202
+ });
203
+ return ending;
204
+ },
205
+ };
206
+ }
207
+
132
208
  /**
133
209
  * Build the gate-output sink for one close run.
134
210
  *
@@ -155,14 +231,14 @@ export function createGateLogSink({
155
231
  const verbose = (level ?? resolveLevel()) === 'verbose';
156
232
  const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
157
233
 
158
- let handle = null;
234
+ let writer = null;
159
235
  let logPath = null;
160
236
  try {
161
237
  fs.mkdirSync(dir, { recursive: true });
162
238
  logPath = path.join(dir, logNameFor(storyId));
163
239
  // Truncate: each close run owns its artifact outright, so a re-run never
164
240
  // hands the reader a file interleaving two runs' gates.
165
- handle = fs.openSync(logPath, 'w');
241
+ writer = createArtifactWriter(fs, logPath, fs.openSync(logPath, 'w'));
166
242
  } catch {
167
243
  // No artifact — fall back to inline streaming rather than dropping the
168
244
  // gate output on the floor.
@@ -174,13 +250,11 @@ export function createGateLogSink({
174
250
  });
175
251
  }
176
252
 
177
- const write = (line) => {
178
- try {
179
- fs.writeSync(handle, `${line}\n`);
180
- } catch {
181
- /* best-effort: a mid-run write failure must not abort the close */
182
- }
183
- };
184
-
185
- return new GateLogSink({ logPath, streamInline: verbose, write, emit });
253
+ return new GateLogSink({
254
+ logPath,
255
+ streamInline: verbose,
256
+ write: writer.write,
257
+ flush: writer.flush,
258
+ emit,
259
+ });
186
260
  }
@@ -137,22 +137,31 @@ export async function runCloseValidationPhase({
137
137
  // Story #4736 — one sink for both `log` seams (gate construction and gate
138
138
  // execution), so nothing in the chain can route around the artifact.
139
139
  const gateLog = createGateLogSink({ storyId, cwd });
140
- const validation = await runCloseValidation({
141
- cwd,
142
- worktreePath,
143
- gates: buildDefaultGates({
144
- config,
145
- baseBranch,
146
- cwd: worktreePath || cwd,
140
+ let validation;
141
+ try {
142
+ validation = await runCloseValidation({
143
+ cwd,
144
+ worktreePath,
145
+ gates: buildDefaultGates({
146
+ config,
147
+ baseBranch,
148
+ cwd: worktreePath || cwd,
149
+ log: gateLog.log,
150
+ }),
147
151
  log: gateLog.log,
148
- }),
149
- log: gateLog.log,
150
- storyId,
151
- // Story #4250 — standalone storyId-anchored evidence keyspace. No
152
- // epicId; the standalone flag routes the cache to
153
- // temp/standalone/stories/story-<id>/validation-evidence.json.
154
- standalone: true,
155
- });
152
+ storyId,
153
+ // Story #4250 — standalone storyId-anchored evidence keyspace. No
154
+ // epicId; the standalone flag routes the cache to
155
+ // temp/standalone/stories/story-<id>/validation-evidence.json.
156
+ standalone: true,
157
+ });
158
+ } finally {
159
+ // Story #4766 — gate lines are buffered to an async stream so the drain
160
+ // never blocks a gate child's pipe. Settle the artifact before anything
161
+ // reads it, replays from it, or reports its path — including on the throw
162
+ // path, where the artifact is the only surviving record.
163
+ await gateLog.flush();
164
+ }
156
165
  if (!validation.ok) {
157
166
  const [first] = validation.failed;
158
167
  const { gate, status, cwd: gateCwd } = first;
@@ -167,7 +167,9 @@ export async function emitPlanContext({
167
167
  // Advisory only (Story #4722): signals, no route — the planner owns
168
168
  // the trivial-vs-standard verdict and persist validates it by shape.
169
169
  // The nested `deliverLightSuggestion` is the recorded plan-side routing
170
- // handshake (Story #4741 AC-6) advisory, never an automatic reroute.
170
+ // handshake (Story #4741 AC-6) and `uiSurface` the recorded /prototype
171
+ // offer — advisory, never an automatic reroute. Both ride the digest
172
+ // because with `--out` the digest is the only thing the planner reads.
171
173
  complexitySignals: envelope.complexitySignals
172
174
  ? {
173
175
  artifactCount: envelope.complexitySignals.artifactCount,
@@ -176,6 +178,7 @@ export async function emitPlanContext({
176
178
  envelope.complexitySignals.sensitivePathClasses,
177
179
  deliverLightSuggestion:
178
180
  envelope.complexitySignals.deliverLightSuggestion ?? null,
181
+ uiSurface: envelope.complexitySignals.uiSurface ?? null,
179
182
  }
180
183
  : null,
181
184
  amends: envelope.amends ? { id: envelope.amends.id } : null,
@@ -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