cclaw-cli 2.0.0 → 3.0.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.
@@ -8,9 +8,6 @@ export { HOOK_SEMANTIC_EVENTS } from "./hook-manifest.js";
8
8
  */
9
9
  const OPENCODE_SEMANTIC_COVERAGE = {
10
10
  session_rehydrate: "plugin event handlers + transform rehydration",
11
- pre_tool_prompt_guard: "plugin tool.execute.before -> prompt-guard",
12
- pre_tool_workflow_guard: "plugin tool.execute.before -> workflow-guard",
13
- post_tool_context_monitor: "plugin tool.execute.after -> context-monitor",
14
11
  stop_handoff: "plugin session.idle -> stop-handoff"
15
12
  };
16
13
  /**
@@ -21,7 +21,7 @@
21
21
  */
22
22
  export declare const HOOK_MANIFEST_HARNESSES: readonly ["claude", "cursor", "codex"];
23
23
  export type HookManifestHarness = (typeof HOOK_MANIFEST_HARNESSES)[number];
24
- export declare const HOOK_HANDLERS: readonly ["session-start", "prompt-guard", "workflow-guard", "pre-tool-pipeline", "prompt-pipeline", "context-monitor", "stop-handoff", "verify-current-state"];
24
+ export declare const HOOK_HANDLERS: readonly ["session-start", "stop-handoff"];
25
25
  export type HookHandlerId = (typeof HOOK_HANDLERS)[number];
26
26
  export interface HookBinding {
27
27
  /**
@@ -31,6 +31,8 @@ export interface HookBinding {
31
31
  event: string;
32
32
  matcher?: string;
33
33
  timeout?: number;
34
+ /** Optional harness UI status line while this hook runs. */
35
+ statusMessage?: string;
34
36
  /**
35
37
  * Within a single (harness, event) group, entries are sorted by
36
38
  * `priority` ASC, ties broken by manifest-declaration order. Use
@@ -49,7 +51,7 @@ export interface HookHandlerSpec {
49
51
  semantic: HookSemanticEvent | null;
50
52
  bindings: Partial<Record<HookManifestHarness, HookBinding[]>>;
51
53
  }
52
- export declare const HOOK_SEMANTIC_EVENTS: readonly ["session_rehydrate", "pre_tool_prompt_guard", "pre_tool_workflow_guard", "post_tool_context_monitor", "stop_handoff", "strict_state_verify"];
54
+ export declare const HOOK_SEMANTIC_EVENTS: readonly ["session_rehydrate", "stop_handoff"];
53
55
  export type HookSemanticEvent = (typeof HOOK_SEMANTIC_EVENTS)[number];
54
56
  export declare const HOOK_MANIFEST: readonly HookHandlerSpec[];
55
57
  export interface EventGroup {
@@ -62,6 +64,7 @@ export interface EventGroup {
62
64
  handler: HookHandlerId;
63
65
  matcher?: string;
64
66
  timeout?: number;
67
+ statusMessage?: string;
65
68
  }>;
66
69
  }
67
70
  /**
@@ -22,26 +22,16 @@
22
22
  export const HOOK_MANIFEST_HARNESSES = ["claude", "cursor", "codex"];
23
23
  export const HOOK_HANDLERS = [
24
24
  "session-start",
25
- "prompt-guard",
26
- "workflow-guard",
27
- "pre-tool-pipeline",
28
- "prompt-pipeline",
29
- "context-monitor",
30
- "stop-handoff",
31
- "verify-current-state"
25
+ "stop-handoff"
32
26
  ];
33
27
  export const HOOK_SEMANTIC_EVENTS = [
34
28
  "session_rehydrate",
35
- "pre_tool_prompt_guard",
36
- "pre_tool_workflow_guard",
37
- "post_tool_context_monitor",
38
- "stop_handoff",
39
- "strict_state_verify"
29
+ "stop_handoff"
40
30
  ];
41
31
  export const HOOK_MANIFEST = [
42
32
  {
43
33
  handler: "session-start",
44
- description: "Rehydrate flow state, refresh Ralph Loop + compound readiness, emit bootstrap digest.",
34
+ description: "Rehydrate flow state and emit bootstrap digest.",
45
35
  semantic: "session_rehydrate",
46
36
  bindings: {
47
37
  claude: [{ event: "SessionStart", matcher: "startup|resume|clear|compact" }],
@@ -51,50 +41,13 @@ export const HOOK_MANIFEST = [
51
41
  { event: "sessionClear" },
52
42
  { event: "sessionCompact" }
53
43
  ],
54
- codex: [{ event: "SessionStart", matcher: "startup|resume" }]
55
- }
56
- },
57
- {
58
- handler: "prompt-guard",
59
- description: "Stage-aware prompt gate (iron-laws + strictness).",
60
- semantic: "pre_tool_prompt_guard",
61
- bindings: {
62
- claude: [{ event: "PreToolUse", matcher: "*" }]
63
- }
64
- },
65
- {
66
- handler: "workflow-guard",
67
- description: "TDD and workflow gate on Write/Edit/Bash style tool invocations.",
68
- semantic: "pre_tool_workflow_guard",
69
- bindings: {
70
- claude: [{ event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit|Bash" }]
71
- }
72
- },
73
- {
74
- handler: "pre-tool-pipeline",
75
- description: "In-process pre-tool pipeline for harnesses that would otherwise spawn prompt/workflow guards separately.",
76
- semantic: null,
77
- bindings: {
78
- cursor: [{ event: "preToolUse", matcher: "*" }],
79
- codex: [{ event: "PreToolUse", matcher: "Bash|bash" }]
80
- }
81
- },
82
- {
83
- handler: "prompt-pipeline",
84
- description: "In-process prompt pipeline for Codex UserPromptSubmit (prompt-guard + verify-current-state).",
85
- semantic: "strict_state_verify",
86
- bindings: {
87
- codex: [{ event: "UserPromptSubmit" }]
88
- }
89
- },
90
- {
91
- handler: "context-monitor",
92
- description: "Post-tool context usage + stage signal monitor.",
93
- semantic: "post_tool_context_monitor",
94
- bindings: {
95
- claude: [{ event: "PostToolUse", matcher: "*" }],
96
- cursor: [{ event: "postToolUse", matcher: "*" }],
97
- codex: [{ event: "PostToolUse", matcher: "Bash|bash" }]
44
+ codex: [
45
+ {
46
+ event: "SessionStart",
47
+ matcher: "startup|resume",
48
+ statusMessage: "Running cclaw session startup checks"
49
+ }
50
+ ]
98
51
  }
99
52
  },
100
53
  {
@@ -104,14 +57,14 @@ export const HOOK_MANIFEST = [
104
57
  bindings: {
105
58
  claude: [{ event: "Stop", timeout: 10 }],
106
59
  cursor: [{ event: "stop", timeout: 10 }],
107
- codex: [{ event: "Stop", timeout: 10 }]
60
+ codex: [
61
+ {
62
+ event: "Stop",
63
+ timeout: 10,
64
+ statusMessage: "Preparing cclaw handoff checklist"
65
+ }
66
+ ]
108
67
  }
109
- },
110
- {
111
- handler: "verify-current-state",
112
- description: "Supplementary strict-mode guard callable from in-process pipelines to assert live state matches flow.",
113
- semantic: null,
114
- bindings: {}
115
68
  }
116
69
  ];
117
70
  /** Sanity: every harness in HOOK_MANIFEST_HARNESSES must be a HarnessId. */
@@ -141,6 +94,7 @@ export function groupBindingsByEvent(harness) {
141
94
  handler: spec.handler,
142
95
  ...(binding.matcher !== undefined ? { matcher: binding.matcher } : {}),
143
96
  ...(binding.timeout !== undefined ? { timeout: binding.timeout } : {}),
97
+ ...(binding.statusMessage !== undefined ? { statusMessage: binding.statusMessage } : {}),
144
98
  priority: binding.priority ?? 0,
145
99
  seq: seq++
146
100
  });
@@ -1,30 +1,4 @@
1
1
  export interface NodeHookRuntimeOptions {
2
- /**
3
- * Single enforcement knob derived from `config.strictness`. Generated hooks
4
- * embed this value as the default for every guard (prompt, workflow, TDD,
5
- * iron-laws-coupled blocks). `CCLAW_STRICTNESS` env var overrides at run
6
- * time; per-law strictness still flows through `iron-laws.json`.
7
- */
8
- strictness?: "advisory" | "strict";
9
- tddTestPathPatterns?: string[];
10
- tddProductionPathPatterns?: string[];
11
- /**
12
- * Baked-in default recurrence threshold for compound-readiness computed
13
- * by the session-start hook. Derived from
14
- * `config.compound.recurrenceThreshold` at install time; re-run
15
- * `cclaw sync` after changing the config value so hook and CLI agree.
16
- */
17
- compoundRecurrenceThreshold?: number;
18
- /**
19
- * Enables early-stage producer/critic loop diagnostics in session-start.
20
- * Defaults to true.
21
- */
22
- earlyLoopEnabled?: boolean;
23
- /**
24
- * Baked-in max iterations for brainstorm/scope/design early-loop status.
25
- * Derived from `config.earlyLoop.maxIterations`.
26
- */
27
- earlyLoopMaxIterations?: number;
28
2
  }
29
3
  /**
30
4
  * Node-only hook runtime (single entrypoint).
@@ -5,11 +5,6 @@ import { DEFAULT_COMPOUND_RECURRENCE_THRESHOLD, DEFAULT_EARLY_LOOP_MAX_ITERATION
5
5
  import { RUNTIME_ROOT } from "../constants.js";
6
6
  import { SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD, SMALL_PROJECT_RECURRENCE_THRESHOLD } from "../knowledge-store.js";
7
7
  import { SHARED_FLOW_AND_KNOWLEDGE_SNIPPETS, SHARED_STAGE_SUPPORT_SNIPPETS } from "./runtime-shared-snippets.js";
8
- function normalizePatterns(patterns, fallback) {
9
- if (!patterns || patterns.length === 0)
10
- return [...fallback];
11
- return patterns.map((value) => value.trim()).filter((value) => value.length > 0);
12
- }
13
8
  function resolveCliRuntimeForGeneratedHook() {
14
9
  const here = fileURLToPath(import.meta.url);
15
10
  const candidates = [
@@ -39,24 +34,19 @@ function resolveCliRuntimeForGeneratedHook() {
39
34
  * bash/python/jq runtime dependencies.
40
35
  */
41
36
  export function nodeHookRuntimeScript(options = {}) {
42
- const strictness = options.strictness === "strict" ? "strict" : "advisory";
43
- const tddTestPathPatterns = normalizePatterns(options.tddTestPathPatterns, [
37
+ void options;
38
+ const strictness = "advisory";
39
+ const tddTestPathPatterns = [
44
40
  "**/*.test.*",
45
41
  "**/tests/**",
46
42
  "**/__tests__/**"
47
- ]);
48
- const tddProductionPathPatterns = normalizePatterns(options.tddProductionPathPatterns, []);
49
- const compoundRecurrenceThreshold = typeof options.compoundRecurrenceThreshold === "number" &&
50
- Number.isInteger(options.compoundRecurrenceThreshold) &&
51
- options.compoundRecurrenceThreshold >= 1
52
- ? options.compoundRecurrenceThreshold
53
- : DEFAULT_COMPOUND_RECURRENCE_THRESHOLD;
54
- const earlyLoopEnabled = options.earlyLoopEnabled !== false;
55
- const earlyLoopMaxIterations = typeof options.earlyLoopMaxIterations === "number" &&
56
- Number.isInteger(options.earlyLoopMaxIterations) &&
57
- options.earlyLoopMaxIterations >= 1
58
- ? options.earlyLoopMaxIterations
59
- : DEFAULT_EARLY_LOOP_MAX_ITERATIONS;
43
+ ];
44
+ const tddProductionPathPatterns = [];
45
+ const compoundRecurrenceThreshold = DEFAULT_COMPOUND_RECURRENCE_THRESHOLD;
46
+ const earlyLoopEnabled = true;
47
+ const earlyLoopMaxIterations = DEFAULT_EARLY_LOOP_MAX_ITERATIONS;
48
+ const defaultHookProfile = "standard";
49
+ const defaultDisabledHooks = [];
60
50
  const cliRuntime = resolveCliRuntimeForGeneratedHook();
61
51
  return `#!/usr/bin/env node
62
52
  import fs from "node:fs/promises";
@@ -83,6 +73,14 @@ const EARLY_LOOP_ENABLED = ${JSON.stringify(earlyLoopEnabled)};
83
73
  const EARLY_LOOP_MAX_ITERATIONS = ${JSON.stringify(earlyLoopMaxIterations)};
84
74
  const CCLAW_CLI_ENTRYPOINT = ${JSON.stringify(cliRuntime.entrypoint)};
85
75
  const CCLAW_CLI_ARGS_PREFIX = ${JSON.stringify(cliRuntime.argsPrefix)};
76
+ const DEFAULT_HOOK_PROFILE = ${JSON.stringify(defaultHookProfile)};
77
+ const DEFAULT_DISABLED_HOOKS = ${JSON.stringify(defaultDisabledHooks)};
78
+ const HOOK_PROFILE_VALUES = new Set(["minimal", "standard", "strict"]);
79
+ const MINIMAL_PROFILE_ALLOWED_HOOKS = new Set([
80
+ "session-start",
81
+ "session-start-refresh",
82
+ "stop-handoff"
83
+ ]);
86
84
  const SESSION_DIGEST_SCHEMA_VERSION = 1;
87
85
  const SESSION_DIGEST_CACHE_FILE = "session-digest.json";
88
86
  const SESSION_DIGEST_REFRESH_MARKER_FILE = "session-digest.refresh.json";
@@ -91,7 +89,119 @@ const SESSION_DIGEST_REFRESH_STALE_MS = 30000;
91
89
  ${SHARED_FLOW_AND_KNOWLEDGE_SNIPPETS}
92
90
  ${SHARED_STAGE_SUPPORT_SNIPPETS}
93
91
 
92
+ let ACTIVE_HOOK_PROFILE = DEFAULT_HOOK_PROFILE;
93
+
94
+ function normalizeHookToken(value) {
95
+ return String(value == null ? "" : value).trim().toLowerCase();
96
+ }
97
+
98
+ function parseHookProfile(rawValue, fallback = "standard") {
99
+ const normalized = normalizeHookToken(rawValue);
100
+ if (HOOK_PROFILE_VALUES.has(normalized)) return normalized;
101
+ return fallback;
102
+ }
103
+
104
+ function parseDisabledHooksCsv(rawValue) {
105
+ const raw = typeof rawValue === "string" ? rawValue : "";
106
+ if (raw.trim().length === 0) return [];
107
+ const out = [];
108
+ for (const token of raw.split(",")) {
109
+ const normalized = normalizeHookToken(token);
110
+ if (normalized.length === 0) continue;
111
+ if (!out.includes(normalized)) out.push(normalized);
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function parseInlineYamlList(rawValue) {
117
+ const raw = typeof rawValue === "string" ? rawValue.trim() : "";
118
+ if (!raw.startsWith("[") || !raw.endsWith("]")) return [];
119
+ const inside = raw.slice(1, -1).trim();
120
+ if (inside.length === 0) return [];
121
+ return inside.split(",").map((token) => normalizeHookToken(token.replace(/^['"]|['"]$/g, ""))).filter((token) => token.length > 0);
122
+ }
123
+
124
+ function parseConfigHookProfile(rawYaml) {
125
+ if (typeof rawYaml !== "string" || rawYaml.trim().length === 0) {
126
+ return "";
127
+ }
128
+ const match = rawYaml.match(/^\\s*hookProfile\\s*:\\s*([A-Za-z0-9_-]+)\\s*$/m);
129
+ if (!match || typeof match[1] !== "string") return "";
130
+ return parseHookProfile(match[1], "");
131
+ }
132
+
133
+ function parseConfigDisabledHooks(rawYaml) {
134
+ if (typeof rawYaml !== "string" || rawYaml.trim().length === 0) {
135
+ return [];
136
+ }
137
+ const lines = rawYaml.split(/\\r?\\n/u);
138
+ const out = [];
139
+ for (let i = 0; i < lines.length; i += 1) {
140
+ const line = lines[i];
141
+ const inlineMatch = line.match(/^\\s*disabledHooks\\s*:\\s*(\\[[^\\]]*\\])\\s*$/u);
142
+ if (inlineMatch) {
143
+ for (const value of parseInlineYamlList(inlineMatch[1])) {
144
+ if (!out.includes(value)) out.push(value);
145
+ }
146
+ continue;
147
+ }
148
+ const blockMatch = line.match(/^(\\s*)disabledHooks\\s*:\\s*$/u);
149
+ if (!blockMatch) continue;
150
+ const baseIndent = blockMatch[1] ? blockMatch[1].length : 0;
151
+ for (let j = i + 1; j < lines.length; j += 1) {
152
+ const nextLine = lines[j];
153
+ const indent = (nextLine.match(/^(\\s*)/u)?.[1].length ?? 0);
154
+ const trimmed = nextLine.trim();
155
+ if (trimmed.length === 0) continue;
156
+ if (indent <= baseIndent) break;
157
+ const itemMatch = nextLine.match(/^\\s*-\\s*(.+?)\\s*$/u);
158
+ if (!itemMatch) continue;
159
+ const normalized = normalizeHookToken(itemMatch[1].replace(/^['"]|['"]$/g, ""));
160
+ if (normalized.length === 0) continue;
161
+ if (!out.includes(normalized)) out.push(normalized);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ async function readConfigHookPolicy(root) {
168
+ const configPath = path.join(root, RUNTIME_ROOT, "config.yaml");
169
+ const raw = await readTextFile(configPath, "");
170
+ const profile = parseConfigHookProfile(raw);
171
+ const disabledHooks = parseConfigDisabledHooks(raw);
172
+ return { profile, disabledHooks };
173
+ }
174
+
175
+ async function resolveHookPolicy(root) {
176
+ const fromConfig = await readConfigHookPolicy(root);
177
+ const configProfile = parseHookProfile(fromConfig.profile, DEFAULT_HOOK_PROFILE);
178
+ const configDisabledHooks = Array.isArray(fromConfig.disabledHooks) && fromConfig.disabledHooks.length > 0
179
+ ? fromConfig.disabledHooks
180
+ : DEFAULT_DISABLED_HOOKS;
181
+
182
+ const envProfileRaw = process.env.CCLAW_HOOK_PROFILE;
183
+ const envProfile = parseHookProfile(envProfileRaw, "");
184
+ const profile = envProfile.length > 0 ? envProfile : configProfile;
185
+
186
+ const envDisabledRaw = process.env.CCLAW_DISABLED_HOOKS;
187
+ const envDisabledHooks = parseDisabledHooksCsv(envDisabledRaw);
188
+ const disabledHooks = envDisabledHooks.length > 0 ? envDisabledHooks : configDisabledHooks;
189
+ const disabled = new Set(disabledHooks.map((value) => normalizeHookToken(value)));
190
+ return { profile, disabled };
191
+ }
192
+
193
+ function hookDisabledByProfile(profile, hookName) {
194
+ if (profile !== "minimal") return false;
195
+ return !MINIMAL_PROFILE_ALLOWED_HOOKS.has(hookName);
196
+ }
197
+
198
+ function isHookDisabled(policy, hookName) {
199
+ if (policy.disabled.has(hookName)) return true;
200
+ return hookDisabledByProfile(policy.profile, hookName);
201
+ }
202
+
94
203
  function resolveStrictness() {
204
+ if (ACTIVE_HOOK_PROFILE === "strict") return "strict";
95
205
  return process.env.CCLAW_STRICTNESS === "strict" ? "strict" : DEFAULT_STRICTNESS;
96
206
  }
97
207
 
@@ -1169,8 +1279,6 @@ async function readStageSupportContext(root, currentStage) {
1169
1279
 
1170
1280
  async function handleSessionStart(runtime) {
1171
1281
  const state = await readFlowState(runtime.root);
1172
- const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
1173
- const ironLawsFile = path.join(stateDir, "iron-laws.json");
1174
1282
  const metaSkillFile = path.join(runtime.root, RUNTIME_ROOT, "skills", "using-cclaw", "SKILL.md");
1175
1283
 
1176
1284
 
@@ -1186,35 +1294,11 @@ async function handleSessionStart(runtime) {
1186
1294
  );
1187
1295
  const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage, knowledgeRaw);
1188
1296
 
1189
- // Fast path: read precomputed status lines from session-digest cache.
1190
- // If cache is stale, schedule a debounced background refresh so this hook
1191
- // returns quickly inside harness startup.
1192
- const flowStateMtimeMs = await readFileMtimeMs(state.filePath);
1193
- const forceSyncRefresh =
1194
- normalizeText(process.env.CCLAW_SESSION_START_BG_SYNC).toLowerCase() === "1" ||
1195
- ["1", "true", "yes"].includes(normalizeText(process.env.VITEST).toLowerCase());
1196
- let sessionDigest = await readSessionDigestLines(stateDir, state, flowStateMtimeMs);
1197
- if (forceSyncRefresh && flowStateMtimeMs > 0) {
1198
- await refreshSessionDigestCache(runtime.root, state, flowStateMtimeMs);
1199
- sessionDigest = await readSessionDigestLines(stateDir, state, flowStateMtimeMs);
1200
- } else if (!sessionDigest.fresh) {
1201
- await scheduleSessionDigestRefresh(runtime, state, flowStateMtimeMs);
1202
- }
1203
- const ralphLoopLine = sessionDigest.ralphLoopLine;
1204
- const earlyLoopLine = sessionDigest.earlyLoopLine;
1205
- const compoundReadinessLine = sessionDigest.compoundReadinessLine;
1206
-
1207
- const ironLawsObj = toObject(await readJsonFile(ironLawsFile, {})) || {};
1208
- const laws = Array.isArray(ironLawsObj.laws) ? ironLawsObj.laws : [];
1209
- const ironLawLines = laws
1210
- .filter((row) => row && typeof row === "object")
1211
- .slice(0, 6)
1212
- .map((row) => {
1213
- const strict = row.strict === true ? "strict" : "advisory";
1214
- const id = typeof row.id === "string" && row.id.length > 0 ? row.id : "law";
1215
- const rule = typeof row.rule === "string" ? row.rule : "";
1216
- return "- [" + strict + "] " + id + " -> " + rule;
1217
- });
1297
+ // Wave 21 honest-core: session-start no longer runs background helper
1298
+ // pipelines or digest caches. It rehydrates flow + knowledge only.
1299
+ const ralphLoopLine = "";
1300
+ const earlyLoopLine = "";
1301
+ const compoundReadinessLine = "";
1218
1302
  const staleStages = toObject(state.raw.staleStages) || {};
1219
1303
  const staleStageNames = Object.keys(staleStages);
1220
1304
  const interactionHints = toObject(state.raw.interactionHints) || {};
@@ -1275,9 +1359,6 @@ async function handleSessionStart(runtime) {
1275
1359
  if (stageSupportContext.length > 0) {
1276
1360
  parts.push(...stageSupportContext);
1277
1361
  }
1278
- if (ironLawLines.length > 0) {
1279
- parts.push("Iron laws (enforced policy highlights):\\n" + ironLawLines.join("\\n"));
1280
- }
1281
1362
  if (metaContent.length > 0) {
1282
1363
  parts.push(metaContent);
1283
1364
  }
@@ -1316,22 +1397,80 @@ async function isGitDirty(root) {
1316
1397
  });
1317
1398
  }
1318
1399
 
1319
- function stopLawIsStrict(ironLawsObj) {
1320
- if ((ironLawsObj.mode || "advisory") === "strict") return true;
1321
- const laws = Array.isArray(ironLawsObj.laws) ? ironLawsObj.laws : [];
1322
- return laws.some(
1323
- (row) =>
1324
- row &&
1325
- typeof row === "object" &&
1326
- (row.id === "stop-clean-or-handoff" || row.id === "stop-clean-or-checkpointed") &&
1327
- row.strict === true
1328
- );
1400
+ const STOP_BLOCK_LIMIT_PER_TRANSCRIPT = 2;
1401
+
1402
+ function asBoolean(value) {
1403
+ if (value === true || value === false) return value;
1404
+ if (typeof value === "number") return Number.isFinite(value) && value !== 0;
1405
+ if (typeof value !== "string") return false;
1406
+ const normalized = value.trim().toLowerCase();
1407
+ if (normalized.length === 0) return false;
1408
+ return ["1", "true", "yes", "on"].includes(normalized);
1409
+ }
1410
+
1411
+ function stringTokenHit(value, tokens) {
1412
+ const normalized = normalizeText(value).toLowerCase();
1413
+ if (normalized.length === 0) return false;
1414
+ return tokens.some((token) => normalized.includes(token));
1415
+ }
1416
+
1417
+ function sanitizeStopSessionKey(raw) {
1418
+ const normalized = normalizeText(raw)
1419
+ .toLowerCase()
1420
+ .replace(/[^a-z0-9._-]+/gu, "-")
1421
+ .replace(/^-+|-+$/gu, "");
1422
+ return normalized.length > 0 ? normalized.slice(0, 96) : "global";
1423
+ }
1424
+
1425
+ function extractStopSignals(input, fallbackSessionKey) {
1426
+ const event = toObject(input.event) || {};
1427
+ const session = toObject(input.session) || {};
1428
+ const contextLimit =
1429
+ asBoolean(input.context_limit) ||
1430
+ asBoolean(input.contextLimit) ||
1431
+ asBoolean(event.context_limit) ||
1432
+ asBoolean(event.contextLimit) ||
1433
+ stringTokenHit(input.reason, ["context_limit", "context limit"]) ||
1434
+ stringTokenHit(event.reason, ["context_limit", "context limit"]) ||
1435
+ stringTokenHit(input.stop_reason, ["context_limit", "context limit"]) ||
1436
+ stringTokenHit(event.stop_reason, ["context_limit", "context limit"]);
1437
+ const userAbort =
1438
+ asBoolean(input.user_abort) ||
1439
+ asBoolean(input.userAbort) ||
1440
+ asBoolean(input.user_cancelled) ||
1441
+ asBoolean(input.userCancelled) ||
1442
+ asBoolean(event.user_abort) ||
1443
+ asBoolean(event.userAbort) ||
1444
+ stringTokenHit(input.reason, ["user_abort", "user abort", "cancelled by user", "stop button", "ctrl+c"]) ||
1445
+ stringTokenHit(event.reason, ["user_abort", "user abort", "cancelled by user", "stop button", "ctrl+c"]) ||
1446
+ stringTokenHit(input.stop_reason, ["user_abort", "user abort", "cancelled by user", "stop button", "ctrl+c"]) ||
1447
+ stringTokenHit(event.stop_reason, ["user_abort", "user abort", "cancelled by user", "stop button", "ctrl+c"]);
1448
+ const stopHookActive =
1449
+ asBoolean(input.stop_hook_active) ||
1450
+ asBoolean(input.stopHookActive) ||
1451
+ asBoolean(event.stop_hook_active) ||
1452
+ asBoolean(event.stopHookActive);
1453
+
1454
+ const sessionKeyCandidate =
1455
+ (typeof input.transcript_id === "string" && input.transcript_id) ||
1456
+ (typeof input.transcriptId === "string" && input.transcriptId) ||
1457
+ (typeof input.session_id === "string" && input.session_id) ||
1458
+ (typeof input.sessionId === "string" && input.sessionId) ||
1459
+ (typeof session.id === "string" && session.id) ||
1460
+ fallbackSessionKey;
1461
+ const sessionKey = sanitizeStopSessionKey(sessionKeyCandidate);
1462
+
1463
+ return {
1464
+ contextLimit,
1465
+ userAbort,
1466
+ stopHookActive,
1467
+ sessionKey
1468
+ };
1329
1469
  }
1330
1470
 
1331
1471
  async function handleStopHandoff(runtime) {
1332
1472
  const state = await readFlowState(runtime.root);
1333
1473
  const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
1334
- const ironLawsFile = path.join(stateDir, "iron-laws.json");
1335
1474
  const input = toObject(runtime.inputData) || {};
1336
1475
  const loopCount =
1337
1476
  typeof input.loop_count === "number" && Number.isFinite(input.loop_count)
@@ -1339,12 +1478,40 @@ async function handleStopHandoff(runtime) {
1339
1478
  : 0;
1340
1479
 
1341
1480
  const dirtyState = await isGitDirty(runtime.root);
1342
- const strictStop = stopLawIsStrict(toObject(await readJsonFile(ironLawsFile, {})) || {});
1343
- if (dirtyState === "dirty" && strictStop) {
1481
+ const stopSignals = extractStopSignals(input, "run-" + state.activeRunId);
1482
+ const safetyBypassActive = stopSignals.stopHookActive || stopSignals.userAbort || stopSignals.contextLimit;
1483
+ if (dirtyState === "dirty" && !safetyBypassActive) {
1484
+ const stopBlocksPath = path.join(stateDir, "stop-blocks-" + stopSignals.sessionKey + ".json");
1485
+ const prior = toObject(await readJsonFile(stopBlocksPath, {})) || {};
1486
+ const priorCount =
1487
+ typeof prior.blockCount === "number" && Number.isFinite(prior.blockCount)
1488
+ ? Math.max(0, Math.trunc(prior.blockCount))
1489
+ : 0;
1490
+ if (priorCount < STOP_BLOCK_LIMIT_PER_TRANSCRIPT) {
1491
+ const nextCount = priorCount + 1;
1492
+ await writeJsonFile(stopBlocksPath, {
1493
+ schemaVersion: 1,
1494
+ sessionKey: stopSignals.sessionKey,
1495
+ blockCount: nextCount,
1496
+ updatedAt: new Date().toISOString()
1497
+ });
1498
+ process.stderr.write(
1499
+ '[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'
1500
+ );
1501
+ return 1;
1502
+ }
1344
1503
  process.stderr.write(
1345
- '[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'
1504
+ '[cclaw] Stop advisory: dirty working tree detected, but block limit reached for this transcript (max 2). Continuing with handoff reminder only.\\n'
1505
+ );
1506
+ } else if (dirtyState === "dirty" && safetyBypassActive) {
1507
+ const reason = stopSignals.stopHookActive
1508
+ ? "stop_hook_active"
1509
+ : stopSignals.userAbort
1510
+ ? "user_abort"
1511
+ : "context_limit";
1512
+ process.stderr.write(
1513
+ "[cclaw] Stop advisory: bypassing strict stop block due to safety rule (" + reason + ").\\n"
1346
1514
  );
1347
- return 1;
1348
1515
  }
1349
1516
 
1350
1517
  const closeoutObj = toObject(state.raw.closeout) || {};
@@ -1903,16 +2070,9 @@ async function handlePromptPipeline(runtime) {
1903
2070
  function normalizeHookName(rawName) {
1904
2071
  const value = normalizeText(rawName).toLowerCase();
1905
2072
  if (value === "session-start") return "session-start";
1906
- if (value === "session-start-refresh") return "session-start-refresh";
1907
2073
  if (value === "stop-handoff" || value === "stop") return "stop-handoff";
1908
2074
  if (value === "stop-checkpoint") return "stop-handoff";
1909
2075
  if (value === "session-rehydrate") return "session-start";
1910
- if (value === "prompt-guard") return "prompt-guard";
1911
- if (value === "workflow-guard") return "workflow-guard";
1912
- if (value === "pre-tool-pipeline" || value === "pretool-pipeline") return "pre-tool-pipeline";
1913
- if (value === "prompt-pipeline" || value === "promptpipeline") return "prompt-pipeline";
1914
- if (value === "context-monitor") return "context-monitor";
1915
- if (value === "verify-current-state") return "verify-current-state";
1916
2076
  return "";
1917
2077
  }
1918
2078
 
@@ -1922,7 +2082,7 @@ async function main() {
1922
2082
  process.stderr.write(
1923
2083
  "[cclaw] run-hook: usage: node " +
1924
2084
  RUNTIME_ROOT +
1925
- "/hooks/run-hook.mjs <session-start|session-start-refresh|stop-handoff|prompt-guard|workflow-guard|pre-tool-pipeline|prompt-pipeline|context-monitor|verify-current-state>\\n"
2085
+ "/hooks/run-hook.mjs <session-start|stop-handoff>\\n"
1926
2086
  );
1927
2087
  process.exitCode = 1;
1928
2088
  return;
@@ -1954,38 +2114,10 @@ async function main() {
1954
2114
  process.exitCode = await handleSessionStart(runtime);
1955
2115
  return;
1956
2116
  }
1957
- if (hookName === "session-start-refresh") {
1958
- process.exitCode = await handleSessionStartRefresh(runtime);
1959
- return;
1960
- }
1961
2117
  if (hookName === "stop-handoff") {
1962
2118
  process.exitCode = await handleStopHandoff(runtime);
1963
2119
  return;
1964
2120
  }
1965
- if (hookName === "prompt-guard") {
1966
- process.exitCode = await handlePromptGuard(runtime);
1967
- return;
1968
- }
1969
- if (hookName === "workflow-guard") {
1970
- process.exitCode = await handleWorkflowGuard(runtime);
1971
- return;
1972
- }
1973
- if (hookName === "pre-tool-pipeline") {
1974
- process.exitCode = await handlePreToolPipeline(runtime);
1975
- return;
1976
- }
1977
- if (hookName === "prompt-pipeline") {
1978
- process.exitCode = await handlePromptPipeline(runtime);
1979
- return;
1980
- }
1981
- if (hookName === "context-monitor") {
1982
- process.exitCode = await handleContextMonitor(runtime);
1983
- return;
1984
- }
1985
- if (hookName === "verify-current-state") {
1986
- process.exitCode = await handleVerifyCurrentState(runtime);
1987
- return;
1988
- }
1989
2121
  process.stderr.write("[cclaw] run-hook: unsupported hook " + hookName + "\\n");
1990
2122
  process.exitCode = 1;
1991
2123
  } catch (error) {
@@ -31,7 +31,8 @@ function buildClaudeLikeEvents(harness) {
31
31
  const hookEntry = {
32
32
  type: "command",
33
33
  command: hookDispatcherCommand(entry.handler),
34
- ...(entry.timeout !== undefined ? { timeout: entry.timeout } : {})
34
+ ...(entry.timeout !== undefined ? { timeout: entry.timeout } : {}),
35
+ ...(entry.statusMessage !== undefined ? { statusMessage: entry.statusMessage } : {})
35
36
  };
36
37
  bucket.hooks.push(hookEntry);
37
38
  }