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
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* acceptance: string[], // observable criteria
|
|
16
16
|
* verify: string[], // exact commands / tier annotation
|
|
17
17
|
* references: PathEntry[], // read-only paths (optional)
|
|
18
|
+
* non_goals: string[], // negative-scope bullets (optional, advisory)
|
|
18
19
|
* wide: { reason } | null,// declared-wide footprint (optional)
|
|
19
20
|
* reason_to_exist: string | null, // one-sentence cohesion reason (optional)
|
|
20
21
|
* depends_on: string[], // blocker story slugs or #ids
|
|
@@ -69,6 +70,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
|
|
|
69
70
|
* @property {string[]} acceptance - Observable acceptance criteria.
|
|
70
71
|
* @property {string[]} verify - Exact commands with tier annotation.
|
|
71
72
|
* @property {PathEntry[]} references - Read-only paths (may be empty).
|
|
73
|
+
* @property {string[]} non_goals - Negative-scope bullets (advisory; may be empty).
|
|
72
74
|
* @property {{ reason: string }|null} wide - Declared-wide footprint (reason), or null.
|
|
73
75
|
* @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
|
|
74
76
|
* @property {string[]} depends_on - Blocking story slugs / issue refs.
|
|
@@ -89,6 +91,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
|
|
|
89
91
|
* @property {boolean} hasAcceptanceSection - Whether a `## Acceptance` section was found.
|
|
90
92
|
* @property {boolean} hasVerifySection - Whether a `## Verify` section was found.
|
|
91
93
|
* @property {boolean} hasReferencesSection - Whether a `## References` section was found.
|
|
94
|
+
* @property {boolean} hasNonGoalsSection - Whether a `## Non-Goals` section was found.
|
|
92
95
|
* @property {boolean} isLegacyStringBody - True when no structured sections were found.
|
|
93
96
|
*/
|
|
94
97
|
|
|
@@ -125,13 +128,16 @@ export class StoryBodyParseError extends Error {
|
|
|
125
128
|
// Section heading map
|
|
126
129
|
// ---------------------------------------------------------------------------
|
|
127
130
|
|
|
128
|
-
// Heading text → body field name
|
|
131
|
+
// Heading text → body field name. Keys are normalized: lower-cased with `-`
|
|
132
|
+
// folded to `_` (see splitSections), so the hyphenated `## Non-Goals` heading
|
|
133
|
+
// maps to the `non_goals` field.
|
|
129
134
|
const HEADING_TO_FIELD = new Map([
|
|
130
135
|
['goal', 'goal'],
|
|
131
136
|
['changes', 'changes'],
|
|
132
137
|
['acceptance', 'acceptance'],
|
|
133
138
|
['verify', 'verify'],
|
|
134
139
|
['references', 'references'],
|
|
140
|
+
['non_goals', 'non_goals'],
|
|
135
141
|
]);
|
|
136
142
|
|
|
137
143
|
// ---------------------------------------------------------------------------
|
|
@@ -346,15 +352,53 @@ function splitSections(markdown) {
|
|
|
346
352
|
// Forms (Story #4227) render every field label as a level-3 heading
|
|
347
353
|
// (`### Goal`), not the level-2 the canonical serializer emits, so the
|
|
348
354
|
// parser accepts both levels. Any other heading depth is ignored.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
355
|
+
//
|
|
356
|
+
// The token class is `[\w-]+` (not bare `\w+`) so a single hyphenated
|
|
357
|
+
// heading word — the canonical `## Non-Goals` negative-scope section —
|
|
358
|
+
// matches as one token. The captured name is normalized (lower-cased,
|
|
359
|
+
// `-` folded to `_`) before the HEADING_TO_FIELD lookup, so `Non-Goals`
|
|
360
|
+
// resolves to the `non_goals` field. Multi-word headings that contain a
|
|
361
|
+
// space (`## Out of Scope`, `## Agent Prompts`) still do NOT match this
|
|
362
|
+
// single-token shape — they fall through to the catch-all heading branch
|
|
363
|
+
// below, which closes the open section. The chosen canonical spelling is
|
|
364
|
+
// therefore the hyphenated single token `## Non-Goals`.
|
|
365
|
+
const fieldHeadingMatch = line.match(/^#{2,3}\s+([\w-]+)\s*$/i);
|
|
366
|
+
if (fieldHeadingMatch) {
|
|
367
|
+
const name = fieldHeadingMatch[1].toLowerCase().replace(/-/g, '_');
|
|
352
368
|
if (HEADING_TO_FIELD.has(name)) {
|
|
353
369
|
inPreamble = false;
|
|
354
370
|
currentSection = name;
|
|
355
371
|
if (!sections.has(currentSection)) sections.set(currentSection, []);
|
|
356
372
|
continue;
|
|
357
373
|
}
|
|
374
|
+
// A heading that matches the canonical `## Word` shape but is not a
|
|
375
|
+
// recognized field name (e.g. a trailing free-form `## Notes`) closes
|
|
376
|
+
// the currently-open section. Without this reset, the unknown heading
|
|
377
|
+
// and its bullets bleed into the previously-recognized section,
|
|
378
|
+
// silently corrupting `verify[]` / `acceptance[]`. We do NOT re-enter
|
|
379
|
+
// the preamble (`inPreamble` stays false), so a later recognized
|
|
380
|
+
// heading still registers normally; we only stop appending to the
|
|
381
|
+
// closed section. The heading line and its body are dropped from all
|
|
382
|
+
// sections. (Multi-word free-form headings like `## Out of Scope` —
|
|
383
|
+
// with internal spaces — do not match the `[\w-]+` single-token shape
|
|
384
|
+
// and reach this branch too. The hyphenated single-token canonical
|
|
385
|
+
// negative-scope heading is `## Non-Goals`, which IS recognized above.)
|
|
386
|
+
currentSection = null;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Any other markdown heading (`## …` / `### …`, single- or multi-word)
|
|
391
|
+
// that is NOT a canonical field heading TERMINATES the current structured
|
|
392
|
+
// section. Trailing extended content a producer appends after the
|
|
393
|
+
// canonical block — `audit-to-stories`'s `## Agent Prompts` / `## Context`
|
|
394
|
+
// / `## Sequencing` blocks, for instance — must not bleed into the last
|
|
395
|
+
// structured section's bullet list (Story #4270). Without this, those
|
|
396
|
+
// lines were silently absorbed into `verify[]` / `acceptance[]`. The
|
|
397
|
+
// heading and everything under it is dropped from structured parsing
|
|
398
|
+
// (it is extended, non-canonical markdown).
|
|
399
|
+
if (!inPreamble && /^#{1,6}\s+\S/.test(line)) {
|
|
400
|
+
currentSection = null;
|
|
401
|
+
continue;
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
// The trailing `<!-- meta: {...} -->` block is machine metadata, not
|
|
@@ -407,6 +451,7 @@ function parseLegacyStringBody(input, preamble, footer) {
|
|
|
407
451
|
acceptance: [],
|
|
408
452
|
verify: [],
|
|
409
453
|
references: [],
|
|
454
|
+
non_goals: [],
|
|
410
455
|
wide: null,
|
|
411
456
|
reason_to_exist: null,
|
|
412
457
|
depends_on: extractBlockedBy(footer),
|
|
@@ -421,6 +466,7 @@ function parseLegacyStringBody(input, preamble, footer) {
|
|
|
421
466
|
hasAcceptanceSection: false,
|
|
422
467
|
hasVerifySection: false,
|
|
423
468
|
hasReferencesSection: false,
|
|
469
|
+
hasNonGoalsSection: false,
|
|
424
470
|
isLegacyStringBody: true,
|
|
425
471
|
},
|
|
426
472
|
};
|
|
@@ -522,6 +568,7 @@ export function parse(input) {
|
|
|
522
568
|
const hasAcceptanceSection = sections.has('acceptance');
|
|
523
569
|
const hasVerifySection = sections.has('verify');
|
|
524
570
|
const hasReferencesSection = sections.has('references');
|
|
571
|
+
const hasNonGoalsSection = sections.has('non_goals');
|
|
525
572
|
|
|
526
573
|
// If no structured sections found, treat as legacy string body.
|
|
527
574
|
const isLegacyStringBody =
|
|
@@ -545,6 +592,7 @@ export function parse(input) {
|
|
|
545
592
|
sections.get('references') ?? [],
|
|
546
593
|
warnings,
|
|
547
594
|
);
|
|
595
|
+
const non_goals = parseTextListSection(sections.get('non_goals') ?? []);
|
|
548
596
|
const dependsOn = extractBlockedBy(footer);
|
|
549
597
|
|
|
550
598
|
// --- Recover wide / estimated_test_files from the meta block ---
|
|
@@ -566,6 +614,7 @@ export function parse(input) {
|
|
|
566
614
|
acceptance,
|
|
567
615
|
verify,
|
|
568
616
|
references,
|
|
617
|
+
non_goals,
|
|
569
618
|
wide,
|
|
570
619
|
reason_to_exist,
|
|
571
620
|
depends_on: dependsOn,
|
|
@@ -581,6 +630,7 @@ export function parse(input) {
|
|
|
581
630
|
hasAcceptanceSection,
|
|
582
631
|
hasVerifySection,
|
|
583
632
|
hasReferencesSection,
|
|
633
|
+
hasNonGoalsSection,
|
|
584
634
|
isLegacyStringBody: false,
|
|
585
635
|
},
|
|
586
636
|
};
|
|
@@ -625,6 +675,11 @@ function parseStructuredObject(obj) {
|
|
|
625
675
|
if (entry !== null) references.push(entry);
|
|
626
676
|
}
|
|
627
677
|
|
|
678
|
+
// non_goals (advisory negative-scope bullets)
|
|
679
|
+
const non_goals = Array.isArray(obj.non_goals)
|
|
680
|
+
? obj.non_goals.filter((n) => typeof n === 'string' && n.trim().length > 0)
|
|
681
|
+
: [];
|
|
682
|
+
|
|
628
683
|
const wide = normalizeWide(obj.wide);
|
|
629
684
|
const reason_to_exist = normalizeReasonToExist(obj.reason_to_exist);
|
|
630
685
|
|
|
@@ -650,6 +705,7 @@ function parseStructuredObject(obj) {
|
|
|
650
705
|
acceptance,
|
|
651
706
|
verify,
|
|
652
707
|
references,
|
|
708
|
+
non_goals,
|
|
653
709
|
wide,
|
|
654
710
|
reason_to_exist,
|
|
655
711
|
depends_on,
|
|
@@ -665,6 +721,7 @@ function parseStructuredObject(obj) {
|
|
|
665
721
|
hasAcceptanceSection: 'acceptance' in obj,
|
|
666
722
|
hasVerifySection: 'verify' in obj,
|
|
667
723
|
hasReferencesSection: 'references' in obj,
|
|
724
|
+
hasNonGoalsSection: 'non_goals' in obj,
|
|
668
725
|
isLegacyStringBody: false,
|
|
669
726
|
},
|
|
670
727
|
};
|
|
@@ -689,7 +746,7 @@ function serializePathEntry(entry) {
|
|
|
689
746
|
/**
|
|
690
747
|
* Descriptor table for the human-readable Story-body sections, in canonical
|
|
691
748
|
* emit order (`## Goal`, `## Changes`, `## Acceptance`, `## Verify`,
|
|
692
|
-
* `## References`). Each descriptor reads one body field and returns the
|
|
749
|
+
* `## References`, `## Non-Goals`). Each descriptor reads one body field and returns the
|
|
693
750
|
* section's markdown block when the field is present and non-empty, or `null`
|
|
694
751
|
* to omit the section.
|
|
695
752
|
*
|
|
@@ -735,6 +792,18 @@ const SERIALIZE_SECTIONS = [
|
|
|
735
792
|
? `## References\n${references.map((r) => `- ${serializePathEntry(r)}`).join('\n')}`
|
|
736
793
|
: null,
|
|
737
794
|
},
|
|
795
|
+
{
|
|
796
|
+
// Advisory negative-scope bullets. Rendered as the hyphenated canonical
|
|
797
|
+
// `## Non-Goals` heading (the spelling the parser's widened
|
|
798
|
+
// `[\w-]+` field-heading regex recognizes). Render-when-non-empty: an
|
|
799
|
+
// empty or absent `non_goals` emits nothing, so every pre-existing body
|
|
800
|
+
// round-trips byte-identically.
|
|
801
|
+
field: 'non_goals',
|
|
802
|
+
render: (nonGoals) =>
|
|
803
|
+
Array.isArray(nonGoals) && nonGoals.length > 0
|
|
804
|
+
? `## Non-Goals\n${nonGoals.map((n) => `- ${n}`).join('\n')}`
|
|
805
|
+
: null,
|
|
806
|
+
},
|
|
738
807
|
];
|
|
739
808
|
|
|
740
809
|
/**
|
|
@@ -793,8 +862,8 @@ function serializeFooter(body, opts) {
|
|
|
793
862
|
* format written to GitHub issue bodies.
|
|
794
863
|
*
|
|
795
864
|
* The output matches the section order the spec-renderer uses:
|
|
796
|
-
* `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References
|
|
797
|
-
* (omitted when empty).
|
|
865
|
+
* `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References`,
|
|
866
|
+
* `## Non-Goals` (each omitted when empty).
|
|
798
867
|
*
|
|
799
868
|
* `wide`, `reason_to_exist`, and `estimated_test_files` are emitted as a
|
|
800
869
|
* fenced `<!-- meta -->` comment block so round-trips preserve them without
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
classifyBranchSeed,
|
|
28
28
|
ensureEpicBranch,
|
|
29
29
|
ensureEpicBranchRef,
|
|
30
|
+
seedStoryBranchRef,
|
|
30
31
|
} from '../git-branch-lifecycle.js';
|
|
31
32
|
import { gitSpawn } from '../git-utils.js';
|
|
32
33
|
import { Logger } from '../Logger.js';
|
|
@@ -186,7 +187,10 @@ export function ensureStoryBranchSeed({
|
|
|
186
187
|
progress = defaultProgress(),
|
|
187
188
|
git,
|
|
188
189
|
}) {
|
|
189
|
-
const spawn =
|
|
190
|
+
const spawn =
|
|
191
|
+
git?.spawn != null
|
|
192
|
+
? (args) => git.spawn(...args)
|
|
193
|
+
: (args) => gitSpawn(mainCwd, ...args);
|
|
190
194
|
const existsLocally =
|
|
191
195
|
git?.existsLocally ?? ((b) => branchExistsLocally(b, mainCwd));
|
|
192
196
|
// `ensureStoryBranchSeed` is always called after `fetchMainRefs` in
|
|
@@ -195,49 +199,31 @@ export function ensureStoryBranchSeed({
|
|
|
195
199
|
const existsRemotely =
|
|
196
200
|
git?.existsRemotely ?? ((b) => branchExistsViaTrackingRef(b, mainCwd));
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
// The seed-action switch shell is single-homed in `seedStoryBranchRef`
|
|
203
|
+
// (Story #4255). The Epic path runs under concurrent wave dispatch, so it
|
|
204
|
+
// opts into `swallowCreateRace: true` to treat a lost probe→create race
|
|
205
|
+
// (`git branch` exits "already exists") as reuse (Story #3482) — the ref
|
|
206
|
+
// exists, which is exactly the post-condition this function guarantees.
|
|
207
|
+
// No `fetchError` is supplied: the fetch exit status is intentionally not
|
|
208
|
+
// inspected here (the worktree bootstrap re-checks the ref downstream).
|
|
209
|
+
seedStoryBranchRef({
|
|
210
|
+
storyBranch,
|
|
211
|
+
baseRef: epicBranch,
|
|
212
|
+
swallowCreateRace: true,
|
|
213
|
+
spawn,
|
|
214
|
+
existsLocally,
|
|
215
|
+
existsRemotely,
|
|
216
|
+
progress,
|
|
217
|
+
messages: {
|
|
218
|
+
reuse: (b) => `Reusing existing story branch ref: ${b} (no re-seed)`,
|
|
219
|
+
fetch: (b) => `Fetching remote story branch: ${b}`,
|
|
220
|
+
create: (b, ref) => `Creating story branch ref: ${b} from ${ref}`,
|
|
221
|
+
createRace: (b) =>
|
|
222
|
+
`Story branch ref ${b} already exists (created concurrently) — reusing.`,
|
|
223
|
+
createError: (b, ref, stderr) =>
|
|
224
|
+
`ensureStoryBranchSeed: failed to create ${b} from ${ref}: ${stderr}`,
|
|
225
|
+
},
|
|
201
226
|
});
|
|
202
|
-
if (action === 'none') {
|
|
203
|
-
// Story #3482 — a pre-existing `story-<id>` ref is reuse, not an error.
|
|
204
|
-
// The worktree bootstrap below seeds onto whatever the ref already points
|
|
205
|
-
// at (resuming a partially-implemented Story), so seeding is a no-op here.
|
|
206
|
-
progress(
|
|
207
|
-
'GIT',
|
|
208
|
-
`Reusing existing story branch ref: ${storyBranch} (no re-seed)`,
|
|
209
|
-
);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
if (action === 'fetch') {
|
|
213
|
-
progress('GIT', `Fetching remote story branch: ${storyBranch}`);
|
|
214
|
-
spawn('fetch', 'origin', `${storyBranch}:${storyBranch}`);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
progress(
|
|
218
|
-
'GIT',
|
|
219
|
-
`Creating story branch ref: ${storyBranch} from ${epicBranch}`,
|
|
220
|
-
);
|
|
221
|
-
const res = spawn('branch', storyBranch, epicBranch);
|
|
222
|
-
// Story #3482 — close the probe→create race: another concurrent
|
|
223
|
-
// dispatch (or a prior interrupted run) may have created the ref between
|
|
224
|
-
// our existence probe above and this `git branch` call. `git branch`
|
|
225
|
-
// exits non-zero with "already exists" in that window. Treat it as reuse
|
|
226
|
-
// rather than letting story-init abort — the ref exists, which is exactly
|
|
227
|
-
// the post-condition this function guarantees.
|
|
228
|
-
if (res.status !== 0) {
|
|
229
|
-
const stderr = res.stderr || res.stdout || '';
|
|
230
|
-
if (/already exists/i.test(stderr)) {
|
|
231
|
-
progress(
|
|
232
|
-
'GIT',
|
|
233
|
-
`Story branch ref ${storyBranch} already exists (created concurrently) — reusing.`,
|
|
234
|
-
);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
throw new Error(
|
|
238
|
-
`ensureStoryBranchSeed: failed to create ${storyBranch} from ${epicBranch}: ${stderr}`,
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
227
|
}
|
|
242
228
|
|
|
243
229
|
function verifyWorkspaceSafe({
|
|
@@ -2,10 +2,19 @@ import { Logger } from '../Logger.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* hierarchy-tracer.js — Stage 2 of the story-init pipeline.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Resolves the linked PRD and Tech Spec issue IDs for a Story's parent Epic.
|
|
6
|
+
*
|
|
7
|
+
* Story #4253: when both `prdId` and `techSpecId` are supplied as input
|
|
8
|
+
* (the `/deliver` fan-out resolves the immutable Epic once at the top of the
|
|
9
|
+
* run and threads the two ids down via `story-init.js --prd/--tech-spec`),
|
|
10
|
+
* this stage short-circuits and does NOT call `provider.getEpic`. The Epic
|
|
11
|
+
* issue is invariant for the lifetime of a delivery run, so the N per-Story
|
|
12
|
+
* `getEpic` round-trips collapse to one parent-side resolution.
|
|
13
|
+
*
|
|
14
|
+
* When the flags are absent (interactive / single-story use), the legacy
|
|
15
|
+
* `getEpic` resolution runs unchanged. Fetch failures are logged but
|
|
16
|
+
* non-fatal — the result simply reports `null` for whichever linkage could
|
|
17
|
+
* not be resolved, preserving the graceful degradation on a missing Epic.
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
20
|
/**
|
|
@@ -14,12 +23,24 @@ import { Logger } from '../Logger.js';
|
|
|
14
23
|
* @param {object} [deps.logger]
|
|
15
24
|
* @param {object} deps.input
|
|
16
25
|
* @param {number} deps.input.epicId
|
|
26
|
+
* @param {number|null} [deps.input.prdId] Pre-resolved PRD id (from --prd).
|
|
27
|
+
* @param {number|null} [deps.input.techSpecId] Pre-resolved Tech Spec id
|
|
28
|
+
* (from --tech-spec). When both `prdId` and `techSpecId` are supplied,
|
|
29
|
+
* `getEpic` is skipped.
|
|
17
30
|
* @returns {Promise<{ prdId: number|null, techSpecId: number|null }>}
|
|
18
31
|
*/
|
|
19
32
|
export async function traceHierarchy({ provider, logger, input }) {
|
|
20
33
|
const { epicId } = input;
|
|
21
34
|
const warn = logger?.warn ?? ((msg) => Logger.error(msg));
|
|
22
35
|
|
|
36
|
+
// Short-circuit: the parent already resolved both linkages once and threaded
|
|
37
|
+
// them in, so there is nothing left to fetch. Skip the per-Story getEpic.
|
|
38
|
+
const suppliedPrdId = input.prdId ?? null;
|
|
39
|
+
const suppliedTechSpecId = input.techSpecId ?? null;
|
|
40
|
+
if (suppliedPrdId !== null && suppliedTechSpecId !== null) {
|
|
41
|
+
return { prdId: suppliedPrdId, techSpecId: suppliedTechSpecId };
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
let prdId = null;
|
|
24
45
|
let techSpecId = null;
|
|
25
46
|
try {
|
|
@@ -82,22 +82,32 @@ export async function buildTaskGraph({ provider, logger, input }) {
|
|
|
82
82
|
const warn = logger?.warn ?? ((msg) => Logger.error(msg));
|
|
83
83
|
const progress = logger?.progress ?? (() => {});
|
|
84
84
|
|
|
85
|
+
// Story #4251 — under the 2-tier hierarchy every Story is childless, so the
|
|
86
|
+
// `fetchChildTickets` call (a `getTicket` + empty sub-issues GraphQL query +
|
|
87
|
+
// a never-matching `/search/issues` fallback) is pure waste on every
|
|
88
|
+
// story-init. The Story body is already in scope, so detect the inline-
|
|
89
|
+
// acceptance 2-tier shape FIRST and short-circuit without any child fetch —
|
|
90
|
+
// sparing the most aggressively rate-limited GitHub endpoint exactly during
|
|
91
|
+
// wide wave fan-out. A body lacking inline acceptance still falls through to
|
|
92
|
+
// the legacy child-enumeration path below.
|
|
93
|
+
if (hasInlineAcceptance(storyBody)) {
|
|
94
|
+
progress(
|
|
95
|
+
'TASKS',
|
|
96
|
+
`Story #${storyId} has inline acceptance — no child Tasks expected (2-tier shape).`,
|
|
97
|
+
);
|
|
98
|
+
return { sortedTasks: [], mode: '2-tier' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Legacy / 4-tier fall-through: a body lacking inline acceptance still
|
|
102
|
+
// enumerates child Tasks for the topological sort below.
|
|
85
103
|
const tasks = await fetchChildTickets(provider, storyId);
|
|
86
104
|
|
|
87
|
-
const
|
|
88
|
-
const mode = tasks.length === 0 && inlineAcceptance ? '2-tier' : '4-tier';
|
|
105
|
+
const mode = '4-tier';
|
|
89
106
|
|
|
90
107
|
if (tasks.length === 0) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
`Story #${storyId} has inline acceptance — no child Tasks expected (2-tier shape).`,
|
|
95
|
-
);
|
|
96
|
-
} else {
|
|
97
|
-
warn(
|
|
98
|
-
`[story-init] Warning: Story #${storyId} has no child Tasks. The agent will need to work from the Story body directly.`,
|
|
99
|
-
);
|
|
100
|
-
}
|
|
108
|
+
warn(
|
|
109
|
+
`[story-init] Warning: Story #${storyId} has no child Tasks. The agent will need to work from the Story body directly.`,
|
|
110
|
+
);
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
const sortedTasks = sortTasksByDependencies(tasks);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LIMITS_DEFAULTS } from '../config/limits.js';
|
|
2
2
|
import {
|
|
3
|
+
AUTHORING_ALTITUDE_GUIDANCE,
|
|
3
4
|
DEFAULT_TASK_SIZING,
|
|
4
5
|
DELIVERABLE_GRANULARITY_GUIDANCE,
|
|
5
6
|
} from '../orchestration/ticket-validator-sizing.js';
|
|
@@ -56,6 +57,15 @@ function render2TierPrompt({ maxTickets, maxTokenBudget }) {
|
|
|
56
57
|
// cannot drift (Story #3777).
|
|
57
58
|
const { definition: granularityDefinition, singleConsumerRule } =
|
|
58
59
|
DELIVERABLE_GRANULARITY_GUIDANCE;
|
|
60
|
+
// The binding-vs-advisory authoring altitude + the New-File Contract are
|
|
61
|
+
// sourced from the single AUTHORING_ALTITUDE_GUIDANCE constant
|
|
62
|
+
// (ticket-validator-sizing.js) so the prompt and the authoring SKILL cannot
|
|
63
|
+
// drift (Story #4272).
|
|
64
|
+
const {
|
|
65
|
+
altitude: authoringAltitude,
|
|
66
|
+
advisoryCaveat,
|
|
67
|
+
newFileContract,
|
|
68
|
+
} = AUTHORING_ALTITUDE_GUIDANCE;
|
|
59
69
|
return `You are an expert Senior Project Manager and Orchestrator.
|
|
60
70
|
Your job is to take a Product Requirements Document (PRD) and a Technical Specification and decompose them into a flat list of Story tickets for an AI Agent to execute.
|
|
61
71
|
|
|
@@ -114,6 +124,10 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
114
124
|
- {"path": "<read-only dependency path>", "assumption": "exists"}
|
|
115
125
|
- ...
|
|
116
126
|
|
|
127
|
+
## Non-Goals
|
|
128
|
+
- <a capability or change this Story explicitly does NOT deliver>
|
|
129
|
+
- ...
|
|
130
|
+
|
|
117
131
|
#### STORY BODY RULES:
|
|
118
132
|
|
|
119
133
|
- **goal** (in body string): One sentence stating WHY this story exists within the Epic.
|
|
@@ -122,6 +136,15 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
122
136
|
- **verify** (top-level array on the ticket object): Each entry MUST name a testing tier in parentheses, drawn from \`unit\` / \`contract\` / \`e2e\` / \`validate\`. Example: \`npm run test -- src/x.test.ts (unit)\`, \`npm run validate (validate)\`. Stories with zero verify entries SHOULD fail validation; if a story is genuinely unverifiable in isolation (e.g., a copy edit auditor will eyeball), the literal entry \`manual:<reason>\` is allowed so the absence is intentional, not lazy. Manual entries without a reason are rejected.
|
|
123
137
|
- **reason to exist** (REQUIRED, encoded as the \`reason_to_exist\` field of the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): One sentence stating the single coherent reason this Story exists, distinct from its broader \`## Goal\` prose. Every Story MUST carry a non-empty \`reason_to_exist\`; it is the machine-checkable form of the cohesion rule (**one Story = one coherent change with one reason to exist**) and the \`epic-plan-consolidate\` critic flags any Story whose body carries no non-empty reason to exist. Encode it as \`<!-- meta: {"reason_to_exist": "..."} -->\`.
|
|
124
138
|
- **estimated_test_files** (optional, encoded in the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): Integer estimate of how many test files this Story creates or modifies. Omit when the number is not estimable. Informational only — it does not gate the decompose.
|
|
139
|
+
- **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
|
|
140
|
+
|
|
141
|
+
#### AUTHORING ALTITUDE — BINDING ACCEPTANCE vs ADVISORY CHANGES:
|
|
142
|
+
|
|
143
|
+
${authoringAltitude}
|
|
144
|
+
|
|
145
|
+
${newFileContract}
|
|
146
|
+
|
|
147
|
+
${advisoryCaveat}
|
|
125
148
|
|
|
126
149
|
#### STORY SIZING — COHESION FIRST (the numeric ceiling is only a backstop):
|
|
127
150
|
|
|
@@ -103,25 +103,34 @@ function requirePositiveInt(value, label) {
|
|
|
103
103
|
* - `scopeId === epicId` → `<tempRoot>/epic-<epicId>/validation-evidence.json`
|
|
104
104
|
* - `scopeId !== epicId` → treated as a Story id → `<tempRoot>/epic-<epicId>/story-<scopeId>/validation-evidence.json`
|
|
105
105
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
106
|
+
* **Standalone keyspace (Story #4250).** When `opts.standalone === true`,
|
|
107
|
+
* the Story has no parent Epic, so the evidence file is anchored on the
|
|
108
|
+
* Story id alone at
|
|
109
|
+
* `<tempRoot>/standalone/stories/story-<scopeId>/validation-evidence.json`
|
|
110
|
+
* (the `storyTempDir(null, sid)` layout from Story #2874). In this mode
|
|
111
|
+
* `epicId` is ignored — callers MUST NOT feed a `0`/`null` epicId into the
|
|
112
|
+
* Epic-keyed branch (the historical bug this keyspace replaces). Outside
|
|
113
|
+
* standalone mode `epicId` remains required.
|
|
114
|
+
*
|
|
115
|
+
* The legacy flat `temp/validation-evidence-<scopeId>.json` layout is no
|
|
116
|
+
* longer supported — Epic-scoped callers must thread the Epic id through
|
|
117
|
+
* (Epic #1030 follow-up to Story #1054). The synthetic config bag passed to
|
|
118
|
+
* `epicTempDir` / `storyTempDir` keeps the resolver from doing a disk-bound
|
|
119
|
+
* `.agentrc.json` lookup; bare callers can pass `tempDir` via `opts` to
|
|
120
|
+
* override the default `'temp'`.
|
|
112
121
|
*
|
|
113
122
|
* @param {number|string} scopeId
|
|
114
|
-
* @param {{ cwd?: string, tempDir?: string, epicId
|
|
123
|
+
* @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean }} opts
|
|
115
124
|
* @returns {string}
|
|
116
125
|
*/
|
|
117
126
|
export function evidencePath(scopeId, opts = {}) {
|
|
118
|
-
|
|
127
|
+
const standalone = opts.standalone === true;
|
|
128
|
+
if (!standalone && opts.epicId == null) {
|
|
119
129
|
throw new Error(
|
|
120
|
-
'[validation-evidence] evidencePath requires opts.epicId (Epic-scoped path resolution).',
|
|
130
|
+
'[validation-evidence] evidencePath requires opts.epicId (Epic-scoped path resolution) unless opts.standalone is set.',
|
|
121
131
|
);
|
|
122
132
|
}
|
|
123
133
|
const { cwd, tempDir } = resolveOpts(opts);
|
|
124
|
-
const epicId = requirePositiveInt(opts.epicId, 'epicId');
|
|
125
134
|
const scope = requirePositiveInt(scopeId, 'scopeId');
|
|
126
135
|
// Bind the temp tree to the explicit `cwd` (Story #3900): pre-absolutise
|
|
127
136
|
// the tempRoot under `cwd` and pass it through the canonical
|
|
@@ -134,10 +143,18 @@ export function evidencePath(scopeId, opts = {}) {
|
|
|
134
143
|
? tempDir
|
|
135
144
|
: path.join(cwd, tempDir);
|
|
136
145
|
const configBag = { project: { paths: { tempRoot: absTempRoot } } };
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
let dir;
|
|
147
|
+
if (standalone) {
|
|
148
|
+
// Story #4250 — storyId-anchored standalone keyspace. `null` is the
|
|
149
|
+
// standalone-story sentinel `storyTempDir` accepts (Story #2874).
|
|
150
|
+
dir = storyTempDir(null, scope, configBag);
|
|
151
|
+
} else {
|
|
152
|
+
const epicId = requirePositiveInt(opts.epicId, 'epicId');
|
|
153
|
+
dir =
|
|
154
|
+
scope === epicId
|
|
155
|
+
? epicTempDir(epicId, configBag)
|
|
156
|
+
: storyTempDir(epicId, scope, configBag);
|
|
157
|
+
}
|
|
141
158
|
return path.join(dir, EVIDENCE_FILENAME);
|
|
142
159
|
}
|
|
143
160
|
|
|
@@ -172,15 +189,21 @@ function emptyDoc(scopeId) {
|
|
|
172
189
|
* cross-scopeId cases — callers don't have to branch on those failure
|
|
173
190
|
* modes; they manifest as `shouldSkip()` returning `skip: false`.
|
|
174
191
|
*
|
|
175
|
-
* `opts.epicId` is required so the per-Epic-tree path can be resolved
|
|
192
|
+
* `opts.epicId` is required so the per-Epic-tree path can be resolved,
|
|
193
|
+
* unless `opts.standalone === true` (Story #4250) routes to the
|
|
194
|
+
* storyId-anchored standalone keyspace.
|
|
176
195
|
*
|
|
177
196
|
* @param {number|string} scopeId
|
|
178
|
-
* @param {{ cwd?: string, tempDir?: string, epicId
|
|
197
|
+
* @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean, fs?: object }} opts
|
|
179
198
|
* @returns {{ storyId: number, schemaVersion: number, records: object[] }}
|
|
180
199
|
*/
|
|
181
200
|
export function loadEvidence(scopeId, opts = {}) {
|
|
182
201
|
const resolved = resolveOpts(opts);
|
|
183
|
-
const file = evidencePath(scopeId, {
|
|
202
|
+
const file = evidencePath(scopeId, {
|
|
203
|
+
...resolved,
|
|
204
|
+
epicId: opts.epicId,
|
|
205
|
+
standalone: opts.standalone,
|
|
206
|
+
});
|
|
184
207
|
if (!resolved.fs.existsSync(file)) return emptyDoc(scopeId);
|
|
185
208
|
let parsed;
|
|
186
209
|
try {
|
|
@@ -200,7 +223,9 @@ export function loadEvidence(scopeId, opts = {}) {
|
|
|
200
223
|
* Validates the resulting document against the schema before writing — a
|
|
201
224
|
* malformed write throws so the bug surfaces immediately.
|
|
202
225
|
*
|
|
203
|
-
* `opts.epicId` is required so the per-Epic-tree path can be resolved
|
|
226
|
+
* `opts.epicId` is required so the per-Epic-tree path can be resolved,
|
|
227
|
+
* unless `opts.standalone === true` (Story #4250) routes to the
|
|
228
|
+
* storyId-anchored standalone keyspace.
|
|
204
229
|
*
|
|
205
230
|
* @param {{
|
|
206
231
|
* storyId: number|string,
|
|
@@ -210,7 +235,7 @@ export function loadEvidence(scopeId, opts = {}) {
|
|
|
210
235
|
* exitCode?: number,
|
|
211
236
|
* durationMs?: number|null,
|
|
212
237
|
* }} input
|
|
213
|
-
* @param {{ cwd?: string, tempDir?: string, epicId
|
|
238
|
+
* @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean, fs?: object, now?: Function }} opts
|
|
214
239
|
* @returns {object} The persisted record.
|
|
215
240
|
*/
|
|
216
241
|
export function recordPass(
|
|
@@ -231,7 +256,11 @@ export function recordPass(
|
|
|
231
256
|
);
|
|
232
257
|
}
|
|
233
258
|
const resolved = resolveOpts(opts);
|
|
234
|
-
const evidenceOpts = {
|
|
259
|
+
const evidenceOpts = {
|
|
260
|
+
...resolved,
|
|
261
|
+
epicId: opts.epicId,
|
|
262
|
+
standalone: opts.standalone,
|
|
263
|
+
};
|
|
235
264
|
const doc = loadEvidence(storyId, evidenceOpts);
|
|
236
265
|
const record = {
|
|
237
266
|
gateName,
|
|
@@ -268,10 +297,13 @@ export function recordPass(
|
|
|
268
297
|
* with a machine-readable `reason` so callers can log why the skip didn't
|
|
269
298
|
* fire.
|
|
270
299
|
*
|
|
271
|
-
* `opts.epicId` is required so the per-Epic-tree path can be resolved
|
|
300
|
+
* `opts.epicId` is required so the per-Epic-tree path can be resolved,
|
|
301
|
+
* unless `opts.standalone === true` (Story #4250) routes to the
|
|
302
|
+
* storyId-anchored standalone keyspace. `opts` is forwarded verbatim to
|
|
303
|
+
* `loadEvidence`, so `standalone` flows through unchanged.
|
|
272
304
|
*
|
|
273
305
|
* @param {{ storyId: number|string, gateName: string, currentSha: string, configHash: string }} input
|
|
274
|
-
* @param {{ cwd?: string, tempDir?: string, epicId
|
|
306
|
+
* @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean, fs?: object }} opts
|
|
275
307
|
* @returns {{ skip: boolean, reason: string, record?: object }}
|
|
276
308
|
*/
|
|
277
309
|
export function shouldSkip(
|
|
@@ -308,15 +340,21 @@ export function shouldSkip(
|
|
|
308
340
|
* start of each Story so a re-run always starts clean. Idempotent —
|
|
309
341
|
* absent file is not an error.
|
|
310
342
|
*
|
|
311
|
-
* `opts.epicId` is required so the per-Epic-tree path can be resolved
|
|
343
|
+
* `opts.epicId` is required so the per-Epic-tree path can be resolved,
|
|
344
|
+
* unless `opts.standalone === true` (Story #4250) routes to the
|
|
345
|
+
* storyId-anchored standalone keyspace.
|
|
312
346
|
*
|
|
313
347
|
* @param {number|string} scopeId
|
|
314
|
-
* @param {{ cwd?: string, tempDir?: string, epicId
|
|
348
|
+
* @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean, fs?: object }} opts
|
|
315
349
|
* @returns {{ cleared: boolean, path: string }}
|
|
316
350
|
*/
|
|
317
351
|
export function forceClear(scopeId, opts = {}) {
|
|
318
352
|
const resolved = resolveOpts(opts);
|
|
319
|
-
const file = evidencePath(scopeId, {
|
|
353
|
+
const file = evidencePath(scopeId, {
|
|
354
|
+
...resolved,
|
|
355
|
+
epicId: opts.epicId,
|
|
356
|
+
standalone: opts.standalone,
|
|
357
|
+
});
|
|
320
358
|
if (!resolved.fs.existsSync(file)) return { cleared: false, path: file };
|
|
321
359
|
resolved.fs.unlinkSync(file);
|
|
322
360
|
return { cleared: true, path: file };
|