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.
- 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-deliver-preflight.js +37 -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/lifecycle/listeners/finalizer.js +32 -0
- 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/remote-verifier.js +165 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
- 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/single-story-init.js +22 -0
- 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/deliver.md +8 -0
- 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 +16 -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 +13 -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 +20 -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,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
|
+
}
|