mandrel 1.92.0 → 1.94.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/agents/acceptance-critic.md +129 -0
- package/.agents/agents/retro.md +42 -0
- package/.agents/agents/story-worker.md +162 -0
- package/.agents/docs/configuration.md +7 -1
- package/.agents/docs/execution-reference.md +27 -2
- package/.agents/instructions.md +43 -33
- package/.agents/personas/engineer.md +26 -112
- package/.agents/personas/security-engineer.md +1 -2
- package/.agents/rules/git-conventions-reference.md +225 -0
- package/.agents/rules/git-conventions.md +25 -200
- package/.agents/rules/security-baseline.md +5 -0
- package/.agents/rules/testing-standards.md +106 -13
- package/.agents/schemas/agentrc.schema.json +31 -1
- package/.agents/schemas/lifecycle/slice.end.schema.json +21 -0
- package/.agents/schemas/lifecycle/slice.heartbeat.schema.json +20 -0
- package/.agents/schemas/lifecycle/slice.start.schema.json +17 -0
- package/.agents/scripts/acceptance-eval.js +62 -18
- package/.agents/scripts/agents-bootstrap-github.js +1 -1
- package/.agents/scripts/bookkeeping-reconcile.js +117 -0
- package/.agents/scripts/check-context-budget.js +62 -5
- package/.agents/scripts/diagnose-friction.js +0 -6
- package/.agents/scripts/epic-deliver-prepare.js +272 -10
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +56 -18
- package/.agents/scripts/lib/close-validation/gates.js +159 -21
- package/.agents/scripts/lib/config/acceptance-eval.js +52 -5
- package/.agents/scripts/lib/config/delivery-routing.js +87 -0
- package/.agents/scripts/lib/config/explain.js +2 -0
- package/.agents/scripts/lib/config-resolver.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +37 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +9 -0
- package/.agents/scripts/lib/doc-tiers.js +37 -2
- package/.agents/scripts/lib/observability/active-story-env.js +111 -2
- package/.agents/scripts/lib/observability/hook-heartbeat.js +219 -0
- package/.agents/scripts/lib/observability/tool-trace-hook.js +15 -4
- package/.agents/scripts/lib/orchestration/acceptance-clusters.js +111 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +32 -4
- package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +270 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +141 -0
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -124
- package/.agents/scripts/lib/orchestration/deliver-route.js +173 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +233 -0
- package/.agents/scripts/lib/orchestration/file-assumptions.js +68 -7
- package/.agents/scripts/lib/orchestration/lifecycle/emit-slice-lifecycle.js +270 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +83 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/checkpoint-pointer-writer.js +6 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +189 -3
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +3 -2
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +99 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +38 -1
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +16 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -0
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +1 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -2
- package/.agents/scripts/lib/provider-factory.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/plan-context.js +28 -10
- package/.agents/scripts/post-structured-comment.js +38 -0
- package/.agents/scripts/slice-phase.js +361 -0
- package/.agents/scripts/sync-claude-agents.js +165 -0
- package/.agents/scripts/update-ticket-state.js +31 -0
- package/.agents/scripts/wave-tick.js +138 -9
- package/.agents/skills/core/api-and-interface-design/SKILL.md +5 -3
- package/.agents/skills/core/code-review-and-quality/SKILL.md +63 -7
- package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +1 -1
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +5 -5
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +8 -8
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +4 -4
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +26 -56
- package/.agents/skills/core/gates-and-baselines/SKILL.md +149 -0
- package/.agents/skills/core/idea-refinement/SKILL.md +2 -8
- package/.agents/skills/core/qa-coverage-mapping/SKILL.md +7 -7
- package/.agents/skills/skills.index.json +11 -381
- package/.agents/workflows/deliver.md +47 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +38 -13
- package/.agents/workflows/helpers/deliver-epic-reference.md +18 -5
- package/.agents/workflows/helpers/deliver-epic-single.md +331 -0
- package/.agents/workflows/helpers/deliver-epic.md +51 -8
- package/.agents/workflows/helpers/deliver-stories.md +15 -5
- package/.agents/workflows/helpers/epic-deliver-story.md +12 -3
- package/.agents/workflows/helpers/mandrel-sync-config.md +1 -1
- package/.agents/workflows/helpers/plan-epic-reference.md +19 -8
- package/.agents/workflows/helpers/plan-epic.md +95 -27
- package/.agents/workflows/helpers/scope-triage-gate.md +9 -0
- package/.agents/workflows/mandrel-update.md +1 -1
- package/.agents/workflows/plan.md +16 -4
- package/docs/CHANGELOG.md +23 -0
- package/lib/cli/registry.js +95 -0
- package/package.json +4 -2
- package/.agents/personas/engineer-mobile.md +0 -120
- package/.agents/personas/engineer-web.md +0 -111
- package/.agents/personas/product.md +0 -94
- package/.agents/personas/refactorer.md +0 -113
- package/.agents/personas/sre.md +0 -86
- package/.agents/personas/ux-designer.md +0 -95
- package/.agents/scripts/epic-plan-decompose.js +0 -54
- package/.agents/scripts/epic-plan-spec.js +0 -64
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +0 -109
- package/.agents/scripts/plan-critics.js +0 -227
- package/.agents/skills/core/baseline-refresh/SKILL.md +0 -181
- package/.agents/skills/core/ci-cd-and-automation/SKILL.md +0 -274
- package/.agents/skills/core/ci-cd-and-automation/examples.md +0 -211
- package/.agents/skills/core/code-simplification/SKILL.md +0 -389
- package/.agents/skills/core/context-engineering/SKILL.md +0 -309
- package/.agents/skills/core/context-engineering/examples.md +0 -58
- package/.agents/skills/core/deprecation-and-migration/SKILL.md +0 -250
- package/.agents/skills/core/frontend-ui-engineering/SKILL.md +0 -357
- package/.agents/skills/core/hydrate-context/SKILL.md +0 -123
- package/.agents/skills/core/idea-refinement/examples.md +0 -437
- package/.agents/skills/core/idea-refinement/frameworks.md +0 -135
- package/.agents/skills/core/incremental-implementation/SKILL.md +0 -271
- package/.agents/skills/core/introducing-a-baseline-gate/SKILL.md +0 -213
- package/.agents/skills/core/knowledge-transfer/SKILL.md +0 -180
- package/.agents/skills/core/mutation-survivor-remediation/SKILL.md +0 -117
- package/.agents/skills/core/performance-optimization/SKILL.md +0 -314
- package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +0 -277
- package/.agents/skills/core/property-based-testing/SKILL.md +0 -148
- package/.agents/skills/core/refactoring-discipline/SKILL.md +0 -111
- package/.agents/skills/core/shipping-and-launch/SKILL.md +0 -328
- package/.agents/skills/core/spec-driven-development/SKILL.md +0 -252
- package/.agents/skills/core/test-driven-development/SKILL.md +0 -475
- package/.agents/skills/core/using-agent-skills/SKILL.md +0 -232
- package/.agents/skills/stack/architecture/monorepo-path-strategist/SKILL.md +0 -31
- package/.agents/skills/stack/architecture/structured-output-zod/SKILL.md +0 -51
- package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +0 -76
- package/.agents/skills/stack/backend/cloudflare-hono-architect/SKILL.md +0 -31
- package/.agents/skills/stack/backend/cloudflare-hono-architect/examples/route-template.ts +0 -33
- package/.agents/skills/stack/backend/cloudflare-queue-manager/SKILL.md +0 -31
- package/.agents/skills/stack/backend/cloudflare-workers/SKILL.md +0 -51
- package/.agents/skills/stack/backend/highlevel-crm/SKILL.md +0 -54
- package/.agents/skills/stack/backend/sqlite-drizzle-expert/SKILL.md +0 -29
- package/.agents/skills/stack/backend/sqlite-drizzle-expert/examples/schema-template.ts +0 -30
- package/.agents/skills/stack/backend/stripe-integration/SKILL.md +0 -57
- package/.agents/skills/stack/backend/stripe-integration/scripts/listen-stripe.sh +0 -9
- package/.agents/skills/stack/backend/turso-sqlite/SKILL.md +0 -48
- package/.agents/skills/stack/frontend/astro/SKILL.md +0 -62
- package/.agents/skills/stack/frontend/astro-react-island-strategist/SKILL.md +0 -30
- package/.agents/skills/stack/frontend/expo-react-native-developer/SKILL.md +0 -29
- package/.agents/skills/stack/frontend/google-analytics-v4/SKILL.md +0 -50
- package/.agents/skills/stack/frontend/tailwind-v4/SKILL.md +0 -58
- package/.agents/skills/stack/frontend/ui-accessibility-engineer/SKILL.md +0 -34
- package/.agents/skills/stack/qa/audit-accessibility/SKILL.md +0 -51
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +0 -199
- package/.agents/skills/stack/security/backend-security-patterns/SKILL.md +0 -68
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/deliver-route.js — the deliver-side reader for the
|
|
3
|
+
* plan-time single-delivery seam (Epic #4475, M4-A).
|
|
4
|
+
*
|
|
5
|
+
* The plan path already ships the seam inert (Epic #4474): the risk verdict's
|
|
6
|
+
* `deliveryShape`, the `delivery::single` label, and the
|
|
7
|
+
* `epic-plan-state.decompose = { ticketCount: 0, shape: "single" }`
|
|
8
|
+
* checkpoint. Nothing on the deliver side read them — until this module. It
|
|
9
|
+
* folds those two markers (plus the global kill-switch) into a single
|
|
10
|
+
* delivery-route verdict for `/deliver`.
|
|
11
|
+
*
|
|
12
|
+
* Pure and total — inputs in, verdict out. No GitHub calls, no I/O, no
|
|
13
|
+
* throws. It mirrors how `review-depth.js#resolveDepth` and
|
|
14
|
+
* `epic-audit-prepare.js` read `planningRisk` / `decompose` off the
|
|
15
|
+
* `epic-plan-state` checkpoint: the caller fetches the Epic labels + the
|
|
16
|
+
* checkpoint, then hands both here.
|
|
17
|
+
*
|
|
18
|
+
* ── Routing precedence (highest wins) ──────────────────────────────────
|
|
19
|
+
* 1. Kill-switch. `delivery.routing.singleDelivery === false` forces
|
|
20
|
+
* `fan-out` for EVERY Epic, even a single-labelled one — the instant
|
|
21
|
+
* global revert (`lib/config/delivery-routing.js`). Overrides all.
|
|
22
|
+
* 2. Single marker. The `delivery::single` label (primary) OR
|
|
23
|
+
* `checkpoint.decompose.shape === "single"` (secondary) → `single`.
|
|
24
|
+
* 3. Wide-DAG advisory. A fan-out-shaped Epic whose ready-width > 1 is
|
|
25
|
+
* genuine parallelism → `fan-out`. Deliver-time width only ADVISES —
|
|
26
|
+
* it never reroutes a single-marked Epic (that would orphan a Story
|
|
27
|
+
* tree that does not exist; re-route = re-plan).
|
|
28
|
+
* 4. Legacy / no marker → `fan-out` (safe backward-compat: a legacy Epic
|
|
29
|
+
* has an authored Story tree the fan-out engine consumes).
|
|
30
|
+
*
|
|
31
|
+
* BEHAVIOR-PRESERVING in M4-A: the router consumes a `single` verdict via a
|
|
32
|
+
* stub that continues at `deliver-epic.md` (fan-out), so no `/deliver`
|
|
33
|
+
* observably changes until M4-B lands `deliver-epic-single.md`.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {'single'|'fan-out'} DeliveryRoute
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { getDeliveryRouting } from '../config/delivery-routing.js';
|
|
39
|
+
|
|
40
|
+
/** The canonical single-delivery routing marker label. */
|
|
41
|
+
export const DELIVERY_SINGLE_LABEL = 'delivery::single';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True when the Epic carries the `delivery::single` routing label. Tolerant
|
|
45
|
+
* of the two label shapes callers hold: an array of label-name strings, or an
|
|
46
|
+
* array of `{ name }` objects (the GitHub REST label shape).
|
|
47
|
+
*
|
|
48
|
+
* @param {{ labels?: Array<string | { name?: string }> } | null | undefined} epic
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function hasSingleLabel(epic) {
|
|
52
|
+
const labels = Array.isArray(epic?.labels) ? epic.labels : [];
|
|
53
|
+
return labels.some((l) => {
|
|
54
|
+
const name = typeof l === 'string' ? l : l?.name;
|
|
55
|
+
return name === DELIVERY_SINGLE_LABEL;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True when the plan-state checkpoint's `decompose.shape` is the deliberate
|
|
61
|
+
* single shape. `plan-persist.js` writes `decompose = { ticketCount: 0,
|
|
62
|
+
* shape: "single" }` for a spec-only plan; every other value (including a
|
|
63
|
+
* legacy checkpoint with no `shape` field) is not-single.
|
|
64
|
+
*
|
|
65
|
+
* @param {{ decompose?: { shape?: string } } | null | undefined} checkpoint
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
function hasSingleDecomposeShape(checkpoint) {
|
|
69
|
+
return checkpoint?.decompose?.shape === 'single';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the delivery route for one Epic from its labels, its plan-state
|
|
74
|
+
* checkpoint, and the resolved config. See the module header for the full
|
|
75
|
+
* precedence. Pure — never throws; malformed / absent inputs degrade to the
|
|
76
|
+
* safe `fan-out` default.
|
|
77
|
+
*
|
|
78
|
+
* @param {{ labels?: Array<string | { name?: string }> } | null | undefined} epic
|
|
79
|
+
* The Epic ticket snapshot (only `labels` is read here).
|
|
80
|
+
* @param {{ decompose?: { shape?: string } } | null | undefined} checkpoint
|
|
81
|
+
* The `epic-plan-state` structured comment, parsed (or `null` on a legacy
|
|
82
|
+
* Epic that predates the checkpoint — treated as no marker).
|
|
83
|
+
* @param {object | null | undefined} config
|
|
84
|
+
* The resolved config; `delivery.routing.singleDelivery` is the kill-switch.
|
|
85
|
+
* @returns {{ route: DeliveryRoute, reason: string, singleDeliveryEnabled: boolean }}
|
|
86
|
+
*/
|
|
87
|
+
export function resolveEpicDeliveryRoute(epic, checkpoint, config) {
|
|
88
|
+
const { singleDelivery } = getDeliveryRouting(config);
|
|
89
|
+
|
|
90
|
+
// 1. Kill-switch — overrides all. A `false` value forces fan-out even for
|
|
91
|
+
// an Epic the planner marked single (the instant global revert).
|
|
92
|
+
if (singleDelivery === false) {
|
|
93
|
+
return {
|
|
94
|
+
route: 'fan-out',
|
|
95
|
+
reason:
|
|
96
|
+
'kill-switch: delivery.routing.singleDelivery=false forces fan-out for every Epic',
|
|
97
|
+
singleDeliveryEnabled: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Single marker — label (primary) OR checkpoint decompose shape
|
|
102
|
+
// (secondary). Either alone routes single.
|
|
103
|
+
const labelled = hasSingleLabel(epic);
|
|
104
|
+
const shaped = hasSingleDecomposeShape(checkpoint);
|
|
105
|
+
if (labelled || shaped) {
|
|
106
|
+
const via =
|
|
107
|
+
labelled && shaped
|
|
108
|
+
? `${DELIVERY_SINGLE_LABEL} label + decompose.shape="single"`
|
|
109
|
+
: labelled
|
|
110
|
+
? `${DELIVERY_SINGLE_LABEL} label`
|
|
111
|
+
: 'decompose.shape="single"';
|
|
112
|
+
return {
|
|
113
|
+
route: 'single',
|
|
114
|
+
reason: `single marker present (${via})`,
|
|
115
|
+
singleDeliveryEnabled: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3/4. No single marker → fan-out (a wide DAG or a legacy Story tree; the
|
|
120
|
+
// deliver-time width only advises and never reroutes here).
|
|
121
|
+
return {
|
|
122
|
+
route: 'fan-out',
|
|
123
|
+
reason: 'no single marker — fan-out (legacy Story tree or wide DAG)',
|
|
124
|
+
singleDeliveryEnabled: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compute the ready-width of a delivery plan — the count of work units that
|
|
130
|
+
* could dispatch on the first beat. It is the mechanical parallelism signal
|
|
131
|
+
* the planner uses at plan time to mark a genuinely-wide Epic `fan-out`, and
|
|
132
|
+
* that `/deliver` may surface as an advisory at deliver time (it never
|
|
133
|
+
* reroutes — see the module header).
|
|
134
|
+
*
|
|
135
|
+
* Accepts either input shape the framework already produces:
|
|
136
|
+
* - Delivery-Slicing rows — `[{ independent: boolean }, …]` from
|
|
137
|
+
* `parseDeliverySlicingTable`. A serial chain (every row
|
|
138
|
+
* `Independent: No`, or a lone independent slice) has width 1; two or
|
|
139
|
+
* more independent slices yield a width equal to that independent count
|
|
140
|
+
* (> 1) — genuine parallelism.
|
|
141
|
+
* - A wave DAG — `[[…], […], …]` (the array-of-waves `state.waves` shape
|
|
142
|
+
* `runBuildWaveDagPhase` produces). The width is the largest wave — the
|
|
143
|
+
* most Stories ready to dispatch on a single beat.
|
|
144
|
+
*
|
|
145
|
+
* Pure and total: an empty / malformed / unrecognized input degrades to
|
|
146
|
+
* width 1 (the safe "not wide" signal). Never throws.
|
|
147
|
+
*
|
|
148
|
+
* @param {Array<{ independent?: boolean }> | Array<Array<unknown>> | null | undefined} deliverySlicingRowsOrDag
|
|
149
|
+
* @returns {number} an integer ≥ 1.
|
|
150
|
+
*/
|
|
151
|
+
export function computeReadyWidth(deliverySlicingRowsOrDag) {
|
|
152
|
+
const input = deliverySlicingRowsOrDag;
|
|
153
|
+
if (!Array.isArray(input) || input.length === 0) return 1;
|
|
154
|
+
|
|
155
|
+
// Wave-DAG shape: an array whose entries are themselves arrays.
|
|
156
|
+
if (input.every((entry) => Array.isArray(entry))) {
|
|
157
|
+
let widest = 1;
|
|
158
|
+
for (const wave of input) {
|
|
159
|
+
if (wave.length > widest) widest = wave.length;
|
|
160
|
+
}
|
|
161
|
+
return widest;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Delivery-Slicing rows: entries are `{ independent }` records.
|
|
165
|
+
const independentCount = input.reduce(
|
|
166
|
+
(acc, row) =>
|
|
167
|
+
row && typeof row === 'object' && row.independent === true
|
|
168
|
+
? acc + 1
|
|
169
|
+
: acc,
|
|
170
|
+
0,
|
|
171
|
+
);
|
|
172
|
+
return independentCount >= 2 ? independentCount : 1;
|
|
173
|
+
}
|
|
@@ -41,7 +41,7 @@ import { buildAuthoringGrounding } from './spec-authoring-grounding.js';
|
|
|
41
41
|
* @param {{ github?: { owner?: string, repo?: string }|null }} opts
|
|
42
42
|
* @returns {string|null}
|
|
43
43
|
*/
|
|
44
|
-
|
|
44
|
+
function resolveMemoryDir({ github } = {}) {
|
|
45
45
|
if (
|
|
46
46
|
typeof process.env.MANDREL_MEMORY_DIR === 'string' &&
|
|
47
47
|
process.env.MANDREL_MEMORY_DIR.length > 0
|
|
@@ -326,6 +326,239 @@ export async function recordStoryStatus({
|
|
|
326
326
|
});
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Terminal / in-progress per-slice statuses persisted on a single-delivery
|
|
331
|
+
* checkpoint. The single-delivery analogue of `STORY_STATUSES` — a spec-only
|
|
332
|
+
* plan authors no Story tickets, so the leaf work unit is a `## Delivery
|
|
333
|
+
* Slicing` slice walked in-session on `epic/<id>` (Epic #4475). The design's
|
|
334
|
+
* durable contract is `{pending, done}`; `blocked`/`failed` mirror the story
|
|
335
|
+
* map so the M4-B executor can record a stalled slice without a schema bump.
|
|
336
|
+
*/
|
|
337
|
+
export const SLICE_STATUSES = Object.freeze([
|
|
338
|
+
'pending',
|
|
339
|
+
'done',
|
|
340
|
+
'blocked',
|
|
341
|
+
'failed',
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Build the initial per-slice status map from parsed `## Delivery Slicing`
|
|
346
|
+
* rows (`parseDeliverySlicingTable` output — `[{ slice, independent }]`).
|
|
347
|
+
* Every slice seeds at `status: 'pending'`, keyed by a **stable, position-
|
|
348
|
+
* derived** id `slice-<n>` (1-based) so a re-run over the same table
|
|
349
|
+
* round-trips its keys. The slice's human label is carried through as `title`
|
|
350
|
+
* for the operator rollup. Pure — exported for unit tests.
|
|
351
|
+
*
|
|
352
|
+
* @param {Array<{ slice?: string, independent?: boolean } | string>} slices
|
|
353
|
+
* @returns {Record<string, { status: string, title?: string }>}
|
|
354
|
+
*/
|
|
355
|
+
export function buildSliceStatusMap(slices) {
|
|
356
|
+
const out = {};
|
|
357
|
+
const rows = Array.isArray(slices) ? slices : [];
|
|
358
|
+
rows.forEach((entry, i) => {
|
|
359
|
+
const sliceId = `slice-${i + 1}`;
|
|
360
|
+
const record = { status: 'pending' };
|
|
361
|
+
const title =
|
|
362
|
+
typeof entry === 'string'
|
|
363
|
+
? entry
|
|
364
|
+
: entry && typeof entry === 'object' && typeof entry.slice === 'string'
|
|
365
|
+
? entry.slice
|
|
366
|
+
: undefined;
|
|
367
|
+
if (title) record.title = title;
|
|
368
|
+
out[sliceId] = record;
|
|
369
|
+
});
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Merge a freshly-seeded slice status map onto a persisted one. Every slice
|
|
375
|
+
* present in either map appears in the result; when a slice exists in the
|
|
376
|
+
* prior map its recorded `status` wins (recorded progress — a `done` slice
|
|
377
|
+
* whose work already sits on `epic/<id>` — is never lost), while its `title`
|
|
378
|
+
* is refreshed from the incoming seed. Slices present only in the incoming
|
|
379
|
+
* seed are added at `pending`. Pure — exported for unit tests.
|
|
380
|
+
*
|
|
381
|
+
* @param {Record<string, object>|undefined} prior
|
|
382
|
+
* @param {Record<string, object>} incoming
|
|
383
|
+
* @returns {Record<string, object>}
|
|
384
|
+
*/
|
|
385
|
+
export function mergeSliceStatuses(prior, incoming) {
|
|
386
|
+
const priorMap = prior && typeof prior === 'object' ? prior : {};
|
|
387
|
+
const seedMap = incoming && typeof incoming === 'object' ? incoming : {};
|
|
388
|
+
const out = {};
|
|
389
|
+
for (const key of new Set([
|
|
390
|
+
...Object.keys(priorMap),
|
|
391
|
+
...Object.keys(seedMap),
|
|
392
|
+
])) {
|
|
393
|
+
const priorEntry = priorMap[key];
|
|
394
|
+
const seedEntry = seedMap[key];
|
|
395
|
+
if (priorEntry && typeof priorEntry === 'object') {
|
|
396
|
+
const merged = { ...priorEntry };
|
|
397
|
+
if (seedEntry && typeof seedEntry.title === 'string') {
|
|
398
|
+
merged.title = seedEntry.title;
|
|
399
|
+
}
|
|
400
|
+
out[key] = merged;
|
|
401
|
+
} else {
|
|
402
|
+
out[key] = seedEntry;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Initialize (or resume) the single-delivery checkpoint for an Epic. The
|
|
410
|
+
* single-delivery analogue of `initialize` (Story #4155's per-Story map),
|
|
411
|
+
* writing the slice-map shape the M4-B executor walks:
|
|
412
|
+
*
|
|
413
|
+
* ```json
|
|
414
|
+
* {
|
|
415
|
+
* "epicId": 4475,
|
|
416
|
+
* "deliveryShape": "single",
|
|
417
|
+
* "slices": { "slice-1": { "status": "pending", "title": "…" } },
|
|
418
|
+
* "concurrencyCap": 1,
|
|
419
|
+
* "storyCount": 0
|
|
420
|
+
* }
|
|
421
|
+
* ```
|
|
422
|
+
*
|
|
423
|
+
* Idempotent + resume-preserving: on a re-run an existing single checkpoint
|
|
424
|
+
* has its slice map **merged** (every already-`done` slice keeps its status
|
|
425
|
+
* so the executor skips it — the work already sits on `epic/<id>`), while
|
|
426
|
+
* newly-authored slices seed at `pending`. `startedAt` is preserved. A
|
|
427
|
+
* checkpoint written for the fan-out shape is NOT reinterpreted here — the
|
|
428
|
+
* caller (`epic-deliver-prepare.js --single`) owns route selection.
|
|
429
|
+
*
|
|
430
|
+
* @param {{
|
|
431
|
+
* provider: import('../ITicketingProvider.js').ITicketingProvider,
|
|
432
|
+
* epicId: number,
|
|
433
|
+
* slices: Array<{ slice?: string, independent?: boolean } | string>,
|
|
434
|
+
* concurrencyCap?: number,
|
|
435
|
+
* }} opts
|
|
436
|
+
* @returns {Promise<object>} the persisted (or preserved) state
|
|
437
|
+
*/
|
|
438
|
+
export async function initializeSingle({
|
|
439
|
+
provider,
|
|
440
|
+
epicId,
|
|
441
|
+
slices,
|
|
442
|
+
concurrencyCap = 1,
|
|
443
|
+
} = {}) {
|
|
444
|
+
assertProvider(provider);
|
|
445
|
+
assertEpicId(epicId);
|
|
446
|
+
const seededSlices = buildSliceStatusMap(slices);
|
|
447
|
+
const storyCount = 0;
|
|
448
|
+
const existing = await read({ provider, epicId });
|
|
449
|
+
if (existing && existing.deliveryShape === 'single') {
|
|
450
|
+
const mergedSlices = mergeSliceStatuses(existing.slices, seededSlices);
|
|
451
|
+
if (
|
|
452
|
+
existing.concurrencyCap === concurrencyCap &&
|
|
453
|
+
sliceMapsEqual(existing.slices, mergedSlices)
|
|
454
|
+
) {
|
|
455
|
+
return existing;
|
|
456
|
+
}
|
|
457
|
+
return write({
|
|
458
|
+
provider,
|
|
459
|
+
epicId,
|
|
460
|
+
state: {
|
|
461
|
+
...existing,
|
|
462
|
+
deliveryShape: 'single',
|
|
463
|
+
concurrencyCap,
|
|
464
|
+
storyCount,
|
|
465
|
+
slices: mergedSlices,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return write({
|
|
470
|
+
provider,
|
|
471
|
+
epicId,
|
|
472
|
+
state: {
|
|
473
|
+
epicId,
|
|
474
|
+
startedAt: new Date().toISOString(),
|
|
475
|
+
deliveryShape: 'single',
|
|
476
|
+
concurrencyCap,
|
|
477
|
+
storyCount,
|
|
478
|
+
phase: 'prepare',
|
|
479
|
+
slices: seededSlices,
|
|
480
|
+
manualInterventions: [],
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Record a per-slice terminal (or in-progress) status on a single-delivery
|
|
487
|
+
* checkpoint. The single-delivery analogue of `recordStoryStatus`: the M4-B
|
|
488
|
+
* executor calls this after each Delivery-Slicing slice commits to
|
|
489
|
+
* `epic/<id>`, flipping `slices[sliceId].status` `pending → done` so a resumed
|
|
490
|
+
* run skips the already-landed slice. Reads the current state first, splices
|
|
491
|
+
* the single slice's record into the `slices` map, and re-writes; other slices
|
|
492
|
+
* and all run-level fields are preserved verbatim. Tolerant of a
|
|
493
|
+
* legacy/absent `slices` map (treated as empty).
|
|
494
|
+
*
|
|
495
|
+
* @param {{
|
|
496
|
+
* provider: import('../ITicketingProvider.js').ITicketingProvider,
|
|
497
|
+
* epicId: number,
|
|
498
|
+
* sliceId: string,
|
|
499
|
+
* status: string,
|
|
500
|
+
* title?: string,
|
|
501
|
+
* }} opts
|
|
502
|
+
* @returns {Promise<object>} the persisted state
|
|
503
|
+
*/
|
|
504
|
+
export async function recordSliceStatus({
|
|
505
|
+
provider,
|
|
506
|
+
epicId,
|
|
507
|
+
sliceId,
|
|
508
|
+
status,
|
|
509
|
+
title,
|
|
510
|
+
} = {}) {
|
|
511
|
+
assertProvider(provider);
|
|
512
|
+
assertEpicId(epicId);
|
|
513
|
+
if (typeof sliceId !== 'string' || sliceId.length === 0) {
|
|
514
|
+
throw new TypeError(
|
|
515
|
+
'recordSliceStatus: sliceId must be a non-empty string',
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (!SLICE_STATUSES.includes(status)) {
|
|
519
|
+
throw new RangeError(
|
|
520
|
+
`recordSliceStatus: status "${status}" must be one of: ${SLICE_STATUSES.join(', ')}`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const existing = (await read({ provider, epicId })) ?? {};
|
|
524
|
+
const slices =
|
|
525
|
+
existing.slices && typeof existing.slices === 'object'
|
|
526
|
+
? { ...existing.slices }
|
|
527
|
+
: {};
|
|
528
|
+
const prior = slices[sliceId] ?? {};
|
|
529
|
+
const record = { ...prior, status };
|
|
530
|
+
if (typeof title === 'string' && title) record.title = title;
|
|
531
|
+
slices[sliceId] = record;
|
|
532
|
+
return write({
|
|
533
|
+
provider,
|
|
534
|
+
epicId,
|
|
535
|
+
state: { ...existing, slices },
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Structural equality on two slice status maps — same key set and, per key,
|
|
541
|
+
* the same `status` and `title`. Used by `initializeSingle` to decide whether
|
|
542
|
+
* an idempotent re-prepare needs a rewrite. Pure.
|
|
543
|
+
*
|
|
544
|
+
* @param {Record<string, object>|undefined} a
|
|
545
|
+
* @param {Record<string, object>|undefined} b
|
|
546
|
+
* @returns {boolean}
|
|
547
|
+
*/
|
|
548
|
+
function sliceMapsEqual(a, b) {
|
|
549
|
+
const left = a && typeof a === 'object' ? a : {};
|
|
550
|
+
const right = b && typeof b === 'object' ? b : {};
|
|
551
|
+
const keys = Object.keys(left);
|
|
552
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
553
|
+
for (const key of keys) {
|
|
554
|
+
const l = left[key] ?? {};
|
|
555
|
+
const r = right[key];
|
|
556
|
+
if (!r) return false;
|
|
557
|
+
if (l.status !== r.status || l.title !== r.title) return false;
|
|
558
|
+
}
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
329
562
|
/**
|
|
330
563
|
* Append a manual-intervention record to the checkpoint. Out-of-band
|
|
331
564
|
* recovery steps the host LLM performs during a delivery — `AskUserQuestion`
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Rules (one error per mismatched path):
|
|
17
17
|
* - `creates` + path **exists** → error (Story would clobber).
|
|
18
|
-
* - `refactors-existing` + path **absent** →
|
|
18
|
+
* - `refactors-existing` (via `changes`) + path **absent** →
|
|
19
|
+
* auto-normalized to `creates` with a logged warning (#4496 fix 5):
|
|
20
|
+
* a refactor declaration against a base-untracked path is
|
|
21
|
+
* deterministically a create, so rejecting it only forces a
|
|
22
|
+
* reject→amend→re-persist cycle for a mechanical rewrite. Genuine
|
|
23
|
+
* mismatches keep failing — a `references`-sourced `refactors-existing`
|
|
24
|
+
* on an absent path is a missing read dependency and stays an error.
|
|
19
25
|
* - `exists` + path **absent** → error (read dependency missing).
|
|
20
26
|
* - `deletes` + path **absent** → error (nothing to delete).
|
|
21
27
|
*
|
|
@@ -185,6 +191,18 @@ function renderMismatch({
|
|
|
185
191
|
return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path already exists at the base branch.`;
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Render an auto-normalization (#4496 fix 5) into a stable warning string.
|
|
196
|
+
* Kept pure and exported through the report so callers log a
|
|
197
|
+
* self-explanatory line rather than re-deriving the rationale.
|
|
198
|
+
*
|
|
199
|
+
* @param {{ slug: string, source: string, path: string, assumption: string }} normalization
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
function renderNormalization({ slug, source, path, assumption }) {
|
|
203
|
+
return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path is untracked at the base branch — auto-normalized to "creates" (a refactor of a base-untracked path is deterministically a create). Declare assumption="creates" in the plan to silence this warning.`;
|
|
204
|
+
}
|
|
205
|
+
|
|
188
206
|
/**
|
|
189
207
|
* Index, across every Story, which Stories declare a `creates` (and which
|
|
190
208
|
* declare a `deletes`) for each `changes`-sourced path. The maps drive the
|
|
@@ -251,8 +269,11 @@ function predecessorMutator(index, path, predecessors) {
|
|
|
251
269
|
*
|
|
252
270
|
* {
|
|
253
271
|
* errors: string[] // one entry per mismatch, batched per Story
|
|
254
|
-
* warnings: string[] // legacy/no-assumption deprecation nudges
|
|
272
|
+
* warnings: string[] // legacy/no-assumption deprecation nudges +
|
|
273
|
+
* // auto-normalization notices (#4496 fix 5)
|
|
255
274
|
* mismatches: object[] // structured payload for downstream tooling
|
|
275
|
+
* normalizations: object[] // `refactors-existing`→`creates`
|
|
276
|
+
* // auto-normalizations on base-untracked paths
|
|
256
277
|
* }
|
|
257
278
|
*
|
|
258
279
|
* Under the 2-tier hierarchy the Story is the implementation unit, so the
|
|
@@ -283,6 +304,7 @@ export function validateStoryFileAssumptions(opts) {
|
|
|
283
304
|
const errors = [];
|
|
284
305
|
const warnings = [];
|
|
285
306
|
const mismatches = [];
|
|
307
|
+
const normalizations = [];
|
|
286
308
|
const probeCache = new Map();
|
|
287
309
|
|
|
288
310
|
// Wave-aware setup (Story #3960): transitive predecessor sets over the
|
|
@@ -350,6 +372,14 @@ export function validateStoryFileAssumptions(opts) {
|
|
|
350
372
|
predecessorCreator,
|
|
351
373
|
});
|
|
352
374
|
if (mismatch !== null) {
|
|
375
|
+
// Auto-normalization (#4496 fix 5): a deterministic
|
|
376
|
+
// `refactors-existing`→`creates` rewrite is a warning, never a
|
|
377
|
+
// rejection — genuine mismatches keep flowing to `errors`.
|
|
378
|
+
if (mismatch.normalizedTo === 'creates') {
|
|
379
|
+
normalizations.push(mismatch);
|
|
380
|
+
warnings.push(renderNormalization(mismatch));
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
353
383
|
mismatches.push(mismatch);
|
|
354
384
|
errors.push(renderMismatch(mismatch));
|
|
355
385
|
continue;
|
|
@@ -384,7 +414,7 @@ export function validateStoryFileAssumptions(opts) {
|
|
|
384
414
|
}
|
|
385
415
|
}
|
|
386
416
|
}
|
|
387
|
-
return { errors, warnings, mismatches };
|
|
417
|
+
return { errors, warnings, mismatches, normalizations };
|
|
388
418
|
}
|
|
389
419
|
|
|
390
420
|
/**
|
|
@@ -468,12 +498,43 @@ function checkAssumption({
|
|
|
468
498
|
}
|
|
469
499
|
return null;
|
|
470
500
|
case 'refactors-existing':
|
|
501
|
+
// Validate against the simulated tree: a predecessor `creates` makes
|
|
502
|
+
// an otherwise-absent base path present, so `refactors-existing`
|
|
503
|
+
// against it is no longer a false-positive mismatch (Story #3960).
|
|
504
|
+
if (!simulatedExists) {
|
|
505
|
+
// Auto-normalization (#4496 fix 5): a `changes`-sourced refactor
|
|
506
|
+
// declaration on a path untracked at the base branch (and not
|
|
507
|
+
// produced by any predecessor) is deterministically a create —
|
|
508
|
+
// downgrade to a normalization warning instead of rejecting. Two
|
|
509
|
+
// genuine mismatches stay hard errors: a `references`-sourced entry
|
|
510
|
+
// (a read dependency this Story does not author cannot be "created"
|
|
511
|
+
// here), and a base-TRACKED path a predecessor deletes (the absence
|
|
512
|
+
// is a plan-shape conflict, not an untracked-path misdeclaration).
|
|
513
|
+
if (source === 'changes' && !baseExists) {
|
|
514
|
+
return {
|
|
515
|
+
slug,
|
|
516
|
+
source,
|
|
517
|
+
path,
|
|
518
|
+
assumption,
|
|
519
|
+
expected: 'creates',
|
|
520
|
+
actual: 'absent',
|
|
521
|
+
normalizedTo: 'creates',
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
slug,
|
|
526
|
+
source,
|
|
527
|
+
path,
|
|
528
|
+
assumption,
|
|
529
|
+
expected: 'present',
|
|
530
|
+
actual: 'absent',
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
return null;
|
|
471
534
|
case 'exists':
|
|
472
535
|
case 'deletes':
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// `exists` / `deletes` against it is no longer a false-positive
|
|
476
|
-
// mismatch (Story #3960).
|
|
536
|
+
// Same simulated-tree overlay as above (Story #3960); an absent path
|
|
537
|
+
// remains a genuine mismatch for both assumptions.
|
|
477
538
|
if (!simulatedExists) {
|
|
478
539
|
return {
|
|
479
540
|
slug,
|