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.
@@ -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
- complexitySignals: buildComplexitySignals({
593
- seedText: content,
594
- config,
595
- riskHeuristics: heuristics,
596
- cwd,
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: buildComplexitySignals({
752
- seedText: seed,
753
- config,
754
- riskHeuristics: heuristics,
755
- cwd,
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 "tickets".`,
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 and `deliver-recover.js`
76
- * builds its command from here, so the two surfaces never drift into
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
- * @param {'landed'|'pending'|'blocked'|'failed'} args.status
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
- storyId: Number(storyId),
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 entryForms = [hasSeed, hasSeedFile, hasTickets].filter(Boolean).length;
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 --tickets <ids>.',
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
- if (hasTickets) {
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,