mandrel 1.93.0 → 1.94.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 (135) hide show
  1. package/.agents/agents/acceptance-critic.md +129 -0
  2. package/.agents/agents/retro.md +42 -0
  3. package/.agents/agents/story-worker.md +162 -0
  4. package/.agents/docs/configuration.md +7 -1
  5. package/.agents/docs/execution-reference.md +27 -2
  6. package/.agents/instructions.md +43 -33
  7. package/.agents/personas/engineer.md +26 -112
  8. package/.agents/personas/security-engineer.md +1 -2
  9. package/.agents/rules/git-conventions-reference.md +225 -0
  10. package/.agents/rules/git-conventions.md +25 -200
  11. package/.agents/rules/security-baseline.md +5 -0
  12. package/.agents/rules/testing-standards.md +106 -13
  13. package/.agents/schemas/agentrc.schema.json +31 -1
  14. package/.agents/schemas/lifecycle/slice.end.schema.json +21 -0
  15. package/.agents/schemas/lifecycle/slice.heartbeat.schema.json +20 -0
  16. package/.agents/schemas/lifecycle/slice.start.schema.json +17 -0
  17. package/.agents/scripts/acceptance-eval.js +62 -18
  18. package/.agents/scripts/agents-bootstrap-github.js +1 -1
  19. package/.agents/scripts/bookkeeping-reconcile.js +117 -0
  20. package/.agents/scripts/check-context-budget.js +62 -5
  21. package/.agents/scripts/diagnose-friction.js +0 -6
  22. package/.agents/scripts/epic-deliver-prepare.js +272 -10
  23. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +56 -18
  24. package/.agents/scripts/lib/close-validation/gates.js +159 -21
  25. package/.agents/scripts/lib/config/acceptance-eval.js +52 -5
  26. package/.agents/scripts/lib/config/delivery-routing.js +87 -0
  27. package/.agents/scripts/lib/config/explain.js +2 -0
  28. package/.agents/scripts/lib/config-resolver.js +1 -1
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +37 -3
  30. package/.agents/scripts/lib/config-settings-schema-quality.js +9 -0
  31. package/.agents/scripts/lib/doc-tiers.js +37 -2
  32. package/.agents/scripts/lib/observability/active-story-env.js +111 -2
  33. package/.agents/scripts/lib/observability/hook-heartbeat.js +219 -0
  34. package/.agents/scripts/lib/observability/tool-trace-hook.js +15 -4
  35. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +111 -0
  36. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +32 -4
  37. package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +270 -0
  38. package/.agents/scripts/lib/orchestration/ceremony-routing.js +141 -0
  39. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -124
  40. package/.agents/scripts/lib/orchestration/deliver-route.js +173 -0
  41. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +1 -1
  42. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +233 -0
  43. package/.agents/scripts/lib/orchestration/lifecycle/emit-slice-lifecycle.js +270 -0
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +83 -2
  45. package/.agents/scripts/lib/orchestration/lifecycle/listeners/checkpoint-pointer-writer.js +6 -0
  46. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +3 -2
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -0
  48. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +1 -0
  49. package/.agents/scripts/lib/orchestration/ticket-validator.js +1 -1
  50. package/.agents/scripts/lib/provider-factory.js +1 -1
  51. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  52. package/.agents/scripts/post-structured-comment.js +38 -0
  53. package/.agents/scripts/slice-phase.js +361 -0
  54. package/.agents/scripts/sync-claude-agents.js +165 -0
  55. package/.agents/scripts/update-ticket-state.js +31 -0
  56. package/.agents/scripts/wave-tick.js +138 -9
  57. package/.agents/skills/core/api-and-interface-design/SKILL.md +5 -3
  58. package/.agents/skills/core/code-review-and-quality/SKILL.md +63 -7
  59. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +1 -1
  60. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +5 -5
  61. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +8 -8
  62. package/.agents/skills/core/epic-plan-premortem/SKILL.md +4 -4
  63. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +26 -56
  64. package/.agents/skills/core/gates-and-baselines/SKILL.md +149 -0
  65. package/.agents/skills/core/idea-refinement/SKILL.md +2 -8
  66. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +7 -7
  67. package/.agents/skills/skills.index.json +11 -381
  68. package/.agents/workflows/deliver.md +47 -4
  69. package/.agents/workflows/helpers/acceptance-self-eval.md +38 -13
  70. package/.agents/workflows/helpers/deliver-epic-reference.md +18 -5
  71. package/.agents/workflows/helpers/deliver-epic-single.md +331 -0
  72. package/.agents/workflows/helpers/deliver-epic.md +51 -8
  73. package/.agents/workflows/helpers/deliver-stories.md +15 -5
  74. package/.agents/workflows/helpers/epic-deliver-story.md +12 -3
  75. package/.agents/workflows/helpers/mandrel-sync-config.md +1 -1
  76. package/.agents/workflows/helpers/plan-epic.md +25 -23
  77. package/.agents/workflows/mandrel-update.md +1 -1
  78. package/docs/CHANGELOG.md +16 -0
  79. package/lib/cli/registry.js +95 -0
  80. package/package.json +4 -2
  81. package/.agents/personas/engineer-mobile.md +0 -120
  82. package/.agents/personas/engineer-web.md +0 -111
  83. package/.agents/personas/product.md +0 -94
  84. package/.agents/personas/refactorer.md +0 -113
  85. package/.agents/personas/sre.md +0 -86
  86. package/.agents/personas/ux-designer.md +0 -95
  87. package/.agents/scripts/epic-plan-decompose.js +0 -54
  88. package/.agents/scripts/epic-plan-spec.js +0 -64
  89. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +0 -109
  90. package/.agents/scripts/plan-critics.js +0 -199
  91. package/.agents/skills/core/baseline-refresh/SKILL.md +0 -181
  92. package/.agents/skills/core/ci-cd-and-automation/SKILL.md +0 -274
  93. package/.agents/skills/core/ci-cd-and-automation/examples.md +0 -211
  94. package/.agents/skills/core/code-simplification/SKILL.md +0 -389
  95. package/.agents/skills/core/context-engineering/SKILL.md +0 -309
  96. package/.agents/skills/core/context-engineering/examples.md +0 -58
  97. package/.agents/skills/core/deprecation-and-migration/SKILL.md +0 -250
  98. package/.agents/skills/core/frontend-ui-engineering/SKILL.md +0 -357
  99. package/.agents/skills/core/hydrate-context/SKILL.md +0 -123
  100. package/.agents/skills/core/idea-refinement/examples.md +0 -437
  101. package/.agents/skills/core/idea-refinement/frameworks.md +0 -135
  102. package/.agents/skills/core/incremental-implementation/SKILL.md +0 -271
  103. package/.agents/skills/core/introducing-a-baseline-gate/SKILL.md +0 -213
  104. package/.agents/skills/core/knowledge-transfer/SKILL.md +0 -180
  105. package/.agents/skills/core/mutation-survivor-remediation/SKILL.md +0 -117
  106. package/.agents/skills/core/performance-optimization/SKILL.md +0 -314
  107. package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +0 -277
  108. package/.agents/skills/core/property-based-testing/SKILL.md +0 -148
  109. package/.agents/skills/core/refactoring-discipline/SKILL.md +0 -111
  110. package/.agents/skills/core/shipping-and-launch/SKILL.md +0 -328
  111. package/.agents/skills/core/spec-driven-development/SKILL.md +0 -252
  112. package/.agents/skills/core/test-driven-development/SKILL.md +0 -475
  113. package/.agents/skills/core/using-agent-skills/SKILL.md +0 -232
  114. package/.agents/skills/stack/architecture/monorepo-path-strategist/SKILL.md +0 -31
  115. package/.agents/skills/stack/architecture/structured-output-zod/SKILL.md +0 -51
  116. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +0 -76
  117. package/.agents/skills/stack/backend/cloudflare-hono-architect/SKILL.md +0 -31
  118. package/.agents/skills/stack/backend/cloudflare-hono-architect/examples/route-template.ts +0 -33
  119. package/.agents/skills/stack/backend/cloudflare-queue-manager/SKILL.md +0 -31
  120. package/.agents/skills/stack/backend/cloudflare-workers/SKILL.md +0 -51
  121. package/.agents/skills/stack/backend/highlevel-crm/SKILL.md +0 -54
  122. package/.agents/skills/stack/backend/sqlite-drizzle-expert/SKILL.md +0 -29
  123. package/.agents/skills/stack/backend/sqlite-drizzle-expert/examples/schema-template.ts +0 -30
  124. package/.agents/skills/stack/backend/stripe-integration/SKILL.md +0 -57
  125. package/.agents/skills/stack/backend/stripe-integration/scripts/listen-stripe.sh +0 -9
  126. package/.agents/skills/stack/backend/turso-sqlite/SKILL.md +0 -48
  127. package/.agents/skills/stack/frontend/astro/SKILL.md +0 -62
  128. package/.agents/skills/stack/frontend/astro-react-island-strategist/SKILL.md +0 -30
  129. package/.agents/skills/stack/frontend/expo-react-native-developer/SKILL.md +0 -29
  130. package/.agents/skills/stack/frontend/google-analytics-v4/SKILL.md +0 -50
  131. package/.agents/skills/stack/frontend/tailwind-v4/SKILL.md +0 -58
  132. package/.agents/skills/stack/frontend/ui-accessibility-engineer/SKILL.md +0 -34
  133. package/.agents/skills/stack/qa/audit-accessibility/SKILL.md +0 -51
  134. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +0 -199
  135. package/.agents/skills/stack/security/backend-security-patterns/SKILL.md +0 -68
@@ -12,7 +12,13 @@
12
12
  * session and every subagent spawn (instructions.md § 4).
13
13
  * - `mandatoryRead` — the resolved `project.docsContextFiles` set.
14
14
  *
15
- * A tier that resolves **empty** is skipped silently (the `docsContextFiles`
15
+ * It additionally enforces a **per-file** ceiling on the role-scoped agent-boot
16
+ * tier (`.agents/agents/*.md`, #4478): no single boot context may exceed
17
+ * `agentBoot.ceilingBytes` (default 8192). This is a per-agent cap, not a sum
18
+ * ratchet — each role def is a standalone system prompt a converted spawn boots
19
+ * on, and adding another role def is legitimate.
20
+ *
21
+ * A read-tier that resolves **empty** is skipped silently (the `docsContextFiles`
16
22
  * half skips when unconfigured / its files are absent), so a repo with no
17
23
  * `CLAUDE.md` and no context docs is a clean no-op.
18
24
  *
@@ -60,6 +66,31 @@ export const GATED_TIERS = ['alwaysLoaded', 'mandatoryRead'];
60
66
  */
61
67
  export const DEFAULT_TOLERANCE_BYTES = 2048;
62
68
 
69
+ /**
70
+ * Per-file ceiling (bytes) for the role-scoped agent-boot tier (#4478). Unlike
71
+ * the read-tiers (gated by a total-byte ratchet), each `.agents/agents/*.md`
72
+ * boot context is a **standalone** system prompt a converted spawn boots on, so
73
+ * the meaningful budget is per-agent, not the sum: no single role def may
74
+ * exceed this ceiling. Adding another role def is legitimate — a per-file gate
75
+ * (rather than a sum ratchet) does not false-positive on that.
76
+ * @type {number}
77
+ */
78
+ export const AGENT_BOOT_CEILING_BYTES = 8192;
79
+
80
+ /**
81
+ * Return the agent-boot files that exceed the per-file ceiling.
82
+ *
83
+ * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
84
+ * @param {number} ceiling
85
+ * @returns {Array<{ path: string, bytes: number, ceiling: number }>}
86
+ */
87
+ export function agentBootOverflow(tierMap, ceiling = AGENT_BOOT_CEILING_BYTES) {
88
+ const files = tierMap?.tiers?.agentBoot ?? [];
89
+ return files
90
+ .filter((f) => Number.isFinite(f?.bytes) && f.bytes > ceiling)
91
+ .map((f) => ({ path: f.path, bytes: f.bytes, ceiling }));
92
+ }
93
+
63
94
  /**
64
95
  * Parse argv for `--baseline <path>`, `--root <path>`, `--update`, `--json`.
65
96
  * Exported so unit tests can pin the parser.
@@ -127,11 +158,19 @@ export function buildBaseline(tierMap, toleranceBytes) {
127
158
  const files = tierMap.tiers[name] ?? [];
128
159
  tiers[name] = { totalBytes: tierTotalBytes(files), files };
129
160
  }
161
+ // The agent-boot tier is recorded top-level (not under `tiers`) because it is
162
+ // gated by a per-file ceiling, not the total-byte ratchet the `tiers` entries
163
+ // use — keeping it out of `tiers` keeps the ratchet diff loop unambiguous.
164
+ const agentBootFiles = tierMap.tiers.agentBoot ?? [];
130
165
  return {
131
166
  $schema: 'https://mandrel.dev/baselines/context-budget.schema.json',
132
167
  generatedAt: new Date().toISOString(),
133
168
  toleranceBytes,
134
169
  tiers,
170
+ agentBoot: {
171
+ ceilingBytes: AGENT_BOOT_CEILING_BYTES,
172
+ files: agentBootFiles,
173
+ },
135
174
  };
136
175
  }
137
176
 
@@ -278,7 +317,11 @@ export async function runCli({
278
317
  }
279
318
 
280
319
  const diff = diffBudget(tierMap, baseline);
281
- const exitCode = diff.grown.length > 0 ? 1 : 0;
320
+ const ceiling = Number.isFinite(baseline?.agentBoot?.ceilingBytes)
321
+ ? baseline.agentBoot.ceilingBytes
322
+ : AGENT_BOOT_CEILING_BYTES;
323
+ const bootOverflow = agentBootOverflow(tierMap, ceiling);
324
+ const exitCode = diff.grown.length > 0 || bootOverflow.length > 0 ? 1 : 0;
282
325
 
283
326
  if (json) {
284
327
  const envelope = {
@@ -293,17 +336,31 @@ export async function runCli({
293
336
  grown: diff.grown,
294
337
  shrunk: diff.shrunk,
295
338
  skipped: diff.skipped,
339
+ agentBootCeilingBytes: ceiling,
340
+ agentBootOverflow: bootOverflow,
296
341
  exitCode,
297
342
  };
298
343
  stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
299
344
  } else {
300
345
  stdout.write(`\n--- context-budget preview ---\n`);
301
346
  stdout.write(`${renderDiff(diff)}\n`);
302
- if (exitCode === 1) {
303
- stderr.write(
304
- `[context-budget] a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
347
+ for (const o of bootOverflow) {
348
+ stdout.write(
349
+ `+ agentBoot: ${o.path} is ${o.bytes} bytes, over the ${o.ceiling}-byte per-agent ceiling\n`,
305
350
  );
306
351
  }
352
+ if (exitCode === 1) {
353
+ if (bootOverflow.length > 0) {
354
+ stderr.write(
355
+ `[context-budget] ❌ a role-agent boot context exceeds the ${ceiling}-byte per-agent ceiling — trim the role def (the ceiling is a hard cap, not a starve target)\n`,
356
+ );
357
+ }
358
+ if (diff.grown.length > 0) {
359
+ stderr.write(
360
+ `[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
361
+ );
362
+ }
363
+ }
307
364
  }
308
365
 
309
366
  return exitCode;
@@ -81,12 +81,6 @@ const FRICTION_RULES = [
81
81
  remediation:
82
82
  ' - Syntax/parsing error. Check recently modified files for missing brackets, quotes, or invalid structures.',
83
83
  },
84
- {
85
- markers: ['Astro', 'astro'],
86
- category: 'Missing Skill',
87
- remediation:
88
- ' - Framework error: Refer to `.agents/skills/stack/frontend/astro/SKILL.md` for Astro rules.',
89
- },
90
84
  ];
91
85
 
92
86
  const FRICTION_DEFAULT = {
@@ -39,10 +39,21 @@ import { parseArgs } from 'node:util';
39
39
  import { runBootSweep } from './boot-sweep.js';
40
40
  import { buildChecklistPayload } from './lib/audit-suite/index.js';
41
41
  import { runAsCli } from './lib/cli-utils.js';
42
- import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
43
- import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
44
- import { getEpicBranch, gitSpawn } from './lib/git-utils.js';
42
+ import {
43
+ getPaths,
44
+ getRunners,
45
+ resolveConfig,
46
+ resolveRuntime,
47
+ } from './lib/config-resolver.js';
48
+ import { cachedGitFetch } from './lib/git/cached-fetch.js';
49
+ import {
50
+ ensureEpicBranchRef,
51
+ currentBranch as gitCurrentBranch,
52
+ } from './lib/git-branch-lifecycle.js';
53
+ import { getEpicBranch, gitSpawn, gitSync } from './lib/git-utils.js';
45
54
  import { Logger } from './lib/Logger.js';
55
+ import { ACCEPTANCE_NA, TYPE_LABELS } from './lib/label-constants.js';
56
+ import { parseDeliverySlicingTable } from './lib/orchestration/consolidation-precondition.js';
46
57
  import { ensureDocsDigest } from './lib/orchestration/docs-digest.js';
47
58
  import {
48
59
  resolveOperator,
@@ -50,6 +61,7 @@ import {
50
61
  } from './lib/orchestration/epic-deliver-lease-guard.js';
51
62
  import {
52
63
  initialize as initializeEpicRunState,
64
+ initializeSingle as initializeEpicRunStateSingle,
53
65
  write as writeEpicRunState,
54
66
  } from './lib/orchestration/epic-run-state-store.js';
55
67
  import {
@@ -72,7 +84,7 @@ import {
72
84
  } from './lib/orchestration/ticket-lease.js';
73
85
  import { createProvider } from './lib/provider-factory.js';
74
86
 
75
- const HELP = `Usage: node .agents/scripts/epic-deliver-prepare.js --epic <epicId> [--ignore-concurrency-hazards] [--steal] [--as <handle>]
87
+ const HELP = `Usage: node .agents/scripts/epic-deliver-prepare.js --epic <epicId> [--single] [--ignore-concurrency-hazards] [--steal] [--as <handle>]
76
88
 
77
89
  Snapshots Epic #<id>, builds the wave DAG, initializes the epic-run-state
78
90
  checkpoint, and prints the per-wave dispatch plan as JSON. Before any of that,
@@ -81,6 +93,15 @@ runs two fail-closed preflight guards (Story #3482): a checkout-safety check
81
93
  (refuse on a live foreign claim).
82
94
 
83
95
  Options:
96
+ --single Single-delivery prepare (Epic #4475). Short-
97
+ circuits Story enumeration: seeds epic/<id>,
98
+ materializes ONE worktree on it, and writes a
99
+ slice-map epic-run-state (deliveryShape:
100
+ "single", storyCount: 0) from the Epic body's
101
+ ## Delivery Slicing table. Refuses
102
+ acceptance::n-a (fail-closed). INERT until
103
+ M4-B: nothing in production drives this flag
104
+ yet.
84
105
  --ignore-concurrency-hazards Bypass the cross-Story concurrency-hazard
85
106
  gate (Story #2297). The flag's use is
86
107
  recorded on the Epic checkpoint so retro
@@ -424,6 +445,239 @@ export async function writeStoryChecklists({
424
445
  );
425
446
  }
426
447
 
448
+ /**
449
+ * Seed `epic/<id>` and materialize the ONE worktree the single-delivery
450
+ * executor walks — the single-delivery counterpart to `single-story-init.js`'s
451
+ * single-worktree seed (Epic #4475). Fetches origin so remote-tracking refs
452
+ * are authoritative, publishes the Epic integration branch via the shared
453
+ * `ensureEpicBranchRef` seeder (the same helper `branch-initializer.js` uses),
454
+ * then adds a worktree at `.worktrees/epic-<id>/` on `epic/<id>` — idempotent,
455
+ * reused on a re-prepare. When worktree isolation is off it checks the branch
456
+ * out on the main tree instead (mirroring `provisionWorktree`).
457
+ *
458
+ * Skipped in the same injected-test shape the preflight guards use (a provider
459
+ * injected with no git seam) so unit tests never spawn real git.
460
+ *
461
+ * @param {{
462
+ * epicId: number,
463
+ * cwd: string,
464
+ * baseBranch: string,
465
+ * worktreeEnabled: boolean,
466
+ * progress?: (stage: string, msg: string) => void,
467
+ * }} args
468
+ * @returns {{ epicBranch: string, workCwd: string, worktreeCreated: boolean }}
469
+ */
470
+ export function provisionEpicWorktree({
471
+ epicId,
472
+ cwd,
473
+ baseBranch,
474
+ worktreeEnabled,
475
+ progress = () => {},
476
+ }) {
477
+ const epicBranch = getEpicBranch(epicId);
478
+
479
+ // Fetch origin so `ensureEpicBranchRef` can read remote-tracking refs
480
+ // instead of a second network round-trip (mirrors materializeBaseBranch).
481
+ cachedGitFetch(cwd, 'origin');
482
+ ensureEpicBranchRef(epicBranch, baseBranch, cwd, { progress });
483
+
484
+ if (!worktreeEnabled) {
485
+ // Single-tree mode: check out the Epic integration branch in place.
486
+ gitSync(cwd, 'checkout', epicBranch);
487
+ progress('WORKTREE', `Checked out ${epicBranch} on the main tree.`);
488
+ return { epicBranch, workCwd: cwd, worktreeCreated: false };
489
+ }
490
+
491
+ const worktreeRoot = path.join(cwd, '.worktrees');
492
+ const wtPath = path.join(worktreeRoot, `epic-${epicId}`);
493
+ fs.mkdirSync(worktreeRoot, { recursive: true });
494
+
495
+ const listed = gitSpawn(cwd, 'worktree', 'list', '--porcelain');
496
+ const alreadyPresent =
497
+ listed.status === 0 && (listed.stdout ?? '').includes(wtPath);
498
+ if (alreadyPresent) {
499
+ progress('WORKTREE', `♻️ Reusing worktree: ${wtPath}`);
500
+ return { epicBranch, workCwd: wtPath, worktreeCreated: false };
501
+ }
502
+
503
+ const res = gitSpawn(cwd, 'worktree', 'add', wtPath, epicBranch);
504
+ if (res.status !== 0) {
505
+ const stderr = res.stderr || res.stdout || '';
506
+ if (/already (exists|checked out)/.test(stderr)) {
507
+ progress('WORKTREE', `♻️ Reusing worktree (race): ${wtPath}`);
508
+ return { epicBranch, workCwd: wtPath, worktreeCreated: false };
509
+ }
510
+ throw new Error(
511
+ `epic-deliver-prepare --single: git worktree add failed for epic-${epicId}: ${stderr}`,
512
+ );
513
+ }
514
+ progress('WORKTREE', `✨ Created worktree: ${wtPath}`);
515
+ return { epicBranch, workCwd: wtPath, worktreeCreated: true };
516
+ }
517
+
518
+ /**
519
+ * Step 0/1 of `/deliver` for a single-delivery Epic (Epic #4475, design §S1).
520
+ *
521
+ * The single-delivery counterpart to `runEpicDeliverPrepare`. It short-circuits
522
+ * Story enumeration entirely — a spec-only plan authored NO Story tickets, so
523
+ * there is nothing to fan out. Instead it:
524
+ *
525
+ * 1. Refuses `acceptance::n-a` (fail-closed front gate). Under single
526
+ * delivery the non-waivable epic-level acceptance reconcile is the ONLY
527
+ * acceptance gate; an Epic that declares "no acceptance criteria" is
528
+ * structurally incoherent with that contract.
529
+ * 2. Runs the same fail-closed preflight guards (checkout-safety + Epic
530
+ * lease) as the fan-out prepare.
531
+ * 3. Seeds `epic/<id>` and materializes ONE worktree on it.
532
+ * 4. Parses the Epic body's `## Delivery Slicing` table and writes an
533
+ * `epic-run-state` **slice map** (`deliveryShape: "single"`,
534
+ * `storyCount: 0`, `concurrencyCap: 1`) — idempotent + resume-preserving
535
+ * (a re-run keeps every already-`done` slice).
536
+ * 5. Writes the per-Epic docs digest.
537
+ *
538
+ * BEHAVIOR-PRESERVING (M4-A): this function is reachable only through the
539
+ * `--single` flag, which nothing in production drives yet (the `deliver.md`
540
+ * router's single verdict falls through to the fan-out helper until M4-B). The
541
+ * slice-map checkpoint round-trips but no executor consumes it here.
542
+ *
543
+ * @param {object} args — same DI surface as `runEpicDeliverPrepare` minus the
544
+ * concurrency-hazard knobs (single delivery fans out nothing to gate).
545
+ * @returns {Promise<{
546
+ * epicId: number,
547
+ * deliveryShape: 'single',
548
+ * storyCount: 0,
549
+ * concurrencyCap: number,
550
+ * sliceCount: number,
551
+ * slices: Record<string, { status: string, title?: string }>,
552
+ * epicBranch: string,
553
+ * workCwd: string,
554
+ * worktreeCreated: boolean,
555
+ * checkpointInitializedAt: string,
556
+ * docsDigestPath: string|null,
557
+ * }>}
558
+ */
559
+ export async function runEpicDeliverPrepareSingle({
560
+ epicId,
561
+ cwd,
562
+ injectedProvider,
563
+ injectedConfig,
564
+ asOperator,
565
+ steal = false,
566
+ injectedGit,
567
+ leaseHeartbeatAt,
568
+ leaseNow,
569
+ skipPreflightGuards = false,
570
+ } = {}) {
571
+ if (!Number.isInteger(epicId) || epicId <= 0) {
572
+ throw new TypeError(
573
+ 'runEpicDeliverPrepareSingle: --epic must be a positive integer',
574
+ );
575
+ }
576
+
577
+ const config = injectedConfig ?? resolveConfig({ cwd });
578
+ if (!config.github) {
579
+ throw new Error(
580
+ 'runEpicDeliverPrepareSingle: no github block in .agentrc.json',
581
+ );
582
+ }
583
+ const provider = injectedProvider ?? createProvider(config);
584
+ // Single delivery collapses the whole Epic into ONE guarded in-session slice
585
+ // walk — the concurrency cap is 1 by definition (nothing fans out).
586
+ const concurrencyCap = 1;
587
+
588
+ const epic = await provider.getTicket(epicId);
589
+ const labels = Array.isArray(epic?.labels) ? epic.labels : [];
590
+ if (!labels.includes(TYPE_LABELS.EPIC)) {
591
+ throw new Error(
592
+ `runEpicDeliverPrepareSingle: #${epicId} is not a ${TYPE_LABELS.EPIC} (labels: ${labels.join(', ') || 'none'}).`,
593
+ );
594
+ }
595
+
596
+ // Fail-closed front gate (design §"Non-waivable epic reconcile"): under
597
+ // single delivery the epic-level acceptance reconcile is the ONLY acceptance
598
+ // gate that runs — there is no per-Story self-eval critic tier behind it. An
599
+ // Epic labelled `acceptance::n-a` (no acceptance criteria) is therefore
600
+ // structurally incoherent with single delivery: it would waive the sole
601
+ // gate. Refuse loudly instead of silently shipping ungated.
602
+ if (labels.includes(ACCEPTANCE_NA)) {
603
+ throw new Error(
604
+ `[epic-deliver-prepare] BLOCKER: Epic #${epicId} carries ${ACCEPTANCE_NA}, ` +
605
+ 'but single delivery makes the non-waivable epic-level acceptance ' +
606
+ 'reconcile the ONLY acceptance gate — an Epic with no acceptance ' +
607
+ 'criteria would ship ungated. Remove the label (author an ' +
608
+ '## Acceptance Table), or re-plan the Epic as fan-out.',
609
+ );
610
+ }
611
+
612
+ await runPreflightGuardsForPrepare({
613
+ epicId,
614
+ cwd,
615
+ config,
616
+ provider,
617
+ injectedProvider,
618
+ injectedGit,
619
+ asOperator,
620
+ steal,
621
+ leaseHeartbeatAt,
622
+ leaseNow,
623
+ skipPreflightGuards,
624
+ });
625
+
626
+ const baseBranch = config.project?.baseBranch ?? 'main';
627
+ const runtime = resolveRuntime({ config });
628
+
629
+ // Seed epic/<id> + materialize the one worktree. Skipped in the injected-
630
+ // test shape (a provider injected with no git seam) so unit tests never
631
+ // spawn real git — matching the preflight-guard suppression rule.
632
+ const worktreeSuppressed =
633
+ skipPreflightGuards || (Boolean(injectedProvider) && !injectedGit);
634
+ let epicBranch = getEpicBranch(epicId);
635
+ let workCwd = cwd ?? process.cwd();
636
+ let worktreeCreated = false;
637
+ if (!worktreeSuppressed) {
638
+ ({ epicBranch, workCwd, worktreeCreated } = provisionEpicWorktree({
639
+ epicId,
640
+ cwd: cwd ?? process.cwd(),
641
+ baseBranch,
642
+ worktreeEnabled: runtime.worktreeEnabled,
643
+ progress: (stage, msg) =>
644
+ Logger.info(`[epic-deliver-prepare:single] ${stage} ${msg}`),
645
+ }));
646
+ }
647
+
648
+ // Parse the Epic body's `## Delivery Slicing` table — the single mode's
649
+ // audit trail and the source of the slice map. A missing/unparseable table
650
+ // yields an empty slice set (the executor has nothing to walk); that is a
651
+ // plan-quality problem surfaced downstream, not a prepare-time throw.
652
+ const slices = parseDeliverySlicingTable(epic?.body ?? '') ?? [];
653
+
654
+ const checkpointState = await initializeEpicRunStateSingle({
655
+ provider,
656
+ epicId,
657
+ slices,
658
+ concurrencyCap,
659
+ });
660
+
661
+ const docsDigestPath = await writeDocsDigest({ epicId, cwd, config });
662
+
663
+ return {
664
+ epicId,
665
+ deliveryShape: 'single',
666
+ storyCount: 0,
667
+ concurrencyCap,
668
+ sliceCount: Object.keys(checkpointState.slices ?? {}).length,
669
+ slices: checkpointState.slices ?? {},
670
+ epicBranch,
671
+ workCwd,
672
+ worktreeCreated,
673
+ checkpointInitializedAt:
674
+ checkpointState.startedAt ??
675
+ checkpointState.lastUpdatedAt ??
676
+ new Date().toISOString(),
677
+ docsDigestPath,
678
+ };
679
+ }
680
+
427
681
  export async function runEpicDeliverPrepare({
428
682
  epicId,
429
683
  cwd,
@@ -559,6 +813,7 @@ async function main() {
559
813
  const { values } = parseArgs({
560
814
  options: {
561
815
  epic: { type: 'string' },
816
+ single: { type: 'boolean', default: false },
562
817
  help: { type: 'boolean', short: 'h' },
563
818
  'ignore-concurrency-hazards': { type: 'boolean', default: false },
564
819
  steal: { type: 'boolean', default: false },
@@ -578,12 +833,19 @@ async function main() {
578
833
  process.exit(2);
579
834
  }
580
835
 
581
- const result = await runEpicDeliverPrepare({
582
- epicId,
583
- ignoreConcurrencyHazards: values['ignore-concurrency-hazards'] === true,
584
- steal: values.steal === true,
585
- asOperator: typeof values.as === 'string' ? values.as : undefined,
586
- });
836
+ const asOperator = typeof values.as === 'string' ? values.as : undefined;
837
+ const steal = values.steal === true;
838
+
839
+ const result =
840
+ values.single === true
841
+ ? await runEpicDeliverPrepareSingle({ epicId, asOperator, steal })
842
+ : await runEpicDeliverPrepare({
843
+ epicId,
844
+ ignoreConcurrencyHazards:
845
+ values['ignore-concurrency-hazards'] === true,
846
+ steal,
847
+ asOperator,
848
+ });
587
849
  Logger.info(JSON.stringify(result, null, 2));
588
850
  }
589
851
 
@@ -24,6 +24,12 @@ import { applyQualityBootstrap } from './quality-bootstrap.js';
24
24
 
25
25
  export const SYNC_COMMAND = 'node .agents/scripts/sync-claude-commands.js';
26
26
 
27
+ // Epic #4478 (M7-B). The role-scoped agent projection runs in every place the
28
+ // command projection does — `sync:agents` script, `prepare`, and the bootstrap
29
+ // `sync` phase (`runSyncCommands`) — so `.claude/agents/*.md` materializes for
30
+ // hosts that read it, exactly as `.claude/commands/*.md` does.
31
+ export const SYNC_AGENTS_COMMAND = 'node .agents/scripts/sync-claude-agents.js';
32
+
27
33
  export const BOOTSTRAP_COMMAND = 'node .agents/scripts/bootstrap.js';
28
34
 
29
35
  /**
@@ -202,6 +208,7 @@ export function ensurePackageJson(ctx) {
202
208
  const outcomes = {
203
209
  created: false,
204
210
  scriptsSyncCommands: 'already-present',
211
+ scriptsSyncAgents: 'already-present',
205
212
  scriptsPrepare: 'already-present',
206
213
  scriptsBootstrap: 'already-present',
207
214
  };
@@ -220,13 +227,29 @@ export function ensurePackageJson(ctx) {
220
227
  pkg.scripts['sync:commands'] = SYNC_COMMAND;
221
228
  outcomes.scriptsSyncCommands = 'added';
222
229
  }
230
+ if (!pkg.scripts['sync:agents']) {
231
+ pkg.scripts['sync:agents'] = SYNC_AGENTS_COMMAND;
232
+ outcomes.scriptsSyncAgents = 'added';
233
+ }
223
234
  const prepare = pkg.scripts.prepare;
224
235
  if (!prepare) {
225
- pkg.scripts.prepare = SYNC_COMMAND;
236
+ pkg.scripts.prepare = `${SYNC_COMMAND} && ${SYNC_AGENTS_COMMAND}`;
226
237
  outcomes.scriptsPrepare = 'added';
227
- } else if (!prepare.includes('sync-claude-commands.js')) {
228
- pkg.scripts.prepare = `${prepare} && ${SYNC_COMMAND}`;
229
- outcomes.scriptsPrepare = 'appended';
238
+ } else {
239
+ // Append each projection independently so an existing prepare that already
240
+ // carries the command sync still gains the agent sync on the next bootstrap
241
+ // (and both are idempotent — a prepare carrying both is left untouched).
242
+ let next = prepare;
243
+ if (!next.includes('sync-claude-commands.js')) {
244
+ next = `${next} && ${SYNC_COMMAND}`;
245
+ }
246
+ if (!next.includes('sync-claude-agents.js')) {
247
+ next = `${next} && ${SYNC_AGENTS_COMMAND}`;
248
+ }
249
+ if (next !== prepare) {
250
+ pkg.scripts.prepare = next;
251
+ outcomes.scriptsPrepare = 'appended';
252
+ }
230
253
  }
231
254
  // Expose a discoverable `npm run bootstrap` alias for the framework
232
255
  // setup command. An operator-defined `bootstrap` script always wins —
@@ -238,6 +261,7 @@ export function ensurePackageJson(ctx) {
238
261
  const mutated =
239
262
  outcomes.created ||
240
263
  outcomes.scriptsSyncCommands === 'added' ||
264
+ outcomes.scriptsSyncAgents === 'added' ||
241
265
  outcomes.scriptsPrepare !== 'already-present' ||
242
266
  outcomes.scriptsBootstrap === 'added';
243
267
  if (mutated) writeJson(pkgPath, pkg, fsImpl);
@@ -482,25 +506,39 @@ function ensureIssueFormsPhase(ctx) {
482
506
  */
483
507
  export function runSyncCommands(ctx) {
484
508
  const { spawnImpl = defaultSpawnSync } = ctx;
485
- const script = path.join(
509
+ const scriptsDir = path.join(
486
510
  ctx.agentRoot ?? path.join(ctx.projectRoot, '.agents'),
487
511
  'scripts',
488
- 'sync-claude-commands.js',
489
512
  );
490
- const result = spawnImpl(process.execPath, [script], {
491
- cwd: ctx.projectRoot,
492
- encoding: 'utf8',
493
- });
494
- if (result.status !== 0) {
495
- throw new Error(
496
- `[Bootstrap] sync-claude-commands.js failed (exit ${result.status}): ${(
497
- result.stderr ?? ''
498
- )
499
- .trim()
500
- .slice(0, 400)}`,
513
+ // Both projections run here (Epic #4478, M7-B): the command tree AND the
514
+ // role-scoped agent tree, so `mandrel sync` / the postinstall path
515
+ // materializes `.claude/agents/*.md` alongside `.claude/commands/*.md`.
516
+ const projections = [
517
+ { label: 'sync-claude-commands.js', script: 'sync-claude-commands.js' },
518
+ { label: 'sync-claude-agents.js', script: 'sync-claude-agents.js' },
519
+ ];
520
+ const stdouts = [];
521
+ for (const { label, script } of projections) {
522
+ const result = spawnImpl(
523
+ process.execPath,
524
+ [path.join(scriptsDir, script)],
525
+ {
526
+ cwd: ctx.projectRoot,
527
+ encoding: 'utf8',
528
+ },
501
529
  );
530
+ if (result.status !== 0) {
531
+ throw new Error(
532
+ `[Bootstrap] ${label} failed (exit ${result.status}): ${(
533
+ result.stderr ?? ''
534
+ )
535
+ .trim()
536
+ .slice(0, 400)}`,
537
+ );
538
+ }
539
+ stdouts.push((result.stdout ?? '').trim());
502
540
  }
503
- return { ok: true, stdout: (result.stdout ?? '').trim() };
541
+ return { ok: true, stdout: stdouts.filter(Boolean).join('\n') };
504
542
  }
505
543
 
506
544
  /**