mandrel 2.12.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.
@@ -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
  }
@@ -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,
@@ -44,6 +44,15 @@
44
44
  * --no-close-superseded Keep the source tickets open (no comment, no
45
45
  * close) — for a genuinely partial supersede
46
46
  * --dry-run Assemble + validate without GitHub writes
47
+ * --chain-on-clean Plan-diet fast path (Story #4741): run the
48
+ * write-free dry-run first, and — only when it
49
+ * passes clean AND the plan resolves to the `lite`
50
+ * route — chain straight into the real persist in
51
+ * the SAME invocation, collapsing the two operator
52
+ * round-trips into one. A dry-run failure stops
53
+ * before any createIssue; a full-route plan keeps
54
+ * its review round-trip (the chain declines, no
55
+ * writes). Ignored when `--dry-run` is also set
47
56
  * --force-review Operator-forced review stop before persist lands
48
57
  * --allow-over-budget / --allow-large-fan-out
49
58
  *
@@ -111,6 +120,7 @@ const CLI_OPTIONS = {
111
120
  'close-superseded': { type: 'boolean', default: true },
112
121
  'no-close-superseded': { type: 'boolean', default: false },
113
122
  'dry-run': { type: 'boolean', default: false },
123
+ 'chain-on-clean': { type: 'boolean', default: false },
114
124
  'force-review': { type: 'boolean', default: false },
115
125
  'allow-over-budget': { type: 'boolean', default: false },
116
126
  'allow-large-fan-out': { type: 'boolean', default: false },
@@ -122,7 +132,7 @@ const USAGE =
122
132
  '[--plan-acceptance <file>] ' +
123
133
  '[--source-tickets <ids>] [--no-close-superseded] ' +
124
134
  '[--route-downgrade-reason <text>] ' +
125
- '[--dry-run] [--force-review] ' +
135
+ '[--dry-run] [--chain-on-clean] [--force-review] ' +
126
136
  '[--allow-over-budget] [--allow-large-fan-out]';
127
137
 
128
138
  async function readOptional(filePath, { required }) {
@@ -229,8 +239,11 @@ async function runPersistInvocation({
229
239
  provider,
230
240
  artifacts,
231
241
  metricsSince,
242
+ dryRun,
232
243
  }) {
233
244
  const paths = resolveInputPaths(values);
245
+ const effectiveDryRun =
246
+ typeof dryRun === 'boolean' ? dryRun : values['dry-run'] === true;
234
247
  const settings = {
235
248
  baseBranch: config.project?.baseBranch,
236
249
  paths: config.project?.paths,
@@ -241,7 +254,7 @@ async function runPersistInvocation({
241
254
  return recordPlanInvocation(
242
255
  {
243
256
  cli: 'plan-persist',
244
- mode: values['dry-run'] ? 'dry-run' : 'persist',
257
+ mode: effectiveDryRun ? 'dry-run' : 'persist',
245
258
  config,
246
259
  },
247
260
  () =>
@@ -252,12 +265,83 @@ async function runPersistInvocation({
252
265
  settings,
253
266
  opts: {
254
267
  ...buildPersistOptions(values, paths, artifacts.planContextEnvelope),
268
+ dryRun: effectiveDryRun,
269
+ skipCleanup: effectiveDryRun,
255
270
  metricsSince,
256
271
  },
257
272
  }),
258
273
  );
259
274
  }
260
275
 
276
+ /**
277
+ * Plan-diet fast path (Story #4741 AC-1/AC-3): chain the lite dry-run into the
278
+ * real persist in ONE operator invocation.
279
+ *
280
+ * Two passes over the **same** loaded artifacts:
281
+ *
282
+ * 1. A write-free dry-run. Every gate runs before any `createIssue` can
283
+ * happen, so a validation failure — which throws or returns reachability
284
+ * orphans — stops here, before a single issue exists (AC-3).
285
+ * 2. The real write, run **only** when the dry-run passed clean AND resolved
286
+ * to the `lite` route. Because it replays the identical artifacts, the
287
+ * persisted output is byte-identical to what the dry-run validated
288
+ * (AC-1). A full-route plan keeps its review round-trip: the chain
289
+ * declines and returns the dry-run result, mutating nothing.
290
+ *
291
+ * Exported for tests — this is where the round-trip collapse and its
292
+ * fail-closed guard live, so a regression here silently re-opens the second
293
+ * operator round-trip (or worse, persists a plan the dry-run never gated).
294
+ *
295
+ * @param {{ values: object, config: object, provider: object,
296
+ * artifacts: object, metricsSince: string }} args
297
+ * @returns {Promise<object>} the persist result, plus a `chain` receipt.
298
+ */
299
+ export async function runPersistChain({
300
+ values,
301
+ config,
302
+ provider,
303
+ artifacts,
304
+ metricsSince,
305
+ }) {
306
+ const dryResult = await runPersistInvocation({
307
+ values,
308
+ config,
309
+ provider,
310
+ artifacts,
311
+ metricsSince,
312
+ dryRun: true,
313
+ });
314
+
315
+ if (dryResult.route?.route !== 'lite') {
316
+ dryResult.chain = {
317
+ attempted: true,
318
+ persisted: false,
319
+ reason: 'route-not-lite',
320
+ };
321
+ Logger.info(
322
+ '[plan-persist] --chain-on-clean: dry-run clean but the plan did not ' +
323
+ 'resolve to the lite route — declining the auto-persist; run persist ' +
324
+ 'explicitly after review.',
325
+ );
326
+ return dryResult;
327
+ }
328
+
329
+ const persistResult = await runPersistInvocation({
330
+ values,
331
+ config,
332
+ provider,
333
+ artifacts,
334
+ metricsSince,
335
+ dryRun: false,
336
+ });
337
+ persistResult.chain = {
338
+ attempted: true,
339
+ persisted: true,
340
+ reason: 'lite-dry-run-clean',
341
+ };
342
+ return persistResult;
343
+ }
344
+
261
345
  /**
262
346
  * Attach the plan-metrics roll-up for **this** invocation.
263
347
  *
@@ -318,15 +402,28 @@ async function main() {
318
402
  const paths = resolveInputPaths(values);
319
403
  const artifacts = await loadArtifacts(paths);
320
404
 
405
+ // `--chain-on-clean` collapses the dry-run + persist operator round-trips
406
+ // (Story #4741). `--dry-run` always wins — an explicit dry-run never writes.
407
+ const useChain =
408
+ values['chain-on-clean'] === true && values['dry-run'] !== true;
409
+
321
410
  let result;
322
411
  try {
323
- result = await runPersistInvocation({
324
- values,
325
- config,
326
- provider,
327
- artifacts,
328
- metricsSince,
329
- });
412
+ result = useChain
413
+ ? await runPersistChain({
414
+ values,
415
+ config,
416
+ provider,
417
+ artifacts,
418
+ metricsSince,
419
+ })
420
+ : await runPersistInvocation({
421
+ values,
422
+ config,
423
+ provider,
424
+ artifacts,
425
+ metricsSince,
426
+ });
330
427
  } catch (err) {
331
428
  if (err?.code === 'PLAN_REACHABILITY_ORPHANS') {
332
429
  process.stdout.write(`${err.message}\n`);