mandrel 1.91.0 → 1.92.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/README.md +5 -5
- package/.agents/docs/SDLC.md +75 -107
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/docs/workflows.md +4 -10
- package/.agents/instructions.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/lifecycle/README.md +2 -1
- package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
- package/.agents/schemas/risk-verdict.schema.json +14 -1
- package/.agents/scripts/README.md +0 -46
- package/.agents/scripts/analyze-execution.js +76 -1
- package/.agents/scripts/check-doc-links.js +3 -0
- package/.agents/scripts/check-workflow-cli-lint.js +208 -0
- package/.agents/scripts/epic-audit-recheck.js +1 -1
- package/.agents/scripts/epic-plan-clarity.js +16 -6
- package/.agents/scripts/epic-plan-decompose.js +37 -59
- package/.agents/scripts/epic-plan-healthcheck.js +39 -6
- package/.agents/scripts/epic-plan-spec.js +41 -168
- package/.agents/scripts/epic-reconcile.js +7 -1
- package/.agents/scripts/lib/Logger.js +4 -4
- package/.agents/scripts/lib/audit-suite/index.js +4 -5
- package/.agents/scripts/lib/audit-suite/runner.js +6 -4
- package/.agents/scripts/lib/audit-suite/selector.js +2 -3
- package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
- package/.agents/scripts/lib/command-header.js +20 -0
- package/.agents/scripts/lib/config/github.js +1 -3
- package/.agents/scripts/lib/config-settings-schema.js +4 -3
- package/.agents/scripts/lib/degraded-mode.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -3
- package/.agents/scripts/lib/label-taxonomy.js +14 -3
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
- package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
- package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-context.js +510 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
- package/.agents/scripts/lib/orchestration/plan-metrics.js +445 -0
- package/.agents/scripts/lib/orchestration/plan-persist/amend.js +359 -0
- package/.agents/scripts/lib/orchestration/plan-persist/delivery-mode.js +127 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +978 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +191 -0
- package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
- package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
- package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
- package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
- package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
- package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
- package/.agents/scripts/plan-context.js +186 -0
- package/.agents/scripts/plan-critics.js +227 -0
- package/.agents/scripts/plan-persist.js +383 -0
- package/.agents/scripts/pr-watch-with-update.js +8 -7
- package/.agents/scripts/run-lint.js +10 -11
- package/.agents/scripts/story-plan.js +19 -7
- package/.agents/scripts/sync-claude-commands.js +10 -5
- package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
- package/.agents/workflows/audit-documentation.md +5 -7
- package/.agents/workflows/audit-lighthouse.md +1 -0
- package/.agents/workflows/audit-security.md +1 -0
- package/.agents/workflows/audit-to-stories.md +16 -5
- package/.agents/workflows/git-deliver.md +76 -243
- package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
- package/.agents/workflows/helpers/code-review.md +7 -6
- package/.agents/workflows/helpers/deliver-epic.md +5 -4
- package/.agents/workflows/helpers/plan-epic-reference.md +109 -96
- package/.agents/workflows/helpers/plan-epic.md +246 -999
- package/.agents/workflows/helpers/plan-story.md +14 -14
- package/.agents/workflows/helpers/scope-triage-gate.md +6 -4
- package/.agents/workflows/helpers/single-story-deliver.md +5 -4
- package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
- package/.agents/workflows/mandrel-update.md +144 -466
- package/.agents/workflows/plan.md +73 -143
- package/docs/CHANGELOG.md +13 -0
- package/lib/cli/registry.js +21 -3
- package/package.json +1 -1
- package/.agents/schemas/loop-unit.schema.json +0 -70
- package/.agents/scripts/assert-branch.js +0 -81
- package/.agents/scripts/check-loop-units.js +0 -204
- package/.agents/scripts/detect-merges.js +0 -111
- package/.agents/scripts/git-pr-quality-gate.js +0 -205
- package/.agents/scripts/git-rebase-and-resolve.js +0 -234
- package/.agents/scripts/hierarchy-gate.js +0 -192
- package/.agents/scripts/hydrate-context.js +0 -179
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
- package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
- package/.agents/scripts/loc-delta.js +0 -205
- package/.agents/scripts/render-manifest.js +0 -143
- package/.agents/scripts/run-audit-suite.js +0 -97
- package/.agents/scripts/select-audits.js +0 -155
- package/.agents/scripts/update-mutation-baseline.js +0 -189
- package/.agents/workflows/explain.md +0 -118
- package/.agents/workflows/git-merge-pr.md +0 -377
- package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
- package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
- package/.agents/workflows/loops/README.md +0 -65
- package/.agents/workflows/loops/fix-failing-tests.md +0 -74
- package/.agents/workflows/loops/nightly-audit.md +0 -81
- package/.agents/workflows/loops/watch-ci.md +0 -68
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-plan-persist.js — single GitHub-write surface for the /plan collapse
|
|
3
|
+
* (Epic #4474, PR3 + PR4 modes).
|
|
4
|
+
*
|
|
5
|
+
* Implements the ordered, fail-closed superset persist that replaced the
|
|
6
|
+
* retired 12-phase pipeline's separate persist halves
|
|
7
|
+
* (design §1 Step 3 + §2 mode matrix, issue #4474):
|
|
8
|
+
*
|
|
9
|
+
* 1. args (owned by the `plan-persist.js` CLI shell)
|
|
10
|
+
* 2. section gate — `validateSpecSections` runs BEFORE the lease and
|
|
11
|
+
* BEFORE any provider call; a rejection makes zero GitHub calls.
|
|
12
|
+
* 3. risk-verdict validation (CLI-owned `loadRiskVerdict`) +
|
|
13
|
+
* mode-coherence hard error (`resolveDeliveryMode`): fan-out requires
|
|
14
|
+
* tickets; `deliveryShape: "single"` refuses any tickets payload —
|
|
15
|
+
* the single mode's validator/DAG skip is fenced by construction,
|
|
16
|
+
* unreachable when tickets are present; `--amend` requires tickets
|
|
17
|
+
* carrying `op` fields and refuses the single shape.
|
|
18
|
+
* 4. ticket validator + file-assumption gate + DAG + sizing + budget
|
|
19
|
+
* (fan-out and amend; amend validates the MERGED set — existing
|
|
20
|
+
* keeps + adds + modifies, closes excluded; all git-local, still
|
|
21
|
+
* zero provider calls). Skipped entirely in single mode (no tickets
|
|
22
|
+
* exist to validate).
|
|
23
|
+
* 4.5. deterministic draft reachability (Epic #4474 PR6, design §4 —
|
|
24
|
+
* the 8.4 critic demoted into persist): route-glob scan of the
|
|
25
|
+
* draft set vs `planning.navigation.navRegistry`, mirroring the
|
|
26
|
+
* `--paranoid` F7 healthcheck mechanics. Orphan surfaces are a
|
|
27
|
+
* NAMED SOFT FAILURE (`code: PLAN_REACHABILITY_ORPHANS`, CLI exit
|
|
28
|
+
* 3) raised before any provider call — the author appends the
|
|
29
|
+
* single reachability Story in one targeted amend and re-runs the
|
|
30
|
+
* persist once. Silent no-op when `planning.navigation` is
|
|
31
|
+
* unconfigured; skip decisions are appended to the plan-metrics
|
|
32
|
+
* ledger (`kind: critic-skip`) for audit.
|
|
33
|
+
* 5. ideation fold — `renderEpicBody` / `openEpicFromOnePager` create
|
|
34
|
+
* the Epic when the run starts from a one-pager (the first provider
|
|
35
|
+
* call of the run). Amend additionally resolves every
|
|
36
|
+
* modify/keep/close slug to its live issue and enforces the close-op
|
|
37
|
+
* confirmation gate (exit 2 without `--explicit-delete`) BEFORE the
|
|
38
|
+
* lease and before any mutation.
|
|
39
|
+
* 6. Epic lease (KEEP — documented double-create at ~80 creations).
|
|
40
|
+
* From here the lease is released on EVERY exit path (success, gate
|
|
41
|
+
* failure, throw) via try/finally.
|
|
42
|
+
* 7. managed Tech Spec / Acceptance Table sections + risk-verdict
|
|
43
|
+
* structured comment + spec-freshness advisory.
|
|
44
|
+
* 8. mode-split mutation:
|
|
45
|
+
* - fan-out: story creation via the structural reconciler
|
|
46
|
+
* (idempotent per-slug creation; the reconciler's state file is
|
|
47
|
+
* the per-slug resume ledger), bracketed by checkpoint-v2 writes
|
|
48
|
+
* so a rate-limit crash resumes losslessly with `--resume`;
|
|
49
|
+
* - single: NO story tree — the `delivery::single` routing marker is
|
|
50
|
+
* applied instead (inert until #4475 lands the deliver-side
|
|
51
|
+
* reader), and `decompose = { ticketCount: 0, shape: "single" }`
|
|
52
|
+
* is checkpointed so delivery-time consumers never misread absence
|
|
53
|
+
* as unplanned;
|
|
54
|
+
* - amend: op-mapped delta — close ops close, modify ops
|
|
55
|
+
* close-and-recreate, add ops create, keep ops are untouched by
|
|
56
|
+
* construction; the state ledger and blocked-by edges are rebuilt
|
|
57
|
+
* over the merged set.
|
|
58
|
+
* 9. inline post-plan healthcheck (the `agent::ready` exit condition,
|
|
59
|
+
* Story #2921).
|
|
60
|
+
* 10. single terminal `agent::ready` flip — the intermediate
|
|
61
|
+
* `agent::review-spec` flip is retired on this surface (its readers
|
|
62
|
+
* were visibility-only; the /deliver start gate needs only
|
|
63
|
+
* `agent::ready`).
|
|
64
|
+
* 11. checkpoint v2 + single `plan-summary` comment carrying the dry-run
|
|
65
|
+
* wave table as closing text (replaces the Phase 9 dispatcher
|
|
66
|
+
* round-trip and the Phase 12 notify). The single-mode summary
|
|
67
|
+
* records `{ deliveryShape: "single", sliceCount, routingReasons }`.
|
|
68
|
+
* 12. temp cleanup ONLY at terminal success — a failed run leaves
|
|
69
|
+
* techspec/acceptance/risk-verdict/tickets artifacts on disk so a
|
|
70
|
+
* `--force`/`--resume` re-persist reuses them (fixes the
|
|
71
|
+
* `plan-phase-cleanup.js` mid-pipeline deletion defect).
|
|
72
|
+
*
|
|
73
|
+
* Checkpoint v2: same `epic-plan-state` structured comment, `version: 2`,
|
|
74
|
+
* with the `planningRisk` / `riskVerdict` / `reviewRouting` / `spec` /
|
|
75
|
+
* `decompose` fields byte-compatible with v1 so the four delivery-time
|
|
76
|
+
* consumers — `lib/orchestration/code-review.js` (review depth),
|
|
77
|
+
* `epic-audit-prepare.js` (audit-lens routing),
|
|
78
|
+
* `story-close/phases/locked-pipeline.js` (parent-risk inheritance), and
|
|
79
|
+
* the decompose context reader — read it without modification. The only
|
|
80
|
+
* additions are the `version` bump and the additive `persist` progress
|
|
81
|
+
* block; consumers key on field presence, never on `version`.
|
|
82
|
+
*
|
|
83
|
+
* @module lib/orchestration/plan-persist/run-plan-persist
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
87
|
+
import path from 'node:path';
|
|
88
|
+
|
|
89
|
+
import { runPlanHealthcheck as defaultRunPlanHealthcheck } from '../../../epic-plan-healthcheck.js';
|
|
90
|
+
import { verifyBddRunnerPendingTag } from '../../bdd-runner-detect.js';
|
|
91
|
+
import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
|
|
92
|
+
import { openEpicFromOnePager } from '../../epic-plan-ideation.js';
|
|
93
|
+
import { gitSpawn } from '../../git-utils.js';
|
|
94
|
+
import { Logger } from '../../Logger.js';
|
|
95
|
+
import {
|
|
96
|
+
AGENT_LABELS,
|
|
97
|
+
DELIVERY_LABELS,
|
|
98
|
+
TYPE_LABELS,
|
|
99
|
+
} from '../../label-constants.js';
|
|
100
|
+
import { cleanupPhaseTempFiles } from '../../plan-phase-cleanup.js';
|
|
101
|
+
import { loadState, writeSpec, writeState } from '../../spec/index.js';
|
|
102
|
+
import {
|
|
103
|
+
reconcileSubIssueLinks,
|
|
104
|
+
setBlockedByDependencies,
|
|
105
|
+
setEpicLabel,
|
|
106
|
+
warnTicketCapNearLimit,
|
|
107
|
+
} from '../epic-plan-decompose/phases/creation.js';
|
|
108
|
+
import {
|
|
109
|
+
enforceFanOutGate,
|
|
110
|
+
runHealthcheckGate,
|
|
111
|
+
surfaceSoftConflictFindings,
|
|
112
|
+
} from '../epic-plan-decompose/phases/persist.js';
|
|
113
|
+
import {
|
|
114
|
+
buildEpicSpecInput,
|
|
115
|
+
validateTickets,
|
|
116
|
+
} from '../epic-plan-decompose/phases/persist-helpers.js';
|
|
117
|
+
import {
|
|
118
|
+
RECONCILE_CLI,
|
|
119
|
+
spawnReconcilerApply,
|
|
120
|
+
} from '../epic-plan-decompose/phases/reconcile-spawn.js';
|
|
121
|
+
import {
|
|
122
|
+
acquireEpicPlanLease,
|
|
123
|
+
assertNoOpenPlanChildren,
|
|
124
|
+
releaseEpicPlanLease,
|
|
125
|
+
} from '../epic-plan-lease-guard.js';
|
|
126
|
+
import { planEpic } from '../epic-plan-spec/phases/plan-epic.js';
|
|
127
|
+
import { runSpecFreshnessCheck } from '../epic-plan-spec/phases/spec-freshness.js';
|
|
128
|
+
import {
|
|
129
|
+
initialize as initializePlanState,
|
|
130
|
+
read as readPlanState,
|
|
131
|
+
write as writePlanState,
|
|
132
|
+
} from '../epic-plan-state-store.js';
|
|
133
|
+
import {
|
|
134
|
+
appendCriticSkip,
|
|
135
|
+
readPlanMetrics,
|
|
136
|
+
renderPlanMetricsSummaryLine,
|
|
137
|
+
summarizePlanMetrics,
|
|
138
|
+
} from '../plan-metrics.js';
|
|
139
|
+
import {
|
|
140
|
+
evaluateDraftReachability,
|
|
141
|
+
renderReachabilityOrphans,
|
|
142
|
+
} from '../plan-reachability.js';
|
|
143
|
+
import { resolveReviewRouting } from '../plan-review-routing.js';
|
|
144
|
+
import { deriveRiskEnvelope } from '../planning-risk.js';
|
|
145
|
+
import { renderSpec } from '../spec-renderer.js';
|
|
146
|
+
import {
|
|
147
|
+
formatMissingSectionMessage,
|
|
148
|
+
validateSpecSections,
|
|
149
|
+
} from '../spec-section-validator.js';
|
|
150
|
+
import { upsertStructuredComment } from '../ticketing.js';
|
|
151
|
+
import {
|
|
152
|
+
applyAmendOps,
|
|
153
|
+
buildMergedTicketSet,
|
|
154
|
+
enforceAmendCloseGate,
|
|
155
|
+
partitionAmendTickets,
|
|
156
|
+
renderAmendPlanDiff,
|
|
157
|
+
resolveAmendTargets,
|
|
158
|
+
} from './amend.js';
|
|
159
|
+
import { countDeliverySlices, resolveDeliveryMode } from './delivery-mode.js';
|
|
160
|
+
import {
|
|
161
|
+
buildPlanSummaryCommentBody,
|
|
162
|
+
buildWaveTable,
|
|
163
|
+
PLAN_SUMMARY_COMMENT_TYPE,
|
|
164
|
+
} from './summary.js';
|
|
165
|
+
|
|
166
|
+
/** Checkpoint schema version written by this surface. */
|
|
167
|
+
export const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
|
|
168
|
+
|
|
169
|
+
// Mode-coherence resolution (design §1 Step 3 item 3 + §2 mode matrix)
|
|
170
|
+
// lives in `delivery-mode.js`; re-exported here so the CLI's stable public
|
|
171
|
+
// API keeps a single import root for the persist surface.
|
|
172
|
+
export { resolveDeliveryMode };
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Merge-write the epic-plan-state checkpoint at schema v2. Reads the
|
|
176
|
+
* current checkpoint (or initializes a fresh skeleton), shallow-merges
|
|
177
|
+
* `patch`, and stamps `version: 2`. Field shapes for `planningRisk`,
|
|
178
|
+
* `riskVerdict`, `reviewRouting`, `spec`, and `decompose` are byte-compatible
|
|
179
|
+
* with v1 — v2 is additive only.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} provider
|
|
182
|
+
* @param {number} epicId
|
|
183
|
+
* @param {object} patch
|
|
184
|
+
* @returns {Promise<object>} the written checkpoint payload
|
|
185
|
+
*/
|
|
186
|
+
export async function writeCheckpointV2(provider, epicId, patch) {
|
|
187
|
+
const current =
|
|
188
|
+
(await readPlanState({ provider, epicId })) ??
|
|
189
|
+
(await initializePlanState({ provider, epicId }));
|
|
190
|
+
// One-level deep merge for object-valued blocks (`spec`, `decompose`,
|
|
191
|
+
// `persist`, …) so a partial patch (e.g. `persist: { completedAt }`)
|
|
192
|
+
// refines rather than replaces the block — same discipline the v1
|
|
193
|
+
// writers applied by hand with `...currentState.decompose`.
|
|
194
|
+
const merged = { ...current };
|
|
195
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
196
|
+
const existing = merged[key];
|
|
197
|
+
if (
|
|
198
|
+
value !== null &&
|
|
199
|
+
typeof value === 'object' &&
|
|
200
|
+
!Array.isArray(value) &&
|
|
201
|
+
existing !== null &&
|
|
202
|
+
typeof existing === 'object' &&
|
|
203
|
+
!Array.isArray(existing)
|
|
204
|
+
) {
|
|
205
|
+
merged[key] = { ...existing, ...value };
|
|
206
|
+
} else {
|
|
207
|
+
merged[key] = value;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return writePlanState({
|
|
211
|
+
provider,
|
|
212
|
+
epicId,
|
|
213
|
+
state: {
|
|
214
|
+
...merged,
|
|
215
|
+
version: PLAN_CHECKPOINT_SCHEMA_VERSION_V2,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve (or create) the Epic this persist run targets.
|
|
222
|
+
*
|
|
223
|
+
* Ideation mode (`onePagerContent` present): folds the former Phase 3/4
|
|
224
|
+
* ideation steps in — `openEpicFromOnePager` renders the Epic body from the
|
|
225
|
+
* one-pager via the canonical template and opens the Issue with the
|
|
226
|
+
* `type::epic` label. This is deliberately the FIRST provider call of the
|
|
227
|
+
* run (after every deterministic gate), so a gate rejection never leaves an
|
|
228
|
+
* orphaned Epic behind.
|
|
229
|
+
*
|
|
230
|
+
* Existing-Epic mode: fetches and type-asserts the Epic.
|
|
231
|
+
*
|
|
232
|
+
* @returns {Promise<{ epicId: number, epic: object, created: boolean }>}
|
|
233
|
+
*/
|
|
234
|
+
async function resolveTargetEpic({
|
|
235
|
+
epicId,
|
|
236
|
+
onePagerContent,
|
|
237
|
+
templateContent,
|
|
238
|
+
provider,
|
|
239
|
+
}) {
|
|
240
|
+
if (onePagerContent) {
|
|
241
|
+
if (typeof provider.createIssue !== 'function') {
|
|
242
|
+
throw new Error(
|
|
243
|
+
'[plan-persist] provider does not expose createIssue; cannot open ' +
|
|
244
|
+
'an Epic from a one-pager.',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const created = await openEpicFromOnePager({
|
|
248
|
+
onePager: onePagerContent,
|
|
249
|
+
template: templateContent,
|
|
250
|
+
createIssue: (payload) => provider.createIssue(payload),
|
|
251
|
+
});
|
|
252
|
+
Logger.info(
|
|
253
|
+
`[plan-persist] Opened Epic #${created.id} from one-pager ("${created.title}").`,
|
|
254
|
+
);
|
|
255
|
+
const epic = await provider.getEpic(created.id);
|
|
256
|
+
if (!epic) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`[plan-persist] Epic #${created.id} was created but could not be re-fetched.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return { epicId: created.id, epic, created: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const epic = await provider.getEpic(epicId);
|
|
265
|
+
if (!epic) {
|
|
266
|
+
throw new Error(`[plan-persist] Epic #${epicId} not found.`);
|
|
267
|
+
}
|
|
268
|
+
if (!epic.labels?.includes(TYPE_LABELS.EPIC)) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`[plan-persist] Ticket #${epicId} is not a ${TYPE_LABELS.EPIC}.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return { epicId, epic, created: false };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Execute the collapsed persist end to end (module doc has the 12-step
|
|
278
|
+
* order). Modes: fan-out (default), single (`deliveryShape: "single"`),
|
|
279
|
+
* amend (`--amend`).
|
|
280
|
+
*
|
|
281
|
+
* @param {{
|
|
282
|
+
* epicId?: number|null,
|
|
283
|
+
* provider: import('../../ITicketingProvider.js').ITicketingProvider,
|
|
284
|
+
* artifacts: {
|
|
285
|
+
* techSpecContent: string,
|
|
286
|
+
* acceptanceSpecContent?: string|null,
|
|
287
|
+
* riskVerdict: import('../planning-risk.js').RiskVerdict,
|
|
288
|
+
* tickets?: Array<object>|null,
|
|
289
|
+
* onePagerContent?: string|null,
|
|
290
|
+
* templateContent?: string|null,
|
|
291
|
+
* },
|
|
292
|
+
* config?: object,
|
|
293
|
+
* settings?: { baseBranch?: string, paths?: { tempRoot?: string } },
|
|
294
|
+
* opts?: {
|
|
295
|
+
* force?: boolean,
|
|
296
|
+
* resume?: boolean,
|
|
297
|
+
* amend?: boolean,
|
|
298
|
+
* explicitDelete?: boolean,
|
|
299
|
+
* steal?: boolean,
|
|
300
|
+
* forceReview?: boolean,
|
|
301
|
+
* allowOverBudget?: boolean,
|
|
302
|
+
* allowLargeFanOut?: boolean,
|
|
303
|
+
* // test seams (production callers must not set these)
|
|
304
|
+
* skipHealthcheck?: boolean,
|
|
305
|
+
* skipCleanup?: boolean,
|
|
306
|
+
* spawnSync?: typeof defaultSpawnSync,
|
|
307
|
+
* reconcileCli?: string,
|
|
308
|
+
* writeSpecFn?: typeof writeSpec,
|
|
309
|
+
* renderSpecFn?: typeof renderSpec,
|
|
310
|
+
* loadStateFn?: typeof loadState,
|
|
311
|
+
* writeStateFn?: typeof writeState,
|
|
312
|
+
* runHealthcheckFn?: typeof defaultRunPlanHealthcheck,
|
|
313
|
+
* bddProbeFn?: typeof verifyBddRunnerPendingTag,
|
|
314
|
+
* fanOutCounter?: (arg: { path: string }) => number,
|
|
315
|
+
* cwd?: string,
|
|
316
|
+
* },
|
|
317
|
+
* }} input
|
|
318
|
+
*/
|
|
319
|
+
export async function runPlanPersist({
|
|
320
|
+
epicId: requestedEpicId = null,
|
|
321
|
+
provider,
|
|
322
|
+
artifacts,
|
|
323
|
+
config = {},
|
|
324
|
+
settings = {},
|
|
325
|
+
opts = {},
|
|
326
|
+
}) {
|
|
327
|
+
const {
|
|
328
|
+
techSpecContent,
|
|
329
|
+
acceptanceSpecContent = null,
|
|
330
|
+
riskVerdict,
|
|
331
|
+
tickets = null,
|
|
332
|
+
onePagerContent = null,
|
|
333
|
+
templateContent = null,
|
|
334
|
+
} = artifacts ?? {};
|
|
335
|
+
const {
|
|
336
|
+
force = false,
|
|
337
|
+
resume = false,
|
|
338
|
+
amend = false,
|
|
339
|
+
explicitDelete = false,
|
|
340
|
+
steal = false,
|
|
341
|
+
forceReview = false,
|
|
342
|
+
allowOverBudget = false,
|
|
343
|
+
allowLargeFanOut = false,
|
|
344
|
+
skipHealthcheck = false,
|
|
345
|
+
skipCleanup = false,
|
|
346
|
+
spawnSync = defaultSpawnSync,
|
|
347
|
+
reconcileCli = RECONCILE_CLI,
|
|
348
|
+
writeSpecFn = writeSpec,
|
|
349
|
+
renderSpecFn = renderSpec,
|
|
350
|
+
loadStateFn = loadState,
|
|
351
|
+
writeStateFn = writeState,
|
|
352
|
+
runHealthcheckFn = defaultRunPlanHealthcheck,
|
|
353
|
+
bddProbeFn = verifyBddRunnerPendingTag,
|
|
354
|
+
fanOutCounter = undefined,
|
|
355
|
+
cwd = PROJECT_ROOT,
|
|
356
|
+
} = opts;
|
|
357
|
+
|
|
358
|
+
// ---- Step 1: argument coherence (flag parsing itself is CLI-owned). ----
|
|
359
|
+
if (force && resume) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
'[plan-persist] --force and --resume are mutually exclusive.',
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (amend && (force || resume)) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
'[plan-persist] --amend is mutually exclusive with --force/--resume ' +
|
|
367
|
+
'— the amend delta is already an incremental re-persist.',
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (amend && onePagerContent) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
'[plan-persist] --amend requires --epic <id> — there is no existing ' +
|
|
373
|
+
'plan to amend in ideation mode.',
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (onePagerContent && resume) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
'[plan-persist] --resume requires --epic <id> — the Epic already ' +
|
|
379
|
+
"exists after the first attempt (its number is in the failed run's " +
|
|
380
|
+
'output); an ideation --resume would open a duplicate.',
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (onePagerContent && !templateContent) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
'[plan-persist] ideation mode requires the epic-from-idea template ' +
|
|
386
|
+
'content (templateContent).',
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (!onePagerContent && !Number.isInteger(requestedEpicId)) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
'[plan-persist] either --epic <id> or --one-pager <path> is required.',
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---- Step 2: section gate — BEFORE the lease, BEFORE any provider call.
|
|
396
|
+
// A rejection here has made zero GitHub calls (locked in by the
|
|
397
|
+
// fail-closed-ordering test).
|
|
398
|
+
const sectionCheck = validateSpecSections({ body: techSpecContent });
|
|
399
|
+
if (!sectionCheck.ok) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
formatMissingSectionMessage({
|
|
402
|
+
techspecPath: 'authored Tech Spec (--tech-spec)',
|
|
403
|
+
missing: sectionCheck.missing,
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---- Step 3: risk-verdict presence (schema validation is CLI-owned via
|
|
409
|
+
// loadRiskVerdict) + mode-coherence hard error. ----
|
|
410
|
+
if (!riskVerdict || !Array.isArray(riskVerdict.axes)) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
'[plan-persist] risk verdict is required — author risk-verdict.json ' +
|
|
413
|
+
'and pass it with --risk-verdict.',
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const mode = resolveDeliveryMode(riskVerdict, tickets, { amend });
|
|
417
|
+
|
|
418
|
+
// ---- Step 4: ticket validator + file-assumption gate + DAG + sizing +
|
|
419
|
+
// budget (fan-out and amend; git-local — still no provider call). In
|
|
420
|
+
// amend mode every gate runs over the MERGED set — keeps + adds +
|
|
421
|
+
// modifies, closes excluded — so the DAG is validated against the tree
|
|
422
|
+
// that will actually exist post-amend. The skip branch below is fenced
|
|
423
|
+
// by construction: `resolveDeliveryMode` hard-refuses `deliveryShape:
|
|
424
|
+
// "single"` with any tickets payload, so single mode can only reach here
|
|
425
|
+
// with no tickets to validate. ----
|
|
426
|
+
let validated = null;
|
|
427
|
+
let amendPartition = null;
|
|
428
|
+
let reachability = null;
|
|
429
|
+
if (mode !== 'single') {
|
|
430
|
+
amendPartition = mode === 'amend' ? partitionAmendTickets(tickets) : null;
|
|
431
|
+
const gateSet = mode === 'amend' ? buildMergedTicketSet(tickets) : tickets;
|
|
432
|
+
const maxTickets = getLimits(config).maxTickets;
|
|
433
|
+
if (gateSet.length > maxTickets && !allowOverBudget) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`[plan-persist] Tickets (${gateSet.length}) exceed the reviewability ` +
|
|
436
|
+
`budget (${maxTickets}). Re-scope the Epic into a smaller plan, or ` +
|
|
437
|
+
'rerun with --allow-over-budget after confirming the over-budget ' +
|
|
438
|
+
'rationale on the Epic.',
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
warnTicketCapNearLimit(gateSet, maxTickets, 'plan-persist');
|
|
442
|
+
if (gateSet.length > maxTickets && allowOverBudget) {
|
|
443
|
+
Logger.warn(
|
|
444
|
+
`[plan-persist] Persisting an over-budget decomposition: ${gateSet.length} ` +
|
|
445
|
+
`tickets vs. budget ${maxTickets} (operator override --allow-over-budget).`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
Logger.info(
|
|
449
|
+
`[plan-persist] Running cross-validation on ${gateSet.length} tickets` +
|
|
450
|
+
`${mode === 'amend' ? ' (merged amend set)' : ''}...`,
|
|
451
|
+
);
|
|
452
|
+
validated = validateTickets(gateSet, config, { fanOutCounter, cwd });
|
|
453
|
+
enforceFanOutGate(validated.findings, allowLargeFanOut, 'plan-persist');
|
|
454
|
+
surfaceSoftConflictFindings(validated.findings, 'plan-persist');
|
|
455
|
+
|
|
456
|
+
// File-assumption gate (#4474 PR7 — coverage regression fix). The
|
|
457
|
+
// validator batches per-Story `{ path, assumption }` mismatches against
|
|
458
|
+
// the base branch onto `validated.errors`; the retired 12-phase flow
|
|
459
|
+
// gated that channel in the workflow's re-prompt loop, so the collapsed
|
|
460
|
+
// CLI must gate it here or the check is silently advisory. Fan-out and
|
|
461
|
+
// shared-editor findings keep their own policy channels above — this
|
|
462
|
+
// rejects only the deterministic assumption mismatches, still before
|
|
463
|
+
// any provider call. Guarded on ref resolvability: in a checkout where
|
|
464
|
+
// the base branch ref does not resolve (shallow CI fetches, detached
|
|
465
|
+
// test sandboxes) every path probes "absent" and the findings are
|
|
466
|
+
// noise, so they downgrade to warnings instead of hard-failing.
|
|
467
|
+
const assumptionFailures = (validated.errors ?? []).filter((e) =>
|
|
468
|
+
e.startsWith('File assumption mismatch:'),
|
|
469
|
+
);
|
|
470
|
+
if (assumptionFailures.length > 0) {
|
|
471
|
+
const gateBaseRef = config?.baseBranch ?? 'main';
|
|
472
|
+
const refResolves =
|
|
473
|
+
gitSpawn(
|
|
474
|
+
cwd ?? process.cwd(),
|
|
475
|
+
'rev-parse',
|
|
476
|
+
'--verify',
|
|
477
|
+
'--quiet',
|
|
478
|
+
`${gateBaseRef}^{commit}`,
|
|
479
|
+
).status === 0;
|
|
480
|
+
if (refResolves) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`[plan-persist] file-assumption gate: ${assumptionFailures.length} ` +
|
|
483
|
+
`mismatch(es) between declared assumptions and the base branch:\n` +
|
|
484
|
+
`${assumptionFailures.map((e) => ` - ${e}`).join('\n')}\n` +
|
|
485
|
+
'Fix the Story change declarations (or the plan) and re-run the persist.',
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
Logger.warn(
|
|
489
|
+
`[plan-persist] file-assumption gate skipped: base ref '${gateBaseRef}' ` +
|
|
490
|
+
`does not resolve in this checkout — ${assumptionFailures.length} ` +
|
|
491
|
+
'finding(s) downgraded to warnings.',
|
|
492
|
+
);
|
|
493
|
+
for (const e of assumptionFailures) {
|
|
494
|
+
Logger.warn(`[plan-persist] ${e}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---- Step 4.5: deterministic draft reachability (#4474 PR6 — the 8.4
|
|
499
|
+
// critic demoted into persist). Still git-local, zero provider calls,
|
|
500
|
+
// so the one-targeted-amend recovery re-runs a clean persist. ----
|
|
501
|
+
reachability = evaluateDraftReachability({ tickets: gateSet, config });
|
|
502
|
+
if (reachability.status === 'orphans') {
|
|
503
|
+
const err = new Error(renderReachabilityOrphans(reachability));
|
|
504
|
+
err.code = 'PLAN_REACHABILITY_ORPHANS';
|
|
505
|
+
err.orphans = reachability.orphans;
|
|
506
|
+
throw err;
|
|
507
|
+
}
|
|
508
|
+
Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
|
|
509
|
+
} else {
|
|
510
|
+
reachability = {
|
|
511
|
+
status: 'skipped',
|
|
512
|
+
reasons: [
|
|
513
|
+
'single-delivery shape — no draft story tree to scan for orphan surfaces.',
|
|
514
|
+
],
|
|
515
|
+
orphans: [],
|
|
516
|
+
scanned: 0,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (reachability.status === 'skipped') {
|
|
520
|
+
// Audit trail for the skip decision (#4474 PR6). Best-effort by
|
|
521
|
+
// contract — a failed append never fails the persist. Logged before
|
|
522
|
+
// the first provider call so even an ideation run that later fails
|
|
523
|
+
// still records the decision (ideation has no Epic id yet, so the
|
|
524
|
+
// record lands on the standalone stream).
|
|
525
|
+
await appendCriticSkip(
|
|
526
|
+
{
|
|
527
|
+
critic: 'reachability',
|
|
528
|
+
reasons: reachability.reasons,
|
|
529
|
+
cli: 'plan-persist',
|
|
530
|
+
epicId: requestedEpicId,
|
|
531
|
+
},
|
|
532
|
+
config,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---- Step 5: ideation fold / Epic resolution (first provider call). ----
|
|
537
|
+
const { epicId, epic, created } = await resolveTargetEpic({
|
|
538
|
+
epicId: requestedEpicId,
|
|
539
|
+
onePagerContent,
|
|
540
|
+
templateContent,
|
|
541
|
+
provider,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Amend pre-mutation resolution: every modify/keep/close slug must
|
|
545
|
+
// resolve to a live issue, and close ops require --explicit-delete
|
|
546
|
+
// (exit 2 with the dry-run diff otherwise — the epic-reconcile.js
|
|
547
|
+
// contract). Runs BEFORE the lease and before any mutation.
|
|
548
|
+
let amendTargets = null;
|
|
549
|
+
if (mode === 'amend') {
|
|
550
|
+
const priorState = loadStateFn(epicId);
|
|
551
|
+
amendTargets = await resolveAmendTargets({
|
|
552
|
+
partition: amendPartition,
|
|
553
|
+
stateMapping: priorState.mapping,
|
|
554
|
+
provider,
|
|
555
|
+
});
|
|
556
|
+
enforceAmendCloseGate({
|
|
557
|
+
epicId,
|
|
558
|
+
targets: amendTargets,
|
|
559
|
+
adds: amendPartition.add,
|
|
560
|
+
explicitDelete,
|
|
561
|
+
});
|
|
562
|
+
Logger.info(
|
|
563
|
+
renderAmendPlanDiff({
|
|
564
|
+
epicId,
|
|
565
|
+
targets: amendTargets,
|
|
566
|
+
adds: amendPartition.add,
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---- Step 6: Epic lease. Every path after a successful acquire runs
|
|
572
|
+
// through the finally below, so the lease is released on success, on a
|
|
573
|
+
// gate failure, and on a throw alike. ----
|
|
574
|
+
await acquireEpicPlanLease({ provider, epicId, config, steal });
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
// Refuse a duplicate story tree unless this is a deliberate re-persist
|
|
578
|
+
// (`--force` closes + recreates via the reconciler's close ops;
|
|
579
|
+
// `--resume` continues a partial persist). Amend is exempt by
|
|
580
|
+
// definition — its whole purpose is mutating the existing open tree.
|
|
581
|
+
if (mode !== 'amend') {
|
|
582
|
+
await assertNoOpenPlanChildren({
|
|
583
|
+
provider,
|
|
584
|
+
epicId,
|
|
585
|
+
force: force || resume,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await initializePlanState({ provider, epicId });
|
|
590
|
+
|
|
591
|
+
// ---- Step 7: managed sections + risk comment + freshness advisory. ----
|
|
592
|
+
// BDD-runner probe (Story #4145): best-effort; a probe failure degrades
|
|
593
|
+
// to "runner present" and never blocks the persist.
|
|
594
|
+
let bddRunner = null;
|
|
595
|
+
try {
|
|
596
|
+
bddRunner = await bddProbeFn({ cwd: PROJECT_ROOT });
|
|
597
|
+
} catch (err) {
|
|
598
|
+
Logger.warn(
|
|
599
|
+
`[plan-persist] BDD runner probe skipped (${err.message}); ` +
|
|
600
|
+
'acceptance disposition derived from risk axes only.',
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const planningRisk = deriveRiskEnvelope(riskVerdict, { bddRunner });
|
|
604
|
+
if (planningRisk.acceptanceWaivedReason) {
|
|
605
|
+
Logger.info(
|
|
606
|
+
`[plan-persist] Acceptance disposition forced to not-applicable for ` +
|
|
607
|
+
`Epic #${epicId}: ${planningRisk.acceptanceWaivedReason}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Amend always overwrites the managed sections — the amended Tech Spec
|
|
612
|
+
// IS the delta's spec half (planEpic would otherwise short-circuit
|
|
613
|
+
// `already-planned` on the pre-amend sections).
|
|
614
|
+
const planResult = await planEpic(
|
|
615
|
+
epicId,
|
|
616
|
+
provider,
|
|
617
|
+
{ techSpecContent, acceptanceSpecContent },
|
|
618
|
+
settings,
|
|
619
|
+
{ force: force || mode === 'amend', planningRisk },
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
const reviewRouting = resolveReviewRouting({ planningRisk, forceReview });
|
|
623
|
+
Logger.info(`[plan-persist] Review routing: ${reviewRouting.decision}.`);
|
|
624
|
+
|
|
625
|
+
await upsertStructuredComment(
|
|
626
|
+
provider,
|
|
627
|
+
epicId,
|
|
628
|
+
'risk-verdict',
|
|
629
|
+
buildRiskVerdictCommentBody({ epicId, riskVerdict, planningRisk }),
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
const baseBranchRef = settings?.baseBranch ?? 'main';
|
|
633
|
+
const tempRoot = path.resolve(
|
|
634
|
+
PROJECT_ROOT,
|
|
635
|
+
settings?.paths?.tempRoot ?? 'temp',
|
|
636
|
+
);
|
|
637
|
+
const freshness = await runSpecFreshnessCheck({
|
|
638
|
+
epicId,
|
|
639
|
+
techSpecContent,
|
|
640
|
+
baseBranchRef,
|
|
641
|
+
tempRoot,
|
|
642
|
+
provider,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Spec-half checkpoint (v2). A crash after this point resumes with the
|
|
646
|
+
// sections already folded (planEpic short-circuits `already-planned`).
|
|
647
|
+
await writeCheckpointV2(provider, epicId, {
|
|
648
|
+
planningRisk,
|
|
649
|
+
riskVerdict,
|
|
650
|
+
reviewRouting: {
|
|
651
|
+
decision: reviewRouting.decision,
|
|
652
|
+
requiresStop: reviewRouting.requiresStop,
|
|
653
|
+
forceReviewApplied: reviewRouting.forceReviewApplied,
|
|
654
|
+
},
|
|
655
|
+
spec: {
|
|
656
|
+
techSpecPersisted:
|
|
657
|
+
planResult?.techSpecPersisted === true ||
|
|
658
|
+
planResult?.reason === 'already-planned',
|
|
659
|
+
acceptanceTable: planResult?.acceptanceTable ?? 'none',
|
|
660
|
+
completedAt: new Date().toISOString(),
|
|
661
|
+
},
|
|
662
|
+
persist: {
|
|
663
|
+
mode,
|
|
664
|
+
cli: 'plan-persist',
|
|
665
|
+
startedAt: new Date().toISOString(),
|
|
666
|
+
completedAt: null,
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// ---- Step 8: mode-split mutation. ----
|
|
671
|
+
let reconcile = null;
|
|
672
|
+
let specFilePath = null;
|
|
673
|
+
let ticketCount = 0;
|
|
674
|
+
let single = null;
|
|
675
|
+
let amendSummary = null;
|
|
676
|
+
|
|
677
|
+
if (mode === 'fan-out') {
|
|
678
|
+
// Story creation via the structural reconciler.
|
|
679
|
+
Logger.info(
|
|
680
|
+
`[plan-persist] Rendering spec for Epic #${epicId} (${validated.length} tickets)...`,
|
|
681
|
+
);
|
|
682
|
+
const spec = renderSpecFn(validated, {
|
|
683
|
+
epic: buildEpicSpecInput(epic, epicId),
|
|
684
|
+
});
|
|
685
|
+
specFilePath = writeSpecFn(epicId, spec, { epicsDir: undefined });
|
|
686
|
+
Logger.info(`[plan-persist] Wrote spec → ${specFilePath}`);
|
|
687
|
+
|
|
688
|
+
// Pre-creation checkpoint: marks creation in flight so a rate-limit
|
|
689
|
+
// crash mid-creation leaves a checkpoint pointing at the spec + the
|
|
690
|
+
// reconciler's per-slug state file (the resume ledger). `--resume`
|
|
691
|
+
// re-runs the reconciler, which creates only the missing slugs.
|
|
692
|
+
await writeCheckpointV2(provider, epicId, {
|
|
693
|
+
decompose: { ticketCount: null, completedAt: null },
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
Logger.info(
|
|
697
|
+
`[plan-persist] Spawning epic-reconcile.js --apply --yes for Epic #${epicId}...`,
|
|
698
|
+
);
|
|
699
|
+
reconcile = spawnReconcilerApply({
|
|
700
|
+
spawnSync,
|
|
701
|
+
reconcileCli,
|
|
702
|
+
epicId,
|
|
703
|
+
cwd,
|
|
704
|
+
explicitDelete: force,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
await reconcileSubIssueLinks(epicId, provider);
|
|
708
|
+
|
|
709
|
+
const postReconcileState = loadStateFn(epicId);
|
|
710
|
+
await setBlockedByDependencies(
|
|
711
|
+
epicId,
|
|
712
|
+
provider,
|
|
713
|
+
spec,
|
|
714
|
+
postReconcileState.mapping,
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
ticketCount = tickets.length;
|
|
718
|
+
// Post-creation checkpoint (the former recordCheckpoint half).
|
|
719
|
+
await writeCheckpointV2(provider, epicId, {
|
|
720
|
+
decompose: {
|
|
721
|
+
ticketCount,
|
|
722
|
+
shape: 'fan-out',
|
|
723
|
+
completedAt: new Date().toISOString(),
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// A force re-persist over a former single-delivery plan flips the
|
|
728
|
+
// routing shape — drop the stale marker so #4475's reader never sees
|
|
729
|
+
// a fan-out tree labelled single.
|
|
730
|
+
await removeSingleDeliveryMarker(provider, epicId, epic);
|
|
731
|
+
} else if (mode === 'single') {
|
|
732
|
+
// Single-delivery: NO story tree. The delivery::single routing
|
|
733
|
+
// marker (inert until #4475's deliver-side reader) plus the Delivery
|
|
734
|
+
// Slicing table of the persisted Tech Spec are the plan.
|
|
735
|
+
single = {
|
|
736
|
+
deliveryShape: 'single',
|
|
737
|
+
sliceCount: countDeliverySlices(techSpecContent),
|
|
738
|
+
routingReasons: riskVerdict.deliveryShapeRationale
|
|
739
|
+
? [riskVerdict.deliveryShapeRationale]
|
|
740
|
+
: [],
|
|
741
|
+
};
|
|
742
|
+
Logger.info(
|
|
743
|
+
`[plan-persist] Single-delivery mode: applying ${DELIVERY_LABELS.SINGLE} ` +
|
|
744
|
+
`to Epic #${epicId} (no story tree).`,
|
|
745
|
+
);
|
|
746
|
+
await provider.updateTicket(epicId, {
|
|
747
|
+
labels: { add: [DELIVERY_LABELS.SINGLE], remove: [] },
|
|
748
|
+
});
|
|
749
|
+
// Explicit zero-ticket checkpoint so delivery-time consumers read a
|
|
750
|
+
// deliberate single-shape plan, never an unplanned absence.
|
|
751
|
+
await writeCheckpointV2(provider, epicId, {
|
|
752
|
+
decompose: {
|
|
753
|
+
ticketCount: 0,
|
|
754
|
+
shape: 'single',
|
|
755
|
+
completedAt: new Date().toISOString(),
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
} else {
|
|
759
|
+
// Amend delta: close-and-recreate is scoped to modify/close slugs
|
|
760
|
+
// only; keeps are untouched by construction (no code path receives
|
|
761
|
+
// them); adds are created fresh. The state ledger and blocked-by
|
|
762
|
+
// edges are rebuilt over the merged set.
|
|
763
|
+
const spec = renderSpecFn(validated, {
|
|
764
|
+
epic: buildEpicSpecInput(epic, epicId),
|
|
765
|
+
});
|
|
766
|
+
specFilePath = writeSpecFn(epicId, spec, { epicsDir: undefined });
|
|
767
|
+
Logger.info(`[plan-persist] Wrote amended spec → ${specFilePath}`);
|
|
768
|
+
|
|
769
|
+
await writeCheckpointV2(provider, epicId, {
|
|
770
|
+
decompose: { ticketCount: null, completedAt: null },
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
const validatedBySlug = new Map(validated.map((t) => [t.slug, t]));
|
|
774
|
+
const applied = await applyAmendOps({
|
|
775
|
+
epicId,
|
|
776
|
+
provider,
|
|
777
|
+
targets: amendTargets,
|
|
778
|
+
validatedBySlug,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// Rebuild the state ledger over the merged set: prior mapping minus
|
|
782
|
+
// closed/replaced slugs, plus the fresh create/recreate numbers.
|
|
783
|
+
const priorState = loadStateFn(epicId);
|
|
784
|
+
const mergedMapping = { ...(priorState.mapping ?? {}) };
|
|
785
|
+
for (const slug of applied.closedSlugs) delete mergedMapping[slug];
|
|
786
|
+
Object.assign(mergedMapping, applied.mapping);
|
|
787
|
+
writeStateFn(epicId, {
|
|
788
|
+
epicId,
|
|
789
|
+
mapping: mergedMapping,
|
|
790
|
+
lastReconciledAt: new Date().toISOString(),
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
await reconcileSubIssueLinks(epicId, provider);
|
|
794
|
+
await setBlockedByDependencies(epicId, provider, spec, mergedMapping);
|
|
795
|
+
|
|
796
|
+
ticketCount = validated.length;
|
|
797
|
+
amendSummary = {
|
|
798
|
+
closed: applied.closed,
|
|
799
|
+
recreated: applied.recreated,
|
|
800
|
+
created: applied.created,
|
|
801
|
+
keptCount: amendTargets.keep.length,
|
|
802
|
+
};
|
|
803
|
+
await writeCheckpointV2(provider, epicId, {
|
|
804
|
+
decompose: {
|
|
805
|
+
ticketCount,
|
|
806
|
+
shape: 'fan-out',
|
|
807
|
+
completedAt: new Date().toISOString(),
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// An amended plan is fan-out-shaped; drop a stale single marker.
|
|
812
|
+
await removeSingleDeliveryMarker(provider, epicId, epic);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ---- Step 9: inline healthcheck — the agent::ready exit condition. ----
|
|
816
|
+
const healthcheck = skipHealthcheck
|
|
817
|
+
? { ok: true, skipped: true }
|
|
818
|
+
: await runHealthcheckGate({
|
|
819
|
+
epicId,
|
|
820
|
+
epic,
|
|
821
|
+
runHealthcheckFn,
|
|
822
|
+
tag: 'plan-persist',
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// ---- Step 10: single terminal agent::ready flip. This surface never
|
|
826
|
+
// writes agent::review-spec — the HITL review gate sits BEFORE persist
|
|
827
|
+
// in the collapsed flow, so the intermediate label has no reader. ----
|
|
828
|
+
Logger.info(
|
|
829
|
+
`[plan-persist] Flipping Epic #${epicId} to ${AGENT_LABELS.READY}...`,
|
|
830
|
+
);
|
|
831
|
+
await setEpicLabel(provider, epicId, AGENT_LABELS.READY);
|
|
832
|
+
|
|
833
|
+
// ---- Step 11: final checkpoint v2 + single plan-summary comment with
|
|
834
|
+
// the dry-run wave table as closing text (single mode records the
|
|
835
|
+
// { deliveryShape, sliceCount, routingReasons } routing record
|
|
836
|
+
// instead). ----
|
|
837
|
+
const waveTable = mode === 'single' ? [] : buildWaveTable(validated);
|
|
838
|
+
const checkpoint = await writeCheckpointV2(provider, epicId, {
|
|
839
|
+
persist: { completedAt: new Date().toISOString() },
|
|
840
|
+
});
|
|
841
|
+
// G2 measurement receipt (Epic #4474 PR7): roll the plan-metrics ledger
|
|
842
|
+
// into the summary comment so turns-per-plan / per-mode counts / critic
|
|
843
|
+
// skips are readable off the Epic. Best-effort — a missing ledger
|
|
844
|
+
// yields no line. The in-flight persist invocation itself is stamped by
|
|
845
|
+
// the CLI wrapper *after* this function returns, so it appears in the
|
|
846
|
+
// stdout JSON (and any later re-persist), not in this comment.
|
|
847
|
+
let planMetricsLine = null;
|
|
848
|
+
try {
|
|
849
|
+
const metricsSummary = summarizePlanMetrics(
|
|
850
|
+
await readPlanMetrics(epicId, config),
|
|
851
|
+
);
|
|
852
|
+
if (metricsSummary) {
|
|
853
|
+
planMetricsLine = renderPlanMetricsSummaryLine(metricsSummary);
|
|
854
|
+
}
|
|
855
|
+
} catch (err) {
|
|
856
|
+
Logger.warn(
|
|
857
|
+
`[plan-persist] plan-metrics summary line skipped: ${err.message}`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
await upsertStructuredComment(
|
|
861
|
+
provider,
|
|
862
|
+
epicId,
|
|
863
|
+
PLAN_SUMMARY_COMMENT_TYPE,
|
|
864
|
+
buildPlanSummaryCommentBody({
|
|
865
|
+
epicId,
|
|
866
|
+
ticketCount,
|
|
867
|
+
planningRisk,
|
|
868
|
+
reviewRouting,
|
|
869
|
+
freshness,
|
|
870
|
+
healthcheck,
|
|
871
|
+
waveTable,
|
|
872
|
+
mode,
|
|
873
|
+
planMetricsLine,
|
|
874
|
+
single,
|
|
875
|
+
amend: amendSummary,
|
|
876
|
+
}),
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
// ---- Step 12: temp cleanup ONLY at terminal success. A failed run
|
|
880
|
+
// leaves every authored artifact on disk for --force/--resume reuse. ----
|
|
881
|
+
const cleanup = skipCleanup
|
|
882
|
+
? { deleted: [], missing: [], failed: [], skipped: true }
|
|
883
|
+
: await cleanupPhaseTempFiles({ phase: 'persist', epicId });
|
|
884
|
+
Logger.info(
|
|
885
|
+
`[plan-persist] ✅ Persist complete for Epic #${epicId} (${mode}). ` +
|
|
886
|
+
`${ticketCount} ticket(s) persisted; Epic is ${AGENT_LABELS.READY}.`,
|
|
887
|
+
);
|
|
888
|
+
if (cleanup.deleted.length > 0) {
|
|
889
|
+
Logger.info(
|
|
890
|
+
`[plan-persist] 🧹 Cleaned up ${cleanup.deleted.length} temp file(s).`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
epicId,
|
|
896
|
+
epicCreated: created,
|
|
897
|
+
mode,
|
|
898
|
+
ticketCount,
|
|
899
|
+
checkpoint,
|
|
900
|
+
planningRisk,
|
|
901
|
+
reviewRouting,
|
|
902
|
+
freshness,
|
|
903
|
+
healthcheck,
|
|
904
|
+
reachability,
|
|
905
|
+
reconcile,
|
|
906
|
+
specPath: specFilePath,
|
|
907
|
+
waveTable,
|
|
908
|
+
single,
|
|
909
|
+
amend: amendSummary,
|
|
910
|
+
cleanup,
|
|
911
|
+
labelTransition: 'ready',
|
|
912
|
+
};
|
|
913
|
+
} finally {
|
|
914
|
+
// Lease release on EVERY exit path (success, gate failure, throw).
|
|
915
|
+
// Best-effort by contract — releaseEpicPlanLease never throws.
|
|
916
|
+
await releaseEpicPlanLease({ provider, epicId, config });
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Drop a stale `delivery::single` marker when a fan-out-shaped persist
|
|
922
|
+
* (full re-persist or amend) lands over a formerly single-delivery plan.
|
|
923
|
+
* No-op — and no API call — when the fetched Epic never carried it.
|
|
924
|
+
*
|
|
925
|
+
* @param {object} provider
|
|
926
|
+
* @param {number} epicId
|
|
927
|
+
* @param {{ labels?: string[] }} epic the Epic as fetched at step 5
|
|
928
|
+
*/
|
|
929
|
+
async function removeSingleDeliveryMarker(provider, epicId, epic) {
|
|
930
|
+
if (!epic?.labels?.includes(DELIVERY_LABELS.SINGLE)) return;
|
|
931
|
+
Logger.info(
|
|
932
|
+
`[plan-persist] Removing stale ${DELIVERY_LABELS.SINGLE} marker from ` +
|
|
933
|
+
`Epic #${epicId} (plan is fan-out-shaped now).`,
|
|
934
|
+
);
|
|
935
|
+
await provider.updateTicket(epicId, {
|
|
936
|
+
labels: { add: [], remove: [DELIVERY_LABELS.SINGLE] },
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Render the `risk-verdict` structured-comment body. Lifted from
|
|
942
|
+
* `epic-plan-spec/phases/run-spec-phase.js` so the collapsed surface posts
|
|
943
|
+
* the byte-identical audit-trail comment (axis table + fenced-JSON record)
|
|
944
|
+
* downstream tooling parses.
|
|
945
|
+
*
|
|
946
|
+
* @param {{ epicId: number, riskVerdict: import('../planning-risk.js').RiskVerdict, planningRisk: import('../planning-risk.js').PlanningRiskEnvelope }} input
|
|
947
|
+
* @returns {string}
|
|
948
|
+
*/
|
|
949
|
+
function buildRiskVerdictCommentBody({ epicId, riskVerdict, planningRisk }) {
|
|
950
|
+
const axisRows = planningRisk.axes.map(
|
|
951
|
+
(entry) => `| ${entry.axis} | ${entry.level} | ${entry.rationale} |`,
|
|
952
|
+
);
|
|
953
|
+
const axisTable =
|
|
954
|
+
axisRows.length > 0
|
|
955
|
+
? ['| Axis | Level | Rationale |', '| --- | --- | --- |', ...axisRows]
|
|
956
|
+
: ['_No risk axes apply (planner-asserted)._'];
|
|
957
|
+
const record = {
|
|
958
|
+
kind: 'risk-verdict',
|
|
959
|
+
epicId,
|
|
960
|
+
verdict: riskVerdict,
|
|
961
|
+
planningRisk,
|
|
962
|
+
};
|
|
963
|
+
const waiverNote = planningRisk.acceptanceWaivedReason
|
|
964
|
+
? ['', `> ⚠️ **Acceptance waived** — ${planningRisk.acceptanceWaivedReason}`]
|
|
965
|
+
: [];
|
|
966
|
+
return [
|
|
967
|
+
`### 🧭 Planning Risk Verdict — ${planningRisk.overallLevel} · ${planningRisk.gateDecision}`,
|
|
968
|
+
'',
|
|
969
|
+
riskVerdict.summary,
|
|
970
|
+
'',
|
|
971
|
+
...axisTable,
|
|
972
|
+
...waiverNote,
|
|
973
|
+
'',
|
|
974
|
+
'```json',
|
|
975
|
+
JSON.stringify(record, null, 2),
|
|
976
|
+
'```',
|
|
977
|
+
].join('\n');
|
|
978
|
+
}
|