mandrel 1.80.0 → 1.82.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 (101) hide show
  1. package/.agents/README.md +5 -5
  2. package/.agents/docs/SDLC.md +93 -80
  3. package/.agents/docs/configuration.md +11 -1
  4. package/.agents/docs/quality-gates.md +61 -0
  5. package/.agents/docs/workflows.md +1 -1
  6. package/.agents/instructions.md +9 -7
  7. package/.agents/personas/architect.md +8 -5
  8. package/.agents/personas/engineer-mobile.md +3 -2
  9. package/.agents/personas/engineer-web.md +3 -2
  10. package/.agents/personas/engineer.md +6 -5
  11. package/.agents/personas/product.md +19 -13
  12. package/.agents/personas/project-manager.md +9 -8
  13. package/.agents/personas/qa-engineer.md +10 -6
  14. package/.agents/personas/refactorer.md +3 -2
  15. package/.agents/personas/technical-writer.md +2 -1
  16. package/.agents/personas/ux-designer.md +2 -2
  17. package/.agents/schemas/agentrc.schema.json +10 -0
  18. package/.agents/scripts/acceptance-spec-reconciler.js +143 -59
  19. package/.agents/scripts/epic-deliver-prepare.js +0 -31
  20. package/.agents/scripts/epic-plan-decompose.js +2 -5
  21. package/.agents/scripts/epic-plan-spec.js +16 -19
  22. package/.agents/scripts/hierarchy-gate.js +11 -11
  23. package/.agents/scripts/lib/ITicketingProvider.js +4 -3
  24. package/.agents/scripts/lib/baselines/env-overrides.js +35 -0
  25. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  26. package/.agents/scripts/lib/bdd-scenario-scanner.js +1 -1
  27. package/.agents/scripts/lib/cli-args.js +1 -5
  28. package/.agents/scripts/lib/codebase-snapshot.js +1 -1
  29. package/.agents/scripts/lib/config/temp-paths.js +1 -4
  30. package/.agents/scripts/lib/config-settings-schema.js +5 -0
  31. package/.agents/scripts/lib/epic-body-sections.js +222 -0
  32. package/.agents/scripts/lib/epic-plan-clarity.js +38 -1
  33. package/.agents/scripts/lib/epic-plan-ideation.js +15 -3
  34. package/.agents/scripts/lib/label-constants.js +7 -17
  35. package/.agents/scripts/lib/label-taxonomy.js +4 -21
  36. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +47 -1
  37. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  38. package/.agents/scripts/lib/orchestration/check-baselines/phases/pipeline.js +1 -1
  39. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +2 -1
  40. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +19 -8
  41. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +23 -22
  42. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +7 -10
  43. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +4 -38
  44. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +8 -9
  45. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +11 -5
  46. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +26 -5
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +102 -304
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +32 -29
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +19 -20
  50. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +1 -1
  51. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-freshness.js +6 -9
  52. package/.agents/scripts/lib/orchestration/epic-plan-state-store.js +3 -4
  53. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +1 -1
  54. package/.agents/scripts/lib/orchestration/epic-runner/phases/snapshot.js +20 -27
  55. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +11 -5
  56. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +22 -59
  57. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +1 -1
  58. package/.agents/scripts/lib/orchestration/planning-context-budget.js +1 -1
  59. package/.agents/scripts/lib/orchestration/preflight-cache.js +1 -1
  60. package/.agents/scripts/lib/orchestration/spec-freshness.js +3 -3
  61. package/.agents/scripts/lib/orchestration/spec-section-validator.js +1 -1
  62. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +122 -1
  63. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +5 -8
  64. package/.agents/scripts/lib/orchestration/ticketing/reads.js +2 -2
  65. package/.agents/scripts/lib/plan-phase-cleanup.js +1 -2
  66. package/.agents/scripts/lib/qa/qa-context-hydrator.js +6 -85
  67. package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -8
  68. package/.agents/scripts/lifecycle-emit.js +1 -1
  69. package/.agents/scripts/lint-label-vocabulary.js +2 -3
  70. package/.agents/scripts/providers/github/mappers.js +0 -3
  71. package/.agents/scripts/providers/github/tickets.js +7 -18
  72. package/.agents/scripts/single-story-init.js +0 -1
  73. package/.agents/scripts/story-init.js +1 -29
  74. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +37 -18
  75. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +23 -18
  76. package/.agents/skills/core/epic-plan-premortem/SKILL.md +7 -6
  77. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +161 -109
  78. package/.agents/skills/core/hydrate-context/SKILL.md +10 -5
  79. package/.agents/skills/core/knowledge-transfer/SKILL.md +3 -2
  80. package/.agents/skills/core/scope-triage/SKILL.md +2 -1
  81. package/.agents/skills/skills.index.json +6 -6
  82. package/.agents/templates/epic-from-idea.md +4 -0
  83. package/.agents/workflows/audit-to-stories.md +2 -2
  84. package/.agents/workflows/helpers/code-review.md +11 -9
  85. package/.agents/workflows/helpers/deliver-epic.md +32 -44
  86. package/.agents/workflows/helpers/epic-audit.md +11 -8
  87. package/.agents/workflows/helpers/epic-deliver-story.md +10 -16
  88. package/.agents/workflows/helpers/epic-plan-decompose.md +17 -12
  89. package/.agents/workflows/helpers/epic-plan-spec.md +68 -68
  90. package/.agents/workflows/helpers/parallel-tooling.md +2 -1
  91. package/.agents/workflows/helpers/plan-epic.md +114 -99
  92. package/.agents/workflows/helpers/single-story-deliver.md +1 -1
  93. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -1
  94. package/.agents/workflows/plan.md +8 -8
  95. package/.agents/workflows/qa-assist.md +2 -1
  96. package/docs/CHANGELOG.md +28 -0
  97. package/package.json +1 -1
  98. package/.agents/scripts/lib/issue-link-parser.js +0 -74
  99. package/.agents/scripts/lib/orchestration/finalize/close-planning-tickets.js +0 -116
  100. package/.agents/scripts/lib/orchestration/planning-state-manager.js +0 -318
  101. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +0 -57
@@ -86,6 +86,31 @@ function resolveAcceptance(story) {
86
86
  return Array.isArray(body?.acceptance) ? body.acceptance : [];
87
87
  }
88
88
 
89
+ /**
90
+ * Resolve the `depends_on` edge list for a Story. `depends_on` may live at the
91
+ * top level (the validator normalizes it there — see `ticket-validator.js`) or
92
+ * inside the structured body (the shape `story-body.js` parses out). Prefer the
93
+ * top-level array, falling back to the resolved body's `depends_on`. Returns
94
+ * only non-empty string slugs — mirroring the `story-body.js` normalization so
95
+ * a blank / non-string edge never counts as a real dependency.
96
+ *
97
+ * The cross-Story validator already rejects any `depends_on` slug that does not
98
+ * match a sibling in the same decomposition, so every edge that survives to the
99
+ * sizing layer is guaranteed to point at a sibling. The merge-candidate
100
+ * heuristic therefore only needs to know whether the list is non-empty.
101
+ *
102
+ * @param {object} story
103
+ * @returns {string[]}
104
+ */
105
+ function resolveDependsOn(story) {
106
+ const raw = Array.isArray(story?.depends_on)
107
+ ? story.depends_on
108
+ : (resolveStoryBody(story)?.depends_on ?? []);
109
+ return Array.isArray(raw)
110
+ ? raw.filter((d) => typeof d === 'string' && d.trim().length > 0)
111
+ : [];
112
+ }
113
+
89
114
  export const DEFAULT_TASK_SIZING = Object.freeze({
90
115
  // Typical-Story warning thresholds (soft — emit advisory findings).
91
116
  // Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
@@ -97,6 +122,15 @@ export const DEFAULT_TASK_SIZING = Object.freeze({
97
122
  // Hard ceilings (rejection unless lifted).
98
123
  hardFiles: 30,
99
124
  maxAcceptance: 14,
125
+ // Under-size (merge-candidate) thresholds (Story #4312). A Story with a
126
+ // footprint at or below BOTH ceilings that also carries at least one
127
+ // `depends_on` edge to a sibling looks like a dependent fragment rather than
128
+ // a capability slice — the machine-checkable form of the single-consumer
129
+ // merge rule. Emitted as a `soft` advisory only; never a rejection. A tiny
130
+ // ORPHAN Story (no `depends_on`) stays silent — small orthogonal slices are
131
+ // legitimate.
132
+ mergeCandidateMaxFiles: 3,
133
+ mergeCandidateMaxAcceptance: 4,
100
134
  });
101
135
 
102
136
  /**
@@ -117,6 +151,16 @@ export const DEFAULT_TASK_SIZING = Object.freeze({
117
151
  * as a single PR — not a single module or file. Module-level slices fold into
118
152
  * the capability they belong to, and a Story whose only consumer is one
119
153
  * sibling Story is merged into that sibling (single-consumer merge rule).
154
+ *
155
+ * The `envelopeFloor` sentence is the **soft** complement to the sizing
156
+ * section's envelope framing (Story #4313): the delivery envelope
157
+ * (`maxTokenBudget`) bounds the TOP of a Story, but under-utilizing it is
158
+ * itself a merge signal. It is deliberately prose-only — an illustrative
159
+ * fraction, not a threshold constant, and no validator finding backs it (the
160
+ * mechanical backstop is the `merge-candidate` finding). It names the
161
+ * per-Story delivery-session cost (hydration, branch, PR, review, CI) so the
162
+ * "models one-shot bigger things now" planning assumption is stated, not
163
+ * implicit.
120
164
  */
121
165
  export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
122
166
  // The one-sentence definition of Story granularity.
@@ -125,6 +169,9 @@ export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
125
169
  // The single-consumer merge rule.
126
170
  singleConsumerRule:
127
171
  '**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.',
172
+ // The envelope-floor heuristic (soft; no validator finding — Story #4313).
173
+ envelopeFloor:
174
+ '**Envelope floor — under-utilizing the envelope is a merge signal.** A Story that would plausibly consume well under a third of the delivery envelope (`maxTokenBudget`) and is neither parallel-deliverable nor orthogonal to its siblings should be **merged into its consumer**. The fraction is illustrative, not a threshold — the point is that modern frontier models one-shot capability-sized changes, so a chain of small dependent Stories needlessly pays a full delivery session (hydration, branch, PR, review, CI) per link. Merge such links up unless a parallelism or orthogonality reason justifies the separate slice.',
128
175
  });
129
176
 
130
177
  /**
@@ -291,6 +338,66 @@ function computeMissingReasonToExistFinding(story) {
291
338
  return hasReason ? [] : [makeMissingReasonToExist(story.slug)];
292
339
  }
293
340
 
341
+ /**
342
+ * Soft, advisory `merge-candidate` finding (Story #4312). Surfaces a Story that
343
+ * looks like a dependent fragment rather than a capability slice — the
344
+ * machine-checkable form of the single-consumer merge rule
345
+ * (`DELIVERABLE_GRANULARITY_GUIDANCE.singleConsumerRule`). Never a rejection:
346
+ * `severity: 'soft'`, so it rides the advisory `findings[]` channel and never
347
+ * enters the hard `errors[]` array.
348
+ *
349
+ * The rendered message names the depended-on sibling slug(s) and recommends
350
+ * merging into the consumer, mirroring the tone of the `wide-undeclared` nudge.
351
+ */
352
+ function makeMergeCandidate(slug, fileCount, acceptanceCount, dependsOn) {
353
+ const siblings = dependsOn.map((d) => `"${d}"`).join(', ');
354
+ return {
355
+ kind: 'merge-candidate',
356
+ severity: 'soft',
357
+ ticketSlug: slug,
358
+ fileCount,
359
+ acceptanceCount,
360
+ dependsOn,
361
+ message: `Story "${slug}" is a thin dependent slice (${fileCount} declared file(s), ${acceptanceCount} acceptance item(s)) that depends on sibling(s) ${siblings}. A Story whose only role is to feed one sibling is not its own unit of work — consider merging it into the consumer (single-consumer merge rule) rather than shipping it as a separate slice.`,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Emit a soft `merge-candidate` finding when a Story meets the under-size
367
+ * heuristic (Story #4312): footprint ≤ `mergeCandidateMaxFiles` declared
368
+ * `changes[]` files AND ≤ `mergeCandidateMaxAcceptance` acceptance items AND at
369
+ * least one `depends_on` edge to a sibling. All three conditions MUST hold — a
370
+ * tiny ORPHAN Story (no `depends_on`) stays silent because small orthogonal
371
+ * slices are legitimate. Glob entries mark the footprint as unknown-width, so a
372
+ * glob-carrying Story is never a merge candidate.
373
+ *
374
+ * @param {object} story
375
+ * @param {{ fileCount: number, hasGlobs: boolean }} changesAnalysis
376
+ * @param {number} acceptanceCount
377
+ * @param {object} sizing
378
+ * @returns {object[]}
379
+ */
380
+ function computeMergeCandidateFinding(
381
+ story,
382
+ changesAnalysis,
383
+ acceptanceCount,
384
+ sizing,
385
+ ) {
386
+ if (changesAnalysis.hasGlobs) return [];
387
+ const dependsOn = resolveDependsOn(story);
388
+ if (dependsOn.length === 0) return [];
389
+ if (changesAnalysis.fileCount > sizing.mergeCandidateMaxFiles) return [];
390
+ if (acceptanceCount > sizing.mergeCandidateMaxAcceptance) return [];
391
+ return [
392
+ makeMergeCandidate(
393
+ story.slug,
394
+ changesAnalysis.fileCount,
395
+ acceptanceCount,
396
+ dependsOn,
397
+ ),
398
+ ];
399
+ }
400
+
294
401
  /**
295
402
  * Returns true when a `changes[]` entry is a glob pattern. Handles both the
296
403
  * canonical PathEntry object form `{ path, assumption }` and legacy strings.
@@ -394,6 +501,7 @@ function computeStorySizingFindings(story, sizing) {
394
501
  const acceptance = resolveAcceptance(story);
395
502
  const changes = Array.isArray(body?.changes) ? body.changes : [];
396
503
  const declaredWide = isDeclaredWide(body?.wide ?? null);
504
+ const changesAnalysis = analyseChanges(changes);
397
505
 
398
506
  // Soft, advisory: flag acceptance criteria that reference a configuration
399
507
  // constant without inlining a concrete value (Story #3855). Independent of
@@ -407,6 +515,19 @@ function computeStorySizingFindings(story, sizing) {
407
515
  // Independent of the numeric sizing layers below.
408
516
  out.push(...computeMissingReasonToExistFinding(story));
409
517
 
518
+ // Soft, advisory: flag a thin dependent slice (Story #4312) — the symmetric
519
+ // under-size backstop to the over-size ceilings. A tiny footprint with a
520
+ // `depends_on` edge to a sibling is a merge candidate under the
521
+ // single-consumer merge rule. Independent of the numeric sizing layers below.
522
+ out.push(
523
+ ...computeMergeCandidateFinding(
524
+ story,
525
+ changesAnalysis,
526
+ acceptance.length,
527
+ sizing,
528
+ ),
529
+ );
530
+
410
531
  // Acceptance ceiling + soft warn.
411
532
  if (acceptance.length > sizing.maxAcceptance) {
412
533
  out.push(
@@ -428,7 +549,7 @@ function computeStorySizingFindings(story, sizing) {
428
549
  );
429
550
  }
430
551
 
431
- const { fileCount, hasGlobs } = analyseChanges(changes);
552
+ const { fileCount, hasGlobs } = changesAnalysis;
432
553
 
433
554
  // Glob entries mark the Story as unknown-width: a glob cannot be bounded by
434
555
  // the numeric ceiling, so skip it. A non-wide Story carrying globs gets an
@@ -295,15 +295,12 @@ async function processCascadeParentLocked(
295
295
  // PR-driven `Closes #N` auto-close, and a recovery transition in
296
296
  // `epic-deliver-finalize.js`).
297
297
  //
298
- // Planning tickets (context::prd, context::tech-spec) DO close via
299
- // cascade now (Story #1951). Previously they were excluded under
300
- // the assumption that the operator would close them manually
301
- // post-merge — but that step never reliably happened and leaving
298
+ // Legacy planning tickets (pre-#4324 `context::*` artifacts on
299
+ // historical Epics) DO close via cascade (Story #1951): leaving
302
300
  // them open as native sub-issues of the Epic blocks GitHub from
303
- // honoring the Epic's `Closes #N` footer. The Epic finalize phase
304
- // also closes them explicitly; this cascade branch is the
305
- // defense-in-depth path when a Story's tasklist references a
306
- // planning ticket directly.
301
+ // honoring the Epic's `Closes #N` footer. New Epics carry their
302
+ // planning content on the Epic body itself, so this branch is
303
+ // purely a legacy-hygiene path.
307
304
  //
308
305
  // Reuse the parentSnapshot from the idempotency check above — it is
309
306
  // a fresh read (cache was invalidated before the getTicket call) and
@@ -93,7 +93,7 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
93
93
  // a sharpened body rewrite, recording the persistence event for audit.
94
94
  'clarity-gate-update',
95
95
  // Story #2635 — Phase 7 Tech Spec freshness check. `epic-plan-spec.js`
96
- // upserts a `spec-freshness` comment on the Tech Spec issue listing any
96
+ // upserts a `spec-freshness` comment on the Epic listing any
97
97
  // path-shaped references that don't exist at the base branch, so the
98
98
  // operator can correct drift before Phase 8 decomposes from a stale
99
99
  // spec. Advisory: the run continues regardless of the report contents.
@@ -118,7 +118,7 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
118
118
  'model-attribution',
119
119
  // Story #2894 — `finalize/post-handoff-comment.js` upserts an
120
120
  // `epic-handoff` comment on the Epic at the end of the bus-owned
121
- // finalize flow (after `open-or-locate-pr` and `close-planning-tickets`
121
+ // finalize flow (after `open-or-locate-pr`
122
122
  // succeed). The marker carries the freshly opened/located PR URL and
123
123
  // its number so operators can jump straight to the PR from the Epic
124
124
  // ticket. Re-invocations upsert the same marker rather than appending
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The spec and decompose phases write several Epic-scoped temp files under
5
5
  * the per-Epic tree (`temp/epic-<id>/planner-context.json`,
6
- * `temp/epic-<id>/prd.md`, etc. — see `lib/config/temp-paths.js`). The
6
+ * `temp/epic-<id>/techspec.md`, etc. — see `lib/config/temp-paths.js`). The
7
7
  * workflow .md previously told the operator to `Remove-Item` those files by
8
8
  * name at the end of each phase, which rots: adding a new temp file in the
9
9
  * script required a synchronized markdown edit, and missed edits left
@@ -37,7 +37,6 @@ import { PROJECT_ROOT, resolveConfig } from './config-resolver.js';
37
37
  export const PHASE_TEMP_BASENAMES = Object.freeze({
38
38
  spec: Object.freeze([
39
39
  'planner-context.json',
40
- 'prd.md',
41
40
  'techspec.md',
42
41
  'acceptance-spec.md',
43
42
  ]),
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Both QA front-ends (`/qa-explore` and `/qa-run`) need to load the
5
5
  * *grounded* surface context for an Epic before they reason about what to test:
6
- * the Epic body, its linked context tickets (PRD / Tech Spec / Acceptance
7
- * Spec), the project's `.feature` files, the implementation files the surface
6
+ * the Epic body (which carries the folded Tech Spec sections and
7
+ * Acceptance Table Story #4324 retired the separate context tickets),
8
+ * the project's `.feature` files, the implementation files the surface
8
9
  * map points at, and a slice of recent git history. Today a front-end that
9
10
  * trusts in-code comments ("this handler lives at …") can be wrong — the path
10
11
  * may have moved, or never existed on the base branch at all. This hydrator
@@ -37,17 +38,6 @@ export const DEFAULT_BASE_REF = 'main';
37
38
  /** How many recent commits the hydrator pulls into the context object. */
38
39
  export const DEFAULT_LOG_MAX_COUNT = 20;
39
40
 
40
- /**
41
- * The GitHub label prefixes that mark a linked context ticket. The hydrator
42
- * resolves `context::prd`, `context::tech-spec`, and `context::acceptance-spec`
43
- * references off the Epic body and fetches each one through the port.
44
- */
45
- export const CONTEXT_TICKET_KINDS = Object.freeze([
46
- 'prd',
47
- 'tech-spec',
48
- 'acceptance-spec',
49
- ]);
50
-
51
41
  /**
52
42
  * @typedef {object} GithubPort
53
43
  * @property {(issueNumber: number) => Promise<{
@@ -73,57 +63,6 @@ export const CONTEXT_TICKET_KINDS = Object.freeze([
73
63
  * @property {string} [note] Free-form provenance note (e.g. a code comment).
74
64
  */
75
65
 
76
- /**
77
- * Parse the linked context-ticket issue numbers out of an Epic body.
78
- *
79
- * Two reference shapes are recognized, both written by the planning workflows:
80
- * - A labelled line: `context::prd #3800` (or `context::tech-spec: #3801`).
81
- * - A "Planning Artifacts" link: `- PRD: #3800`, `- Tech Spec: #3801`,
82
- * `- Acceptance Spec: #3802`.
83
- *
84
- * Returns a map of kind → issue number for whichever kinds are present. A kind
85
- * that appears more than once keeps the first occurrence; an unparseable line
86
- * is skipped rather than thrown.
87
- *
88
- * @param {string} epicBody
89
- * @returns {Record<string, number>}
90
- */
91
- export function parseContextTicketRefs(epicBody) {
92
- const refs = {};
93
- const body = typeof epicBody === 'string' ? epicBody : '';
94
-
95
- // Map the human-readable labels back to canonical kinds.
96
- const labelToKind = {
97
- prd: 'prd',
98
- 'tech-spec': 'tech-spec',
99
- 'tech spec': 'tech-spec',
100
- techspec: 'tech-spec',
101
- 'acceptance-spec': 'acceptance-spec',
102
- 'acceptance spec': 'acceptance-spec',
103
- acceptancespec: 'acceptance-spec',
104
- };
105
-
106
- for (const rawLine of body.split('\n')) {
107
- const line = rawLine.trim();
108
- // Match `context::tech-spec #N`, `Tech Spec: #N`, `- PRD: #N`, etc.
109
- const match = line.match(
110
- /(?:context::)?([A-Za-z][A-Za-z -]*?)\s*[:#]*\s*#(\d+)/,
111
- );
112
- if (!match) continue;
113
- const key = match[1]
114
- .trim()
115
- .toLowerCase()
116
- .replace(/^context::/, '');
117
- const kind = labelToKind[key];
118
- if (!kind) continue;
119
- const issueNumber = Number(match[2]);
120
- if (!Number.isInteger(issueNumber)) continue;
121
- if (refs[kind] === undefined) refs[kind] = issueNumber;
122
- }
123
-
124
- return refs;
125
- }
126
-
127
66
  /**
128
67
  * Enumerate the `.feature` files under `featureRoot`, returning repo-relative
129
68
  * POSIX-style paths sorted for determinism. A missing root yields an empty
@@ -189,9 +128,9 @@ export async function verifySurfaceMap(surfaceMap, gitPort, baseRef) {
189
128
  * Hydrate the QA context object for an Epic.
190
129
  *
191
130
  * Assembles, in one object:
192
- * - `epic` — the Epic's `{ number, body, labels }`.
193
- * - `contextTickets` — the linked PRD / Tech Spec / Acceptance Spec tickets,
194
- * keyed by kind, each `{ number, body, labels }`.
131
+ * - `epic` — the Epic's `{ number, body, labels }`. The body is
132
+ * the single planning document (ideation sections +
133
+ * folded Tech Spec sections + Acceptance Table).
195
134
  * - `featureFiles` — repo-relative paths of the project's `.feature` files.
196
135
  * - `implementation` — the verified surface map (each entry carries
197
136
  * `verified` against the base ref).
@@ -214,9 +153,6 @@ export async function verifySurfaceMap(surfaceMap, gitPort, baseRef) {
214
153
  * }} opts
215
154
  * @returns {Promise<{
216
155
  * epic: { number: number, body: string, labels: string[] },
217
- * contextTickets: Record<string, {
218
- * number: number, body: string, labels: string[],
219
- * }>,
220
156
  * featureFiles: string[],
221
157
  * implementation: Array<{ path: string, note: string | null, verified: boolean }>,
222
158
  * gitLog: Array<{ sha: string, subject: string }>,
@@ -263,20 +199,6 @@ export async function hydrateQaContext(opts) {
263
199
  labels: Array.isArray(epicIssue.labels) ? [...epicIssue.labels] : [],
264
200
  };
265
201
 
266
- // Resolve and fetch the linked context tickets off the Epic body.
267
- const refs = parseContextTicketRefs(epic.body);
268
- const contextTickets = {};
269
- for (const kind of CONTEXT_TICKET_KINDS) {
270
- const number = refs[kind];
271
- if (number === undefined) continue;
272
- const issue = await githubPort.fetchIssue(number);
273
- contextTickets[kind] = {
274
- number: issue.number,
275
- body: issue.body ?? '',
276
- labels: Array.isArray(issue.labels) ? [...issue.labels] : [],
277
- };
278
- }
279
-
280
202
  const featureFiles = collectFeatureFiles(featureRoot, { fsImpl });
281
203
  const implementation = await verifySurfaceMap(surfaceMap, gitPort, baseRef);
282
204
  const gitLog = await gitPort.recentLog({ maxCount: logMaxCount });
@@ -286,7 +208,6 @@ export async function hydrateQaContext(opts) {
286
208
 
287
209
  return {
288
210
  epic,
289
- contextTickets,
290
211
  featureFiles,
291
212
  implementation,
292
213
  gitLog: Array.isArray(gitLog) ? gitLog : [],
@@ -52,12 +52,16 @@ function render2TierPrompt({ maxTickets, maxTokenBudget, epicId = null }) {
52
52
  // (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
53
53
  const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
54
54
  DEFAULT_TASK_SIZING;
55
- // Deliverable-granularity definition + single-consumer merge rule are
56
- // sourced from the single DELIVERABLE_GRANULARITY_GUIDANCE constant
57
- // (ticket-validator-sizing.js) so the prompt and the authoring SKILL
58
- // cannot drift (Story #3777).
59
- const { definition: granularityDefinition, singleConsumerRule } =
60
- DELIVERABLE_GRANULARITY_GUIDANCE;
55
+ // Deliverable-granularity definition + single-consumer merge rule + the
56
+ // soft envelope-floor heuristic are sourced from the single
57
+ // DELIVERABLE_GRANULARITY_GUIDANCE constant (ticket-validator-sizing.js) so
58
+ // the prompt and the authoring SKILL cannot drift (Story #3777; the
59
+ // envelope-floor sentence added by Story #4313).
60
+ const {
61
+ definition: granularityDefinition,
62
+ singleConsumerRule,
63
+ envelopeFloor,
64
+ } = DELIVERABLE_GRANULARITY_GUIDANCE;
61
65
  // The binding-vs-advisory authoring altitude + the New-File Contract are
62
66
  // sourced from the single AUTHORING_ALTITUDE_GUIDANCE constant
63
67
  // (ticket-validator-sizing.js) so the prompt and the authoring SKILL cannot
@@ -75,7 +79,7 @@ function render2TierPrompt({ maxTickets, maxTokenBudget, epicId = null }) {
75
79
  ? `@epic-${epicId}-ac-1`
76
80
  : '@epic-<id>-ac-N';
77
81
  return `You are an expert Senior Project Manager and Orchestrator.
78
- 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.
82
+ Your job is to take an Epic (including its inline User Stories) and a Technical Specification and decompose them into a flat list of Story tickets for an AI Agent to execute.
79
83
 
80
84
  ### HIERARCHY RULES:
81
85
  1. **Stories**: Specific user-facing or architectural user stories (e.g., "Implement JWT Token Exchange").
@@ -162,6 +166,8 @@ The primary question is **cohesion, not count**: *is this one coherent change wi
162
166
 
163
167
  **Size against the real one-pass delivery envelope.** Each Story is delivered and self-verified by a single agent in one pass, whose context is capped by the delivery token budget \`maxTokenBudget = ${maxTokenBudget}\` tokens (the task-prompt hydration cap). Use that envelope — not the file count alone — as the leading sizing input: a Story is correctly sized when one agent can hold its full change, acceptance, and verification in a single pass within \`maxTokenBudget\`. The numeric file thresholds below are a coarse backstop on top of this envelope, not the primary signal.
164
168
 
169
+ ${envelopeFloor}
170
+
165
171
  - **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories — or two Stories that should be one. State that sentence explicitly in the Story's \`reason_to_exist\` meta field (see STORY BODY RULES) so the consolidate critic can check it.
166
172
  - ${singleConsumerRule}
167
173
  - **Split independent, parallelizable work** into sibling Stories — but only when the pieces genuinely have separate reasons to exist.
@@ -194,7 +200,7 @@ Declaring \`wide\` with a non-empty reason **lifts the \`hardFiles\` rejection**
194
200
 
195
201
  #### BRAND / COPY / STYLE WORK:
196
202
 
197
- - Stories that touch user-visible copy, brand assets, or visual style MUST cite the relevant section of \`docs/style-guide.md\` in \`acceptance\` (e.g. \`"acceptance": ["Hero copy matches docs/style-guide.md §3 (voice & tone)"]\`). If \`docs/style-guide.md\` does not exist or has no relevant section, state that explicitly: \`"acceptance": ["docs/style-guide.md absent — copy reviewed against the inline brand brief in PRD §2"]\`. Silence on style sourcing is a smell.
203
+ - Stories that touch user-visible copy, brand assets, or visual style MUST cite the relevant section of \`docs/style-guide.md\` in \`acceptance\` (e.g. \`"acceptance": ["Hero copy matches docs/style-guide.md §3 (voice & tone)"]\`). If \`docs/style-guide.md\` does not exist or has no relevant section, state that explicitly: \`"acceptance": ["docs/style-guide.md absent — copy reviewed against the inline brand brief in the Epic body"]\`. Silence on style sourcing is a smell.
198
204
 
199
205
  ### WAVE-0 BDD SCAFFOLD STORY (features-first; emit when the Acceptance Spec has \`new\`-disposition rows):
200
206
  The Acceptance Spec's AC table (columns \`AC ID | Outcome | Feature File | Scenario | Disposition\`) tags each row's \`Disposition\` with one of \`new | updated | unchanged\`. A \`new\` row names a \`.feature\` file + scenario that does NOT yet exist on \`main\`. The framework is features-first: implementing Stories reference those \`.feature\` paths in their \`verify[]\` lines, so the files MUST already exist when those Stories run — otherwise verification fails mid-delivery on a missing file. (These Gherkin \`.feature\` files are BDD artifacts, unrelated to any ticket tier.)
@@ -412,7 +412,7 @@ export async function runLifecycleEmit({
412
412
  // record `failed` classifications instead of throwing (the bus's
413
413
  // `onFailed` boundary already persists the originating event), so the
414
414
  // bus emit resolves cleanly even when a downstream side effect — e.g.
415
- // an acceptance-reconcile gap or a `closePlanningTickets` throw inside
415
+ // an acceptance-reconcile gap or an `openOrLocatePr` throw inside
416
416
  // the Finalizer — failed. Without surfacing these, the CLI would exit 0
417
417
  // with success-shaped JSON despite a partial finalize (Story #3904).
418
418
  const outcomes = collectOutcomes(chain);
@@ -66,7 +66,6 @@ export const KNOWN_AXES = Object.freeze([
66
66
  'type',
67
67
  'status',
68
68
  'persona',
69
- 'context',
70
69
  'acceptance',
71
70
  'meta',
72
71
  'planning',
@@ -156,11 +155,11 @@ export function findVocabularyViolations(src, axes = KNOWN_AXES) {
156
155
  // - value is a lowercase identifier (`[a-z][a-z0-9-]*`)
157
156
  //
158
157
  // The whole-body anchor avoids false positives on:
159
- // - `context::acceptance-spec` — canonical label, value contains a
158
+ // - `acceptance::n-a` — canonical label, value contains a
160
159
  // hyphen but is not at axis position.
161
160
  // - `<type>/<slug>` — template placeholders.
162
161
  // - `delivery.maxTokenBudget` — config-key paths with `.`.
163
- // - `context.{prdId,techSpecId}` — JS destructure shapes.
162
+ // - `delivery.{maxTickets}` — JS destructure shapes.
164
163
  // - `agent-protocol.md` — filenames / concept slugs.
165
164
  //
166
165
  // Real drift like `type/epic` matches because the WHOLE body is
@@ -11,8 +11,6 @@
11
11
  * Extracted from `../github.js` in Story #1846 / Task #1859.
12
12
  */
13
13
 
14
- import { parseLinkedIssues } from '../../lib/issue-link-parser.js';
15
-
16
14
  function normalizeLabels(issue) {
17
15
  const raw = issue?.labels;
18
16
  if (!raw) return [];
@@ -50,7 +48,6 @@ export function issueToEpic(issue) {
50
48
  body: issue.body ?? '',
51
49
  labels,
52
50
  labelSet: new Set(labels),
53
- linkedIssues: parseLinkedIssues(issue.body),
54
51
  };
55
52
  }
56
53
 
@@ -112,7 +112,7 @@ export class TicketGateway {
112
112
  this._cache = cache ?? createInlineTicketCache();
113
113
  /**
114
114
  * Per-instance memo of `getTickets(epicId, filters)` results (Story
115
- * #3988). The planning-state-manager fetches the same child list twice
115
+ * #3988). Planning-era healing fetched the same child list twice
116
116
  * per planning pass; without this memo each fetch re-pays the full
117
117
  * search/list round-trip. Invalidated on every write surface.
118
118
  * @type {Map<string, object[]>}
@@ -322,24 +322,13 @@ export class TicketGateway {
322
322
  // Mirror the Epic create path (issues.js:160 → `labels: TYPE_LABELS.EPIC`):
323
323
  // inject TYPE_LABELS.STORY so a spec that omits the labels array cannot
324
324
  // produce an unlabeled, undispatchable Story. Dedupe to avoid duplicates
325
- // when the caller already carries the label.
326
- //
327
- // Context spec tickets (PRD / Tech Spec / Acceptance Spec) are a distinct
328
- // ticket class created through this same factory carrying a `context::*`
329
- // label (and no `type::story`). They MUST NOT be stamped `type::story`:
330
- // doing so makes every `type::story`-counting consumer — the decompose
331
- // open-children guard (`assertNoOpenPlanChildren`), the delivery wave
332
- // builder (`discoverOpenStories`), the plan healthcheck — mis-classify
333
- // them as deliverable Stories. Skip the injection when the caller's labels
334
- // already classify the ticket as context.
325
+ // when the caller already carries the label. (Story #4324 retired the
326
+ // `context::*` ticket classes, so every ticket created through this
327
+ // factory is a Story.)
335
328
  const callerLabels = ticketData.labels ?? [];
336
- const isContextTicket = callerLabels.some(
337
- (l) => typeof l === 'string' && l.startsWith('context::'),
338
- );
339
- const labels =
340
- isContextTicket || callerLabels.includes(TYPE_LABELS.STORY)
341
- ? callerLabels
342
- : [TYPE_LABELS.STORY, ...callerLabels];
329
+ const labels = callerLabels.includes(TYPE_LABELS.STORY)
330
+ ? callerLabels
331
+ : [TYPE_LABELS.STORY, ...callerLabels];
343
332
  const result = await this._gh.api({
344
333
  method: 'POST',
345
334
  endpoint: `/repos/${this.owner}/${this.repo}/issues`,
@@ -23,7 +23,6 @@
23
23
  * 6. Flip the Story to `agent::executing`.
24
24
  *
25
25
  * What this script does NOT do (and why):
26
- * - Skips `traceHierarchy` — no Epic → no PRD/Tech-Spec.
27
26
  * - Skips `validateBlockers` against the body's `Blocked by:` markers —
28
27
  * pre-flight is still the operator's responsibility, but the Epic-scope
29
28
  * blocker chain doesn't fit.
@@ -9,7 +9,6 @@
9
9
  * pipeline stages from `lib/story-init/`:
10
10
  *
11
11
  * 1. context-resolver — fetch the Story + optionally mark as recut.
12
- * 2. hierarchy-tracer — resolve Feature/Epic → PRD / Tech Spec.
13
12
  * 3. blocker-validator — refuse to proceed while dependencies are open.
14
13
  * 4. task-graph-builder — fetch + topologically sort child Tasks.
15
14
  * 5. branch-initializer — materialise the story branch (single-tree
@@ -53,7 +52,6 @@ import {
53
52
  planStoryBranchSeed,
54
53
  } from './lib/story-init/branch-initializer.js';
55
54
  import { resolveContext } from './lib/story-init/context-resolver.js';
56
- import { traceHierarchy } from './lib/story-init/hierarchy-tracer.js';
57
55
  import { transitionStoryToExecuting } from './lib/story-init/state-transitioner.js';
58
56
  import { buildTaskGraph } from './lib/story-init/task-graph-builder.js';
59
57
  import { createPhaseTimer } from './lib/util/phase-timer.js';
@@ -88,8 +86,6 @@ export async function runStoryInit({
88
86
  dryRun: dryRunParam,
89
87
  cwd: cwdParam,
90
88
  recutOf: recutOfParam,
91
- prdId: prdIdParam,
92
- techSpecId: techSpecIdParam,
93
89
  injectedProvider,
94
90
  injectedConfig,
95
91
  } = {}) {
@@ -100,17 +96,10 @@ export async function runStoryInit({
100
96
  dryRun: !!dryRunParam,
101
97
  cwd: cwdParam ?? null,
102
98
  recutOf: recutOfParam ?? null,
103
- prdId: prdIdParam ?? null,
104
- techSpecId: techSpecIdParam ?? null,
105
99
  }
106
100
  : parseSprintArgs();
107
101
  const { storyId, dryRun } = parsed;
108
102
  const recutOf = recutOfParam ?? parsed.recutOf ?? null;
109
- // Story #4253: pre-resolved Epic linkages (from the /deliver fan-out's
110
- // one-time Epic resolution). When both are present, hierarchy-tracer skips
111
- // the per-Story getEpic round-trip; when absent it resolves them itself.
112
- const threadedPrdId = prdIdParam ?? parsed.prdId ?? null;
113
- const threadedTechSpecId = techSpecIdParam ?? parsed.techSpecId ?? null;
114
103
  // Worktree-aware cwd resolution: explicit param > --cwd flag > env > PROJECT_ROOT.
115
104
  const cwd = path.resolve(cwdParam ?? parsed.cwd ?? PROJECT_ROOT);
116
105
 
@@ -155,20 +144,7 @@ export async function runStoryInit({
155
144
  input: { storyId, recutOf, dryRun },
156
145
  });
157
146
 
158
- // Stage 2 — hierarchy. When the /deliver fan-out threaded --prd/--tech-spec
159
- // (both resolved once by the parent), this short-circuits the per-Story
160
- // getEpic. Absent flags fall back to the legacy getEpic resolution.
161
- const { prdId, techSpecId } = await traceHierarchy({
162
- provider,
163
- logger: stageLogger,
164
- input: { epicId, prdId: threadedPrdId, techSpecId: threadedTechSpecId },
165
- });
166
-
167
147
  progress('CONTEXT', `Epic: #${epicId}, Parent: #${parentId ?? 'none'}`);
168
- progress(
169
- 'CONTEXT',
170
- `PRD: #${prdId ?? 'none'}, Tech Spec: #${techSpecId ?? 'none'}`,
171
- );
172
148
 
173
149
  // Stage 3 — blockers.
174
150
  const { openBlockers } = await validateBlockers({
@@ -335,8 +311,6 @@ export async function runStoryInit({
335
311
  installStatus,
336
312
  sortedTasks,
337
313
  parentId,
338
- prdId,
339
- techSpecId,
340
314
  dryRun,
341
315
  recutOf,
342
316
  hierarchy: hierarchyMode,
@@ -473,8 +447,6 @@ function buildStoryInitResult({
473
447
  installStatus,
474
448
  sortedTasks,
475
449
  parentId,
476
- prdId,
477
- techSpecId,
478
450
  dryRun,
479
451
  recutOf,
480
452
  hierarchy,
@@ -506,7 +478,7 @@ function buildStoryInitResult({
506
478
  labels: t.labels,
507
479
  dependencies: t.dependsOn ?? parseBlockedBy(t.body ?? ''),
508
480
  })),
509
- context: { parentId, prdId, techSpecId },
481
+ context: { parentId },
510
482
  dryRun,
511
483
  };
512
484
  }