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
@@ -34,6 +34,47 @@
34
34
  */
35
35
 
36
36
  import { reconcileAcceptanceSpec as defaultReconcileAcceptanceSpec } from '../../../../acceptance-spec-reconciler.js';
37
+ import { DELIVERY_SINGLE_LABEL } from '../../deliver-route.js';
38
+ import { read as readEpicRunState } from '../../epic-run-state-store.js';
39
+
40
+ /**
41
+ * Default single-delivery resolver (Epic #4475, M4-B, design §2c). Decides
42
+ * whether the Epic under reconciliation is the single-delivery shape — the
43
+ * signal that flips a `waived` reconcile into a hard failure (the back gate
44
+ * of the non-waivable epic-level acceptance contract). Two sources, primary
45
+ * first:
46
+ *
47
+ * 1. `epic-run-state.deliveryShape === 'single'` — the durable checkpoint
48
+ * the single-delivery prepare writes.
49
+ * 2. The `delivery::single` label on the Epic ticket — the plan-time marker.
50
+ *
51
+ * Best-effort + fail-open-to-fan-out: any probe failure (null provider in a
52
+ * unit fixture, a GitHub read error) degrades to `false` (treat as fan-out),
53
+ * so the defence-in-depth back gate never *invents* a block — the front gate
54
+ * (prepare refusing `acceptance::n-a`) is the primary guard. Tests inject
55
+ * `resolveSingleFn` directly to bypass the probes.
56
+ *
57
+ * @param {{ provider?: object|null, epicId: number, config?: object|null }} args
58
+ * @returns {Promise<boolean>}
59
+ */
60
+ export async function defaultResolveSingle({ provider, epicId }) {
61
+ if (!provider) return false;
62
+ try {
63
+ const checkpoint = await readEpicRunState({ provider, epicId });
64
+ if (checkpoint?.deliveryShape === 'single') return true;
65
+ } catch {
66
+ // Fall through to the label probe.
67
+ }
68
+ try {
69
+ const epic = await provider.getTicket(epicId);
70
+ const labels = Array.isArray(epic?.labels) ? epic.labels : [];
71
+ return labels.some(
72
+ (l) => (typeof l === 'string' ? l : l?.name) === DELIVERY_SINGLE_LABEL,
73
+ );
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
37
78
 
38
79
  /**
39
80
  * Classify a `reconcileAcceptanceSpec` result envelope into the typed
@@ -49,6 +90,16 @@ import { reconcileAcceptanceSpec as defaultReconcileAcceptanceSpec } from '../..
49
90
  * `.waived` and route waived Epics through to PR
50
91
  * creation, while empty-spec Epics still
51
92
  * terminate without a PR via `.skipped`.
93
+ * **Non-waivable under single delivery (Epic #4475,
94
+ * M4-B, design §2c):** when `opts.single` is true the
95
+ * epic-level reconcile is the ONLY acceptance gate
96
+ * left, so a `waived` status is treated as a hard
97
+ * `failed` (reason `single-delivery-non-waivable`)
98
+ * instead of passing through — the back gate that
99
+ * forecloses the cohort's dilution (waived reconcile
100
+ * as the sole gate). The front gate (prepare refusing
101
+ * `acceptance::n-a`) already blocks this at seed time;
102
+ * this is defence in depth.
52
103
  * - `'empty-spec'` → the linked spec exists but declares zero AC IDs.
53
104
  * Treated as "no work to do"; emit `.skipped` with
54
105
  * reason `'empty-spec'` so operators see the
@@ -61,14 +112,23 @@ import { reconcileAcceptanceSpec as defaultReconcileAcceptanceSpec } from '../..
61
112
  * the Epic ticket.
62
113
  *
63
114
  * @param {object|undefined|null} result reconciler envelope.
115
+ * @param {{ single?: boolean }} [opts] `single: true` flips a `waived` status
116
+ * into a hard `failed` (the non-waivable epic reconcile back gate).
64
117
  * @returns {{ outcome: 'ok'|'waived'|'skipped'|'failed', reason?: string }}
65
118
  */
66
- export function classifyReconcileResult(result) {
119
+ export function classifyReconcileResult(result, opts = {}) {
120
+ const single = opts?.single === true;
67
121
  if (!result || typeof result !== 'object') {
68
122
  return { outcome: 'failed', reason: 'reconciler-no-result' };
69
123
  }
70
124
  const status = result.status;
71
125
  if (status === 'waived') {
126
+ if (single) {
127
+ return {
128
+ outcome: 'failed',
129
+ reason: 'single-delivery-non-waivable',
130
+ };
131
+ }
72
132
  return { outcome: 'waived', reason: 'waiver' };
73
133
  }
74
134
  if (status === 'empty-spec') {
@@ -112,6 +172,9 @@ export class AcceptanceReconciler {
112
172
  * injected for the same reason.
113
173
  * @param {Function} [opts.reconcileAcceptanceSpecFn] Override of the
114
174
  * helper for tests; defaults to the production export.
175
+ * @param {Function} [opts.resolveSingleFn] Override of the single-delivery
176
+ * resolver for tests (Epic #4475, M4-B); defaults to `defaultResolveSingle`
177
+ * (checkpoint `deliveryShape` → `delivery::single` label).
115
178
  * @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
116
179
  */
117
180
  constructor(opts = {}) {
@@ -134,6 +197,7 @@ export class AcceptanceReconciler {
134
197
  this.config = opts.config ?? null;
135
198
  this.reconcileAcceptanceSpecFn =
136
199
  opts.reconcileAcceptanceSpecFn ?? defaultReconcileAcceptanceSpec;
200
+ this.resolveSingleFn = opts.resolveSingleFn ?? defaultResolveSingle;
137
201
  this.logger = opts.logger ?? console;
138
202
  /** @type {Set<string>} `${event}:${seqId}` keys we've handled. */
139
203
  this._seen = new Set();
@@ -256,7 +320,24 @@ export class AcceptanceReconciler {
256
320
  return;
257
321
  }
258
322
 
259
- const classification = classifyReconcileResult(result);
323
+ // Resolve the delivery shape before classifying: under single delivery a
324
+ // `waived` reconcile is non-waivable (design §2c). Best-effort — a probe
325
+ // failure degrades to `false` (fan-out pass-through), never inventing a
326
+ // block.
327
+ let single = false;
328
+ try {
329
+ single = await this.resolveSingleFn({
330
+ provider: this.provider,
331
+ epicId,
332
+ config: this.config,
333
+ });
334
+ } catch (err) {
335
+ this.logger.warn?.(
336
+ `[AcceptanceReconciler] single-delivery probe failed (treating as fan-out): ${err?.message ?? err}`,
337
+ );
338
+ }
339
+
340
+ const classification = classifyReconcileResult(result, { single });
260
341
  if (classification.outcome === 'ok') {
261
342
  await this._emitOk({ event, seqId, baseRead });
262
343
  return;
@@ -81,6 +81,12 @@ export const SUBSCRIBED_END_EVENTS = Object.freeze([
81
81
  'epic.snapshot.end',
82
82
  'epic.watch.end',
83
83
  'retro.end',
84
+ // Epic #4475 (M4-A) — single-delivery slice boundary. A completed slice is
85
+ // a resume checkpoint for the one long guarded session (the analogue of
86
+ // story.dispatch.end for fan-out). Subscribed now so the pointer updates
87
+ // once the M4-B executor emits slice.end through the bus; inert until then
88
+ // (nothing emits it in M4-A).
89
+ 'slice.end',
84
90
  'story.dispatch.end',
85
91
  ]);
86
92
 
@@ -28,8 +28,9 @@
28
28
  * skip decision this module produces is logged to the plan-metrics ledger
29
29
  * (`appendCriticSkip`) by the callers so under-firing is auditable.
30
30
  *
31
- * Pure, synchronous, no I/O — the `plan-critics.js` CLI owns reading the
32
- * authored artifacts and the resolved config.
31
+ * Pure, synchronous, no I/O — the folded pre-write phase inside
32
+ * `plan-persist.js` owns reading the authored artifacts and the resolved
33
+ * config.
33
34
  */
34
35
 
35
36
  import { evaluateConsolidationPrecondition } from './consolidation-precondition.js';
@@ -129,6 +129,7 @@ export async function runCloseValidationPhase({
129
129
  config,
130
130
  epicBranch: baseBranch,
131
131
  cwd: worktreePath || cwd,
132
+ log: (m) => Logger.info(m),
132
133
  }),
133
134
  log: (m) => Logger.info(m),
134
135
  storyId,
@@ -140,6 +140,7 @@ export async function runPreMergeGates({
140
140
  config,
141
141
  epicBranch,
142
142
  cwd: worktreePath || cwd,
143
+ log: (m) => logger.info?.(m),
143
144
  });
144
145
  const gateCount = Array.isArray(gates) ? gates.length : 0;
145
146
  // Story #2250 — emit `close-validate.start` only when both an epicId
@@ -272,7 +272,7 @@ export function validateAcFreshness({
272
272
  * Epic #2501 introduced this guard after the legacy `baseline-refresh`
273
273
  * leading-token prescription created a wave of commit-msg hook failures
274
274
  * across story-deliver sub-agents. See
275
- * `.agents/skills/core/baseline-refresh/SKILL.md` for the canonical refresh
275
+ * `.agents/skills/core/gates-and-baselines/SKILL.md` for the canonical refresh
276
276
  * shape (Conventional-Commits subject + `baseline-refresh: true` body
277
277
  * trailer).
278
278
  */
@@ -4,7 +4,7 @@
4
4
  * Accepts the canonical resolved config object (the wrapper returned by
5
5
  * `resolveConfig()` with `config.github` populated). The legacy
6
6
  * `orchestration`-shaped argument is no longer supported as part of the
7
- * Epic #2880 hard cutover; see `.agents/rules/git-conventions.md#contract-cutovers-—-no-shim-layer`.
7
+ * Epic #2880 hard cutover; see `.agents/rules/git-conventions-reference.md#contract-cutovers-—-no-shim-layer`.
8
8
  *
9
9
  * @see docs/v5-implementation-plan.md Sprint 1B
10
10
  */
@@ -88,7 +88,7 @@ Your job is to take an Epic (including its inline User Stories) and a Technical
88
88
 
89
89
  ### LABEL CONVENTIONS:
90
90
  - Every ticket must have the \`type::story\` label. No other type label is allowed — the retired Feature and Task tiers have no labels under this hierarchy.
91
- - Every ticket must have a \`persona::[engineer|architect|qa-engineer|engineer-web|etc]\` label indicating WHO should execute it.
91
+ - Every ticket must have a \`persona::[engineer|architect|qa-engineer|security-engineer|etc]\` label indicating WHO should execute it.
92
92
 
93
93
  ### OUTPUT FORMAT:
94
94
  You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown blocks.
@@ -22,6 +22,10 @@ import fs from 'node:fs/promises';
22
22
  import { parseArgs } from 'node:util';
23
23
  import { runAsCli } from './lib/cli-utils.js';
24
24
  import { resolveConfig } from './lib/config-resolver.js';
25
+ import {
26
+ outboxPathFor,
27
+ postCommentOrBuffer,
28
+ } from './lib/orchestration/bookkeeping-outbox.js';
25
29
  import {
26
30
  assertValidStructuredCommentType,
27
31
  upsertStructuredComment,
@@ -37,6 +41,11 @@ Flags:
37
41
  retro, epic-run-state, wave-0-start) (required).
38
42
  --body-file Path to a file containing the markdown body (required).
39
43
  --provider Provider name (default: inferred from .agentrc.json github block).
44
+ --buffer Headless mode: buffer this upsert to the per-Epic bookkeeping
45
+ outbox instead of posting live. Requires --epic. Drained to
46
+ GitHub once at finalize by bookkeeping-reconcile.js (Epic
47
+ #4476). Omit for attended runs (posts live, unchanged).
48
+ --epic <id> Epic id owning the outbox (required with --buffer).
40
49
  --help Show this message.
41
50
  `;
42
51
 
@@ -64,6 +73,8 @@ export function parseArgv(argv) {
64
73
  marker: { type: 'string' },
65
74
  'body-file': { type: 'string' },
66
75
  provider: { type: 'string' },
76
+ buffer: { type: 'boolean' },
77
+ epic: { type: 'string' },
67
78
  help: { type: 'boolean' },
68
79
  },
69
80
  strict: false,
@@ -115,6 +126,33 @@ export async function main(argv = process.argv.slice(2)) {
115
126
  : config;
116
127
  const provider = createProvider(effectiveConfig);
117
128
 
129
+ // Headless buffering (Epic #4476 M5): route the upsert to the per-Epic
130
+ // outbox instead of a live GitHub round-trip. Requires --epic to locate the
131
+ // outbox; finalize's bookkeeping-reconcile.js drains it once. Attended runs
132
+ // (no --buffer) are unchanged.
133
+ if (values.buffer) {
134
+ const epicId = Number.parseInt(values.epic ?? '', 10);
135
+ if (!Number.isInteger(epicId) || epicId <= 0) {
136
+ process.stderr.write(
137
+ '[post-structured-comment] --buffer requires --epic <id>.\n',
138
+ );
139
+ process.exit(2);
140
+ }
141
+ assertValidStructuredCommentType(values.marker);
142
+ const { buffered } = await postCommentOrBuffer({
143
+ provider,
144
+ ticketId,
145
+ marker: values.marker,
146
+ body,
147
+ headless: true,
148
+ outboxPath: outboxPathFor(epicId, config),
149
+ });
150
+ process.stdout.write(
151
+ `${JSON.stringify({ success: true, ticketId, type: values.marker, buffered })}\n`,
152
+ );
153
+ return;
154
+ }
155
+
118
156
  const envelope = await runPostStructuredComment({
119
157
  ticketId,
120
158
  type: values.marker,
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * slice-phase.js — single-delivery slice lifecycle + checkpoint writer
5
+ * (Epic #4475, M4-B). The single-delivery analogue of `story-phase.js`.
6
+ *
7
+ * A single-delivery run collapses the whole Epic into one long guarded session
8
+ * walking the Epic body's `## Delivery Slicing` table on `epic/<id>` (there is
9
+ * no per-Story fan-out, so no `story.heartbeat`). The `deliver-epic-single.md`
10
+ * executor calls this CLI at each slice boundary so the run has:
11
+ *
12
+ * 1. A ledger signal — one `slice.start` / `slice.end` / `slice.heartbeat`
13
+ * record appended to `temp/epic-<epicId>/lifecycle.ndjson` (via the M4-A
14
+ * `emit-slice-lifecycle.js` emitters). `slice.heartbeat` is the
15
+ * forward-progress signal the `/deliver` §2e Idle Watchdog reads to tell
16
+ * the one long session apart from a dead one; `slice.start`/`.end` bracket
17
+ * each slice.
18
+ * 2. A durable checkpoint flip — when `--record <status>` is passed (the
19
+ * executor passes `--record done` after a slice commits to `epic/<id>`),
20
+ * `slices[sliceId].status` is spliced `pending → done` on the
21
+ * `epic-run-state` checkpoint so a resumed run SKIPS the already-landed
22
+ * slice (the branch already carries the work — no re-pay).
23
+ *
24
+ * The ledger emit is best-effort: a missing/unreachable ledger or a schema
25
+ * hiccup is logged and swallowed — the checkpoint is the source of truth, the
26
+ * ledger record is observability (mirrors `story-phase.js`). The checkpoint
27
+ * flip (when requested) is authoritative and its failure propagates.
28
+ *
29
+ * CLI:
30
+ * --epic <id> Epic ID (required).
31
+ * --slice <sliceId> Slice-map key, e.g. slice-1 (required).
32
+ * --event <start|end|heartbeat> Lifecycle event to emit (required).
33
+ * --outcome <done|blocked|failed|skipped>
34
+ * Required for --event end.
35
+ * --record <pending|done|blocked|failed>
36
+ * Flip slices[sliceId].status on the
37
+ * checkpoint. Omit to leave it untouched.
38
+ * --slice-index <n> Zero-based table position (metadata).
39
+ * --title <str> Human-readable slice label.
40
+ * --phase <init|implementing|closing|blocked|done>
41
+ * Heartbeat phase (default implementing).
42
+ * --duration-ms <n> Slice duration for --event end.
43
+ * --work-cwd <path> Single worktree root for the
44
+ * active-slice env export (--event
45
+ * start). Defaults to the process cwd.
46
+ * --no-emit Suppress the ledger emit (tests).
47
+ *
48
+ * Stdout: a single JSON envelope
49
+ * { ok, epicId, sliceId, event, emitted, ledgerPath, recorded, status }
50
+ */
51
+
52
+ import { parseArgs } from 'node:util';
53
+
54
+ import { runAsCli } from './lib/cli-utils.js';
55
+ import { resolveConfig } from './lib/config-resolver.js';
56
+ import { Logger } from './lib/Logger.js';
57
+ import { setActiveSliceEnv } from './lib/observability/active-story-env.js';
58
+ import { recordSliceStatus } from './lib/orchestration/epic-run-state-store.js';
59
+ import {
60
+ emitSliceEnd,
61
+ emitSliceHeartbeat,
62
+ emitSliceStart,
63
+ } from './lib/orchestration/lifecycle/emit-slice-lifecycle.js';
64
+ import { normalizeOperatorHandle } from './lib/orchestration/ticket-lease.js';
65
+ import { createProvider } from './lib/provider-factory.js';
66
+
67
+ const VALID_EVENTS = new Set(['start', 'end', 'heartbeat']);
68
+ const VALID_OUTCOMES = new Set(['done', 'blocked', 'failed', 'skipped']);
69
+ const VALID_HEARTBEAT_PHASES = new Set([
70
+ 'init',
71
+ 'implementing',
72
+ 'closing',
73
+ 'blocked',
74
+ 'done',
75
+ ]);
76
+
77
+ const HELP = `Usage: node .agents/scripts/slice-phase.js \\
78
+ --epic <id> --slice <sliceId> --event <start|end|heartbeat> \\
79
+ [--outcome <done|blocked|failed|skipped>] [--record <pending|done|blocked|failed>] \\
80
+ [--slice-index <n>] [--title <str>] [--phase <phase>] [--duration-ms <n>] [--no-emit]
81
+
82
+ Emits one slice.start / slice.end / slice.heartbeat record to the Epic's
83
+ lifecycle ledger and, when --record is supplied, flips slices[sliceId].status
84
+ on the epic-run-state checkpoint (the M4-B single-delivery slice walk uses
85
+ --record done after each slice commits to epic/<id> so resume skips it).
86
+ `;
87
+
88
+ /**
89
+ * Best-effort ledger emit for one slice boundary. Dispatches to the matching
90
+ * `emit-slice-lifecycle.js` emitter and swallows any append failure (logged,
91
+ * never fatal) — the checkpoint is state, the ledger is observability. A
92
+ * programming error (bad event / missing outcome) still throws so it surfaces
93
+ * in tests.
94
+ *
95
+ * @param {object} args
96
+ * @returns {{ emitted: boolean, ledgerPath: string|null }}
97
+ */
98
+ function emitSliceEventBestEffort({
99
+ event,
100
+ epicId,
101
+ sliceId,
102
+ sliceIndex,
103
+ title,
104
+ outcome,
105
+ durationMs,
106
+ phase,
107
+ operator,
108
+ config,
109
+ ledgerPath,
110
+ timestamp,
111
+ }) {
112
+ try {
113
+ let res;
114
+ if (event === 'start') {
115
+ res = emitSliceStart({
116
+ epicId,
117
+ sliceId,
118
+ ...(Number.isInteger(sliceIndex) ? { sliceIndex } : {}),
119
+ ...(typeof title === 'string' ? { title } : {}),
120
+ timestamp,
121
+ config: config ?? undefined,
122
+ ledgerPath,
123
+ });
124
+ } else if (event === 'end') {
125
+ res = emitSliceEnd({
126
+ epicId,
127
+ sliceId,
128
+ outcome,
129
+ ...(Number.isInteger(sliceIndex) ? { sliceIndex } : {}),
130
+ ...(Number.isInteger(durationMs) ? { durationMs } : {}),
131
+ timestamp,
132
+ config: config ?? undefined,
133
+ ledgerPath,
134
+ });
135
+ } else {
136
+ res = emitSliceHeartbeat({
137
+ epicId,
138
+ sliceId,
139
+ phase,
140
+ timestamp,
141
+ ...(operator !== null && operator !== undefined ? { operator } : {}),
142
+ config: config ?? undefined,
143
+ ledgerPath,
144
+ });
145
+ }
146
+ return { emitted: true, ledgerPath: res.ledgerPath };
147
+ } catch (err) {
148
+ Logger.warn(
149
+ `[slice-phase] slice.${event} emit failed (continuing): ${err.message}`,
150
+ );
151
+ return { emitted: false, ledgerPath: null };
152
+ }
153
+ }
154
+
155
+ /**
156
+ * End-to-end slice-phase writer. DI-friendly: tests pass `provider`, override
157
+ * the ledger path, and skip the emit as needed.
158
+ *
159
+ * @param {{
160
+ * epicId: number,
161
+ * sliceId: string,
162
+ * event: string,
163
+ * outcome?: string,
164
+ * record?: string,
165
+ * sliceIndex?: number,
166
+ * title?: string,
167
+ * phase?: string,
168
+ * durationMs?: number,
169
+ * noEmit?: boolean,
170
+ * provider?: object,
171
+ * config?: object,
172
+ * ledgerPath?: string,
173
+ * now?: Date,
174
+ * }} args
175
+ */
176
+ export async function runSlicePhase(args) {
177
+ const {
178
+ epicId,
179
+ sliceId,
180
+ event,
181
+ outcome,
182
+ record,
183
+ sliceIndex,
184
+ title,
185
+ phase = 'implementing',
186
+ durationMs,
187
+ noEmit = false,
188
+ provider: providerOverride,
189
+ config: configOverride,
190
+ ledgerPath: ledgerPathOverride,
191
+ workCwd: workCwdOverride,
192
+ now = new Date(),
193
+ } = args ?? {};
194
+
195
+ if (!Number.isInteger(epicId) || epicId <= 0) {
196
+ throw new Error('runSlicePhase: --epic must be a positive integer');
197
+ }
198
+ if (typeof sliceId !== 'string' || sliceId.length === 0) {
199
+ throw new Error('runSlicePhase: --slice must be a non-empty string');
200
+ }
201
+ if (!VALID_EVENTS.has(event)) {
202
+ throw new Error(
203
+ `runSlicePhase: --event "${event}" must be one of: ${[...VALID_EVENTS].join(', ')}`,
204
+ );
205
+ }
206
+ if (event === 'end' && !VALID_OUTCOMES.has(outcome)) {
207
+ throw new Error(
208
+ `runSlicePhase: --event end requires --outcome one of: ${[...VALID_OUTCOMES].join(', ')}`,
209
+ );
210
+ }
211
+ if (event === 'heartbeat' && !VALID_HEARTBEAT_PHASES.has(phase)) {
212
+ throw new Error(
213
+ `runSlicePhase: --phase "${phase}" must be one of: ${[...VALID_HEARTBEAT_PHASES].join(', ')}`,
214
+ );
215
+ }
216
+
217
+ const config = configOverride ?? (providerOverride ? null : resolveConfig());
218
+ const operator = normalizeOperatorHandle(config?.github?.operatorHandle);
219
+ const timestamp = now.toISOString();
220
+
221
+ // Epic #4476 (M5): at each slice boundary START, export the active-slice
222
+ // env (`CC_EPIC_ID` / `CC_SLICE_ID` / `CC_OPERATOR`) into the single
223
+ // worktree's `.env.local` so the PostToolUse hook emits `slice.heartbeat`
224
+ // off the token stream for the duration of this slice — no per-step
225
+ // heartbeat CLI turn. Best-effort: a write failure never blocks the slice.
226
+ // Gated on an explicit `workCwd` (the CLI supplies `process.cwd()`); a bare
227
+ // `runSlicePhase` call in a unit test passes none and never touches the env
228
+ // or writes a `.env.local`.
229
+ let sliceEnvWritten = false;
230
+ if (
231
+ event === 'start' &&
232
+ typeof workCwdOverride === 'string' &&
233
+ workCwdOverride.length > 0
234
+ ) {
235
+ try {
236
+ const res = setActiveSliceEnv({
237
+ epicId,
238
+ sliceId,
239
+ ...(operator ? { operator } : {}),
240
+ workCwd: workCwdOverride,
241
+ logger: Logger,
242
+ });
243
+ sliceEnvWritten = res.fileWritten;
244
+ } catch (err) {
245
+ Logger.warn(
246
+ `[slice-phase] active-slice env export failed (continuing): ${err.message}`,
247
+ );
248
+ }
249
+ }
250
+
251
+ let emitted = false;
252
+ let ledgerPath = null;
253
+ if (!noEmit) {
254
+ ({ emitted, ledgerPath } = emitSliceEventBestEffort({
255
+ event,
256
+ epicId,
257
+ sliceId,
258
+ sliceIndex,
259
+ title,
260
+ outcome,
261
+ durationMs,
262
+ phase,
263
+ operator,
264
+ config,
265
+ ledgerPath: ledgerPathOverride,
266
+ timestamp,
267
+ }));
268
+ }
269
+
270
+ // Authoritative checkpoint flip — only when --record is supplied. Unlike the
271
+ // best-effort emit, a record failure propagates (the slice-map marker is the
272
+ // resume contract; a silent failure would re-pay the slice on resume).
273
+ let recorded = false;
274
+ let status = null;
275
+ if (typeof record === 'string' && record.length > 0) {
276
+ const provider =
277
+ providerOverride ?? createProvider(config ?? resolveConfig());
278
+ await recordSliceStatus({
279
+ provider,
280
+ epicId,
281
+ sliceId,
282
+ status: record,
283
+ ...(typeof title === 'string' && title ? { title } : {}),
284
+ });
285
+ recorded = true;
286
+ status = record;
287
+ }
288
+
289
+ return {
290
+ ok: true,
291
+ epicId,
292
+ sliceId,
293
+ event,
294
+ emitted,
295
+ ledgerPath,
296
+ recorded,
297
+ status,
298
+ sliceEnvWritten,
299
+ };
300
+ }
301
+
302
+ export function parseArgv(argv) {
303
+ const { values } = parseArgs({
304
+ args: argv,
305
+ options: {
306
+ epic: { type: 'string' },
307
+ slice: { type: 'string' },
308
+ event: { type: 'string' },
309
+ outcome: { type: 'string' },
310
+ record: { type: 'string' },
311
+ 'slice-index': { type: 'string' },
312
+ title: { type: 'string' },
313
+ phase: { type: 'string' },
314
+ 'duration-ms': { type: 'string' },
315
+ 'work-cwd': { type: 'string' },
316
+ 'no-emit': { type: 'boolean' },
317
+ help: { type: 'boolean' },
318
+ },
319
+ strict: false,
320
+ });
321
+ const parsed = {
322
+ help: Boolean(values.help),
323
+ epicId: Number.parseInt(values.epic ?? '', 10),
324
+ sliceId: values.slice,
325
+ event: values.event,
326
+ noEmit: Boolean(values['no-emit']),
327
+ };
328
+ if (typeof values.outcome === 'string') parsed.outcome = values.outcome;
329
+ if (typeof values.record === 'string') parsed.record = values.record;
330
+ if (typeof values.title === 'string') parsed.title = values.title;
331
+ if (typeof values.phase === 'string') parsed.phase = values.phase;
332
+ if (typeof values['work-cwd'] === 'string' && values['work-cwd']) {
333
+ parsed.workCwd = values['work-cwd'];
334
+ }
335
+ if (values['slice-index'] !== undefined) {
336
+ parsed.sliceIndex = Number.parseInt(values['slice-index'], 10);
337
+ }
338
+ if (values['duration-ms'] !== undefined) {
339
+ parsed.durationMs = Number.parseInt(values['duration-ms'], 10);
340
+ }
341
+ return parsed;
342
+ }
343
+
344
+ export async function main(argv = process.argv.slice(2)) {
345
+ const parsed = parseArgv(argv);
346
+ if (parsed.help) {
347
+ process.stdout.write(HELP);
348
+ return;
349
+ }
350
+ // The CLI runs from inside the single worktree (the executor `cd`s there),
351
+ // so default the active-slice env target to the process cwd unless an
352
+ // explicit --work-cwd overrides it. runSlicePhase only writes the
353
+ // `.env.local` for --event start.
354
+ const envelope = await runSlicePhase({
355
+ ...parsed,
356
+ workCwd: parsed.workCwd ?? process.cwd(),
357
+ });
358
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
359
+ }
360
+
361
+ runAsCli(import.meta.url, main, { source: 'slice-phase' });