mandrel 2.14.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/audit-checklists/navigability.md +1 -1
- package/.agents/docs/workflows.md +4 -4
- package/.agents/scripts/acceptance-eval.js +18 -1
- package/.agents/scripts/agents-bootstrap-github.js +22 -1
- package/.agents/scripts/apply-quality-bootstrap.js +6 -0
- package/.agents/scripts/audit-labels-bootstrap.js +15 -1
- package/.agents/scripts/audit-to-stories.js +26 -1
- package/.agents/scripts/boot-sweep.js +4 -1
- package/.agents/scripts/bootstrap.js +1 -0
- package/.agents/scripts/check-arch-cycles.js +20 -0
- package/.agents/scripts/check-baselines.js +8 -2
- package/.agents/scripts/check-context-budget.js +40 -5
- package/.agents/scripts/check-dead-exports.js +21 -0
- package/.agents/scripts/check-doc-links.js +12 -1
- package/.agents/scripts/check-lifecycle-doc-drift.js +9 -0
- package/.agents/scripts/check-workflow-citations.js +332 -0
- package/.agents/scripts/deliver-light.js +32 -3
- package/.agents/scripts/deliver-recover.js +4 -1
- package/.agents/scripts/diagnose-friction.js +17 -1
- package/.agents/scripts/diagnose.js +20 -14
- package/.agents/scripts/drain-pending-cleanup.js +20 -1
- package/.agents/scripts/evidence-gate.js +20 -1
- package/.agents/scripts/generate-config-docs.js +14 -1
- package/.agents/scripts/generate-lifecycle-docs.js +14 -1
- package/.agents/scripts/generate-workflows-doc.js +14 -1
- package/.agents/scripts/git-cleanup.js +32 -1
- package/.agents/scripts/lib/cli-usage.js +174 -0
- package/.agents/scripts/lib/cli-utils.js +12 -0
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/doc-tiers.js +53 -10
- 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 +205 -20
- 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/lib/workflow-closure.js +431 -0
- package/.agents/scripts/mandrel-update-preflight.js +9 -0
- package/.agents/scripts/nav-registry-diff.js +13 -0
- package/.agents/scripts/plan-context.js +21 -2
- package/.agents/scripts/plan-critics.js +10 -0
- package/.agents/scripts/plan-persist.js +33 -1
- package/.agents/scripts/plan-run-epilogue.js +12 -1
- package/.agents/scripts/quality-preview.js +17 -1
- package/.agents/scripts/resolve-doc-tiers.js +13 -0
- package/.agents/scripts/resolve-stories.js +1 -0
- package/.agents/scripts/resync-status-column.js +4 -1
- package/.agents/scripts/signals-view.js +11 -0
- package/.agents/scripts/single-story-close.js +24 -0
- package/.agents/scripts/single-story-confirm-merge.js +16 -0
- package/.agents/scripts/single-story-init.js +21 -1
- package/.agents/scripts/stories-wave-tick.js +1 -0
- package/.agents/scripts/sync-agentrc.js +16 -4
- package/.agents/scripts/update-ticket-state.js +23 -2
- package/.agents/workflows/audit-navigability.md +2 -2
- package/.agents/workflows/audit-to-stories.md +1 -1
- package/.agents/workflows/deliver.md +80 -81
- package/.agents/workflows/git-cleanup.md +9 -14
- package/.agents/workflows/helpers/acceptance-self-eval.md +14 -13
- package/.agents/workflows/helpers/audit-lens-core.md +2 -2
- package/.agents/workflows/helpers/code-review.md +11 -11
- package/.agents/workflows/helpers/deliver-digest.md +7 -8
- package/.agents/workflows/helpers/deliver-light.md +222 -0
- package/.agents/workflows/helpers/deliver-reference.md +46 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +55 -63
- package/.agents/workflows/helpers/deliver-story.md +22 -22
- package/.agents/workflows/helpers/mandrel-sync-config.md +2 -2
- package/.agents/workflows/helpers/parallel-tooling.md +1 -2
- package/.agents/workflows/helpers/plan-reference.md +96 -17
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
- package/.agents/workflows/mandrel-update.md +6 -6
- package/.agents/workflows/plan.md +90 -85
- package/.agents/workflows/prototype.md +104 -0
- package/docs/CHANGELOG.md +32 -0
- package/package.json +2 -1
- package/.agents/workflows/deliver-light.md +0 -148
|
@@ -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';
|
|
@@ -356,7 +361,7 @@ function resolveRiskHeuristics(config = {}) {
|
|
|
356
361
|
|
|
357
362
|
/**
|
|
358
363
|
* Ceilings a seed's advisory complexity signals must fit for the plan
|
|
359
|
-
* workflow to **suggest**
|
|
364
|
+
* workflow to **suggest** the light path at Gate #1 (Story #4741 R3 plan-side
|
|
360
365
|
* handshake). Framework constants, not operator knobs — mirroring the
|
|
361
366
|
* conservative intent of `complexity-gate.js`'s `STORY_SHAPE_CEILINGS`
|
|
362
367
|
* (small, mostly-additive, non-sensitive) but read against the *seed-time*
|
|
@@ -364,9 +369,14 @@ function resolveRiskHeuristics(config = {}) {
|
|
|
364
369
|
*
|
|
365
370
|
* The suggestion is **advisory only and never an automatic reroute**: it
|
|
366
371
|
* surfaces at Gate #1 for the operator to decide, and under `--yes` it is
|
|
367
|
-
* recorded on the envelope while planning proceeds unchanged.
|
|
368
|
-
*
|
|
369
|
-
*
|
|
372
|
+
* recorded on the envelope while planning proceeds unchanged.
|
|
373
|
+
*
|
|
374
|
+
* These ceilings are deliberately NOT the ones the light path itself applies
|
|
375
|
+
* (Story #4760). A confirmed suggestion routes into
|
|
376
|
+
* `workflows/helpers/deliver-light.md`, whose gate re-judges the *predicted
|
|
377
|
+
* shape* against `STORY_SHAPE_CEILINGS`. Two checks at two different stages:
|
|
378
|
+
* this one screens a seed, that one decides. Collapsing them would make a
|
|
379
|
+
* confirm a bypass.
|
|
370
380
|
*
|
|
371
381
|
* - `maxArtifacts` — enumerated seed items (one artifact each).
|
|
372
382
|
* - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
|
|
@@ -381,9 +391,9 @@ const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
|
|
|
381
391
|
});
|
|
382
392
|
|
|
383
393
|
/**
|
|
384
|
-
* Derive the advisory
|
|
385
|
-
*
|
|
386
|
-
*
|
|
394
|
+
* Derive the advisory light-path suggestion from a seed's complexity signals
|
|
395
|
+
* (Story #4741 AC-6). Pure and total: a malformed / missing signal bag fails
|
|
396
|
+
* conservative (not suggested), never throws.
|
|
387
397
|
*
|
|
388
398
|
* `automatic: false` is part of the contract — the suggestion is surfaced for
|
|
389
399
|
* the operator, never a silent reroute of a non-interactive run.
|
|
@@ -435,27 +445,198 @@ export function buildDeliverLightSuggestion(complexitySignals) {
|
|
|
435
445
|
ceilings,
|
|
436
446
|
reasons: suggested
|
|
437
447
|
? [
|
|
438
|
-
`seed fits the
|
|
448
|
+
`seed fits the light-path ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
|
|
439
449
|
'no risk-heuristic hits, no sensitive-path classes) — the operator ' +
|
|
440
|
-
'may prefer /deliver
|
|
450
|
+
'may prefer /deliver for this scope',
|
|
441
451
|
]
|
|
442
452
|
: reasons,
|
|
443
453
|
};
|
|
444
454
|
}
|
|
445
455
|
|
|
446
456
|
/**
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
|
|
450
|
-
|
|
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.
|
|
451
629
|
*
|
|
452
630
|
* @param {object} complexitySignals
|
|
453
|
-
* @
|
|
631
|
+
* @param {{ config?: object, cwd?: string }} [context]
|
|
632
|
+
* @returns {object} the same signals plus `deliverLightSuggestion` and
|
|
633
|
+
* `uiSurface`.
|
|
454
634
|
*/
|
|
455
|
-
function
|
|
635
|
+
function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
|
|
456
636
|
return {
|
|
457
637
|
...complexitySignals,
|
|
458
638
|
deliverLightSuggestion: buildDeliverLightSuggestion(complexitySignals),
|
|
639
|
+
uiSurface: buildUiSurfaceSignal({ complexitySignals, config, cwd }),
|
|
459
640
|
};
|
|
460
641
|
}
|
|
461
642
|
|
|
@@ -696,14 +877,16 @@ async function buildSeedFileModeEnvelope({
|
|
|
696
877
|
// authority. The planner authors the trivial-vs-standard verdict; persist
|
|
697
878
|
// validates a lite claim against the authored Story's shape. The nested
|
|
698
879
|
// `deliverLightSuggestion` is the advisory plan-side routing handshake
|
|
699
|
-
// (Story #4741 AC-6)
|
|
700
|
-
|
|
880
|
+
// (Story #4741 AC-6) and `uiSurface` the advisory /prototype offer —
|
|
881
|
+
// neither is ever an automatic reroute.
|
|
882
|
+
complexitySignals: withAdvisorySignals(
|
|
701
883
|
buildComplexitySignals({
|
|
702
884
|
seedText: content,
|
|
703
885
|
config,
|
|
704
886
|
riskHeuristics: heuristics,
|
|
705
887
|
cwd,
|
|
706
888
|
}),
|
|
889
|
+
{ config, cwd },
|
|
707
890
|
),
|
|
708
891
|
duplicates,
|
|
709
892
|
docsContext,
|
|
@@ -858,13 +1041,14 @@ async function buildTicketsModeEnvelope({
|
|
|
858
1041
|
mode: 'tickets',
|
|
859
1042
|
sourceTickets,
|
|
860
1043
|
seed: { text: seed, path: null },
|
|
861
|
-
complexitySignals:
|
|
1044
|
+
complexitySignals: withAdvisorySignals(
|
|
862
1045
|
buildComplexitySignals({
|
|
863
1046
|
seedText: seed,
|
|
864
1047
|
config,
|
|
865
1048
|
riskHeuristics: heuristics,
|
|
866
1049
|
cwd,
|
|
867
1050
|
}),
|
|
1051
|
+
{ config, cwd },
|
|
868
1052
|
),
|
|
869
1053
|
duplicates,
|
|
870
1054
|
docsContext,
|
|
@@ -920,7 +1104,7 @@ function extractPriorArtifacts(priorBody) {
|
|
|
920
1104
|
/**
|
|
921
1105
|
* Build the amendment (delta) envelope — `plan-context --amends #<id>`
|
|
922
1106
|
* (Story #4741 AC-4, R3-A). The heavy-amendment counterpart to routing a
|
|
923
|
-
* light amendment through
|
|
1107
|
+
* light amendment through the light path: instead of re-interrogating the
|
|
924
1108
|
* repo from scratch (`buildAuthoringContext`'s codebase snapshot and the BDD /
|
|
925
1109
|
* memory / feedback probes), the envelope composes a DELTA from what already
|
|
926
1110
|
* exists — the prior Story's body, its acceptance criteria (the real
|
|
@@ -983,13 +1167,14 @@ async function buildAmendmentModeEnvelope({
|
|
|
983
1167
|
},
|
|
984
1168
|
// The prior body is the seed the delta is authored against.
|
|
985
1169
|
seed: { text: priorBody, path: null },
|
|
986
|
-
complexitySignals:
|
|
1170
|
+
complexitySignals: withAdvisorySignals(
|
|
987
1171
|
buildComplexitySignals({
|
|
988
1172
|
seedText: priorBody,
|
|
989
1173
|
config,
|
|
990
1174
|
riskHeuristics: heuristics,
|
|
991
1175
|
cwd,
|
|
992
1176
|
}),
|
|
1177
|
+
{ config, cwd },
|
|
993
1178
|
),
|
|
994
1179
|
duplicates,
|
|
995
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;
|