mandrel 1.76.0 → 1.78.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/docs/workflows.md +19 -0
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
- package/.agents/schemas/loop-unit.schema.json +70 -0
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/check-doc-links.js +24 -1
- package/.agents/scripts/check-loop-units.js +204 -0
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/generate-workflows-doc.js +37 -4
- 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/process.js +61 -5
- package/.agents/scripts/lib/close-validation/runner.js +42 -9
- 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/loop-units/validate-loop-unit.js +197 -0
- package/.agents/scripts/lib/mandrel-catalog.js +36 -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/emit-loop-tick.js +183 -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/branch-protection.js +1 -1
- package/.agents/scripts/providers/github/errors.js +53 -2
- package/.agents/scripts/providers/github/labels.js +1 -1
- package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/run-lint.js +11 -0
- 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/scripts/sync-claude-commands.js +112 -29
- package/.agents/scripts/update-maintainability-baseline.js +19 -76
- 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/.agents/workflows/loops/README.md +65 -0
- package/.agents/workflows/loops/fix-failing-tests.md +74 -0
- package/.agents/workflows/loops/nightly-audit.md +71 -0
- package/.agents/workflows/loops/watch-ci.md +68 -0
- package/docs/CHANGELOG.md +51 -0
- package/package.json +1 -1
- package/.agents/scripts/providers/github/transient-retry.js +0 -62
|
@@ -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?.(
|
|
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
34
34
|
import { parseArgs } from 'node:util';
|
|
35
35
|
import { runAsCli } from './lib/cli-utils.js';
|
|
36
36
|
import { Logger } from './lib/Logger.js';
|
|
37
|
-
import { buildCatalog } from './lib/mandrel-catalog.js';
|
|
37
|
+
import { buildCatalog, buildLoopCatalog } from './lib/mandrel-catalog.js';
|
|
38
38
|
|
|
39
39
|
const __filename = fileURLToPath(import.meta.url);
|
|
40
40
|
const __dirname = path.dirname(__filename);
|
|
@@ -66,12 +66,14 @@ function cellEscape(description) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Render the full generated `workflows.md` content from
|
|
69
|
+
* Render the full generated `workflows.md` content from the flat command
|
|
70
|
+
* catalog and the loop-unit catalog.
|
|
70
71
|
*
|
|
71
72
|
* @param {Array<{ name: string, description: string | null, vague: boolean }>} catalog
|
|
73
|
+
* @param {Array<{ name: string, description: string | null, vague: boolean }>} [loopCatalog]
|
|
72
74
|
* @returns {string}
|
|
73
75
|
*/
|
|
74
|
-
export function renderWorkflowsDoc(catalog) {
|
|
76
|
+
export function renderWorkflowsDoc(catalog, loopCatalog = []) {
|
|
75
77
|
const lines = [
|
|
76
78
|
'<!--',
|
|
77
79
|
' GENERATED FILE — do not edit by hand.',
|
|
@@ -95,6 +97,13 @@ export function renderWorkflowsDoc(catalog) {
|
|
|
95
97
|
'`.claude/commands/<name>.md` — there is no plugin manifest and no',
|
|
96
98
|
'marketplace listing. The commands load in every Claude Code environment.',
|
|
97
99
|
'',
|
|
100
|
+
'Loop units are the one namespaced exception: files under',
|
|
101
|
+
'`.agents/workflows/loops/<name>.md` project to',
|
|
102
|
+
'`.claude/commands/loops/<name>.md` and are invoked as the namespaced',
|
|
103
|
+
'`/loops:<name>` command. On hosts that flatten subdirectory commands the',
|
|
104
|
+
'same unit surfaces under the flat fallback `/loops-<name>`. They are',
|
|
105
|
+
'listed separately in the **Loops namespace** section below.',
|
|
106
|
+
'',
|
|
98
107
|
'This index is regenerated from each workflow’s front-matter `description:`',
|
|
99
108
|
'by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`',
|
|
100
109
|
'fails when it drifts from the on-disk workflow set. To change a command’s',
|
|
@@ -110,6 +119,29 @@ export function renderWorkflowsDoc(catalog) {
|
|
|
110
119
|
lines.push(`| \`/${entry.name}\` | ${cellEscape(entry.description)} |`);
|
|
111
120
|
}
|
|
112
121
|
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push(`## Loops namespace (${loopCatalog.length})`);
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(
|
|
126
|
+
'Loop units project to `.claude/commands/loops/<name>.md` and are invoked',
|
|
127
|
+
);
|
|
128
|
+
lines.push(
|
|
129
|
+
'as `/loops:<name>` (flat fallback `/loops-<name>` on hosts that flatten',
|
|
130
|
+
);
|
|
131
|
+
lines.push('subdirectory commands).');
|
|
132
|
+
lines.push('');
|
|
133
|
+
if (loopCatalog.length === 0) {
|
|
134
|
+
lines.push('> No loop units are shipped yet.');
|
|
135
|
+
} else {
|
|
136
|
+
lines.push('| Command | Description |');
|
|
137
|
+
lines.push('| --- | --- |');
|
|
138
|
+
for (const entry of loopCatalog) {
|
|
139
|
+
lines.push(
|
|
140
|
+
`| \`/loops:${entry.name}\` | ${cellEscape(entry.description)} |`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
113
145
|
lines.push('');
|
|
114
146
|
return lines.join('\n');
|
|
115
147
|
}
|
|
@@ -121,7 +153,8 @@ export function renderWorkflowsDoc(catalog) {
|
|
|
121
153
|
*/
|
|
122
154
|
export function buildExpected() {
|
|
123
155
|
const catalog = buildCatalog(WORKFLOWS_DIR);
|
|
124
|
-
const
|
|
156
|
+
const loopCatalog = buildLoopCatalog(WORKFLOWS_DIR);
|
|
157
|
+
const generated = renderWorkflowsDoc(catalog, loopCatalog);
|
|
125
158
|
const original = fs.existsSync(DOC_PATH)
|
|
126
159
|
? fs.readFileSync(DOC_PATH, 'utf8')
|
|
127
160
|
: null;
|
|
@@ -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,
|
|
@@ -53,6 +53,34 @@ export function gateExitCode(code, sig) {
|
|
|
53
53
|
return sig ? 143 : 1;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Biome's marker for "you handed me a path set, but every one of them is
|
|
58
|
+
* excluded by my own config (`files.includes` allowlist / `files.ignore` /
|
|
59
|
+
* `overrides`), so I processed nothing" — biome exits 1 in that case.
|
|
60
|
+
*
|
|
61
|
+
* The format gate scopes biome to the changed-file subset (Story #3410). When
|
|
62
|
+
* that subset is non-empty by extension but every path is biome-config-ignored,
|
|
63
|
+
* the scoped invocation reports this message and exits 1 even though
|
|
64
|
+
* `biome format .` over the whole tree is clean — a false negative for the
|
|
65
|
+
* gate (Story #4292). Detecting the marker lets the runner treat that exit as
|
|
66
|
+
* a clean skip rather than a formatting failure.
|
|
67
|
+
*/
|
|
68
|
+
const BIOME_NO_FILES_PROCESSED =
|
|
69
|
+
'No files were processed in the specified paths';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether biome's combined gate output carries the "No files were processed"
|
|
73
|
+
* marker. Pure function — no I/O. Exported for unit coverage (Story #4292).
|
|
74
|
+
*
|
|
75
|
+
* @param {string} output - Combined stdout/stderr captured from the gate child.
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function isBiomeNoFilesProcessed(output) {
|
|
79
|
+
return (
|
|
80
|
+
typeof output === 'string' && output.includes(BIOME_NO_FILES_PROCESSED)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/**
|
|
57
85
|
* Default async gate runner — used by `runCloseValidation` when no `runner`
|
|
58
86
|
* is injected. Spawns the gate via `child_process.spawn`, prefixes every
|
|
@@ -66,13 +94,19 @@ export function gateExitCode(code, sig) {
|
|
|
66
94
|
* `runCloseValidation` sees a non-zero status and folds it into the
|
|
67
95
|
* already-recorded first-failure.
|
|
68
96
|
*
|
|
97
|
+
* When `opts.tolerateNoFilesProcessed` is set (the biome-scoped format gate —
|
|
98
|
+
* Story #4292), a non-zero exit whose combined output carries biome's
|
|
99
|
+
* "No files were processed" marker is downgraded to a clean `status: 0`,
|
|
100
|
+
* because that exit means every config-included path was already excluded,
|
|
101
|
+
* not that formatting drifted.
|
|
102
|
+
*
|
|
69
103
|
* @param {string} cmd
|
|
70
104
|
* @param {string[]} args
|
|
71
|
-
* @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string
|
|
105
|
+
* @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean }} opts
|
|
72
106
|
* @returns {Promise<{ status: number }>}
|
|
73
107
|
*/
|
|
74
108
|
export function defaultGateRunner(cmd, args, opts = {}) {
|
|
75
|
-
const { cwd, signal, gateName, log, env } = opts;
|
|
109
|
+
const { cwd, signal, gateName, log, env, tolerateNoFilesProcessed } = opts;
|
|
76
110
|
const child = spawn(cmd, args, {
|
|
77
111
|
cwd,
|
|
78
112
|
shell: process.platform === 'win32',
|
|
@@ -85,13 +119,35 @@ export function defaultGateRunner(cmd, args, opts = {}) {
|
|
|
85
119
|
const prefix = gateName ? `[${gateName}] ` : '';
|
|
86
120
|
const emit =
|
|
87
121
|
typeof log === 'function' ? log : (m) => process.stdout.write(`${m}\n`);
|
|
88
|
-
|
|
89
|
-
|
|
122
|
+
// Capture the combined output only when we may need to inspect it for the
|
|
123
|
+
// biome "No files were processed" marker — otherwise the stream is purely
|
|
124
|
+
// piped through to the operator (no retained buffer).
|
|
125
|
+
let captured = '';
|
|
126
|
+
const tap = tolerateNoFilesProcessed
|
|
127
|
+
? (line) => {
|
|
128
|
+
captured += `${line}\n`;
|
|
129
|
+
emit(line);
|
|
130
|
+
}
|
|
131
|
+
: emit;
|
|
132
|
+
pipePrefixed(child.stdout, prefix, tap);
|
|
133
|
+
pipePrefixed(child.stderr, prefix, tap);
|
|
90
134
|
const detach = attachGateAbortHandler(child, signal);
|
|
91
135
|
return new Promise((resolve) => {
|
|
92
136
|
child.on('exit', (code, sig) => {
|
|
93
137
|
detach();
|
|
94
|
-
|
|
138
|
+
const status = gateExitCode(code, sig);
|
|
139
|
+
if (
|
|
140
|
+
status !== 0 &&
|
|
141
|
+
tolerateNoFilesProcessed &&
|
|
142
|
+
isBiomeNoFilesProcessed(captured)
|
|
143
|
+
) {
|
|
144
|
+
emit(
|
|
145
|
+
`${prefix}↳ biome processed zero files (all changed paths are config-ignored); treating as a clean skip`,
|
|
146
|
+
);
|
|
147
|
+
resolve({ status: 0 });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
resolve({ status });
|
|
95
151
|
});
|
|
96
152
|
child.on('error', () => {
|
|
97
153
|
detach();
|