cclaw-cli 1.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.
Files changed (52) hide show
  1. package/dist/artifact-linter/brainstorm.js +15 -1
  2. package/dist/artifact-linter/design.js +14 -0
  3. package/dist/artifact-linter/scope.js +14 -0
  4. package/dist/artifact-linter/shared.d.ts +1 -0
  5. package/dist/artifact-linter/shared.js +32 -0
  6. package/dist/artifact-linter.js +13 -5
  7. package/dist/cli.js +2 -9
  8. package/dist/config.d.ts +11 -67
  9. package/dist/config.js +59 -649
  10. package/dist/content/hook-events.js +1 -5
  11. package/dist/content/hook-manifest.d.ts +6 -4
  12. package/dist/content/hook-manifest.js +16 -65
  13. package/dist/content/hooks.js +54 -14
  14. package/dist/content/meta-skill.js +4 -3
  15. package/dist/content/node-hooks.d.ts +0 -26
  16. package/dist/content/node-hooks.js +459 -157
  17. package/dist/content/observe.js +5 -4
  18. package/dist/content/opencode-plugin.js +1 -78
  19. package/dist/content/skills-elicitation.d.ts +1 -0
  20. package/dist/content/skills-elicitation.js +123 -0
  21. package/dist/content/skills.js +6 -4
  22. package/dist/content/stages/brainstorm.js +7 -3
  23. package/dist/content/stages/design.js +6 -2
  24. package/dist/content/stages/plan.js +2 -2
  25. package/dist/content/stages/scope.js +9 -5
  26. package/dist/content/stages/tdd.js +11 -11
  27. package/dist/content/start-command.js +4 -4
  28. package/dist/content/templates.js +21 -0
  29. package/dist/flow-state.d.ts +7 -0
  30. package/dist/flow-state.js +1 -0
  31. package/dist/gate-evidence.js +1 -5
  32. package/dist/hook-schema.js +3 -0
  33. package/dist/hook-schemas/claude-hooks.v1.json +2 -5
  34. package/dist/hook-schemas/codex-hooks.v1.json +1 -4
  35. package/dist/hook-schemas/cursor-hooks.v1.json +1 -3
  36. package/dist/install.d.ts +2 -7
  37. package/dist/install.js +32 -123
  38. package/dist/internal/advance-stage/advance.js +22 -1
  39. package/dist/internal/advance-stage/parsers.d.ts +1 -0
  40. package/dist/internal/advance-stage/parsers.js +6 -0
  41. package/dist/internal/compound-readiness.js +1 -16
  42. package/dist/internal/early-loop-status.js +1 -3
  43. package/dist/internal/runtime-integrity.js +0 -20
  44. package/dist/policy.js +6 -9
  45. package/dist/run-persistence.d.ts +1 -1
  46. package/dist/run-persistence.js +29 -2
  47. package/dist/runtime/run-hook.mjs +459 -265
  48. package/dist/tdd-verification-evidence.js +6 -18
  49. package/dist/track-heuristics.d.ts +7 -1
  50. package/dist/track-heuristics.js +12 -0
  51. package/dist/types.d.ts +0 -56
  52. package/package.json +1 -1
@@ -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,11 +73,135 @@ 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
+ ]);
84
+ const SESSION_DIGEST_SCHEMA_VERSION = 1;
85
+ const SESSION_DIGEST_CACHE_FILE = "session-digest.json";
86
+ const SESSION_DIGEST_REFRESH_MARKER_FILE = "session-digest.refresh.json";
87
+ const SESSION_DIGEST_REFRESH_STALE_MS = 30000;
86
88
 
87
89
  ${SHARED_FLOW_AND_KNOWLEDGE_SNIPPETS}
88
90
  ${SHARED_STAGE_SUPPORT_SNIPPETS}
89
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
+
90
203
  function resolveStrictness() {
204
+ if (ACTIVE_HOOK_PROFILE === "strict") return "strict";
91
205
  return process.env.CCLAW_STRICTNESS === "strict" ? "strict" : DEFAULT_STRICTNESS;
92
206
  }
93
207
 
@@ -508,9 +622,10 @@ function hookEventNameForOutput(hookName) {
508
622
  if (hookName === "session-start") return "SessionStart";
509
623
  if (hookName === "prompt-guard") return "PreToolUse";
510
624
  if (hookName === "workflow-guard") return "PreToolUse";
625
+ if (hookName === "pre-tool-pipeline") return "PreToolUse";
626
+ if (hookName === "prompt-pipeline") return "UserPromptSubmit";
511
627
  if (hookName === "context-monitor") return "PostToolUse";
512
628
  if (hookName === "stop-handoff") return "Stop";
513
- if (hookName === "pre-compact") return "PreCompact";
514
629
  if (hookName === "verify-current-state") return "UserPromptSubmit";
515
630
  return "SessionStart";
516
631
  }
@@ -919,78 +1034,55 @@ async function readFlowState(root) {
919
1034
  };
920
1035
  }
921
1036
 
922
-
923
- async function buildKnowledgeDigest(root, currentStage, prereadRaw) {
924
- const knowledgeFile = path.join(root, RUNTIME_ROOT, "knowledge.jsonl");
925
- // Caller may supply pre-read raw bytes to avoid re-reading knowledge.jsonl.
926
- // Falls back to a local read if nothing is passed in.
927
- const raw = typeof prereadRaw === "string"
928
- ? prereadRaw
929
- : await readTextFile(knowledgeFile, "");
930
- const digest = parseKnowledgeDigest(raw, currentStage, 6);
931
- return {
932
- digestLines: digest.lines,
933
- learningsCount: digest.learningsCount
934
- };
1037
+ async function readFileMtimeMs(filePath) {
1038
+ try {
1039
+ const stat = await fs.stat(filePath);
1040
+ if (!stat.isFile()) return 0;
1041
+ return Math.trunc(stat.mtimeMs);
1042
+ } catch {
1043
+ return 0;
1044
+ }
935
1045
  }
936
1046
 
937
- async function readStageSupportContext(root, currentStage) {
938
- if (!isKnownStageId(currentStage)) return [];
939
- const stage = currentStage;
940
-
941
- const parts = [];
942
- const contractPath = path.join(root, RUNTIME_ROOT, "templates", "state-contracts", stage + ".json");
943
- const contract = (await readTextFile(contractPath, "")).trim();
944
- if (contract.length > 0) {
945
- parts.push(
946
- "Current stage state contract (read before drafting or editing the stage artifact):\\n" +
947
- contract
948
- );
949
- }
1047
+ function parseNumericMs(value) {
1048
+ return typeof value === "number" && Number.isFinite(value)
1049
+ ? Math.trunc(value)
1050
+ : -1;
1051
+ }
950
1052
 
951
- const promptName = reviewPromptFileName(stage);
952
- if (typeof promptName === "string") {
953
- const promptPath = path.join(root, RUNTIME_ROOT, "skills", "review-prompts", promptName);
954
- const prompt = (await readTextFile(promptPath, "")).trim();
955
- if (prompt.length > 0) {
956
- parts.push(
957
- "Current stage calibrated review prompt (use before asking for approval/completion):\\n" +
958
- prompt
959
- );
960
- }
1053
+ async function readSessionDigestLines(stateDir, state, flowStateMtimeMs) {
1054
+ const cachePath = path.join(stateDir, SESSION_DIGEST_CACHE_FILE);
1055
+ const cache = toObject(await readJsonFile(cachePath, {})) || {};
1056
+ const cachedMtimeMs = parseNumericMs(cache.flowStateMtimeMs);
1057
+ const sameStage = typeof cache.currentStage === "string" ? cache.currentStage === state.currentStage : true;
1058
+ const sameRun = typeof cache.activeRunId === "string" ? cache.activeRunId === state.activeRunId : true;
1059
+ const fresh = cachedMtimeMs === flowStateMtimeMs && sameStage && sameRun;
1060
+ if (!fresh) {
1061
+ return {
1062
+ ralphLoopLine: "",
1063
+ earlyLoopLine: "",
1064
+ compoundReadinessLine: "",
1065
+ fresh: false
1066
+ };
961
1067
  }
962
-
963
- return parts;
1068
+ return {
1069
+ ralphLoopLine: typeof cache.ralphLoopLine === "string" ? cache.ralphLoopLine : "",
1070
+ earlyLoopLine: typeof cache.earlyLoopLine === "string" ? cache.earlyLoopLine : "",
1071
+ compoundReadinessLine: typeof cache.compoundReadinessLine === "string" ? cache.compoundReadinessLine : "",
1072
+ fresh: true
1073
+ };
964
1074
  }
965
1075
 
966
- async function handleSessionStart(runtime) {
967
- const state = await readFlowState(runtime.root);
968
- const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
969
- const ironLawsFile = path.join(stateDir, "iron-laws.json");
970
- const metaSkillFile = path.join(runtime.root, RUNTIME_ROOT, "skills", "using-cclaw", "SKILL.md");
971
-
972
-
973
- // Read knowledge.jsonl exactly once per session-start while holding the
974
- // SAME lock CLI writers acquire in \`appendKnowledge\`. Guarantees we never
975
- // see a partial (mid-write) snapshot. Both the digest and
976
- // compound-readiness derive from this single read.
977
- const knowledgeFilePath = path.join(runtime.root, RUNTIME_ROOT, "knowledge.jsonl");
978
- const knowledgeRaw = await readTextFileLocked(
979
- knowledgeLockPathInline(runtime.root),
980
- knowledgeFilePath,
981
- ""
982
- );
983
- const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage, knowledgeRaw);
984
-
985
- // Refresh Ralph Loop status each session-start so /cc and the model
986
- // both read a consistent "iter=N, acClosed=[...]" snapshot. Runs only when
987
- // we are in tdd — other stages skip the write to keep the file stable.
1076
+ async function refreshSessionDigestCache(root, state, flowStateMtimeMs) {
1077
+ const stateDir = path.join(root, RUNTIME_ROOT, "state");
988
1078
  let ralphLoopLine = "";
989
1079
  let earlyLoopLine = "";
1080
+ let compoundReadinessLine = "";
1081
+
990
1082
  if (state.currentStage === "tdd") {
991
1083
  try {
992
1084
  const internalRalph = await runCclawInternal(
993
- runtime.root,
1085
+ root,
994
1086
  ["tdd-loop-status", "--json", "--write"],
995
1087
  { captureStdout: true }
996
1088
  );
@@ -1003,12 +1095,8 @@ async function handleSessionStart(runtime) {
1003
1095
  }
1004
1096
  ralphLoopLine = formatRalphLoopStatusLineFromJson(ralphStatus);
1005
1097
  } catch (err) {
1006
- // Best-effort — a malformed cycle log should never break
1007
- // session-start. But we DO leave a breadcrumb in
1008
- // hook-errors.jsonl so \`npx cclaw-cli sync\` can surface chronic
1009
- // failures (previously this was a silent swallow).
1010
1098
  await recordHookError(
1011
- runtime.root,
1099
+ root,
1012
1100
  "session-start:ralph-loop",
1013
1101
  err instanceof Error ? err.message : String(err)
1014
1102
  );
@@ -1020,7 +1108,7 @@ async function handleSessionStart(runtime) {
1020
1108
  ) {
1021
1109
  try {
1022
1110
  const internalEarly = await runCclawInternal(
1023
- runtime.root,
1111
+ root,
1024
1112
  [
1025
1113
  "early-loop-status",
1026
1114
  "--json",
@@ -1042,21 +1130,17 @@ async function handleSessionStart(runtime) {
1042
1130
  earlyLoopLine = formatEarlyLoopStatusLineFromJson(earlyLoopStatus);
1043
1131
  } catch (err) {
1044
1132
  await recordHookError(
1045
- runtime.root,
1133
+ root,
1046
1134
  "session-start:early-loop",
1047
1135
  err instanceof Error ? err.message : String(err)
1048
1136
  );
1049
1137
  }
1050
1138
  }
1051
1139
 
1052
- // Keep compound-readiness.json fresh on every session-start (cheap derived
1053
- // summary). Surface a one-line nudge only from review and ship stages
1054
- // where lifting becomes relevant; earlier stages update the file silently.
1055
- let compoundReadinessLine = "";
1056
1140
  try {
1057
1141
  const shouldShowReadiness = state.currentStage === "review" || state.currentStage === "ship";
1058
1142
  const internalReadiness = await runCclawInternal(
1059
- runtime.root,
1143
+ root,
1060
1144
  shouldShowReadiness ? ["compound-readiness"] : ["compound-readiness", "--quiet"],
1061
1145
  { captureStdout: true }
1062
1146
  );
@@ -1067,30 +1151,165 @@ async function handleSessionStart(runtime) {
1067
1151
  compoundReadinessLine = firstStdoutLine(internalReadiness.stdout);
1068
1152
  }
1069
1153
  } catch (err) {
1070
- // Best-effort — a malformed knowledge.jsonl must never break
1071
- // session-start. But we DO leave a breadcrumb in
1072
- // hook-errors.jsonl so config/IO problems become visible in
1073
- // \`npx cclaw-cli sync\` instead of silently degrading readiness output.
1074
1154
  await recordHookError(
1075
- runtime.root,
1155
+ root,
1076
1156
  "session-start:compound-readiness",
1077
1157
  err instanceof Error ? err.message : String(err)
1078
1158
  );
1079
1159
  }
1080
1160
 
1081
- const ironLawsObj = toObject(await readJsonFile(ironLawsFile, {})) || {};
1082
- const laws = Array.isArray(ironLawsObj.laws) ? ironLawsObj.laws : [];
1083
- const ironLawLines = laws
1084
- .filter((row) => row && typeof row === "object")
1085
- .slice(0, 6)
1086
- .map((row) => {
1087
- const strict = row.strict === true ? "strict" : "advisory";
1088
- const id = typeof row.id === "string" && row.id.length > 0 ? row.id : "law";
1089
- const rule = typeof row.rule === "string" ? row.rule : "";
1090
- return "- [" + strict + "] " + id + " -> " + rule;
1161
+ const digestPath = path.join(stateDir, SESSION_DIGEST_CACHE_FILE);
1162
+ await writeJsonFile(digestPath, {
1163
+ schemaVersion: SESSION_DIGEST_SCHEMA_VERSION,
1164
+ generatedAt: new Date().toISOString(),
1165
+ flowStateMtimeMs,
1166
+ currentStage: state.currentStage,
1167
+ activeRunId: state.activeRunId,
1168
+ ralphLoopLine,
1169
+ earlyLoopLine,
1170
+ compoundReadinessLine
1171
+ });
1172
+ }
1173
+
1174
+ async function scheduleSessionDigestRefresh(runtime, state, flowStateMtimeMs) {
1175
+ if (flowStateMtimeMs <= 0) return;
1176
+ const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
1177
+ const digestPath = path.join(stateDir, SESSION_DIGEST_CACHE_FILE);
1178
+ const markerPath = path.join(stateDir, SESSION_DIGEST_REFRESH_MARKER_FILE);
1179
+
1180
+ const cache = toObject(await readJsonFile(digestPath, {})) || {};
1181
+ const cachedMtimeMs = parseNumericMs(cache.flowStateMtimeMs);
1182
+ if (cachedMtimeMs === flowStateMtimeMs) return;
1183
+
1184
+ const marker = toObject(await readJsonFile(markerPath, {})) || {};
1185
+ const markerMtimeMs = parseNumericMs(marker.flowStateMtimeMs);
1186
+ const markerStartedAtMs = parseNumericMs(marker.startedAtMs);
1187
+ const markerFresh =
1188
+ markerMtimeMs === flowStateMtimeMs &&
1189
+ markerStartedAtMs > 0 &&
1190
+ Date.now() - markerStartedAtMs < SESSION_DIGEST_REFRESH_STALE_MS;
1191
+ if (markerFresh) return;
1192
+
1193
+ await writeJsonFile(markerPath, {
1194
+ flowStateMtimeMs,
1195
+ startedAtMs: Date.now(),
1196
+ currentStage: state.currentStage,
1197
+ activeRunId: state.activeRunId
1198
+ });
1199
+
1200
+ try {
1201
+ const child = spawn(process.execPath, [process.argv[1], "session-start-refresh"], {
1202
+ cwd: runtime.root,
1203
+ stdio: "ignore",
1204
+ windowsHide: true,
1205
+ detached: true,
1206
+ env: {
1207
+ ...process.env,
1208
+ CCLAW_PROJECT_ROOT: runtime.root,
1209
+ CCLAW_BG_WORKER: "1"
1210
+ }
1091
1211
  });
1212
+ child.unref();
1213
+ } catch (err) {
1214
+ await fs.rm(markerPath, { force: true }).catch(() => undefined);
1215
+ await recordHookError(
1216
+ runtime.root,
1217
+ "session-start:spawn-refresh",
1218
+ err instanceof Error ? err.message : String(err)
1219
+ );
1220
+ }
1221
+ }
1222
+
1223
+ async function handleSessionStartRefresh(runtime) {
1224
+ const state = await readFlowState(runtime.root);
1225
+ const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
1226
+ const markerPath = path.join(stateDir, SESSION_DIGEST_REFRESH_MARKER_FILE);
1227
+ try {
1228
+ const flowStateMtimeMs = await readFileMtimeMs(state.filePath);
1229
+ await refreshSessionDigestCache(runtime.root, state, flowStateMtimeMs);
1230
+ } finally {
1231
+ await fs.rm(markerPath, { force: true }).catch(() => undefined);
1232
+ }
1233
+ return 0;
1234
+ }
1235
+
1236
+
1237
+ async function buildKnowledgeDigest(root, currentStage, prereadRaw) {
1238
+ const knowledgeFile = path.join(root, RUNTIME_ROOT, "knowledge.jsonl");
1239
+ // Caller may supply pre-read raw bytes to avoid re-reading knowledge.jsonl.
1240
+ // Falls back to a local read if nothing is passed in.
1241
+ const raw = typeof prereadRaw === "string"
1242
+ ? prereadRaw
1243
+ : await readTextFile(knowledgeFile, "");
1244
+ const digest = parseKnowledgeDigest(raw, currentStage, 6);
1245
+ return {
1246
+ digestLines: digest.lines,
1247
+ learningsCount: digest.learningsCount
1248
+ };
1249
+ }
1250
+
1251
+ async function readStageSupportContext(root, currentStage) {
1252
+ if (!isKnownStageId(currentStage)) return [];
1253
+ const stage = currentStage;
1254
+
1255
+ const parts = [];
1256
+ const contractPath = path.join(root, RUNTIME_ROOT, "templates", "state-contracts", stage + ".json");
1257
+ const contract = (await readTextFile(contractPath, "")).trim();
1258
+ if (contract.length > 0) {
1259
+ parts.push(
1260
+ "Current stage state contract (read before drafting or editing the stage artifact):\\n" +
1261
+ contract
1262
+ );
1263
+ }
1264
+
1265
+ const promptName = reviewPromptFileName(stage);
1266
+ if (typeof promptName === "string") {
1267
+ const promptPath = path.join(root, RUNTIME_ROOT, "skills", "review-prompts", promptName);
1268
+ const prompt = (await readTextFile(promptPath, "")).trim();
1269
+ if (prompt.length > 0) {
1270
+ parts.push(
1271
+ "Current stage calibrated review prompt (use before asking for approval/completion):\\n" +
1272
+ prompt
1273
+ );
1274
+ }
1275
+ }
1276
+
1277
+ return parts;
1278
+ }
1279
+
1280
+ async function handleSessionStart(runtime) {
1281
+ const state = await readFlowState(runtime.root);
1282
+ const metaSkillFile = path.join(runtime.root, RUNTIME_ROOT, "skills", "using-cclaw", "SKILL.md");
1283
+
1284
+
1285
+ // Read knowledge.jsonl exactly once per session-start while holding the
1286
+ // SAME lock CLI writers acquire in \`appendKnowledge\`. Guarantees we never
1287
+ // see a partial (mid-write) snapshot. Both the digest and
1288
+ // compound-readiness derive from this single read.
1289
+ const knowledgeFilePath = path.join(runtime.root, RUNTIME_ROOT, "knowledge.jsonl");
1290
+ const knowledgeRaw = await readTextFileLocked(
1291
+ knowledgeLockPathInline(runtime.root),
1292
+ knowledgeFilePath,
1293
+ ""
1294
+ );
1295
+ const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage, knowledgeRaw);
1296
+
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 = "";
1092
1302
  const staleStages = toObject(state.raw.staleStages) || {};
1093
1303
  const staleStageNames = Object.keys(staleStages);
1304
+ const interactionHints = toObject(state.raw.interactionHints) || {};
1305
+ const stageInteractionHint = toObject(interactionHints[state.currentStage]);
1306
+ const skipQuestionsHintActive = stageInteractionHint?.skipQuestions === true;
1307
+ const skipQuestionsSource = typeof stageInteractionHint?.sourceStage === "string"
1308
+ ? stageInteractionHint.sourceStage
1309
+ : "";
1310
+ const skipQuestionsRecordedAt = typeof stageInteractionHint?.recordedAt === "string"
1311
+ ? stageInteractionHint.recordedAt
1312
+ : "";
1094
1313
  const metaContent = (await readTextFile(metaSkillFile, "")).trim();
1095
1314
  const stageSupportContext = await readStageSupportContext(runtime.root, state.currentStage);
1096
1315
 
@@ -1123,6 +1342,14 @@ async function handleSessionStart(runtime) {
1123
1342
  " (use npx cclaw-cli internal rewind --ack <stage> after redo)."
1124
1343
  );
1125
1344
  }
1345
+ if (skipQuestionsHintActive) {
1346
+ parts.push(
1347
+ "Adaptive elicitation hint: this stage inherits a prior user stop signal (--skip-questions" +
1348
+ (skipQuestionsSource ? " from " + skipQuestionsSource : "") +
1349
+ (skipQuestionsRecordedAt ? " at " + skipQuestionsRecordedAt : "") +
1350
+ "). Draft with available context unless irreversible/security override checks still require explicit confirmation."
1351
+ );
1352
+ }
1126
1353
  if (knowledge.digestLines.length > 0) {
1127
1354
  parts.push(
1128
1355
  "Knowledge digest (top relevant entries):\\n" +
@@ -1132,9 +1359,6 @@ async function handleSessionStart(runtime) {
1132
1359
  if (stageSupportContext.length > 0) {
1133
1360
  parts.push(...stageSupportContext);
1134
1361
  }
1135
- if (ironLawLines.length > 0) {
1136
- parts.push("Iron laws (enforced policy highlights):\\n" + ironLawLines.join("\\n"));
1137
- }
1138
1362
  if (metaContent.length > 0) {
1139
1363
  parts.push(metaContent);
1140
1364
  }
@@ -1173,22 +1397,80 @@ async function isGitDirty(root) {
1173
1397
  });
1174
1398
  }
1175
1399
 
1176
- function stopLawIsStrict(ironLawsObj) {
1177
- if ((ironLawsObj.mode || "advisory") === "strict") return true;
1178
- const laws = Array.isArray(ironLawsObj.laws) ? ironLawsObj.laws : [];
1179
- return laws.some(
1180
- (row) =>
1181
- row &&
1182
- typeof row === "object" &&
1183
- (row.id === "stop-clean-or-handoff" || row.id === "stop-clean-or-checkpointed") &&
1184
- row.strict === true
1185
- );
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
+ };
1186
1469
  }
1187
1470
 
1188
1471
  async function handleStopHandoff(runtime) {
1189
1472
  const state = await readFlowState(runtime.root);
1190
1473
  const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
1191
- const ironLawsFile = path.join(stateDir, "iron-laws.json");
1192
1474
  const input = toObject(runtime.inputData) || {};
1193
1475
  const loopCount =
1194
1476
  typeof input.loop_count === "number" && Number.isFinite(input.loop_count)
@@ -1196,12 +1478,40 @@ async function handleStopHandoff(runtime) {
1196
1478
  : 0;
1197
1479
 
1198
1480
  const dirtyState = await isGitDirty(runtime.root);
1199
- const strictStop = stopLawIsStrict(toObject(await readJsonFile(ironLawsFile, {})) || {});
1200
- 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
+ }
1503
+ process.stderr.write(
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";
1201
1512
  process.stderr.write(
1202
- '[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'
1513
+ "[cclaw] Stop advisory: bypassing strict stop block due to safety rule (" + reason + ").\\n"
1203
1514
  );
1204
- return 1;
1205
1515
  }
1206
1516
 
1207
1517
  const closeoutObj = toObject(state.raw.closeout) || {};
@@ -1235,10 +1545,6 @@ async function handleStopHandoff(runtime) {
1235
1545
  return 0;
1236
1546
  }
1237
1547
 
1238
- async function handlePreCompact(_runtime) {
1239
- return 0;
1240
- }
1241
-
1242
1548
  async function handlePromptGuard(runtime) {
1243
1549
  const mode = resolveStrictness();
1244
1550
  const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
@@ -1740,17 +2046,33 @@ async function handleVerifyCurrentState(runtime) {
1740
2046
  return 0;
1741
2047
  }
1742
2048
 
2049
+ async function handlePreToolPipeline(runtime) {
2050
+ const promptExitCode = await handlePromptGuard(runtime);
2051
+ if (promptExitCode !== 0) {
2052
+ return promptExitCode;
2053
+ }
2054
+ return await handleWorkflowGuard(runtime);
2055
+ }
2056
+
2057
+ async function handlePromptPipeline(runtime) {
2058
+ const promptExitCode = await handlePromptGuard(runtime);
2059
+ if (promptExitCode !== 0) {
2060
+ return promptExitCode;
2061
+ }
2062
+ const verifyExitCode = await handleVerifyCurrentState(runtime);
2063
+ if (verifyExitCode !== 0) {
2064
+ return verifyExitCode;
2065
+ }
2066
+ runtime.writeJson({ ok: true });
2067
+ return 0;
2068
+ }
2069
+
1743
2070
  function normalizeHookName(rawName) {
1744
2071
  const value = normalizeText(rawName).toLowerCase();
1745
2072
  if (value === "session-start") return "session-start";
1746
2073
  if (value === "stop-handoff" || value === "stop") return "stop-handoff";
1747
2074
  if (value === "stop-checkpoint") return "stop-handoff";
1748
- if (value === "pre-compact" || value === "precompact") return "pre-compact";
1749
2075
  if (value === "session-rehydrate") return "session-start";
1750
- if (value === "prompt-guard") return "prompt-guard";
1751
- if (value === "workflow-guard") return "workflow-guard";
1752
- if (value === "context-monitor") return "context-monitor";
1753
- if (value === "verify-current-state") return "verify-current-state";
1754
2076
  return "";
1755
2077
  }
1756
2078
 
@@ -1760,7 +2082,7 @@ async function main() {
1760
2082
  process.stderr.write(
1761
2083
  "[cclaw] run-hook: usage: node " +
1762
2084
  RUNTIME_ROOT +
1763
- "/hooks/run-hook.mjs <session-start|stop-handoff|pre-compact|prompt-guard|workflow-guard|context-monitor|verify-current-state>\\n"
2085
+ "/hooks/run-hook.mjs <session-start|stop-handoff>\\n"
1764
2086
  );
1765
2087
  process.exitCode = 1;
1766
2088
  return;
@@ -1796,26 +2118,6 @@ async function main() {
1796
2118
  process.exitCode = await handleStopHandoff(runtime);
1797
2119
  return;
1798
2120
  }
1799
- if (hookName === "pre-compact") {
1800
- process.exitCode = await handlePreCompact(runtime);
1801
- return;
1802
- }
1803
- if (hookName === "prompt-guard") {
1804
- process.exitCode = await handlePromptGuard(runtime);
1805
- return;
1806
- }
1807
- if (hookName === "workflow-guard") {
1808
- process.exitCode = await handleWorkflowGuard(runtime);
1809
- return;
1810
- }
1811
- if (hookName === "context-monitor") {
1812
- process.exitCode = await handleContextMonitor(runtime);
1813
- return;
1814
- }
1815
- if (hookName === "verify-current-state") {
1816
- process.exitCode = await handleVerifyCurrentState(runtime);
1817
- return;
1818
- }
1819
2121
  process.stderr.write("[cclaw] run-hook: unsupported hook " + hookName + "\\n");
1820
2122
  process.exitCode = 1;
1821
2123
  } catch (error) {