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
|
@@ -6,43 +6,60 @@
|
|
|
6
6
|
* Epic-attached Stories so the experience matches — only the baseline
|
|
7
7
|
* ref changes (`main`, not `epic/<id>`).
|
|
8
8
|
*
|
|
9
|
-
* Standalone
|
|
10
|
-
* scope a `validation-evidence.json` under
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Standalone evidence keyspace (Story #4250). Standalone Stories have no
|
|
10
|
+
* parent Epic, so they cannot scope a `validation-evidence.json` under a
|
|
11
|
+
* `temp/epic-<id>/` tree. Rather than feed a null `epicId` into the
|
|
12
|
+
* Epic-keyed path (which structurally disabled the evidence cache and
|
|
13
|
+
* forced every re-run — base-sync conflict, review remediation, baseline
|
|
14
|
+
* absorb — to re-execute ALL gates including the coverage suite), the
|
|
15
|
+
* standalone close now passes `standalone: true`. `runCloseValidation`
|
|
16
|
+
* then anchors the cache on the Story id alone at
|
|
17
|
+
* `temp/standalone/stories/story-<id>/validation-evidence.json`, so a
|
|
18
|
+
* second close at unchanged HEAD short-circuits the already-passed gates.
|
|
15
19
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* the
|
|
20
|
+
* Format-autofix self-heal (Story #4250). The Epic path runs
|
|
21
|
+
* `runScopedFormatAutofix` before the check-only gates so benign JSON/YAML
|
|
22
|
+
* drift the formatter can fix is folded into a `fix(story-close):` commit
|
|
23
|
+
* rather than hard-failing the format gate. The standalone path now does
|
|
24
|
+
* the same, with `baseBranch` as the diff anchor and the Story worktree as
|
|
25
|
+
* the commit target.
|
|
19
26
|
*
|
|
20
|
-
* `runCloseValidation` and `
|
|
21
|
-
* dependencies so the parent CLI's cache-busted
|
|
22
|
-
* that mock the upstream module URLs.
|
|
27
|
+
* `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
|
|
28
|
+
* are accepted as injected dependencies so the parent CLI's cache-busted
|
|
29
|
+
* bindings win in tests that mock the upstream module URLs.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-validation/gates.js';
|
|
26
33
|
import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
|
|
27
34
|
import { Logger } from '../../../Logger.js';
|
|
35
|
+
import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
38
|
* Run the close-validation gate chain. Throws on first gate failure.
|
|
31
39
|
*
|
|
40
|
+
* Order (Story #4250): format-autofix self-heal → close-validation gates.
|
|
41
|
+
* The autofix step scopes the formatter to the `baseBranch...storyBranch`
|
|
42
|
+
* diff, commits any fix on the Story branch inside the Story worktree, and
|
|
43
|
+
* is best-effort — a missing `storyBranch` (resume/legacy callers) skips it
|
|
44
|
+
* with a log line rather than failing.
|
|
45
|
+
*
|
|
32
46
|
* Gates are built from the canonical resolved config (`buildDefaultGates`
|
|
33
47
|
* reads `project.commands` and `delivery.quality.gates.crap.enabled`); the
|
|
34
48
|
* `baseBranch` is forwarded as the gate `epicBranch` so the format gate's
|
|
35
|
-
* changed-file scope anchors on it.
|
|
49
|
+
* changed-file scope anchors on it. `standalone: true` routes the evidence
|
|
50
|
+
* cache to the storyId-anchored keyspace.
|
|
36
51
|
*
|
|
37
52
|
* @param {{
|
|
38
53
|
* cwd: string,
|
|
39
54
|
* worktreePath: string|null,
|
|
40
55
|
* config: object,
|
|
41
56
|
* baseBranch: string,
|
|
57
|
+
* storyBranch?: string,
|
|
42
58
|
* storyId: number,
|
|
43
59
|
* progress: (tag: string, msg: string) => void,
|
|
44
60
|
* runCloseValidation?: typeof defaultRunCloseValidation,
|
|
45
61
|
* buildDefaultGates?: typeof defaultBuildDefaultGates,
|
|
62
|
+
* runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
|
|
46
63
|
* }} args
|
|
47
64
|
*/
|
|
48
65
|
export async function runCloseValidationPhase({
|
|
@@ -50,11 +67,57 @@ export async function runCloseValidationPhase({
|
|
|
50
67
|
worktreePath,
|
|
51
68
|
config,
|
|
52
69
|
baseBranch,
|
|
70
|
+
storyBranch,
|
|
53
71
|
storyId,
|
|
54
72
|
progress,
|
|
55
73
|
runCloseValidation = defaultRunCloseValidation,
|
|
56
74
|
buildDefaultGates = defaultBuildDefaultGates,
|
|
75
|
+
runScopedFormatAutofix = defaultRunScopedFormatAutofix,
|
|
57
76
|
}) {
|
|
77
|
+
// Story #4250 — format-autofix self-heal before the check-only gates.
|
|
78
|
+
// Mirrors the Epic path (story-close/phases/gates.js): the formatter is
|
|
79
|
+
// scoped to the baseBranch...storyBranch diff, and any fix is committed on
|
|
80
|
+
// the Story branch in the Story worktree. Skipped (with a log) when no
|
|
81
|
+
// storyBranch is available so resume/legacy callers don't trip a throw.
|
|
82
|
+
if (storyBranch) {
|
|
83
|
+
progress(
|
|
84
|
+
'FORMAT',
|
|
85
|
+
`Running scoped format-autofix on ${baseBranch}...${storyBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
|
|
86
|
+
);
|
|
87
|
+
// Best-effort self-heal: a failure to even compute the diff (e.g. a
|
|
88
|
+
// missing ref) must never abort close — the format check gate downstream
|
|
89
|
+
// is the source of truth for "is the tree formatted". We log and proceed.
|
|
90
|
+
try {
|
|
91
|
+
const autofix = runScopedFormatAutofix({
|
|
92
|
+
cwd,
|
|
93
|
+
worktreePath,
|
|
94
|
+
storyId,
|
|
95
|
+
epicBranch: baseBranch,
|
|
96
|
+
storyBranch,
|
|
97
|
+
config,
|
|
98
|
+
logger: Logger,
|
|
99
|
+
});
|
|
100
|
+
if (autofix?.committed) {
|
|
101
|
+
progress(
|
|
102
|
+
'FORMAT',
|
|
103
|
+
`✅ Auto-applied format fix committed as ${autofix.sha} on ${storyBranch}.`,
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
progress(
|
|
107
|
+
'FORMAT',
|
|
108
|
+
`⏭ No format-autofix commit (${autofix?.reason ?? 'clean'}).`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
progress(
|
|
113
|
+
'FORMAT',
|
|
114
|
+
`⚠️ scoped format-autofix failed (close continues; format gate is authoritative): ${err?.message ?? err}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
progress('FORMAT', '⏭ Skipped scoped format-autofix (no story branch).');
|
|
119
|
+
}
|
|
120
|
+
|
|
58
121
|
progress(
|
|
59
122
|
'VALIDATE',
|
|
60
123
|
`Running close-validation gates against baseline ${baseBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
|
|
@@ -65,7 +128,10 @@ export async function runCloseValidationPhase({
|
|
|
65
128
|
gates: buildDefaultGates({ config, epicBranch: baseBranch }),
|
|
66
129
|
log: (m) => Logger.info(m),
|
|
67
130
|
storyId,
|
|
68
|
-
|
|
131
|
+
// Story #4250 — standalone storyId-anchored evidence keyspace. No
|
|
132
|
+
// epicId; the standalone flag routes the cache to
|
|
133
|
+
// temp/standalone/stories/story-<id>/validation-evidence.json.
|
|
134
|
+
standalone: true,
|
|
69
135
|
});
|
|
70
136
|
if (!validation.ok) {
|
|
71
137
|
const [first] = validation.failed;
|
|
@@ -67,6 +67,7 @@ async function runPrePushPhases({
|
|
|
67
67
|
worktreePath,
|
|
68
68
|
config,
|
|
69
69
|
baseBranch,
|
|
70
|
+
storyBranch,
|
|
70
71
|
storyId,
|
|
71
72
|
progress,
|
|
72
73
|
runCloseValidation,
|
|
@@ -157,6 +158,41 @@ async function releaseLease({
|
|
|
157
158
|
}
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Story #4257 — run a blocked-prone phase and, if it throws, release the
|
|
163
|
+
* assignee-lease best-effort BEFORE re-throwing the original error.
|
|
164
|
+
*
|
|
165
|
+
* The two recoverable-blocked close exits (base-sync conflict in
|
|
166
|
+
* `runBaseSyncPhase`, and a critical-blocker review halt in
|
|
167
|
+
* `openAndReviewPr`) throw before the clean-close lease release at the
|
|
168
|
+
* tail of `runSingleStoryClose`, stranding the operator's lease until its
|
|
169
|
+
* TTL expires. That fail-closed-refuses a different operator who picks up
|
|
170
|
+
* the blocked Story — exactly the hand-off case. Releasing here closes
|
|
171
|
+
* that gap.
|
|
172
|
+
*
|
|
173
|
+
* The original throw is preserved verbatim (per
|
|
174
|
+
* `rules/orchestration-error-handling.md` — throw, never `Logger.fatal`),
|
|
175
|
+
* so the CLI boundary still maps it to a non-zero exit; the lease release
|
|
176
|
+
* must not swallow it. `releaseLease` is itself best-effort and never
|
|
177
|
+
* throws, so it cannot mask the real failure. Fail-closed re-acquire
|
|
178
|
+
* semantics are preserved: `releaseStoryLease` no-ops when the operator no
|
|
179
|
+
* longer holds the claim, and a self-held re-acquire on a re-run still
|
|
180
|
+
* succeeds against the now-unclaimed ticket.
|
|
181
|
+
*
|
|
182
|
+
* @template T
|
|
183
|
+
* @param {() => Promise<T>} run The blocked-prone phase to execute.
|
|
184
|
+
* @param {{ provider: object, storyId: number, config: object, injectedReleaseLease?: Function }} leaseArgs
|
|
185
|
+
* @returns {Promise<T>}
|
|
186
|
+
*/
|
|
187
|
+
async function releaseLeaseOnBlock(run, leaseArgs) {
|
|
188
|
+
try {
|
|
189
|
+
return await run();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
await releaseLease(leaseArgs);
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
160
196
|
function closeResult({
|
|
161
197
|
storyId,
|
|
162
198
|
storyBranch,
|
|
@@ -236,27 +272,45 @@ export async function runSingleStoryClose({
|
|
|
236
272
|
config,
|
|
237
273
|
storyId: options.storyId,
|
|
238
274
|
});
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
275
|
+
// Story #4257 — the base-sync conflict and review-critical exits throw
|
|
276
|
+
// before the clean-close lease release at the tail of this function.
|
|
277
|
+
// Wrap both blocked-prone phases so the lease is released best-effort
|
|
278
|
+
// before the throw propagates; the original error is preserved.
|
|
279
|
+
const leaseArgs = {
|
|
244
280
|
provider,
|
|
245
|
-
worktreePath,
|
|
246
|
-
injectedSync,
|
|
247
|
-
injectedGitSpawn,
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
const { prUrl, prNumber } = await openAndReviewPr({
|
|
251
|
-
cwd: options.cwd,
|
|
252
|
-
story,
|
|
253
281
|
storyId: options.storyId,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
282
|
+
config,
|
|
283
|
+
injectedReleaseLease,
|
|
284
|
+
};
|
|
285
|
+
await releaseLeaseOnBlock(
|
|
286
|
+
() =>
|
|
287
|
+
runPrePushPhases({
|
|
288
|
+
...options,
|
|
289
|
+
config,
|
|
290
|
+
baseBranch,
|
|
291
|
+
storyBranch,
|
|
292
|
+
provider,
|
|
293
|
+
worktreePath,
|
|
294
|
+
injectedSync,
|
|
295
|
+
injectedGitSpawn,
|
|
296
|
+
}),
|
|
297
|
+
leaseArgs,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const { prUrl, prNumber } = await releaseLeaseOnBlock(
|
|
301
|
+
() =>
|
|
302
|
+
openAndReviewPr({
|
|
303
|
+
cwd: options.cwd,
|
|
304
|
+
story,
|
|
305
|
+
storyId: options.storyId,
|
|
306
|
+
storyBranch,
|
|
307
|
+
baseBranch,
|
|
308
|
+
provider,
|
|
309
|
+
injectedGh,
|
|
310
|
+
injectedRunCodeReview,
|
|
311
|
+
}),
|
|
312
|
+
leaseArgs,
|
|
313
|
+
);
|
|
260
314
|
const { autoMergeEnabled, autoMergeReason } = await runAutoMergePhase({
|
|
261
315
|
cwd: options.cwd,
|
|
262
316
|
prNumber,
|
|
@@ -284,12 +338,7 @@ export async function runSingleStoryClose({
|
|
|
284
338
|
progress,
|
|
285
339
|
WorktreeManager,
|
|
286
340
|
});
|
|
287
|
-
const leaseReleased = await releaseLease(
|
|
288
|
-
provider,
|
|
289
|
-
storyId: options.storyId,
|
|
290
|
-
config,
|
|
291
|
-
injectedReleaseLease,
|
|
292
|
-
});
|
|
341
|
+
const leaseReleased = await releaseLease(leaseArgs);
|
|
293
342
|
const result = closeResult({
|
|
294
343
|
storyId: options.storyId,
|
|
295
344
|
storyBranch,
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { Logger } from '../../../Logger.js';
|
|
22
|
+
import { hasInlineAcceptance } from '../../../story-init/task-graph-builder.js';
|
|
22
23
|
import { fetchChildTickets } from '../../../story-lifecycle.js';
|
|
23
24
|
import { createPhaseTimer } from '../../../util/phase-timer.js';
|
|
24
25
|
import {
|
|
@@ -197,7 +198,15 @@ export async function runStoryCloseLocked(args) {
|
|
|
197
198
|
logger: Logger,
|
|
198
199
|
});
|
|
199
200
|
|
|
200
|
-
|
|
201
|
+
// Story #4251 — mirror the init-side short-circuit: a 2-tier Story (inline
|
|
202
|
+
// acceptance on its body) has no children, so skip the `fetchChildTickets`
|
|
203
|
+
// probe (empty sub-issues GraphQL query + never-matching `/search/issues`
|
|
204
|
+
// scan) entirely. The cascade target is just the Story itself. A body
|
|
205
|
+
// lacking inline acceptance still enumerates children for legacy / Epic
|
|
206
|
+
// callers.
|
|
207
|
+
const tasks = hasInlineAcceptance(story?.body)
|
|
208
|
+
? []
|
|
209
|
+
: await fetchChildTickets(provider, storyId);
|
|
201
210
|
provider.primeTicketCache([story, ...tasks]);
|
|
202
211
|
progress('TICKETS', `Found ${tasks.length} child ticket(s)`);
|
|
203
212
|
|
|
@@ -1,6 +1,49 @@
|
|
|
1
|
+
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
1
2
|
import { collectStoryAssumptionEntries } from './file-assumptions.js';
|
|
2
3
|
import { computeStoryReachability } from './story-reachability.js';
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a Story so its `body` is the structured object the conflict
|
|
7
|
+
* passes scan, mirroring `validateAcFreshness` /
|
|
8
|
+
* `collectStoryAssumptionEntries` (Story #3302) and the sizing gate's
|
|
9
|
+
* `resolveStoryBody` (Story #4271).
|
|
10
|
+
*
|
|
11
|
+
* The decomposer emits `body` as the canonical serialized **string**, but
|
|
12
|
+
* the conflict passes (`indexConsumers`, `indexAssumptionEntries`,
|
|
13
|
+
* `computeMissingBddScaffoldFindings`, the sibling-create scan in
|
|
14
|
+
* `computeRegistryFindings`, and the legacy-bullet branch of
|
|
15
|
+
* `collectStoryProducerPaths`) historically read `story.body` only when it
|
|
16
|
+
* was already an object — so on the production string shape the
|
|
17
|
+
* `implicit-cross-story-dep`, `fan-out`, registry, and `missing-bdd-scaffold`
|
|
18
|
+
* findings emitted nothing. Parsing the body once at the entry point and
|
|
19
|
+
* threading the normalized Story through every pass restores parity.
|
|
20
|
+
*
|
|
21
|
+
* `collectStoryAssumptionEntries` already parses string bodies itself, so a
|
|
22
|
+
* normalized object body round-trips through it unchanged. The returned Story
|
|
23
|
+
* keeps every other field (notably `slug` and `depends_on`) intact.
|
|
24
|
+
*
|
|
25
|
+
* - **string body** → parsed via `parseStoryBody`; an unparseable string
|
|
26
|
+
* yields `body: null` (the passes degrade to "no structured signal",
|
|
27
|
+
* never throw mid-validation).
|
|
28
|
+
* - **object body** → returned verbatim.
|
|
29
|
+
* - **null / other** → `body: null`.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} story
|
|
32
|
+
* @returns {object} A shallow clone of `story` with a structured `body`.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeStoryBody(story) {
|
|
35
|
+
const body = story?.body;
|
|
36
|
+
if (typeof body === 'string') {
|
|
37
|
+
if (body.trim().length === 0) return { ...story, body: null };
|
|
38
|
+
try {
|
|
39
|
+
return { ...story, body: parseStoryBody(body).body };
|
|
40
|
+
} catch {
|
|
41
|
+
return { ...story, body: null };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return story;
|
|
45
|
+
}
|
|
46
|
+
|
|
4
47
|
/**
|
|
5
48
|
* Cross-Story path-conflict & implicit-dependency findings.
|
|
6
49
|
*
|
|
@@ -661,7 +704,11 @@ function computeFanOutFindings({
|
|
|
661
704
|
*/
|
|
662
705
|
export function computeConflictFindings({ stories, policy } = {}) {
|
|
663
706
|
const merged = { ...DEFAULT_POLICY, ...(policy ?? {}) };
|
|
664
|
-
|
|
707
|
+
// Story #4271: normalize every Story's body to its structured object form
|
|
708
|
+
// once, up front, so the canonical serialized **string** shape the
|
|
709
|
+
// decomposer emits is scanned at parity with the pre-serialize object
|
|
710
|
+
// shape across every conflict pass.
|
|
711
|
+
const storyList = (stories ?? []).map(normalizeStoryBody);
|
|
665
712
|
const producers = indexProducers(storyList);
|
|
666
713
|
const consumers = indexConsumers(storyList, producers);
|
|
667
714
|
const reach = computeStoryReachability(storyList);
|
|
@@ -30,6 +30,62 @@
|
|
|
30
30
|
* decomposer prompt and authoring SKILL.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a Story's `body` to the structured object the sizing layers
|
|
37
|
+
* score, mirroring `validateAcFreshness` / `collectStoryAssumptionEntries`
|
|
38
|
+
* (Story #3302) and `resolveStructuredBody` in `task-body-validator.js`.
|
|
39
|
+
*
|
|
40
|
+
* The decomposer emits `body` as the canonical serialized **string**
|
|
41
|
+
* (`decomposer-prompts.js`), but the sizing layers historically read
|
|
42
|
+
* `story.body` only when it was already an object — so on the production
|
|
43
|
+
* string shape `changes` / `wide` fell through to empty and the `hardFiles`
|
|
44
|
+
* / unanchored-constant backstops emitted nothing. A defensive parse here
|
|
45
|
+
* restores parity:
|
|
46
|
+
* - **string body** → parsed via `parseStoryBody`; an unparseable string
|
|
47
|
+
* yields `null` (the gate degrades to "no structured signal", never
|
|
48
|
+
* throws mid-validation).
|
|
49
|
+
* - **object body** → returned verbatim (a caller may pass the
|
|
50
|
+
* pre-serialize shape directly; `parse` round-trips it).
|
|
51
|
+
* - **null / other** → `null`.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} story
|
|
54
|
+
* @returns {object|null}
|
|
55
|
+
*/
|
|
56
|
+
function resolveStoryBody(story) {
|
|
57
|
+
const body = story?.body;
|
|
58
|
+
if (typeof body === 'string') {
|
|
59
|
+
if (body.trim().length === 0) return null;
|
|
60
|
+
try {
|
|
61
|
+
return parseStoryBody(body).body;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (body !== null && typeof body === 'object') return body;
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the acceptance-criteria array for a Story, preferring the
|
|
72
|
+
* authoritative top-level `story.acceptance` (the binding contract the
|
|
73
|
+
* validator already requires every Story to carry inline) over the
|
|
74
|
+
* structured body's `acceptance`. Reading the top-level array makes the
|
|
75
|
+
* acceptance ceiling correct regardless of body shape — a string body whose
|
|
76
|
+
* structured `acceptance` is only reachable after a parse, or an object body
|
|
77
|
+
* (Story #4271). Falls back to `resolveStoryBody(story).acceptance` only when
|
|
78
|
+
* the top-level array is absent.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} story
|
|
81
|
+
* @returns {unknown[]}
|
|
82
|
+
*/
|
|
83
|
+
function resolveAcceptance(story) {
|
|
84
|
+
if (Array.isArray(story?.acceptance)) return story.acceptance;
|
|
85
|
+
const body = resolveStoryBody(story);
|
|
86
|
+
return Array.isArray(body?.acceptance) ? body.acceptance : [];
|
|
87
|
+
}
|
|
88
|
+
|
|
33
89
|
export const DEFAULT_TASK_SIZING = Object.freeze({
|
|
34
90
|
// Typical-Story warning thresholds (soft — emit advisory findings).
|
|
35
91
|
// Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
|
|
@@ -71,6 +127,39 @@ export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
|
|
|
71
127
|
'**Single-consumer merge rule.** A Story whose only consumer is one sibling Story should be **merged into that sibling** rather than emitted separately — a single-consumer downstream slice is not its own unit of work.',
|
|
72
128
|
});
|
|
73
129
|
|
|
130
|
+
/**
|
|
131
|
+
* `AUTHORING_ALTITUDE_GUIDANCE` is the **single source of truth** for the
|
|
132
|
+
* binding-vs-advisory authoring altitude (Epic #4131 F8) and the New-File
|
|
133
|
+
* Contract (Story #4272). It is stated ONCE here and consumed by BOTH the
|
|
134
|
+
* decomposer prompt template
|
|
135
|
+
* (`.agents/scripts/lib/templates/decomposer-prompts.js`, which interpolates
|
|
136
|
+
* the strings verbatim into the rendered system prompt) AND the authoring
|
|
137
|
+
* SKILL (`.agents/skills/core/epic-plan-decompose-author/SKILL.md`, whose
|
|
138
|
+
* prose mirrors these sentences). The SKILL cannot import JS, so the
|
|
139
|
+
* `ticket-decomposer` prompt test asserts the canonical phrasing on both
|
|
140
|
+
* surfaces — a divergent restatement fails that gate. This reuses the #3777
|
|
141
|
+
* single-source mechanism (one constant, two surfaces, drift-gated by tests).
|
|
142
|
+
*
|
|
143
|
+
* The altitude: `acceptance[]` / `verify[]` are the **binding contract** (the
|
|
144
|
+
* sole definition of "done"); `changes[]` / `references[]` are an **advisory
|
|
145
|
+
* implementation sketch** the executor MAY revise. Author acceptance to assert
|
|
146
|
+
* the **outcome** independent of file layout — never pin an incidental helper
|
|
147
|
+
* name or private path into an acceptance item. The advisory sketch is still
|
|
148
|
+
* validated (base-branch probes, New-File Contract) and never licenses
|
|
149
|
+
* skipping `acceptance[]` / `verify[]` or any `rules/security-baseline.md` MUST.
|
|
150
|
+
*/
|
|
151
|
+
export const AUTHORING_ALTITUDE_GUIDANCE = Object.freeze({
|
|
152
|
+
// The binding-vs-advisory altitude statement.
|
|
153
|
+
altitude:
|
|
154
|
+
'**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.',
|
|
155
|
+
// The advisory-does-not-mean-unvalidated caveat.
|
|
156
|
+
advisoryCaveat:
|
|
157
|
+
"**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.",
|
|
158
|
+
// The New-File Contract.
|
|
159
|
+
newFileContract:
|
|
160
|
+
'**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.',
|
|
161
|
+
});
|
|
162
|
+
|
|
74
163
|
/**
|
|
75
164
|
* Configuration-constant phrase patterns the `unanchored-constant` heuristic
|
|
76
165
|
* scans Story acceptance criteria for. Each entry matches the *kind* of
|
|
@@ -143,8 +232,11 @@ function makeUnanchoredConstant(slug, criterion) {
|
|
|
143
232
|
*/
|
|
144
233
|
function computeUnanchoredConstantFindings(story) {
|
|
145
234
|
const out = [];
|
|
146
|
-
|
|
147
|
-
|
|
235
|
+
// Read the authoritative top-level `story.acceptance` (the binding
|
|
236
|
+
// contract), falling back to the structured body's acceptance only when the
|
|
237
|
+
// top-level array is absent. This is correct regardless of body shape —
|
|
238
|
+
// string or object (Story #4271).
|
|
239
|
+
const acceptance = resolveAcceptance(story);
|
|
148
240
|
for (const item of acceptance) {
|
|
149
241
|
const criterion = String(item ?? '');
|
|
150
242
|
if (CONCRETE_VALUE_RE.test(criterion)) continue;
|
|
@@ -158,6 +250,47 @@ function computeUnanchoredConstantFindings(story) {
|
|
|
158
250
|
return out;
|
|
159
251
|
}
|
|
160
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Soft, advisory `missing-reason-to-exist` finding (Story #4273). Surfaces a
|
|
255
|
+
* Story whose body carries no non-empty `reason_to_exist` — the
|
|
256
|
+
* machine-checkable form of the cohesion rule (**one Story = one coherent
|
|
257
|
+
* change with one reason to exist**). `reason_to_exist` is marked REQUIRED by
|
|
258
|
+
* the decomposer prompt and is the field the `epic-plan-consolidate` critic
|
|
259
|
+
* gates on, but that critic is an honor-system LLM check with no runtime
|
|
260
|
+
* backstop. This deterministic finding is the cheap backstop.
|
|
261
|
+
*
|
|
262
|
+
* Severity is `soft` (not a hard reject) so existing `reason_to_exist`-less
|
|
263
|
+
* standalone / audit Stories are surfaced as an advisory nudge rather than
|
|
264
|
+
* blocked — matching the `unanchored-constant` finding's advisory contract.
|
|
265
|
+
*/
|
|
266
|
+
function makeMissingReasonToExist(slug) {
|
|
267
|
+
return {
|
|
268
|
+
kind: 'missing-reason-to-exist',
|
|
269
|
+
severity: 'soft',
|
|
270
|
+
ticketSlug: slug,
|
|
271
|
+
message:
|
|
272
|
+
'Story body carries no non-empty `reason_to_exist`. State the single coherent reason this Story exists in one sentence (the machine-checkable form of "one Story = one coherent change with one reason to exist"), encoded as the `reason_to_exist` field of the body meta comment.',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Emit a soft `missing-reason-to-exist` finding when the Story body resolves
|
|
278
|
+
* to no non-empty `reason_to_exist`. The body parser
|
|
279
|
+
* (`story-body/story-body.js`) already normalizes `reason_to_exist` to a
|
|
280
|
+
* non-empty trimmed string or `null`, so reading `body.reason_to_exist` after
|
|
281
|
+
* `resolveStoryBody` is correct regardless of body shape — a serialized
|
|
282
|
+
* **string** body (the production decomposer shape) or an object body
|
|
283
|
+
* (Story #4271). A body that fails to parse resolves to `null` and trips the
|
|
284
|
+
* finding, which is the right advisory signal: the author should re-emit a
|
|
285
|
+
* parseable body carrying the field. One finding per Story.
|
|
286
|
+
*/
|
|
287
|
+
function computeMissingReasonToExistFinding(story) {
|
|
288
|
+
const body = resolveStoryBody(story);
|
|
289
|
+
const reason = body?.reason_to_exist;
|
|
290
|
+
const hasReason = typeof reason === 'string' && reason.trim().length > 0;
|
|
291
|
+
return hasReason ? [] : [makeMissingReasonToExist(story.slug)];
|
|
292
|
+
}
|
|
293
|
+
|
|
161
294
|
/**
|
|
162
295
|
* Returns true when a `changes[]` entry is a glob pattern. Handles both the
|
|
163
296
|
* canonical PathEntry object form `{ path, assumption }` and legacy strings.
|
|
@@ -253,8 +386,12 @@ function isDeclaredWide(wide) {
|
|
|
253
386
|
*/
|
|
254
387
|
function computeStorySizingFindings(story, sizing) {
|
|
255
388
|
const out = [];
|
|
256
|
-
|
|
257
|
-
|
|
389
|
+
// Story #4271: normalize the body so the canonical serialized **string**
|
|
390
|
+
// shape the decomposer emits is scored at parity with the pre-serialize
|
|
391
|
+
// object shape. The acceptance ceiling reads the authoritative top-level
|
|
392
|
+
// `story.acceptance` (the binding contract), not `body.acceptance`.
|
|
393
|
+
const body = resolveStoryBody(story);
|
|
394
|
+
const acceptance = resolveAcceptance(story);
|
|
258
395
|
const changes = Array.isArray(body?.changes) ? body.changes : [];
|
|
259
396
|
const declaredWide = isDeclaredWide(body?.wide ?? null);
|
|
260
397
|
|
|
@@ -263,6 +400,13 @@ function computeStorySizingFindings(story, sizing) {
|
|
|
263
400
|
// the numeric sizing layers below — purely an authoring nudge.
|
|
264
401
|
out.push(...computeUnanchoredConstantFindings(story));
|
|
265
402
|
|
|
403
|
+
// Soft, advisory: flag a Story body that carries no non-empty
|
|
404
|
+
// `reason_to_exist` (Story #4273). The decomposer prompt marks the field
|
|
405
|
+
// REQUIRED and the consolidate critic gates on it, but that critic has no
|
|
406
|
+
// runtime backstop — this deterministic finding is the cheap backstop.
|
|
407
|
+
// Independent of the numeric sizing layers below.
|
|
408
|
+
out.push(...computeMissingReasonToExistFinding(story));
|
|
409
|
+
|
|
266
410
|
// Acceptance ceiling + soft warn.
|
|
267
411
|
if (acceptance.length > sizing.maxAcceptance) {
|
|
268
412
|
out.push(
|
|
@@ -179,6 +179,7 @@ async function syncProjectStatusColumn(
|
|
|
179
179
|
ticketId,
|
|
180
180
|
newState,
|
|
181
181
|
_makeColumnSync,
|
|
182
|
+
config,
|
|
182
183
|
) {
|
|
183
184
|
try {
|
|
184
185
|
let sync;
|
|
@@ -191,10 +192,15 @@ async function syncProjectStatusColumn(
|
|
|
191
192
|
// The instance's `_meta` cache survives across label transitions
|
|
192
193
|
// so the invariant project metadata (projectId, fieldId, options)
|
|
193
194
|
// is only fetched once per process run. Story #3661.
|
|
195
|
+
//
|
|
196
|
+
// Story #4252 — `config` is threaded so the on-disk board-metadata
|
|
197
|
+
// cache lands under the project's configured tempRoot. It is read at
|
|
198
|
+
// construction only; the registry caches the first instance per
|
|
199
|
+
// provider, so a later transition's config is intentionally ignored.
|
|
194
200
|
if (!_columnSyncRegistry.has(provider)) {
|
|
195
201
|
_columnSyncRegistry.set(
|
|
196
202
|
provider,
|
|
197
|
-
new ColumnSync({ provider, logger: Logger }),
|
|
203
|
+
new ColumnSync({ provider, logger: Logger, config }),
|
|
198
204
|
);
|
|
199
205
|
}
|
|
200
206
|
sync = _columnSyncRegistry.get(provider);
|
|
@@ -363,6 +369,7 @@ export async function transitionTicketState(
|
|
|
363
369
|
ticketId,
|
|
364
370
|
newState,
|
|
365
371
|
opts._makeColumnSync,
|
|
372
|
+
opts.config,
|
|
366
373
|
);
|
|
367
374
|
|
|
368
375
|
// Automatically trigger upward cascade on every transition (Story
|