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.
Files changed (68) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/docs/workflows.md +19 -0
  3. package/.agents/schemas/agentrc.schema.json +1 -1
  4. package/.agents/schemas/dispatch-manifest.json +1 -1
  5. package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
  6. package/.agents/schemas/loop-unit.schema.json +70 -0
  7. package/.agents/schemas/validation-evidence.schema.json +2 -1
  8. package/.agents/scripts/audit-to-stories.js +43 -1
  9. package/.agents/scripts/check-doc-links.js +24 -1
  10. package/.agents/scripts/check-loop-units.js +204 -0
  11. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  12. package/.agents/scripts/evidence-gate.js +48 -12
  13. package/.agents/scripts/generate-workflows-doc.js +37 -4
  14. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  15. package/.agents/scripts/lib/cli-args.js +6 -0
  16. package/.agents/scripts/lib/close-validation/process.js +61 -5
  17. package/.agents/scripts/lib/close-validation/runner.js +42 -9
  18. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  19. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  20. package/.agents/scripts/lib/config-resolver.js +4 -1
  21. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  22. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  23. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
  24. package/.agents/scripts/lib/mandrel-catalog.js +36 -0
  25. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  26. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  27. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
  28. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  29. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  30. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  34. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  35. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  36. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  37. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  38. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  39. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  40. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  41. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  43. package/.agents/scripts/lib/validation-evidence.js +63 -25
  44. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  45. package/.agents/scripts/providers/github/branch-protection.js +1 -1
  46. package/.agents/scripts/providers/github/errors.js +53 -2
  47. package/.agents/scripts/providers/github/labels.js +1 -1
  48. package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
  49. package/.agents/scripts/resync-status-column.js +5 -0
  50. package/.agents/scripts/run-coverage.js +85 -45
  51. package/.agents/scripts/run-lint.js +11 -0
  52. package/.agents/scripts/single-story-init.js +22 -29
  53. package/.agents/scripts/story-init.js +38 -63
  54. package/.agents/scripts/story-phase.js +46 -4
  55. package/.agents/scripts/sync-claude-commands.js +112 -29
  56. package/.agents/scripts/update-maintainability-baseline.js +19 -76
  57. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  59. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  60. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  61. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  62. package/.agents/workflows/loops/README.md +65 -0
  63. package/.agents/workflows/loops/fix-failing-tests.md +74 -0
  64. package/.agents/workflows/loops/nightly-audit.md +71 -0
  65. package/.agents/workflows/loops/watch-ci.md +68 -0
  66. package/docs/CHANGELOG.md +51 -0
  67. package/package.json +1 -1
  68. 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
- const headingMatch = line.match(/^#{2,3}\s+(\w+)\s*$/i);
350
- if (headingMatch) {
351
- const name = headingMatch[1].toLowerCase();
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 = git?.spawn ?? ((...args) => gitSpawn(mainCwd, ...args));
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
- const action = planStoryBranchSeed({
199
- localHas: existsLocally(storyBranch),
200
- remoteHas: existsRemotely(storyBranch),
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
- * Given an epicId, resolves the linked PRD and Tech Spec issue IDs by
6
- * fetching the Epic. Fetch failures are logged but non-fatal — the result
7
- * simply reports `null` for whichever linkage could not be resolved, which
8
- * mirrors legacy behaviour in story-init.js.
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 inlineAcceptance = hasInlineAcceptance(storyBody);
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
- if (inlineAcceptance) {
92
- progress(
93
- 'TASKS',
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
- * `epicId` is required. The legacy flat
107
- * `temp/validation-evidence-<scopeId>.json` layout is no longer supported —
108
- * callers must thread the Epic id through (Epic #1030 follow-up to Story
109
- * #1054). The synthetic config bag passed to `epicTempDir` keeps the
110
- * resolver from doing a disk-bound `.agentrc.json` lookup; bare callers can
111
- * pass `tempDir` via `opts` to override the default `'temp'`.
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: number|string }} opts
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
- if (opts.epicId == null) {
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
- const dir =
138
- scope === epicId
139
- ? epicTempDir(epicId, configBag)
140
- : storyTempDir(epicId, scope, configBag);
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: number|string, fs?: object }} opts
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, { ...resolved, epicId: opts.epicId });
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: number|string, fs?: object, now?: Function }} opts
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 = { ...resolved, epicId: opts.epicId };
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: number|string, fs?: object }} opts
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: number|string, fs?: object }} opts
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, { ...resolved, epicId: opts.epicId });
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 };