cclaw-cli 7.7.0 → 8.1.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 (282) hide show
  1. package/README.md +210 -134
  2. package/dist/artifact-frontmatter.d.ts +51 -0
  3. package/dist/artifact-frontmatter.js +131 -0
  4. package/dist/artifact-paths.d.ts +7 -27
  5. package/dist/artifact-paths.js +20 -249
  6. package/dist/cancel.d.ts +16 -0
  7. package/dist/cancel.js +66 -0
  8. package/dist/cli.d.ts +2 -27
  9. package/dist/cli.js +90 -508
  10. package/dist/compound.d.ts +26 -0
  11. package/dist/compound.js +96 -0
  12. package/dist/config.d.ts +14 -51
  13. package/dist/config.js +23 -359
  14. package/dist/constants.d.ts +11 -18
  15. package/dist/constants.js +19 -106
  16. package/dist/content/antipatterns.d.ts +1 -0
  17. package/dist/content/antipatterns.js +109 -0
  18. package/dist/content/artifact-templates.d.ts +10 -0
  19. package/dist/content/artifact-templates.js +550 -0
  20. package/dist/content/cancel-command.d.ts +2 -2
  21. package/dist/content/cancel-command.js +25 -17
  22. package/dist/content/core-agents.d.ts +9 -233
  23. package/dist/content/core-agents.js +39 -766
  24. package/dist/content/decision-protocol.d.ts +1 -12
  25. package/dist/content/decision-protocol.js +27 -20
  26. package/dist/content/examples.d.ts +8 -42
  27. package/dist/content/examples.js +293 -425
  28. package/dist/content/idea-command.d.ts +2 -0
  29. package/dist/content/idea-command.js +38 -0
  30. package/dist/content/iron-laws.d.ts +4 -138
  31. package/dist/content/iron-laws.js +18 -197
  32. package/dist/content/meta-skill.d.ts +1 -3
  33. package/dist/content/meta-skill.js +57 -132
  34. package/dist/content/node-hooks.d.ts +12 -8
  35. package/dist/content/node-hooks.js +188 -838
  36. package/dist/content/recovery.d.ts +8 -0
  37. package/dist/content/recovery.js +179 -0
  38. package/dist/content/reference-patterns.d.ts +4 -13
  39. package/dist/content/reference-patterns.js +260 -389
  40. package/dist/content/research-playbooks.d.ts +8 -8
  41. package/dist/content/research-playbooks.js +108 -121
  42. package/dist/content/review-loop.d.ts +6 -192
  43. package/dist/content/review-loop.js +29 -731
  44. package/dist/content/skills.d.ts +8 -38
  45. package/dist/content/skills.js +681 -732
  46. package/dist/content/specialist-prompts/architect.d.ts +1 -0
  47. package/dist/content/specialist-prompts/architect.js +225 -0
  48. package/dist/content/specialist-prompts/brainstormer.d.ts +1 -0
  49. package/dist/content/specialist-prompts/brainstormer.js +168 -0
  50. package/dist/content/specialist-prompts/index.d.ts +2 -0
  51. package/dist/content/specialist-prompts/index.js +14 -0
  52. package/dist/content/specialist-prompts/planner.d.ts +1 -0
  53. package/dist/content/specialist-prompts/planner.js +182 -0
  54. package/dist/content/specialist-prompts/reviewer.d.ts +1 -0
  55. package/dist/content/specialist-prompts/reviewer.js +193 -0
  56. package/dist/content/specialist-prompts/security-reviewer.d.ts +1 -0
  57. package/dist/content/specialist-prompts/security-reviewer.js +133 -0
  58. package/dist/content/specialist-prompts/slice-builder.d.ts +1 -0
  59. package/dist/content/specialist-prompts/slice-builder.js +232 -0
  60. package/dist/content/stage-playbooks.d.ts +8 -0
  61. package/dist/content/stage-playbooks.js +404 -0
  62. package/dist/content/start-command.d.ts +2 -12
  63. package/dist/content/start-command.js +221 -207
  64. package/dist/flow-state.d.ts +21 -178
  65. package/dist/flow-state.js +67 -170
  66. package/dist/fs-utils.d.ts +6 -26
  67. package/dist/fs-utils.js +29 -162
  68. package/dist/gitignore.d.ts +2 -1
  69. package/dist/gitignore.js +51 -34
  70. package/dist/harness-detect.d.ts +10 -0
  71. package/dist/harness-detect.js +29 -0
  72. package/dist/install.d.ts +27 -15
  73. package/dist/install.js +230 -1342
  74. package/dist/knowledge-store.d.ts +19 -163
  75. package/dist/knowledge-store.js +56 -590
  76. package/dist/logger.d.ts +8 -3
  77. package/dist/logger.js +13 -4
  78. package/dist/orchestrator-routing.d.ts +29 -0
  79. package/dist/orchestrator-routing.js +156 -0
  80. package/dist/run-persistence.d.ts +7 -118
  81. package/dist/run-persistence.js +29 -845
  82. package/dist/runtime/run-hook.entry.d.ts +1 -3
  83. package/dist/runtime/run-hook.entry.js +19 -4
  84. package/dist/runtime/run-hook.mjs +13 -1024
  85. package/dist/types.d.ts +25 -261
  86. package/dist/types.js +8 -36
  87. package/package.json +6 -3
  88. package/dist/artifact-linter/brainstorm.d.ts +0 -2
  89. package/dist/artifact-linter/brainstorm.js +0 -353
  90. package/dist/artifact-linter/design.d.ts +0 -18
  91. package/dist/artifact-linter/design.js +0 -444
  92. package/dist/artifact-linter/findings-dedup.d.ts +0 -56
  93. package/dist/artifact-linter/findings-dedup.js +0 -232
  94. package/dist/artifact-linter/plan.d.ts +0 -2
  95. package/dist/artifact-linter/plan.js +0 -826
  96. package/dist/artifact-linter/review-army.d.ts +0 -49
  97. package/dist/artifact-linter/review-army.js +0 -520
  98. package/dist/artifact-linter/review.d.ts +0 -2
  99. package/dist/artifact-linter/review.js +0 -113
  100. package/dist/artifact-linter/scope.d.ts +0 -2
  101. package/dist/artifact-linter/scope.js +0 -158
  102. package/dist/artifact-linter/shared.d.ts +0 -637
  103. package/dist/artifact-linter/shared.js +0 -2163
  104. package/dist/artifact-linter/ship.d.ts +0 -2
  105. package/dist/artifact-linter/ship.js +0 -250
  106. package/dist/artifact-linter/spec.d.ts +0 -2
  107. package/dist/artifact-linter/spec.js +0 -176
  108. package/dist/artifact-linter/tdd.d.ts +0 -118
  109. package/dist/artifact-linter/tdd.js +0 -1404
  110. package/dist/artifact-linter.d.ts +0 -15
  111. package/dist/artifact-linter.js +0 -517
  112. package/dist/codex-feature-flag.d.ts +0 -58
  113. package/dist/codex-feature-flag.js +0 -193
  114. package/dist/content/closeout-guidance.d.ts +0 -14
  115. package/dist/content/closeout-guidance.js +0 -44
  116. package/dist/content/diff-command.d.ts +0 -1
  117. package/dist/content/diff-command.js +0 -43
  118. package/dist/content/harness-doc.d.ts +0 -1
  119. package/dist/content/harness-doc.js +0 -65
  120. package/dist/content/hook-events.d.ts +0 -9
  121. package/dist/content/hook-events.js +0 -23
  122. package/dist/content/hook-manifest.d.ts +0 -81
  123. package/dist/content/hook-manifest.js +0 -156
  124. package/dist/content/hooks.d.ts +0 -11
  125. package/dist/content/hooks.js +0 -1972
  126. package/dist/content/idea.d.ts +0 -60
  127. package/dist/content/idea.js +0 -416
  128. package/dist/content/language-policy.d.ts +0 -2
  129. package/dist/content/language-policy.js +0 -13
  130. package/dist/content/learnings.d.ts +0 -6
  131. package/dist/content/learnings.js +0 -141
  132. package/dist/content/observe.d.ts +0 -19
  133. package/dist/content/observe.js +0 -86
  134. package/dist/content/opencode-plugin.d.ts +0 -1
  135. package/dist/content/opencode-plugin.js +0 -635
  136. package/dist/content/review-prompts.d.ts +0 -1
  137. package/dist/content/review-prompts.js +0 -104
  138. package/dist/content/runtime-shared-snippets.d.ts +0 -8
  139. package/dist/content/runtime-shared-snippets.js +0 -80
  140. package/dist/content/session-hooks.d.ts +0 -7
  141. package/dist/content/session-hooks.js +0 -107
  142. package/dist/content/skills-elicitation.d.ts +0 -1
  143. package/dist/content/skills-elicitation.js +0 -167
  144. package/dist/content/stage-command.d.ts +0 -2
  145. package/dist/content/stage-command.js +0 -17
  146. package/dist/content/stage-schema.d.ts +0 -117
  147. package/dist/content/stage-schema.js +0 -955
  148. package/dist/content/stages/_lint-metadata/index.d.ts +0 -2
  149. package/dist/content/stages/_lint-metadata/index.js +0 -97
  150. package/dist/content/stages/brainstorm.d.ts +0 -2
  151. package/dist/content/stages/brainstorm.js +0 -184
  152. package/dist/content/stages/design.d.ts +0 -2
  153. package/dist/content/stages/design.js +0 -288
  154. package/dist/content/stages/index.d.ts +0 -8
  155. package/dist/content/stages/index.js +0 -11
  156. package/dist/content/stages/plan.d.ts +0 -2
  157. package/dist/content/stages/plan.js +0 -191
  158. package/dist/content/stages/review.d.ts +0 -2
  159. package/dist/content/stages/review.js +0 -240
  160. package/dist/content/stages/schema-types.d.ts +0 -203
  161. package/dist/content/stages/schema-types.js +0 -1
  162. package/dist/content/stages/scope.d.ts +0 -2
  163. package/dist/content/stages/scope.js +0 -254
  164. package/dist/content/stages/ship.d.ts +0 -2
  165. package/dist/content/stages/ship.js +0 -159
  166. package/dist/content/stages/spec.d.ts +0 -2
  167. package/dist/content/stages/spec.js +0 -170
  168. package/dist/content/stages/tdd.d.ts +0 -4
  169. package/dist/content/stages/tdd.js +0 -273
  170. package/dist/content/state-contracts.d.ts +0 -1
  171. package/dist/content/state-contracts.js +0 -63
  172. package/dist/content/status-command.d.ts +0 -4
  173. package/dist/content/status-command.js +0 -109
  174. package/dist/content/subagent-context-skills.d.ts +0 -4
  175. package/dist/content/subagent-context-skills.js +0 -279
  176. package/dist/content/subagents.d.ts +0 -3
  177. package/dist/content/subagents.js +0 -997
  178. package/dist/content/templates.d.ts +0 -26
  179. package/dist/content/templates.js +0 -1692
  180. package/dist/content/track-render-context.d.ts +0 -18
  181. package/dist/content/track-render-context.js +0 -53
  182. package/dist/content/tree-command.d.ts +0 -1
  183. package/dist/content/tree-command.js +0 -64
  184. package/dist/content/utility-skills.d.ts +0 -30
  185. package/dist/content/utility-skills.js +0 -160
  186. package/dist/content/view-command.d.ts +0 -2
  187. package/dist/content/view-command.js +0 -92
  188. package/dist/delegation.d.ts +0 -649
  189. package/dist/delegation.js +0 -1539
  190. package/dist/early-loop.d.ts +0 -70
  191. package/dist/early-loop.js +0 -302
  192. package/dist/execution-topology.d.ts +0 -36
  193. package/dist/execution-topology.js +0 -73
  194. package/dist/gate-evidence.d.ts +0 -85
  195. package/dist/gate-evidence.js +0 -631
  196. package/dist/harness-adapters.d.ts +0 -151
  197. package/dist/harness-adapters.js +0 -756
  198. package/dist/harness-selection.d.ts +0 -31
  199. package/dist/harness-selection.js +0 -214
  200. package/dist/hook-schema.d.ts +0 -6
  201. package/dist/hook-schema.js +0 -114
  202. package/dist/hook-schemas/claude-hooks.v1.json +0 -10
  203. package/dist/hook-schemas/codex-hooks.v1.json +0 -10
  204. package/dist/hook-schemas/cursor-hooks.v1.json +0 -13
  205. package/dist/init-detect.d.ts +0 -2
  206. package/dist/init-detect.js +0 -50
  207. package/dist/internal/advance-stage/advance.d.ts +0 -89
  208. package/dist/internal/advance-stage/advance.js +0 -655
  209. package/dist/internal/advance-stage/cancel-run.d.ts +0 -8
  210. package/dist/internal/advance-stage/cancel-run.js +0 -19
  211. package/dist/internal/advance-stage/flow-state-coercion.d.ts +0 -3
  212. package/dist/internal/advance-stage/flow-state-coercion.js +0 -81
  213. package/dist/internal/advance-stage/helpers.d.ts +0 -14
  214. package/dist/internal/advance-stage/helpers.js +0 -145
  215. package/dist/internal/advance-stage/hook.d.ts +0 -8
  216. package/dist/internal/advance-stage/hook.js +0 -40
  217. package/dist/internal/advance-stage/parsers.d.ts +0 -72
  218. package/dist/internal/advance-stage/parsers.js +0 -357
  219. package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +0 -24
  220. package/dist/internal/advance-stage/proactive-delegation-trace.js +0 -56
  221. package/dist/internal/advance-stage/review-loop.d.ts +0 -16
  222. package/dist/internal/advance-stage/review-loop.js +0 -199
  223. package/dist/internal/advance-stage/rewind.d.ts +0 -14
  224. package/dist/internal/advance-stage/rewind.js +0 -108
  225. package/dist/internal/advance-stage/start-flow.d.ts +0 -13
  226. package/dist/internal/advance-stage/start-flow.js +0 -241
  227. package/dist/internal/advance-stage/verify.d.ts +0 -21
  228. package/dist/internal/advance-stage/verify.js +0 -185
  229. package/dist/internal/advance-stage.d.ts +0 -7
  230. package/dist/internal/advance-stage.js +0 -138
  231. package/dist/internal/cohesion-contract-stub.d.ts +0 -24
  232. package/dist/internal/cohesion-contract-stub.js +0 -148
  233. package/dist/internal/compound-readiness.d.ts +0 -23
  234. package/dist/internal/compound-readiness.js +0 -102
  235. package/dist/internal/detect-public-api-changes.d.ts +0 -5
  236. package/dist/internal/detect-public-api-changes.js +0 -45
  237. package/dist/internal/detect-supply-chain-changes.d.ts +0 -6
  238. package/dist/internal/detect-supply-chain-changes.js +0 -138
  239. package/dist/internal/early-loop-status.d.ts +0 -7
  240. package/dist/internal/early-loop-status.js +0 -93
  241. package/dist/internal/envelope-validate.d.ts +0 -7
  242. package/dist/internal/envelope-validate.js +0 -66
  243. package/dist/internal/flow-state-repair.d.ts +0 -20
  244. package/dist/internal/flow-state-repair.js +0 -104
  245. package/dist/internal/plan-split-waves.d.ts +0 -190
  246. package/dist/internal/plan-split-waves.js +0 -764
  247. package/dist/internal/runtime-integrity.d.ts +0 -7
  248. package/dist/internal/runtime-integrity.js +0 -268
  249. package/dist/internal/slice-commit.d.ts +0 -7
  250. package/dist/internal/slice-commit.js +0 -619
  251. package/dist/internal/tdd-loop-status.d.ts +0 -14
  252. package/dist/internal/tdd-loop-status.js +0 -68
  253. package/dist/internal/tdd-red-evidence.d.ts +0 -7
  254. package/dist/internal/tdd-red-evidence.js +0 -153
  255. package/dist/internal/waiver-grant.d.ts +0 -62
  256. package/dist/internal/waiver-grant.js +0 -294
  257. package/dist/internal/wave-status.d.ts +0 -63
  258. package/dist/internal/wave-status.js +0 -450
  259. package/dist/managed-resources.d.ts +0 -53
  260. package/dist/managed-resources.js +0 -313
  261. package/dist/policy.d.ts +0 -10
  262. package/dist/policy.js +0 -167
  263. package/dist/retro-gate.d.ts +0 -9
  264. package/dist/retro-gate.js +0 -47
  265. package/dist/run-archive.d.ts +0 -61
  266. package/dist/run-archive.js +0 -391
  267. package/dist/runs.d.ts +0 -2
  268. package/dist/runs.js +0 -2
  269. package/dist/stack-detection.d.ts +0 -116
  270. package/dist/stack-detection.js +0 -489
  271. package/dist/streaming/event-stream.d.ts +0 -31
  272. package/dist/streaming/event-stream.js +0 -114
  273. package/dist/tdd-cycle.d.ts +0 -107
  274. package/dist/tdd-cycle.js +0 -289
  275. package/dist/tdd-verification-evidence.d.ts +0 -17
  276. package/dist/tdd-verification-evidence.js +0 -122
  277. package/dist/track-heuristics.d.ts +0 -27
  278. package/dist/track-heuristics.js +0 -154
  279. package/dist/util/slice-id.d.ts +0 -58
  280. package/dist/util/slice-id.js +0 -89
  281. package/dist/worktree-manager.d.ts +0 -20
  282. package/dist/worktree-manager.js +0 -108
@@ -1,1539 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { execFile } from "node:child_process";
4
- import { promisify } from "node:util";
5
- import { RUNTIME_ROOT } from "./constants.js";
6
- import { readConfig, resolveMaxBuilders } from "./config.js";
7
- import { exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
8
- import { HARNESS_ADAPTERS } from "./harness-adapters.js";
9
- import { readFlowState } from "./runs.js";
10
- import { mandatoryAgentsFor, stageSchema } from "./content/stage-schema.js";
11
- import { compareCanonicalUnitIds, mergeParallelWaveDefinitions, parseImplementationUnitParallelFields, parseImplementationUnits, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "./internal/plan-split-waves.js";
12
- import { compareSliceIds } from "./util/slice-id.js";
13
- const execFileAsync = promisify(execFile);
14
- const TERMINAL_DELEGATION_STATUSES = new Set(["completed", "failed", "waived", "stale"]);
15
- export const DELEGATION_DISPATCH_SURFACES = [
16
- "claude-task",
17
- "cursor-task",
18
- "opencode-agent",
19
- "codex-agent",
20
- "generic-task",
21
- "role-switch",
22
- "manual"
23
- ];
24
- /** Agents that declare `claimedPaths` for parallel/disjoint scheduling and fan-out caps. */
25
- export function isParallelTddSliceWorker(agent) {
26
- return agent === "slice-builder";
27
- }
28
- /**
29
- * Per-surface allowed agent-definition path prefixes. Used by the generated
30
- * `.cclaw/hooks/delegation-record.mjs` helper to reject mismatched
31
- * `--agent-definition-path` values without inspecting any harness state.
32
- *
33
- * The list is intentionally structural: each surface maps to one or more
34
- * repo-relative path prefixes that must be a parent of the supplied path.
35
- * `role-switch` and `manual` accept any path because the agent-definition
36
- * is intentionally not a generated artifact for those surfaces.
37
- */
38
- export const DELEGATION_DISPATCH_SURFACE_PATH_PREFIXES = {
39
- "claude-task": [".claude/agents/", ".cclaw/agents/"],
40
- "cursor-task": [".cursor/agents/", ".cclaw/agents/"],
41
- "opencode-agent": [".opencode/agents/", ".cclaw/agents/"],
42
- "codex-agent": [".codex/agents/", ".cclaw/agents/"],
43
- "generic-task": [".cclaw/agents/"],
44
- "role-switch": [],
45
- "manual": []
46
- };
47
- export const DELEGATION_PHASES = [
48
- "red",
49
- "green",
50
- "refactor",
51
- "refactor-deferred",
52
- "doc",
53
- "resolve-conflict"
54
- ];
55
- export const DELEGATION_LEDGER_SCHEMA_VERSION = 3;
56
- function delegationLogPath(projectRoot) {
57
- return path.join(projectRoot, RUNTIME_ROOT, "state", "delegation-log.json");
58
- }
59
- function delegationLockPath(projectRoot) {
60
- return path.join(projectRoot, RUNTIME_ROOT, "state", ".delegation.lock");
61
- }
62
- function delegationEventsPath(projectRoot) {
63
- return path.join(projectRoot, RUNTIME_ROOT, "state", "delegation-events.jsonl");
64
- }
65
- function subagentsStatePath(projectRoot) {
66
- return path.join(projectRoot, RUNTIME_ROOT, "state", "subagents.json");
67
- }
68
- function createSpanId() {
69
- return `dspan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
70
- }
71
- function activeHarnessSubagentFallback() {
72
- const activeHarness = process.env.CCLAW_ACTIVE_HARNESS;
73
- if (!activeHarness)
74
- return undefined;
75
- return HARNESS_ADAPTERS[activeHarness]
76
- ?.capabilities.subagentFallback;
77
- }
78
- async function resolveReviewDiffBase(projectRoot) {
79
- let head = "";
80
- try {
81
- head = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: projectRoot })).stdout.trim();
82
- }
83
- catch {
84
- return null;
85
- }
86
- const candidates = ["origin/main", "origin/master", "main", "master"];
87
- for (const candidate of candidates) {
88
- try {
89
- await execFileAsync("git", ["rev-parse", "--verify", candidate], { cwd: projectRoot });
90
- const { stdout } = await execFileAsync("git", ["merge-base", "HEAD", candidate], {
91
- cwd: projectRoot
92
- });
93
- const base = stdout.trim();
94
- if (base.length > 0 && base !== head) {
95
- return base;
96
- }
97
- }
98
- catch {
99
- continue;
100
- }
101
- }
102
- try {
103
- const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD~1"], {
104
- cwd: projectRoot
105
- });
106
- const base = stdout.trim();
107
- return base.length > 0 ? base : null;
108
- }
109
- catch {
110
- return null;
111
- }
112
- }
113
- /**
114
- * Heuristic: does a changed file path strongly imply a trust-boundary
115
- * surface? Used by tests and prompt guidance for risk-triggered review.
116
- *
117
- * Matches authN/Z, credentials, crypto, policy, or explicit sanitization
118
- * or injection handling. Intentionally excludes broad terms like `input`
119
- * and `validation` because they match innocuous paths such as
120
- * `form-input.ts` or `number-validation.ts` and produce false positives.
121
- */
122
- export function isTrustBoundaryPath(filePath) {
123
- return /(auth|security|secret|token|credential|permission|acl|policy|oauth|session|encrypt|decrypt|sanitize|untrusted|csrf|xss|injection|taint)/iu.test(filePath);
124
- }
125
- async function detectReviewTriggers(projectRoot) {
126
- const empty = {
127
- changedFiles: 0,
128
- changedLines: 0,
129
- trustBoundaryChanged: false
130
- };
131
- const base = await resolveReviewDiffBase(projectRoot);
132
- if (!base) {
133
- return empty;
134
- }
135
- try {
136
- const range = `${base}..HEAD`;
137
- const shortstat = await execFileAsync("git", ["diff", "--shortstat", range], {
138
- cwd: projectRoot
139
- });
140
- const short = shortstat.stdout.trim();
141
- const changedFiles = Number((/(\d+)\s+files?\s+changed/u.exec(short)?.[1] ?? "0"));
142
- const insertions = Number((/(\d+)\s+insertions?\(\+\)/u.exec(short)?.[1] ?? "0"));
143
- const deletions = Number((/(\d+)\s+deletions?\(-\)/u.exec(short)?.[1] ?? "0"));
144
- const changedLines = insertions + deletions;
145
- const names = await execFileAsync("git", ["diff", "--name-only", range], {
146
- cwd: projectRoot
147
- });
148
- const changedPaths = names.stdout
149
- .split(/\r?\n/gu)
150
- .map((line) => line.trim())
151
- .filter((line) => line.length > 0);
152
- const trustBoundaryChanged = changedPaths.some((p) => isTrustBoundaryPath(p));
153
- return {
154
- changedFiles,
155
- changedLines,
156
- trustBoundaryChanged
157
- };
158
- }
159
- catch {
160
- return empty;
161
- }
162
- }
163
- function hasValidWaiverReason(value) {
164
- return typeof value === "string" && value.trim().length > 0;
165
- }
166
- function isDelegationTokenUsage(value) {
167
- if (!value || typeof value !== "object" || Array.isArray(value))
168
- return false;
169
- const o = value;
170
- return (typeof o.input === "number" &&
171
- Number.isFinite(o.input) &&
172
- typeof o.output === "number" &&
173
- Number.isFinite(o.output) &&
174
- typeof o.model === "string" &&
175
- o.model.trim().length > 0);
176
- }
177
- function isDelegationEntry(value) {
178
- if (!value || typeof value !== "object" || Array.isArray(value))
179
- return false;
180
- const o = value;
181
- const modeOk = o.mode === "mandatory" || o.mode === "proactive";
182
- const statusOk = o.status === "scheduled" ||
183
- o.status === "launched" ||
184
- o.status === "acknowledged" ||
185
- o.status === "completed" ||
186
- o.status === "failed" ||
187
- o.status === "waived" ||
188
- o.status === "stale";
189
- const timestampOk = typeof o.ts === "string" ||
190
- typeof o.startTs === "string";
191
- const terminalStatus = o.status === "completed" || o.status === "failed" || o.status === "waived" || o.status === "stale";
192
- const lifecycleOk = (o.status !== "scheduled" && o.status !== "launched" && o.status !== "acknowledged") || o.endTs === undefined;
193
- const terminalLifecycleOk = !terminalStatus ||
194
- o.endTs === undefined ||
195
- typeof o.endTs === "string";
196
- const retryOk = o.retryCount === undefined ||
197
- (typeof o.retryCount === "number" &&
198
- Number.isFinite(o.retryCount) &&
199
- Number.isInteger(o.retryCount) &&
200
- o.retryCount >= 0);
201
- const waiverOk = o.status !== "waived" || hasValidWaiverReason(o.waiverReason);
202
- return (typeof o.stage === "string" &&
203
- typeof o.agent === "string" &&
204
- modeOk &&
205
- statusOk &&
206
- timestampOk &&
207
- lifecycleOk &&
208
- terminalLifecycleOk &&
209
- (o.spanId === undefined || typeof o.spanId === "string") &&
210
- (o.parentSpanId === undefined || typeof o.parentSpanId === "string") &&
211
- (o.startTs === undefined || typeof o.startTs === "string") &&
212
- (o.endTs === undefined || typeof o.endTs === "string") &&
213
- (o.taskId === undefined || typeof o.taskId === "string") &&
214
- (o.waiverReason === undefined || typeof o.waiverReason === "string") &&
215
- (o.acceptedBy === undefined || o.acceptedBy === "user-flag") &&
216
- (o.approvalToken === undefined || typeof o.approvalToken === "string") &&
217
- (o.approvalReason === undefined || typeof o.approvalReason === "string") &&
218
- (o.approvalIssuedAt === undefined || typeof o.approvalIssuedAt === "string") &&
219
- waiverOk &&
220
- (o.runId === undefined || typeof o.runId === "string") &&
221
- (o.fulfillmentMode === undefined ||
222
- o.fulfillmentMode === "isolated" ||
223
- o.fulfillmentMode === "generic-dispatch" ||
224
- o.fulfillmentMode === "role-switch" ||
225
- o.fulfillmentMode === "harness-waiver" ||
226
- o.fulfillmentMode === "legacy-inferred") &&
227
- (o.conditionTrigger === undefined || typeof o.conditionTrigger === "string") &&
228
- (o.dispatchId === undefined || typeof o.dispatchId === "string") &&
229
- (o.workerRunId === undefined || typeof o.workerRunId === "string") &&
230
- (o.dispatchSurface === undefined || isDelegationDispatchSurface(o.dispatchSurface)) &&
231
- (o.agentDefinitionPath === undefined || typeof o.agentDefinitionPath === "string") &&
232
- (o.ackTs === undefined || typeof o.ackTs === "string") &&
233
- (o.launchedTs === undefined || typeof o.launchedTs === "string") &&
234
- (o.completedTs === undefined || typeof o.completedTs === "string") &&
235
- (o.tokens === undefined || isDelegationTokenUsage(o.tokens)) &&
236
- retryOk &&
237
- (o.evidenceRefs === undefined || (Array.isArray(o.evidenceRefs) && o.evidenceRefs.every((item) => typeof item === "string"))) &&
238
- (o.skill === undefined || typeof o.skill === "string") &&
239
- (o.schemaVersion === undefined || o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3) &&
240
- (o.allowParallel === undefined || typeof o.allowParallel === "boolean") &&
241
- (o.supersededBy === undefined || typeof o.supersededBy === "string") &&
242
- (o.claimedPaths === undefined ||
243
- (Array.isArray(o.claimedPaths) && o.claimedPaths.every((item) => typeof item === "string"))) &&
244
- (o.sliceId === undefined || typeof o.sliceId === "string") &&
245
- (o.phase === undefined ||
246
- (typeof o.phase === "string" &&
247
- DELEGATION_PHASES.includes(o.phase))) &&
248
- (o.refactorOutcome === undefined || isRefactorOutcomeShape(o.refactorOutcome)) &&
249
- (o.riskTier === undefined ||
250
- o.riskTier === "low" ||
251
- o.riskTier === "medium" ||
252
- o.riskTier === "high"));
253
- }
254
- function isRefactorOutcomeShape(value) {
255
- if (!value || typeof value !== "object" || Array.isArray(value))
256
- return false;
257
- const o = value;
258
- if (o.mode !== "inline" && o.mode !== "deferred")
259
- return false;
260
- if (o.rationale !== undefined && typeof o.rationale !== "string")
261
- return false;
262
- return true;
263
- }
264
- function isDelegationDispatchSurface(value) {
265
- return typeof value === "string" && DELEGATION_DISPATCH_SURFACES.includes(value);
266
- }
267
- function statusTimestampPatch(entry, ts) {
268
- const patch = { ...entry };
269
- if (patch.status === "launched")
270
- patch.launchedTs = patch.launchedTs ?? ts;
271
- if (patch.status === "acknowledged")
272
- patch.ackTs = patch.ackTs ?? ts;
273
- if (patch.status === "completed")
274
- patch.completedTs = patch.completedTs ?? patch.endTs ?? ts;
275
- return patch;
276
- }
277
- function eventFromEntry(entry) {
278
- const eventTs = entry.completedTs ?? entry.ackTs ?? entry.launchedTs ?? entry.endTs ?? entry.startTs ?? entry.ts ?? new Date().toISOString();
279
- return {
280
- ...entry,
281
- event: entry.status,
282
- eventTs,
283
- schemaVersion: DELEGATION_LEDGER_SCHEMA_VERSION
284
- };
285
- }
286
- function isDelegationEvent(value) {
287
- if (!isDelegationEntry(value))
288
- return false;
289
- const o = value;
290
- if (o.event !== o.status || typeof o.eventTs !== "string")
291
- return false;
292
- return true;
293
- }
294
- function parseLedger(raw, runId) {
295
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
296
- return { runId, entries: [], schemaVersion: DELEGATION_LEDGER_SCHEMA_VERSION };
297
- }
298
- const o = raw;
299
- const ledgerSchemaVersion = (o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3
300
- ? o.schemaVersion
301
- : undefined);
302
- const entriesRaw = o.entries;
303
- const entries = [];
304
- if (Array.isArray(entriesRaw)) {
305
- for (const item of entriesRaw) {
306
- if (isDelegationEntry(item)) {
307
- const ts = item.startTs ?? item.ts ?? new Date().toISOString();
308
- // A row is "pre-v3 legacy" when the file format predates the
309
- // dispatch-proof contract: schemaVersion is missing on both ledger
310
- // and entry, the entry has no fulfillmentMode, and there is no
311
- // dispatch-surface or dispatch-id evidence on the row. We honor
312
- // that by tagging fulfillmentMode = "legacy-inferred" so callers
313
- // (stage-complete, sync/runtime checks) can require an explicit `--rerecord`
314
- // before the row counts as proof-era.
315
- const ledgerHasNoVersion = ledgerSchemaVersion === undefined || ledgerSchemaVersion === 1;
316
- const entryHasNoVersion = item.schemaVersion === undefined || item.schemaVersion === 1;
317
- const looksLegacy = ledgerHasNoVersion &&
318
- entryHasNoVersion &&
319
- item.fulfillmentMode === undefined &&
320
- item.dispatchSurface === undefined &&
321
- item.dispatchId === undefined &&
322
- item.workerRunId === undefined &&
323
- item.agentDefinitionPath === undefined &&
324
- item.status === "completed";
325
- const inferredFulfillmentMode = item.fulfillmentMode
326
- ?? (looksLegacy ? "legacy-inferred" : (item.status === "completed" && item.schemaVersion === undefined ? "isolated" : undefined));
327
- entries.push({
328
- ...item,
329
- spanId: item.spanId ?? createSpanId(),
330
- startTs: ts,
331
- endTs: TERMINAL_DELEGATION_STATUSES.has(item.status) ? (item.endTs ?? ts) : undefined,
332
- ts,
333
- launchedTs: item.launchedTs ?? (item.status === "launched" ? ts : undefined),
334
- ackTs: item.ackTs ?? (item.status === "acknowledged" ? ts : undefined),
335
- completedTs: item.completedTs ?? (item.status === "completed" ? (item.endTs ?? ts) : undefined),
336
- retryCount: typeof item.retryCount === "number" && Number.isInteger(item.retryCount) && item.retryCount >= 0
337
- ? item.retryCount
338
- : 0,
339
- evidenceRefs: Array.isArray(item.evidenceRefs) ? item.evidenceRefs : [],
340
- fulfillmentMode: inferredFulfillmentMode,
341
- schemaVersion: item.schemaVersion ?? DELEGATION_LEDGER_SCHEMA_VERSION
342
- });
343
- }
344
- }
345
- }
346
- return { runId, entries, schemaVersion: ledgerSchemaVersion ?? DELEGATION_LEDGER_SCHEMA_VERSION };
347
- }
348
- export async function readDelegationLedger(projectRoot) {
349
- const { activeRunId } = await readFlowState(projectRoot);
350
- const filePath = delegationLogPath(projectRoot);
351
- if (!(await exists(filePath))) {
352
- return { runId: activeRunId, entries: [] };
353
- }
354
- try {
355
- const text = await fs.readFile(filePath, "utf8");
356
- const parsed = JSON.parse(text);
357
- return parseLedger(parsed, activeRunId);
358
- }
359
- catch {
360
- return { runId: activeRunId, entries: [] };
361
- }
362
- }
363
- /**
364
- * Audit-only event types that live in
365
- * `delegation-events.jsonl` but do NOT carry a delegation lifecycle
366
- * payload (no agent/spanId). The parser must accept them so they
367
- * don't show up as corrupt lines.
368
- */
369
- const NON_DELEGATION_AUDIT_EVENTS = new Set([
370
- "mandatory_delegations_skipped_by_track",
371
- "artifact_validation_demoted_by_track",
372
- "expansion_strategist_skipped_by_track",
373
- "cclaw_slice_lease_expired",
374
- "cclaw_fanin_applied",
375
- "cclaw_fanin_conflict",
376
- "cclaw_fanin_resolved",
377
- "cclaw_fanin_abandoned",
378
- "cclaw_integration_overseer_skipped",
379
- "cclaw_allow_parallel_auto_flip",
380
- "slice-completed"
381
- ]);
382
- function isAuditEventLine(parsed) {
383
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
384
- return false;
385
- const evt = parsed.event;
386
- return typeof evt === "string" && NON_DELEGATION_AUDIT_EVENTS.has(evt);
387
- }
388
- export async function readDelegationEvents(projectRoot) {
389
- const filePath = delegationEventsPath(projectRoot);
390
- if (!(await exists(filePath))) {
391
- return { events: [], corruptLines: [] };
392
- }
393
- const events = [];
394
- const corruptLines = [];
395
- const text = await fs.readFile(filePath, "utf8").catch(() => "");
396
- const lines = text.split(/\r?\n/gu);
397
- for (let index = 0; index < lines.length; index += 1) {
398
- const line = lines[index]?.trim() ?? "";
399
- if (line.length === 0)
400
- continue;
401
- try {
402
- const parsed = JSON.parse(line);
403
- if (isDelegationEvent(parsed)) {
404
- events.push(parsed);
405
- }
406
- else if (isAuditEventLine(parsed)) {
407
- // Audit-only row (e.g. mandatory_delegations_skipped_by_track).
408
- // Not a delegation lifecycle event but valid audit content.
409
- continue;
410
- }
411
- else {
412
- corruptLines.push(index + 1);
413
- }
414
- }
415
- catch {
416
- corruptLines.push(index + 1);
417
- }
418
- }
419
- return { events, corruptLines };
420
- }
421
- async function appendDelegationEvent(projectRoot, event) {
422
- const filePath = delegationEventsPath(projectRoot);
423
- await fs.mkdir(path.dirname(filePath), { recursive: true });
424
- await fs.appendFile(filePath, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
425
- }
426
- /**
427
- * Effective timestamp used to order rows that share a `spanId`. Newest
428
- * lifecycle column wins. Returns the empty string when nothing is set
429
- * so the caller still has a stable lexicographic compare key.
430
- *
431
- * keep in sync with the inline copy in
432
- * `src/content/hooks.ts::delegationRecordScript`.
433
- */
434
- function effectiveSpanTs(entry) {
435
- return entry.completedTs ?? entry.ackTs ?? entry.launchedTs ?? entry.endTs ?? entry.startTs ?? entry.ts ?? "";
436
- }
437
- const ACTIVE_DELEGATION_STATUSES = new Set([
438
- "scheduled",
439
- "launched",
440
- "acknowledged"
441
- ]);
442
- /**
443
- * Fold ledger entries to the latest row per `spanId` and keep only spans
444
- * whose latest status is still active (`scheduled | launched |
445
- * acknowledged`). Used by the `state/subagents.json` writer so the
446
- * tracker never reports a span that already has a terminal row.
447
- *
448
- * Output is ordered by ascending `startTs ?? ts` so existing UI
449
- * consumers see a stable presentation order.
450
- *
451
- * Rows without a `spanId` are skipped — they are not addressable by
452
- * the tracker contract and would collide on the empty key.
453
- *
454
- * Callers are expected to pass entries already filtered to the active
455
- * `runId`; cross-run rows are therefore not re-filtered here.
456
- *
457
- * keep in sync with the inline copy in
458
- * `src/content/hooks.ts::delegationRecordScript`.
459
- */
460
- export function computeActiveSubagents(entries) {
461
- const latestBySpan = new Map();
462
- for (const entry of entries) {
463
- if (!entry.spanId)
464
- continue;
465
- const existing = latestBySpan.get(entry.spanId);
466
- if (!existing) {
467
- latestBySpan.set(entry.spanId, entry);
468
- continue;
469
- }
470
- const existingTs = effectiveSpanTs(existing);
471
- const incomingTs = effectiveSpanTs(entry);
472
- if (incomingTs >= existingTs) {
473
- latestBySpan.set(entry.spanId, entry);
474
- }
475
- }
476
- const folded = [];
477
- for (const entry of latestBySpan.values()) {
478
- if (ACTIVE_DELEGATION_STATUSES.has(entry.status)) {
479
- folded.push(entry);
480
- }
481
- }
482
- folded.sort((a, b) => {
483
- const aKey = a.startTs ?? a.ts ?? "";
484
- const bKey = b.startTs ?? b.ts ?? "";
485
- if (aKey === bKey)
486
- return 0;
487
- return aKey < bKey ? -1 : 1;
488
- });
489
- return folded;
490
- }
491
- /**
492
- * Thrown by `validateMonotonicTimestamps` when an incoming row
493
- * would push a span's timeline backwards. Carries enough context that
494
- * the CLI / hook surface can format a `delegation_timestamp_non_monotonic`
495
- * JSON payload without re-deriving the offending field.
496
- *
497
- * keep in sync with the inline copy in
498
- * `src/content/hooks.ts::delegationRecordScript`.
499
- */
500
- export class DelegationTimestampError extends Error {
501
- field;
502
- actual;
503
- priorBound;
504
- constructor(field, actual, priorBound) {
505
- super(`delegation_timestamp_non_monotonic — ${field}: ${actual} < ${priorBound}`);
506
- this.name = "DelegationTimestampError";
507
- this.field = field;
508
- this.actual = actual;
509
- this.priorBound = priorBound;
510
- }
511
- }
512
- /**
513
- * Enforce that lifecycle timestamps on a delegation span move
514
- * forward (or stay equal). Validates both per-row invariants
515
- * (`startTs ≤ launchedTs ≤ ackTs ≤ completedTs`) and a cross-row
516
- * invariant: the union of prior rows for this `spanId` plus the
517
- * incoming row must have non-decreasing `ts`.
518
- *
519
- * Equality is allowed because fast-completing dispatches legitimately
520
- * collapse multiple lifecycle markers onto the same instant.
521
- *
522
- * keep in sync with the inline copy in
523
- * `src/content/hooks.ts::delegationRecordScript`.
524
- */
525
- export function validateMonotonicTimestamps(stamped, prior) {
526
- const startTs = stamped.startTs;
527
- if (stamped.launchedTs && startTs && stamped.launchedTs < startTs) {
528
- throw new DelegationTimestampError("launchedTs", stamped.launchedTs, startTs);
529
- }
530
- if (stamped.ackTs) {
531
- const ackBound = stamped.launchedTs ?? startTs;
532
- if (ackBound && stamped.ackTs < ackBound) {
533
- throw new DelegationTimestampError("ackTs", stamped.ackTs, ackBound);
534
- }
535
- }
536
- if (stamped.completedTs) {
537
- const completedBound = stamped.ackTs ?? stamped.launchedTs ?? startTs;
538
- if (completedBound && stamped.completedTs < completedBound) {
539
- throw new DelegationTimestampError("completedTs", stamped.completedTs, completedBound);
540
- }
541
- }
542
- if (!stamped.spanId)
543
- return;
544
- const priorForSpan = prior.filter((entry) => entry.spanId === stamped.spanId);
545
- if (priorForSpan.length === 0)
546
- return;
547
- const timeline = [...priorForSpan, stamped]
548
- .map((entry) => ({ entry, ts: entry.ts ?? entry.startTs ?? "" }))
549
- .filter((row) => row.ts.length > 0)
550
- .sort((a, b) => (a.ts === b.ts ? 0 : a.ts < b.ts ? -1 : 1));
551
- for (let i = 1; i < timeline.length; i += 1) {
552
- const previous = timeline[i - 1];
553
- const current = timeline[i];
554
- if (current.ts < previous.ts) {
555
- throw new DelegationTimestampError("ts", current.ts, previous.ts);
556
- }
557
- }
558
- // Find the latest existing row by `ts` for the same spanId; if the
559
- // new row's `ts` is older than that latest, the timeline regressed.
560
- const latestPrior = priorForSpan
561
- .map((entry) => entry.ts ?? entry.startTs ?? "")
562
- .filter((ts) => ts.length > 0)
563
- .sort()
564
- .at(-1);
565
- const stampedTs = stamped.ts ?? stamped.startTs ?? "";
566
- if (latestPrior && stampedTs && stampedTs < latestPrior) {
567
- throw new DelegationTimestampError("ts", stampedTs, latestPrior);
568
- }
569
- }
570
- /**
571
- * Thrown by `appendDelegation` when the operator opens a
572
- * second `scheduled` span on the same `(stage, agent)` pair while an
573
- * earlier span on the same pair is still active. Callers can catch and
574
- * either pass the existing span id via `--supersede=<id>` (which
575
- * pre-writes a synthetic `stale` row) or `--allow-parallel` to record
576
- * concurrent spans intentionally.
577
- */
578
- export class DispatchDuplicateError extends Error {
579
- existingSpanId;
580
- existingStatus;
581
- newSpanId;
582
- pair;
583
- constructor(params) {
584
- super(`dispatch_duplicate — already-active spanId=${params.existingSpanId} (status=${params.existingStatus}) on stage=${params.pair.stage}, agent=${params.pair.agent}. ` +
585
- `pass --supersede=${params.existingSpanId} to close the previous span as stale, or --allow-parallel to record both as concurrent.`);
586
- this.name = "DispatchDuplicateError";
587
- this.existingSpanId = params.existingSpanId;
588
- this.existingStatus = params.existingStatus;
589
- this.newSpanId = params.newSpanId;
590
- this.pair = params.pair;
591
- }
592
- }
593
- /**
594
- * Thrown by `validateFileOverlap` when a new `slice-builder` is scheduled
595
- * on a TDD stage with at least one `claimedPaths` entry that overlaps an
596
- * active span. The scheduler auto-allows parallel dispatch when paths are
597
- * disjoint, so an explicit overlap is treated as a serialization signal:
598
- * the operator must wait for the existing span to terminate or pass
599
- * `--allow-parallel` deliberately to acknowledge the conflict.
600
- */
601
- export class DispatchOverlapError extends Error {
602
- existingSpanId;
603
- newSpanId;
604
- pair;
605
- conflictingPaths;
606
- constructor(params) {
607
- super(`dispatch_overlap — slice-builder span ${params.newSpanId} claims path(s) ${params.conflictingPaths.join(", ")} already held by active spanId=${params.existingSpanId} on stage=${params.pair.stage}. ` +
608
- `Wait for ${params.existingSpanId} to finish, dispatch a non-overlapping slice, or pass --allow-parallel to acknowledge the conflict.`);
609
- this.name = "DispatchOverlapError";
610
- this.existingSpanId = params.existingSpanId;
611
- this.newSpanId = params.newSpanId;
612
- this.pair = params.pair;
613
- this.conflictingPaths = params.conflictingPaths;
614
- }
615
- }
616
- /**
617
- * Thrown when the count of active `slice-builder` spans reaches
618
- * `MAX_PARALLEL_SLICE_BUILDERS` and a new scheduled row would push it past
619
- * the cap. Cap can be configured via `.cclaw/config.yaml::execution.maxBuilders`,
620
- * overridden once via `--override-cap=N` on the hook flag, or globally via
621
- * `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<N>` env.
622
- */
623
- export class DispatchCapError extends Error {
624
- cap;
625
- active;
626
- pair;
627
- constructor(params) {
628
- super(`dispatch_cap — ${params.active} active ${params.pair.agent}(s) at the cap of ${params.cap}. ` +
629
- `Complete one before scheduling another, or pass --override-cap=N (or CCLAW_MAX_PARALLEL_SLICE_BUILDERS=N) to lift the cap for this run.`);
630
- this.name = "DispatchCapError";
631
- this.cap = params.cap;
632
- this.active = params.active;
633
- this.pair = params.pair;
634
- }
635
- }
636
- /**
637
- * Patterns describing repo-relative paths owned by the cclaw managed
638
- * runtime under `.cclaw/`. Workers MUST NOT claim these as
639
- * `claimedPaths` because they are regenerated/rebound by `cclaw-cli sync`
640
- * (and similar managed flows), and worker writes silently bypass the
641
- * managed-resources manifest. Note: `.cclaw/artifacts/` is intentionally
642
- * NOT protected — slice-builders legitimately write slice cards there.
643
- *
644
- * Motivated by the hox-session 7.0.5 finding: subagent S-36 hand-edited
645
- * `.cclaw/hooks/delegation-record.mjs`, which had to be reverted because
646
- * the next `cclaw-cli sync` would have stomped the change.
647
- */
648
- const MANAGED_RUNTIME_PATH_PATTERNS = [
649
- /^\.cclaw\/(hooks|agents|skills|commands|templates|seeds|rules|state)\//u,
650
- /^\.cclaw\/config\.yaml$/u,
651
- /^\.cclaw\/managed-resources\.json$/u,
652
- /^\.cclaw\/\.flow-state\.guard\.json$/u
653
- ];
654
- /**
655
- * Return `true` when `path` is a repo-relative path owned by the cclaw
656
- * managed runtime under `.cclaw/`. Used by `validateClaimedPathsNotProtected`
657
- * during `appendDelegation` to reject `slice-builder` (or any worker)
658
- * spans that try to claim ownership of cclaw-managed files. Does not
659
- * normalise the input — callers pass the path exactly as the worker wrote
660
- * it into `claimedPaths` so the error message points at the real string.
661
- */
662
- export function isManagedRuntimePath(path) {
663
- if (typeof path !== "string" || path.length === 0)
664
- return false;
665
- return MANAGED_RUNTIME_PATH_PATTERNS.some((pattern) => pattern.test(path));
666
- }
667
- /**
668
- * Thrown by `appendDelegation` when a scheduled span declares a
669
- * `claimedPaths` entry that lives under the cclaw managed runtime
670
- * (see `isManagedRuntimePath`). Workers must never edit those paths
671
- * directly — they are owned by the managed sync surface. The error
672
- * lists the offending paths so the operator can drop or rewrite them.
673
- */
674
- export class DispatchClaimedPathProtectedError extends Error {
675
- protectedPaths;
676
- spanId;
677
- constructor(params) {
678
- super(`dispatch_claimed_path_protected — span ${params.spanId} claims managed-runtime path(s) ${params.protectedPaths.join(", ")}; ` +
679
- `paths under .cclaw/{hooks,agents,skills,commands,templates,seeds,rules,state}/, .cclaw/config.yaml, .cclaw/managed-resources.json, and .cclaw/.flow-state.guard.json are owned by cclaw-cli sync and must not appear in claimedPaths. ` +
680
- `Drop them from claimedPaths or, if a managed-runtime change is genuinely required, ship it through a cclaw release rather than a worker span.`);
681
- this.name = "DispatchClaimedPathProtectedError";
682
- this.protectedPaths = params.protectedPaths;
683
- this.spanId = params.spanId;
684
- }
685
- }
686
- /**
687
- * Reject any worker span that declares `claimedPaths` entries owned by
688
- * the cclaw managed runtime. Called from `appendDelegation` for
689
- * `status === "scheduled"` rows alongside the overlap and fan-out
690
- * checks. Throws `DispatchClaimedPathProtectedError` listing every
691
- * offending path so the operator can fix the dispatch in one pass.
692
- */
693
- export function validateClaimedPathsNotProtected(stamped) {
694
- const claimed = Array.isArray(stamped.claimedPaths) ? stamped.claimedPaths : [];
695
- if (claimed.length === 0)
696
- return;
697
- const offending = claimed.filter((p) => isManagedRuntimePath(p));
698
- if (offending.length === 0)
699
- return;
700
- throw new DispatchClaimedPathProtectedError({
701
- protectedPaths: offending,
702
- spanId: stamped.spanId ?? "unknown"
703
- });
704
- }
705
- /**
706
- * Thrown by `appendDelegation` (and the inline `delegation-record.mjs`
707
- * helper) when an event with a non-null `phase` is recorded with
708
- * `status="acknowledged"`. Phase-level granularity only makes sense on
709
- * terminal outcomes (`completed` or `failed`); the dispatch-level ACK
710
- * (no phase) is the controller saying "I see the dispatch surface back".
711
- *
712
- * Motivated by hox W-08/S-41: the slice-builder agent recorded all four
713
- * phase events with `--status=acknowledged`, which the helper silently
714
- * accepted but `slice-commit.mjs` only fires on `phase=doc status=completed`.
715
- * `wave-status` then saw the slice as phantom-open even though the
716
- * worker had finished. Recovery required raw backfill commands.
717
- *
718
- * 7.6.0 makes the constraint explicit: pair `--phase=<phase>` with
719
- * `--status=completed` (or `--status=failed`) and use
720
- * `--status=acknowledged` only for the dispatch-level ack (no phase).
721
- */
722
- export class PhaseEventRequiresTerminalStatusError extends Error {
723
- phase;
724
- status;
725
- spanId;
726
- correctedCommandHint;
727
- constructor(params) {
728
- super(`phase_event_requires_completed_or_failed_status — span ${params.spanId} recorded --phase=${params.phase} with --status=${params.status}; ` +
729
- `phase-level events are only valid on terminal outcomes (--status=completed or --status=failed). ` +
730
- `The dispatch-level ack (no --phase) can still use --status=acknowledged. ` +
731
- `Corrected command: ${params.correctedCommandHint}`);
732
- this.name = "PhaseEventRequiresTerminalStatusError";
733
- this.phase = params.phase;
734
- this.status = params.status;
735
- this.spanId = params.spanId;
736
- this.correctedCommandHint = params.correctedCommandHint;
737
- }
738
- }
739
- /**
740
- * Reject delegation rows where `phase` is set but `status` is not
741
- * `completed` or `failed`. Acknowledged/launched/scheduled/waived/stale
742
- * rows must NOT carry a phase — the phase-level lifecycle exists only
743
- * to record terminal outcomes per phase (RED/GREEN/REFACTOR/DOC).
744
- *
745
- * Throws `PhaseEventRequiresTerminalStatusError`; the message includes
746
- * an actionable corrected-command hint that the controller can paste.
747
- */
748
- export function validatePhaseEventStatus(stamped) {
749
- if (typeof stamped.phase !== "string" || stamped.phase.length === 0)
750
- return;
751
- if (stamped.status === "completed" || stamped.status === "failed")
752
- return;
753
- const phase = stamped.phase;
754
- const sliceFlag = typeof stamped.sliceId === "string" && stamped.sliceId.length > 0
755
- ? `--slice=${stamped.sliceId} `
756
- : "";
757
- const spanFlag = typeof stamped.spanId === "string" && stamped.spanId.length > 0
758
- ? `--span-id=${stamped.spanId} `
759
- : "";
760
- const correctedCommandHint = `node .cclaw/hooks/delegation-record.mjs --stage=${stamped.stage} --agent=${stamped.agent} --mode=${stamped.mode} --status=completed --phase=${phase} ${sliceFlag}${spanFlag}--evidence-ref="<phase outcome>"`;
761
- throw new PhaseEventRequiresTerminalStatusError({
762
- phase,
763
- status: stamped.status,
764
- spanId: stamped.spanId ?? "unknown",
765
- correctedCommandHint
766
- });
767
- }
768
- /**
769
- * Thrown by `appendDelegation` when a new `scheduled` span would open a
770
- * second TDD cycle for a slice that already has at least one closed span
771
- * (a span with completed phase rows for `red`, `green`, at least one of
772
- * `refactor`/`refactor-deferred`, and `doc`) in the same run. Re-running
773
- * a slice under a fresh span is almost always controller drift —
774
- * legitimate replay reuses the original spanId and is absorbed by the
775
- * existing dedup. Motivated by the hox-session 7.0.5 finding where
776
- * `S-36` had two scheduled spans (`span-w07-S-36-final` and `span-w07-S-36`)
777
- * that the linter then misread as out-of-order phases.
778
- */
779
- export class SliceAlreadyClosedError extends Error {
780
- sliceId;
781
- runId;
782
- closedSpanId;
783
- newSpanId;
784
- constructor(params) {
785
- super(`slice ${params.sliceId} already has a closed span (${params.closedSpanId}); refusing to schedule new span ${params.newSpanId} in run ${params.runId}`);
786
- this.name = "SliceAlreadyClosedError";
787
- this.sliceId = params.sliceId;
788
- this.runId = params.runId;
789
- this.closedSpanId = params.closedSpanId;
790
- this.newSpanId = params.newSpanId;
791
- }
792
- }
793
- /**
794
- * Detect closed spans for `(sliceId, runId)`. A span is considered
795
- * closed when it has completed phase rows for `red`, `green`, REFACTOR
796
- * coverage (either `phase=refactor`, `phase=refactor-deferred`, or
797
- * `phase=green` carrying `refactorOutcome`), AND `doc`. Returns the set of
798
- * closed spanIds; callers use this to reject new scheduled spans on
799
- * already-closed slices.
800
- */
801
- function closedSliceSpans(prior, sliceId, runId) {
802
- const closed = new Set();
803
- if (typeof sliceId !== "string" || sliceId.length === 0)
804
- return closed;
805
- const matches = prior.filter((entry) => entry.sliceId === sliceId &&
806
- entry.runId === runId &&
807
- typeof entry.spanId === "string" &&
808
- entry.spanId.length > 0);
809
- const bySpan = new Map();
810
- for (const entry of matches) {
811
- const spanId = entry.spanId;
812
- const existing = bySpan.get(spanId) ?? [];
813
- existing.push(entry);
814
- bySpan.set(spanId, existing);
815
- }
816
- for (const [spanId, entries] of bySpan.entries()) {
817
- const phases = new Set(entries
818
- .filter((e) => e.status === "completed" && typeof e.phase === "string")
819
- .map((e) => e.phase));
820
- const hasRed = phases.has("red");
821
- const hasGreen = phases.has("green");
822
- const hasRefactorPhase = phases.has("refactor") || phases.has("refactor-deferred");
823
- const greens = entries.filter((e) => e.status === "completed" && e.phase === "green");
824
- const greenWithOutcome = greens.find((e) => e.refactorOutcome &&
825
- (e.refactorOutcome.mode === "inline" || e.refactorOutcome.mode === "deferred"));
826
- let hasRefactorFromGreen = false;
827
- if (greenWithOutcome?.refactorOutcome?.mode === "deferred") {
828
- hasRefactorFromGreen = !!((greenWithOutcome.refactorOutcome.rationale &&
829
- greenWithOutcome.refactorOutcome.rationale.trim().length > 0) ||
830
- (Array.isArray(greenWithOutcome.evidenceRefs) &&
831
- greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0)));
832
- }
833
- else if (greenWithOutcome?.refactorOutcome?.mode === "inline") {
834
- hasRefactorFromGreen = true;
835
- }
836
- const hasRefactor = hasRefactorPhase || hasRefactorFromGreen;
837
- const hasDoc = phases.has("doc");
838
- if (hasRed && hasGreen && hasRefactor && hasDoc) {
839
- closed.add(spanId);
840
- }
841
- }
842
- return closed;
843
- }
844
- /**
845
- * Default cap on active `slice-builder` spans in a single TDD run. Override
846
- * via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<int>` (validated `>=1`).
847
- */
848
- export const MAX_PARALLEL_SLICE_BUILDERS = 5;
849
- /**
850
- * Return up to `cap` slice units whose dependsOn are satisfied, avoiding
851
- * `claimedPaths` intersections with already-selected units and active holders.
852
- */
853
- export function selectReadySlices(units, opts) {
854
- const ordered = [...units].sort((a, b) => compareCanonicalUnitIds(a.unitId, b.unitId));
855
- const selected = [];
856
- const blockedPaths = new Set();
857
- for (const holder of opts.activePathHolders) {
858
- for (const p of holder.paths) {
859
- blockedPaths.add(p);
860
- }
861
- }
862
- for (const u of ordered) {
863
- if (opts.completedUnitIds.has(u.unitId))
864
- continue;
865
- if (!u.dependsOn.every((d) => opts.completedUnitIds.has(d)))
866
- continue;
867
- let clash = false;
868
- for (const p of u.claimedPaths) {
869
- if (blockedPaths.has(p)) {
870
- clash = true;
871
- break;
872
- }
873
- }
874
- if (clash)
875
- continue;
876
- for (const v of selected) {
877
- for (const pu of u.claimedPaths) {
878
- if (v.claimedPaths.includes(pu)) {
879
- clash = true;
880
- break;
881
- }
882
- }
883
- if (clash)
884
- break;
885
- }
886
- if (clash)
887
- continue;
888
- selected.push(u);
889
- for (const p of u.claimedPaths) {
890
- blockedPaths.add(p);
891
- }
892
- if (selected.length >= opts.cap)
893
- break;
894
- }
895
- return selected;
896
- }
897
- /**
898
- * Build scheduler rows from merged parallel wave definitions + plan units.
899
- */
900
- export function readySliceUnitsFromMergedWaves(mergedWaves, planMarkdown, options) {
901
- const units = parseImplementationUnits(planMarkdown);
902
- const metaByUnit = new Map(units.map((u) => {
903
- const m = parseImplementationUnitParallelFields(u, options);
904
- return [m.unitId, m];
905
- }));
906
- const sliceSet = new Set();
907
- for (const w of mergedWaves) {
908
- for (const m of w.members) {
909
- sliceSet.add(m.sliceId);
910
- }
911
- }
912
- const out = [];
913
- for (const sliceId of [...sliceSet].sort(compareSliceIds)) {
914
- const member = mergedWaves.flatMap((w) => w.members).find((x) => x.sliceId === sliceId);
915
- if (!member)
916
- continue;
917
- const meta = metaByUnit.get(member.unitId);
918
- if (!meta) {
919
- out.push({
920
- unitId: member.unitId,
921
- sliceId,
922
- dependsOn: [],
923
- claimedPaths: [],
924
- parallelizable: true
925
- });
926
- continue;
927
- }
928
- out.push({
929
- unitId: meta.unitId,
930
- sliceId,
931
- dependsOn: meta.dependsOn,
932
- claimedPaths: meta.claimedPaths,
933
- parallelizable: meta.parallelizable
934
- });
935
- }
936
- return out;
937
- }
938
- /**
939
- * Heuristic helper deciding whether a multi-slice wave needs
940
- * the `integration-overseer` dispatch.
941
- *
942
- * Triggers (any one):
943
- * - **two or more closed slices share import boundaries** (heuristic:
944
- * two slices declare a `claimedPaths` whose first 2 path segments
945
- * match — same package/module directory);
946
- * - any slice has `riskTier === "high"`.
947
- *
948
- * When none fire, the verdict is `{ required: false, reasons: ["disjoint-paths"] }`
949
- * and the caller should record a `cclaw_integration_overseer_skipped`
950
- * audit before bypassing the dispatch.
951
- *
952
- * Note on inputs: this function reads from the supplied delegation
953
- * events list directly so callers can inject synthetic data in tests.
954
- * Use `readDelegationEvents(projectRoot)` in production paths.
955
- */
956
- export function integrationCheckRequired(events) {
957
- const reasons = [];
958
- // Closed slices = ones whose phase=green or phase=refactor row is
959
- // completed. We collect each unique sliceId's representative paths
960
- // and risk tier so the heuristic looks at terminal state only.
961
- const sliceState = new Map();
962
- for (const evt of events) {
963
- if (evt.stage !== "tdd")
964
- continue;
965
- if (typeof evt.sliceId !== "string" || evt.sliceId.length === 0)
966
- continue;
967
- if (evt.status !== "completed")
968
- continue;
969
- if (evt.phase !== "green" && evt.phase !== "refactor" && evt.phase !== "refactor-deferred") {
970
- continue;
971
- }
972
- const existing = sliceState.get(evt.sliceId) ?? { sliceId: evt.sliceId };
973
- if (Array.isArray(evt.claimedPaths) && evt.claimedPaths.length > 0) {
974
- const merged = new Set(existing.claimedPaths ?? []);
975
- for (const p of evt.claimedPaths)
976
- merged.add(p);
977
- existing.claimedPaths = [...merged];
978
- }
979
- if (evt.riskTier === "low" || evt.riskTier === "medium" || evt.riskTier === "high") {
980
- // Highest-wins so the verdict is conservative.
981
- const order = { low: 0, medium: 1, high: 2 };
982
- const prev = existing.riskTier ?? "low";
983
- if (order[evt.riskTier] >= order[prev]) {
984
- existing.riskTier = evt.riskTier;
985
- }
986
- }
987
- sliceState.set(evt.sliceId, existing);
988
- }
989
- const slices = [...sliceState.values()];
990
- if (slices.some((s) => s.riskTier === "high")) {
991
- reasons.push("high-risk-slice");
992
- }
993
- // Shared-directory heuristic — two distinct slices with overlapping
994
- // first-2-segment directory prefixes count as shared boundary.
995
- const sliceDirs = new Map();
996
- for (const s of slices) {
997
- const dirs = new Set();
998
- for (const raw of s.claimedPaths ?? []) {
999
- const segments = raw.split("/").filter((seg) => seg.length > 0);
1000
- if (segments.length === 0)
1001
- continue;
1002
- // For top-level files like `package.json`, fall back to the
1003
- // first segment so single-segment paths still count as a shared
1004
- // directory when two slices both claim the file.
1005
- const prefix = segments.slice(0, Math.max(1, Math.min(2, segments.length))).join("/");
1006
- dirs.add(prefix);
1007
- }
1008
- if (dirs.size > 0)
1009
- sliceDirs.set(s.sliceId, dirs);
1010
- }
1011
- let sharedFound = false;
1012
- const ids = [...sliceDirs.keys()];
1013
- outer: for (let i = 0; i < ids.length; i += 1) {
1014
- const a = sliceDirs.get(ids[i]);
1015
- for (let j = i + 1; j < ids.length; j += 1) {
1016
- const b = sliceDirs.get(ids[j]);
1017
- for (const dir of a) {
1018
- if (b.has(dir)) {
1019
- sharedFound = true;
1020
- break outer;
1021
- }
1022
- }
1023
- }
1024
- }
1025
- if (sharedFound)
1026
- reasons.push("shared-import-boundary");
1027
- if (reasons.length > 0) {
1028
- return { required: true, reasons };
1029
- }
1030
- return { required: false, reasons: ["disjoint-paths"] };
1031
- }
1032
- /**
1033
- * Append a non-delegation audit event recording that the
1034
- * integration-overseer dispatch was skipped because
1035
- * `integrationCheckRequired()` returned `required: false`. Best-effort;
1036
- * never throws.
1037
- */
1038
- export async function recordIntegrationOverseerSkipped(projectRoot, params) {
1039
- const eventsPath = delegationEventsPath(projectRoot);
1040
- const payload = {
1041
- event: "cclaw_integration_overseer_skipped",
1042
- runId: params.runId,
1043
- reasons: params.reasons,
1044
- sliceIds: params.sliceIds,
1045
- ts: new Date().toISOString()
1046
- };
1047
- try {
1048
- await fs.mkdir(path.dirname(eventsPath), { recursive: true });
1049
- await fs.appendFile(eventsPath, `${JSON.stringify(payload)}\n`, "utf8");
1050
- }
1051
- catch {
1052
- // best-effort audit; never block stage advance.
1053
- }
1054
- }
1055
- /**
1056
- * Load merged wave plan (Parallel Execution Plan block + wave-plans/) and map to `ReadySliceUnit[]`.
1057
- */
1058
- export async function loadTddReadySlicePool(planMarkdown, artifactsDir, options) {
1059
- const merged = mergeParallelWaveDefinitions(parseParallelExecutionPlanWaves(planMarkdown), await parseWavePlanDirectory(artifactsDir));
1060
- return readySliceUnitsFromMergedWaves(merged, planMarkdown, options);
1061
- }
1062
- function readMaxParallelOverrideFromEnv() {
1063
- const raw = process.env.CCLAW_MAX_PARALLEL_SLICE_BUILDERS;
1064
- if (typeof raw !== "string" || raw.trim().length === 0)
1065
- return null;
1066
- const parsed = Number(raw);
1067
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1)
1068
- return null;
1069
- return parsed;
1070
- }
1071
- /**
1072
- * When scheduling a `slice-builder` on a TDD stage, compare `claimedPaths`
1073
- * against every currently active span on the same `(stage, agent)` pair.
1074
- * Overlap → throw `DispatchOverlapError`; disjoint paths → return
1075
- * `{ autoParallel: true }` so the caller can mark the new entry
1076
- * `allowParallel = true` without explicit operator intent. When the agent
1077
- * is not a slice-builder or no `claimedPaths` are supplied, the function
1078
- * returns `{ autoParallel: false }` and the standard dedup path takes over.
1079
- */
1080
- export function validateFileOverlap(stamped, activeEntries) {
1081
- if (!isParallelTddSliceWorker(stamped.agent) || stamped.stage !== "tdd") {
1082
- return { autoParallel: false };
1083
- }
1084
- const newPaths = Array.isArray(stamped.claimedPaths) ? stamped.claimedPaths : [];
1085
- if (newPaths.length === 0) {
1086
- return { autoParallel: false };
1087
- }
1088
- const sameLane = activeEntries.filter((entry) => entry.stage === stamped.stage &&
1089
- entry.agent === stamped.agent &&
1090
- entry.spanId !== stamped.spanId);
1091
- if (sameLane.length === 0) {
1092
- return { autoParallel: true };
1093
- }
1094
- for (const existing of sameLane) {
1095
- const existingPaths = Array.isArray(existing.claimedPaths) ? existing.claimedPaths : [];
1096
- if (existingPaths.length === 0) {
1097
- // We can't prove disjoint without the other side declaring paths;
1098
- // be conservative and let the standard dedup error path fire.
1099
- return { autoParallel: false };
1100
- }
1101
- const overlap = newPaths.filter((p) => existingPaths.includes(p));
1102
- if (overlap.length > 0) {
1103
- throw new DispatchOverlapError({
1104
- existingSpanId: existing.spanId ?? "unknown",
1105
- newSpanId: stamped.spanId ?? "unknown",
1106
- pair: { stage: stamped.stage, agent: stamped.agent },
1107
- conflictingPaths: overlap
1108
- });
1109
- }
1110
- }
1111
- return { autoParallel: true };
1112
- }
1113
- /**
1114
- * Enforce the slice-builder fan-out cap. The new scheduled row pushes the
1115
- * active count from N to N+1; if that would exceed the cap (default/config 5,
1116
- * env-overridable via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS`), throw
1117
- * `DispatchCapError`.
1118
- *
1119
- * Caller passes the already-folded list of active entries (latest row per
1120
- * spanId, ACTIVE statuses only). The function counts entries that match
1121
- * the agent on the same `stage`. The new row's own spanId is excluded so
1122
- * re-recording a `scheduled` doesn't trip the cap on a span that's already
1123
- * counted.
1124
- */
1125
- export function validateFanOutCap(stamped, activeEntries, override) {
1126
- if (!isParallelTddSliceWorker(stamped.agent) || stamped.stage !== "tdd")
1127
- return;
1128
- if (stamped.status !== "scheduled")
1129
- return;
1130
- const cap = readMaxParallelOverrideFromEnv() ??
1131
- (override !== null &&
1132
- override !== undefined &&
1133
- Number.isInteger(override) &&
1134
- override >= 1
1135
- ? override
1136
- : MAX_PARALLEL_SLICE_BUILDERS);
1137
- const sameLaneActive = activeEntries.filter((entry) => entry.stage === stamped.stage &&
1138
- entry.agent === stamped.agent &&
1139
- entry.spanId !== stamped.spanId);
1140
- if (sameLaneActive.length + 1 > cap) {
1141
- throw new DispatchCapError({
1142
- cap,
1143
- active: sameLaneActive.length,
1144
- pair: { stage: stamped.stage, agent: stamped.agent }
1145
- });
1146
- }
1147
- }
1148
- /**
1149
- * Find the latest active span for a given `(stage, agent)`
1150
- * pair in the supplied ledger entries. Returns the row whose latest
1151
- * status (after the latest-by-spanId fold) is still in the active set
1152
- * (`scheduled | launched | acknowledged`).
1153
- *
1154
- * Run-scope is **strict**: only entries whose `runId` matches the
1155
- * supplied `runId` are folded. Entries with empty/missing `runId`
1156
- * (older ledgers without explicit run scoping) are treated as NOT belonging
1157
- * to the current run, so they cannot keep an old span "active" across
1158
- * a fresh dispatch and trip a spurious `dispatch_duplicate`. This
1159
- * Ensures a slice-builder that ran in run-1 does not block a
1160
- * slice-builder scheduled in run-2.
1161
- *
1162
- * keep in sync with the inline copy in
1163
- * `src/content/hooks.ts::delegationRecordScript`.
1164
- */
1165
- export function findActiveSpanForPair(stage, agent, runId, ledger) {
1166
- const sameRun = ledger.entries.filter((entry) => {
1167
- if (typeof entry.runId !== "string" || entry.runId.length === 0)
1168
- return false;
1169
- if (entry.runId !== runId)
1170
- return false;
1171
- return entry.stage === stage && entry.agent === agent;
1172
- });
1173
- for (const entry of computeActiveSubagents(sameRun)) {
1174
- return entry;
1175
- }
1176
- return null;
1177
- }
1178
- async function writeSubagentTracker(projectRoot, entries) {
1179
- const active = computeActiveSubagents(entries).map((entry) => ({
1180
- spanId: entry.spanId,
1181
- dispatchId: entry.dispatchId,
1182
- workerRunId: entry.workerRunId,
1183
- stage: entry.stage,
1184
- agent: entry.agent,
1185
- status: entry.status,
1186
- dispatchSurface: entry.dispatchSurface,
1187
- agentDefinitionPath: entry.agentDefinitionPath,
1188
- startedAt: entry.startTs,
1189
- launchedAt: entry.launchedTs,
1190
- acknowledgedAt: entry.ackTs,
1191
- allowParallel: entry.allowParallel
1192
- }));
1193
- await writeFileSafe(subagentsStatePath(projectRoot), `${JSON.stringify({ active, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
1194
- }
1195
- export async function appendDelegation(projectRoot, entry) {
1196
- const flowState = await readFlowState(projectRoot);
1197
- const { activeRunId } = flowState;
1198
- await withDirectoryLock(delegationLockPath(projectRoot), async () => {
1199
- const filePath = delegationLogPath(projectRoot);
1200
- const prior = await readDelegationLedger(projectRoot);
1201
- const lifecycleCandidates = [
1202
- entry.startTs,
1203
- entry.launchedTs,
1204
- entry.ackTs,
1205
- entry.completedTs,
1206
- entry.ts
1207
- ].filter((value) => typeof value === "string" && value.length > 0);
1208
- const earliestLifecycle = lifecycleCandidates.length > 0
1209
- ? lifecycleCandidates.reduce((min, candidate) => (candidate < min ? candidate : min))
1210
- : undefined;
1211
- const startTs = entry.startTs ?? earliestLifecycle ?? new Date().toISOString();
1212
- if (entry.status === "waived" && !hasValidWaiverReason(entry.waiverReason)) {
1213
- throw new Error("waived delegation entries require a non-empty waiverReason");
1214
- }
1215
- const stamped = statusTimestampPatch({ ...entry, runId: entry.runId ?? activeRunId }, startTs);
1216
- stamped.spanId = entry.spanId ?? createSpanId();
1217
- stamped.startTs = startTs;
1218
- stamped.ts = startTs;
1219
- if (TERMINAL_DELEGATION_STATUSES.has(stamped.status) && !stamped.endTs) {
1220
- stamped.endTs = new Date().toISOString();
1221
- }
1222
- if (stamped.status === "completed") {
1223
- stamped.completedTs = stamped.completedTs ?? stamped.endTs ?? new Date().toISOString();
1224
- }
1225
- if (stamped.status === "scheduled") {
1226
- delete stamped.endTs;
1227
- }
1228
- stamped.schemaVersion = DELEGATION_LEDGER_SCHEMA_VERSION;
1229
- if (stamped.retryCount === undefined ||
1230
- !Number.isInteger(stamped.retryCount) ||
1231
- stamped.retryCount < 0) {
1232
- stamped.retryCount = 0;
1233
- }
1234
- if (!Array.isArray(stamped.evidenceRefs)) {
1235
- stamped.evidenceRefs = [];
1236
- }
1237
- if (stamped.status === "completed" && stamped.fulfillmentMode === undefined) {
1238
- const activeFallback = activeHarnessSubagentFallback();
1239
- if (activeFallback) {
1240
- stamped.fulfillmentMode = expectedFulfillmentMode([activeFallback]);
1241
- }
1242
- else {
1243
- const config = await readConfig(projectRoot).catch(() => null);
1244
- const harnesses = config?.harnesses ?? [];
1245
- const fallbacks = harnesses.map((h) => HARNESS_ADAPTERS[h].capabilities.subagentFallback);
1246
- stamped.fulfillmentMode = expectedFulfillmentMode(fallbacks);
1247
- }
1248
- }
1249
- if (prior.entries.some((existing) => existing.spanId === stamped.spanId &&
1250
- existing.status === stamped.status &&
1251
- (existing.phase ?? null) === (stamped.phase ?? null))) {
1252
- return;
1253
- }
1254
- validateMonotonicTimestamps(stamped, prior.entries);
1255
- validatePhaseEventStatus(stamped);
1256
- if (stamped.status === "scheduled" &&
1257
- typeof stamped.sliceId === "string" &&
1258
- stamped.sliceId.length > 0 &&
1259
- stamped.phase === undefined) {
1260
- const closed = closedSliceSpans(prior.entries, stamped.sliceId, activeRunId);
1261
- if (closed.size > 0 && !(stamped.spanId && closed.has(stamped.spanId))) {
1262
- const closedSpanId = closed.values().next().value;
1263
- throw new SliceAlreadyClosedError({
1264
- sliceId: stamped.sliceId,
1265
- runId: activeRunId,
1266
- closedSpanId,
1267
- newSpanId: stamped.spanId ?? "unknown"
1268
- });
1269
- }
1270
- }
1271
- if (stamped.status === "scheduled") {
1272
- validateClaimedPathsNotProtected(stamped);
1273
- const sameRunPrior = prior.entries.filter((entry) => entry.runId === activeRunId);
1274
- const activeForRun = computeActiveSubagents(sameRunPrior);
1275
- const overlap = validateFileOverlap(stamped, activeForRun);
1276
- if (overlap.autoParallel && stamped.allowParallel !== true) {
1277
- stamped.allowParallel = true;
1278
- }
1279
- const config = await readConfig(projectRoot).catch(() => null);
1280
- validateFanOutCap(stamped, activeForRun, resolveMaxBuilders(config));
1281
- if (stamped.allowParallel !== true) {
1282
- const existing = findActiveSpanForPair(stamped.stage, stamped.agent, activeRunId, prior);
1283
- if (existing && existing.spanId && existing.spanId !== stamped.spanId) {
1284
- throw new DispatchDuplicateError({
1285
- existingSpanId: existing.spanId,
1286
- existingStatus: existing.status,
1287
- newSpanId: stamped.spanId,
1288
- pair: { stage: stamped.stage, agent: stamped.agent }
1289
- });
1290
- }
1291
- }
1292
- }
1293
- await appendDelegationEvent(projectRoot, eventFromEntry(stamped));
1294
- const ledger = {
1295
- runId: activeRunId,
1296
- entries: [...prior.entries, stamped],
1297
- schemaVersion: DELEGATION_LEDGER_SCHEMA_VERSION
1298
- };
1299
- await writeFileSafe(filePath, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
1300
- await writeSubagentTracker(projectRoot, ledger.entries);
1301
- });
1302
- }
1303
- /**
1304
- * Aggregate the fulfillment mode cclaw expects for the active harness set.
1305
- * Priority native > generic-dispatch > role-switch > waiver — the best
1306
- * available mode wins so mixed installs (e.g. claude + codex) inherit the
1307
- * strongest guarantee.
1308
- */
1309
- export function expectedFulfillmentMode(fallbacks) {
1310
- if (fallbacks.length === 0)
1311
- return "isolated";
1312
- if (fallbacks.some((f) => f === "native"))
1313
- return "isolated";
1314
- if (fallbacks.some((f) => f === "generic-dispatch"))
1315
- return "generic-dispatch";
1316
- if (fallbacks.some((f) => f === "role-switch"))
1317
- return "role-switch";
1318
- return "harness-waiver";
1319
- }
1320
- export async function checkMandatoryDelegations(projectRoot, stage, options = {}) {
1321
- const flowState = await readFlowState(projectRoot, {
1322
- repairFeatureSystem: options.repairFeatureSystem
1323
- });
1324
- // Read `flowState.taskClass` as a fallback
1325
- // when the caller doesn't pass an explicit override. The
1326
- // `cclaw advance-stage` path (`buildValidationReport` →
1327
- // `checkMandatoryDelegations`) never forwarded `taskClass`, which left
1328
- // the `software-bugfix` skip dead for users who classified their run
1329
- // via `flow-state.json`. Forward-typed `null` callers still suppress
1330
- // the lookup explicitly; only `undefined` triggers the fallback.
1331
- const resolvedTaskClass = options.taskClass !== undefined ? options.taskClass : flowState.taskClass ?? null;
1332
- const mandatory = mandatoryAgentsFor(stage, flowState.track, resolvedTaskClass, "standard", flowState.discoveryMode);
1333
- const skippedByTrack = mandatory.length === 0 &&
1334
- stageSchema(stage, flowState.track, flowState.discoveryMode, resolvedTaskClass).mandatoryDelegations.length > 0;
1335
- if (skippedByTrack) {
1336
- await recordMandatorySkippedByTrack(projectRoot, {
1337
- stage,
1338
- track: flowState.track,
1339
- taskClass: resolvedTaskClass,
1340
- runId: flowState.activeRunId
1341
- });
1342
- }
1343
- const { activeRunId } = flowState;
1344
- const ledger = await readDelegationLedger(projectRoot);
1345
- const events = await readDelegationEvents(projectRoot);
1346
- const forStage = ledger.entries.filter((e) => e.stage === stage);
1347
- const forRun = forStage.filter((e) => e.runId === activeRunId);
1348
- const staleIgnored = forStage
1349
- .filter((e) => e.runId !== activeRunId)
1350
- .map((e) => `${e.agent}(runId=${e.runId ?? "unknown"})`);
1351
- const missing = [];
1352
- const waived = [];
1353
- const missingEvidence = [];
1354
- const missingDispatchProof = [];
1355
- const legacyInferredCompletions = [];
1356
- let legacyRequiresRerecord = false;
1357
- const terminalSpanIds = new Set(forRun
1358
- .filter((entry) => TERMINAL_DELEGATION_STATUSES.has(entry.status) && entry.spanId)
1359
- .map((entry) => entry.spanId));
1360
- const staleWorkers = forRun
1361
- .filter((entry) => entry.status === "scheduled" && entry.spanId && !terminalSpanIds.has(entry.spanId))
1362
- .map((entry) => `${entry.agent}(spanId=${entry.spanId})`);
1363
- const config = await readConfig(projectRoot).catch(() => null);
1364
- const harnesses = config?.harnesses ?? [];
1365
- const configuredFallbacks = harnesses.map((h) => HARNESS_ADAPTERS[h].capabilities.subagentFallback);
1366
- const activeFallback = activeHarnessSubagentFallback();
1367
- const expectedMode = expectedFulfillmentMode(activeFallback ? [activeFallback] : configuredFallbacks);
1368
- for (const agent of mandatory) {
1369
- const rows = forRun.filter((e) => e.agent === agent);
1370
- const completedRows = rows.filter((e) => e.status === "completed");
1371
- const waivedRows = rows.filter((e) => e.status === "waived" && e.mode === "mandatory");
1372
- const hasCompleted = completedRows.length >= 1;
1373
- const hasWaived = waivedRows.length > 0;
1374
- const ok = hasWaived || hasCompleted;
1375
- if (!ok) {
1376
- missing.push(agent);
1377
- continue;
1378
- }
1379
- if (hasWaived) {
1380
- waived.push(agent);
1381
- }
1382
- // Evidence is required for non-isolated completions and for explicit
1383
- // degraded role-switch rows. Native OpenCode/Codex/Claude isolated
1384
- // dispatch is accepted as true subagent work; role-switch remains a
1385
- // fallback that must point at artifact evidence.
1386
- const evidenceRequired = expectedMode !== "isolated" || completedRows.some((e) => (e.fulfillmentMode ?? "isolated") !== "isolated");
1387
- if (hasCompleted &&
1388
- evidenceRequired &&
1389
- !completedRows.some((e) => Array.isArray(e.evidenceRefs) && e.evidenceRefs.length > 0)) {
1390
- missingEvidence.push(agent);
1391
- }
1392
- // legacyInferredCompletions has two sources, split by `legacyTagged`:
1393
- // - legacyTagged === true : the row was *parsed* as legacy-inferred
1394
- // from a pre-v3 ledger file. Requires `delegation-record.mjs
1395
- // --rerecord` and BLOCKS satisfied.
1396
- // - legacyTagged === false: in-check inference for minimally-spec'd
1397
- // isolated rows that lack proof-era signals. Advisory only —
1398
- // preserves backward-compatible behavior for existing API callers.
1399
- for (const row of completedRows) {
1400
- const mode = row.fulfillmentMode ?? "isolated";
1401
- if (mode === "legacy-inferred") {
1402
- legacyInferredCompletions.push(`${agent}(spanId=${row.spanId ?? "unknown"})`);
1403
- legacyRequiresRerecord = true;
1404
- continue;
1405
- }
1406
- if (mode === "isolated") {
1407
- const spanEvents = events.events.filter((event) => event.runId === activeRunId &&
1408
- event.stage === stage &&
1409
- event.agent === agent &&
1410
- event.spanId === row.spanId);
1411
- const dispatchId = row.dispatchId ?? row.workerRunId ?? spanEvents.find((event) => event.dispatchId || event.workerRunId)?.dispatchId ?? spanEvents.find((event) => event.workerRunId)?.workerRunId;
1412
- const dispatchSurface = row.dispatchSurface ?? spanEvents.find((event) => event.dispatchSurface)?.dispatchSurface;
1413
- const agentDefinitionPath = row.agentDefinitionPath ?? spanEvents.find((event) => event.agentDefinitionPath)?.agentDefinitionPath;
1414
- const hasAck = Boolean(row.ackTs || spanEvents.some((event) => event.event === "acknowledged" && event.ackTs));
1415
- const hasCompleted = Boolean(row.completedTs || spanEvents.some((event) => event.event === "completed" && event.completedTs));
1416
- const hasDispatchProof = Boolean(row.spanId && dispatchId && dispatchSurface && agentDefinitionPath && hasAck && hasCompleted);
1417
- if (!hasDispatchProof) {
1418
- const proofEraSignal = Boolean(row.dispatchId || row.workerRunId || row.dispatchSurface || row.agentDefinitionPath || spanEvents.some((event) => event.dispatchId || event.workerRunId || event.dispatchSurface || event.agentDefinitionPath || event.event === "acknowledged" || event.event === "launched"));
1419
- if (proofEraSignal) {
1420
- missingDispatchProof.push(agent);
1421
- }
1422
- else {
1423
- legacyInferredCompletions.push(`${agent}(spanId=${row.spanId ?? "unknown"})`);
1424
- }
1425
- }
1426
- }
1427
- }
1428
- }
1429
- return {
1430
- satisfied: missing.length === 0 &&
1431
- missingEvidence.length === 0 &&
1432
- missingDispatchProof.length === 0 &&
1433
- !legacyRequiresRerecord &&
1434
- staleWorkers.length === 0 &&
1435
- events.corruptLines.length === 0,
1436
- missing,
1437
- waived,
1438
- staleIgnored,
1439
- missingEvidence,
1440
- missingDispatchProof,
1441
- legacyInferredCompletions,
1442
- corruptEventLines: events.corruptLines,
1443
- staleWorkers,
1444
- expectedMode,
1445
- skippedByTrack
1446
- };
1447
- }
1448
- /**
1449
- * Append a non-delegation audit event to
1450
- * `delegation-events.jsonl` recording that the mandatory delegation
1451
- * gate was skipped because of the active track / task class. Plays the
1452
- * same audit role as a `waived` row but does NOT carry an agent —
1453
- * downstream tooling treats `event === "mandatory_delegations_skipped_by_track"`
1454
- * lines as informational.
1455
- *
1456
- * Failures are swallowed: the audit log is best-effort. Missing the
1457
- * event must never block stage advance because the gate skip itself is
1458
- * authoritative.
1459
- */
1460
- async function recordMandatorySkippedByTrack(projectRoot, params) {
1461
- const eventsPath = delegationEventsPath(projectRoot);
1462
- const payload = {
1463
- event: "mandatory_delegations_skipped_by_track",
1464
- stage: params.stage,
1465
- track: params.track,
1466
- taskClass: params.taskClass,
1467
- runId: params.runId,
1468
- ts: new Date().toISOString()
1469
- };
1470
- try {
1471
- await fs.mkdir(path.dirname(eventsPath), { recursive: true });
1472
- await fs.appendFile(eventsPath, `${JSON.stringify(payload)}\n`, "utf8");
1473
- }
1474
- catch {
1475
- // best-effort audit; never block stage advance.
1476
- }
1477
- }
1478
- /**
1479
- * Append a non-delegation audit event recording
1480
- * that one or more required artifact-validation findings were
1481
- * demoted from blocking to advisory because the active run is on a
1482
- * small-fix lane (`track === "quick"` or `taskClass === "software-bugfix"`).
1483
- *
1484
- * The event mirrors `mandatory_delegations_skipped_by_track`
1485
- * audit pattern: best-effort write to `delegation-events.jsonl`, no
1486
- * agent payload, recognized by `readDelegationEvents` so it does not
1487
- * corrupt downstream parsers. Failures are swallowed.
1488
- */
1489
- export async function recordArtifactValidationDemotedByTrack(projectRoot, params) {
1490
- if (params.sections.length === 0)
1491
- return;
1492
- const eventsPath = delegationEventsPath(projectRoot);
1493
- const payload = {
1494
- event: "artifact_validation_demoted_by_track",
1495
- stage: params.stage,
1496
- track: params.track,
1497
- taskClass: params.taskClass,
1498
- runId: params.runId,
1499
- sections: params.sections,
1500
- ts: new Date().toISOString()
1501
- };
1502
- try {
1503
- await fs.mkdir(path.dirname(eventsPath), { recursive: true });
1504
- await fs.appendFile(eventsPath, `${JSON.stringify(payload)}\n`, "utf8");
1505
- }
1506
- catch {
1507
- // best-effort audit; never block stage advance.
1508
- }
1509
- }
1510
- /**
1511
- * Append a non-delegation audit event recording
1512
- * that the scope-stage Expansion Strategist (`product-discovery`)
1513
- * delegation requirement was skipped because the active run is on a
1514
- * small-fix lane (`track === "quick"` or `taskClass === "software-bugfix"`).
1515
- *
1516
- * Mirrors the `mandatory_delegations_skipped_by_track`
1517
- * audit pattern: best-effort write to `delegation-events.jsonl`, no
1518
- * agent payload, recognized by `readDelegationEvents` so it does not
1519
- * corrupt downstream parsers. Failures are swallowed.
1520
- */
1521
- export async function recordExpansionStrategistSkippedByTrack(projectRoot, params) {
1522
- const eventsPath = delegationEventsPath(projectRoot);
1523
- const payload = {
1524
- event: "expansion_strategist_skipped_by_track",
1525
- stage: "scope",
1526
- track: params.track,
1527
- taskClass: params.taskClass,
1528
- runId: params.runId,
1529
- selectedScopeMode: params.selectedScopeMode,
1530
- ts: new Date().toISOString()
1531
- };
1532
- try {
1533
- await fs.mkdir(path.dirname(eventsPath), { recursive: true });
1534
- await fs.appendFile(eventsPath, `${JSON.stringify(payload)}\n`, "utf8");
1535
- }
1536
- catch {
1537
- // best-effort audit; never block stage advance.
1538
- }
1539
- }