mandrel 2.12.0 → 2.14.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/schemas/story-deliver-terminal.schema.json +60 -6
- package/.agents/scripts/deliver-light.js +446 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +7 -4
- 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/story-deliver-terminal.js +122 -6
- package/.agents/scripts/plan-context.js +45 -3
- package/.agents/scripts/plan-persist.js +106 -9
- package/.agents/workflows/deliver-light.md +148 -0
- package/.agents/workflows/plan.md +66 -84
- package/docs/CHANGELOG.md +15 -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
|
}
|
|
@@ -61,6 +61,15 @@ export const TERMINAL_EXIT_CODES = Object.freeze({
|
|
|
61
61
|
pending: 3,
|
|
62
62
|
blocked: 1,
|
|
63
63
|
failed: 1,
|
|
64
|
+
/**
|
|
65
|
+
* `escalated` reuses **2**, the code `/deliver-light` already documents for
|
|
66
|
+
* "the gate did not proceed light" — the escalation is that outcome made
|
|
67
|
+
* terminal, not a new one, so giving it a fresh code would fork a vocabulary
|
|
68
|
+
* callers already branch on. It stays distinct from `landed` (nothing was
|
|
69
|
+
* delivered) and from `blocked`/`failed` (nothing is wrong — the work simply
|
|
70
|
+
* belongs to `/plan`).
|
|
71
|
+
*/
|
|
72
|
+
escalated: 2,
|
|
64
73
|
});
|
|
65
74
|
|
|
66
75
|
export const TERMINAL_STATUSES = Object.freeze([
|
|
@@ -68,13 +77,38 @@ export const TERMINAL_STATUSES = Object.freeze([
|
|
|
68
77
|
'pending',
|
|
69
78
|
'blocked',
|
|
70
79
|
'failed',
|
|
80
|
+
'escalated',
|
|
71
81
|
]);
|
|
72
82
|
|
|
83
|
+
/** Cap on the prompt echoed into an escalation's `/plan` next command. */
|
|
84
|
+
const PLAN_PROMPT_MAX = 200;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render an operator prompt safe to sit inside the double quotes of the
|
|
88
|
+
* `/plan "<prompt>"` next command: collapse newlines (a next command is one
|
|
89
|
+
* line by contract), escape backslashes and double quotes so the quoting
|
|
90
|
+
* cannot be broken out of, and cap the length so a long prompt does not turn
|
|
91
|
+
* the envelope into a transcript. Total — a non-string yields the empty
|
|
92
|
+
* string, which the escalation builder rejects rather than emitting.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} prompt
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function quoteForPlan(prompt) {
|
|
98
|
+
const text =
|
|
99
|
+
typeof prompt === 'string' ? prompt.replace(/\s+/g, ' ').trim() : '';
|
|
100
|
+
const capped =
|
|
101
|
+
text.length > PLAN_PROMPT_MAX
|
|
102
|
+
? `${text.slice(0, PLAN_PROMPT_MAX - 1).trimEnd()}…`
|
|
103
|
+
: text;
|
|
104
|
+
return capped.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
/**
|
|
74
108
|
* The shared next-command vocabulary. Every producer of a "what now?"
|
|
75
|
-
* answer — the `pending` terminal envelope
|
|
76
|
-
* builds its command from here, so the
|
|
77
|
-
* naming different commands for the same observed state.
|
|
109
|
+
* answer — the `pending` terminal envelope, an `escalated` one, and
|
|
110
|
+
* `deliver-recover.js` — builds its command from here, so the surfaces never
|
|
111
|
+
* drift into naming different commands for the same observed state.
|
|
78
112
|
*/
|
|
79
113
|
export const NEXT_COMMANDS = Object.freeze({
|
|
80
114
|
/**
|
|
@@ -104,6 +138,18 @@ export const NEXT_COMMANDS = Object.freeze({
|
|
|
104
138
|
/** Probe a stranded Story and print its single next command. */
|
|
105
139
|
recover: (storyId) =>
|
|
106
140
|
`node .agents/scripts/deliver-recover.js --story ${storyId}`,
|
|
141
|
+
/**
|
|
142
|
+
* Hand over-scope work to `/plan` — the next command of an `escalated`
|
|
143
|
+
* terminal (Story #4746).
|
|
144
|
+
*
|
|
145
|
+
* The only entry here that is a slash command rather than a script, and
|
|
146
|
+
* deliberately so: the other entries resume a Story that exists, while this
|
|
147
|
+
* one names work that has no Story yet and needs planning before it can have
|
|
148
|
+
* one. It is quoted for a shell but addressed to a **fresh session** — see
|
|
149
|
+
* the workflow's escalation section for why running it in the escalating
|
|
150
|
+
* session is forbidden.
|
|
151
|
+
*/
|
|
152
|
+
escalateToPlan: (prompt) => `/plan "${quoteForPlan(prompt)}"`,
|
|
107
153
|
});
|
|
108
154
|
|
|
109
155
|
/** @type {Function|null} */
|
|
@@ -165,8 +211,9 @@ function compact(obj) {
|
|
|
165
211
|
* this replaces.
|
|
166
212
|
*
|
|
167
213
|
* @param {object} args
|
|
168
|
-
* @param {number} args.storyId
|
|
169
|
-
*
|
|
214
|
+
* @param {number|null} args.storyId `null` only for an `escalated` terminal,
|
|
215
|
+
* which by construction never authored a Story.
|
|
216
|
+
* @param {'landed'|'pending'|'blocked'|'failed'|'escalated'} args.status
|
|
170
217
|
* @param {string} args.phase
|
|
171
218
|
* @param {string} [args.storyBranch]
|
|
172
219
|
* @param {string} [args.baseBranch]
|
|
@@ -175,6 +222,7 @@ function compact(obj) {
|
|
|
175
222
|
* @param {object|null} [args.tail]
|
|
176
223
|
* @param {object|null} [args.blocked]
|
|
177
224
|
* @param {object|null} [args.failure]
|
|
225
|
+
* @param {object|null} [args.escalation]
|
|
178
226
|
* @param {string|null} [args.nextCommand]
|
|
179
227
|
* @param {number} args.elapsedSeconds
|
|
180
228
|
* @param {object|null} [args.waitBudget]
|
|
@@ -192,6 +240,7 @@ export function buildTerminalEnvelope({
|
|
|
192
240
|
tail,
|
|
193
241
|
blocked,
|
|
194
242
|
failure,
|
|
243
|
+
escalation,
|
|
195
244
|
nextCommand,
|
|
196
245
|
elapsedSeconds = 0,
|
|
197
246
|
waitBudget,
|
|
@@ -199,7 +248,10 @@ export function buildTerminalEnvelope({
|
|
|
199
248
|
}) {
|
|
200
249
|
const envelope = compact({
|
|
201
250
|
kind: TERMINAL_ENVELOPE_KIND,
|
|
202
|
-
|
|
251
|
+
// `Number(null)` is 0, which would quietly satisfy nothing and confuse
|
|
252
|
+
// everything — a nullish storyId stays null and lets the schema decide
|
|
253
|
+
// whether this status is allowed to omit one.
|
|
254
|
+
storyId: storyId === null || storyId === undefined ? null : Number(storyId),
|
|
203
255
|
status,
|
|
204
256
|
phase,
|
|
205
257
|
storyBranch: storyBranch ?? null,
|
|
@@ -209,6 +261,7 @@ export function buildTerminalEnvelope({
|
|
|
209
261
|
tail: tail ?? null,
|
|
210
262
|
blocked: blocked ?? null,
|
|
211
263
|
failure: failure ?? null,
|
|
264
|
+
escalation: escalation ?? null,
|
|
212
265
|
nextCommand: nextCommand ?? null,
|
|
213
266
|
elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
|
|
214
267
|
waitBudget: waitBudget ?? null,
|
|
@@ -225,6 +278,69 @@ export function buildTerminalEnvelope({
|
|
|
225
278
|
return envelope;
|
|
226
279
|
}
|
|
227
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Build the `escalated` terminal — the one envelope emitted before a Story
|
|
283
|
+
* exists (Story #4746).
|
|
284
|
+
*
|
|
285
|
+
* `/deliver-light`'s suitability gate already decided correctly when it
|
|
286
|
+
* overrode a `lite` self-verdict on shape; what it lacked was an outcome a
|
|
287
|
+
* session could not walk past. A mandrel-bench 2.13.0 light-arm run did
|
|
288
|
+
* exactly that — it read the gate's `escalate-plan`, then invoked `/plan`
|
|
289
|
+
* in the same session and delivered. The continuation was not harmless:
|
|
290
|
+
* planning inside a session already framed as small work authored ONE Story
|
|
291
|
+
* against the scenario's 3-5 contract, where a fresh `/plan` session on the
|
|
292
|
+
* identical seed authored four. Escalation silently produced the very
|
|
293
|
+
* under-decomposition the guard exists to prevent.
|
|
294
|
+
*
|
|
295
|
+
* So the outcome is a validated envelope with its own exit code, naming the
|
|
296
|
+
* `/plan` command that owns the work, and asserting per artifact that nothing
|
|
297
|
+
* was started. Every guarantee here is enforced by the schema rather than by
|
|
298
|
+
* prose: `storyId` must be null, `escalation.created.*` are pinned `false`.
|
|
299
|
+
*
|
|
300
|
+
* @param {{
|
|
301
|
+
* prompt: string,
|
|
302
|
+
* reasons?: string[],
|
|
303
|
+
* elapsedSeconds?: number,
|
|
304
|
+
* timestamp?: string,
|
|
305
|
+
* }} args
|
|
306
|
+
* @returns {object} The validated `escalated` envelope.
|
|
307
|
+
*/
|
|
308
|
+
export function buildEscalationTerminal({
|
|
309
|
+
prompt,
|
|
310
|
+
reasons,
|
|
311
|
+
elapsedSeconds = 0,
|
|
312
|
+
timestamp,
|
|
313
|
+
}) {
|
|
314
|
+
const quoted = quoteForPlan(prompt);
|
|
315
|
+
if (quoted === '') {
|
|
316
|
+
// An escalation whose next command is `/plan ""` hands the operator
|
|
317
|
+
// nothing — the same walk-past-able non-outcome in envelope clothing.
|
|
318
|
+
throw new TypeError(
|
|
319
|
+
'buildEscalationTerminal: a non-empty prompt is required — the escalated terminal exists to name the /plan invocation that owns the work',
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const recorded = (Array.isArray(reasons) ? reasons : []).filter(
|
|
323
|
+
(r) => typeof r === 'string' && r.trim() !== '',
|
|
324
|
+
);
|
|
325
|
+
return buildTerminalEnvelope({
|
|
326
|
+
storyId: null,
|
|
327
|
+
status: 'escalated',
|
|
328
|
+
phase: 'suitability-gate',
|
|
329
|
+
escalation: {
|
|
330
|
+
reasons:
|
|
331
|
+
recorded.length > 0
|
|
332
|
+
? recorded
|
|
333
|
+
: ['predicted scope exceeds the light ceilings — escalate to /plan'],
|
|
334
|
+
// Not computed from anything: the escalation path returns before the
|
|
335
|
+
// receipt/init call sites, so these are the assertion that it did.
|
|
336
|
+
created: { receiptStory: false, storyBranch: false, worktree: false },
|
|
337
|
+
},
|
|
338
|
+
nextCommand: NEXT_COMMANDS.escalateToPlan(prompt),
|
|
339
|
+
elapsedSeconds,
|
|
340
|
+
...(timestamp === undefined ? {} : { timestamp }),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
228
344
|
/**
|
|
229
345
|
* Resolve the process exit code for a terminal envelope.
|
|
230
346
|
*
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
* --tickets 123[,456…] Analyze existing issue(s) into proper
|
|
18
18
|
* Stories. Envelope carries `sourceTickets[]`.
|
|
19
19
|
*
|
|
20
|
+
* --amends 123 | #123 Amendment (delta) planning. Composes a DELTA
|
|
21
|
+
* envelope from the prior Story's body, its
|
|
22
|
+
* acceptance criteria, and its delivered file map
|
|
23
|
+
* instead of re-interrogating the repo from
|
|
24
|
+
* scratch (Story #4741). Envelope carries `amends`.
|
|
25
|
+
*
|
|
20
26
|
* Flags:
|
|
21
27
|
* --out <path> Write the envelope to <path> (parent dirs created).
|
|
22
28
|
* `/plan` points this at `<plan-dir>/plan-context.json`,
|
|
@@ -83,6 +89,25 @@ export function parseTicketIds(raw) {
|
|
|
83
89
|
return [...new Set(ids)];
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Parse a single `--amends` id, tolerating a leading `#` (`#123` or `123`).
|
|
94
|
+
*
|
|
95
|
+
* @param {string} raw
|
|
96
|
+
* @returns {number}
|
|
97
|
+
*/
|
|
98
|
+
export function parseAmendsId(raw) {
|
|
99
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
100
|
+
throw new Error('--amends requires a single prior Story id.');
|
|
101
|
+
}
|
|
102
|
+
const id = Number(raw.trim().replace(/^#/, ''));
|
|
103
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`--amends expects a positive integer Story id; got ${JSON.stringify(raw)}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
/**
|
|
87
112
|
* Build the envelope and write it to `stdout` as a single JSON line
|
|
88
113
|
* (or pretty-printed with --pretty). Exported for tests.
|
|
@@ -96,6 +121,7 @@ export async function emitPlanContext({
|
|
|
96
121
|
seedFileContent,
|
|
97
122
|
seedText,
|
|
98
123
|
ticketIds,
|
|
124
|
+
amendsId,
|
|
99
125
|
provider,
|
|
100
126
|
config,
|
|
101
127
|
settings,
|
|
@@ -110,6 +136,7 @@ export async function emitPlanContext({
|
|
|
110
136
|
seedFileContent,
|
|
111
137
|
seedText,
|
|
112
138
|
ticketIds,
|
|
139
|
+
amendsId,
|
|
113
140
|
provider,
|
|
114
141
|
config,
|
|
115
142
|
settings,
|
|
@@ -139,14 +166,19 @@ export async function emitPlanContext({
|
|
|
139
166
|
duplicates: (envelope.duplicates ?? []).length,
|
|
140
167
|
// Advisory only (Story #4722): signals, no route — the planner owns
|
|
141
168
|
// the trivial-vs-standard verdict and persist validates it by shape.
|
|
169
|
+
// The nested `deliverLightSuggestion` is the recorded plan-side routing
|
|
170
|
+
// handshake (Story #4741 AC-6) — advisory, never an automatic reroute.
|
|
142
171
|
complexitySignals: envelope.complexitySignals
|
|
143
172
|
? {
|
|
144
173
|
artifactCount: envelope.complexitySignals.artifactCount,
|
|
145
174
|
riskHeuristicHits: envelope.complexitySignals.riskHeuristicHits,
|
|
146
175
|
sensitivePathClasses:
|
|
147
176
|
envelope.complexitySignals.sensitivePathClasses,
|
|
177
|
+
deliverLightSuggestion:
|
|
178
|
+
envelope.complexitySignals.deliverLightSuggestion ?? null,
|
|
148
179
|
}
|
|
149
180
|
: null,
|
|
181
|
+
amends: envelope.amends ? { id: envelope.amends.id } : null,
|
|
150
182
|
};
|
|
151
183
|
stdout.write(`${JSON.stringify(digest)}\n`);
|
|
152
184
|
} else {
|
|
@@ -221,6 +253,7 @@ async function main() {
|
|
|
221
253
|
seed: { type: 'string' },
|
|
222
254
|
'seed-file': { type: 'string' },
|
|
223
255
|
tickets: { type: 'string' },
|
|
256
|
+
amends: { type: 'string' },
|
|
224
257
|
out: { type: 'string' },
|
|
225
258
|
pretty: { type: 'boolean', default: false },
|
|
226
259
|
},
|
|
@@ -234,16 +267,24 @@ async function main() {
|
|
|
234
267
|
typeof seedFilePath === 'string' && seedFilePath.length > 0;
|
|
235
268
|
const hasTickets =
|
|
236
269
|
typeof values.tickets === 'string' && values.tickets.trim().length > 0;
|
|
237
|
-
const
|
|
270
|
+
const hasAmends =
|
|
271
|
+
typeof values.amends === 'string' && values.amends.trim().length > 0;
|
|
272
|
+
const entryForms = [hasSeed, hasSeedFile, hasTickets, hasAmends].filter(
|
|
273
|
+
Boolean,
|
|
274
|
+
).length;
|
|
238
275
|
if (entryForms !== 1) {
|
|
239
276
|
throw new Error(
|
|
240
|
-
'Pass exactly one of --seed "<text>", --seed-file <path>, or --
|
|
277
|
+
'Pass exactly one of --seed "<text>", --seed-file <path>, --tickets <ids>, or --amends <id>.',
|
|
241
278
|
);
|
|
242
279
|
}
|
|
243
280
|
|
|
244
281
|
let mode;
|
|
245
282
|
let ticketIds;
|
|
246
|
-
|
|
283
|
+
let amendsId;
|
|
284
|
+
if (hasAmends) {
|
|
285
|
+
mode = 'amends';
|
|
286
|
+
amendsId = parseAmendsId(values.amends);
|
|
287
|
+
} else if (hasTickets) {
|
|
247
288
|
mode = 'tickets';
|
|
248
289
|
ticketIds = parseTicketIds(values.tickets);
|
|
249
290
|
} else if (hasSeedFile) {
|
|
@@ -288,6 +329,7 @@ async function main() {
|
|
|
288
329
|
seedFilePath: hasSeedFile ? seedFilePath : undefined,
|
|
289
330
|
seedText: hasSeed ? seedText : undefined,
|
|
290
331
|
ticketIds,
|
|
332
|
+
amendsId,
|
|
291
333
|
provider,
|
|
292
334
|
config,
|
|
293
335
|
settings,
|