mandrel 1.91.0 → 1.93.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.
Files changed (112) hide show
  1. package/.agents/README.md +5 -5
  2. package/.agents/docs/SDLC.md +75 -107
  3. package/.agents/docs/configuration.md +1 -1
  4. package/.agents/docs/quality-gates.md +8 -7
  5. package/.agents/docs/workflows.md +4 -10
  6. package/.agents/instructions.md +2 -2
  7. package/.agents/schemas/agentrc.schema.json +1 -1
  8. package/.agents/schemas/lifecycle/README.md +2 -1
  9. package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
  10. package/.agents/schemas/risk-verdict.schema.json +14 -1
  11. package/.agents/scripts/README.md +0 -46
  12. package/.agents/scripts/analyze-execution.js +76 -1
  13. package/.agents/scripts/check-doc-links.js +3 -0
  14. package/.agents/scripts/check-workflow-cli-lint.js +208 -0
  15. package/.agents/scripts/epic-audit-recheck.js +1 -1
  16. package/.agents/scripts/epic-plan-clarity.js +16 -6
  17. package/.agents/scripts/epic-plan-decompose.js +37 -59
  18. package/.agents/scripts/epic-plan-healthcheck.js +39 -6
  19. package/.agents/scripts/epic-plan-spec.js +41 -168
  20. package/.agents/scripts/epic-reconcile.js +7 -1
  21. package/.agents/scripts/lib/Logger.js +4 -4
  22. package/.agents/scripts/lib/audit-suite/index.js +4 -5
  23. package/.agents/scripts/lib/audit-suite/runner.js +6 -4
  24. package/.agents/scripts/lib/audit-suite/selector.js +2 -3
  25. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  26. package/.agents/scripts/lib/command-header.js +20 -0
  27. package/.agents/scripts/lib/config/github.js +1 -3
  28. package/.agents/scripts/lib/config-settings-schema.js +4 -3
  29. package/.agents/scripts/lib/degraded-mode.js +1 -1
  30. package/.agents/scripts/lib/label-constants.js +18 -3
  31. package/.agents/scripts/lib/label-taxonomy.js +14 -3
  32. package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
  33. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
  34. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
  35. package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
  36. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
  37. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
  38. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
  39. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
  40. package/.agents/scripts/lib/orchestration/file-assumptions.js +68 -7
  41. package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
  42. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
  43. package/.agents/scripts/lib/orchestration/plan-context.js +696 -0
  44. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
  45. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +99 -0
  46. package/.agents/scripts/lib/orchestration/plan-metrics.js +445 -0
  47. package/.agents/scripts/lib/orchestration/plan-persist/amend.js +359 -0
  48. package/.agents/scripts/lib/orchestration/plan-persist/delivery-mode.js +127 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +1015 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +206 -0
  51. package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
  52. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
  53. package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
  54. package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
  55. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -1
  56. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
  57. package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
  58. package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
  59. package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
  60. package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
  61. package/.agents/scripts/plan-context.js +204 -0
  62. package/.agents/scripts/plan-critics.js +199 -0
  63. package/.agents/scripts/plan-persist.js +383 -0
  64. package/.agents/scripts/pr-watch-with-update.js +8 -7
  65. package/.agents/scripts/run-lint.js +10 -11
  66. package/.agents/scripts/story-plan.js +19 -7
  67. package/.agents/scripts/sync-claude-commands.js +10 -5
  68. package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
  69. package/.agents/workflows/audit-documentation.md +5 -7
  70. package/.agents/workflows/audit-lighthouse.md +1 -0
  71. package/.agents/workflows/audit-security.md +1 -0
  72. package/.agents/workflows/audit-to-stories.md +16 -5
  73. package/.agents/workflows/git-deliver.md +76 -243
  74. package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
  75. package/.agents/workflows/helpers/code-review.md +7 -6
  76. package/.agents/workflows/helpers/deliver-epic.md +5 -4
  77. package/.agents/workflows/helpers/plan-epic-reference.md +120 -96
  78. package/.agents/workflows/helpers/plan-epic.md +307 -994
  79. package/.agents/workflows/helpers/plan-story.md +14 -14
  80. package/.agents/workflows/helpers/scope-triage-gate.md +15 -4
  81. package/.agents/workflows/helpers/single-story-deliver.md +5 -4
  82. package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
  83. package/.agents/workflows/mandrel-update.md +144 -466
  84. package/.agents/workflows/plan.md +87 -145
  85. package/docs/CHANGELOG.md +20 -0
  86. package/lib/cli/registry.js +21 -3
  87. package/package.json +1 -1
  88. package/.agents/schemas/loop-unit.schema.json +0 -70
  89. package/.agents/scripts/assert-branch.js +0 -81
  90. package/.agents/scripts/check-loop-units.js +0 -204
  91. package/.agents/scripts/detect-merges.js +0 -111
  92. package/.agents/scripts/git-pr-quality-gate.js +0 -205
  93. package/.agents/scripts/git-rebase-and-resolve.js +0 -234
  94. package/.agents/scripts/hierarchy-gate.js +0 -192
  95. package/.agents/scripts/hydrate-context.js +0 -179
  96. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
  97. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
  98. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
  99. package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
  100. package/.agents/scripts/loc-delta.js +0 -205
  101. package/.agents/scripts/render-manifest.js +0 -143
  102. package/.agents/scripts/run-audit-suite.js +0 -97
  103. package/.agents/scripts/select-audits.js +0 -155
  104. package/.agents/scripts/update-mutation-baseline.js +0 -189
  105. package/.agents/workflows/explain.md +0 -118
  106. package/.agents/workflows/git-merge-pr.md +0 -377
  107. package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
  108. package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
  109. package/.agents/workflows/loops/README.md +0 -65
  110. package/.agents/workflows/loops/fix-failing-tests.md +0 -74
  111. package/.agents/workflows/loops/nightly-audit.md +0 -81
  112. package/.agents/workflows/loops/watch-ci.md +0 -68
@@ -0,0 +1,696 @@
1
+ /**
2
+ * plan-context.js — single planner-context envelope build (Epic #4474, M3
3
+ * PR2 — `/plan` collapse step 1).
4
+ *
5
+ * Folds the two `--emit-context` halves of the 12-phase pipeline
6
+ * (`buildAuthoringContext` from `epic-plan-spec/phases/authoring-context.js`
7
+ * and `buildDecompositionContext` from
8
+ * `epic-plan-decompose/phases/context.js`) plus the three currently-no-CLI
9
+ * library calls (`findSimilarOpenEpics`, clarity scoring, re-plan
10
+ * detection) into ONE JSON envelope, so the authoring middle reads a single
11
+ * file instead of shim-scripting library imports (the bench measured
12
+ * ~12–15 turns of shim-writing for the dup search alone).
13
+ *
14
+ * Three modes (the design's mode matrix + the #4496 seed entry):
15
+ * - `epic` — the Epic exists. Carries `epic`, `clarity` (the Epic
16
+ * Clarity Gate rubric — free, same body fetch), `replan`
17
+ * (already-planned signals) and `planState`.
18
+ * - `one-pager` — ideation; the Epic does not exist yet (creation moves
19
+ * to the persist half). Carries `onePager` and
20
+ * `duplicates[]` (cross-Epic dup search). Clarity is not
21
+ * scored — the ideation path is definitionally clear.
22
+ * - `seed` — headless ideation entry (#4496 fix 1): the one-pager
23
+ * does not exist yet either. The dup search runs off the
24
+ * raw seed text, and the envelope additively carries
25
+ * `seed`, `scopeTriage` (the scope-triage rubric applied
26
+ * CLI-side — no skill Reads on the headless path) and
27
+ * `onePagerSpec` (the canonical one-pager sections, so
28
+ * the authoring pass writes the one-pager in the SAME
29
+ * batched write as the spec artifacts).
30
+ *
31
+ * All fields are JSON-serialisable; the module performs no GitHub writes.
32
+ * The only I/O surfaces are the injected `provider` (reads) and the
33
+ * best-effort local scans the folded builders already perform.
34
+ */
35
+
36
+ import { readFile } from 'node:fs/promises';
37
+ import { getLimits, resolvePreflightCeilings } from '../config-resolver.js';
38
+ import { findSimilarOpenEpics } from '../duplicate-search.js';
39
+ import { hasEpicSection, hasTechSpecContent } from '../epic-body-sections.js';
40
+ import { scoreEpicBody } from '../epic-plan-clarity.js';
41
+ import { Logger } from '../Logger.js';
42
+ import {
43
+ renderAcceptanceSpecSystemPrompt,
44
+ renderTechSpecSystemPrompt,
45
+ } from '../templates/spec-author-prompts.js';
46
+ import { parseDeliverySlicingTable } from './consolidation-precondition.js';
47
+ import { buildDocsDigest } from './docs-digest.js';
48
+ import { buildDecomposerSystemPrompt } from './epic-plan-decompose/phases/context.js';
49
+ import { buildAuthoringContext } from './epic-plan-spec/phases/authoring-context.js';
50
+ import { read as readPlanState } from './epic-plan-state-store.js';
51
+
52
+ /**
53
+ * Envelope byte ceiling (regression guard for the design's named PR2 risk:
54
+ * two envelopes → one bigger one). The folded envelope's bounded parts are:
55
+ * the `applyBudget`-capped body (`planningContext.maxBytes` = 50 KB), the
56
+ * tier-capped codebase snapshot (~35 KB skinny on this repo), the three
57
+ * rendered system prompts (~15 KB), and the digest-first `docsContext`
58
+ * (outline-only, pointer in epic mode). Measured folded envelopes on this
59
+ * repo land at ~42 KB; 256 KB (~64K tokens at the ≈4-chars/token estimate)
60
+ * gives >2× headroom over a worst-case budgeted body + medium-tier snapshot
61
+ * while staying an order of magnitude under the session budget. The test
62
+ * suite asserts serialized envelopes stay under this value — raise it only
63
+ * with a measured justification.
64
+ */
65
+ export const PLAN_CONTEXT_ENVELOPE_BYTE_CEILING = 256_000;
66
+
67
+ /**
68
+ * Compact, machine-readable descriptor of the `tickets.json` array the
69
+ * authoring pass writes and `validateAndNormalizeTickets` gates at persist
70
+ * time. A descriptor, not a validator: the deterministic gate stays in the
71
+ * persist half (design § 1 step 3); this field exists so the authoring
72
+ * middle knows the shape without re-reading the decomposer prompt prose.
73
+ */
74
+ export const TICKET_SCHEMA_DESCRIPTOR = Object.freeze({
75
+ shape: 'array',
76
+ itemFields: Object.freeze({
77
+ slug: 'string — ^[a-z0-9][a-z0-9-]*$ (hyphen-case, unique per decompose)',
78
+ type: "string — literal 'story' (2-tier hierarchy: Epic → Story only)",
79
+ title: 'string — short descriptive title',
80
+ body: 'string — serialized Story-body markdown (never a JSON object)',
81
+ acceptance: 'string[] — top-level testable criteria (not nested in body)',
82
+ verify: 'string[] — top-level exact commands/test paths with (<tier>)',
83
+ labels: "string[] — must include 'type::story' and one 'persona::*'",
84
+ depends_on: 'string[]? — sibling Story slugs that block execution',
85
+ }),
86
+ validatedBy:
87
+ 'validateAndNormalizeTickets (lib/orchestration/ticket-validator.js) at persist time',
88
+ });
89
+
90
+ /**
91
+ * Canonical one-pager authoring descriptor for the `seed` envelope
92
+ * (#4496 fix 1). The section names are the ones `plan-epic.md`'s ideation
93
+ * entry has always named, chosen so the authored headings parse against
94
+ * the `SECTION_RE` map in `lib/epic-plan-ideation.js` (which renders the
95
+ * Epic body from the one-pager at persist time via
96
+ * `.agents/templates/epic-from-idea.md`).
97
+ */
98
+ export const ONE_PAGER_AUTHORING_SPEC = Object.freeze({
99
+ sections: Object.freeze([
100
+ 'Problem Statement',
101
+ 'Recommended Direction',
102
+ 'Key Assumptions',
103
+ 'MVP Scope',
104
+ 'Not Doing',
105
+ ]),
106
+ instruction:
107
+ 'Author the one-pager markdown (the canonical sections above, as `## ` ' +
108
+ 'headings) in the SAME batched write as the other planning artifacts — ' +
109
+ 'no separate ideation pass and no idea-refinement skill activation on ' +
110
+ 'this path. Every unresolved unknown lands in Key Assumptions instead ' +
111
+ 'of a question.',
112
+ consumedBy:
113
+ 'plan-persist.js --one-pager (ideation Epic creation via ' +
114
+ '.agents/templates/epic-from-idea.md)',
115
+ });
116
+
117
+ /**
118
+ * Count top-level enumerated items (`- `, `* `, `1. `) anywhere in a
119
+ * free-form seed text. Unlike {@link countScopeItems} this does not require
120
+ * a scope-shaped heading — a raw `--idea` seed rarely has one.
121
+ *
122
+ * @param {string} text
123
+ * @returns {number}
124
+ */
125
+ function countEnumeratedItems(text) {
126
+ if (typeof text !== 'string' || text.length === 0) return 0;
127
+ return text
128
+ .split(/\r?\n/)
129
+ .filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)).length;
130
+ }
131
+
132
+ /**
133
+ * Delta-shaped change-request verbs — the `core/scope-triage` skill's
134
+ * change-request rubric routes these to `story` by default when the
135
+ * footprint stays inside Story width.
136
+ */
137
+ const DELTA_VERB_RE =
138
+ /\b(fix(?:es)?|tweak(?:s)?|extend(?:s)?|update(?:s)?|adjust(?:s)?|rename(?:s)?|correct(?:s)?|patch(?:es)?|bug|regression|flaky)\b/i;
139
+
140
+ /**
141
+ * Deterministic, CLI-applied scope-triage verdict over a raw `--idea` seed
142
+ * (#4496 fix 6). Embedding the verdict in the `--seed` envelope removes the
143
+ * two skill Reads (`core/scope-triage` + the gate fragment's rubric pass)
144
+ * from the headless path; the attended path keeps the skill-based judgment.
145
+ *
146
+ * The heuristics anchor to the same sizing SSOT the skill anchors to —
147
+ * `DELIVERABLE_GRANULARITY_GUIDANCE` / `DEFAULT_TASK_SIZING` in
148
+ * `ticket-validator-sizing.js` (one Story = one coherent capability slice;
149
+ * multiple independent capabilities = an Epic) — and to the skill's
150
+ * change-request delta rubric. Like the skill, the verdict is **advisory**:
151
+ * being wrong in the `epic` direction is cheap (the consolidation critic and
152
+ * the sizing validator catch an over-planned Story later), and `borderline`
153
+ * is a first-class output, not a forced call.
154
+ *
155
+ * @param {{ seedText?: string }} args
156
+ * @returns {{ verdict: 'epic'|'story'|'borderline', reasons: string[], advisory: true, appliedBy: 'cli' }}
157
+ */
158
+ export function buildScopeTriageSignal({ seedText = '' } = {}) {
159
+ const advisory = /** @type {const} */ (true);
160
+ const appliedBy = /** @type {const} */ ('cli');
161
+ const text = typeof seedText === 'string' ? seedText : '';
162
+ const listItems = countEnumeratedItems(text);
163
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
164
+
165
+ if (listItems >= 3) {
166
+ return {
167
+ verdict: 'epic',
168
+ reasons: [
169
+ `seed enumerates ${listItems} candidate capabilities — a genuine fan-out surface`,
170
+ ],
171
+ advisory,
172
+ appliedBy,
173
+ };
174
+ }
175
+ if (listItems >= 1) {
176
+ return {
177
+ verdict: 'story',
178
+ reasons: [
179
+ `seed enumerates ${listItems} capability item(s) — one coherent change with one reason to exist`,
180
+ ],
181
+ advisory,
182
+ appliedBy,
183
+ };
184
+ }
185
+ if (DELTA_VERB_RE.test(text) && wordCount <= 120) {
186
+ return {
187
+ verdict: 'story',
188
+ reasons: [
189
+ 'delta-shaped seed (change-request verb, no capability enumeration) within Story width',
190
+ ],
191
+ advisory,
192
+ appliedBy,
193
+ };
194
+ }
195
+ if (wordCount >= 250) {
196
+ return {
197
+ verdict: 'epic',
198
+ reasons: [
199
+ `broad prose seed (~${wordCount} words) with no enumeration — plausibly multiple independent capabilities`,
200
+ ],
201
+ advisory,
202
+ appliedBy,
203
+ };
204
+ }
205
+ return {
206
+ verdict: 'borderline',
207
+ reasons: [
208
+ 'no capability enumeration and no clear delta signal — could be one ambitious Story or a small Epic; the operator (or the --yes Recommended branch) decides',
209
+ ],
210
+ advisory,
211
+ appliedBy,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Resolve the planning risk heuristics list from the canonical config
217
+ * block (same resolution the decompose context uses).
218
+ *
219
+ * @param {object} config
220
+ * @returns {string[]}
221
+ */
222
+ function resolveRiskHeuristics(config = {}) {
223
+ if (Array.isArray(config.planning?.riskHeuristics)) {
224
+ return config.planning.riskHeuristics;
225
+ }
226
+ return config.agentSettings?.planning?.riskHeuristics || [];
227
+ }
228
+
229
+ /**
230
+ * Count top-level enumerated items (`- `, `* `, `1. `) under the first
231
+ * scope-shaped `## ` heading (Scope / MVP Scope / Proposed Scope / Work
232
+ * Breakdown / Capabilities), up to the next `## ` heading. Returns `null`
233
+ * when no scope-shaped heading exists — the caller treats that as "no
234
+ * sizing signal" and defaults to fan-out.
235
+ *
236
+ * @param {string} body
237
+ * @returns {number|null}
238
+ */
239
+ function countScopeItems(body) {
240
+ if (typeof body !== 'string' || body.length === 0) return null;
241
+ const lines = body.split(/\r?\n/);
242
+ const headingIdx = lines.findIndex((line) =>
243
+ /^##\s+(?:(?:MVP\s+|Proposed\s+)?Scope(?:\s+\([^)]+\))?|Work\s+Breakdown|Capabilities)\s*$/i.test(
244
+ line.trim(),
245
+ ),
246
+ );
247
+ if (headingIdx === -1) return null;
248
+ let count = 0;
249
+ for (let i = headingIdx + 1; i < lines.length; i++) {
250
+ const line = lines[i];
251
+ if (/^##\s+/.test(line)) break;
252
+ if (/^\s*(?:[-*]|\d+\.)\s+\S/.test(line)) count += 1;
253
+ }
254
+ return count;
255
+ }
256
+
257
+ /**
258
+ * Advisory single-vs-fan-out delivery-shape signal (design § 1 step 1;
259
+ * routing pilot #4475). Derived from the same size/shape heuristics the
260
+ * scope-triage rubric anchors to — the Delivery Slicing table when the Epic
261
+ * body already carries one (slice count + "Independent?" chain shape,
262
+ * via the Phase 8.3 precondition parser), else a scope-enumeration count.
263
+ *
264
+ * **Advisory only, fan-out by default.** This signal changes no routing
265
+ * behaviour in this PR: the deliver-side reader is #4475's scope, and until
266
+ * it lands the recommendation defaults to `fan-out` for every ambiguous
267
+ * case. `single` is recommended only on clear one-pass indicators: a
268
+ * slicing table proposing ≤ 2 slices, a pure dependent chain (zero
269
+ * realized parallelism from the Story tier — the N=2 bench finding), or a
270
+ * scope enumeration of ≤ 2 capabilities.
271
+ *
272
+ * @param {{ body: string }} args
273
+ * @returns {{ recommendation: 'single'|'fan-out', reasons: string[], advisory: true }}
274
+ */
275
+ export function buildDeliveryShapeSignal({ body } = {}) {
276
+ const advisory = /** @type {const} */ (true);
277
+ const rows = parseDeliverySlicingTable(body ?? '');
278
+
279
+ if (Array.isArray(rows) && rows.length > 0) {
280
+ if (rows.length <= 2) {
281
+ return {
282
+ recommendation: 'single',
283
+ reasons: [
284
+ `delivery-slicing table proposes ${rows.length} slice(s) — one-pass-sized`,
285
+ ],
286
+ advisory,
287
+ };
288
+ }
289
+ const chain = rows.slice(1).every((r) => r.independent === false);
290
+ if (chain) {
291
+ return {
292
+ recommendation: 'single',
293
+ reasons: [
294
+ `delivery-slicing table is a pure dependent chain (${rows.length} slices, every non-first slice "Independent? No") — zero parallelism value from Story fan-out`,
295
+ ],
296
+ advisory,
297
+ };
298
+ }
299
+ return {
300
+ recommendation: 'fan-out',
301
+ reasons: [
302
+ `delivery-slicing table proposes ${rows.length} slices with independent parallelism`,
303
+ ],
304
+ advisory,
305
+ };
306
+ }
307
+
308
+ const scopeItems = countScopeItems(body ?? '');
309
+ if (scopeItems !== null && scopeItems > 0 && scopeItems <= 2) {
310
+ return {
311
+ recommendation: 'single',
312
+ reasons: [
313
+ `scope enumerates ${scopeItems} capability item(s) — one-pass-sized`,
314
+ ],
315
+ advisory,
316
+ };
317
+ }
318
+ if (scopeItems !== null && scopeItems > 2) {
319
+ return {
320
+ recommendation: 'fan-out',
321
+ reasons: [`scope enumerates ${scopeItems} capability items`],
322
+ advisory,
323
+ };
324
+ }
325
+ return {
326
+ recommendation: 'fan-out',
327
+ reasons: [
328
+ 'no delivery-slicing table or scope enumeration to size against — defaulting to fan-out',
329
+ ],
330
+ advisory,
331
+ };
332
+ }
333
+
334
+ /**
335
+ * Re-plan detection signals (folds the workflow's Phase 5 into the
336
+ * envelope): the Tech Spec sections alone are the already-planned signal;
337
+ * the open-Story count and section presence let the authoring middle (and
338
+ * the persist half's `--force` prompt) cite concrete numbers.
339
+ *
340
+ * `openStoryCount` is best-effort: a provider listing failure degrades to
341
+ * `null` rather than aborting the envelope build.
342
+ *
343
+ * @param {{ epicBody: string, provider: object, epicId: number }} args
344
+ * @returns {Promise<{
345
+ * alreadyPlanned: boolean,
346
+ * planningSections: { techSpec: boolean, acceptanceTable: boolean },
347
+ * openStoryCount: number|null,
348
+ * }>}
349
+ */
350
+ export async function buildReplanSignal({ epicBody, provider, epicId }) {
351
+ const body = epicBody ?? '';
352
+ let openStoryCount = null;
353
+ try {
354
+ const tickets = await provider.getTickets(epicId, { state: 'open' });
355
+ if (Array.isArray(tickets)) openStoryCount = tickets.length;
356
+ } catch (err) {
357
+ Logger.warn(
358
+ `[plan-context] open-children listing skipped: ${err?.message ?? err}`,
359
+ );
360
+ }
361
+ return {
362
+ alreadyPlanned: hasTechSpecContent(body),
363
+ planningSections: {
364
+ techSpec: hasEpicSection(body, 'techSpec'),
365
+ acceptanceTable: hasEpicSection(body, 'acceptanceTable'),
366
+ },
367
+ openStoryCount,
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Render the three authoring system prompts the collapsed pipeline's
373
+ * single authoring pass consumes. The spec/acceptance prompts render from
374
+ * `lib/templates/spec-author-prompts.js` (the M3/M8 handshake — envelope
375
+ * authoritative from day one); the decompose prompt reuses the existing
376
+ * Story #4162 carrier including the risk-heuristics suffix.
377
+ *
378
+ * @param {{ heuristics?: string[], maxTickets?: number, maxTokenBudget?: number, epicId?: number|null }} args
379
+ * @returns {{ spec: string, acceptance: string, decompose: string }}
380
+ */
381
+ export function buildSystemPrompts({
382
+ heuristics = [],
383
+ maxTickets,
384
+ maxTokenBudget,
385
+ epicId = null,
386
+ } = {}) {
387
+ return {
388
+ spec: renderTechSpecSystemPrompt(),
389
+ acceptance: renderAcceptanceSpecSystemPrompt(),
390
+ decompose: buildDecomposerSystemPrompt(heuristics, {
391
+ maxTickets,
392
+ maxTokenBudget,
393
+ epicId,
394
+ }),
395
+ };
396
+ }
397
+
398
+ /**
399
+ * Read the `epic-plan-state` structured comment, degrading to `null` when
400
+ * the comment is missing/unparseable or the provider fetch fails (same
401
+ * tolerance the decompose context applies).
402
+ *
403
+ * @param {{ provider: object, epicId: number }} args
404
+ * @returns {Promise<object|null>}
405
+ */
406
+ async function readPlanStateTolerant({ provider, epicId }) {
407
+ try {
408
+ return await readPlanState({ provider, epicId });
409
+ } catch (_err) {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Build the epic-mode envelope. One Epic fetch feeds everything: the
416
+ * authoring context (prefetch seam on `buildAuthoringContext`), clarity
417
+ * scoring, re-plan detection, and the delivery-shape heuristics — the
418
+ * fetch-twice shape of the split pipeline is gone.
419
+ */
420
+ async function buildEpicModeEnvelope({
421
+ epicId,
422
+ provider,
423
+ config,
424
+ settings,
425
+ fullContext,
426
+ cwd,
427
+ }) {
428
+ const epic = await provider.getEpic(epicId);
429
+ if (!epic) {
430
+ throw new Error(`[plan-context] Epic #${epicId} not found.`);
431
+ }
432
+ const body = epic.body ?? '';
433
+
434
+ const authoringOpts = {
435
+ epic,
436
+ fullContext,
437
+ github: config.github ?? null,
438
+ };
439
+ if (cwd) authoringOpts.cwd = cwd;
440
+ const authoring = await buildAuthoringContext(
441
+ epicId,
442
+ provider,
443
+ settings,
444
+ authoringOpts,
445
+ );
446
+
447
+ const limits = getLimits(config);
448
+ const heuristics = resolveRiskHeuristics(config);
449
+ const clarityScore = scoreEpicBody({ body });
450
+ const [replan, planState] = await Promise.all([
451
+ buildReplanSignal({ epicBody: body, provider, epicId }),
452
+ readPlanStateTolerant({ provider, epicId }),
453
+ ]);
454
+
455
+ return {
456
+ mode: 'epic',
457
+ epic: authoring.epic,
458
+ clarity: clarityScore,
459
+ replan,
460
+ docsContext: authoring.docsContext,
461
+ codebaseSnapshot: authoring.codebaseSnapshot,
462
+ bddRunner: authoring.bddRunner,
463
+ bddScenarios: authoring.bddScenarios,
464
+ memoryFreshness: authoring.memoryFreshness,
465
+ priorFeedback: authoring.priorFeedback,
466
+ ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
467
+ maxTickets: limits.maxTickets,
468
+ maxTokenBudget: limits.maxTokenBudget,
469
+ preflightCeilings: resolvePreflightCeilings(config),
470
+ riskHeuristics: heuristics,
471
+ systemPrompts: buildSystemPrompts({
472
+ heuristics,
473
+ maxTickets: limits.maxTickets,
474
+ maxTokenBudget: limits.maxTokenBudget,
475
+ epicId,
476
+ }),
477
+ deliveryShapeSignal: buildDeliveryShapeSignal({ body }),
478
+ planState,
479
+ };
480
+ }
481
+
482
+ /**
483
+ * Build the one-pager (ideation) envelope. The Epic does not exist yet —
484
+ * creation moves to the persist half — so there is no clarity score, no
485
+ * re-plan signal, and no plan state; the dup search replaces them as the
486
+ * mode's gating input. `docsContext` is inline-digest (the standalone
487
+ * `story-plan.js --emit-context` convention): there is no per-Epic temp
488
+ * directory to anchor a digest file to yet.
489
+ */
490
+ async function buildOnePagerModeEnvelope({
491
+ onePagerPath,
492
+ onePagerContent,
493
+ provider,
494
+ config,
495
+ settings,
496
+ fullContext,
497
+ cwd,
498
+ }) {
499
+ const content =
500
+ onePagerContent ?? (await readFile(onePagerPath ?? '', 'utf-8'));
501
+ if (typeof content !== 'string' || content.trim().length === 0) {
502
+ throw new Error(
503
+ `[plan-context] one-pager at ${onePagerPath} is empty — nothing to plan from.`,
504
+ );
505
+ }
506
+
507
+ let duplicates = [];
508
+ try {
509
+ duplicates = await findSimilarOpenEpics({
510
+ onePager: content,
511
+ provider,
512
+ owner: config.github?.owner,
513
+ repo: config.github?.repo,
514
+ });
515
+ } catch (err) {
516
+ // The dup search is a triage signal, not a gate: a provider listing
517
+ // failure must not abort the envelope build. Surface the degradation
518
+ // on stderr; the authoring middle sees an empty candidate list.
519
+ Logger.warn(
520
+ `[plan-context] duplicate search degraded to no candidates: ${err?.message ?? err}`,
521
+ );
522
+ duplicates = [];
523
+ }
524
+
525
+ // Fold the same authoring-context builders the epic path uses, grounded
526
+ // in the one-pager prose instead of an Epic body. Reuse
527
+ // `buildAuthoringContext` via the prefetch seam so the fold has exactly
528
+ // one implementation of the snapshot/BDD/memory/feedback pipeline to
529
+ // drift from. `docsContextFiles` is emptied for this call: the per-Epic
530
+ // digest-file path needs an Epic id (and a temp directory) that does not
531
+ // exist yet — the inline digest below replaces it.
532
+ const authoring = await buildAuthoringContext(
533
+ 0,
534
+ /* provider (unused behind the prefetch seam) */ {},
535
+ { ...settings, docsContextFiles: [] },
536
+ {
537
+ epic: { id: 0, title: onePagerPath ?? 'one-pager', body: content },
538
+ fullContext,
539
+ github: config.github ?? null,
540
+ cwd,
541
+ },
542
+ );
543
+
544
+ // Replace the per-Epic digest-file pointer with an inline digest — the
545
+ // Epic (and its temp directory) does not exist yet.
546
+ const paths = settings?.paths ?? {};
547
+ const inlineDigest = await buildDocsDigest({
548
+ docsContextFiles: settings?.docsContextFiles,
549
+ docsRoot: paths.docsRoot,
550
+ });
551
+ const docsContext =
552
+ inlineDigest == null
553
+ ? null
554
+ : { mode: 'digest-inline', digest: inlineDigest };
555
+
556
+ const limits = getLimits(config);
557
+ const heuristics = resolveRiskHeuristics(config);
558
+
559
+ return {
560
+ mode: 'one-pager',
561
+ onePager: { path: onePagerPath ?? null, content },
562
+ duplicates,
563
+ docsContext,
564
+ codebaseSnapshot: authoring.codebaseSnapshot,
565
+ bddRunner: authoring.bddRunner,
566
+ bddScenarios: authoring.bddScenarios,
567
+ memoryFreshness: authoring.memoryFreshness,
568
+ priorFeedback: authoring.priorFeedback,
569
+ ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
570
+ maxTickets: limits.maxTickets,
571
+ maxTokenBudget: limits.maxTokenBudget,
572
+ preflightCeilings: resolvePreflightCeilings(config),
573
+ riskHeuristics: heuristics,
574
+ systemPrompts: buildSystemPrompts({
575
+ heuristics,
576
+ maxTickets: limits.maxTickets,
577
+ maxTokenBudget: limits.maxTokenBudget,
578
+ epicId: null,
579
+ }),
580
+ deliveryShapeSignal: buildDeliveryShapeSignal({ body: content }),
581
+ planState: null,
582
+ };
583
+ }
584
+
585
+ /**
586
+ * Build the seed-mode (headless ideation) envelope — #4496 fix 1. The
587
+ * one-pager does not exist yet: the dup search and the authoring-context
588
+ * fold both run off the raw seed text (the same builders the one-pager mode
589
+ * uses), and the envelope additively carries `seed`, the CLI-applied
590
+ * `scopeTriage` verdict (fix 6 — no skill Reads on the headless path), and
591
+ * `onePagerSpec` so the one-pager sections are authored in the same batched
592
+ * write as the spec artifacts.
593
+ */
594
+ async function buildSeedModeEnvelope({
595
+ seedText,
596
+ provider,
597
+ config,
598
+ settings,
599
+ fullContext,
600
+ cwd,
601
+ }) {
602
+ if (typeof seedText !== 'string' || seedText.trim().length === 0) {
603
+ throw new Error(
604
+ '[plan-context] --seed requires non-empty seed text — nothing to plan from.',
605
+ );
606
+ }
607
+ const base = await buildOnePagerModeEnvelope({
608
+ onePagerPath: undefined,
609
+ onePagerContent: seedText,
610
+ provider,
611
+ config,
612
+ settings,
613
+ fullContext,
614
+ cwd,
615
+ });
616
+ const { onePager: _onePager, ...rest } = base;
617
+ return {
618
+ ...rest,
619
+ mode: 'seed',
620
+ seed: { text: seedText },
621
+ scopeTriage: buildScopeTriageSignal({ seedText }),
622
+ onePagerSpec: ONE_PAGER_AUTHORING_SPEC,
623
+ };
624
+ }
625
+
626
+ /**
627
+ * Build the single planner-context envelope.
628
+ *
629
+ * @param {{
630
+ * mode: 'epic'|'one-pager'|'seed',
631
+ * epicId?: number,
632
+ * onePagerPath?: string,
633
+ * onePagerContent?: string,
634
+ * seedText?: string,
635
+ * provider: object,
636
+ * config: object,
637
+ * settings: object,
638
+ * cwd?: string,
639
+ * }} args
640
+ * @returns {Promise<object>} the JSON-serialisable envelope.
641
+ */
642
+ export async function buildPlanContext({
643
+ mode,
644
+ epicId,
645
+ onePagerPath,
646
+ onePagerContent,
647
+ seedText,
648
+ provider,
649
+ config = {},
650
+ settings = {},
651
+ fullContext = false,
652
+ cwd,
653
+ }) {
654
+ if (mode === 'epic') {
655
+ if (!Number.isInteger(epicId)) {
656
+ throw new Error('[plan-context] epic mode requires a numeric epicId.');
657
+ }
658
+ return buildEpicModeEnvelope({
659
+ epicId,
660
+ provider,
661
+ config,
662
+ settings,
663
+ fullContext,
664
+ cwd,
665
+ });
666
+ }
667
+ if (mode === 'one-pager') {
668
+ if (!onePagerPath && typeof onePagerContent !== 'string') {
669
+ throw new Error(
670
+ '[plan-context] one-pager mode requires --one-pager <path>.',
671
+ );
672
+ }
673
+ return buildOnePagerModeEnvelope({
674
+ onePagerPath,
675
+ onePagerContent,
676
+ provider,
677
+ config,
678
+ settings,
679
+ fullContext,
680
+ cwd,
681
+ });
682
+ }
683
+ if (mode === 'seed') {
684
+ return buildSeedModeEnvelope({
685
+ seedText,
686
+ provider,
687
+ config,
688
+ settings,
689
+ fullContext,
690
+ cwd,
691
+ });
692
+ }
693
+ throw new Error(
694
+ `[plan-context] unknown mode "${mode}" — expected "epic", "one-pager" or "seed".`,
695
+ );
696
+ }