mandrel 1.75.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/epic-plan-lease-guard.js +14 -5
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +11 -0
- 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/providers/github/tickets.js +19 -6
- 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 +40 -0
- 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`
|
|
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. |
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
},
|
|
126
126
|
"verify": {
|
|
127
127
|
"type": "array",
|
|
128
|
-
"description": "Inline verify commands or shell snippets
|
|
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)
|
|
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.
|
|
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 ===
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 {
|
|
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 {
|
|
148
|
-
|
|
160
|
+
const {
|
|
161
|
+
scopeId,
|
|
162
|
+
epicId,
|
|
163
|
+
standalone = false,
|
|
164
|
+
gate,
|
|
165
|
+
useEvidence,
|
|
166
|
+
cwd,
|
|
167
|
+
worktreePath,
|
|
168
|
+
runnerArgs,
|
|
169
|
+
} = params ?? {};
|
|
149
170
|
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
120
|
-
// fingerprint footer) that are not part of the canonical
|
|
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
|
|
72
|
-
* `useEvidence !== false`, each gate consults
|
|
73
|
-
* .shouldSkip()` against current HEAD + the gate's
|
|
74
|
-
* matching record skips the gate; a successful run
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|