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
@@ -27,6 +27,14 @@
27
27
  * `c8 report` path is ~19% faster end-to-end on a Windows dev host
28
28
  * while producing an identical `coverage-final.json` artifact for the
29
29
  * CRAP gate.
30
+ *
31
+ * Test-runner concurrency: the suite spawn reuses `TEST_RUNNER_FLAGS`
32
+ * from `run-tests.js` — the single source of truth for the
33
+ * `--test-concurrency` value, derived at startup from the host's
34
+ * available parallelism and clamped to `[TEST_CONCURRENCY_MIN,
35
+ * TEST_CONCURRENCY_MAX]`. This keeps the coverage gate (which runs the
36
+ * suite at every story close on both delivery paths) host-aware instead
37
+ * of pinned to the historical literal of 8.
30
38
  */
31
39
 
32
40
  import { spawnSync } from 'node:child_process';
@@ -37,6 +45,7 @@ import { fileURLToPath } from 'node:url';
37
45
 
38
46
  import { cleanupRepoTestTempArtifacts } from './cleanup-repo-test-temp.js';
39
47
  import { C8_CLI } from './lib/c8-cli-path.js';
48
+ import { TEST_RUNNER_FLAGS } from './run-tests.js';
40
49
 
41
50
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
51
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -45,59 +54,90 @@ const require = createRequire(import.meta.url);
45
54
  const C8_CONFIG = require('../../.c8rc.cjs');
46
55
  const V8_TMP = path.join(COVERAGE_DIR, 'tmp');
47
56
 
48
- rmSync(COVERAGE_DIR, { recursive: true, force: true });
49
- mkdirSync(V8_TMP, { recursive: true });
57
+ /**
58
+ * Build the `node --test` argv for the coverage suite spawn.
59
+ *
60
+ * Reuses the shared `TEST_RUNNER_FLAGS` (the single source of truth for
61
+ * the host-aware, clamped `--test-concurrency` value) so the coverage
62
+ * path never drifts from `run-tests.js`. The `runnerFlags` parameter is
63
+ * injected in tests so the argv can be asserted without touching the OS.
64
+ *
65
+ * @param {object} [opts]
66
+ * @param {readonly string[]} [opts.runnerFlags]
67
+ * @param {string} [opts.testGlob]
68
+ * @returns {string[]}
69
+ */
70
+ export function buildCoverageTestArgs({
71
+ runnerFlags = TEST_RUNNER_FLAGS,
72
+ testGlob = 'tests/**/*.test.js',
73
+ } = {}) {
74
+ return [...runnerFlags, testGlob];
75
+ }
76
+
77
+ /**
78
+ * Execute the coverage pipeline: run the suite under `NODE_V8_COVERAGE`,
79
+ * post-process the dumps with `c8 report`, then gate on the coverage
80
+ * baseline. Returns the first non-zero exit code across the three stages
81
+ * (or the baseline check's status when both prior stages pass).
82
+ *
83
+ * @returns {number}
84
+ */
85
+ function runCoveragePipeline() {
86
+ rmSync(COVERAGE_DIR, { recursive: true, force: true });
87
+ mkdirSync(V8_TMP, { recursive: true });
50
88
 
51
- const testRun = spawnSync(
52
- process.execPath,
53
- [
54
- '--experimental-test-module-mocks',
55
- '--test',
56
- '--test-concurrency=8',
57
- 'tests/**/*.test.js',
58
- ],
59
- {
89
+ const testRun = spawnSync(process.execPath, buildCoverageTestArgs(), {
60
90
  cwd: ROOT,
61
91
  stdio: 'inherit',
62
92
  env: { ...process.env, NODE_V8_COVERAGE: V8_TMP },
63
- },
64
- );
93
+ });
65
94
 
66
- cleanupRepoTestTempArtifacts({ repoRoot: ROOT });
95
+ cleanupRepoTestTempArtifacts({ repoRoot: ROOT });
67
96
 
68
- const includeArgs = (C8_CONFIG.include ?? []).flatMap((p) => ['--include', p]);
69
- const excludeArgs = (C8_CONFIG.exclude ?? []).flatMap((p) => ['--exclude', p]);
97
+ const includeArgs = (C8_CONFIG.include ?? []).flatMap((p) => [
98
+ '--include',
99
+ p,
100
+ ]);
101
+ const excludeArgs = (C8_CONFIG.exclude ?? []).flatMap((p) => [
102
+ '--exclude',
103
+ p,
104
+ ]);
70
105
 
71
- const reportRun = spawnSync(
72
- process.execPath,
73
- [
74
- C8_CLI,
75
- 'report',
76
- '--reporter=json',
77
- '--reporter=text',
78
- '--temp-directory',
79
- V8_TMP,
80
- ...includeArgs,
81
- ...excludeArgs,
82
- ],
83
- { cwd: ROOT, stdio: 'inherit', shell: false },
84
- );
106
+ const reportRun = spawnSync(
107
+ process.execPath,
108
+ [
109
+ C8_CLI,
110
+ 'report',
111
+ '--reporter=json',
112
+ '--reporter=text',
113
+ '--temp-directory',
114
+ V8_TMP,
115
+ ...includeArgs,
116
+ ...excludeArgs,
117
+ ],
118
+ { cwd: ROOT, stdio: 'inherit', shell: false },
119
+ );
85
120
 
86
- const checkRun = spawnSync(
87
- process.execPath,
88
- [
89
- path.join(ROOT, '.agents', 'scripts', 'check-baselines.js'),
90
- '--gate',
91
- 'coverage',
92
- ],
93
- { cwd: ROOT, stdio: 'inherit' },
94
- );
121
+ const checkRun = spawnSync(
122
+ process.execPath,
123
+ [
124
+ path.join(ROOT, '.agents', 'scripts', 'check-baselines.js'),
125
+ '--gate',
126
+ 'coverage',
127
+ ],
128
+ { cwd: ROOT, stdio: 'inherit' },
129
+ );
95
130
 
96
- const exitCode =
97
- testRun.status !== 0
98
- ? testRun.status
131
+ return testRun.status !== 0
132
+ ? (testRun.status ?? 1)
99
133
  : reportRun.status !== 0
100
- ? reportRun.status
101
- : checkRun.status;
134
+ ? (reportRun.status ?? 1)
135
+ : (checkRun.status ?? 1);
136
+ }
102
137
 
103
- process.exit(exitCode ?? 1);
138
+ // Run the pipeline only when invoked directly as a CLI; importing the
139
+ // module (e.g. from a test asserting the spawn argv) must not spawn the
140
+ // real suite.
141
+ if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? '')) {
142
+ process.exit(runCoveragePipeline());
143
+ }
@@ -51,6 +51,7 @@ import {
51
51
  branchExistsLocally,
52
52
  branchExistsViaTrackingRef,
53
53
  classifyBranchSeed,
54
+ seedStoryBranchRef,
54
55
  } from './lib/git-branch-lifecycle.js';
55
56
  import { getStoryBranch, gitSpawn, gitSync } from './lib/git-utils.js';
56
57
  import { Logger } from './lib/Logger.js';
@@ -376,36 +377,28 @@ export async function materializeBaseBranch({
376
377
  * @param {Function} opts.progress
377
378
  */
378
379
  export function seedStoryBranch({ cwd, storyBranch, baseBranch, progress }) {
379
- const seedAction = decideStoryBranchSeed({
380
- localHas: branchExistsLocally(storyBranch, cwd),
381
- remoteHas: branchExistsViaTrackingRef(storyBranch, cwd),
380
+ // Standalone path: no concurrent creator to race, so create failures are
381
+ // fatal (`swallowCreateRace: false`) and a failed fetch throws. The
382
+ // seed-action switch shell is single-homed in `seedStoryBranchRef`
383
+ // (Story #4255); this caller only supplies its `baseRef`, its git seams
384
+ // bound to `cwd`, and its own log/error vocabulary.
385
+ seedStoryBranchRef({
386
+ storyBranch,
387
+ baseRef: baseBranch,
388
+ swallowCreateRace: false,
389
+ spawn: (args) => gitSpawn(cwd, ...args),
390
+ existsLocally: (b) => branchExistsLocally(b, cwd),
391
+ existsRemotely: (b) => branchExistsViaTrackingRef(b, cwd),
392
+ progress,
393
+ messages: {
394
+ reuse: (b) => `Reusing existing local story branch: ${b}`,
395
+ fetch: (b) => `Fetching remote story branch: ${b}`,
396
+ create: (b, ref) => `Creating story branch ref: ${b} from ${ref}`,
397
+ createError: (b, _ref, stderr) =>
398
+ `Failed to create story branch ${b}: ${stderr || '(no stderr)'}`,
399
+ fetchError: (b, stderr) => `Failed to fetch story branch ${b}: ${stderr}`,
400
+ },
382
401
  });
383
- if (seedAction === 'fetch') {
384
- progress('GIT', `Fetching remote story branch: ${storyBranch}`);
385
- const r = gitSpawn(cwd, 'fetch', 'origin', `${storyBranch}:${storyBranch}`);
386
- if (r.status !== 0) {
387
- throw new Error(
388
- `Failed to fetch story branch ${storyBranch}: ${r.stderr || '(no stderr)'}`,
389
- );
390
- }
391
- return;
392
- }
393
- if (seedAction === 'create') {
394
- progress(
395
- 'GIT',
396
- `Creating story branch ref: ${storyBranch} from ${baseBranch}`,
397
- );
398
- const r = gitSpawn(cwd, 'branch', storyBranch, baseBranch);
399
- if (r.status !== 0) {
400
- throw new Error(
401
- `Failed to create story branch ${storyBranch}: ${r.stderr || '(no stderr)'}`,
402
- );
403
- }
404
- return;
405
- }
406
- // seedAction === 'reuse' — the local ref already exists. Do NOT run
407
- // `git branch` here; re-creating an existing ref throws. Reuse it.
408
- progress('GIT', `Reusing existing local story branch: ${storyBranch}`);
409
402
  }
410
403
 
411
404
  /**
@@ -38,7 +38,6 @@ import {
38
38
  } from './lib/config-resolver.js';
39
39
  import { parseBlockedBy } from './lib/dependency-parser.js';
40
40
  import { getEpicBranch, getStoryBranch } from './lib/git-utils.js';
41
- import { runInstallCommand } from './lib/install-cmd-parser.js';
42
41
  import { Logger } from './lib/Logger.js';
43
42
  import { setActiveStoryEnv } from './lib/observability/active-story-env.js';
44
43
  import {
@@ -89,6 +88,8 @@ export async function runStoryInit({
89
88
  dryRun: dryRunParam,
90
89
  cwd: cwdParam,
91
90
  recutOf: recutOfParam,
91
+ prdId: prdIdParam,
92
+ techSpecId: techSpecIdParam,
92
93
  injectedProvider,
93
94
  injectedConfig,
94
95
  } = {}) {
@@ -99,10 +100,17 @@ export async function runStoryInit({
99
100
  dryRun: !!dryRunParam,
100
101
  cwd: cwdParam ?? null,
101
102
  recutOf: recutOfParam ?? null,
103
+ prdId: prdIdParam ?? null,
104
+ techSpecId: techSpecIdParam ?? null,
102
105
  }
103
106
  : parseSprintArgs();
104
107
  const { storyId, dryRun } = parsed;
105
108
  const recutOf = recutOfParam ?? parsed.recutOf ?? null;
109
+ // Story #4253: pre-resolved Epic linkages (from the /deliver fan-out's
110
+ // one-time Epic resolution). When both are present, hierarchy-tracer skips
111
+ // the per-Story getEpic round-trip; when absent it resolves them itself.
112
+ const threadedPrdId = prdIdParam ?? parsed.prdId ?? null;
113
+ const threadedTechSpecId = techSpecIdParam ?? parsed.techSpecId ?? null;
106
114
  // Worktree-aware cwd resolution: explicit param > --cwd flag > env > PROJECT_ROOT.
107
115
  const cwd = path.resolve(cwdParam ?? parsed.cwd ?? PROJECT_ROOT);
108
116
 
@@ -147,11 +155,13 @@ export async function runStoryInit({
147
155
  input: { storyId, recutOf, dryRun },
148
156
  });
149
157
 
150
- // Stage 2 — hierarchy.
158
+ // Stage 2 — hierarchy. When the /deliver fan-out threaded --prd/--tech-spec
159
+ // (both resolved once by the parent), this short-circuits the per-Story
160
+ // getEpic. Absent flags fall back to the legacy getEpic resolution.
151
161
  const { prdId, techSpecId } = await traceHierarchy({
152
162
  provider,
153
163
  logger: stageLogger,
154
- input: { epicId },
164
+ input: { epicId, prdId: threadedPrdId, techSpecId: threadedTechSpecId },
155
165
  });
156
166
 
157
167
  progress('CONTEXT', `Epic: #${epicId}, Parent: #${parentId ?? 'none'}`);
@@ -366,9 +376,16 @@ const VALID_INSTALLED_STATES = new Set(['true', 'false', 'skipped']);
366
376
 
367
377
  /**
368
378
  * Apply the dependenciesInstalled tri-state to derive the next install
369
- * action. Pure helper — exposes the Step 0.5 truth table as data so tests
379
+ * action. Pure helper — exposes the install truth table as data so tests
370
380
  * can pin each branch without spinning up a child process.
371
381
  *
382
+ * Story #4249: this is no longer wired into a post-init re-install. The
383
+ * in-`ensure` install (with its PM-aware retry budget) is the single install
384
+ * owner; the formerly-hardcoded `npm ci` retry inside `runStoryInitPrepare`
385
+ * was deleted. The tri-state still flows onto the `story-init` structured
386
+ * comment (`dependenciesInstalled`) as the workflow-facing signal, and this
387
+ * helper remains its canonical classifier.
388
+ *
372
389
  * @param {'true' | 'false' | 'skipped'} dependenciesInstalled
373
390
  * @param {{ skipInstall?: boolean }} [options]
374
391
  * @returns {'skip' | 'install'}
@@ -384,54 +401,33 @@ export function deriveInstallAction(dependenciesInstalled, options = {}) {
384
401
  }
385
402
 
386
403
  /**
387
- * Resolve the install command to run when `dependenciesInstalled === 'false'`.
388
- * `project.commands` does not currently carry a dedicated install key,
389
- * so this defaults to `npm ci`. Operators can override per-invocation via
390
- * the `installCmd` option.
404
+ * Post-init prepare step (Story #4017 formerly a standalone prepare CLI,
405
+ * now consuming the in-process init result instead of re-reading the
406
+ * `story-init` structured comment).
391
407
  *
392
- * @param {{ override?: string }} [options]
393
- * @returns {string}
394
- */
395
- export function resolveInstallCommand(options = {}) {
396
- const trimmed = options.override?.trim();
397
- if (trimmed) {
398
- return trimmed;
399
- }
400
- return 'npm ci';
401
- }
402
-
403
- /**
404
- * Post-init prepare step (Story #4017 — formerly a standalone prepare
405
- * CLI, now consuming the in-process init result
406
- * instead of re-reading the `story-init` structured comment):
408
+ * Story #4249: the install branch was deleted. The worktree install is owned
409
+ * entirely by `WorktreeManager.ensure` (via `installDependencies`), which now
410
+ * carries a PM-aware retry budget (`installRetryPolicy`) — so a transient
411
+ * first-attempt failure retries there with the correct package-manager
412
+ * command, never an unconditional `npm ci` re-run after init. This step is now
413
+ * purely the snapshot-render half:
407
414
  *
408
- * 1. Apply the `dependenciesInstalled` tri-state truth table `'false'`
409
- * (install attempted and failed) retries the install command in the
410
- * worktree; `'true'` / `'skipped'` proceed.
411
- * 2. Render the initial Story-phase snapshot with every phase pinned to
412
- * `pending` and `phase: 'init'` via `upsertStoryRunProgress`
413
- * (render-only since Story #3909 — no comment is posted). The
414
- * `renderedBody` markdown is relayed to chat by the delivery
415
- * workflows so operators see the initial progress block before the
416
- * first commit lands.
415
+ * - Render the initial Story-phase snapshot with every phase pinned to
416
+ * `pending` and `phase: 'init'` via `upsertStoryRunProgress` (render-only
417
+ * since Story #3909 — no comment is posted). The `renderedBody` markdown is
418
+ * relayed to chat by the delivery workflows so operators see the initial
419
+ * progress block before the first commit lands.
417
420
  *
418
- * Install failure throws (init exits non-zero); a snapshot-render failure
419
- * is non-fatal observability loss and only warns.
421
+ * A snapshot-render failure is non-fatal observability loss and only warns.
420
422
  *
421
423
  * @param {{
422
424
  * provider: object,
423
425
  * storyId: number,
424
- * result: { workCwd?: string, dependenciesInstalled?: string, storyBranch?: string },
426
+ * result: { storyBranch?: string },
425
427
  * notify?: Function | null,
426
- * runInstall?: (cmd: string, cwd: string) => { status: number, stderr?: string },
427
- * skipInstall?: boolean,
428
- * installCmd?: string,
429
428
  * logger?: object,
430
429
  * }} args
431
430
  * @returns {Promise<{
432
- * installAction: 'skip' | 'install',
433
- * installCmd: string | null,
434
- * installResult: { status: number, stderr?: string } | null,
435
431
  * snapshot: object | null,
436
432
  * renderedBody: string | null,
437
433
  * }>}
@@ -441,29 +437,8 @@ export async function runStoryInitPrepare({
441
437
  storyId,
442
438
  result,
443
439
  notify: notifyFn = null,
444
- runInstall = runInstallCommand,
445
- skipInstall = false,
446
- installCmd: installCmdOverride,
447
440
  logger = stageLogger,
448
441
  }) {
449
- const dependenciesInstalled = String(
450
- result?.dependenciesInstalled ?? 'skipped',
451
- );
452
- const installAction = deriveInstallAction(dependenciesInstalled, {
453
- skipInstall,
454
- });
455
- let installCmd = null;
456
- let installResult = null;
457
- if (installAction === 'install') {
458
- installCmd = resolveInstallCommand({ override: installCmdOverride });
459
- installResult = runInstall(installCmd, result.workCwd);
460
- if (installResult.status !== 0) {
461
- throw new Error(
462
- `runStoryInitPrepare: install command \`${installCmd}\` failed with status ${installResult.status}: ${installResult.stderr ?? ''}`,
463
- );
464
- }
465
- }
466
-
467
442
  let snapshot = null;
468
443
  let renderedBody = null;
469
444
  try {
@@ -483,7 +458,7 @@ export async function runStoryInitPrepare({
483
458
  );
484
459
  }
485
460
 
486
- return { installAction, installCmd, installResult, snapshot, renderedBody };
461
+ return { snapshot, renderedBody };
487
462
  }
488
463
 
489
464
  function buildStoryInitResult({
@@ -27,6 +27,12 @@
27
27
  * --story <id> Story ID (required).
28
28
  * --phase <init|implementing|closing|blocked|done>
29
29
  * Phase the Story is entering (required).
30
+ * --epic <id> Parent Epic id from the Step 0 envelope.
31
+ * When supplied, skips the readEpicIdFromStory
32
+ * GitHub read.
33
+ * --branch <name> Story branch from the Step 0 envelope.
34
+ * When supplied, skips the resolveStoryBranch
35
+ * GitHub read.
30
36
  * --no-heartbeat Suppress the lifecycle emit (tests).
31
37
  *
32
38
  * Stdout: a single JSON envelope
@@ -67,12 +73,17 @@ const VALID_PHASES = new Set([
67
73
 
68
74
  const HELP = `Usage: node .agents/scripts/story-phase.js \\
69
75
  --story <id> --phase <init|implementing|closing|blocked|done> \\
70
- [--no-heartbeat]
76
+ [--epic <id>] [--branch <name>] [--no-heartbeat]
71
77
 
72
78
  Renders the Story-phase snapshot for Story #<id> at the requested phase
73
79
  (returned as renderedBody for chat relay; no comment is posted) and
74
80
  (unless --no-heartbeat) appends one story.heartbeat record to the parent
75
81
  Epic's lifecycle ledger so the Idle Watchdog can confirm the Story is alive.
82
+
83
+ --epic / --branch let the caller pass the parent Epic id and Story branch
84
+ from story-init.js's Step 0 envelope, skipping the GitHub reads
85
+ (readEpicIdFromStory / resolveStoryBranch) that would otherwise re-fetch
86
+ these immutable values on every phase call. Omit both for interactive use.
76
87
  `;
77
88
 
78
89
  /**
@@ -216,9 +227,17 @@ function emitHeartbeatBestEffort({
216
227
  * End-to-end phase writer. DI-friendly: tests pass `provider`, override
217
228
  * the ledger path, and skip the heartbeat as needed.
218
229
  *
230
+ * When `epicId` / `branch` are supplied (the `/deliver` worker passes them
231
+ * from `story-init.js`'s Step 0 envelope), the corresponding GitHub read is
232
+ * skipped entirely: `epicId` short-circuits `readEpicIdFromStory` and
233
+ * `branch` short-circuits `resolveStoryBranch`. Omit both for interactive
234
+ * use to restore the original GitHub-read resolution.
235
+ *
219
236
  * @param {{
220
237
  * storyId: number,
221
238
  * phase: string,
239
+ * epicId?: number|null,
240
+ * branch?: string,
222
241
  * noHeartbeat?: boolean,
223
242
  * provider?: object,
224
243
  * config?: object,
@@ -230,6 +249,8 @@ export async function runStoryPhase(args) {
230
249
  const {
231
250
  storyId,
232
251
  phase,
252
+ epicId: epicIdOverride,
253
+ branch: branchOverride,
233
254
  noHeartbeat = false,
234
255
  provider: providerOverride,
235
256
  config: configOverride,
@@ -253,8 +274,17 @@ export async function runStoryPhase(args) {
253
274
  : (ticketId, payload, opts = {}) =>
254
275
  notify(ticketId, payload, { config, provider, ...opts });
255
276
 
256
- const branch = await resolveStoryBranch({ provider, storyId });
257
- const epicId = await readEpicIdFromStory({ provider, storyId });
277
+ // When the Step 0 envelope supplied the branch / epicId (the /deliver
278
+ // worker passes them via --branch / --epic), skip the GitHub reads that
279
+ // would otherwise re-fetch these immutable values on every phase call.
280
+ const branch =
281
+ typeof branchOverride === 'string' && branchOverride
282
+ ? branchOverride
283
+ : await resolveStoryBranch({ provider, storyId });
284
+ const epicId =
285
+ epicIdOverride !== undefined
286
+ ? epicIdOverride
287
+ : await readEpicIdFromStory({ provider, storyId });
258
288
  const phases = phasesForWorkflowPhase(phase, now);
259
289
 
260
290
  const { body: renderedBody, payload: snapshot } =
@@ -301,17 +331,29 @@ export function parseArgv(argv) {
301
331
  options: {
302
332
  story: { type: 'string' },
303
333
  phase: { type: 'string' },
334
+ epic: { type: 'string' },
335
+ branch: { type: 'string' },
304
336
  'no-heartbeat': { type: 'boolean' },
305
337
  help: { type: 'boolean' },
306
338
  },
307
339
  strict: false,
308
340
  });
309
- return {
341
+ // `--epic` absent → leave `epicId` undefined so runStoryPhase falls back to
342
+ // the readEpicIdFromStory GitHub read. `--branch` absent → leave `branch`
343
+ // undefined so it falls back to resolveStoryBranch.
344
+ const parsed = {
310
345
  help: Boolean(values.help),
311
346
  storyId: Number.parseInt(values.story ?? '', 10),
312
347
  phase: values.phase,
313
348
  noHeartbeat: Boolean(values['no-heartbeat']),
314
349
  };
350
+ if (values.epic !== undefined) {
351
+ parsed.epicId = Number.parseInt(values.epic, 10);
352
+ }
353
+ if (typeof values.branch === 'string' && values.branch) {
354
+ parsed.branch = values.branch;
355
+ }
356
+ return parsed;
315
357
  }
316
358
 
317
359
  export async function main(argv = process.argv.slice(2)) {
@@ -206,6 +206,7 @@ They are NOT top-level ticket fields.
206
206
 
207
207
  - **slug**: MUST be hyphen-case (`^[a-z0-9][a-z0-9-]*$`). Do not use underscores.
208
208
  - **goal** (in body string): One sentence stating WHY this Story exists within the Epic.
209
+ - **reason_to_exist** (REQUIRED, encoded in the body `<!-- meta: {...} -->` comment — NOT a top-level ticket field): One sentence stating the single coherent reason this Story exists, distinct from the broader `goal` prose. Every Story MUST carry a non-empty `reason_to_exist`; it is the machine-checkable form of the cohesion rule (**one Story = one coherent change with one reason to exist**). The `epic-plan-consolidate` critic flags any Story whose body carries no non-empty reason, and the sizing validator (`ticket-validator-sizing.js`) emits a deterministic **soft** `missing-reason-to-exist` finding as the runtime backstop. Encode it as `<!-- meta: {"reason_to_exist": "..."} -->`.
209
210
  - **changes** (in body string): Each entry is an object `{ path, assumption }` where `assumption` is one of `creates | refactors-existing | deletes`. The Phase 8 validator probes the base branch for every declared path and rejects the decompose when the declared assumption contradicts reality: `creates` against an existing path is an error, `refactors-existing` / `deletes` against a missing path is an error. Use `refactors-existing` for in-place edits to a file already on `main`; `creates` for net-new files; `deletes` for removals. Acceptable path shapes include explicit files (`src/components/Foo.tsx`), glob patterns (`tests/e2e/*.spec.ts`, `**/*.astro`), and module identifiers that resolve to files.
210
211
  - **references** (in body string, optional): Object-form entries `{ path, assumption: "exists" }` for paths the Story **reads** but does not modify (test fixtures it relies on, sibling modules it imports, feature files it scans). The validator probes these like `changes` and rejects the decompose when an `exists` path is absent on the base branch. Use this list to make read-dependencies explicit so a hallucinated or stale assumption surfaces at planning time rather than execution time.
211
212
  - **NEW-FILE CONTRACT (must-follow)**: Any path the Story references in `goal`, `acceptance`, or `verify` that does **not** already exist on `main` MUST also appear in the same Story's `changes` array with `assumption: "creates"`. The freshness validator probes `main` for every referenced code path and rejects the decompose when a missing path is absent from `changes` — even when the Story is the one authoring the file. Example: a Story creating `tests/lib/foo.test.js` whose `verify` runs `node --test tests/lib/foo.test.js` MUST include `{ "path": "tests/lib/foo.test.js", "assumption": "creates" }` in `changes`, otherwise the validator emits a freshness miss and the decompose round trips for a re-emit.
@@ -266,10 +267,11 @@ Declaring `wide` with a non-empty reason **lifts the `hardFiles` rejection** —
266
267
 
267
268
  #### BINDING ACCEPTANCE vs ADVISORY CHANGES (authoring altitude)
268
269
 
269
- `acceptance[]` and `verify[]` are the **binding contract** the executor MUST satisfy — they are the sole definition of "done." `changes[]` and `references[]` are an **advisory implementation sketch**: your best prediction of the file footprint, which the executor is permitted to revise when the real codebase diverges from the sketch. Author at that altitude:
270
+ The canonical altitude + New-File Contract wording is single-sourced in `AUTHORING_ALTITUDE_GUIDANCE` in `ticket-validator-sizing.js` (Story #4272); the rendered decomposer prompt interpolates the same strings, so do not restate a divergent version here. The three canonical statements:
270
271
 
271
- - Write `acceptance[]`/`verify[]` to capture the **outcome**, independent of any one file layout. Do NOT pin an incidental implementation detail (an internal helper name, a private file path) into an acceptance item that the advisory `changes[]` is free to reshape assert the observable behaviour instead.
272
- - Keep `changes[]`/`references[]` as the honest predicted footprint. They still pass through the structural file-assumption gate (the `creates`/`refactors-existing`/`deletes` probes against the base branch) and the New-File Contract unchanged advisory does NOT mean unvalidated. The executor's latitude to revise the approach never licenses skipping `acceptance[]`/`verify[]` or any `rules/security-baseline.md` MUST.
272
+ - **Binding contract vs advisory sketch.** `acceptance[]` and `verify[]` are the Story's **binding contract** — the executor MUST satisfy them exactly, and they are the only definition of "done." `changes[]` and `references[]` are an **advisory implementation sketch**: your best prediction of the file footprint, which the executor MAY revise when the real codebase diverges from the sketch. Author `acceptance[]` / `verify[]` to assert the **outcome** independent of any one file layout never pin an incidental implementation detail (an internal helper name, a private file path) into an acceptance item that the advisory `changes[]` is free to reshape; assert the observable behaviour instead.
273
+ - **New-File Contract.** Any path named in a Story's `goal`, `acceptance`, or `verify` that does NOT already exist on `main` MUST also appear in that Story's `changes[]` with `assumption: "creates"`; otherwise the freshness validator rejects the decompose even when the Story is the one authoring the file.
274
+ - **Advisory does not mean unvalidated.** `changes[]` paths still pass the base-branch file-assumption probes (a `creates` against an existing path still fails), the New-File Contract still holds, and the executor's latitude to revise the approach never licenses skipping `acceptance[]` / `verify[]` or relaxing any `rules/security-baseline.md` MUST.
273
275
 
274
276
  #### NAVIGATE-DON'T-DEEP-LINK (signed-in acceptance scenarios)
275
277
 
@@ -43,6 +43,33 @@ per-criterion, mid-delivery, and evaluates the actual work product.
43
43
  optional advisory pre-flight — a criterion cannot be scored `met` without
44
44
  the supporting `verify[]` evidence where a `verify[]` command is relevant
45
45
  to it.
46
+ - **Shares `lint` / `typecheck` evidence with close (Story #4250).** When a
47
+ `verify[]` command is **byte-identical** to a close-validation gate — in
48
+ practice only the cheap, command-identical `lint` and `typecheck` gates
49
+ (`npm run lint` and the resolved `project.commands.typecheck`) — the
50
+ critic MUST run it through `evidence-gate.js` so a passing run records an
51
+ evidence entry in the **same keyspace** `close-validation/runner.js`
52
+ consults. Run it in the **same Story worktree** the close validates (the
53
+ HEAD-sha key enforces "unchanged HEAD") and pass the exact gate name:
54
+
55
+ ```bash
56
+ # Epic-attached Story:
57
+ node <main-repo>/.agents/scripts/evidence-gate.js \
58
+ --epic-id <epicId> --scope-id <storyId> --gate lint \
59
+ --worktree <worktree> -- npm run lint
60
+
61
+ # Standalone Story (no parent Epic) — use --standalone, omit --epic-id:
62
+ node <main-repo>/.agents/scripts/evidence-gate.js \
63
+ --standalone --scope-id <storyId> --gate typecheck \
64
+ --worktree <worktree> -- <resolved typecheck command>
65
+ ```
66
+
67
+ Close's `shouldSkip` then short-circuits that gate when HEAD is
68
+ unchanged; a redraft round (HEAD moves) correctly busts it. **Never**
69
+ run the coverage / CRAP suite through `evidence-gate.js` to stamp it
70
+ fresh — a false-fresh coverage record without `coverage-final.json`
71
+ silently weakens the floor. Limit the evidence-share to `lint` and
72
+ `typecheck`.
46
73
  - Emits a verdict file under `temp/` conforming to
47
74
  [`acceptance-eval-verdict.schema.json`](../../schemas/acceptance-eval-verdict.schema.json):
48
75
  one `{ index, criterion, verdict: met|partial|unmet, evidence,
@@ -167,11 +167,25 @@ Validates `type::epic`, enumerates `type::story` descendants, parses
167
167
  (to enumerate the open Story set), and upserts the `epic-run-state`
168
168
  checkpoint in the per-Story-status shape (a flat `stories` map seeded at
169
169
  `pending`, plus the global `concurrencyCap`). Treat the printed JSON as
170
- `state`: `{ epicId, storyCount, concurrencyCap, stories, checkpointInitializedAt }`.
170
+ `state`: `{ epicId, storyCount, concurrencyCap, stories, prdId, techSpecId, checkpointInitializedAt }`.
171
171
  `stories` is the flat dispatch hint (`{ storyId, worktree, title }` per open
172
172
  Story); the ready-set `tick` (Phase 2) decides which to dispatch on each
173
173
  beat. Flip the Epic to `agent::executing` (idempotent) after the CLI returns.
174
174
 
175
+ **Epic linkages resolved once (Story #4253).** The envelope also carries
176
+ `prdId` and `techSpecId` — the Epic's linked PRD / Tech-Spec issue ids,
177
+ resolved a **single** time here from the Epic snapshot prepare already
178
+ holds (no extra fetch). Capture both and thread them into **every**
179
+ per-Story `story-init.js` invocation (§ 2b → `epic-deliver-story` Step 0)
180
+ as `--prd <prdId> --tech-spec <techSpecId>`. This collapses the N
181
+ per-Story `getEpic` round-trips (one per child, each in its own process
182
+ with its own provider cache) to this one parent-side resolution — the
183
+ immutable Epic issue is invariant for the lifetime of a delivery run.
184
+ When a linkage is `null` (the Epic links no PRD or Tech Spec), omit the
185
+ corresponding flag; the child's `story-init.js` then falls back to its
186
+ own `getEpic` resolution for the missing id, preserving graceful
187
+ degradation.
188
+
175
189
  > **Preflight guards (Story #3482 / F-workflow-guards).** Before the
176
190
  > snapshot phase runs — and before any worktree is created — prepare runs
177
191
  > two **fail-closed** guards
@@ -334,7 +348,10 @@ matching `story.dispatch.end` record is appended later by
334
348
  `epic-execute-record-wave.js` (via `emit-story-dispatch-end.js`, Story #3900)
335
349
  after the Agent return is recorded in § 2c.
336
350
 
337
- Each Agent call's prompt must (1) name the Story + Epic ids, (2)
351
+ Each Agent call's prompt must (1) name the Story + Epic ids **and the
352
+ `prdId` / `techSpecId` from the Phase 1 prepare envelope** (Story #4253) so
353
+ the child can thread `--prd <prdId> --tech-spec <techSpecId>` into its
354
+ `story-init.js` Step 0 — omit whichever flag is `null`, (2)
338
355
  instruct the child to invoke `helpers/epic-deliver-story <storyId>`
339
356
  (whose Step 4 defines the child's return shape), (3) remind the child
340
357
  of the **non-interactive contract** (no clarifying questions;