cclaw-cli 7.7.1 → 8.1.1

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 (284) hide show
  1. package/README.md +211 -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 +107 -511
  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 -768
  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 -134
  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/harness-prompt.d.ts +26 -0
  73. package/dist/harness-prompt.js +142 -0
  74. package/dist/install.d.ts +35 -15
  75. package/dist/install.js +238 -1347
  76. package/dist/knowledge-store.d.ts +19 -163
  77. package/dist/knowledge-store.js +56 -590
  78. package/dist/logger.d.ts +8 -3
  79. package/dist/logger.js +13 -4
  80. package/dist/orchestrator-routing.d.ts +29 -0
  81. package/dist/orchestrator-routing.js +156 -0
  82. package/dist/run-persistence.d.ts +7 -118
  83. package/dist/run-persistence.js +29 -845
  84. package/dist/runtime/run-hook.entry.d.ts +1 -3
  85. package/dist/runtime/run-hook.entry.js +19 -4
  86. package/dist/runtime/run-hook.mjs +13 -1024
  87. package/dist/types.d.ts +25 -261
  88. package/dist/types.js +8 -36
  89. package/package.json +6 -3
  90. package/dist/artifact-linter/brainstorm.d.ts +0 -2
  91. package/dist/artifact-linter/brainstorm.js +0 -353
  92. package/dist/artifact-linter/design.d.ts +0 -18
  93. package/dist/artifact-linter/design.js +0 -444
  94. package/dist/artifact-linter/findings-dedup.d.ts +0 -56
  95. package/dist/artifact-linter/findings-dedup.js +0 -232
  96. package/dist/artifact-linter/plan.d.ts +0 -2
  97. package/dist/artifact-linter/plan.js +0 -826
  98. package/dist/artifact-linter/review-army.d.ts +0 -49
  99. package/dist/artifact-linter/review-army.js +0 -520
  100. package/dist/artifact-linter/review.d.ts +0 -2
  101. package/dist/artifact-linter/review.js +0 -113
  102. package/dist/artifact-linter/scope.d.ts +0 -2
  103. package/dist/artifact-linter/scope.js +0 -158
  104. package/dist/artifact-linter/shared.d.ts +0 -637
  105. package/dist/artifact-linter/shared.js +0 -2163
  106. package/dist/artifact-linter/ship.d.ts +0 -2
  107. package/dist/artifact-linter/ship.js +0 -250
  108. package/dist/artifact-linter/spec.d.ts +0 -2
  109. package/dist/artifact-linter/spec.js +0 -176
  110. package/dist/artifact-linter/tdd.d.ts +0 -118
  111. package/dist/artifact-linter/tdd.js +0 -1404
  112. package/dist/artifact-linter.d.ts +0 -15
  113. package/dist/artifact-linter.js +0 -517
  114. package/dist/codex-feature-flag.d.ts +0 -58
  115. package/dist/codex-feature-flag.js +0 -193
  116. package/dist/content/closeout-guidance.d.ts +0 -14
  117. package/dist/content/closeout-guidance.js +0 -44
  118. package/dist/content/diff-command.d.ts +0 -1
  119. package/dist/content/diff-command.js +0 -43
  120. package/dist/content/harness-doc.d.ts +0 -1
  121. package/dist/content/harness-doc.js +0 -65
  122. package/dist/content/hook-events.d.ts +0 -9
  123. package/dist/content/hook-events.js +0 -23
  124. package/dist/content/hook-manifest.d.ts +0 -81
  125. package/dist/content/hook-manifest.js +0 -156
  126. package/dist/content/hooks.d.ts +0 -11
  127. package/dist/content/hooks.js +0 -1972
  128. package/dist/content/idea.d.ts +0 -60
  129. package/dist/content/idea.js +0 -416
  130. package/dist/content/language-policy.d.ts +0 -2
  131. package/dist/content/language-policy.js +0 -13
  132. package/dist/content/learnings.d.ts +0 -6
  133. package/dist/content/learnings.js +0 -141
  134. package/dist/content/observe.d.ts +0 -19
  135. package/dist/content/observe.js +0 -86
  136. package/dist/content/opencode-plugin.d.ts +0 -1
  137. package/dist/content/opencode-plugin.js +0 -635
  138. package/dist/content/review-prompts.d.ts +0 -1
  139. package/dist/content/review-prompts.js +0 -104
  140. package/dist/content/runtime-shared-snippets.d.ts +0 -8
  141. package/dist/content/runtime-shared-snippets.js +0 -80
  142. package/dist/content/session-hooks.d.ts +0 -7
  143. package/dist/content/session-hooks.js +0 -107
  144. package/dist/content/skills-elicitation.d.ts +0 -1
  145. package/dist/content/skills-elicitation.js +0 -167
  146. package/dist/content/stage-command.d.ts +0 -2
  147. package/dist/content/stage-command.js +0 -17
  148. package/dist/content/stage-schema.d.ts +0 -117
  149. package/dist/content/stage-schema.js +0 -955
  150. package/dist/content/stages/_lint-metadata/index.d.ts +0 -2
  151. package/dist/content/stages/_lint-metadata/index.js +0 -97
  152. package/dist/content/stages/brainstorm.d.ts +0 -2
  153. package/dist/content/stages/brainstorm.js +0 -184
  154. package/dist/content/stages/design.d.ts +0 -2
  155. package/dist/content/stages/design.js +0 -288
  156. package/dist/content/stages/index.d.ts +0 -8
  157. package/dist/content/stages/index.js +0 -11
  158. package/dist/content/stages/plan.d.ts +0 -2
  159. package/dist/content/stages/plan.js +0 -191
  160. package/dist/content/stages/review.d.ts +0 -2
  161. package/dist/content/stages/review.js +0 -240
  162. package/dist/content/stages/schema-types.d.ts +0 -203
  163. package/dist/content/stages/schema-types.js +0 -1
  164. package/dist/content/stages/scope.d.ts +0 -2
  165. package/dist/content/stages/scope.js +0 -254
  166. package/dist/content/stages/ship.d.ts +0 -2
  167. package/dist/content/stages/ship.js +0 -159
  168. package/dist/content/stages/spec.d.ts +0 -2
  169. package/dist/content/stages/spec.js +0 -170
  170. package/dist/content/stages/tdd.d.ts +0 -4
  171. package/dist/content/stages/tdd.js +0 -273
  172. package/dist/content/state-contracts.d.ts +0 -1
  173. package/dist/content/state-contracts.js +0 -63
  174. package/dist/content/status-command.d.ts +0 -4
  175. package/dist/content/status-command.js +0 -109
  176. package/dist/content/subagent-context-skills.d.ts +0 -4
  177. package/dist/content/subagent-context-skills.js +0 -279
  178. package/dist/content/subagents.d.ts +0 -3
  179. package/dist/content/subagents.js +0 -997
  180. package/dist/content/templates.d.ts +0 -26
  181. package/dist/content/templates.js +0 -1692
  182. package/dist/content/track-render-context.d.ts +0 -18
  183. package/dist/content/track-render-context.js +0 -53
  184. package/dist/content/tree-command.d.ts +0 -1
  185. package/dist/content/tree-command.js +0 -64
  186. package/dist/content/utility-skills.d.ts +0 -30
  187. package/dist/content/utility-skills.js +0 -160
  188. package/dist/content/view-command.d.ts +0 -2
  189. package/dist/content/view-command.js +0 -92
  190. package/dist/delegation.d.ts +0 -649
  191. package/dist/delegation.js +0 -1539
  192. package/dist/early-loop.d.ts +0 -70
  193. package/dist/early-loop.js +0 -302
  194. package/dist/execution-topology.d.ts +0 -44
  195. package/dist/execution-topology.js +0 -95
  196. package/dist/gate-evidence.d.ts +0 -85
  197. package/dist/gate-evidence.js +0 -631
  198. package/dist/harness-adapters.d.ts +0 -151
  199. package/dist/harness-adapters.js +0 -756
  200. package/dist/harness-selection.d.ts +0 -31
  201. package/dist/harness-selection.js +0 -214
  202. package/dist/hook-schema.d.ts +0 -6
  203. package/dist/hook-schema.js +0 -114
  204. package/dist/hook-schemas/claude-hooks.v1.json +0 -10
  205. package/dist/hook-schemas/codex-hooks.v1.json +0 -10
  206. package/dist/hook-schemas/cursor-hooks.v1.json +0 -13
  207. package/dist/init-detect.d.ts +0 -2
  208. package/dist/init-detect.js +0 -50
  209. package/dist/internal/advance-stage/advance.d.ts +0 -89
  210. package/dist/internal/advance-stage/advance.js +0 -655
  211. package/dist/internal/advance-stage/cancel-run.d.ts +0 -8
  212. package/dist/internal/advance-stage/cancel-run.js +0 -19
  213. package/dist/internal/advance-stage/flow-state-coercion.d.ts +0 -3
  214. package/dist/internal/advance-stage/flow-state-coercion.js +0 -81
  215. package/dist/internal/advance-stage/helpers.d.ts +0 -14
  216. package/dist/internal/advance-stage/helpers.js +0 -145
  217. package/dist/internal/advance-stage/hook.d.ts +0 -8
  218. package/dist/internal/advance-stage/hook.js +0 -40
  219. package/dist/internal/advance-stage/parsers.d.ts +0 -72
  220. package/dist/internal/advance-stage/parsers.js +0 -357
  221. package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +0 -24
  222. package/dist/internal/advance-stage/proactive-delegation-trace.js +0 -56
  223. package/dist/internal/advance-stage/review-loop.d.ts +0 -16
  224. package/dist/internal/advance-stage/review-loop.js +0 -199
  225. package/dist/internal/advance-stage/rewind.d.ts +0 -14
  226. package/dist/internal/advance-stage/rewind.js +0 -108
  227. package/dist/internal/advance-stage/start-flow.d.ts +0 -13
  228. package/dist/internal/advance-stage/start-flow.js +0 -241
  229. package/dist/internal/advance-stage/verify.d.ts +0 -21
  230. package/dist/internal/advance-stage/verify.js +0 -185
  231. package/dist/internal/advance-stage.d.ts +0 -7
  232. package/dist/internal/advance-stage.js +0 -138
  233. package/dist/internal/cohesion-contract-stub.d.ts +0 -24
  234. package/dist/internal/cohesion-contract-stub.js +0 -148
  235. package/dist/internal/compound-readiness.d.ts +0 -23
  236. package/dist/internal/compound-readiness.js +0 -102
  237. package/dist/internal/detect-public-api-changes.d.ts +0 -5
  238. package/dist/internal/detect-public-api-changes.js +0 -45
  239. package/dist/internal/detect-supply-chain-changes.d.ts +0 -6
  240. package/dist/internal/detect-supply-chain-changes.js +0 -138
  241. package/dist/internal/early-loop-status.d.ts +0 -7
  242. package/dist/internal/early-loop-status.js +0 -93
  243. package/dist/internal/envelope-validate.d.ts +0 -7
  244. package/dist/internal/envelope-validate.js +0 -66
  245. package/dist/internal/flow-state-repair.d.ts +0 -20
  246. package/dist/internal/flow-state-repair.js +0 -104
  247. package/dist/internal/plan-split-waves.d.ts +0 -190
  248. package/dist/internal/plan-split-waves.js +0 -764
  249. package/dist/internal/runtime-integrity.d.ts +0 -7
  250. package/dist/internal/runtime-integrity.js +0 -268
  251. package/dist/internal/slice-commit.d.ts +0 -7
  252. package/dist/internal/slice-commit.js +0 -619
  253. package/dist/internal/tdd-loop-status.d.ts +0 -14
  254. package/dist/internal/tdd-loop-status.js +0 -68
  255. package/dist/internal/tdd-red-evidence.d.ts +0 -7
  256. package/dist/internal/tdd-red-evidence.js +0 -153
  257. package/dist/internal/waiver-grant.d.ts +0 -62
  258. package/dist/internal/waiver-grant.js +0 -294
  259. package/dist/internal/wave-status.d.ts +0 -74
  260. package/dist/internal/wave-status.js +0 -506
  261. package/dist/managed-resources.d.ts +0 -53
  262. package/dist/managed-resources.js +0 -313
  263. package/dist/policy.d.ts +0 -10
  264. package/dist/policy.js +0 -167
  265. package/dist/retro-gate.d.ts +0 -9
  266. package/dist/retro-gate.js +0 -47
  267. package/dist/run-archive.d.ts +0 -61
  268. package/dist/run-archive.js +0 -391
  269. package/dist/runs.d.ts +0 -2
  270. package/dist/runs.js +0 -2
  271. package/dist/stack-detection.d.ts +0 -116
  272. package/dist/stack-detection.js +0 -489
  273. package/dist/streaming/event-stream.d.ts +0 -31
  274. package/dist/streaming/event-stream.js +0 -114
  275. package/dist/tdd-cycle.d.ts +0 -107
  276. package/dist/tdd-cycle.js +0 -289
  277. package/dist/tdd-verification-evidence.d.ts +0 -17
  278. package/dist/tdd-verification-evidence.js +0 -122
  279. package/dist/track-heuristics.d.ts +0 -27
  280. package/dist/track-heuristics.js +0 -154
  281. package/dist/util/slice-id.d.ts +0 -58
  282. package/dist/util/slice-id.js +0 -89
  283. package/dist/worktree-manager.d.ts +0 -20
  284. package/dist/worktree-manager.js +0 -108
@@ -1,887 +1,237 @@
1
- import { existsSync } from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { RUNTIME_ROOT } from "../constants.js";
5
- import { SHARED_FLOW_AND_KNOWLEDGE_SNIPPETS, SHARED_STAGE_SUPPORT_SNIPPETS } from "./runtime-shared-snippets.js";
6
- function resolveCliRuntimeForGeneratedHook() {
7
- const here = fileURLToPath(import.meta.url);
8
- const candidates = [
9
- path.resolve(path.dirname(here), "..", "cli.js"),
10
- path.resolve(path.dirname(here), "..", "..", "dist", "cli.js")
11
- ];
12
- for (const candidate of candidates) {
13
- // Synchronous probe runs only during cclaw-cli init/sync generation.
14
- if (existsSync(candidate))
15
- return { entrypoint: candidate, argsPrefix: [] };
16
- }
17
- // Vitest exercises init/sync directly from src/ without a compiled dist/.
18
- // Route that dev-only shape through vite-node so hooks still prove a local runtime.
19
- if (process.env.VITEST === "true") {
20
- const sourceCli = path.resolve(path.dirname(here), "..", "cli.ts");
21
- const viteNode = path.resolve(path.dirname(here), "..", "..", "node_modules", "vite-node", "vite-node.mjs");
22
- if (existsSync(sourceCli) && existsSync(viteNode)) {
23
- return { entrypoint: viteNode, argsPrefix: ["--script", sourceCli] };
24
- }
25
- }
26
- return { entrypoint: null, argsPrefix: [] };
27
- }
28
- /**
29
- * Node-only hook runtime (single entrypoint).
30
- *
31
- * Generated into `.cclaw/hooks/run-hook.mjs` and used by all harnesses to avoid
32
- * bash/python/jq runtime dependencies.
33
- */
34
- export function nodeHookRuntimeScript(options = {}) {
35
- void options;
36
- const defaultHookProfile = "standard";
37
- const defaultDisabledHooks = [];
38
- const cliRuntime = resolveCliRuntimeForGeneratedHook();
39
- return `#!/usr/bin/env node
40
- import { createHash } from "node:crypto";
1
+ const SESSION_START_HOOK = `#!/usr/bin/env node
2
+ // cclaw session-start: rehydrate flow state and surface active slug.
41
3
  import fs from "node:fs/promises";
42
4
  import path from "node:path";
43
- import process from "node:process";
44
- import { spawn } from "node:child_process";
45
-
46
- const RUNTIME_ROOT = ${JSON.stringify(RUNTIME_ROOT)};
47
- const FLOW_STATE_GUARD_REL_PATH = RUNTIME_ROOT + "/.flow-state.guard.json";
48
- const CCLAW_CLI_ENTRYPOINT = ${JSON.stringify(cliRuntime.entrypoint)};
49
- const CCLAW_CLI_ARGS_PREFIX = ${JSON.stringify(cliRuntime.argsPrefix)};
50
- const DEFAULT_HOOK_PROFILE = ${JSON.stringify(defaultHookProfile)};
51
- const DEFAULT_DISABLED_HOOKS = ${JSON.stringify(defaultDisabledHooks)};
52
- const HOOK_PROFILE_VALUES = new Set(["minimal", "standard", "strict"]);
53
- const MINIMAL_PROFILE_ALLOWED_HOOKS = new Set([
54
- "session-start",
55
- "stop-handoff"
56
- ]);
57
-
58
- ${SHARED_FLOW_AND_KNOWLEDGE_SNIPPETS}
59
- ${SHARED_STAGE_SUPPORT_SNIPPETS}
60
-
61
- function normalizeHookToken(value) {
62
- return String(value == null ? "" : value).trim().toLowerCase();
63
- }
64
-
65
- function parseHookProfile(rawValue, fallback = "standard") {
66
- const normalized = normalizeHookToken(rawValue);
67
- if (HOOK_PROFILE_VALUES.has(normalized)) return normalized;
68
- return fallback;
69
- }
70
-
71
- function parseDisabledHooksCsv(rawValue) {
72
- const raw = typeof rawValue === "string" ? rawValue : "";
73
- if (raw.trim().length === 0) return [];
74
- const out = [];
75
- for (const token of raw.split(",")) {
76
- const normalized = normalizeHookToken(token);
77
- if (normalized.length === 0) continue;
78
- if (!out.includes(normalized)) out.push(normalized);
79
- }
80
- return out;
81
- }
82
-
83
- function parseInlineYamlList(rawValue) {
84
- const raw = typeof rawValue === "string" ? rawValue.trim() : "";
85
- if (!raw.startsWith("[") || !raw.endsWith("]")) return [];
86
- const inside = raw.slice(1, -1).trim();
87
- if (inside.length === 0) return [];
88
- return inside.split(",").map((token) => normalizeHookToken(token.replace(/^['"]|['"]$/g, ""))).filter((token) => token.length > 0);
89
- }
90
-
91
- function parseConfigHookProfile(rawYaml) {
92
- if (typeof rawYaml !== "string" || rawYaml.trim().length === 0) {
93
- return "";
94
- }
95
- const match = rawYaml.match(/^\\s*hookProfile\\s*:\\s*([A-Za-z0-9_-]+)\\s*$/m);
96
- if (!match || typeof match[1] !== "string") return "";
97
- return parseHookProfile(match[1], "");
98
- }
99
-
100
- function parseConfigDisabledHooks(rawYaml) {
101
- if (typeof rawYaml !== "string" || rawYaml.trim().length === 0) {
102
- return [];
103
- }
104
- const lines = rawYaml.split(/\\r?\\n/u);
105
- const out = [];
106
- for (let i = 0; i < lines.length; i += 1) {
107
- const line = lines[i];
108
- const inlineMatch = line.match(/^\\s*disabledHooks\\s*:\\s*(\\[[^\\]]*\\])\\s*$/u);
109
- if (inlineMatch) {
110
- for (const value of parseInlineYamlList(inlineMatch[1])) {
111
- if (!out.includes(value)) out.push(value);
112
- }
113
- continue;
114
- }
115
- const blockMatch = line.match(/^(\\s*)disabledHooks\\s*:\\s*$/u);
116
- if (!blockMatch) continue;
117
- const baseIndent = blockMatch[1] ? blockMatch[1].length : 0;
118
- for (let j = i + 1; j < lines.length; j += 1) {
119
- const nextLine = lines[j];
120
- const indent = (nextLine.match(/^(\\s*)/u)?.[1].length ?? 0);
121
- const trimmed = nextLine.trim();
122
- if (trimmed.length === 0) continue;
123
- if (indent <= baseIndent) break;
124
- const itemMatch = nextLine.match(/^\\s*-\\s*(.+?)\\s*$/u);
125
- if (!itemMatch) continue;
126
- const normalized = normalizeHookToken(itemMatch[1].replace(/^['"]|['"]$/g, ""));
127
- if (normalized.length === 0) continue;
128
- if (!out.includes(normalized)) out.push(normalized);
129
- }
130
- }
131
- return out;
132
- }
133
-
134
- async function readConfigHookPolicy(root) {
135
- const configPath = path.join(root, RUNTIME_ROOT, "config.yaml");
136
- const raw = await readTextFile(configPath, "");
137
- const profile = parseConfigHookProfile(raw);
138
- const disabledHooks = parseConfigDisabledHooks(raw);
139
- return { profile, disabledHooks };
140
- }
141
-
142
- async function resolveHookPolicy(root) {
143
- const fromConfig = await readConfigHookPolicy(root);
144
- const configProfile = parseHookProfile(fromConfig.profile, DEFAULT_HOOK_PROFILE);
145
- const configDisabledHooks = Array.isArray(fromConfig.disabledHooks) && fromConfig.disabledHooks.length > 0
146
- ? fromConfig.disabledHooks
147
- : DEFAULT_DISABLED_HOOKS;
148
-
149
- const envProfileRaw = process.env.CCLAW_HOOK_PROFILE;
150
- const envProfile = parseHookProfile(envProfileRaw, "");
151
- const profile = envProfile.length > 0 ? envProfile : configProfile;
152
-
153
- const envDisabledRaw = process.env.CCLAW_DISABLED_HOOKS;
154
- const envDisabledHooks = parseDisabledHooksCsv(envDisabledRaw);
155
- const disabledHooks = envDisabledHooks.length > 0 ? envDisabledHooks : configDisabledHooks;
156
- const disabled = new Set(disabledHooks.map((value) => normalizeHookToken(value)));
157
- return { profile, disabled };
158
- }
159
-
160
- function hookDisabledByProfile(profile, hookName) {
161
- if (profile !== "minimal") return false;
162
- return !MINIMAL_PROFILE_ALLOWED_HOOKS.has(hookName);
163
- }
164
-
165
- function isHookDisabled(policy, hookName) {
166
- if (policy.disabled.has(hookName)) return true;
167
- return hookDisabledByProfile(policy.profile, hookName);
168
- }
169
5
 
170
- function toObject(value) {
171
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
172
- return value;
173
- }
6
+ const root = process.cwd();
7
+ const statePath = path.join(root, ".cclaw", "state", "flow-state.json");
174
8
 
175
- function safeParseJson(raw, fallback = {}) {
176
- if (typeof raw !== "string" || raw.trim().length === 0) {
177
- return fallback;
178
- }
9
+ async function readState() {
179
10
  try {
180
- const parsed = JSON.parse(raw);
181
- return parsed === undefined ? fallback : parsed;
11
+ return JSON.parse(await fs.readFile(statePath, "utf8"));
182
12
  } catch {
183
- return fallback;
13
+ return null;
184
14
  }
185
15
  }
186
16
 
187
- // === atomic/locked state I/O =========================================
188
- //
189
- // The generated hook script runs OUTSIDE the cclaw CLI process, so it
190
- // cannot import \`fs-utils.ts\`. These helpers mirror \`writeFileSafe\` and
191
- // \`withDirectoryLock\` just enough to keep hook-owned state files
192
- // atomic and free of interleaved concurrent writes.
193
-
194
- function hookSleep(ms) {
195
- return new Promise((resolve) => setTimeout(resolve, ms));
196
- }
197
-
198
- async function withDirectoryLockInline(lockPath, fn, options = {}) {
199
- const retries = Number.isFinite(options.retries) ? options.retries : 200;
200
- const retryDelayMs = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 20;
201
- const staleAfterMs = Number.isFinite(options.staleAfterMs) ? options.staleAfterMs : 60000;
202
- try {
203
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
204
- } catch {
205
- // parent may already exist
206
- }
207
- let acquired = false;
208
- let lastError = null;
209
- for (let attempt = 0; attempt < retries; attempt += 1) {
210
- try {
211
- await fs.mkdir(lockPath);
212
- acquired = true;
213
- break;
214
- } catch (error) {
215
- lastError = error;
216
- const code = error && typeof error === "object" && "code" in error ? error.code : null;
217
- if (code !== "EEXIST") {
218
- throw error;
219
- }
220
- try {
221
- const stat = await fs.stat(lockPath);
222
- if (!stat.isDirectory()) {
223
- throw new Error("Lock path exists but is not a directory: " + lockPath);
224
- }
225
- if (Date.now() - stat.mtimeMs > staleAfterMs) {
226
- await fs.rm(lockPath, { recursive: true, force: true });
227
- continue;
228
- }
229
- } catch (statError) {
230
- if (
231
- statError instanceof Error &&
232
- statError.message.startsWith("Lock path exists but is not a directory")
233
- ) {
234
- throw statError;
235
- }
236
- // lock vanished between retries
237
- }
238
- await hookSleep(retryDelayMs);
239
- }
240
- }
241
- if (!acquired) {
242
- const details = lastError instanceof Error ? lastError.message : String(lastError);
243
- throw new Error(
244
- "cclaw hook: failed to acquire lock " + lockPath + " (attempts=" + retries + ", lastError=" + details + ")"
245
- );
246
- }
247
- try {
248
- return await fn();
249
- } finally {
250
- await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
251
- }
252
- }
253
-
254
- async function writeFileAtomic(filePath, content, options = {}) {
255
- await fs.mkdir(path.dirname(filePath), { recursive: true });
256
- const tempPath = path.join(
257
- path.dirname(filePath),
258
- "." + path.basename(filePath) + ".tmp-" + process.pid + "-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8)
259
- );
260
- await fs.writeFile(tempPath, content, { encoding: "utf8" });
261
- // Windows' fs.rename can fail transiently with EPERM/EBUSY/EACCES when the
262
- // destination file is held open by another process (antivirus, indexer,
263
- // or a sibling hook invocation racing on the same file). Retry with tiny
264
- // backoff before falling back to copyFile.
265
- const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
266
- let attempt = 0;
267
- const maxAttempts = 6;
268
- while (true) {
269
- try {
270
- await fs.rename(tempPath, filePath);
271
- if (options.mode !== undefined) {
272
- await fs.chmod(filePath, options.mode).catch(() => undefined);
273
- }
274
- return;
275
- } catch (error) {
276
- const code = error && typeof error === "object" && "code" in error ? error.code : null;
277
- if (code === "EXDEV") {
278
- try {
279
- await fs.copyFile(tempPath, filePath);
280
- } finally {
281
- await fs.unlink(tempPath).catch(() => undefined);
282
- }
283
- if (options.mode !== undefined) {
284
- await fs.chmod(filePath, options.mode).catch(() => undefined);
285
- }
286
- return;
287
- }
288
- if (renameRetryableCodes.has(code) && attempt < maxAttempts) {
289
- attempt += 1;
290
- await hookSleep(10 * attempt + Math.floor(Math.random() * 10));
291
- continue;
292
- }
293
- if (renameRetryableCodes.has(code)) {
294
- // Last-resort fallback: copy-then-unlink. Not atomic, but the
295
- // directory lock around this call already serializes writers.
296
- try {
297
- await fs.copyFile(tempPath, filePath);
298
- if (options.mode !== undefined) {
299
- await fs.chmod(filePath, options.mode).catch(() => undefined);
300
- }
301
- return;
302
- } finally {
303
- await fs.unlink(tempPath).catch(() => undefined);
304
- }
305
- }
306
- await fs.unlink(tempPath).catch(() => undefined);
307
- throw error;
308
- }
309
- }
17
+ const state = await readState();
18
+ if (!state) {
19
+ console.log("[cclaw] no active flow. Use /cc <task> to start.");
20
+ process.exit(0);
310
21
  }
311
22
 
312
- function lockPathFor(filePath) {
313
- return filePath + ".lock";
23
+ if (state.schemaVersion !== 2) {
24
+ console.error("[cclaw] flow-state schema is from cclaw 7.x. cclaw v8 cannot resume it.");
25
+ console.error("[cclaw] options: 1) finish/abandon the run with cclaw 7.x; 2) delete .cclaw/state/flow-state.json; 3) start a new v8 plan.");
26
+ process.exit(0);
314
27
  }
315
28
 
316
- async function recordHookError(root, stage, detail) {
317
- try {
318
- const errorsPath = path.join(root, RUNTIME_ROOT, "state", "hook-errors.jsonl");
319
- await fs.mkdir(path.dirname(errorsPath), { recursive: true });
320
- const payload = JSON.stringify({
321
- ts: new Date().toISOString(),
322
- stage: typeof stage === "string" ? stage : "unknown",
323
- detail: typeof detail === "string" ? detail : String(detail)
324
- });
325
- await fs.appendFile(errorsPath, payload + "\\n", "utf8");
326
- } catch {
327
- // diagnostics must never cascade
328
- }
29
+ if (!state.currentSlug) {
30
+ console.log("[cclaw] no active slug. Use /cc <task> to start.");
31
+ process.exit(0);
329
32
  }
330
33
 
331
- async function readJsonFile(filePath, fallback = {}, options = {}) {
332
- try {
333
- const raw = await fs.readFile(filePath, "utf8");
334
- if (typeof raw !== "string" || raw.trim().length === 0) {
335
- return fallback;
336
- }
337
- try {
338
- const parsed = JSON.parse(raw);
339
- return parsed === undefined ? fallback : parsed;
340
- } catch (parseErr) {
341
- // Emit a diagnostic breadcrumb instead of silently returning fallback.
342
- // The hook must still continue (soft-fail), but the corruption is
343
- // now visible in \`state/hook-errors.jsonl\` and to \`npx cclaw-cli sync\`.
344
- if (options.root) {
345
- await recordHookError(
346
- options.root,
347
- options.stage || "read-json",
348
- "corrupt-json file=" + filePath + " error=" + (parseErr instanceof Error ? parseErr.message : String(parseErr))
349
- );
350
- }
351
- return fallback;
352
- }
353
- } catch {
354
- return fallback;
355
- }
356
- }
34
+ const pending = (state.ac || []).filter((item) => item.status !== "committed").length;
35
+ const total = (state.ac || []).length;
36
+ console.log(\`[cclaw] active: \${state.currentSlug} (stage=\${state.currentStage ?? "n/a"}); AC committed \${total - pending}/\${total}\`);
37
+ `;
38
+ const STOP_HANDOFF_HOOK = `#!/usr/bin/env node
39
+ // cclaw stop-handoff: short reminder when the agent stops mid-flow.
40
+ import fs from "node:fs/promises";
41
+ import path from "node:path";
357
42
 
358
- async function writeJsonFile(filePath, value) {
359
- const next = JSON.stringify(value, null, 2) + "\\n";
360
- await withDirectoryLockInline(lockPathFor(filePath), async () => {
361
- await writeFileAtomic(filePath, next);
362
- });
363
- }
43
+ const root = process.cwd();
44
+ const statePath = path.join(root, ".cclaw", "state", "flow-state.json");
364
45
 
365
- async function readTextFile(filePath, fallback = "") {
46
+ async function readState() {
366
47
  try {
367
- return await fs.readFile(filePath, "utf8");
48
+ return JSON.parse(await fs.readFile(statePath, "utf8"));
368
49
  } catch {
369
- return fallback;
370
- }
371
- }
372
-
373
- // CLI-compatible knowledge lock. Must match
374
- // src/knowledge-store.ts::knowledgeLockPath exactly so the hook and the
375
- // CLI serialize on the same mutex when reading / appending
376
- // knowledge.jsonl. Drift here re-introduces the race we just closed.
377
- function knowledgeLockPathInline(root) {
378
- return path.join(root, RUNTIME_ROOT, "state", ".knowledge.lock");
379
- }
380
-
381
- async function readTextFileLocked(lockPath, filePath, fallback = "") {
382
- return withDirectoryLockInline(lockPath, async () => {
383
- try {
384
- return await fs.readFile(filePath, "utf8");
385
- } catch {
386
- return fallback;
387
- }
388
- });
389
- }
390
-
391
- async function readStdin() {
392
- return await new Promise((resolve) => {
393
- let data = "";
394
- process.stdin.setEncoding("utf8");
395
- process.stdin.on("data", (chunk) => {
396
- data += String(chunk);
397
- });
398
- process.stdin.on("end", () => resolve(data));
399
- process.stdin.on("error", () => resolve(""));
400
- });
401
- }
402
-
403
- function detectHarness(env) {
404
- if (env.CLAUDE_PROJECT_DIR) return "claude";
405
- if (env.CURSOR_PROJECT_DIR || env.CURSOR_PROJECT_ROOT) return "cursor";
406
- if (env.OPENCODE_PROJECT_DIR || env.OPENCODE_PROJECT_ROOT) return "opencode";
407
- return "codex";
408
- }
409
-
410
- async function detectRoot(env) {
411
- const candidates = [
412
- env.CCLAW_PROJECT_ROOT,
413
- env.CLAUDE_PROJECT_DIR,
414
- env.CURSOR_PROJECT_DIR,
415
- env.CURSOR_PROJECT_ROOT,
416
- env.OPENCODE_PROJECT_DIR,
417
- env.OPENCODE_PROJECT_ROOT,
418
- process.cwd()
419
- ].filter((value) => typeof value === "string" && value.length > 0);
420
- for (const candidate of candidates) {
421
- try {
422
- const runtimePath = path.join(candidate, RUNTIME_ROOT);
423
- const stat = await fs.stat(runtimePath);
424
- if (stat.isDirectory()) return { root: candidate, foundRuntime: true };
425
- } catch {
426
- // continue
427
- }
50
+ return null;
428
51
  }
429
- return { root: candidates[0] || process.cwd(), foundRuntime: false };
430
52
  }
431
53
 
432
- function normalizeText(value) {
433
- return String(value || "").replace(/\\s+/gu, " ").trim();
434
- }
435
-
436
- async function verifyFlowStateGuardInline(root, hookName) {
437
- const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
438
- const guardPath = path.join(root, FLOW_STATE_GUARD_REL_PATH);
439
- let raw;
440
- try {
441
- raw = await fs.readFile(statePath, "utf8");
442
- } catch {
443
- return true;
444
- }
445
- let guard;
446
- try {
447
- const guardRaw = await fs.readFile(guardPath, "utf8");
448
- guard = JSON.parse(guardRaw);
449
- } catch {
450
- return true;
451
- }
452
- if (!guard || typeof guard !== "object" || typeof guard.sha256 !== "string") {
453
- return true;
454
- }
455
- const actual = createHash("sha256").update(raw, "utf8").digest("hex");
456
- if (actual === guard.sha256) return true;
457
- const hookLabel = typeof hookName === "string" && hookName.length > 0 ? hookName : "hook";
458
- process.stderr.write(
459
- "[cclaw] " + hookLabel + ": flow-state guard mismatch: " + (guard.runId || "unknown-run") + "\\n" +
460
- "expected sha: " + guard.sha256 + "\\n" +
461
- "actual sha: " + actual + "\\n" +
462
- "last writer: " + (guard.writerSubsystem || "unknown") + "@" + (guard.writtenAt || "unknown") + "\\n" +
463
- "do not edit flow-state.json by hand. To recover, run:\\n" +
464
- " cclaw-cli internal flow-state-repair --reason \\"manual_edit_recovery\\"\\n"
465
- );
466
- await recordHookError(root, hookLabel, "flow-state guard mismatch actual=" + actual + " expected=" + guard.sha256).catch(() => undefined);
467
- return false;
468
- }
54
+ const state = await readState();
55
+ if (!state || !state.currentSlug) process.exit(0);
56
+ const pending = (state.ac || []).filter((item) => item.status !== "committed");
57
+ if (pending.length === 0) process.exit(0);
58
+ console.error(\`[cclaw] stopping with \${pending.length} pending AC for \${state.currentSlug}: \${pending.map((item) => item.id).join(", ")}\`);
59
+ `;
60
+ const COMMIT_HELPER_HOOK = `#!/usr/bin/env node
61
+ // cclaw commit-helper: TDD-aware atomic commit per AC phase
62
+ // (RED -> GREEN -> REFACTOR) + AC traceability gate.
63
+ import { execFileSync } from "node:child_process";
64
+ import fs from "node:fs/promises";
65
+ import path from "node:path";
469
66
 
470
- async function readFlowState(root) {
471
- const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
472
- // Loud-on-corrupt: if flow-state.json exists but fails JSON.parse, log
473
- // a breadcrumb into state/hook-errors.jsonl before falling back to an
474
- // empty object. Silent fallbacks used to mask stale CLI+hook drift.
475
- const parsed = await readJsonFile(statePath, {}, { root, stage: "read-flow-state" });
476
- const obj = toObject(parsed) || {};
477
- const summary = summarizeFlowState(obj);
478
- return {
479
- filePath: statePath,
480
- currentStage: summary.stage,
481
- activeRunId: summary.activeRunId === "none" ? "active" : summary.activeRunId,
482
- completedCount: summary.completed,
483
- raw: obj
484
- };
485
- }
67
+ const root = process.cwd();
68
+ const statePath = path.join(root, ".cclaw", "state", "flow-state.json");
486
69
 
487
- async function buildKnowledgeDigest(root, currentStage, prereadRaw) {
488
- const knowledgeFile = path.join(root, RUNTIME_ROOT, "knowledge.jsonl");
489
- // Caller may supply pre-read raw bytes to avoid re-reading knowledge.jsonl.
490
- // Falls back to a local read if nothing is passed in.
491
- const raw = typeof prereadRaw === "string"
492
- ? prereadRaw
493
- : await readTextFile(knowledgeFile, "");
494
- const digest = parseKnowledgeDigest(raw, currentStage, 6);
495
- return {
496
- digestLines: digest.lines,
497
- learningsCount: digest.learningsCount
498
- };
70
+ function arg(name) {
71
+ const prefix = \`--\${name}=\`;
72
+ const found = process.argv.find((value) => value.startsWith(prefix));
73
+ return found ? found.slice(prefix.length) : null;
499
74
  }
500
75
 
501
- async function readStageSupportContext(root, currentStage) {
502
- if (!isKnownStageId(currentStage)) return [];
503
- const stage = currentStage;
504
-
505
- const parts = [];
506
- const contractPath = path.join(root, RUNTIME_ROOT, "templates", "state-contracts", stage + ".json");
507
- const contract = (await readTextFile(contractPath, "")).trim();
508
- if (contract.length > 0) {
509
- parts.push(
510
- "Current stage state contract (read before drafting or editing the stage artifact):\\n" +
511
- contract
512
- );
513
- }
514
-
515
- const promptName = reviewPromptFileName(stage);
516
- if (typeof promptName === "string") {
517
- const promptPath = path.join(root, RUNTIME_ROOT, "skills", "review-prompts", promptName);
518
- const prompt = (await readTextFile(promptPath, "")).trim();
519
- if (prompt.length > 0) {
520
- parts.push(
521
- "Current stage calibrated review prompt (use before asking for approval/completion):\\n" +
522
- prompt
523
- );
524
- }
525
- }
526
-
527
- return parts;
76
+ function flag(name) {
77
+ return process.argv.includes(\`--\${name}\`);
528
78
  }
529
79
 
530
- async function handleSessionStart(runtime) {
531
- const state = await readFlowState(runtime.root);
532
- const metaSkillFile = path.join(runtime.root, RUNTIME_ROOT, "skills", "using-cclaw", "SKILL.md");
533
-
534
-
535
- // Read knowledge.jsonl exactly once per session-start while holding the
536
- // SAME lock CLI writers acquire in \`appendKnowledge\`. Guarantees we never
537
- // see a partial (mid-write) snapshot. Both the digest and
538
- // compound-readiness derive from this single read.
539
- const knowledgeFilePath = path.join(runtime.root, RUNTIME_ROOT, "knowledge.jsonl");
540
- const knowledgeRaw = await readTextFileLocked(
541
- knowledgeLockPathInline(runtime.root),
542
- knowledgeFilePath,
543
- ""
544
- );
545
- const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage, knowledgeRaw);
80
+ const acId = arg("ac");
81
+ const phase = arg("phase");
82
+ const message = arg("message") ?? \`cclaw: progress on \${acId ?? "AC"}\`;
83
+ const skipped = flag("skipped");
546
84
 
547
- // honest-core: session-start no longer runs background helper
548
- // pipelines or digest caches. It rehydrates flow + knowledge only.
549
- const ralphLoopLine = "";
550
- const earlyLoopLine = "";
551
- const compoundReadinessLine = "";
552
- const staleStages = toObject(state.raw.staleStages) || {};
553
- const staleStageNames = Object.keys(staleStages);
554
- const interactionHints = toObject(state.raw.interactionHints) || {};
555
- const stageInteractionHint = toObject(interactionHints[state.currentStage]);
556
- const skipQuestionsHintActive = stageInteractionHint?.skipQuestions === true;
557
- const skipQuestionsSource = typeof stageInteractionHint?.sourceStage === "string"
558
- ? stageInteractionHint.sourceStage
559
- : "";
560
- const skipQuestionsRecordedAt = typeof stageInteractionHint?.recordedAt === "string"
561
- ? stageInteractionHint.recordedAt
562
- : "";
563
- const metaContent = (await readTextFile(metaSkillFile, "")).trim();
564
- const ironLawsSkillFile = path.join(runtime.root, RUNTIME_ROOT, "skills", "iron-laws", "SKILL.md");
565
- const ironLawsContent = (await readTextFile(ironLawsSkillFile, "")).trim();
566
- const stageSupportContext = await readStageSupportContext(runtime.root, state.currentStage);
567
-
568
- const parts = [
569
- "cclaw loaded. Flow: stage=" +
570
- state.currentStage +
571
- " (" +
572
- String(state.completedCount) +
573
- "/8 completed, run=" +
574
- state.activeRunId +
575
- "). Active artifacts: " +
576
- activeArtifactsPathLabel(RUNTIME_ROOT) +
577
- " Learnings: " +
578
- String(knowledge.learningsCount) +
579
- " entries."
580
- ];
581
- if (ralphLoopLine.length > 0) {
582
- parts.push(ralphLoopLine);
583
- }
584
- if (earlyLoopLine.length > 0) {
585
- parts.push(earlyLoopLine);
586
- }
587
- if (compoundReadinessLine.length > 0) {
588
- parts.push(compoundReadinessLine);
589
- }
590
- if (staleStageNames.length > 0) {
591
- parts.push(
592
- "Stale stages pending acknowledgement: " +
593
- staleStageNames.join(", ") +
594
- " (use npx cclaw-cli internal rewind --ack <stage> after redo)."
595
- );
596
- }
597
- if (skipQuestionsHintActive) {
598
- parts.push(
599
- "Adaptive elicitation hint: this stage inherits a prior user stop signal (--skip-questions" +
600
- (skipQuestionsSource ? " from " + skipQuestionsSource : "") +
601
- (skipQuestionsRecordedAt ? " at " + skipQuestionsRecordedAt : "") +
602
- "). Draft with available context unless irreversible/security override checks still require explicit confirmation."
603
- );
604
- }
605
- if (knowledge.digestLines.length > 0) {
606
- parts.push(
607
- "Knowledge digest (top relevant entries):\\n" +
608
- knowledge.digestLines.join("\\n")
609
- );
610
- }
611
- if (stageSupportContext.length > 0) {
612
- parts.push(...stageSupportContext);
613
- }
614
- if (metaContent.length > 0) {
615
- parts.push(metaContent);
616
- }
617
- // load iron-laws content into the session-start digest so the
618
- // non-negotiable workflow constraints are visible from the first turn,
619
- // not lazily on tool dispatch.
620
- if (ironLawsContent.length > 0) {
621
- parts.push(ironLawsContent);
622
- }
623
-
624
- const context = parts.join("\\n");
625
- if (runtime.harness === "claude" || runtime.harness === "codex") {
626
- runtime.writeJson({
627
- hookSpecificOutput: {
628
- hookEventName: "SessionStart",
629
- additionalContext: context
630
- }
631
- });
632
- return 0;
633
- }
634
- runtime.writeJson({ additional_context: context });
635
- return 0;
85
+ if (!acId || !/^AC-\\d+$/.test(acId)) {
86
+ console.error("[commit-helper] usage: commit-helper.mjs --ac=AC-N --phase=red|green|refactor [--skipped] [--message='...']");
87
+ process.exit(2);
636
88
  }
637
89
 
638
- async function isGitDirty(root) {
639
- return await new Promise((resolve) => {
640
- const child = spawn("git", ["-C", root, "status", "--porcelain"], {
641
- stdio: ["ignore", "pipe", "ignore"]
642
- });
643
- let output = "";
644
- child.stdout.on("data", (chunk) => {
645
- output += String(chunk);
646
- });
647
- child.on("error", () => resolve("unknown"));
648
- child.on("close", (code) => {
649
- if (code !== 0) {
650
- resolve("unknown");
651
- } else {
652
- resolve(output.trim().length > 0 ? "dirty" : "clean");
653
- }
654
- });
655
- });
90
+ if (!phase || !["red", "green", "refactor"].includes(phase)) {
91
+ console.error("[commit-helper] --phase is required. Allowed: red, green, refactor.");
92
+ console.error("[commit-helper] build is a TDD cycle: every AC needs RED -> GREEN -> REFACTOR.");
93
+ process.exit(2);
656
94
  }
657
95
 
658
- const STOP_BLOCK_LIMIT_PER_TRANSCRIPT = 2;
659
-
660
- function asBoolean(value) {
661
- if (value === true || value === false) return value;
662
- if (typeof value === "number") return Number.isFinite(value) && value !== 0;
663
- if (typeof value !== "string") return false;
664
- const normalized = value.trim().toLowerCase();
665
- if (normalized.length === 0) return false;
666
- return ["1", "true", "yes", "on"].includes(normalized);
96
+ if (skipped && phase !== "refactor") {
97
+ console.error("[commit-helper] --skipped is only valid for --phase=refactor.");
98
+ process.exit(2);
667
99
  }
668
100
 
669
- function stringTokenHit(value, tokens) {
670
- const normalized = normalizeText(value).toLowerCase();
671
- if (normalized.length === 0) return false;
672
- return tokens.some((token) => normalized.includes(token));
101
+ let state;
102
+ try {
103
+ state = JSON.parse(await fs.readFile(statePath, "utf8"));
104
+ } catch {
105
+ console.error("[commit-helper] no flow-state.json. Start a flow with /cc first.");
106
+ process.exit(2);
673
107
  }
674
108
 
675
- function sanitizeStopSessionKey(raw) {
676
- const normalized = normalizeText(raw)
677
- .toLowerCase()
678
- .replace(/[^a-z0-9._-]+/gu, "-")
679
- .replace(/^-+|-+$/gu, "");
680
- return normalized.length > 0 ? normalized.slice(0, 96) : "global";
109
+ if (state.schemaVersion !== 2) {
110
+ console.error("[commit-helper] flow-state schema is not v8.");
111
+ process.exit(2);
681
112
  }
682
113
 
683
- function extractStopSignals(input, fallbackSessionKey) {
684
- const event = toObject(input.event) || {};
685
- const session = toObject(input.session) || {};
686
- const contextLimit =
687
- asBoolean(input.context_limit) ||
688
- asBoolean(input.contextLimit) ||
689
- asBoolean(event.context_limit) ||
690
- asBoolean(event.contextLimit) ||
691
- stringTokenHit(input.reason, ["context_limit", "context limit"]) ||
692
- stringTokenHit(event.reason, ["context_limit", "context limit"]) ||
693
- stringTokenHit(input.stop_reason, ["context_limit", "context limit"]) ||
694
- stringTokenHit(event.stop_reason, ["context_limit", "context limit"]);
695
- const userAbort =
696
- asBoolean(input.user_abort) ||
697
- asBoolean(input.userAbort) ||
698
- asBoolean(input.user_cancelled) ||
699
- asBoolean(input.userCancelled) ||
700
- asBoolean(event.user_abort) ||
701
- asBoolean(event.userAbort) ||
702
- stringTokenHit(input.reason, ["user_abort", "user abort", "cancelled by user", "stop button", "ctrl+c"]) ||
703
- stringTokenHit(event.reason, ["user_abort", "user abort", "cancelled by user", "stop button", "ctrl+c"]) ||
704
- stringTokenHit(input.stop_reason, ["user_abort", "user abort", "cancelled by user", "stop button", "ctrl+c"]) ||
705
- stringTokenHit(event.stop_reason, ["user_abort", "user abort", "cancelled by user", "stop button", "ctrl+c"]);
706
- const stopHookActive =
707
- asBoolean(input.stop_hook_active) ||
708
- asBoolean(input.stopHookActive) ||
709
- asBoolean(event.stop_hook_active) ||
710
- asBoolean(event.stopHookActive);
711
-
712
- const sessionKeyCandidate =
713
- (typeof input.transcript_id === "string" && input.transcript_id) ||
714
- (typeof input.transcriptId === "string" && input.transcriptId) ||
715
- (typeof input.session_id === "string" && input.session_id) ||
716
- (typeof input.sessionId === "string" && input.sessionId) ||
717
- (typeof session.id === "string" && session.id) ||
718
- fallbackSessionKey;
719
- const sessionKey = sanitizeStopSessionKey(sessionKeyCandidate);
720
-
721
- return {
722
- contextLimit,
723
- userAbort,
724
- stopHookActive,
725
- sessionKey
726
- };
114
+ const matching = (state.ac ?? []).find((item) => item.id === acId);
115
+ if (!matching) {
116
+ console.error(\`[commit-helper] AC \${acId} is not declared in plan.md / flow-state.\`);
117
+ process.exit(2);
727
118
  }
728
119
 
729
- async function handleStopHandoff(runtime) {
730
- const state = await readFlowState(runtime.root);
731
- const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
732
- const input = toObject(runtime.inputData) || {};
733
- const loopCount =
734
- typeof input.loop_count === "number" && Number.isFinite(input.loop_count)
735
- ? Math.trunc(input.loop_count)
736
- : 0;
737
-
738
- const dirtyState = await isGitDirty(runtime.root);
739
- const stopSignals = extractStopSignals(input, "run-" + state.activeRunId);
740
- const safetyBypassActive = stopSignals.stopHookActive || stopSignals.userAbort || stopSignals.contextLimit;
741
- if (dirtyState === "dirty" && !safetyBypassActive) {
742
- const stopBlocksPath = path.join(stateDir, "stop-blocks-" + stopSignals.sessionKey + ".json");
743
- const prior = toObject(await readJsonFile(stopBlocksPath, {})) || {};
744
- const priorCount =
745
- typeof prior.blockCount === "number" && Number.isFinite(prior.blockCount)
746
- ? Math.max(0, Math.trunc(prior.blockCount))
747
- : 0;
748
- if (priorCount < STOP_BLOCK_LIMIT_PER_TRANSCRIPT) {
749
- const nextCount = priorCount + 1;
750
- await writeJsonFile(stopBlocksPath, {
751
- schemaVersion: 1,
752
- sessionKey: stopSignals.sessionKey,
753
- blockCount: nextCount,
754
- updatedAt: new Date().toISOString()
755
- });
756
- process.stderr.write(
757
- '[cclaw] Stop blocked by iron law "stop-clean-or-handoff": working tree is dirty. Commit/revert changes or record blockers in the current artifact before ending the session.\\n'
758
- );
759
- return 1;
760
- }
761
- process.stderr.write(
762
- '[cclaw] Stop advisory: dirty working tree detected, but block limit reached for this transcript (max 2). Continuing with handoff reminder only.\\n'
763
- );
764
- } else if (dirtyState === "dirty" && safetyBypassActive) {
765
- const reason = stopSignals.stopHookActive
766
- ? "stop_hook_active"
767
- : stopSignals.userAbort
768
- ? "user_abort"
769
- : "context_limit";
770
- process.stderr.write(
771
- "[cclaw] Stop advisory: bypassing strict stop block due to safety rule (" + reason + ").\\n"
772
- );
773
- }
774
-
775
- const closeoutObj = toObject(state.raw.closeout) || {};
776
- const shipSubstate = typeof closeoutObj.shipSubstate === "string" ? closeoutObj.shipSubstate : "idle";
777
- const closeoutContext =
778
- state.currentStage === "ship" || shipSubstate !== "idle"
779
- ? " closeout.shipSubstate=" + shipSubstate + "; closeout chain=post_ship_review -> archive; continue closeout with /cc."
780
- : "";
120
+ const profile = state.buildProfile ?? "default";
121
+ const phases = matching.phases ?? {};
781
122
 
782
- const message =
783
- "Cclaw: session ending (stage=" +
784
- state.currentStage +
785
- ", run=" +
786
- state.activeRunId +
787
- ")." +
788
- closeoutContext +
789
- " Active artifacts stay in " +
790
- RUNTIME_ROOT +
791
- "/artifacts until archive. Before stopping: (1) confirm flow-state reflects reality, (2) ensure artifact changes match current intent, (3) if you discovered a non-obvious rule/pattern during stage work, add it to the current artifact ## Learnings section so stage-complete can harvest it, (4) commit or revert pending changes.";
792
-
793
- if (runtime.harness === "cursor") {
794
- if (loopCount === 0) {
795
- runtime.writeJson({ followup_message: message });
796
- } else {
797
- runtime.writeJson({});
798
- }
799
- return 0;
800
- }
801
-
802
- runtime.writeJson({ systemMessage: message });
803
- return 0;
123
+ if (phase === "green" && !phases.red && profile !== "bootstrap") {
124
+ console.error(\`[commit-helper] cannot record GREEN for \${acId}: no RED commit on record.\`);
125
+ console.error("[commit-helper] write a failing test first and commit it with --phase=red.");
126
+ console.error("[commit-helper] (override: set buildProfile to 'bootstrap' in flow-state for test-framework bootstrap slugs only.)");
127
+ process.exit(2);
804
128
  }
805
-
806
- function normalizeHookName(rawName) {
807
- const value = normalizeText(rawName).toLowerCase();
808
- if (value === "session-start") return "session-start";
809
- if (value === "stop-handoff" || value === "stop") return "stop-handoff";
810
- if (value === "stop-checkpoint") return "stop-handoff";
811
- if (value === "session-rehydrate") return "session-start";
812
- return "";
129
+ if (phase === "refactor" && (!phases.red || !phases.green)) {
130
+ console.error(\`[commit-helper] cannot record REFACTOR for \${acId}: missing \${!phases.red ? "RED" : "GREEN"} commit.\`);
131
+ process.exit(2);
813
132
  }
814
133
 
815
- async function main() {
816
- const hookName = normalizeHookName(process.argv[2] || "");
817
- if (!hookName) {
818
- process.stderr.write(
819
- "[cclaw] run-hook: usage: node " +
820
- RUNTIME_ROOT +
821
- "/hooks/run-hook.mjs <session-start|stop-handoff>\\n"
822
- );
823
- process.exitCode = 1;
824
- return;
134
+ if (phase === "refactor" && skipped) {
135
+ if (!arg("message") || !arg("message").includes("skipped:")) {
136
+ console.error("[commit-helper] --phase=refactor --skipped requires --message=\\"refactor(AC-N) skipped: <reason>\\".");
137
+ process.exit(2);
825
138
  }
826
-
827
- const harness = detectHarness(process.env);
828
- const { root, foundRuntime } = await detectRoot(process.env);
829
- if (!foundRuntime) {
830
- // No .cclaw/ runtime in any candidate root this directory is not
831
- // initialized for cclaw. Exit 0 silently so hooks never block harnesses
832
- // that run in unrelated repos; users initialize with \`cclaw init\`.
833
- process.exitCode = 0;
834
- return;
835
- }
836
- const inputRaw = await readStdin();
837
- const inputData = safeParseJson(inputRaw, {});
838
- const runtime = {
839
- harness,
840
- root,
841
- inputRaw,
842
- inputData,
843
- writeJson(value) {
844
- process.stdout.write(JSON.stringify(value) + "\\n");
845
- }
139
+ const updated = {
140
+ ...state,
141
+ ac: state.ac.map((item) => {
142
+ if (item.id !== acId) return item;
143
+ const nextPhases = { ...(item.phases ?? {}), refactor: { skipped: true, reason: arg("message") } };
144
+ const allDone = nextPhases.red && nextPhases.green && nextPhases.refactor;
145
+ return {
146
+ ...item,
147
+ phases: nextPhases,
148
+ commit: allDone ? (nextPhases.green.sha ?? item.commit ?? null) : item.commit ?? null,
149
+ status: allDone ? "committed" : "pending"
150
+ };
151
+ })
846
152
  };
847
-
848
- try {
849
- const policy = await resolveHookPolicy(runtime.root);
850
- if (isHookDisabled(policy, hookName)) {
851
- // Honor CCLAW_HOOK_PROFILE / CCLAW_DISABLED_HOOKS / config disabledHooks.
852
- // Disabled hooks must exit 0 quietly so harnesses keep running.
853
- process.exitCode = 0;
854
- return;
855
- }
856
- if (hookName === "session-start" || hookName === "stop-handoff") {
857
- const guardOk = await verifyFlowStateGuardInline(runtime.root, hookName);
858
- if (!guardOk) {
859
- process.exitCode = 2;
860
- return;
861
- }
862
- }
863
- if (hookName === "session-start") {
864
- process.exitCode = await handleSessionStart(runtime);
865
- return;
866
- }
867
- if (hookName === "stop-handoff") {
868
- process.exitCode = await handleStopHandoff(runtime);
869
- return;
870
- }
871
- process.stderr.write("[cclaw] run-hook: unsupported hook " + hookName + "\\n");
872
- process.exitCode = 1;
873
- } catch (error) {
874
- process.stderr.write(
875
- "[cclaw] run-hook: " +
876
- hookName +
877
- " failed: " +
878
- (error instanceof Error ? error.message : String(error)) +
879
- "\\n"
880
- );
881
- process.exitCode = 1;
882
- }
153
+ await fs.writeFile(statePath, \`\${JSON.stringify(updated, null, 2)}\\n\`, "utf8");
154
+ console.log(\`[commit-helper] \${acId} phase=refactor skipped (recorded).\`);
155
+ if (updated.ac.find((item) => item.id === acId)?.status === "committed") {
156
+ console.log(\`[commit-helper] \${acId} cycle complete (red, green, refactor=skipped).\`);
157
+ }
158
+ process.exit(0);
159
+ }
160
+
161
+ let staged;
162
+ try {
163
+ staged = execFileSync("git", ["diff", "--cached", "--name-only"], { cwd: root, encoding: "utf8" }).trim();
164
+ } catch (error) {
165
+ console.error(\`[commit-helper] git not available: \${error.message}\`);
166
+ process.exit(2);
167
+ }
168
+ if (!staged) {
169
+ console.error("[commit-helper] nothing staged. Stage AC-related changes before invoking commit-helper.");
170
+ process.exit(2);
171
+ }
172
+
173
+ if (phase === "red") {
174
+ const stagedFiles = staged.split("\\n").filter(Boolean);
175
+ const looksLikeProduction = stagedFiles.find((file) => /^src\\//.test(file) || /^lib\\//.test(file) || /^app\\//.test(file));
176
+ if (looksLikeProduction) {
177
+ console.error(\`[commit-helper] RED phase rejects production files: \${looksLikeProduction}\`);
178
+ console.error("[commit-helper] RED commits must contain test files only. Write the failing test first; commit production code under --phase=green.");
179
+ process.exit(2);
180
+ }
181
+ }
182
+
183
+ const commitMessage = \`\${message}\\n\\nrefs: \${acId} (phase=\${phase})\`;
184
+ execFileSync("git", ["commit", "-m", commitMessage], { cwd: root, stdio: "inherit" });
185
+
186
+ const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
187
+ const updated = {
188
+ ...state,
189
+ ac: state.ac.map((item) => {
190
+ if (item.id !== acId) return item;
191
+ const nextPhases = { ...(item.phases ?? {}), [phase]: { sha } };
192
+ const cycleDone = nextPhases.red && nextPhases.green && nextPhases.refactor;
193
+ return {
194
+ ...item,
195
+ phases: nextPhases,
196
+ commit: cycleDone ? (nextPhases.green.sha ?? sha) : item.commit ?? null,
197
+ status: cycleDone ? "committed" : "pending"
198
+ };
199
+ })
200
+ };
201
+ await fs.writeFile(statePath, \`\${JSON.stringify(updated, null, 2)}\\n\`, "utf8");
202
+ console.log(\`[commit-helper] \${acId} phase=\${phase} committed as \${sha}\`);
203
+ const after = updated.ac.find((item) => item.id === acId);
204
+ if (after && after.status === "committed") {
205
+ console.log(\`[commit-helper] \${acId} cycle complete (red, green, refactor).\`);
883
206
  }
884
-
885
- void main();
886
207
  `;
887
- }
208
+ export const SESSION_START_HOOK_SPEC = {
209
+ id: "session-start",
210
+ fileName: "session-start.mjs",
211
+ description: "Rehydrate flow state when a new session begins.",
212
+ events: ["session.start"],
213
+ body: SESSION_START_HOOK,
214
+ defaultEnabled: true
215
+ };
216
+ export const STOP_HANDOFF_HOOK_SPEC = {
217
+ id: "stop-handoff",
218
+ fileName: "stop-handoff.mjs",
219
+ description: "Surface a short handoff message when the agent stops mid-flow.",
220
+ events: ["session.stop"],
221
+ body: STOP_HANDOFF_HOOK,
222
+ defaultEnabled: true
223
+ };
224
+ export const COMMIT_HELPER_HOOK_SPEC = {
225
+ id: "commit-helper",
226
+ fileName: "commit-helper.mjs",
227
+ description: "Atomic commit per AC plus traceability check (AC -> commit SHA).",
228
+ events: [],
229
+ body: COMMIT_HELPER_HOOK,
230
+ defaultEnabled: true
231
+ };
232
+ export const NODE_HOOKS = [
233
+ SESSION_START_HOOK_SPEC,
234
+ STOP_HANDOFF_HOOK_SPEC,
235
+ COMMIT_HELPER_HOOK_SPEC
236
+ ];
237
+ export const DEFAULT_HOOK_PROFILE = "minimal";