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.
- package/.agents/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-light.js +31 -3
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- 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/failed-terminal.js +122 -0
- 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 +38 -15
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-context.js +4 -1
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/update-crap-baseline.js +13 -0
- 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/README.md +14 -6
- package/docs/CHANGELOG.md +41 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -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
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* single-story-close/failed-terminal.js — the `failed` terminal a close
|
|
3
|
+
* emits when a phase crashes, and the gate reconstruction it carries.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `single-story-close.js` so the CLI entry stays an entry: it
|
|
6
|
+
* parses args, dispatches the runner, and maps a terminal onto an exit code.
|
|
7
|
+
* The reasoning about which gates had run by the time a phase died belongs
|
|
8
|
+
* with the envelope it feeds, not in the file that owns process lifetime.
|
|
9
|
+
*
|
|
10
|
+
* The runner deliberately throws rather than returning a failure (a red gate
|
|
11
|
+
* must not look like a return value), so without this the most common
|
|
12
|
+
* non-happy ending — a failing close-validation gate — would emit **no
|
|
13
|
+
* envelope at all**, exiting 1 with only a stderr line while the workflow
|
|
14
|
+
* docs promise the agent a `failed` envelope naming the phase.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Logger } from '../../Logger.js';
|
|
18
|
+
import {
|
|
19
|
+
buildTerminalEnvelope,
|
|
20
|
+
NEXT_COMMANDS,
|
|
21
|
+
} from '../story-deliver-terminal.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The close pipeline's phase order, as `setPhase` walks it. Only used to
|
|
25
|
+
* decide whether a gate had already run when a later phase died.
|
|
26
|
+
*/
|
|
27
|
+
const PHASE_ORDER = Object.freeze([
|
|
28
|
+
'init',
|
|
29
|
+
'wrong-tree-guard',
|
|
30
|
+
'close-validation',
|
|
31
|
+
'base-sync',
|
|
32
|
+
'push',
|
|
33
|
+
'pull-request',
|
|
34
|
+
'code-review',
|
|
35
|
+
'auto-merge',
|
|
36
|
+
'confirm-merge',
|
|
37
|
+
'post-land',
|
|
38
|
+
'done',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Each reported gate and the pipeline phase that decides it. */
|
|
42
|
+
const GATE_PHASES = Object.freeze([
|
|
43
|
+
['validation', 'close-validation'],
|
|
44
|
+
['baseSync', 'base-sync'],
|
|
45
|
+
['codeReview', 'code-review'],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Report every gate's outcome for a run that died at `phase`.
|
|
50
|
+
*
|
|
51
|
+
* The schema's contract: "A gate the run skipped … reports `skipped` rather
|
|
52
|
+
* than being omitted, so a missing gate is never mistaken for a passing one."
|
|
53
|
+
* The previous shape named only the gate that died and omitted the rest
|
|
54
|
+
* entirely — exactly the ambiguity the contract forbids.
|
|
55
|
+
*
|
|
56
|
+
* Reconstructed from the phase order, which is sound because the pipeline is
|
|
57
|
+
* strictly sequential: reaching phase N means every gate before it completed.
|
|
58
|
+
* A gate whose phase the run never reached is `skipped`; one the operator
|
|
59
|
+
* turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
|
|
60
|
+
* not pass — it never ran).
|
|
61
|
+
*
|
|
62
|
+
* @param {string} phase The phase the run died in.
|
|
63
|
+
* @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
|
|
64
|
+
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
65
|
+
*/
|
|
66
|
+
export function gatesForFailedPhase(phase, args = {}) {
|
|
67
|
+
const skipped = { validation: args.skipValidation, baseSync: args.skipSync };
|
|
68
|
+
const failedAt = PHASE_ORDER.indexOf(phase);
|
|
69
|
+
const gates = {};
|
|
70
|
+
for (const [gate, gatePhase] of GATE_PHASES) {
|
|
71
|
+
const at = PHASE_ORDER.indexOf(gatePhase);
|
|
72
|
+
if (gatePhase === phase) gates[gate] = 'failed';
|
|
73
|
+
else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
|
|
74
|
+
else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
|
|
75
|
+
}
|
|
76
|
+
return gates;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the `failed` terminal for a phase that crashed. Every close
|
|
81
|
+
* invocation emits exactly one envelope; this is the path that keeps that
|
|
82
|
+
* true when a phase dies.
|
|
83
|
+
*
|
|
84
|
+
* `err.closePhase` is tagged by the runner's phase tracker.
|
|
85
|
+
*
|
|
86
|
+
* **Never throws.** This runs on the path that already has one failure in
|
|
87
|
+
* hand, so a second failure here must not REPLACE the first: an
|
|
88
|
+
* envelope-build error surfacing as the run's cause sends the operator to
|
|
89
|
+
* diagnose the wrong thing entirely — a close whose PR had already merged
|
|
90
|
+
* once reported a schema `ENOENT` as its fatal error, because the worktree
|
|
91
|
+
* holding the script had been reaped mid-run. On failure this returns null
|
|
92
|
+
* and the caller rethrows the original.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} err
|
|
95
|
+
* @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
|
|
96
|
+
* Parsed CLI args — the story id the envelope reports on, plus the skip
|
|
97
|
+
* flags `gatesForFailedPhase` needs.
|
|
98
|
+
* @returns {object|null} A validated envelope, or null when even the story id
|
|
99
|
+
* is unknown (a usage error — there is nothing to report an envelope about)
|
|
100
|
+
* or the envelope itself could not be assembled.
|
|
101
|
+
*/
|
|
102
|
+
export function failedTerminalFor(err, args = {}) {
|
|
103
|
+
const phase = err?.closePhase ?? 'init';
|
|
104
|
+
const storyId = Number(args.storyId);
|
|
105
|
+
if (!Number.isInteger(storyId) || storyId <= 0) return null;
|
|
106
|
+
try {
|
|
107
|
+
return buildTerminalEnvelope({
|
|
108
|
+
storyId,
|
|
109
|
+
status: 'failed',
|
|
110
|
+
phase,
|
|
111
|
+
gates: gatesForFailedPhase(phase, args),
|
|
112
|
+
failure: { reason: String(err?.message ?? err) },
|
|
113
|
+
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
114
|
+
elapsedSeconds: 0,
|
|
115
|
+
});
|
|
116
|
+
} catch (buildErr) {
|
|
117
|
+
Logger.error(
|
|
118
|
+
`[single-story-close] ⚠️ Could not assemble the failed terminal envelope: ${buildErr?.message ?? buildErr}. Reporting the original failure instead.`,
|
|
119
|
+
);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -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
|
}
|
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
* needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
|
|
34
34
|
* live streaming.
|
|
35
35
|
*
|
|
36
|
+
* Projection advisories (Story #4776). `baseBranch`, `storyBranch` and the
|
|
37
|
+
* resolved `config` are forwarded to `runCloseValidation` so its projection
|
|
38
|
+
* phase can run. They surface, after the gates pass, which committed
|
|
39
|
+
* baseline rows the post-merge tree would breach and the exact
|
|
40
|
+
* `*:update` + `baseline-refresh:` remedy — advisory only, so the close
|
|
41
|
+
* verdict is unchanged.
|
|
42
|
+
*
|
|
36
43
|
* `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
|
|
37
44
|
* are accepted as injected dependencies so the parent CLI's cache-busted
|
|
38
45
|
* bindings win in tests that mock the upstream module URLs.
|
|
@@ -137,22 +144,38 @@ export async function runCloseValidationPhase({
|
|
|
137
144
|
// Story #4736 — one sink for both `log` seams (gate construction and gate
|
|
138
145
|
// execution), so nothing in the chain can route around the artifact.
|
|
139
146
|
const gateLog = createGateLogSink({ storyId, cwd });
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
let validation;
|
|
148
|
+
try {
|
|
149
|
+
validation = await runCloseValidation({
|
|
150
|
+
cwd,
|
|
151
|
+
worktreePath,
|
|
152
|
+
gates: buildDefaultGates({
|
|
153
|
+
config,
|
|
154
|
+
baseBranch,
|
|
155
|
+
cwd: worktreePath || cwd,
|
|
156
|
+
log: gateLog.log,
|
|
157
|
+
}),
|
|
147
158
|
log: gateLog.log,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
storyId,
|
|
160
|
+
// Story #4250 — standalone storyId-anchored evidence keyspace. No
|
|
161
|
+
// epicId; the standalone flag routes the cache to
|
|
162
|
+
// temp/standalone/stories/story-<id>/validation-evidence.json.
|
|
163
|
+
standalone: true,
|
|
164
|
+
// Story #4776 — the branch pair and resolved config the advisory
|
|
165
|
+
// projections need. Without them the runner skips the projection
|
|
166
|
+
// phase entirely, which is the correct behaviour for resume/legacy
|
|
167
|
+
// callers that have no story branch to diff.
|
|
168
|
+
baseBranch,
|
|
169
|
+
storyBranch,
|
|
170
|
+
config,
|
|
171
|
+
});
|
|
172
|
+
} finally {
|
|
173
|
+
// Story #4766 — gate lines are buffered to an async stream so the drain
|
|
174
|
+
// never blocks a gate child's pipe. Settle the artifact before anything
|
|
175
|
+
// reads it, replays from it, or reports its path — including on the throw
|
|
176
|
+
// path, where the artifact is the only surviving record.
|
|
177
|
+
await gateLog.flush();
|
|
178
|
+
}
|
|
156
179
|
if (!validation.ok) {
|
|
157
180
|
const [first] = validation.failed;
|
|
158
181
|
const { gate, status, cwd: gateCwd } = first;
|