mandrel 2.11.0 → 2.13.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/rules/orchestration-error-handling.md +9 -1
- package/.agents/scripts/deliver-light.js +385 -0
- package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -26
- package/.agents/scripts/lib/orchestration/complexity-gate.js +68 -17
- package/.agents/scripts/lib/orchestration/light-suitability.js +439 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +256 -15
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +6 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
- package/.agents/scripts/plan-context.js +45 -3
- package/.agents/scripts/plan-persist.js +106 -9
- package/.agents/workflows/deliver-light.md +117 -0
- package/.agents/workflows/deliver.md +27 -29
- package/.agents/workflows/helpers/deliver-digest.md +126 -0
- package/.agents/workflows/helpers/deliver-reference.md +21 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +6 -0
- package/.agents/workflows/helpers/deliver-story.md +31 -35
- package/.agents/workflows/plan.md +66 -84
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -21,6 +21,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
21
21
|
import { getLimits } from '../config-resolver.js';
|
|
22
22
|
import { findSimilarOpenStories } from '../duplicate-search.js';
|
|
23
23
|
import { Logger } from '../Logger.js';
|
|
24
|
+
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
24
25
|
import {
|
|
25
26
|
renderAcceptanceSpecSystemPrompt,
|
|
26
27
|
renderTechSpecSystemPrompt,
|
|
@@ -353,6 +354,111 @@ function resolveRiskHeuristics(config = {}) {
|
|
|
353
354
|
return [];
|
|
354
355
|
}
|
|
355
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Ceilings a seed's advisory complexity signals must fit for the plan
|
|
359
|
+
* workflow to **suggest** `/deliver-light` at Gate #1 (Story #4741 R3 plan-side
|
|
360
|
+
* handshake). Framework constants, not operator knobs — mirroring the
|
|
361
|
+
* conservative intent of `complexity-gate.js`'s `STORY_SHAPE_CEILINGS`
|
|
362
|
+
* (small, mostly-additive, non-sensitive) but read against the *seed-time*
|
|
363
|
+
* signals rather than an authored Story shape.
|
|
364
|
+
*
|
|
365
|
+
* The suggestion is **advisory only and never an automatic reroute**: it
|
|
366
|
+
* surfaces at Gate #1 for the operator to decide, and under `--yes` it is
|
|
367
|
+
* recorded on the envelope while planning proceeds unchanged. `/deliver-light`
|
|
368
|
+
* is a sibling Story; these ceilings define the plan side of the routing
|
|
369
|
+
* handshake independently of it.
|
|
370
|
+
*
|
|
371
|
+
* - `maxArtifacts` — enumerated seed items (one artifact each).
|
|
372
|
+
* - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
|
|
373
|
+
* is exactly what a light path should not carry.
|
|
374
|
+
* - `maxSensitivePathClasses`— any sensitive-path class disqualifies, the
|
|
375
|
+
* same taxonomy close applies to a landed diff.
|
|
376
|
+
*/
|
|
377
|
+
const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
|
|
378
|
+
maxArtifacts: 2,
|
|
379
|
+
maxRiskHeuristicHits: 0,
|
|
380
|
+
maxSensitivePathClasses: 0,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Derive the advisory `/deliver-light` suggestion from a seed's complexity
|
|
385
|
+
* signals (Story #4741 AC-6). Pure and total: a malformed / missing signal
|
|
386
|
+
* bag fails conservative (not suggested), never throws.
|
|
387
|
+
*
|
|
388
|
+
* `automatic: false` is part of the contract — the suggestion is surfaced for
|
|
389
|
+
* the operator, never a silent reroute of a non-interactive run.
|
|
390
|
+
*
|
|
391
|
+
* @param {object|null|undefined} complexitySignals
|
|
392
|
+
* @returns {{
|
|
393
|
+
* suggested: boolean,
|
|
394
|
+
* automatic: false,
|
|
395
|
+
* advisory: true,
|
|
396
|
+
* ceilings: typeof DELIVER_LIGHT_SUGGESTION_CEILINGS,
|
|
397
|
+
* reasons: string[],
|
|
398
|
+
* }}
|
|
399
|
+
*/
|
|
400
|
+
export function buildDeliverLightSuggestion(complexitySignals) {
|
|
401
|
+
const ceilings = DELIVER_LIGHT_SUGGESTION_CEILINGS;
|
|
402
|
+
const advisory = /** @type {const} */ (true);
|
|
403
|
+
const automatic = /** @type {const} */ (false);
|
|
404
|
+
const s = complexitySignals ?? {};
|
|
405
|
+
const artifactCount = Number.isInteger(s.artifactCount)
|
|
406
|
+
? s.artifactCount
|
|
407
|
+
: Number.POSITIVE_INFINITY;
|
|
408
|
+
const riskHits = Array.isArray(s.riskHeuristicHits)
|
|
409
|
+
? s.riskHeuristicHits.length
|
|
410
|
+
: Number.POSITIVE_INFINITY;
|
|
411
|
+
const sensitive = Array.isArray(s.sensitivePathClasses)
|
|
412
|
+
? s.sensitivePathClasses.length
|
|
413
|
+
: Number.POSITIVE_INFINITY;
|
|
414
|
+
|
|
415
|
+
const reasons = [];
|
|
416
|
+
if (artifactCount > ceilings.maxArtifacts) {
|
|
417
|
+
reasons.push(
|
|
418
|
+
`seed enumerates ${artifactCount} artifacts (> ${ceilings.maxArtifacts})`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
if (riskHits > ceilings.maxRiskHeuristicHits) {
|
|
422
|
+
reasons.push(`seed hits ${riskHits} risk-heuristic phrase(s)`);
|
|
423
|
+
}
|
|
424
|
+
if (sensitive > ceilings.maxSensitivePathClasses) {
|
|
425
|
+
reasons.push(
|
|
426
|
+
`predicted footprint touches ${sensitive} sensitive-path class(es)`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const suggested = reasons.length === 0;
|
|
431
|
+
return {
|
|
432
|
+
suggested,
|
|
433
|
+
automatic,
|
|
434
|
+
advisory,
|
|
435
|
+
ceilings,
|
|
436
|
+
reasons: suggested
|
|
437
|
+
? [
|
|
438
|
+
`seed fits the /deliver-light ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
|
|
439
|
+
'no risk-heuristic hits, no sensitive-path classes) — the operator ' +
|
|
440
|
+
'may prefer /deliver-light for this scope',
|
|
441
|
+
]
|
|
442
|
+
: reasons,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Attach the advisory `/deliver-light` suggestion to a complexity-signals bag
|
|
448
|
+
* as a **nested** field (Story #4741). Nesting — rather than a new top-level
|
|
449
|
+
* envelope key — keeps every existing per-mode envelope key set byte-stable
|
|
450
|
+
* (AC-5): the suggestion is derived from the signals it rides on.
|
|
451
|
+
*
|
|
452
|
+
* @param {object} complexitySignals
|
|
453
|
+
* @returns {object} the same signals plus `deliverLightSuggestion`.
|
|
454
|
+
*/
|
|
455
|
+
function withDeliverLightSuggestion(complexitySignals) {
|
|
456
|
+
return {
|
|
457
|
+
...complexitySignals,
|
|
458
|
+
deliverLightSuggestion: buildDeliverLightSuggestion(complexitySignals),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
356
462
|
/**
|
|
357
463
|
* Count top-level enumerated items (`- `, `* `, `1. `) under the first
|
|
358
464
|
* scope-shaped `## ` heading (Scope / MVP Scope / Proposed Scope / Work
|
|
@@ -588,13 +694,17 @@ async function buildSeedFileModeEnvelope({
|
|
|
588
694
|
seed: { path: seedFilePath ?? null, content },
|
|
589
695
|
// Advisory complexity signals only (Story #4722): no route, no routing
|
|
590
696
|
// authority. The planner authors the trivial-vs-standard verdict; persist
|
|
591
|
-
// validates a lite claim against the authored Story's shape.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
697
|
+
// validates a lite claim against the authored Story's shape. The nested
|
|
698
|
+
// `deliverLightSuggestion` is the advisory plan-side routing handshake
|
|
699
|
+
// (Story #4741 AC-6) — never an automatic reroute.
|
|
700
|
+
complexitySignals: withDeliverLightSuggestion(
|
|
701
|
+
buildComplexitySignals({
|
|
702
|
+
seedText: content,
|
|
703
|
+
config,
|
|
704
|
+
riskHeuristics: heuristics,
|
|
705
|
+
cwd,
|
|
706
|
+
}),
|
|
707
|
+
),
|
|
598
708
|
duplicates,
|
|
599
709
|
docsContext,
|
|
600
710
|
codebaseSnapshot: authoring.codebaseSnapshot,
|
|
@@ -748,12 +858,14 @@ async function buildTicketsModeEnvelope({
|
|
|
748
858
|
mode: 'tickets',
|
|
749
859
|
sourceTickets,
|
|
750
860
|
seed: { text: seed, path: null },
|
|
751
|
-
complexitySignals:
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
861
|
+
complexitySignals: withDeliverLightSuggestion(
|
|
862
|
+
buildComplexitySignals({
|
|
863
|
+
seedText: seed,
|
|
864
|
+
config,
|
|
865
|
+
riskHeuristics: heuristics,
|
|
866
|
+
cwd,
|
|
867
|
+
}),
|
|
868
|
+
),
|
|
757
869
|
duplicates,
|
|
758
870
|
docsContext,
|
|
759
871
|
codebaseSnapshot: authoring.codebaseSnapshot,
|
|
@@ -779,6 +891,122 @@ async function buildTicketsModeEnvelope({
|
|
|
779
891
|
};
|
|
780
892
|
}
|
|
781
893
|
|
|
894
|
+
/**
|
|
895
|
+
* Parse a prior Story body into its acceptance criteria and delivered file
|
|
896
|
+
* map. Total: an unparseable body degrades to empty lists (a delta envelope
|
|
897
|
+
* grounded on whatever survived), never a throw.
|
|
898
|
+
*
|
|
899
|
+
* @param {string} priorBody
|
|
900
|
+
* @returns {{ priorAcceptance: string[], deliveredFiles: string[] }}
|
|
901
|
+
*/
|
|
902
|
+
function extractPriorArtifacts(priorBody) {
|
|
903
|
+
let parsed;
|
|
904
|
+
try {
|
|
905
|
+
parsed = parseStoryBody(priorBody).body;
|
|
906
|
+
} catch {
|
|
907
|
+
return { priorAcceptance: [], deliveredFiles: [] };
|
|
908
|
+
}
|
|
909
|
+
const priorAcceptance = Array.isArray(parsed.acceptance)
|
|
910
|
+
? parsed.acceptance.filter((a) => typeof a === 'string' && a.length > 0)
|
|
911
|
+
: [];
|
|
912
|
+
const deliveredFiles = Array.isArray(parsed.changes)
|
|
913
|
+
? parsed.changes
|
|
914
|
+
.map((c) => (c && typeof c === 'object' ? c.path : c))
|
|
915
|
+
.filter((p) => typeof p === 'string' && p.length > 0)
|
|
916
|
+
: [];
|
|
917
|
+
return { priorAcceptance, deliveredFiles };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Build the amendment (delta) envelope — `plan-context --amends #<id>`
|
|
922
|
+
* (Story #4741 AC-4, R3-A). The heavy-amendment counterpart to routing a
|
|
923
|
+
* light amendment through `/deliver-light`: instead of re-interrogating the
|
|
924
|
+
* repo from scratch (`buildAuthoringContext`'s codebase snapshot and the BDD /
|
|
925
|
+
* memory / feedback probes), the envelope composes a DELTA from what already
|
|
926
|
+
* exists — the prior Story's body, its acceptance criteria (the real
|
|
927
|
+
* contract), and its delivered file map — so a follow-up change plans from the
|
|
928
|
+
* shape already shipped.
|
|
929
|
+
*
|
|
930
|
+
* The semantic steps that reach the ticket are preserved: the open-Story
|
|
931
|
+
* duplicate search (excluding the amended Story itself), the risk heuristics,
|
|
932
|
+
* and the authoring system prompts all still ride the envelope. What is
|
|
933
|
+
* dropped is only the from-scratch repo interrogation the prior artifacts
|
|
934
|
+
* already stand in for — that is the round-trip diet, not an amputation.
|
|
935
|
+
*
|
|
936
|
+
* @param {{
|
|
937
|
+
* amendsId: number,
|
|
938
|
+
* provider: object,
|
|
939
|
+
* config: object,
|
|
940
|
+
* settings: object,
|
|
941
|
+
* cwd?: string,
|
|
942
|
+
* }} args
|
|
943
|
+
* @returns {Promise<object>}
|
|
944
|
+
*/
|
|
945
|
+
async function buildAmendmentModeEnvelope({
|
|
946
|
+
amendsId,
|
|
947
|
+
provider,
|
|
948
|
+
config,
|
|
949
|
+
settings: _settings,
|
|
950
|
+
cwd,
|
|
951
|
+
}) {
|
|
952
|
+
if (!provider || typeof provider.getTicket !== 'function') {
|
|
953
|
+
throw new Error(
|
|
954
|
+
'[plan-context] --amends requires provider.getTicket() to load the prior Story.',
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
const prior = await provider.getTicket(amendsId);
|
|
958
|
+
if (!prior) {
|
|
959
|
+
throw new Error(
|
|
960
|
+
`[plan-context] --amends #${amendsId}: prior Story not found — nothing to amend.`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
const priorBody = typeof prior.body === 'string' ? prior.body : '';
|
|
964
|
+
const { priorAcceptance, deliveredFiles } = extractPriorArtifacts(priorBody);
|
|
965
|
+
|
|
966
|
+
const heuristics = resolveRiskHeuristics(config);
|
|
967
|
+
const limits = getLimits(config);
|
|
968
|
+
const duplicates = await searchStoryDuplicates({
|
|
969
|
+
seed: priorBody,
|
|
970
|
+
provider,
|
|
971
|
+
config,
|
|
972
|
+
excludeIds: [amendsId],
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
return {
|
|
976
|
+
mode: 'amends',
|
|
977
|
+
amends: {
|
|
978
|
+
id: Number(prior.id ?? prior.number ?? amendsId),
|
|
979
|
+
title: prior.title ?? '',
|
|
980
|
+
priorBody,
|
|
981
|
+
priorAcceptance,
|
|
982
|
+
deliveredFiles,
|
|
983
|
+
},
|
|
984
|
+
// The prior body is the seed the delta is authored against.
|
|
985
|
+
seed: { text: priorBody, path: null },
|
|
986
|
+
complexitySignals: withDeliverLightSuggestion(
|
|
987
|
+
buildComplexitySignals({
|
|
988
|
+
seedText: priorBody,
|
|
989
|
+
config,
|
|
990
|
+
riskHeuristics: heuristics,
|
|
991
|
+
cwd,
|
|
992
|
+
}),
|
|
993
|
+
),
|
|
994
|
+
duplicates,
|
|
995
|
+
// No plan temp dir and no from-scratch repo interrogation — the prior
|
|
996
|
+
// artifacts are the grounding, so there is no docs digest to anchor.
|
|
997
|
+
docsContext: null,
|
|
998
|
+
ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
|
|
999
|
+
maxTickets: limits.maxTickets,
|
|
1000
|
+
riskHeuristics: heuristics,
|
|
1001
|
+
systemPrompts: buildSystemPrompts({
|
|
1002
|
+
heuristics,
|
|
1003
|
+
maxTickets: limits.maxTickets,
|
|
1004
|
+
}),
|
|
1005
|
+
planState: null,
|
|
1006
|
+
planProfile: 'story-amendment',
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
782
1010
|
/**
|
|
783
1011
|
* Build the single planner-context envelope.
|
|
784
1012
|
*
|
|
@@ -787,11 +1015,12 @@ async function buildTicketsModeEnvelope({
|
|
|
787
1015
|
* bound it (see {@link assertPlanContextWithinCeiling}).
|
|
788
1016
|
*
|
|
789
1017
|
* @param {{
|
|
790
|
-
* mode: 'seed-file'|'seed'|'tickets',
|
|
1018
|
+
* mode: 'seed-file'|'seed'|'tickets'|'amends',
|
|
791
1019
|
* seedFilePath?: string,
|
|
792
1020
|
* seedFileContent?: string,
|
|
793
1021
|
* seedText?: string,
|
|
794
1022
|
* ticketIds?: number[],
|
|
1023
|
+
* amendsId?: number,
|
|
795
1024
|
* provider: object,
|
|
796
1025
|
* config: object,
|
|
797
1026
|
* settings: object,
|
|
@@ -805,6 +1034,7 @@ export async function buildPlanContext({
|
|
|
805
1034
|
seedFileContent,
|
|
806
1035
|
seedText,
|
|
807
1036
|
ticketIds,
|
|
1037
|
+
amendsId,
|
|
808
1038
|
provider,
|
|
809
1039
|
config = {},
|
|
810
1040
|
settings = {},
|
|
@@ -817,6 +1047,7 @@ export async function buildPlanContext({
|
|
|
817
1047
|
seedFileContent,
|
|
818
1048
|
seedText,
|
|
819
1049
|
ticketIds,
|
|
1050
|
+
amendsId,
|
|
820
1051
|
provider,
|
|
821
1052
|
config,
|
|
822
1053
|
settings,
|
|
@@ -835,11 +1066,21 @@ async function buildPlanContextEnvelope({
|
|
|
835
1066
|
seedFileContent,
|
|
836
1067
|
seedText,
|
|
837
1068
|
ticketIds,
|
|
1069
|
+
amendsId,
|
|
838
1070
|
provider,
|
|
839
1071
|
config,
|
|
840
1072
|
settings,
|
|
841
1073
|
cwd,
|
|
842
1074
|
}) {
|
|
1075
|
+
if (mode === 'amends') {
|
|
1076
|
+
return buildAmendmentModeEnvelope({
|
|
1077
|
+
amendsId,
|
|
1078
|
+
provider,
|
|
1079
|
+
config,
|
|
1080
|
+
settings,
|
|
1081
|
+
cwd,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
843
1084
|
if (mode === 'seed-file') {
|
|
844
1085
|
if (!seedFilePath && typeof seedFileContent !== 'string') {
|
|
845
1086
|
throw new Error(
|
|
@@ -875,6 +1116,6 @@ async function buildPlanContextEnvelope({
|
|
|
875
1116
|
});
|
|
876
1117
|
}
|
|
877
1118
|
throw new Error(
|
|
878
|
-
`[plan-context] unknown mode "${mode}" — expected "seed", "seed-file", or "
|
|
1119
|
+
`[plan-context] unknown mode "${mode}" — expected "seed", "seed-file", "tickets", or "amends".`,
|
|
879
1120
|
);
|
|
880
1121
|
}
|
|
@@ -301,6 +301,7 @@ async function renderRunScopedPlanMetricsLine({
|
|
|
301
301
|
* stories: ReturnType<typeof assemblePlanStories>['stories'],
|
|
302
302
|
* routeDowngradeReason?: string|null,
|
|
303
303
|
* config?: object,
|
|
304
|
+
* injectedRules?: object,
|
|
304
305
|
* }} args
|
|
305
306
|
* @returns {{
|
|
306
307
|
* route: 'lite'|'full',
|
|
@@ -313,6 +314,7 @@ function resolveEffectiveRoute({
|
|
|
313
314
|
stories,
|
|
314
315
|
routeDowngradeReason = null,
|
|
315
316
|
config = {},
|
|
317
|
+
injectedRules,
|
|
316
318
|
}) {
|
|
317
319
|
const verdict = resolvePlannerRouteVerdict({ reason: routeDowngradeReason });
|
|
318
320
|
if (verdict.route !== 'lite') return null;
|
|
@@ -339,6 +341,7 @@ function resolveEffectiveRoute({
|
|
|
339
341
|
const derived = deriveStoryShape({
|
|
340
342
|
changes: story.bodyObject?.changes,
|
|
341
343
|
acceptance: story.acceptance,
|
|
344
|
+
injectedRules,
|
|
342
345
|
});
|
|
343
346
|
return {
|
|
344
347
|
slug: story.slug,
|
|
@@ -455,6 +458,7 @@ export async function reapStalePlanDirs({
|
|
|
455
458
|
* sourceTicketOrigin?: 'flag'|'envelope'|'none',
|
|
456
459
|
* closeSuperseded?: boolean,
|
|
457
460
|
* routeDowngradeReason?: string|null,
|
|
461
|
+
* injectedRules?: object,
|
|
458
462
|
* },
|
|
459
463
|
* }} input
|
|
460
464
|
*/
|
|
@@ -483,6 +487,7 @@ export async function runPlanPersist({
|
|
|
483
487
|
sourceTicketOrigin = 'none',
|
|
484
488
|
closeSuperseded = true,
|
|
485
489
|
routeDowngradeReason = null,
|
|
490
|
+
injectedRules = undefined,
|
|
486
491
|
} = opts;
|
|
487
492
|
|
|
488
493
|
// Boundary for the plan-metrics summary below: everything this invocation
|
|
@@ -566,6 +571,7 @@ export async function runPlanPersist({
|
|
|
566
571
|
stories,
|
|
567
572
|
routeDowngradeReason,
|
|
568
573
|
config,
|
|
574
|
+
injectedRules,
|
|
569
575
|
});
|
|
570
576
|
const isLiteRoute = route?.route === 'lite';
|
|
571
577
|
if (isLiteRoute) {
|
|
@@ -300,6 +300,9 @@ export async function readNativeBlockedBy({
|
|
|
300
300
|
* @param {Map<number, number[]>} nativeEdges
|
|
301
301
|
* @param {number[]} foreignDone Ids outside the set already satisfied.
|
|
302
302
|
* @param {(msg: string) => void} [warn]
|
|
303
|
+
* @param {object} [injectedRules] Test seam forwarded to the shape
|
|
304
|
+
* derivation — skips the `audit-rules.json` disk read. Production callers
|
|
305
|
+
* omit it (the real manifest, memoized per process, is the default).
|
|
303
306
|
* @returns {{ kind: string, stories: object[], dag: object[], done: number[] }}
|
|
304
307
|
*/
|
|
305
308
|
export function buildStoriesEnvelope({
|
|
@@ -308,6 +311,7 @@ export function buildStoriesEnvelope({
|
|
|
308
311
|
foreignDone = [],
|
|
309
312
|
warn,
|
|
310
313
|
config,
|
|
314
|
+
injectedRules,
|
|
311
315
|
}) {
|
|
312
316
|
const sorted = [...stories].sort((a, b) => a.id - b.id);
|
|
313
317
|
const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
|
|
@@ -321,13 +325,26 @@ export function buildStoriesEnvelope({
|
|
|
321
325
|
// `route::lite` label is a human-visible hint only, never the control
|
|
322
326
|
// signal: a lost label cannot misroute delivery. Model-side fan-out
|
|
323
327
|
// only; close gates are untouched.
|
|
328
|
+
//
|
|
329
|
+
// `storyCount` (Story #4736) carries the run's topology into that same
|
|
330
|
+
// decision: a run resolving exactly ONE Story is inline whatever its
|
|
331
|
+
// shape, because the isolation a sub-agent buys only matters against a
|
|
332
|
+
// concurrently-dispatched sibling. It is the resolved set size — not the
|
|
333
|
+
// undelivered remainder — so the mode a caller reads for a given `--ids`
|
|
334
|
+
// list never changes as siblings land mid-run.
|
|
324
335
|
stories: sorted.map(({ id, title, body, url, labels, state }) => ({
|
|
325
336
|
id,
|
|
326
337
|
title,
|
|
327
338
|
url,
|
|
328
339
|
labels,
|
|
329
340
|
state,
|
|
330
|
-
dispatchMode: resolveStoryDispatchMode({
|
|
341
|
+
dispatchMode: resolveStoryDispatchMode({
|
|
342
|
+
body,
|
|
343
|
+
labels,
|
|
344
|
+
config,
|
|
345
|
+
storyCount: sorted.length,
|
|
346
|
+
injectedRules,
|
|
347
|
+
}).mode,
|
|
331
348
|
})),
|
|
332
349
|
dag: storiesToDag(sorted, nativeEdges, warn),
|
|
333
350
|
done: [...new Set([...inSetDone, ...foreignDone])].sort((a, b) => a - b),
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* single-story-close/gate-log.js — bounded gate output for the close path
|
|
3
|
+
* (Story #4736).
|
|
4
|
+
*
|
|
5
|
+
* ## Why
|
|
6
|
+
*
|
|
7
|
+
* `runCloseValidation` streams every child gate's stdout/stderr line through
|
|
8
|
+
* an injected `log`, and the close phase used to hand it `Logger.info` — whose
|
|
9
|
+
* default sink is `console.log`. A single successful close therefore wrote the
|
|
10
|
+
* whole of `npm test`, the linter, and the baseline checks onto the invoking
|
|
11
|
+
* agent's stdout: ~50KB, over the host's inline tool-result ceiling. The caller
|
|
12
|
+
* got a truncated preview, had to open the persisted file anyway, and re-ran
|
|
13
|
+
* close for a clean envelope — burning the run's most expensive stretch to
|
|
14
|
+
* re-derive output it already had.
|
|
15
|
+
*
|
|
16
|
+
* Story #4708 set the contract this restores compliance with (see
|
|
17
|
+
* `rules/orchestration-error-handling.md` § Output Contract): compact digest
|
|
18
|
+
* plus an on-disk artifact path, ≤ ~2KB on the **default success path**.
|
|
19
|
+
*
|
|
20
|
+
* ## The shape
|
|
21
|
+
*
|
|
22
|
+
* A sink captures every gate line to a log under the gitignored temp tree and
|
|
23
|
+
* emits nothing inline. What happens next depends on the outcome, because the
|
|
24
|
+
* two outcomes want opposite things:
|
|
25
|
+
*
|
|
26
|
+
* - **success** — the caller wants the verdict, not the evidence.
|
|
27
|
+
* {@link GateLogSink#digest} is one line: the pass count and the log path.
|
|
28
|
+
* - **failure** — the evidence IS the point, and making the caller open a
|
|
29
|
+
* file to see why a gate went red just moves the cost.
|
|
30
|
+
* {@link GateLogSink#replay} puts the captured tail back inline.
|
|
31
|
+
*
|
|
32
|
+
* `AGENT_LOG_LEVEL=verbose` opts back into live inline streaming (the
|
|
33
|
+
* "existing log-level control"): the capture still happens, so the artifact is
|
|
34
|
+
* written either way.
|
|
35
|
+
*
|
|
36
|
+
* The sink never throws. A log directory that cannot be written degrades to
|
|
37
|
+
* inline streaming — losing the size bound is strictly better than losing the
|
|
38
|
+
* gate output that says why a close failed.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import nodeFs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
|
|
44
|
+
import { Logger, resolveLevel } from '../../Logger.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How many trailing captured lines {@link GateLogSink#replay} puts back
|
|
48
|
+
* inline. A failed gate's actionable evidence — the assertion, the stack, the
|
|
49
|
+
* summary counts — sits at the end of its output; the head is startup noise.
|
|
50
|
+
* The full text is always in the artifact regardless.
|
|
51
|
+
*/
|
|
52
|
+
export const REPLAY_TAIL_LINES = 200;
|
|
53
|
+
|
|
54
|
+
/** Basename of the per-Story gate log inside the temp directory. */
|
|
55
|
+
function logNameFor(storyId) {
|
|
56
|
+
return `close-gates-${storyId ?? 'unknown'}.log`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A capturing sink for close-validation gate output.
|
|
61
|
+
*
|
|
62
|
+
* Not exported as a constructor — {@link createGateLogSink} owns the
|
|
63
|
+
* degradation decision, so every instance in the wild has already resolved
|
|
64
|
+
* whether it has a writable artifact.
|
|
65
|
+
*/
|
|
66
|
+
class GateLogSink {
|
|
67
|
+
/**
|
|
68
|
+
* @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, emit: (line: string) => void }} args
|
|
69
|
+
*/
|
|
70
|
+
constructor({ logPath, streamInline, write, emit }) {
|
|
71
|
+
/** Absolute path of the artifact, or `null` when capture is unavailable. */
|
|
72
|
+
this.logPath = logPath;
|
|
73
|
+
/** Whether lines are ALSO echoed inline as they arrive. */
|
|
74
|
+
this.streamInline = streamInline;
|
|
75
|
+
/** Number of lines captured so far. */
|
|
76
|
+
this.lineCount = 0;
|
|
77
|
+
this._write = write;
|
|
78
|
+
this._emit = emit;
|
|
79
|
+
this._tail = [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The `log` callable handed to `runCloseValidation` / `buildDefaultGates`.
|
|
84
|
+
* Bound, because it is passed by reference into the gate machinery.
|
|
85
|
+
*
|
|
86
|
+
* @type {(message: string) => void}
|
|
87
|
+
*/
|
|
88
|
+
get log() {
|
|
89
|
+
return (message) => {
|
|
90
|
+
const line = String(message ?? '');
|
|
91
|
+
this.lineCount += 1;
|
|
92
|
+
this._tail.push(line);
|
|
93
|
+
if (this._tail.length > REPLAY_TAIL_LINES) this._tail.shift();
|
|
94
|
+
this._write(line);
|
|
95
|
+
if (this.streamInline) this._emit(line);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The success-path digest: one line, no gate output. Names the artifact so
|
|
101
|
+
* the caller can open it on demand rather than carrying it all session.
|
|
102
|
+
*
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
digest() {
|
|
106
|
+
const where = this.logPath
|
|
107
|
+
? `full gate output → ${this.logPath}`
|
|
108
|
+
: 'full gate output was streamed inline (no artifact could be written)';
|
|
109
|
+
return `${this.lineCount} line(s) of gate output captured; ${where}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Put the captured tail back inline — the failure path, where the evidence
|
|
114
|
+
* is what the caller came for. A no-op when the lines were already streamed
|
|
115
|
+
* inline (verbose, or degraded capture), so nothing is ever printed twice.
|
|
116
|
+
*
|
|
117
|
+
* @returns {number} Lines replayed.
|
|
118
|
+
*/
|
|
119
|
+
replay() {
|
|
120
|
+
if (this.streamInline || this._tail.length === 0) return 0;
|
|
121
|
+
const dropped = this.lineCount - this._tail.length;
|
|
122
|
+
if (dropped > 0) {
|
|
123
|
+
this._emit(
|
|
124
|
+
`[close-validation] … ${dropped} earlier line(s) omitted; full output → ${this.logPath}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
for (const line of this._tail) this._emit(line);
|
|
128
|
+
return this._tail.length;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the gate-output sink for one close run.
|
|
134
|
+
*
|
|
135
|
+
* @param {{
|
|
136
|
+
* storyId: number|null,
|
|
137
|
+
* cwd?: string,
|
|
138
|
+
* logDir?: string,
|
|
139
|
+
* fs?: typeof nodeFs,
|
|
140
|
+
* logger?: { info: (m: string) => void },
|
|
141
|
+
* level?: string,
|
|
142
|
+
* }} [args] `logDir` defaults to `<cwd>/temp/orchestration`; `level` defaults
|
|
143
|
+
* to the live Logger level so `AGENT_LOG_LEVEL=verbose` restores streaming.
|
|
144
|
+
* @returns {GateLogSink}
|
|
145
|
+
*/
|
|
146
|
+
export function createGateLogSink({
|
|
147
|
+
storyId = null,
|
|
148
|
+
cwd = process.cwd(),
|
|
149
|
+
logDir,
|
|
150
|
+
fs = nodeFs,
|
|
151
|
+
logger = Logger,
|
|
152
|
+
level,
|
|
153
|
+
} = {}) {
|
|
154
|
+
const emit = (line) => logger.info?.(line);
|
|
155
|
+
const verbose = (level ?? resolveLevel()) === 'verbose';
|
|
156
|
+
const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
|
|
157
|
+
|
|
158
|
+
let handle = null;
|
|
159
|
+
let logPath = null;
|
|
160
|
+
try {
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
logPath = path.join(dir, logNameFor(storyId));
|
|
163
|
+
// Truncate: each close run owns its artifact outright, so a re-run never
|
|
164
|
+
// hands the reader a file interleaving two runs' gates.
|
|
165
|
+
handle = fs.openSync(logPath, 'w');
|
|
166
|
+
} catch {
|
|
167
|
+
// No artifact — fall back to inline streaming rather than dropping the
|
|
168
|
+
// gate output on the floor.
|
|
169
|
+
return new GateLogSink({
|
|
170
|
+
logPath: null,
|
|
171
|
+
streamInline: true,
|
|
172
|
+
write: () => {},
|
|
173
|
+
emit,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
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 });
|
|
186
|
+
}
|
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
* the same, with `baseBranch` as the diff anchor and the Story worktree as
|
|
25
25
|
* the commit target.
|
|
26
26
|
*
|
|
27
|
+
* Bounded gate output (Story #4736). Every gate line goes to the run's
|
|
28
|
+
* `gate-log.js` sink — an artifact under the gitignored temp tree — instead
|
|
29
|
+
* of straight to the agent's stdout, where a passing `npm test` alone once
|
|
30
|
+
* pushed a successful close past the host's inline tool-result ceiling. A
|
|
31
|
+
* clean run reports one digest line naming the artifact; a failing gate
|
|
32
|
+
* replays its captured tail inline, because that is exactly when the caller
|
|
33
|
+
* needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
|
|
34
|
+
* live streaming.
|
|
35
|
+
*
|
|
27
36
|
* `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
|
|
28
37
|
* are accepted as injected dependencies so the parent CLI's cache-busted
|
|
29
38
|
* bindings win in tests that mock the upstream module URLs.
|
|
@@ -33,6 +42,7 @@ import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-va
|
|
|
33
42
|
import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
|
|
34
43
|
import { Logger } from '../../../Logger.js';
|
|
35
44
|
import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
|
|
45
|
+
import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
|
|
36
46
|
|
|
37
47
|
/**
|
|
38
48
|
* Run the close-validation gate chain. Throws on first gate failure.
|
|
@@ -60,6 +70,7 @@ import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../s
|
|
|
60
70
|
* runCloseValidation?: typeof defaultRunCloseValidation,
|
|
61
71
|
* buildDefaultGates?: typeof defaultBuildDefaultGates,
|
|
62
72
|
* runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
|
|
73
|
+
* createGateLogSink?: typeof defaultCreateGateLogSink,
|
|
63
74
|
* }} args
|
|
64
75
|
*/
|
|
65
76
|
export async function runCloseValidationPhase({
|
|
@@ -73,6 +84,7 @@ export async function runCloseValidationPhase({
|
|
|
73
84
|
runCloseValidation = defaultRunCloseValidation,
|
|
74
85
|
buildDefaultGates = defaultBuildDefaultGates,
|
|
75
86
|
runScopedFormatAutofix = defaultRunScopedFormatAutofix,
|
|
87
|
+
createGateLogSink = defaultCreateGateLogSink,
|
|
76
88
|
}) {
|
|
77
89
|
// Story #4250 — format-autofix self-heal before the check-only gates.
|
|
78
90
|
// Mirrors the Epic path (story-close/phases/gates.js): the formatter is
|
|
@@ -122,6 +134,9 @@ export async function runCloseValidationPhase({
|
|
|
122
134
|
'VALIDATE',
|
|
123
135
|
`Running close-validation gates against baseline ${baseBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
|
|
124
136
|
);
|
|
137
|
+
// Story #4736 — one sink for both `log` seams (gate construction and gate
|
|
138
|
+
// execution), so nothing in the chain can route around the artifact.
|
|
139
|
+
const gateLog = createGateLogSink({ storyId, cwd });
|
|
125
140
|
const validation = await runCloseValidation({
|
|
126
141
|
cwd,
|
|
127
142
|
worktreePath,
|
|
@@ -129,9 +144,9 @@ export async function runCloseValidationPhase({
|
|
|
129
144
|
config,
|
|
130
145
|
baseBranch,
|
|
131
146
|
cwd: worktreePath || cwd,
|
|
132
|
-
log:
|
|
147
|
+
log: gateLog.log,
|
|
133
148
|
}),
|
|
134
|
-
log:
|
|
149
|
+
log: gateLog.log,
|
|
135
150
|
storyId,
|
|
136
151
|
// Story #4250 — standalone storyId-anchored evidence keyspace. No
|
|
137
152
|
// epicId; the standalone flag routes the cache to
|
|
@@ -141,10 +156,13 @@ export async function runCloseValidationPhase({
|
|
|
141
156
|
if (!validation.ok) {
|
|
142
157
|
const [first] = validation.failed;
|
|
143
158
|
const { gate, status, cwd: gateCwd } = first;
|
|
159
|
+
// The evidence is the point on this path: replay the captured tail inline
|
|
160
|
+
// rather than making the caller open a file to learn why close stopped.
|
|
161
|
+
gateLog.replay();
|
|
144
162
|
throw new Error(
|
|
145
163
|
`[single-story-close] Gate failed: ${gate.name} (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
|
|
146
164
|
(gate.hint ? ` ${gate.hint}` : ''),
|
|
147
165
|
);
|
|
148
166
|
}
|
|
149
|
-
progress('VALIDATE',
|
|
167
|
+
progress('VALIDATE', `✅ All gates passed. ${gateLog.digest()}`);
|
|
150
168
|
}
|