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.
- package/.agents/docs/configuration.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
- package/.agents/scripts/lib/cli-args.js +6 -0
- package/.agents/scripts/lib/close-validation/runner.js +25 -8
- package/.agents/scripts/lib/config/temp-paths.js +1 -1
- package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
- package/.agents/scripts/lib/config-resolver.js +4 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
- package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
- package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
- package/.agents/scripts/lib/story-body/story-body.js +76 -7
- package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
- package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
- package/.agents/scripts/lib/validation-evidence.js +63 -25
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/single-story-init.js +22 -29
- package/.agents/scripts/story-init.js +38 -63
- package/.agents/scripts/story-phase.js +46 -4
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
- package/.agents/workflows/helpers/deliver-epic.md +19 -2
- package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
- package/.agents/workflows/helpers/single-story-deliver.md +12 -0
- package/docs/CHANGELOG.md +33 -0
- 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
|
-
|
|
49
|
-
|
|
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) => [
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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
|
-
*
|
|
393
|
-
*
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
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
|
-
*
|
|
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: {
|
|
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 {
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
272
|
-
-
|
|
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
|
|
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;
|