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.
- package/.agents/README.md +5 -5
- package/.agents/docs/SDLC.md +75 -107
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/docs/workflows.md +4 -10
- package/.agents/instructions.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/lifecycle/README.md +2 -1
- package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
- package/.agents/schemas/risk-verdict.schema.json +14 -1
- package/.agents/scripts/README.md +0 -46
- package/.agents/scripts/analyze-execution.js +76 -1
- package/.agents/scripts/check-doc-links.js +3 -0
- package/.agents/scripts/check-workflow-cli-lint.js +208 -0
- package/.agents/scripts/epic-audit-recheck.js +1 -1
- package/.agents/scripts/epic-plan-clarity.js +16 -6
- package/.agents/scripts/epic-plan-decompose.js +37 -59
- package/.agents/scripts/epic-plan-healthcheck.js +39 -6
- package/.agents/scripts/epic-plan-spec.js +41 -168
- package/.agents/scripts/epic-reconcile.js +7 -1
- package/.agents/scripts/lib/Logger.js +4 -4
- package/.agents/scripts/lib/audit-suite/index.js +4 -5
- package/.agents/scripts/lib/audit-suite/runner.js +6 -4
- package/.agents/scripts/lib/audit-suite/selector.js +2 -3
- package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
- package/.agents/scripts/lib/command-header.js +20 -0
- package/.agents/scripts/lib/config/github.js +1 -3
- package/.agents/scripts/lib/config-settings-schema.js +4 -3
- package/.agents/scripts/lib/degraded-mode.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -3
- package/.agents/scripts/lib/label-taxonomy.js +14 -3
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
- package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
- package/.agents/scripts/lib/orchestration/file-assumptions.js +68 -7
- package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-context.js +696 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +99 -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 +1015 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +206 -0
- package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
- package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
- package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -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 +204 -0
- package/.agents/scripts/plan-critics.js +199 -0
- package/.agents/scripts/plan-persist.js +383 -0
- package/.agents/scripts/pr-watch-with-update.js +8 -7
- package/.agents/scripts/run-lint.js +10 -11
- package/.agents/scripts/story-plan.js +19 -7
- package/.agents/scripts/sync-claude-commands.js +10 -5
- package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
- package/.agents/workflows/audit-documentation.md +5 -7
- package/.agents/workflows/audit-lighthouse.md +1 -0
- package/.agents/workflows/audit-security.md +1 -0
- package/.agents/workflows/audit-to-stories.md +16 -5
- package/.agents/workflows/git-deliver.md +76 -243
- package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
- package/.agents/workflows/helpers/code-review.md +7 -6
- package/.agents/workflows/helpers/deliver-epic.md +5 -4
- package/.agents/workflows/helpers/plan-epic-reference.md +120 -96
- package/.agents/workflows/helpers/plan-epic.md +307 -994
- package/.agents/workflows/helpers/plan-story.md +14 -14
- package/.agents/workflows/helpers/scope-triage-gate.md +15 -4
- package/.agents/workflows/helpers/single-story-deliver.md +5 -4
- package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
- package/.agents/workflows/mandrel-update.md +144 -466
- package/.agents/workflows/plan.md +87 -145
- 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,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
|
+
}
|