mandrel 1.90.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.
Files changed (115) 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-deliver-preflight.js +37 -1
  17. package/.agents/scripts/epic-plan-clarity.js +16 -6
  18. package/.agents/scripts/epic-plan-decompose.js +37 -59
  19. package/.agents/scripts/epic-plan-healthcheck.js +39 -6
  20. package/.agents/scripts/epic-plan-spec.js +41 -168
  21. package/.agents/scripts/epic-reconcile.js +7 -1
  22. package/.agents/scripts/lib/Logger.js +4 -4
  23. package/.agents/scripts/lib/audit-suite/index.js +4 -5
  24. package/.agents/scripts/lib/audit-suite/runner.js +6 -4
  25. package/.agents/scripts/lib/audit-suite/selector.js +2 -3
  26. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  27. package/.agents/scripts/lib/command-header.js +20 -0
  28. package/.agents/scripts/lib/config/github.js +1 -3
  29. package/.agents/scripts/lib/config-settings-schema.js +4 -3
  30. package/.agents/scripts/lib/degraded-mode.js +1 -1
  31. package/.agents/scripts/lib/label-constants.js +18 -3
  32. package/.agents/scripts/lib/label-taxonomy.js +14 -3
  33. package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
  34. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
  35. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
  36. package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
  37. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
  38. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
  39. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
  40. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
  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/lifecycle/listeners/finalizer.js +32 -0
  44. package/.agents/scripts/lib/orchestration/plan-context.js +510 -0
  45. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -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 +978 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +191 -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/remote-verifier.js +165 -0
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
  56. package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
  57. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
  58. package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
  59. package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
  60. package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
  61. package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
  62. package/.agents/scripts/plan-context.js +186 -0
  63. package/.agents/scripts/plan-critics.js +227 -0
  64. package/.agents/scripts/plan-persist.js +383 -0
  65. package/.agents/scripts/pr-watch-with-update.js +8 -7
  66. package/.agents/scripts/run-lint.js +10 -11
  67. package/.agents/scripts/single-story-init.js +22 -0
  68. package/.agents/scripts/story-plan.js +19 -7
  69. package/.agents/scripts/sync-claude-commands.js +10 -5
  70. package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
  71. package/.agents/workflows/audit-documentation.md +5 -7
  72. package/.agents/workflows/audit-lighthouse.md +1 -0
  73. package/.agents/workflows/audit-security.md +1 -0
  74. package/.agents/workflows/audit-to-stories.md +16 -5
  75. package/.agents/workflows/deliver.md +8 -0
  76. package/.agents/workflows/git-deliver.md +76 -243
  77. package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
  78. package/.agents/workflows/helpers/code-review.md +7 -6
  79. package/.agents/workflows/helpers/deliver-epic.md +16 -4
  80. package/.agents/workflows/helpers/plan-epic-reference.md +109 -96
  81. package/.agents/workflows/helpers/plan-epic.md +246 -999
  82. package/.agents/workflows/helpers/plan-story.md +14 -14
  83. package/.agents/workflows/helpers/scope-triage-gate.md +6 -4
  84. package/.agents/workflows/helpers/single-story-deliver.md +13 -4
  85. package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
  86. package/.agents/workflows/mandrel-update.md +144 -466
  87. package/.agents/workflows/plan.md +73 -143
  88. package/docs/CHANGELOG.md +20 -0
  89. package/lib/cli/registry.js +21 -3
  90. package/package.json +1 -1
  91. package/.agents/schemas/loop-unit.schema.json +0 -70
  92. package/.agents/scripts/assert-branch.js +0 -81
  93. package/.agents/scripts/check-loop-units.js +0 -204
  94. package/.agents/scripts/detect-merges.js +0 -111
  95. package/.agents/scripts/git-pr-quality-gate.js +0 -205
  96. package/.agents/scripts/git-rebase-and-resolve.js +0 -234
  97. package/.agents/scripts/hierarchy-gate.js +0 -192
  98. package/.agents/scripts/hydrate-context.js +0 -179
  99. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
  100. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
  101. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
  102. package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
  103. package/.agents/scripts/loc-delta.js +0 -205
  104. package/.agents/scripts/render-manifest.js +0 -143
  105. package/.agents/scripts/run-audit-suite.js +0 -97
  106. package/.agents/scripts/select-audits.js +0 -155
  107. package/.agents/scripts/update-mutation-baseline.js +0 -189
  108. package/.agents/workflows/explain.md +0 -118
  109. package/.agents/workflows/git-merge-pr.md +0 -377
  110. package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
  111. package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
  112. package/.agents/workflows/loops/README.md +0 -65
  113. package/.agents/workflows/loops/fix-failing-tests.md +0 -74
  114. package/.agents/workflows/loops/nightly-audit.md +0 -81
  115. package/.agents/workflows/loops/watch-ci.md +0 -68
@@ -0,0 +1,510 @@
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
+ * Two modes (the design's mode matrix):
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
+ *
23
+ * All fields are JSON-serialisable; the module performs no GitHub writes.
24
+ * The only I/O surfaces are the injected `provider` (reads) and the
25
+ * best-effort local scans the folded builders already perform.
26
+ */
27
+
28
+ import { readFile } from 'node:fs/promises';
29
+ import { getLimits, resolvePreflightCeilings } from '../config-resolver.js';
30
+ import { findSimilarOpenEpics } from '../duplicate-search.js';
31
+ import { hasEpicSection, hasTechSpecContent } from '../epic-body-sections.js';
32
+ import { scoreEpicBody } from '../epic-plan-clarity.js';
33
+ import { Logger } from '../Logger.js';
34
+ import {
35
+ renderAcceptanceSpecSystemPrompt,
36
+ renderTechSpecSystemPrompt,
37
+ } from '../templates/spec-author-prompts.js';
38
+ import { parseDeliverySlicingTable } from './consolidation-precondition.js';
39
+ import { buildDocsDigest } from './docs-digest.js';
40
+ import { buildDecomposerSystemPrompt } from './epic-plan-decompose/phases/context.js';
41
+ import { buildAuthoringContext } from './epic-plan-spec/phases/authoring-context.js';
42
+ import { read as readPlanState } from './epic-plan-state-store.js';
43
+
44
+ /**
45
+ * Envelope byte ceiling (regression guard for the design's named PR2 risk:
46
+ * two envelopes → one bigger one). The folded envelope's bounded parts are:
47
+ * the `applyBudget`-capped body (`planningContext.maxBytes` = 50 KB), the
48
+ * tier-capped codebase snapshot (~35 KB skinny on this repo), the three
49
+ * rendered system prompts (~15 KB), and the digest-first `docsContext`
50
+ * (outline-only, pointer in epic mode). Measured folded envelopes on this
51
+ * repo land at ~42 KB; 256 KB (~64K tokens at the ≈4-chars/token estimate)
52
+ * gives >2× headroom over a worst-case budgeted body + medium-tier snapshot
53
+ * while staying an order of magnitude under the session budget. The test
54
+ * suite asserts serialized envelopes stay under this value — raise it only
55
+ * with a measured justification.
56
+ */
57
+ export const PLAN_CONTEXT_ENVELOPE_BYTE_CEILING = 256_000;
58
+
59
+ /**
60
+ * Compact, machine-readable descriptor of the `tickets.json` array the
61
+ * authoring pass writes and `validateAndNormalizeTickets` gates at persist
62
+ * time. A descriptor, not a validator: the deterministic gate stays in the
63
+ * persist half (design § 1 step 3); this field exists so the authoring
64
+ * middle knows the shape without re-reading the decomposer prompt prose.
65
+ */
66
+ export const TICKET_SCHEMA_DESCRIPTOR = Object.freeze({
67
+ shape: 'array',
68
+ itemFields: Object.freeze({
69
+ slug: 'string — ^[a-z0-9][a-z0-9-]*$ (hyphen-case, unique per decompose)',
70
+ type: "string — literal 'story' (2-tier hierarchy: Epic → Story only)",
71
+ title: 'string — short descriptive title',
72
+ body: 'string — serialized Story-body markdown (never a JSON object)',
73
+ acceptance: 'string[] — top-level testable criteria (not nested in body)',
74
+ verify: 'string[] — top-level exact commands/test paths with (<tier>)',
75
+ labels: "string[] — must include 'type::story' and one 'persona::*'",
76
+ depends_on: 'string[]? — sibling Story slugs that block execution',
77
+ }),
78
+ validatedBy:
79
+ 'validateAndNormalizeTickets (lib/orchestration/ticket-validator.js) at persist time',
80
+ });
81
+
82
+ /**
83
+ * Resolve the planning risk heuristics list from the canonical config
84
+ * block (same resolution the decompose context uses).
85
+ *
86
+ * @param {object} config
87
+ * @returns {string[]}
88
+ */
89
+ function resolveRiskHeuristics(config = {}) {
90
+ if (Array.isArray(config.planning?.riskHeuristics)) {
91
+ return config.planning.riskHeuristics;
92
+ }
93
+ return config.agentSettings?.planning?.riskHeuristics || [];
94
+ }
95
+
96
+ /**
97
+ * Count top-level enumerated items (`- `, `* `, `1. `) under the first
98
+ * scope-shaped `## ` heading (Scope / MVP Scope / Proposed Scope / Work
99
+ * Breakdown / Capabilities), up to the next `## ` heading. Returns `null`
100
+ * when no scope-shaped heading exists — the caller treats that as "no
101
+ * sizing signal" and defaults to fan-out.
102
+ *
103
+ * @param {string} body
104
+ * @returns {number|null}
105
+ */
106
+ function countScopeItems(body) {
107
+ if (typeof body !== 'string' || body.length === 0) return null;
108
+ const lines = body.split(/\r?\n/);
109
+ const headingIdx = lines.findIndex((line) =>
110
+ /^##\s+(?:(?:MVP\s+|Proposed\s+)?Scope(?:\s+\([^)]+\))?|Work\s+Breakdown|Capabilities)\s*$/i.test(
111
+ line.trim(),
112
+ ),
113
+ );
114
+ if (headingIdx === -1) return null;
115
+ let count = 0;
116
+ for (let i = headingIdx + 1; i < lines.length; i++) {
117
+ const line = lines[i];
118
+ if (/^##\s+/.test(line)) break;
119
+ if (/^\s*(?:[-*]|\d+\.)\s+\S/.test(line)) count += 1;
120
+ }
121
+ return count;
122
+ }
123
+
124
+ /**
125
+ * Advisory single-vs-fan-out delivery-shape signal (design § 1 step 1;
126
+ * routing pilot #4475). Derived from the same size/shape heuristics the
127
+ * scope-triage rubric anchors to — the Delivery Slicing table when the Epic
128
+ * body already carries one (slice count + "Independent?" chain shape,
129
+ * via the Phase 8.3 precondition parser), else a scope-enumeration count.
130
+ *
131
+ * **Advisory only, fan-out by default.** This signal changes no routing
132
+ * behaviour in this PR: the deliver-side reader is #4475's scope, and until
133
+ * it lands the recommendation defaults to `fan-out` for every ambiguous
134
+ * case. `single` is recommended only on clear one-pass indicators: a
135
+ * slicing table proposing ≤ 2 slices, a pure dependent chain (zero
136
+ * realized parallelism from the Story tier — the N=2 bench finding), or a
137
+ * scope enumeration of ≤ 2 capabilities.
138
+ *
139
+ * @param {{ body: string }} args
140
+ * @returns {{ recommendation: 'single'|'fan-out', reasons: string[], advisory: true }}
141
+ */
142
+ export function buildDeliveryShapeSignal({ body } = {}) {
143
+ const advisory = /** @type {const} */ (true);
144
+ const rows = parseDeliverySlicingTable(body ?? '');
145
+
146
+ if (Array.isArray(rows) && rows.length > 0) {
147
+ if (rows.length <= 2) {
148
+ return {
149
+ recommendation: 'single',
150
+ reasons: [
151
+ `delivery-slicing table proposes ${rows.length} slice(s) — one-pass-sized`,
152
+ ],
153
+ advisory,
154
+ };
155
+ }
156
+ const chain = rows.slice(1).every((r) => r.independent === false);
157
+ if (chain) {
158
+ return {
159
+ recommendation: 'single',
160
+ reasons: [
161
+ `delivery-slicing table is a pure dependent chain (${rows.length} slices, every non-first slice "Independent? No") — zero parallelism value from Story fan-out`,
162
+ ],
163
+ advisory,
164
+ };
165
+ }
166
+ return {
167
+ recommendation: 'fan-out',
168
+ reasons: [
169
+ `delivery-slicing table proposes ${rows.length} slices with independent parallelism`,
170
+ ],
171
+ advisory,
172
+ };
173
+ }
174
+
175
+ const scopeItems = countScopeItems(body ?? '');
176
+ if (scopeItems !== null && scopeItems > 0 && scopeItems <= 2) {
177
+ return {
178
+ recommendation: 'single',
179
+ reasons: [
180
+ `scope enumerates ${scopeItems} capability item(s) — one-pass-sized`,
181
+ ],
182
+ advisory,
183
+ };
184
+ }
185
+ if (scopeItems !== null && scopeItems > 2) {
186
+ return {
187
+ recommendation: 'fan-out',
188
+ reasons: [`scope enumerates ${scopeItems} capability items`],
189
+ advisory,
190
+ };
191
+ }
192
+ return {
193
+ recommendation: 'fan-out',
194
+ reasons: [
195
+ 'no delivery-slicing table or scope enumeration to size against — defaulting to fan-out',
196
+ ],
197
+ advisory,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Re-plan detection signals (folds the workflow's Phase 5 into the
203
+ * envelope): the Tech Spec sections alone are the already-planned signal;
204
+ * the open-Story count and section presence let the authoring middle (and
205
+ * the persist half's `--force` prompt) cite concrete numbers.
206
+ *
207
+ * `openStoryCount` is best-effort: a provider listing failure degrades to
208
+ * `null` rather than aborting the envelope build.
209
+ *
210
+ * @param {{ epicBody: string, provider: object, epicId: number }} args
211
+ * @returns {Promise<{
212
+ * alreadyPlanned: boolean,
213
+ * planningSections: { techSpec: boolean, acceptanceTable: boolean },
214
+ * openStoryCount: number|null,
215
+ * }>}
216
+ */
217
+ export async function buildReplanSignal({ epicBody, provider, epicId }) {
218
+ const body = epicBody ?? '';
219
+ let openStoryCount = null;
220
+ try {
221
+ const tickets = await provider.getTickets(epicId, { state: 'open' });
222
+ if (Array.isArray(tickets)) openStoryCount = tickets.length;
223
+ } catch (err) {
224
+ Logger.warn(
225
+ `[plan-context] open-children listing skipped: ${err?.message ?? err}`,
226
+ );
227
+ }
228
+ return {
229
+ alreadyPlanned: hasTechSpecContent(body),
230
+ planningSections: {
231
+ techSpec: hasEpicSection(body, 'techSpec'),
232
+ acceptanceTable: hasEpicSection(body, 'acceptanceTable'),
233
+ },
234
+ openStoryCount,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Render the three authoring system prompts the collapsed pipeline's
240
+ * single authoring pass consumes. The spec/acceptance prompts render from
241
+ * `lib/templates/spec-author-prompts.js` (the M3/M8 handshake — envelope
242
+ * authoritative from day one); the decompose prompt reuses the existing
243
+ * Story #4162 carrier including the risk-heuristics suffix.
244
+ *
245
+ * @param {{ heuristics?: string[], maxTickets?: number, maxTokenBudget?: number, epicId?: number|null }} args
246
+ * @returns {{ spec: string, acceptance: string, decompose: string }}
247
+ */
248
+ export function buildSystemPrompts({
249
+ heuristics = [],
250
+ maxTickets,
251
+ maxTokenBudget,
252
+ epicId = null,
253
+ } = {}) {
254
+ return {
255
+ spec: renderTechSpecSystemPrompt(),
256
+ acceptance: renderAcceptanceSpecSystemPrompt(),
257
+ decompose: buildDecomposerSystemPrompt(heuristics, {
258
+ maxTickets,
259
+ maxTokenBudget,
260
+ epicId,
261
+ }),
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Read the `epic-plan-state` structured comment, degrading to `null` when
267
+ * the comment is missing/unparseable or the provider fetch fails (same
268
+ * tolerance the decompose context applies).
269
+ *
270
+ * @param {{ provider: object, epicId: number }} args
271
+ * @returns {Promise<object|null>}
272
+ */
273
+ async function readPlanStateTolerant({ provider, epicId }) {
274
+ try {
275
+ return await readPlanState({ provider, epicId });
276
+ } catch (_err) {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Build the epic-mode envelope. One Epic fetch feeds everything: the
283
+ * authoring context (prefetch seam on `buildAuthoringContext`), clarity
284
+ * scoring, re-plan detection, and the delivery-shape heuristics — the
285
+ * fetch-twice shape of the split pipeline is gone.
286
+ */
287
+ async function buildEpicModeEnvelope({
288
+ epicId,
289
+ provider,
290
+ config,
291
+ settings,
292
+ fullContext,
293
+ cwd,
294
+ }) {
295
+ const epic = await provider.getEpic(epicId);
296
+ if (!epic) {
297
+ throw new Error(`[plan-context] Epic #${epicId} not found.`);
298
+ }
299
+ const body = epic.body ?? '';
300
+
301
+ const authoringOpts = {
302
+ epic,
303
+ fullContext,
304
+ github: config.github ?? null,
305
+ };
306
+ if (cwd) authoringOpts.cwd = cwd;
307
+ const authoring = await buildAuthoringContext(
308
+ epicId,
309
+ provider,
310
+ settings,
311
+ authoringOpts,
312
+ );
313
+
314
+ const limits = getLimits(config);
315
+ const heuristics = resolveRiskHeuristics(config);
316
+ const clarityScore = scoreEpicBody({ body });
317
+ const [replan, planState] = await Promise.all([
318
+ buildReplanSignal({ epicBody: body, provider, epicId }),
319
+ readPlanStateTolerant({ provider, epicId }),
320
+ ]);
321
+
322
+ return {
323
+ mode: 'epic',
324
+ epic: authoring.epic,
325
+ clarity: clarityScore,
326
+ replan,
327
+ docsContext: authoring.docsContext,
328
+ codebaseSnapshot: authoring.codebaseSnapshot,
329
+ bddRunner: authoring.bddRunner,
330
+ bddScenarios: authoring.bddScenarios,
331
+ memoryFreshness: authoring.memoryFreshness,
332
+ priorFeedback: authoring.priorFeedback,
333
+ ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
334
+ maxTickets: limits.maxTickets,
335
+ maxTokenBudget: limits.maxTokenBudget,
336
+ preflightCeilings: resolvePreflightCeilings(config),
337
+ riskHeuristics: heuristics,
338
+ systemPrompts: buildSystemPrompts({
339
+ heuristics,
340
+ maxTickets: limits.maxTickets,
341
+ maxTokenBudget: limits.maxTokenBudget,
342
+ epicId,
343
+ }),
344
+ deliveryShapeSignal: buildDeliveryShapeSignal({ body }),
345
+ planState,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Build the one-pager (ideation) envelope. The Epic does not exist yet —
351
+ * creation moves to the persist half — so there is no clarity score, no
352
+ * re-plan signal, and no plan state; the dup search replaces them as the
353
+ * mode's gating input. `docsContext` is inline-digest (the standalone
354
+ * `story-plan.js --emit-context` convention): there is no per-Epic temp
355
+ * directory to anchor a digest file to yet.
356
+ */
357
+ async function buildOnePagerModeEnvelope({
358
+ onePagerPath,
359
+ onePagerContent,
360
+ provider,
361
+ config,
362
+ settings,
363
+ fullContext,
364
+ cwd,
365
+ }) {
366
+ const content =
367
+ onePagerContent ?? (await readFile(onePagerPath ?? '', 'utf-8'));
368
+ if (typeof content !== 'string' || content.trim().length === 0) {
369
+ throw new Error(
370
+ `[plan-context] one-pager at ${onePagerPath} is empty — nothing to plan from.`,
371
+ );
372
+ }
373
+
374
+ let duplicates = [];
375
+ try {
376
+ duplicates = await findSimilarOpenEpics({
377
+ onePager: content,
378
+ provider,
379
+ owner: config.github?.owner,
380
+ repo: config.github?.repo,
381
+ });
382
+ } catch (err) {
383
+ // The dup search is a triage signal, not a gate: a provider listing
384
+ // failure must not abort the envelope build. Surface the degradation
385
+ // on stderr; the authoring middle sees an empty candidate list.
386
+ Logger.warn(
387
+ `[plan-context] duplicate search degraded to no candidates: ${err?.message ?? err}`,
388
+ );
389
+ duplicates = [];
390
+ }
391
+
392
+ // Fold the same authoring-context builders the epic path uses, grounded
393
+ // in the one-pager prose instead of an Epic body. Reuse
394
+ // `buildAuthoringContext` via the prefetch seam so the fold has exactly
395
+ // one implementation of the snapshot/BDD/memory/feedback pipeline to
396
+ // drift from. `docsContextFiles` is emptied for this call: the per-Epic
397
+ // digest-file path needs an Epic id (and a temp directory) that does not
398
+ // exist yet — the inline digest below replaces it.
399
+ const authoring = await buildAuthoringContext(
400
+ 0,
401
+ /* provider (unused behind the prefetch seam) */ {},
402
+ { ...settings, docsContextFiles: [] },
403
+ {
404
+ epic: { id: 0, title: onePagerPath ?? 'one-pager', body: content },
405
+ fullContext,
406
+ github: config.github ?? null,
407
+ cwd,
408
+ },
409
+ );
410
+
411
+ // Replace the per-Epic digest-file pointer with an inline digest — the
412
+ // Epic (and its temp directory) does not exist yet.
413
+ const paths = settings?.paths ?? {};
414
+ const inlineDigest = await buildDocsDigest({
415
+ docsContextFiles: settings?.docsContextFiles,
416
+ docsRoot: paths.docsRoot,
417
+ });
418
+ const docsContext =
419
+ inlineDigest == null
420
+ ? null
421
+ : { mode: 'digest-inline', digest: inlineDigest };
422
+
423
+ const limits = getLimits(config);
424
+ const heuristics = resolveRiskHeuristics(config);
425
+
426
+ return {
427
+ mode: 'one-pager',
428
+ onePager: { path: onePagerPath ?? null, content },
429
+ duplicates,
430
+ docsContext,
431
+ codebaseSnapshot: authoring.codebaseSnapshot,
432
+ bddRunner: authoring.bddRunner,
433
+ bddScenarios: authoring.bddScenarios,
434
+ memoryFreshness: authoring.memoryFreshness,
435
+ priorFeedback: authoring.priorFeedback,
436
+ ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
437
+ maxTickets: limits.maxTickets,
438
+ maxTokenBudget: limits.maxTokenBudget,
439
+ preflightCeilings: resolvePreflightCeilings(config),
440
+ riskHeuristics: heuristics,
441
+ systemPrompts: buildSystemPrompts({
442
+ heuristics,
443
+ maxTickets: limits.maxTickets,
444
+ maxTokenBudget: limits.maxTokenBudget,
445
+ epicId: null,
446
+ }),
447
+ deliveryShapeSignal: buildDeliveryShapeSignal({ body: content }),
448
+ planState: null,
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Build the single planner-context envelope.
454
+ *
455
+ * @param {{
456
+ * mode: 'epic'|'one-pager',
457
+ * epicId?: number,
458
+ * onePagerPath?: string,
459
+ * onePagerContent?: string,
460
+ * provider: object,
461
+ * config: object,
462
+ * settings: object,
463
+ * cwd?: string,
464
+ * }} args
465
+ * @returns {Promise<object>} the JSON-serialisable envelope.
466
+ */
467
+ export async function buildPlanContext({
468
+ mode,
469
+ epicId,
470
+ onePagerPath,
471
+ onePagerContent,
472
+ provider,
473
+ config = {},
474
+ settings = {},
475
+ fullContext = false,
476
+ cwd,
477
+ }) {
478
+ if (mode === 'epic') {
479
+ if (!Number.isInteger(epicId)) {
480
+ throw new Error('[plan-context] epic mode requires a numeric epicId.');
481
+ }
482
+ return buildEpicModeEnvelope({
483
+ epicId,
484
+ provider,
485
+ config,
486
+ settings,
487
+ fullContext,
488
+ cwd,
489
+ });
490
+ }
491
+ if (mode === 'one-pager') {
492
+ if (!onePagerPath && typeof onePagerContent !== 'string') {
493
+ throw new Error(
494
+ '[plan-context] one-pager mode requires --one-pager <path>.',
495
+ );
496
+ }
497
+ return buildOnePagerModeEnvelope({
498
+ onePagerPath,
499
+ onePagerContent,
500
+ provider,
501
+ config,
502
+ settings,
503
+ fullContext,
504
+ cwd,
505
+ });
506
+ }
507
+ throw new Error(
508
+ `[plan-context] unknown mode "${mode}" — expected "epic" or "one-pager".`,
509
+ );
510
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * plan-critic-conditions.js — risk/size-conditional dispatch decisions for
3
+ * the /plan author-step critics (Epic #4474 PR6, design §4).
4
+ *
5
+ * The collapsed plan flow keeps the consolidation (8.3) and pre-mortem
6
+ * (8.5) critics as fresh-context sub-agent dispatches, but makes each
7
+ * dispatch **conditional** instead of unconditional — the dominant plan
8
+ * cost is turns × standing context, and an unconditional critic pays a
9
+ * full sub-agent spawn even when it provably has nothing to find. This
10
+ * module computes those decisions deterministically so the workflow never
11
+ * judges its own dispatch conditions:
12
+ *
13
+ * - **Consolidation (8.3)**: dispatch only when the existing
14
+ * `evaluateConsolidationPrecondition` gate says `dispatch: true` AND
15
+ * (the draft has more than `CONSOLIDATION_STORY_THRESHOLD` stories OR
16
+ * the precondition confirmed a divergence from the Delivery Slicing
17
+ * table). A fail-open precondition (missing/unparseable table) on a
18
+ * small draft is NOT a confirmed divergence — it skips, because a
19
+ * ≤-threshold draft is small enough for gate #2's single-view review
20
+ * to catch a distorted shape without a dedicated sub-agent.
21
+ * - **Pre-mortem (8.5)**: dispatch when the risk verdict's overall level
22
+ * is `high`, OR the ticket count is at least half of `maxTickets`, OR
23
+ * any configured `planning.riskHeuristics` phrase matches the plan
24
+ * text (case-insensitive substring).
25
+ *
26
+ * Under-firing risk (design PR6 note): the persist validators are
27
+ * unchanged hard gates and G2's cohort re-measures plan quality; every
28
+ * skip decision this module produces is logged to the plan-metrics ledger
29
+ * (`appendCriticSkip`) by the callers so under-firing is auditable.
30
+ *
31
+ * Pure, synchronous, no I/O — the `plan-critics.js` CLI owns reading the
32
+ * authored artifacts and the resolved config.
33
+ */
34
+
35
+ import { evaluateConsolidationPrecondition } from './consolidation-precondition.js';
36
+ import { deriveRiskEnvelope } from './planning-risk.js';
37
+
38
+ /**
39
+ * Draft-story count above which the consolidation critic fires even
40
+ * without a confirmed slicing divergence (design §6 PR6: "> 5 stories").
41
+ */
42
+ export const CONSOLIDATION_STORY_THRESHOLD = 5;
43
+
44
+ /**
45
+ * @typedef {Object} CriticDispatchDecision
46
+ * @property {'consolidation'|'pre-mortem'} critic
47
+ * @property {boolean} dispatch
48
+ * @property {string[]} reasons Why the critic fires — or why it is safe to
49
+ * skip. Never empty: a skip's reasons are the audit trail the
50
+ * plan-metrics ledger records.
51
+ */
52
+
53
+ /**
54
+ * Decide the 8.3 consolidation dispatch: precondition AND size/divergence.
55
+ *
56
+ * @param {object} input
57
+ * @param {object[]} input.draftStories - The draft `tickets.json` array
58
+ * (raw Story objects with top-level `slug` / `depends_on` / `body`).
59
+ * @param {string} input.specText - The text carrying the `## Delivery
60
+ * Slicing` table. At author time this is the authored `techspec.md`
61
+ * content (the Epic body carries the same folded section post-persist).
62
+ * @returns {CriticDispatchDecision}
63
+ */
64
+ export function evaluateConsolidationDispatch({ draftStories, specText }) {
65
+ const precondition = evaluateConsolidationPrecondition({
66
+ draftStories,
67
+ epicBody: specText,
68
+ });
69
+
70
+ if (!precondition.dispatch) {
71
+ return {
72
+ critic: 'consolidation',
73
+ dispatch: false,
74
+ reasons: precondition.reasons,
75
+ };
76
+ }
77
+
78
+ const storyCount = draftStories.length;
79
+ const oversized = storyCount > CONSOLIDATION_STORY_THRESHOLD;
80
+ const diverges = precondition.cause === 'divergence';
81
+
82
+ if (!oversized && !diverges) {
83
+ return {
84
+ critic: 'consolidation',
85
+ dispatch: false,
86
+ reasons: [
87
+ `Draft has ${storyCount} story(ies) (≤ ${CONSOLIDATION_STORY_THRESHOLD}) and no confirmed Delivery Slicing divergence — gate #2's single-view review covers a draft this small.`,
88
+ ...precondition.reasons,
89
+ ],
90
+ };
91
+ }
92
+
93
+ const reasons = [];
94
+ if (diverges) reasons.push(...precondition.reasons);
95
+ if (oversized) {
96
+ reasons.push(
97
+ `Draft has ${storyCount} stories (> ${CONSOLIDATION_STORY_THRESHOLD}) — large enough that a distorted shape can hide from the gate #2 single view.`,
98
+ );
99
+ }
100
+ if (!diverges && precondition.cause === 'fail-open') {
101
+ reasons.push(...precondition.reasons);
102
+ }
103
+ return { critic: 'consolidation', dispatch: true, reasons };
104
+ }
105
+
106
+ /**
107
+ * Decide the 8.5 pre-mortem dispatch: high risk, or size ≥ ½ budget, or a
108
+ * risk-heuristic phrase match.
109
+ *
110
+ * @param {object} input
111
+ * @param {import('./planning-risk.js').RiskVerdict} input.riskVerdict -
112
+ * The authored `risk-verdict.json` payload; the overall level is derived
113
+ * deterministically from its axes (`deriveRiskEnvelope`), never trusted
114
+ * as a free-standing field.
115
+ * @param {number} input.ticketCount - Draft ticket count (0 in the
116
+ * single-delivery shape — no tickets exist).
117
+ * @param {number} input.maxTickets - The reviewability budget
118
+ * (`getLimits(config).maxTickets`).
119
+ * @param {string[]} [input.riskHeuristics] - `planning.riskHeuristics`
120
+ * phrases from the resolved config.
121
+ * @param {string} [input.planText] - Concatenated plan text the heuristics
122
+ * match against (tech spec + serialized tickets + risk summary).
123
+ * @returns {CriticDispatchDecision}
124
+ */
125
+ export function evaluatePremortemDispatch({
126
+ riskVerdict,
127
+ ticketCount,
128
+ maxTickets,
129
+ riskHeuristics = [],
130
+ planText = '',
131
+ }) {
132
+ if (!Number.isInteger(maxTickets) || maxTickets <= 0) {
133
+ throw new TypeError(
134
+ 'evaluatePremortemDispatch: maxTickets must be a positive integer',
135
+ );
136
+ }
137
+ const reasons = [];
138
+
139
+ const { overallLevel } = deriveRiskEnvelope(riskVerdict);
140
+ if (overallLevel === 'high') {
141
+ reasons.push(
142
+ 'Risk verdict overall level is high — predicted-rework findings are worth a fresh-context pass.',
143
+ );
144
+ }
145
+
146
+ const count = Number.isInteger(ticketCount) ? ticketCount : 0;
147
+ if (count * 2 >= maxTickets) {
148
+ reasons.push(
149
+ `Ticket count ${count} is at least half the reviewability budget (maxTickets ${maxTickets}).`,
150
+ );
151
+ }
152
+
153
+ const haystack = String(planText).toLowerCase();
154
+ const matched = riskHeuristics.filter(
155
+ (phrase) =>
156
+ typeof phrase === 'string' &&
157
+ phrase.trim().length > 0 &&
158
+ haystack.includes(phrase.trim().toLowerCase()),
159
+ );
160
+ if (matched.length > 0) {
161
+ reasons.push(
162
+ `planning.riskHeuristics match(es) in the plan text: ${matched.map((p) => `"${p.trim()}"`).join(', ')}.`,
163
+ );
164
+ }
165
+
166
+ if (reasons.length > 0) {
167
+ return { critic: 'pre-mortem', dispatch: true, reasons };
168
+ }
169
+
170
+ return {
171
+ critic: 'pre-mortem',
172
+ dispatch: false,
173
+ reasons: [
174
+ `Overall risk is ${overallLevel} (not high), ticket count ${count} is under half the budget (maxTickets ${maxTickets}), and no planning.riskHeuristics phrase matches the plan text.`,
175
+ ],
176
+ };
177
+ }