cclaw-cli 7.7.1 → 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 -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/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 -44
  193. package/dist/execution-topology.js +0 -95
  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 -74
  258. package/dist/internal/wave-status.js +0 -506
  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,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";