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.
- package/.agents/docs/workflows.md +2 -1
- package/.agents/scripts/deliver-light.js +31 -3
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +24 -15
- package/.agents/scripts/plan-context.js +4 -1
- package/.agents/workflows/helpers/deliver-light.md +34 -8
- package/.agents/workflows/helpers/plan-reference.md +27 -6
- package/.agents/workflows/plan.md +4 -2
- package/.agents/workflows/prototype.md +104 -0
- package/docs/CHANGELOG.md +17 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
453
|
-
*
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
* @
|
|
631
|
+
* @param {{ config?: object, cwd?: string }} [context]
|
|
632
|
+
* @returns {object} the same signals plus `deliverLightSuggestion` and
|
|
633
|
+
* `uiSurface`.
|
|
459
634
|
*/
|
|
460
|
-
function
|
|
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)
|
|
705
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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)
|
|
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
|
|
31
|
-
predicted *
|
|
32
|
-
|
|
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
|
|
38
|
-
ledgered model verdict with a recorded reason. Both must agree on
|
|
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)
|
|
54
|
-
|
|
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
|
|
53
|
-
(`STORY_SHAPE_CEILINGS` —
|
|
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 —
|
|
89
|
-
|
|
90
|
-
against the framework `STORY_SHAPE_CEILINGS`
|
|
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)).
|
|
160
|
-
command — never hand-delete issues.
|
|
162
|
+
[detail](helpers/plan-reference.md)).
|
|
161
163
|
|
|
162
164
|
## Constraints
|
|
163
165
|
|