mandrel 1.76.0 → 1.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/schemas/agentrc.schema.json +1 -1
  3. package/.agents/schemas/dispatch-manifest.json +1 -1
  4. package/.agents/schemas/validation-evidence.schema.json +2 -1
  5. package/.agents/scripts/audit-to-stories.js +43 -1
  6. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  7. package/.agents/scripts/evidence-gate.js +48 -12
  8. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  9. package/.agents/scripts/lib/cli-args.js +6 -0
  10. package/.agents/scripts/lib/close-validation/runner.js +25 -8
  11. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  12. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  13. package/.agents/scripts/lib/config-resolver.js +4 -1
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  15. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  16. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  17. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  18. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  19. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  20. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  23. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  24. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  25. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  27. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  28. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  29. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  30. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  31. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  32. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  33. package/.agents/scripts/lib/validation-evidence.js +63 -25
  34. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  35. package/.agents/scripts/resync-status-column.js +5 -0
  36. package/.agents/scripts/run-coverage.js +85 -45
  37. package/.agents/scripts/single-story-init.js +22 -29
  38. package/.agents/scripts/story-init.js +38 -63
  39. package/.agents/scripts/story-phase.js +46 -4
  40. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  41. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  42. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  43. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  44. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  45. package/docs/CHANGELOG.md +33 -0
  46. package/package.json +1 -1
@@ -138,7 +138,7 @@ top-level keys are validation errors.
138
138
  | `worktreeIsolation` | No | `object` | — | Nested configuration block. |
139
139
  | `worktreeIsolation.enabled` | No | `boolean` | — | — |
140
140
  | `worktreeIsolation.root` | No | `string` | — | — |
141
- | `worktreeIsolation.nodeModulesStrategy` | No | `"per-worktree"` \| `"symlink"` \| `"pnpm-store"` | — | — |
141
+ | `worktreeIsolation.nodeModulesStrategy` | No | `"per-worktree"` \| `"clone"` \| `"symlink"` \| `"pnpm-store"` | — | — |
142
142
  | `worktreeIsolation.primeFromPath` | No | `string` \| `null` | — | — |
143
143
  | `worktreeIsolation.allowSymlinkOnWindows` | No | `boolean` | — | — |
144
144
  | `worktreeIsolation.reapOnSuccess` | No | `boolean` | — | — |
@@ -526,7 +526,7 @@ checkout's HEAD.
526
526
  | ----------------------- | --------------- | ---------------- | ----------------------------------------------------------- |
527
527
  | `enabled` | No | `false` | Master switch. |
528
528
  | `root` | Conditional | `.worktrees` | Required when `enabled: true`. Worktree parent directory. |
529
- | `nodeModulesStrategy` | No | `per-worktree` | One of `per-worktree`, `symlink`, `pnpm-store`. |
529
+ | `nodeModulesStrategy` | No | `clone` (darwin/linux); `per-worktree` (Windows) | One of `per-worktree`, `clone`, `symlink`, `pnpm-store`. `clone` copy-on-write (reflink/clonefile) clones the donor's `node_modules` and skips the per-tree install on a byte-exact lockfile match, falling back to `per-worktree` on any failure. |
530
530
  | `primeFromPath` | No | `null` | Optional source path used to prime `node_modules`. |
531
531
  | `allowSymlinkOnWindows` | No | `false` | Permit symlink strategy on Windows (requires admin/dev mode). |
532
532
  | `reapOnSuccess` | No | `true` | Reap the worktree after a successful Story close. |
@@ -635,7 +635,7 @@
635
635
  },
636
636
  "nodeModulesStrategy": {
637
637
  "type": "string",
638
- "enum": ["per-worktree", "symlink", "pnpm-store"]
638
+ "enum": ["per-worktree", "clone", "symlink", "pnpm-store"]
639
639
  },
640
640
  "primeFromPath": {
641
641
  "type": ["string", "null"],
@@ -125,7 +125,7 @@
125
125
  },
126
126
  "verify": {
127
127
  "type": "array",
128
- "description": "Inline verify commands or shell snippets executed at close to prove the Story.",
128
+ "description": "Inline verify commands or shell snippets run by the pre-close acceptance self-eval critic as required, binding evidence that the Story's acceptance criteria are met — NOT by the close-validation gate chain (which runs only the canonical gate list).",
129
129
  "items": { "type": "string" }
130
130
  },
131
131
  "dependsOn": {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "validation-evidence",
4
4
  "title": "Validation Evidence",
5
- "description": "Per-scope record of which validation gates have passed against which commit SHA. Written by lib/validation-evidence.js under the per-Epic temp tree at temp/epic-<epicId>/validation-evidence.json (Epic-scoped) or temp/epic-<epicId>/stories/story-<storyId>/validation-evidence.json (Story-scoped); both are gitignored via temp/. Consumed by close-validation, epic-code-review, and /deliver Phase 3 (close-validation) to skip identical re-runs against an already-validated tree. The `storyId` field carries the scope id and equals the epic id for Epic-scoped records.",
5
+ "description": "Per-scope record of which validation gates have passed against which commit SHA. Written by lib/validation-evidence.js under the per-Epic temp tree at temp/epic-<epicId>/validation-evidence.json (Epic-scoped) or temp/epic-<epicId>/stories/story-<storyId>/validation-evidence.json (Story-scoped). Standalone Stories (no parent Epic) use a storyId-anchored keyspace at temp/standalone/stories/story-<storyId>/validation-evidence.json. All paths are gitignored via temp/. Consumed by close-validation, epic-code-review, the acceptance self-eval critic, and /deliver Phase 3 (close-validation) to skip identical re-runs against an already-validated tree. The `storyId` field carries the scope id and equals the epic id for Epic-scoped records.",
6
6
  "type": "object",
7
7
  "required": ["storyId", "schemaVersion", "records"],
8
8
  "properties": {
@@ -32,6 +32,7 @@
32
32
  "gateName": {
33
33
  "type": "string",
34
34
  "enum": [
35
+ "typecheck",
35
36
  "lint",
36
37
  "test",
37
38
  "format",
@@ -40,6 +40,7 @@ import { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
40
40
  import { buildEpicSeedMarkdown } from './lib/audit-to-stories/seed-epic-from-findings.js';
41
41
  import { runAsCli } from './lib/cli-utils.js';
42
42
  import { Logger } from './lib/Logger.js';
43
+ import { parse as parseStoryBody } from './lib/story-body/story-body.js';
43
44
 
44
45
  const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
45
46
  const DEFAULT_GLOB = 'temp/audits/audit-*-results.md';
@@ -227,6 +228,46 @@ function loadPlan(planPath) {
227
228
  return JSON.parse(fs.readFileSync(planPath, 'utf8'));
228
229
  }
229
230
 
231
+ /**
232
+ * Build every eligible group into a `{ title, body, labels }` Story object and
233
+ * gate the batch against the inline-contract bar BEFORE any issue is opened.
234
+ *
235
+ * The `--emit-stories` path opens GitHub issues directly (no decomposer
236
+ * round-trip), so `assertEveryStoryHasInlineContract` never runs against these
237
+ * bodies. This gate restores that guarantee at the standalone seam: each
238
+ * emitted body is re-parsed through the canonical `story-body` parser and must
239
+ * carry a non-empty `acceptance[]` AND a non-empty `verify[]`. A body that
240
+ * fails throws, surfacing the gap instead of opening an ungated Story
241
+ * (Story #4270).
242
+ *
243
+ * @param {Array<{ group: object }>} eligible — classifications eligible to create.
244
+ * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} edges — sequencing edges.
245
+ * @returns {Array<{ title: string, body: string, labels: string[] }>}
246
+ */
247
+ function buildAndGateStories(eligible, edges) {
248
+ const built = eligible.map((g) => buildStoryBody({ group: g, edges }));
249
+ const offenders = [];
250
+ for (const story of built) {
251
+ const { body } = parseStoryBody(story.body);
252
+ const ok =
253
+ Array.isArray(body.acceptance) &&
254
+ body.acceptance.length > 0 &&
255
+ Array.isArray(body.verify) &&
256
+ body.verify.length > 0;
257
+ if (!ok) offenders.push(story.title);
258
+ }
259
+ if (offenders.length > 0) {
260
+ throw new Error(
261
+ `inline-contract gate failed: ${offenders.length} generated audit Story/Stories lack a non-empty acceptance[] + verify[] contract: ${offenders
262
+ .map((t) => `"${t}"`)
263
+ .join(
264
+ ', ',
265
+ )}. No issues were opened. Every emitted Story must carry both arrays.`,
266
+ );
267
+ }
268
+ return built;
269
+ }
270
+
230
271
  function persist(text, outPath) {
231
272
  if (!outPath) {
232
273
  process.stdout.write(text);
@@ -242,6 +283,7 @@ export const __testing = {
242
283
  buildPlan,
243
284
  loadProvider,
244
285
  dedupSkippedWarning,
286
+ buildAndGateStories,
245
287
  };
246
288
 
247
289
  async function main() {
@@ -289,7 +331,7 @@ async function main() {
289
331
  const eligible = (plan.classifications ?? [])
290
332
  .filter((c) => c.action === 'create')
291
333
  .map((c) => c.group);
292
- const built = eligible.map((g) => buildStoryBody({ group: g }));
334
+ const built = buildAndGateStories(eligible, plan.edges ?? []);
293
335
  const out = values.json
294
336
  ? JSON.stringify(built, null, 2)
295
337
  : built
@@ -38,6 +38,7 @@ import { runAsCli } from './lib/cli-utils.js';
38
38
  import { getRunners, resolveConfig } from './lib/config-resolver.js';
39
39
  import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
40
40
  import { getEpicBranch, gitSpawn } from './lib/git-utils.js';
41
+ import { parseLinkedIssues } from './lib/issue-link-parser.js';
41
42
  import { Logger } from './lib/Logger.js';
42
43
  import {
43
44
  resolveOperator,
@@ -143,6 +144,8 @@ function resolveGitUserEmail(cwd) {
143
144
  * storyCount: number,
144
145
  * concurrencyCap: number,
145
146
  * stories: Array<{ storyId: number, title: string, worktree?: string }>,
147
+ * prdId: number|null,
148
+ * techSpecId: number|null,
146
149
  * checkpointInitializedAt: string,
147
150
  * }>}
148
151
  */
@@ -374,11 +377,21 @@ export async function runEpicDeliverPrepare({
374
377
  });
375
378
  }
376
379
 
380
+ // Story #4253: resolve the Epic's PRD / Tech-Spec linkages ONCE here and
381
+ // surface them in the prepare envelope. The /deliver fan-out threads these
382
+ // into each per-Story `story-init.js --prd/--tech-spec`, collapsing the
383
+ // N per-Story `getEpic` round-trips to this single parent-side resolution.
384
+ // The Epic snapshot is already in hand (`state.epic`), so this adds no
385
+ // extra fetch; the body-parse fallback mirrors hierarchy-tracer's source.
386
+ const { prdId, techSpecId } = resolveEpicLinkages(state.epic);
387
+
377
388
  return {
378
389
  epicId,
379
390
  storyCount: openStories.length,
380
391
  concurrencyCap,
381
392
  stories,
393
+ prdId,
394
+ techSpecId,
382
395
  checkpointInitializedAt:
383
396
  checkpointState.startedAt ??
384
397
  checkpointState.lastUpdatedAt ??
@@ -388,6 +401,24 @@ export async function runEpicDeliverPrepare({
388
401
  };
389
402
  }
390
403
 
404
+ /**
405
+ * Resolve an Epic's linked PRD / Tech-Spec issue ids from the snapshot ticket.
406
+ * Prefers the provider-supplied `linkedIssues` map and falls back to parsing
407
+ * the Epic body's `## Planning Artifacts` section — the same two sources
408
+ * `hierarchy-tracer.js` reads — so the threaded ids match what an unthreaded
409
+ * `story-init.js` run would have resolved itself. Story #4253.
410
+ *
411
+ * @param {{ linkedIssues?: { prd?: number|null, techSpec?: number|null }|null, body?: string }|null|undefined} epic
412
+ * @returns {{ prdId: number|null, techSpecId: number|null }}
413
+ */
414
+ function resolveEpicLinkages(epic) {
415
+ const linked = epic?.linkedIssues ?? parseLinkedIssues(epic?.body ?? '');
416
+ return {
417
+ prdId: linked?.prd ?? null,
418
+ techSpecId: linked?.techSpec ?? null,
419
+ };
420
+ }
421
+
391
422
  async function main() {
392
423
  const { values } = parseArgs({
393
424
  options: {
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Usage:
15
15
  * node .agents/scripts/evidence-gate.js \
16
- * --epic-id <epicId> --scope-id <storyOrEpicId> --gate <name> \
16
+ * (--epic-id <epicId> | --standalone) --scope-id <storyOrEpicId> --gate <name> \
17
17
  * [--worktree <path>] [--no-evidence] -- <cmd> [args...]
18
18
  *
19
19
  * Examples:
@@ -21,11 +21,18 @@
21
21
  * node .agents/scripts/evidence-gate.js --epic-id 1030 --scope-id 1030 --gate test -- npm test
22
22
  * node .agents/scripts/evidence-gate.js --epic-id 1114 --scope-id 1120 --gate test \
23
23
  * --worktree .worktrees/story-1120 -- npm test
24
+ * node .agents/scripts/evidence-gate.js --standalone --scope-id 4250 --gate lint \
25
+ * --worktree .worktrees/story-4250 -- npm run lint
24
26
  *
25
- * `--epic-id` is required. When `scope-id === epic-id` the evidence file is
26
- * Epic-scoped (`<tempRoot>/epic-<eid>/validation-evidence.json`); when
27
- * `scope-id !== epic-id` it is Story-scoped under
28
- * `<tempRoot>/epic-<eid>/story-<sid>/validation-evidence.json`.
27
+ * Either `--epic-id` or `--standalone` is required. When `scope-id ===
28
+ * epic-id` the evidence file is Epic-scoped
29
+ * (`<tempRoot>/epic-<eid>/validation-evidence.json`); when `scope-id !==
30
+ * epic-id` it is Story-scoped under
31
+ * `<tempRoot>/epic-<eid>/story-<sid>/validation-evidence.json`. With
32
+ * `--standalone` (Story #4250) the file is anchored on the Story id alone at
33
+ * `<tempRoot>/standalone/stories/story-<sid>/validation-evidence.json` — the
34
+ * same keyspace the standalone close consults, so the acceptance-self-eval
35
+ * critic's verify[] runs (lint / typecheck) are shared with the close.
29
36
  *
30
37
  * **Worktree-aware spawn (Story #1120).** `--cwd` (default `PROJECT_ROOT`)
31
38
  * is the *evidence cwd* — it locates the per-Epic temp tree under the main
@@ -84,6 +91,7 @@ export function parseWrapperArgs(argv) {
84
91
  'epic-id': { type: 'string' },
85
92
  gate: { type: 'string' },
86
93
  'no-evidence': { type: 'boolean', default: false },
94
+ standalone: { type: 'boolean', default: false },
87
95
  cwd: { type: 'string' },
88
96
  worktree: { type: 'string' },
89
97
  },
@@ -94,6 +102,7 @@ export function parseWrapperArgs(argv) {
94
102
  return {
95
103
  scopeId: Number.isNaN(scopeId) || scopeId <= 0 ? null : scopeId,
96
104
  epicId: Number.isNaN(epicId) || epicId <= 0 ? null : epicId,
105
+ standalone: values.standalone === true,
97
106
  gate: values.gate ?? null,
98
107
  useEvidence: values['no-evidence'] !== true,
99
108
  cwd: values.cwd ?? PROJECT_ROOT,
@@ -118,7 +127,11 @@ function resolveHeadShaDefault(cwd, gitSpawnFn) {
118
127
  *
119
128
  * @param {object} params
120
129
  * @param {number} params.scopeId — Story / Epic ID (positive integer).
121
- * @param {string} params.gate Logical gate name (`lint`, `test`, …).
130
+ * @param {number|null} [params.epicId] Parent Epic id (Epic-keyed path).
131
+ * @param {boolean} [params.standalone] — When true, route to the
132
+ * storyId-anchored standalone keyspace instead of the Epic-keyed path
133
+ * (Story #4250). Substitutes for `epicId`.
134
+ * @param {string} params.gate — Logical gate name (`lint`, `typecheck`, …).
122
135
  * @param {boolean} params.useEvidence — When false, force the runner.
123
136
  * @param {string} params.cwd — Evidence cwd (locates the per-Epic
124
137
  * temp tree). The runner is spawned in `worktreePath` when set, else `cwd`.
@@ -144,15 +157,38 @@ export async function runEvidenceGate(params, deps = {}) {
144
157
  recordPassFn = recordPass,
145
158
  logger = Logger,
146
159
  } = deps;
147
- const { scopeId, epicId, gate, useEvidence, cwd, worktreePath, runnerArgs } =
148
- params ?? {};
160
+ const {
161
+ scopeId,
162
+ epicId,
163
+ standalone = false,
164
+ gate,
165
+ useEvidence,
166
+ cwd,
167
+ worktreePath,
168
+ runnerArgs,
169
+ } = params ?? {};
149
170
 
150
- if (!scopeId || !epicId || !gate || !runnerArgs || runnerArgs.length === 0) {
171
+ // `--epic-id` is required for the Epic-keyed path; `--standalone` (Story
172
+ // #4250) substitutes for it and routes the evidence file to the
173
+ // storyId-anchored standalone keyspace so the acceptance-self-eval critic
174
+ // can record verify[] evidence into the same keyspace the standalone close
175
+ // consults.
176
+ if (
177
+ !scopeId ||
178
+ (!epicId && !standalone) ||
179
+ !gate ||
180
+ !runnerArgs ||
181
+ runnerArgs.length === 0
182
+ ) {
151
183
  logger.fatal(
152
- 'Usage: node evidence-gate.js --epic-id <epicId> --scope-id <id> --gate <name> [--worktree <path>] [--no-evidence] -- <cmd> [args...]',
184
+ 'Usage: node evidence-gate.js (--epic-id <epicId> | --standalone) --scope-id <id> --gate <name> [--worktree <path>] [--no-evidence] -- <cmd> [args...]',
153
185
  );
154
186
  return { status: 1, skipped: false };
155
187
  }
188
+ // Evidence-store opts shared by shouldSkip + recordPass below. `standalone`
189
+ // routes to the storyId-anchored keyspace; otherwise the Epic-keyed path
190
+ // resolves under `epicId`.
191
+ const evidenceStoreOpts = { cwd, epicId, standalone };
156
192
 
157
193
  // Spawn cwd is the worktree when supplied — every gate command sees the
158
194
  // Story branch's tree, not the main checkout. Evidence cwd stays anchored
@@ -174,7 +210,7 @@ export async function runEvidenceGate(params, deps = {}) {
174
210
  currentSha: headSha,
175
211
  configHash,
176
212
  },
177
- { cwd, epicId },
213
+ evidenceStoreOpts,
178
214
  );
179
215
  if (verdict.skip) {
180
216
  const ts = verdict.record?.timestamp ?? 'n/a';
@@ -215,7 +251,7 @@ export async function runEvidenceGate(params, deps = {}) {
215
251
  exitCode: 0,
216
252
  durationMs: Date.now() - startedAt,
217
253
  },
218
- { cwd, epicId },
254
+ evidenceStoreOpts,
219
255
  );
220
256
  } catch (err) {
221
257
  logger.warn?.(
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * lib/audit-to-stories/build-story-body.js
3
3
  *
4
- * Render the canonical Story body for the standalone grouping mode. The
5
- * body follows the contract spelled out in Story #2583 acceptance
6
- * criteria #8: Title (caller), Summary, Acceptance Criteria, Agent
7
- * Prompts, Context block, fingerprint footer.
4
+ * Render the canonical Story body for the standalone grouping mode so a
5
+ * generated audit Story clears the same inline-contract bar the decomposer
6
+ * enforces (`assertEveryStoryHasInlineContract`): a clean goal, observable
7
+ * `acceptance[]`, a populated `changes[]` footprint, and a non-empty,
8
+ * tier-tagged `verify[]` (Story #4270).
8
9
  *
9
10
  * Pure: returns { title, body, labels }. Labels carry one canonical
10
11
  * `audit::<lens>` per distinct source report represented in the merge
@@ -14,10 +15,11 @@
14
15
  * `risk::high`.
15
16
  *
16
17
  * The body is serialized via the canonical story-body serializer
17
- * (`.agents/scripts/lib/story-body/story-body.js`) so the output is
18
- * parseable by `parse()` and round-trippable. Audit-specific content
19
- * (agent prompts, context links, fingerprint footer) is appended after
20
- * the canonical sections as extended markdown.
18
+ * (`.agents/scripts/lib/story-body/story-body.js`) so the output round-trips
19
+ * through `parse()` / `serialize()`. Audit-specific content (agent prompts,
20
+ * context links, fingerprint footer) is appended after the canonical sections
21
+ * as extended markdown it is informational only and is not part of the
22
+ * structured contract.
21
23
  */
22
24
 
23
25
  import { serialize } from '../story-body/story-body.js';
@@ -26,36 +28,128 @@ import { renderFingerprintFooter } from './finding-adapter.js';
26
28
 
27
29
  const STATIC_LABELS = Object.freeze(['type::story', 'agent::ready']);
28
30
 
31
+ // The verify[] contract every generated audit Story carries. These commands
32
+ // exist in this repo's harness (package.json scripts) so the Story satisfies
33
+ // the inline-contract bar with runnable, tier-tagged gates rather than
34
+ // placeholder prose. Kept as a frozen constant so the same contract is
35
+ // asserted by the unit suite.
36
+ const DEFAULT_VERIFY = Object.freeze([
37
+ 'npm run lint (validate)',
38
+ 'npm test (unit)',
39
+ ]);
40
+
29
41
  function uniq(items) {
30
42
  return [...new Set(items)];
31
43
  }
32
44
 
33
- function summaryFromGroup(group) {
34
- const lines = group.findings.map((f, idx) => {
35
- const sev = f.severity ? `[${f.severity.toUpperCase()}]` : '[—]';
36
- const dim = f.dimension ? `(${f.dimension})` : '';
37
- return `${idx + 1}. ${sev} ${dim} **${f.title}** ${
38
- f.currentState || '_(no current-state captured)_'
39
- }`;
40
- });
41
- return lines.join('\n');
45
+ /**
46
+ * The goal is the group intent only — the synthesized `group.title`. It
47
+ * carries no leading ordinal (`1.`/`2.`) and no `[SEVERITY]` / `(dimension)`
48
+ * prefix (the polluted shape Story #4270 replaced); those signals live in the
49
+ * per-finding fingerprint footer and the extended Agent Prompts section, not
50
+ * in the goal.
51
+ *
52
+ * @param {object} group
53
+ * @returns {string}
54
+ */
55
+ function goalFromGroup(group) {
56
+ return (group.title ?? '').trim();
42
57
  }
43
58
 
44
- function goalFromGroup(group) {
45
- // Derive a concise goal statement from the group title + finding summary.
46
- const summary = summaryFromGroup(group);
47
- return `${group.title}\n\n${summary}`;
59
+ /**
60
+ * Map every distinct file mentioned across the merge onto a canonical
61
+ * `changes[]` PathEntry (`{ path, assumption }`). Audit findings remediate
62
+ * code that already exists, so the assumption is `refactors-existing`.
63
+ *
64
+ * `group.files` is an array post-`groupFindings`; fall back to scanning the
65
+ * findings' own `files[]` when an upstream caller hands a group whose `files`
66
+ * aggregate was not materialized.
67
+ *
68
+ * @param {object} group
69
+ * @returns {Array<{ path: string, assumption: string }>}
70
+ */
71
+ function changesFromGroup(group) {
72
+ const fromGroup = Array.isArray(group.files) ? group.files : [];
73
+ const fromFindings = (group.findings ?? []).flatMap((f) =>
74
+ Array.isArray(f.files) ? f.files : [],
75
+ );
76
+ const paths = uniq(
77
+ [...fromGroup, ...fromFindings].filter(
78
+ (p) => typeof p === 'string' && p.length > 0,
79
+ ),
80
+ );
81
+ return paths.map((path) => ({ path, assumption: 'refactors-existing' }));
82
+ }
83
+
84
+ /**
85
+ * Build an observable acceptance item from a single finding: a checkable
86
+ * end-state a reviewer can confirm, NOT the verbatim recommendation
87
+ * paragraph. The recommendation prose is preserved verbatim in the Agent
88
+ * Prompts / fingerprint footer for the implementer; the acceptance line is
89
+ * the binding, confirmable outcome.
90
+ *
91
+ * Shape: `<title> is remediated in <primary file>: the recommended end-state
92
+ * holds and the finding is no longer reproducible.` — anchored on the finding
93
+ * title and primary file so the reviewer knows exactly what to check.
94
+ *
95
+ * @param {object} finding
96
+ * @returns {string}
97
+ */
98
+ function acceptanceItemFromFinding(finding) {
99
+ const title = (finding.title ?? 'finding').trim();
100
+ const primaryFile =
101
+ Array.isArray(finding.files) && finding.files.length > 0
102
+ ? finding.files[0]
103
+ : null;
104
+ const where = primaryFile ? ` in \`${primaryFile}\`` : '';
105
+ return `${title} is remediated${where}: the recommended end-state holds and the finding is no longer reproducible`;
48
106
  }
49
107
 
50
108
  function acceptanceCriteriaFromGroup(group) {
51
- return group.findings.map((f) => {
52
- const rec = f.recommendation || '_(no recommendation captured)_';
53
- return `${f.title} — ${rec}`;
54
- });
109
+ return (group.findings ?? []).map(acceptanceItemFromFinding);
110
+ }
111
+
112
+ /**
113
+ * Resolve the `edges[]` sequencing anchored on this group. Each edge whose
114
+ * `fromGroupKey` matches this group's key contributes its `toGroupKey`. Group
115
+ * keys are the only stable identifier available at emit time — issues are not
116
+ * numbered yet — so the relationship is preserved as machine-readable keys the
117
+ * operator can resolve.
118
+ *
119
+ * @param {object} group
120
+ * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} edges
121
+ * @returns {string[]}
122
+ */
123
+ function sequencingDepsForGroup(group, edges) {
124
+ if (!Array.isArray(edges) || edges.length === 0) return [];
125
+ const deps = edges
126
+ .filter((e) => e && e.fromGroupKey === group.groupKey)
127
+ .map((e) => e.toGroupKey)
128
+ .filter((k) => typeof k === 'string' && k.length > 0);
129
+ return uniq(deps);
130
+ }
131
+
132
+ /**
133
+ * Render the carried-through `edges[]` sequencing as a dedicated extended
134
+ * markdown block. The canonical `depends_on[]` footer only round-trips `#N`
135
+ * issue refs (`blocked by #123`), which do not exist before the issues are
136
+ * opened; rendering the group-key sequencing as its own informational section
137
+ * keeps the signal in the body (not discarded — Story #4270) and survives
138
+ * `parse()` / `serialize()` round-tripping (it is preamble/extended content,
139
+ * not a structured section). Returns the empty string when there is no
140
+ * sequencing to surface.
141
+ *
142
+ * @param {string[]} deps
143
+ * @returns {string}
144
+ */
145
+ function sequencingSection(deps) {
146
+ if (deps.length === 0) return '';
147
+ const lines = deps.map((k) => `- depends on group \`${k}\``);
148
+ return ['## Sequencing', '', lines.join('\n'), ''].join('\n');
55
149
  }
56
150
 
57
151
  function agentPromptsSection(group) {
58
- const blocks = group.findings
152
+ const blocks = (group.findings ?? [])
59
153
  .filter(
60
154
  (f) => typeof f.agentPrompt === 'string' && f.agentPrompt.length > 0,
61
155
  )
@@ -65,7 +159,7 @@ function agentPromptsSection(group) {
65
159
 
66
160
  function contextLinksFromGroup(group) {
67
161
  const reports = uniq(
68
- group.findings
162
+ (group.findings ?? [])
69
163
  .map((f) => f.sourceReport)
70
164
  .filter((s) => typeof s === 'string'),
71
165
  );
@@ -93,34 +187,47 @@ function labelsForGroup(group) {
93
187
  /**
94
188
  * @param {object} params
95
189
  * @param {object} params.group — output of `groupFindings` (one entry).
190
+ * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} [params.edges]
191
+ * — the dependency `edges[]` emitted by `groupFindings`. Edges anchored on
192
+ * this group are carried through to `depends_on[]`; omit when no sequencing
193
+ * is known.
96
194
  * @returns {{ title: string, body: string, labels: string[] }}
97
195
  */
98
- export function buildStoryBody({ group }) {
196
+ export function buildStoryBody({ group, edges = [] }) {
99
197
  if (!group || !Array.isArray(group.findings)) {
100
198
  throw new Error('buildStoryBody: group with findings[] is required');
101
199
  }
102
200
  const title = group.title;
103
201
 
104
- // Build the canonical StoryBody object from the audit group data.
202
+ // Build the canonical StoryBody object from the audit group data. The
203
+ // acceptance + verify arrays are populated so the body clears the
204
+ // inline-contract bar; changes[] carries the file footprint. The edges[]
205
+ // sequencing is carried through as an extended `## Sequencing` block (see
206
+ // sequencingSection) — group keys are not `#N` refs, so they cannot ride the
207
+ // canonical depends_on footer.
105
208
  const storyBody = {
106
209
  goal: goalFromGroup(group),
107
- changes: [],
210
+ changes: changesFromGroup(group),
108
211
  acceptance: acceptanceCriteriaFromGroup(group),
109
- verify: [],
212
+ verify: [...DEFAULT_VERIFY],
110
213
  references: [],
111
214
  wide: null,
215
+ reason_to_exist: null,
112
216
  depends_on: [],
113
217
  estimated_test_files: null,
114
218
  };
115
219
 
116
- // Serialize via the canonical serializer.
220
+ // Serialize via the canonical serializer (no footer — depends_on is empty).
117
221
  const canonicalSections = serialize(storyBody);
222
+ const sequencing = sequencingSection(sequencingDepsForGroup(group, edges));
118
223
 
119
- // Append audit-specific extended sections (agent prompts, context links,
120
- // fingerprint footer) that are not part of the canonical shape.
224
+ // Append audit-specific extended sections (sequencing, agent prompts,
225
+ // context links, fingerprint footer) that are not part of the canonical
226
+ // shape.
121
227
  const body = [
122
228
  canonicalSections,
123
229
  '',
230
+ ...(sequencing ? [sequencing] : []),
124
231
  '## Agent Prompts',
125
232
  '',
126
233
  agentPromptsSection(group),
@@ -60,6 +60,8 @@ export function parseSprintArgs(args = process.argv) {
60
60
  'no-full-scope-crap': { type: 'boolean', default: false },
61
61
  executor: { type: 'string' },
62
62
  cwd: { type: 'string' },
63
+ prd: { type: 'string' },
64
+ 'tech-spec': { type: 'string' },
63
65
  'recut-of': { type: 'string' },
64
66
  resume: { type: 'boolean', default: false },
65
67
  restart: { type: 'boolean', default: false },
@@ -86,6 +88,10 @@ export function parseSprintArgs(args = process.argv) {
86
88
  process.env.AGENT_WORKTREE_ROOT ||
87
89
  null,
88
90
  recutOf: parseTicketId(values['recut-of']),
91
+ // Story #4253: pre-resolved Epic linkages threaded by the /deliver
92
+ // fan-out so `story-init.js` can skip the per-Story `getEpic` round-trip.
93
+ prdId: parseTicketId(values.prd),
94
+ techSpecId: parseTicketId(values['tech-spec']),
89
95
  resume: values.resume ?? false,
90
96
  restart: values.restart ?? false,
91
97
  noEvidence: values['no-evidence'] ?? false,
@@ -68,11 +68,18 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
68
68
  * `cwd` (the main checkout) because the per-Epic temp tree lives under
69
69
  * the main `.git/`. Failure messages name the worktree path.
70
70
  *
71
- * Evidence-aware: when both `storyId` and `epicId` are provided and
72
- * `useEvidence !== false`, each gate consults `validation-evidence
73
- * .shouldSkip()` against current HEAD + the gate's command-config hash. A
74
- * matching record skips the gate; a successful run is recorded so the
75
- * next caller in the local hot path can skip in turn.
71
+ * Evidence-aware: when `storyId` is provided alongside either `epicId` or
72
+ * `standalone: true`, and `useEvidence !== false`, each gate consults
73
+ * `validation-evidence.shouldSkip()` against current HEAD + the gate's
74
+ * command-config hash. A matching record skips the gate; a successful run
75
+ * is recorded so the next caller in the local hot path can skip in turn.
76
+ *
77
+ * Standalone keyspace (Story #4250): the standalone path has no parent
78
+ * Epic, so it passes `standalone: true` (and leaves `epicId` null) to route
79
+ * the evidence file to the storyId-anchored
80
+ * `<tempRoot>/standalone/stories/story-<id>/validation-evidence.json`
81
+ * keyspace instead of feeding a null `epicId` into the Epic-keyed path
82
+ * (which structurally disabled the cache before).
76
83
  *
77
84
  * `onGateStart` is invoked immediately before each gate's runner spawn.
78
85
  * story-close uses it to drive `phaseTimer.mark(...)` for per-gate
@@ -87,6 +94,7 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
87
94
  * onGateStart?: (gate: Gate) => void,
88
95
  * storyId?: number|null,
89
96
  * epicId?: number|null,
97
+ * standalone?: boolean,
90
98
  * useEvidence?: boolean,
91
99
  * evidenceClock?: () => number,
92
100
  * getHeadSha?: (cwd: string) => string|null,
@@ -104,6 +112,7 @@ export async function runCloseValidation({
104
112
  onGateStart,
105
113
  storyId = null,
106
114
  epicId = null,
115
+ standalone = false,
107
116
  useEvidence = true,
108
117
  evidenceClock = () => Date.now(),
109
118
  getHeadSha = (resolvedCwd) => defaultGetHeadSha(resolvedCwd),
@@ -112,7 +121,15 @@ export async function runCloseValidation({
112
121
  } = {}) {
113
122
  const failed = [];
114
123
  const skipped = [];
115
- const evidenceActive = useEvidence && storyId != null && epicId != null;
124
+ // Evidence is active when a Story id is present AND there is a keyspace to
125
+ // anchor on: either a real Epic id (Epic path) or `standalone: true`
126
+ // (Story #4250 — standalone storyId-anchored keyspace). A bare `epicId:
127
+ // null` without `standalone` keeps the cache off, as before.
128
+ const evidenceActive =
129
+ useEvidence && storyId != null && (epicId != null || standalone);
130
+ // The evidence-store opts: `standalone` routes to the storyId-anchored
131
+ // keyspace; otherwise the Epic-keyed path resolves under `epicId`.
132
+ const evidenceStoreOpts = { cwd, epicId, standalone };
116
133
  // Evidence keys against the main checkout's HEAD because the per-Epic
117
134
  // evidence file lives under the main `.git/`. Gate spawn, in contrast,
118
135
  // runs in the worktree when one is supplied — that's the whole point of
@@ -134,7 +151,7 @@ export async function runCloseValidation({
134
151
  configHash,
135
152
  inputFingerprint: gate.inputFingerprint ?? null,
136
153
  },
137
- { cwd, epicId },
154
+ evidenceStoreOpts,
138
155
  );
139
156
  if (verdict.skip) {
140
157
  const tsHint = verdict.record?.timestamp
@@ -160,7 +177,7 @@ export async function runCloseValidation({
160
177
  durationMs,
161
178
  inputFingerprint: gate.inputFingerprint ?? null,
162
179
  },
163
- { cwd, epicId },
180
+ evidenceStoreOpts,
164
181
  );
165
182
  } catch (err) {
166
183
  log(
@@ -128,7 +128,7 @@ export function _clearMainCheckoutRootCache() {
128
128
  * @param {string} tempRoot
129
129
  * @returns {string}
130
130
  */
131
- function anchorTempRoot(tempRoot) {
131
+ export function anchorTempRoot(tempRoot) {
132
132
  if (path.isAbsolute(tempRoot)) return tempRoot;
133
133
  const root = mainCheckoutRoot();
134
134
  return root ? path.join(root, tempRoot) : tempRoot;