mandrel 1.92.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 (144) 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/file-assumptions.js +68 -7
  44. package/.agents/scripts/lib/orchestration/lifecycle/emit-slice-lifecycle.js +270 -0
  45. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +83 -2
  46. package/.agents/scripts/lib/orchestration/lifecycle/listeners/checkpoint-pointer-writer.js +6 -0
  47. package/.agents/scripts/lib/orchestration/plan-context.js +189 -3
  48. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +3 -2
  49. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +99 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +38 -1
  51. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +16 -1
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -0
  53. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +1 -0
  54. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -2
  55. package/.agents/scripts/lib/provider-factory.js +1 -1
  56. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  57. package/.agents/scripts/plan-context.js +28 -10
  58. package/.agents/scripts/post-structured-comment.js +38 -0
  59. package/.agents/scripts/slice-phase.js +361 -0
  60. package/.agents/scripts/sync-claude-agents.js +165 -0
  61. package/.agents/scripts/update-ticket-state.js +31 -0
  62. package/.agents/scripts/wave-tick.js +138 -9
  63. package/.agents/skills/core/api-and-interface-design/SKILL.md +5 -3
  64. package/.agents/skills/core/code-review-and-quality/SKILL.md +63 -7
  65. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +1 -1
  66. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +5 -5
  67. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +8 -8
  68. package/.agents/skills/core/epic-plan-premortem/SKILL.md +4 -4
  69. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +26 -56
  70. package/.agents/skills/core/gates-and-baselines/SKILL.md +149 -0
  71. package/.agents/skills/core/idea-refinement/SKILL.md +2 -8
  72. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +7 -7
  73. package/.agents/skills/skills.index.json +11 -381
  74. package/.agents/workflows/deliver.md +47 -4
  75. package/.agents/workflows/helpers/acceptance-self-eval.md +38 -13
  76. package/.agents/workflows/helpers/deliver-epic-reference.md +18 -5
  77. package/.agents/workflows/helpers/deliver-epic-single.md +331 -0
  78. package/.agents/workflows/helpers/deliver-epic.md +51 -8
  79. package/.agents/workflows/helpers/deliver-stories.md +15 -5
  80. package/.agents/workflows/helpers/epic-deliver-story.md +12 -3
  81. package/.agents/workflows/helpers/mandrel-sync-config.md +1 -1
  82. package/.agents/workflows/helpers/plan-epic-reference.md +19 -8
  83. package/.agents/workflows/helpers/plan-epic.md +95 -27
  84. package/.agents/workflows/helpers/scope-triage-gate.md +9 -0
  85. package/.agents/workflows/mandrel-update.md +1 -1
  86. package/.agents/workflows/plan.md +16 -4
  87. package/docs/CHANGELOG.md +23 -0
  88. package/lib/cli/registry.js +95 -0
  89. package/package.json +4 -2
  90. package/.agents/personas/engineer-mobile.md +0 -120
  91. package/.agents/personas/engineer-web.md +0 -111
  92. package/.agents/personas/product.md +0 -94
  93. package/.agents/personas/refactorer.md +0 -113
  94. package/.agents/personas/sre.md +0 -86
  95. package/.agents/personas/ux-designer.md +0 -95
  96. package/.agents/scripts/epic-plan-decompose.js +0 -54
  97. package/.agents/scripts/epic-plan-spec.js +0 -64
  98. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +0 -109
  99. package/.agents/scripts/plan-critics.js +0 -227
  100. package/.agents/skills/core/baseline-refresh/SKILL.md +0 -181
  101. package/.agents/skills/core/ci-cd-and-automation/SKILL.md +0 -274
  102. package/.agents/skills/core/ci-cd-and-automation/examples.md +0 -211
  103. package/.agents/skills/core/code-simplification/SKILL.md +0 -389
  104. package/.agents/skills/core/context-engineering/SKILL.md +0 -309
  105. package/.agents/skills/core/context-engineering/examples.md +0 -58
  106. package/.agents/skills/core/deprecation-and-migration/SKILL.md +0 -250
  107. package/.agents/skills/core/frontend-ui-engineering/SKILL.md +0 -357
  108. package/.agents/skills/core/hydrate-context/SKILL.md +0 -123
  109. package/.agents/skills/core/idea-refinement/examples.md +0 -437
  110. package/.agents/skills/core/idea-refinement/frameworks.md +0 -135
  111. package/.agents/skills/core/incremental-implementation/SKILL.md +0 -271
  112. package/.agents/skills/core/introducing-a-baseline-gate/SKILL.md +0 -213
  113. package/.agents/skills/core/knowledge-transfer/SKILL.md +0 -180
  114. package/.agents/skills/core/mutation-survivor-remediation/SKILL.md +0 -117
  115. package/.agents/skills/core/performance-optimization/SKILL.md +0 -314
  116. package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +0 -277
  117. package/.agents/skills/core/property-based-testing/SKILL.md +0 -148
  118. package/.agents/skills/core/refactoring-discipline/SKILL.md +0 -111
  119. package/.agents/skills/core/shipping-and-launch/SKILL.md +0 -328
  120. package/.agents/skills/core/spec-driven-development/SKILL.md +0 -252
  121. package/.agents/skills/core/test-driven-development/SKILL.md +0 -475
  122. package/.agents/skills/core/using-agent-skills/SKILL.md +0 -232
  123. package/.agents/skills/stack/architecture/monorepo-path-strategist/SKILL.md +0 -31
  124. package/.agents/skills/stack/architecture/structured-output-zod/SKILL.md +0 -51
  125. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +0 -76
  126. package/.agents/skills/stack/backend/cloudflare-hono-architect/SKILL.md +0 -31
  127. package/.agents/skills/stack/backend/cloudflare-hono-architect/examples/route-template.ts +0 -33
  128. package/.agents/skills/stack/backend/cloudflare-queue-manager/SKILL.md +0 -31
  129. package/.agents/skills/stack/backend/cloudflare-workers/SKILL.md +0 -51
  130. package/.agents/skills/stack/backend/highlevel-crm/SKILL.md +0 -54
  131. package/.agents/skills/stack/backend/sqlite-drizzle-expert/SKILL.md +0 -29
  132. package/.agents/skills/stack/backend/sqlite-drizzle-expert/examples/schema-template.ts +0 -30
  133. package/.agents/skills/stack/backend/stripe-integration/SKILL.md +0 -57
  134. package/.agents/skills/stack/backend/stripe-integration/scripts/listen-stripe.sh +0 -9
  135. package/.agents/skills/stack/backend/turso-sqlite/SKILL.md +0 -48
  136. package/.agents/skills/stack/frontend/astro/SKILL.md +0 -62
  137. package/.agents/skills/stack/frontend/astro-react-island-strategist/SKILL.md +0 -30
  138. package/.agents/skills/stack/frontend/expo-react-native-developer/SKILL.md +0 -29
  139. package/.agents/skills/stack/frontend/google-analytics-v4/SKILL.md +0 -50
  140. package/.agents/skills/stack/frontend/tailwind-v4/SKILL.md +0 -58
  141. package/.agents/skills/stack/frontend/ui-accessibility-engineer/SKILL.md +0 -34
  142. package/.agents/skills/stack/qa/audit-accessibility/SKILL.md +0 -51
  143. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +0 -199
  144. package/.agents/skills/stack/security/backend-security-patterns/SKILL.md +0 -68
@@ -37,9 +37,21 @@ const ENV_LOCAL_BASENAME = '.env.local';
37
37
  /**
38
38
  * Names of the env vars we own. Keeping the list central makes the
39
39
  * round-trip (set on init, clear on close) trivially auditable —
40
- * grep `CC_EPIC_ID` / `CC_STORY_ID` to find every read site.
40
+ * grep `CC_EPIC_ID` / `CC_STORY_ID` / `CC_SLICE_ID` to find every read site.
41
+ *
42
+ * Epic #4476 (M5) added `CC_SLICE_ID` (the single-delivery analogue of
43
+ * `CC_STORY_ID` — set on the ONE long guarded session so the PostToolUse
44
+ * hook can emit `slice.heartbeat` off the token stream) and `CC_OPERATOR`
45
+ * (the resolved lease-owner handle, stamped onto hook-emitted heartbeats so
46
+ * `latestHeartbeatForOwner` keeps resolving a live claim). The clear path
47
+ * wipes all four so no stale context leaks past close.
41
48
  */
42
- export const ACTIVE_STORY_ENV_KEYS = ['CC_EPIC_ID', 'CC_STORY_ID'];
49
+ export const ACTIVE_STORY_ENV_KEYS = [
50
+ 'CC_EPIC_ID',
51
+ 'CC_STORY_ID',
52
+ 'CC_SLICE_ID',
53
+ 'CC_OPERATOR',
54
+ ];
43
55
 
44
56
  /**
45
57
  * Render the `.env.local` body. One `KEY=value` line per var, LF
@@ -180,3 +192,100 @@ export function clearActiveStoryEnv({
180
192
  }
181
193
  return { envCleared: true, fileRemoved, filePath };
182
194
  }
195
+
196
+ /**
197
+ * Render the single-delivery `.env.local` body. The single-delivery executor
198
+ * (`deliver-epic-single.md`) walks the Delivery Slicing table inside ONE long
199
+ * session; there is no Story fan-out and thus no `CC_STORY_ID`. Instead the
200
+ * "current slice" is exported as `CC_SLICE_ID` so the PostToolUse hook emits
201
+ * `slice.heartbeat` — the watchdog's forward-progress signal for the single
202
+ * session — as a free byproduct of tool activity.
203
+ *
204
+ * `CC_STORY_ID` MUST be absent (its presence would make the hook emit
205
+ * `story.heartbeat` and the trace path key off a non-existent Story). The
206
+ * optional `operator` handle is emitted as `CC_OPERATOR` only when supplied.
207
+ *
208
+ * Exported for testing.
209
+ *
210
+ * @param {{ epicId: number, sliceId: string, operator?: string }} input
211
+ * @returns {string}
212
+ */
213
+ export function renderActiveSliceEnvFile({ epicId, sliceId, operator }) {
214
+ const lines = [
215
+ '# Auto-managed by .agents/scripts/lib/observability/active-story-env.js',
216
+ '# Re-generated on every slice-start; deleted on epic-close.',
217
+ `CC_EPIC_ID=${epicId}`,
218
+ `CC_SLICE_ID=${sliceId}`,
219
+ ];
220
+ if (typeof operator === 'string' && operator.length > 0) {
221
+ lines.push(`CC_OPERATOR=${operator}`);
222
+ }
223
+ lines.push('');
224
+ return lines.join('\n');
225
+ }
226
+
227
+ /**
228
+ * Set `CC_EPIC_ID` / `CC_SLICE_ID` (+ optional `CC_OPERATOR`) on the current
229
+ * process and, when `workCwd` is provided, export them to
230
+ * `<workCwd>/.env.local` for the harness to reload on the next tool call. The
231
+ * single-delivery analogue of {@link setActiveStoryEnv}; called at each
232
+ * `slice.start` boundary so the hook's throttled `slice.heartbeat` is keyed to
233
+ * the slice currently being implemented.
234
+ *
235
+ * `CC_STORY_ID` is explicitly removed so a prior Story context (if any ever
236
+ * leaked in) cannot make the hook emit the wrong heartbeat shape.
237
+ *
238
+ * @param {{ epicId: number, sliceId: string, operator?: string|null,
239
+ * workCwd?: string, env?: NodeJS.ProcessEnv, fs?: typeof nodeFs,
240
+ * logger?: { warn?: (m: string) => void } }} args
241
+ * @returns {{ envSet: boolean, fileWritten: boolean, filePath: string|null }}
242
+ */
243
+ export function setActiveSliceEnv({
244
+ epicId,
245
+ sliceId,
246
+ operator,
247
+ workCwd,
248
+ env = process.env,
249
+ fs = nodeFs,
250
+ logger,
251
+ } = {}) {
252
+ if (!Number.isInteger(epicId) || epicId <= 0) {
253
+ throw new Error(
254
+ `[active-story-env] epicId must be a positive integer; got ${epicId}`,
255
+ );
256
+ }
257
+ if (typeof sliceId !== 'string' || sliceId.length === 0) {
258
+ throw new Error(
259
+ `[active-story-env] sliceId must be a non-empty string; got ${sliceId}`,
260
+ );
261
+ }
262
+ const normOperator =
263
+ typeof operator === 'string' && operator.length > 0 ? operator : undefined;
264
+
265
+ if ('CC_STORY_ID' in env) delete env.CC_STORY_ID;
266
+ env.CC_EPIC_ID = String(epicId);
267
+ env.CC_SLICE_ID = sliceId;
268
+ if (normOperator) env.CC_OPERATOR = normOperator;
269
+ else if ('CC_OPERATOR' in env) delete env.CC_OPERATOR;
270
+
271
+ let fileWritten = false;
272
+ let filePath = null;
273
+ if (typeof workCwd === 'string' && workCwd.length > 0) {
274
+ filePath = nodePath.join(workCwd, ENV_LOCAL_BASENAME);
275
+ try {
276
+ fs.writeFileSync(
277
+ filePath,
278
+ renderActiveSliceEnvFile({ epicId, sliceId, operator: normOperator }),
279
+ { encoding: 'utf8' },
280
+ );
281
+ fileWritten = true;
282
+ } catch (err) {
283
+ logger?.warn?.(
284
+ `[active-story-env] Failed to write ${filePath}: ${
285
+ err instanceof Error ? err.message : String(err)
286
+ }`,
287
+ );
288
+ }
289
+ }
290
+ return { envSet: true, fileWritten, filePath };
291
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * hook-heartbeat.js — Epic #4476 (M5): heartbeats OFF the token stream.
3
+ *
4
+ * Today the `story.heartbeat` / `slice.heartbeat` forward-progress signal the
5
+ * `/deliver` §2e Idle Watchdog (`wave-tick.js --check-idle 30`) reads is an
6
+ * **LLM obligation**: the delivery workflow instructs the agent to run
7
+ * `story-phase.js` / `slice-phase.js --event heartbeat` at least once per
8
+ * meaningful step, and every such call is a full-priced LLM turn re-reading
9
+ * ~100k of cached context just to append one NDJSON line.
10
+ *
11
+ * This module makes that liveness signal a **free byproduct of the agent
12
+ * doing any work**. It is invoked from the existing PostToolUse trace hook
13
+ * (`tool-trace-hook.js`), so every tool call the agent makes refreshes the
14
+ * heartbeat — no dedicated bookkeeping turn required. The record it appends is
15
+ * byte-for-byte the same `story.heartbeat` / `slice.heartbeat` shape the
16
+ * watchdog already consumes (emitted through the same
17
+ * `emit-story-heartbeat.js` / `emit-slice-lifecycle.js` emitters), so the
18
+ * watchdog needs no change: the signal is preserved, only its emission
19
+ * mechanism moves off the token stream.
20
+ *
21
+ * ## Robustness contract (mirrors `tool-trace-hook.js`)
22
+ * - **No-op outside an active Story / slice.** When neither a valid
23
+ * `{ CC_EPIC_ID, CC_STORY_ID }` (fan-out child) nor a valid
24
+ * `{ CC_EPIC_ID, CC_SLICE_ID }` (single-delivery session) pair is present
25
+ * in the environment, `emitHeartbeatFromHook` returns without touching the
26
+ * filesystem.
27
+ * - **Best-effort.** Every failure is swallowed. A heartbeat is
28
+ * observability, not state; a hook must never block tool execution.
29
+ * - **Throttled across processes.** Command hooks are spawned as fresh
30
+ * `node` processes per tool call, so in-process state cannot throttle. The
31
+ * throttle is anchored to the **mtime of a sidecar marker file** under the
32
+ * Epic temp dir: at most one heartbeat per `HEARTBEAT_MIN_INTERVAL_MS`
33
+ * lands, regardless of how many tool calls fire in that window. This keeps
34
+ * the ledger from ballooning while still refreshing liveness faster than
35
+ * the watchdog's threshold.
36
+ */
37
+
38
+ import { statSync, utimesSync, writeFileSync } from 'node:fs';
39
+ import path from 'node:path';
40
+
41
+ import { epicTempDir } from '../config/temp-paths.js';
42
+ import { emitSliceHeartbeat } from '../orchestration/lifecycle/emit-slice-lifecycle.js';
43
+ import { emitStoryHeartbeat } from '../orchestration/lifecycle/emit-story-heartbeat.js';
44
+
45
+ /**
46
+ * Default minimum wall-clock gap between two hook-emitted heartbeats for the
47
+ * same target. Comfortably below the watchdog's default 30-minute staleness
48
+ * threshold, so an actively-working agent never trips a false stall, while
49
+ * still bounding ledger growth to ~1 line/minute of activity.
50
+ */
51
+ const HEARTBEAT_MIN_INTERVAL_MS = 60_000;
52
+
53
+ /**
54
+ * Resolve the heartbeat target from the active-Story / active-slice env vars.
55
+ *
56
+ * Precedence: a present `CC_STORY_ID` (fan-out Story child) wins over
57
+ * `CC_SLICE_ID` (single-delivery session) — the two are never set together in
58
+ * practice, but the ordering keeps the resolution deterministic if they were.
59
+ *
60
+ * A `story.heartbeat` requires a parent `epicId` (its schema pins
61
+ * `epicId >= 1`), so a **standalone** Story (`CC_STORY_ID` set, `CC_EPIC_ID`
62
+ * absent) yields `null` — there is no Epic-scoped ledger to write to and the
63
+ * standalone path is not watched by the Epic idle watchdog. The trace hook
64
+ * still records that context's traces; only the heartbeat is skipped.
65
+ *
66
+ * @param {NodeJS.ProcessEnv} [env]
67
+ * @returns {{ kind: 'story', epicId: number, storyId: number, operator?: string }
68
+ * | { kind: 'slice', epicId: number, sliceId: string, operator?: string }
69
+ * | null}
70
+ */
71
+ export function resolveHeartbeatTarget(env = process.env) {
72
+ const epicRaw = env.CC_EPIC_ID;
73
+ const storyRaw = env.CC_STORY_ID;
74
+ const sliceRaw = env.CC_SLICE_ID;
75
+ const operator =
76
+ typeof env.CC_OPERATOR === 'string' && env.CC_OPERATOR.length > 0
77
+ ? env.CC_OPERATOR
78
+ : undefined;
79
+
80
+ const epicId = epicRaw ? Number.parseInt(epicRaw, 10) : Number.NaN;
81
+ const epicOk = Number.isInteger(epicId) && epicId > 0;
82
+
83
+ if (storyRaw) {
84
+ const storyId = Number.parseInt(storyRaw, 10);
85
+ if (!Number.isInteger(storyId) || storyId <= 0) return null;
86
+ // story.heartbeat has no meaning without a parent Epic ledger.
87
+ if (!epicOk) return null;
88
+ return {
89
+ kind: 'story',
90
+ epicId,
91
+ storyId,
92
+ ...(operator ? { operator } : {}),
93
+ };
94
+ }
95
+
96
+ if (sliceRaw) {
97
+ if (typeof sliceRaw !== 'string' || sliceRaw.length === 0) return null;
98
+ if (!epicOk) return null;
99
+ return {
100
+ kind: 'slice',
101
+ epicId,
102
+ sliceId: sliceRaw,
103
+ ...(operator ? { operator } : {}),
104
+ };
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Stable, filesystem-safe marker basename for a target. The marker's mtime is
112
+ * the throttle anchor; its contents are irrelevant.
113
+ *
114
+ * @param {{ kind: string, storyId?: number, sliceId?: string }} target
115
+ * @returns {string}
116
+ */
117
+ export function heartbeatMarkerName(target) {
118
+ const key =
119
+ target.kind === 'story'
120
+ ? `story-${target.storyId}`
121
+ : `slice-${String(target.sliceId).replace(/[^A-Za-z0-9._-]/g, '_')}`;
122
+ return `.heartbeat-${key}`;
123
+ }
124
+
125
+ /**
126
+ * Throttle predicate. Returns `true` when no marker exists yet, or the marker
127
+ * is older than `intervalMs`. Any stat error (missing file / unreadable) is
128
+ * treated as "emit" — the safe direction for a liveness signal.
129
+ *
130
+ * @param {{ markerPath: string, now: Date, intervalMs: number, statFn?: typeof statSync }} args
131
+ * @returns {boolean}
132
+ */
133
+ export function shouldEmitHeartbeat({
134
+ markerPath,
135
+ now,
136
+ intervalMs,
137
+ statFn = statSync,
138
+ }) {
139
+ try {
140
+ const st = statFn(markerPath);
141
+ return now.getTime() - st.mtimeMs >= intervalMs;
142
+ } catch {
143
+ return true;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Update (or create) the throttle marker so its mtime is `now`. Best-effort:
149
+ * a failure here at worst lets the next tool call emit a second heartbeat.
150
+ *
151
+ * @param {string} markerPath
152
+ * @param {Date} now
153
+ */
154
+ function touchMarker(markerPath, now) {
155
+ try {
156
+ writeFileSync(markerPath, '', { flag: 'a' });
157
+ utimesSync(markerPath, now, now);
158
+ } catch {
159
+ // swallow — throttle degrades to "emit again next call", never fatal.
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Emit one throttled heartbeat for the active target, if any. The whole body
165
+ * is best-effort: any failure returns a `{ emitted: false }` envelope rather
166
+ * than throwing, so the calling PostToolUse hook never blocks the tool.
167
+ *
168
+ * @param {object} [opts]
169
+ * @param {NodeJS.ProcessEnv} [opts.env] Defaults to `process.env`.
170
+ * @param {Date} [opts.now] Injected clock (tests).
171
+ * @param {number} [opts.intervalMs] Throttle window.
172
+ * @param {object} [opts.config] Resolved config (tempRoot).
173
+ * @returns {{ emitted: boolean, reason?: string, kind?: string, ledgerPath?: string }}
174
+ */
175
+ export function emitHeartbeatFromHook({
176
+ env = process.env,
177
+ now = new Date(),
178
+ intervalMs = HEARTBEAT_MIN_INTERVAL_MS,
179
+ config,
180
+ } = {}) {
181
+ try {
182
+ const target = resolveHeartbeatTarget(env);
183
+ if (!target) return { emitted: false, reason: 'no-target' };
184
+
185
+ const markerPath = path.join(
186
+ epicTempDir(target.epicId, config),
187
+ heartbeatMarkerName(target),
188
+ );
189
+ if (!shouldEmitHeartbeat({ markerPath, now, intervalMs })) {
190
+ return { emitted: false, reason: 'throttled', kind: target.kind };
191
+ }
192
+
193
+ const timestamp = now.toISOString();
194
+ let res;
195
+ if (target.kind === 'story') {
196
+ res = emitStoryHeartbeat({
197
+ epicId: target.epicId,
198
+ storyId: target.storyId,
199
+ phase: 'implementing',
200
+ timestamp,
201
+ ...(target.operator ? { operator: target.operator } : {}),
202
+ config: config ?? undefined,
203
+ });
204
+ } else {
205
+ res = emitSliceHeartbeat({
206
+ epicId: target.epicId,
207
+ sliceId: target.sliceId,
208
+ phase: 'implementing',
209
+ timestamp,
210
+ ...(target.operator ? { operator: target.operator } : {}),
211
+ config: config ?? undefined,
212
+ });
213
+ }
214
+ touchMarker(markerPath, now);
215
+ return { emitted: true, kind: target.kind, ledgerPath: res?.ledgerPath };
216
+ } catch {
217
+ return { emitted: false, reason: 'error' };
218
+ }
219
+ }
@@ -48,6 +48,7 @@
48
48
 
49
49
  import { createHash } from 'node:crypto';
50
50
 
51
+ import { emitHeartbeatFromHook } from './hook-heartbeat.js';
51
52
  import { appendTrace } from './signals-writer.js';
52
53
 
53
54
  /**
@@ -381,15 +382,25 @@ export async function handlePost(event, active) {
381
382
  */
382
383
  export async function main(event) {
383
384
  try {
384
- const active = resolveActiveStory();
385
- if (!active) return; // No active Story => zero filesystem calls.
386
385
  if (!event || typeof event !== 'object') return;
386
+ const active = resolveActiveStory();
387
387
 
388
388
  const phase = event.hook_event_name;
389
389
  if (phase === 'PreToolUse') {
390
- handlePre(event);
390
+ // Pre-pairing only matters for the trace-line duration, which only
391
+ // the Story-scoped trace path records; slice-only context (single
392
+ // delivery) needs no Pre.
393
+ if (active) handlePre(event);
391
394
  } else if (phase === 'PostToolUse') {
392
- await handlePost(event, active);
395
+ if (active) await handlePost(event, active);
396
+ // Heartbeat OFF the token stream (Epic #4476 M5). A throttled
397
+ // story.heartbeat / slice.heartbeat, keyed off the same active-Story /
398
+ // active-slice env vars, so the §2e Idle Watchdog's forward-progress
399
+ // signal is a free byproduct of ANY tool call — no dedicated
400
+ // bookkeeping LLM turn. Best-effort and self-guarded; resolves its own
401
+ // target (fires for slice context even when `active` is null because
402
+ // there is no CC_STORY_ID under single delivery).
403
+ emitHeartbeatFromHook();
393
404
  }
394
405
  // Any other phase is silently ignored — the hook is registered for
395
406
  // Pre/Post only; receiving anything else is a configuration error
@@ -0,0 +1,111 @@
1
+ /**
2
+ * acceptance-clusters.js — Epic #4475 (M4-B), design §S2a.
3
+ *
4
+ * The **acceptance-dilution guard** for single delivery. In the fan-out
5
+ * shape, each Story ran its own fresh-context acceptance self-eval critic, so
6
+ * a 14-AC Epic decomposed into 4 Stories got ~4 independent critic passes for
7
+ * free. Single delivery collapses the whole Epic into ONE guarded session — if
8
+ * we ran a single critic over all 14 ACs it would degrade to two redraft
9
+ * rounds scoring everything at once (the exact "acceptance dilution" risk the
10
+ * adopted design calls out as blocking).
11
+ *
12
+ * This module restores the distributed coverage: the Epic's
13
+ * `## Acceptance Table` AC ids are split into clusters of at most
14
+ * `clusterCeiling` (config `delivery.acceptanceEval.clusterCeiling`, default 4,
15
+ * hard-clamped to `[1, 8]`), and the executor spawns ONE maker-blind
16
+ * fresh-context `Agent` critic per cluster. The cluster count is therefore
17
+ * `ceil(totalACs / clusterCeiling)` — a deterministic fan-out width the
18
+ * `deliver-epic-single.md` executor and its isolation test both key off.
19
+ *
20
+ * Pure and total — inputs in, clusters out. No I/O, no throws on malformed
21
+ * input (a non-array / empty AC set yields zero clusters — the executor then
22
+ * has nothing to critique, which the front gate already forecloses by refusing
23
+ * `acceptance::n-a`).
24
+ */
25
+
26
+ import { getAcceptanceEval } from '../config/acceptance-eval.js';
27
+
28
+ /**
29
+ * @typedef {object} AcceptanceCluster
30
+ * @property {number} clusterIndex Zero-based position in the fan-out.
31
+ * @property {string} clusterId Stable id (`ac-cluster-<n>`, 1-based).
32
+ * @property {string[]} acIds The AC ids this critic scores (≤ ceiling).
33
+ */
34
+
35
+ /**
36
+ * Split an ordered list of AC ids into fresh-context critic clusters of at
37
+ * most `ceiling` ids each. The count is exactly `ceil(acIds.length / ceiling)`
38
+ * — the load-bearing fan-out-width invariant the isolation test pins.
39
+ *
40
+ * The input order is preserved (the executor orders ACs by their associated
41
+ * Delivery-Slicing slice before calling this, so same-slice ACs land adjacent
42
+ * and tend to share a cluster), but ordering is a nicety — the count guarantee
43
+ * is what forecloses dilution.
44
+ *
45
+ * @param {string[]} acIds Ordered AC ids (e.g. `['AC-1', 'AC-2', …]`).
46
+ * @param {number} [ceiling=4] Max ACs per cluster. A non-positive / non-
47
+ * integer ceiling degrades to 1 (one AC per
48
+ * cluster — the maximally-distributed, never-
49
+ * collapsed fan-out).
50
+ * @returns {AcceptanceCluster[]} `ceil(n / ceiling)` clusters; `[]` when there
51
+ * are no AC ids.
52
+ */
53
+ export function clusterAcceptanceCriteria(acIds, ceiling = 4) {
54
+ const ids = Array.isArray(acIds)
55
+ ? acIds.filter((id) => typeof id === 'string' && id.length > 0)
56
+ : [];
57
+ if (ids.length === 0) return [];
58
+
59
+ const size =
60
+ typeof ceiling === 'number' && Number.isInteger(ceiling) && ceiling >= 1
61
+ ? ceiling
62
+ : 1;
63
+
64
+ const clusters = [];
65
+ for (let start = 0; start < ids.length; start += size) {
66
+ const clusterIndex = clusters.length;
67
+ clusters.push({
68
+ clusterIndex,
69
+ clusterId: `ac-cluster-${clusterIndex + 1}`,
70
+ acIds: ids.slice(start, start + size),
71
+ });
72
+ }
73
+ return clusters;
74
+ }
75
+
76
+ /**
77
+ * The expected fan-out width for `total` ACs at `ceiling` — the number of
78
+ * independent maker-blind critic passes the single-delivery executor spawns.
79
+ * Exported so the executor (and its isolation test) can assert the number of
80
+ * `Agent` critic spawns equals this without re-deriving the ceil math.
81
+ *
82
+ * @param {number} total Total AC count.
83
+ * @param {number} ceiling Max ACs per cluster (≥ 1; degrades to 1 otherwise).
84
+ * @returns {number} `ceil(total / ceiling)`; `0` when `total <= 0`.
85
+ */
86
+ export function expectedClusterCount(total, ceiling) {
87
+ const n = Number.isInteger(total) && total > 0 ? total : 0;
88
+ if (n === 0) return 0;
89
+ const size =
90
+ typeof ceiling === 'number' && Number.isInteger(ceiling) && ceiling >= 1
91
+ ? ceiling
92
+ : 1;
93
+ return Math.ceil(n / size);
94
+ }
95
+
96
+ /**
97
+ * Convenience wrapper: resolve the effective `clusterCeiling` from config
98
+ * (applying the framework default + the undisableable `[1, 8]` clamp) and
99
+ * cluster `acIds` with it. The single home the executor's `slice-phase`
100
+ * substrate and any future CLI use so the clamp is applied once.
101
+ *
102
+ * @param {string[]} acIds
103
+ * @param {object | null | undefined} config Resolved `.agentrc.json`.
104
+ * @returns {{ clusters: AcceptanceCluster[], clusterCeiling: number, totalAcs: number }}
105
+ */
106
+ export function clusterAcceptanceForConfig(acIds, config) {
107
+ const { clusterCeiling } = getAcceptanceEval(config);
108
+ const clusters = clusterAcceptanceCriteria(acIds, clusterCeiling);
109
+ const ids = Array.isArray(acIds) ? acIds.filter(Boolean) : [];
110
+ return { clusters, clusterCeiling, totalAcs: ids.length };
111
+ }
@@ -41,7 +41,10 @@
41
41
 
42
42
  import { readFileSync } from 'node:fs';
43
43
 
44
- import { signalsFile } from '../config/temp-paths.js';
44
+ import { epicArtifactPath, signalsFile } from '../config/temp-paths.js';
45
+
46
+ /** Epic-level signals stream basename (mirrors signals-writer). */
47
+ const EPIC_SIGNALS_BASENAME = 'signals.ndjson';
45
48
 
46
49
  /**
47
50
  * Verdicts that clear a criterion. Anything else (`partial`, `unmet`, or
@@ -168,11 +171,18 @@ export function buildAcceptanceEvalSignal({
168
171
  epicId,
169
172
  outcome,
170
173
  phase = 'implement',
174
+ clusterId = null,
171
175
  }) {
172
176
  return {
173
177
  kind: 'acceptance-eval',
174
178
  epicId: epicId ?? null,
175
- storyId,
179
+ storyId: storyId ?? null,
180
+ // Epic #4475 (M4-B): single-delivery acceptance critics score an AC
181
+ // *cluster*, not a Story. `clusterId` scopes the per-cluster round count
182
+ // on the epic-level signals stream; omitted (null) for the per-Story path.
183
+ ...(typeof clusterId === 'string' && clusterId.length > 0
184
+ ? { clusterId }
185
+ : {}),
176
186
  phase,
177
187
  emitter: { tool: 'acceptance-eval.js' },
178
188
  details: {
@@ -216,13 +226,27 @@ export function buildAcceptanceEvalSignal({
216
226
  export function deriveAcceptanceEvalRound({
217
227
  epicId,
218
228
  storyId,
229
+ clusterId = null,
219
230
  config,
220
231
  readFile = (p) => readFileSync(p, 'utf8'),
221
232
  signalsPathResolver = signalsFile,
233
+ epicSignalsPathResolver = (eid, cfg) =>
234
+ epicArtifactPath(eid, EPIC_SIGNALS_BASENAME, cfg),
222
235
  }) {
236
+ // Epic #4475 (M4-B): single-delivery critics score AC clusters, not
237
+ // Stories. When `clusterId` is supplied the round is counted per cluster
238
+ // off the epic-level signals stream; otherwise the per-Story path
239
+ // (unchanged) counts by `storyId` off the Story's stream.
240
+ const clusterMode =
241
+ typeof clusterId === 'string' &&
242
+ clusterId.length > 0 &&
243
+ Number.isInteger(epicId);
244
+
223
245
  let text;
224
246
  try {
225
- text = readFile(signalsPathResolver(epicId ?? null, storyId, config));
247
+ text = clusterMode
248
+ ? readFile(epicSignalsPathResolver(epicId, config))
249
+ : readFile(signalsPathResolver(epicId ?? null, storyId, config));
226
250
  } catch (_err) {
227
251
  // No ledger yet → no prior rounds.
228
252
  return 1;
@@ -240,7 +264,11 @@ export function deriveAcceptanceEvalRound({
240
264
  }
241
265
  if (!record || typeof record !== 'object') continue;
242
266
  if (record.kind !== 'acceptance-eval') continue;
243
- if (record.storyId !== storyId) continue;
267
+ if (clusterMode) {
268
+ if (record.clusterId !== clusterId) continue;
269
+ } else if (record.storyId !== storyId) {
270
+ continue;
271
+ }
244
272
  priorRounds += 1;
245
273
  }
246
274
  return priorRounds + 1;