cclaw-cli 0.48.7 → 0.48.8
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.
- package/dist/content/hooks.d.ts +1 -0
- package/dist/content/hooks.js +1 -0
- package/dist/content/node-hooks.d.ts +14 -0
- package/dist/content/node-hooks.js +1527 -0
- package/dist/install.js +9 -0
- package/dist/internal/advance-stage.js +53 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1527 @@
|
|
|
1
|
+
import { RUNTIME_ROOT } from "../constants.js";
|
|
2
|
+
function normalizePatterns(patterns, fallback) {
|
|
3
|
+
if (!patterns || patterns.length === 0)
|
|
4
|
+
return [...fallback];
|
|
5
|
+
return patterns.map((value) => value.trim()).filter((value) => value.length > 0);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Node-only hook runtime (single entrypoint).
|
|
9
|
+
*
|
|
10
|
+
* Generated into `.cclaw/hooks/run-hook.mjs` and used by all harnesses to avoid
|
|
11
|
+
* bash/python/jq runtime dependencies.
|
|
12
|
+
*/
|
|
13
|
+
export function nodeHookRuntimeScript(options = {}) {
|
|
14
|
+
const promptGuardMode = options.promptGuardMode === "strict" ? "strict" : "advisory";
|
|
15
|
+
const workflowGuardMode = options.workflowGuardMode === "strict" ? "strict" : "advisory";
|
|
16
|
+
const tddEnforcementMode = options.tddEnforcementMode === "strict" ? "strict" : "advisory";
|
|
17
|
+
const tddTestPathPatterns = normalizePatterns(options.tddTestPathPatterns, [
|
|
18
|
+
"**/*.test.*",
|
|
19
|
+
"**/tests/**",
|
|
20
|
+
"**/__tests__/**"
|
|
21
|
+
]);
|
|
22
|
+
const tddProductionPathPatterns = normalizePatterns(options.tddProductionPathPatterns, []);
|
|
23
|
+
return `#!/usr/bin/env node
|
|
24
|
+
import fs from "node:fs/promises";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import process from "node:process";
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
|
|
29
|
+
const RUNTIME_ROOT = ${JSON.stringify(RUNTIME_ROOT)};
|
|
30
|
+
const DEFAULT_PROMPT_GUARD_MODE = ${JSON.stringify(promptGuardMode)};
|
|
31
|
+
const DEFAULT_WORKFLOW_GUARD_MODE = ${JSON.stringify(workflowGuardMode)};
|
|
32
|
+
const DEFAULT_TDD_ENFORCEMENT_MODE = ${JSON.stringify(tddEnforcementMode)};
|
|
33
|
+
const DEFAULT_TDD_TEST_PATH_PATTERNS = ${JSON.stringify(tddTestPathPatterns)};
|
|
34
|
+
const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS = ${JSON.stringify(tddProductionPathPatterns)};
|
|
35
|
+
|
|
36
|
+
function toObject(value) {
|
|
37
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeParseJson(raw, fallback = {}) {
|
|
42
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
return parsed === undefined ? fallback : parsed;
|
|
48
|
+
} catch {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readJsonFile(filePath, fallback = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
56
|
+
return safeParseJson(raw, fallback);
|
|
57
|
+
} catch {
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeJsonFile(filePath, value) {
|
|
63
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
64
|
+
const next = JSON.stringify(value, null, 2) + "\\n";
|
|
65
|
+
await fs.writeFile(filePath, next, "utf8");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fileExists(filePath) {
|
|
69
|
+
try {
|
|
70
|
+
await fs.stat(filePath);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readTextFile(filePath, fallback = "") {
|
|
78
|
+
try {
|
|
79
|
+
return await fs.readFile(filePath, "utf8");
|
|
80
|
+
} catch {
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function appendJsonLine(filePath, value) {
|
|
86
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
87
|
+
await fs.appendFile(filePath, JSON.stringify(value) + "\\n", "utf8");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readStdin() {
|
|
91
|
+
return await new Promise((resolve) => {
|
|
92
|
+
let data = "";
|
|
93
|
+
process.stdin.setEncoding("utf8");
|
|
94
|
+
process.stdin.on("data", (chunk) => {
|
|
95
|
+
data += String(chunk);
|
|
96
|
+
});
|
|
97
|
+
process.stdin.on("end", () => resolve(data));
|
|
98
|
+
process.stdin.on("error", () => resolve(""));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function detectHarness(env) {
|
|
103
|
+
if (env.CLAUDE_PROJECT_DIR) return "claude";
|
|
104
|
+
if (env.CURSOR_PROJECT_DIR || env.CURSOR_PROJECT_ROOT) return "cursor";
|
|
105
|
+
if (env.OPENCODE_PROJECT_DIR || env.OPENCODE_PROJECT_ROOT) return "opencode";
|
|
106
|
+
return "codex";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function detectRoot(env) {
|
|
110
|
+
const candidates = [
|
|
111
|
+
env.CCLAW_PROJECT_ROOT,
|
|
112
|
+
env.CLAUDE_PROJECT_DIR,
|
|
113
|
+
env.CURSOR_PROJECT_DIR,
|
|
114
|
+
env.CURSOR_PROJECT_ROOT,
|
|
115
|
+
env.OPENCODE_PROJECT_DIR,
|
|
116
|
+
env.OPENCODE_PROJECT_ROOT,
|
|
117
|
+
process.cwd()
|
|
118
|
+
].filter((value) => typeof value === "string" && value.length > 0);
|
|
119
|
+
for (const candidate of candidates) {
|
|
120
|
+
try {
|
|
121
|
+
const runtimePath = path.join(candidate, RUNTIME_ROOT);
|
|
122
|
+
const stat = await fs.stat(runtimePath);
|
|
123
|
+
if (stat.isDirectory()) return candidate;
|
|
124
|
+
} catch {
|
|
125
|
+
// continue
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return candidates[0] || process.cwd();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toLower(value) {
|
|
132
|
+
return String(value || "").toLowerCase();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeText(value) {
|
|
136
|
+
return String(value || "").replace(/\\s+/gu, " ").trim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizePathForMatch(rawPath) {
|
|
140
|
+
return normalizeText(rawPath)
|
|
141
|
+
.replace(/\\\\/gu, "/")
|
|
142
|
+
.replace(/^\\.\\//u, "")
|
|
143
|
+
.toLowerCase();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeToolName(value) {
|
|
147
|
+
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
148
|
+
if (value && typeof value === "object") {
|
|
149
|
+
if (typeof value.name === "string" && value.name.trim().length > 0) {
|
|
150
|
+
return value.name.trim();
|
|
151
|
+
}
|
|
152
|
+
if (typeof value.id === "string" && value.id.trim().length > 0) {
|
|
153
|
+
return value.id.trim();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return "";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extractToolAndPayload(inputData, inputRaw) {
|
|
160
|
+
const root = toObject(inputData) || {};
|
|
161
|
+
const nestedInput = toObject(root.input) || {};
|
|
162
|
+
const nestedTool = toObject(root.tool) || {};
|
|
163
|
+
const nestedInputTool = toObject(nestedInput.tool) || {};
|
|
164
|
+
const candidates = [
|
|
165
|
+
root.tool_name,
|
|
166
|
+
root.tool,
|
|
167
|
+
root.toolName,
|
|
168
|
+
root.name,
|
|
169
|
+
root.id,
|
|
170
|
+
root.command,
|
|
171
|
+
nestedTool.name,
|
|
172
|
+
nestedTool.id,
|
|
173
|
+
nestedInput.tool_name,
|
|
174
|
+
nestedInput.tool,
|
|
175
|
+
nestedInput.toolName,
|
|
176
|
+
nestedInput.name,
|
|
177
|
+
nestedInput.id,
|
|
178
|
+
nestedInput.command,
|
|
179
|
+
nestedInputTool.name,
|
|
180
|
+
nestedInputTool.id
|
|
181
|
+
];
|
|
182
|
+
let tool = "unknown";
|
|
183
|
+
for (const candidate of candidates) {
|
|
184
|
+
const next = normalizeToolName(candidate);
|
|
185
|
+
if (next.length > 0) {
|
|
186
|
+
tool = next;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const payload =
|
|
191
|
+
root.tool_input ??
|
|
192
|
+
root.input ??
|
|
193
|
+
root.arguments ??
|
|
194
|
+
root.params ??
|
|
195
|
+
root.payload ??
|
|
196
|
+
{};
|
|
197
|
+
let payloadText = "";
|
|
198
|
+
try {
|
|
199
|
+
payloadText = JSON.stringify(payload);
|
|
200
|
+
} catch {
|
|
201
|
+
payloadText = "";
|
|
202
|
+
}
|
|
203
|
+
if (payloadText.length === 0) {
|
|
204
|
+
payloadText = typeof inputRaw === "string" ? inputRaw : "";
|
|
205
|
+
}
|
|
206
|
+
return { tool, payload, payloadText };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function collectPaths(value, bucket = new Set()) {
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
for (const item of value) collectPaths(item, bucket);
|
|
212
|
+
return bucket;
|
|
213
|
+
}
|
|
214
|
+
if (!value || typeof value !== "object") {
|
|
215
|
+
return bucket;
|
|
216
|
+
}
|
|
217
|
+
const obj = value;
|
|
218
|
+
for (const key of ["path", "file_path", "filepath"]) {
|
|
219
|
+
const current = obj[key];
|
|
220
|
+
if (typeof current === "string" && current.trim().length > 0) {
|
|
221
|
+
bucket.add(current.trim());
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const child of Object.values(obj)) {
|
|
225
|
+
collectPaths(child, bucket);
|
|
226
|
+
}
|
|
227
|
+
return bucket;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const globRegexCache = new Map();
|
|
231
|
+
|
|
232
|
+
function escapeRegex(value) {
|
|
233
|
+
return value.replace(/[.*+?^\\\${}()|[\\]\\\\]/gu, "\\\\$&");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function globToRegExp(globPattern) {
|
|
237
|
+
const normalized = normalizePathForMatch(globPattern);
|
|
238
|
+
const cached = globRegexCache.get(normalized);
|
|
239
|
+
if (cached) return cached;
|
|
240
|
+
let pattern = normalized;
|
|
241
|
+
pattern = pattern.replace(/\\*\\*\\//gu, "__GLOBSTAR_DIR__");
|
|
242
|
+
pattern = pattern.replace(/\\/\\*\\*/gu, "__DIR_GLOBSTAR__");
|
|
243
|
+
pattern = pattern.replace(/\\*\\*/gu, "__GLOBSTAR__");
|
|
244
|
+
pattern = pattern.replace(/\\*/gu, "__STAR__");
|
|
245
|
+
pattern = escapeRegex(pattern);
|
|
246
|
+
pattern = pattern.replace(/__GLOBSTAR_DIR__/gu, "(?:.*\\\\/)?");
|
|
247
|
+
pattern = pattern.replace(/__DIR_GLOBSTAR__/gu, "\\\\/.*");
|
|
248
|
+
pattern = pattern.replace(/__GLOBSTAR__/gu, ".*");
|
|
249
|
+
pattern = pattern.replace(/__STAR__/gu, "[^\\\\/]*");
|
|
250
|
+
const built = new RegExp("^" + pattern + "$", "u");
|
|
251
|
+
globRegexCache.set(normalized, built);
|
|
252
|
+
return built;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function matchesPathPatterns(rawPath, patterns) {
|
|
256
|
+
if (!Array.isArray(patterns) || patterns.length === 0) return false;
|
|
257
|
+
const normalized = normalizePathForMatch(rawPath);
|
|
258
|
+
for (const pattern of patterns) {
|
|
259
|
+
if (globToRegExp(pattern).test(normalized)) return true;
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isCodeLikePath(rawPath) {
|
|
265
|
+
return /\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)$/u.test(
|
|
266
|
+
normalizePathForMatch(rawPath)
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isMutatingTool(toolLower) {
|
|
271
|
+
return /^(write|edit|multiedit|multi_edit|delete|applypatch|apply_patch)$/u.test(toolLower);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isExecutionOrMutatingTool(toolLower) {
|
|
275
|
+
if (isMutatingTool(toolLower)) return true;
|
|
276
|
+
return /^(shell|bash|runcommand|run_command|execcommand|exec_command|terminal)$/u.test(toolLower);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isPlanModeSafeTool(toolLower) {
|
|
280
|
+
return /^(read|readfile|open|view|cat|head|tail|grep|glob|search|semanticsearch|ripgrep|rg|find|list_directory|ls|askquestion|askuserquestion|ask_question|ask_user_question|question|todowrite|todoread|todo_write|todo_read|webfetch|websearch|web_fetch|web_search|fetchmcpresource|switchmode|switch_mode|task|delegate)$/u.test(
|
|
281
|
+
toolLower
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isCclawCliPayload(payloadLower) {
|
|
286
|
+
return /(cclaw |npx cclaw |\\/cc-|\\/cc[^a-z0-9_-])/u.test(payloadLower);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function stageIndex(stage) {
|
|
290
|
+
const ordered = [
|
|
291
|
+
"brainstorm",
|
|
292
|
+
"scope",
|
|
293
|
+
"design",
|
|
294
|
+
"spec",
|
|
295
|
+
"plan",
|
|
296
|
+
"tdd",
|
|
297
|
+
"review",
|
|
298
|
+
"ship"
|
|
299
|
+
];
|
|
300
|
+
const index = ordered.indexOf(stage);
|
|
301
|
+
return index < 0 ? 0 : index + 1;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function detectTargetStage(payloadLower) {
|
|
305
|
+
for (const stage of [
|
|
306
|
+
"brainstorm",
|
|
307
|
+
"scope",
|
|
308
|
+
"design",
|
|
309
|
+
"spec",
|
|
310
|
+
"plan",
|
|
311
|
+
"tdd",
|
|
312
|
+
"review",
|
|
313
|
+
"ship"
|
|
314
|
+
]) {
|
|
315
|
+
if (new RegExp("(/cc-" + stage + "|cc-" + stage + ")([^a-z0-9_-]|$)", "u").test(payloadLower)) {
|
|
316
|
+
return stage;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return "";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isFlowProgressionCommand(payloadLower) {
|
|
323
|
+
if (/(\\/cc-next|cc-next)([^a-z0-9_-]|$)/u.test(payloadLower)) return true;
|
|
324
|
+
return /\\/cc([^a-z0-9_-]|$)/u.test(payloadLower);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isPreimplementationStage(stage) {
|
|
328
|
+
return ["brainstorm", "scope", "design", "spec", "plan"].includes(stage);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function extractCommandFromPayload(payload) {
|
|
332
|
+
const stack = [payload];
|
|
333
|
+
while (stack.length > 0) {
|
|
334
|
+
const current = stack.shift();
|
|
335
|
+
if (!current || typeof current !== "object") continue;
|
|
336
|
+
if (Array.isArray(current)) {
|
|
337
|
+
for (const item of current) stack.push(item);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
for (const key of ["command", "cmd"]) {
|
|
341
|
+
const value = current[key];
|
|
342
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
343
|
+
return value.trim();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const value of Object.values(current)) {
|
|
347
|
+
stack.push(value);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return "";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function extractExitCodeFromPayload(payload) {
|
|
354
|
+
const stack = [payload];
|
|
355
|
+
while (stack.length > 0) {
|
|
356
|
+
const current = stack.shift();
|
|
357
|
+
if (!current || typeof current !== "object") continue;
|
|
358
|
+
if (Array.isArray(current)) {
|
|
359
|
+
for (const item of current) stack.push(item);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
for (const key of ["exitCode", "exit_code", "code", "status"]) {
|
|
363
|
+
const value = current[key];
|
|
364
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
365
|
+
return Math.trunc(value);
|
|
366
|
+
}
|
|
367
|
+
if (typeof value === "boolean") {
|
|
368
|
+
return value ? 0 : 1;
|
|
369
|
+
}
|
|
370
|
+
if (typeof value === "string" && /^-?[0-9]+$/u.test(value.trim())) {
|
|
371
|
+
return Number(value.trim());
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const value of Object.values(current)) {
|
|
375
|
+
stack.push(value);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function extractRemainingPercent(payload) {
|
|
382
|
+
const readPath = (segments) => {
|
|
383
|
+
let current = payload;
|
|
384
|
+
for (const segment of segments) {
|
|
385
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return null;
|
|
386
|
+
current = current[segment];
|
|
387
|
+
}
|
|
388
|
+
if (typeof current !== "number" || !Number.isFinite(current)) return null;
|
|
389
|
+
return current;
|
|
390
|
+
};
|
|
391
|
+
const candidates = [
|
|
392
|
+
{ path: ["context", "remaining_percent"], invert: false },
|
|
393
|
+
{ path: ["context", "remainingPercent"], invert: false },
|
|
394
|
+
{ path: ["context_usage", "remaining_percent"], invert: false },
|
|
395
|
+
{ path: ["context_usage", "remainingPercent"], invert: false },
|
|
396
|
+
{ path: ["contextUsage", "remainingPercent"], invert: false },
|
|
397
|
+
{ path: ["context_window", "remaining_percent"], invert: false },
|
|
398
|
+
{ path: ["remaining_context_percent"], invert: false },
|
|
399
|
+
{ path: ["remainingContextPercent"], invert: false },
|
|
400
|
+
{ path: ["remaining_context_ratio"], invert: false },
|
|
401
|
+
{ path: ["remainingContextRatio"], invert: false },
|
|
402
|
+
{ path: ["context", "used_percent"], invert: true },
|
|
403
|
+
{ path: ["context", "usedPercent"], invert: true },
|
|
404
|
+
{ path: ["context_usage", "used_percent"], invert: true },
|
|
405
|
+
{ path: ["context_usage", "usedPercent"], invert: true },
|
|
406
|
+
{ path: ["contextUsage", "usedPercent"], invert: true },
|
|
407
|
+
{ path: ["context_window", "used_ratio"], invert: true },
|
|
408
|
+
{ path: ["context_window", "usedRatio"], invert: true }
|
|
409
|
+
];
|
|
410
|
+
for (const candidate of candidates) {
|
|
411
|
+
const value = readPath(candidate.path);
|
|
412
|
+
if (value === null) continue;
|
|
413
|
+
let percent = value <= 1 ? value * 100 : value;
|
|
414
|
+
if (candidate.invert) {
|
|
415
|
+
percent = 100 - percent;
|
|
416
|
+
}
|
|
417
|
+
if (!Number.isFinite(percent)) continue;
|
|
418
|
+
if (percent < 0) percent = 0;
|
|
419
|
+
if (percent > 100) percent = 100;
|
|
420
|
+
return Number(percent.toFixed(2));
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function extractTextBlobs(payload) {
|
|
426
|
+
const stack = [payload];
|
|
427
|
+
const lines = [];
|
|
428
|
+
while (stack.length > 0) {
|
|
429
|
+
const current = stack.shift();
|
|
430
|
+
if (typeof current === "string" && current.length > 0) {
|
|
431
|
+
lines.push(current);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!current || typeof current !== "object") continue;
|
|
435
|
+
if (Array.isArray(current)) {
|
|
436
|
+
for (const item of current) stack.push(item);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
for (const value of Object.values(current)) {
|
|
440
|
+
stack.push(value);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return lines.join("\\n");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function extractCodePathsFromText(value) {
|
|
447
|
+
const pattern =
|
|
448
|
+
/(?:[A-Za-z0-9_.-]+[\\\\/])+[A-Za-z0-9_.-]+\\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)/gu;
|
|
449
|
+
const matches = value.match(pattern) || [];
|
|
450
|
+
const out = [];
|
|
451
|
+
const seen = new Set();
|
|
452
|
+
for (const match of matches) {
|
|
453
|
+
const normalized = match.trim().replace(/^[\\s"']+|[\\s"'.,:;()\\[\\]{}<>]+$/gu, "");
|
|
454
|
+
if (normalized.length === 0) continue;
|
|
455
|
+
const key = normalizePathForMatch(normalized);
|
|
456
|
+
if (seen.has(key)) continue;
|
|
457
|
+
seen.add(key);
|
|
458
|
+
out.push(normalized);
|
|
459
|
+
if (out.length >= 20) break;
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function readFlowState(root) {
|
|
465
|
+
const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
|
|
466
|
+
const parsed = await readJsonFile(statePath, {});
|
|
467
|
+
const obj = toObject(parsed) || {};
|
|
468
|
+
const completed = Array.isArray(obj.completedStages) ? obj.completedStages : [];
|
|
469
|
+
return {
|
|
470
|
+
filePath: statePath,
|
|
471
|
+
currentStage: typeof obj.currentStage === "string" ? obj.currentStage : "none",
|
|
472
|
+
activeRunId: typeof obj.activeRunId === "string" ? obj.activeRunId : "active",
|
|
473
|
+
completedCount: completed.length,
|
|
474
|
+
raw: obj
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function formatCheckpointSummary(checkpointObj) {
|
|
479
|
+
const stage = typeof checkpointObj.stage === "string" ? checkpointObj.stage : "none";
|
|
480
|
+
const status = typeof checkpointObj.status === "string" ? checkpointObj.status : "unknown";
|
|
481
|
+
const runId = typeof checkpointObj.runId === "string" ? checkpointObj.runId : "none";
|
|
482
|
+
const timestamp = typeof checkpointObj.timestamp === "string" ? checkpointObj.timestamp : "unknown";
|
|
483
|
+
return "Checkpoint: stage=" + stage + ", status=" + status + ", run=" + runId + ", at=" + timestamp;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function stageSuggestion(stage) {
|
|
487
|
+
const map = {
|
|
488
|
+
brainstorm:
|
|
489
|
+
"Suggestion: list 2-3 alternatives and ask a single focused clarifying question before direction lock.",
|
|
490
|
+
scope: "Suggestion: lock explicit in-scope/out-of-scope boundaries and choose one scope mode.",
|
|
491
|
+
design:
|
|
492
|
+
"Suggestion: map failure modes per new codepath and confirm architecture boundaries before moving forward.",
|
|
493
|
+
spec: "Suggestion: ensure every acceptance criterion is measurable and mapped to a concrete test.",
|
|
494
|
+
plan: "Suggestion: group tasks into dependency batches and keep WAIT_FOR_CONFIRM pending until approval.",
|
|
495
|
+
tdd: "Suggestion: execute RED -> GREEN -> REFACTOR for each selected slice and capture evidence per cycle.",
|
|
496
|
+
review: "Suggestion: run Layer 1 before Layer 2 and reconcile findings into 07-review-army.json.",
|
|
497
|
+
ship: "Suggestion: verify preflight + rollback plan before selecting exactly one finalization mode."
|
|
498
|
+
};
|
|
499
|
+
return map[stage] || "";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function buildKnowledgeDigest(root, currentStage) {
|
|
503
|
+
const knowledgeFile = path.join(root, RUNTIME_ROOT, "knowledge.jsonl");
|
|
504
|
+
const digestFile = path.join(root, RUNTIME_ROOT, "state", "knowledge-digest.md");
|
|
505
|
+
const raw = await readTextFile(knowledgeFile, "");
|
|
506
|
+
const lines = raw.split(/\\r?\\n/gu).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
507
|
+
let learningsCount = 0;
|
|
508
|
+
const parsedRows = [];
|
|
509
|
+
for (const line of lines) {
|
|
510
|
+
if (line.startsWith("{")) learningsCount += 1;
|
|
511
|
+
try {
|
|
512
|
+
const parsed = JSON.parse(line);
|
|
513
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
514
|
+
parsedRows.push(parsed);
|
|
515
|
+
} catch {
|
|
516
|
+
// ignore malformed knowledge line in digest
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const relevant = parsedRows
|
|
520
|
+
.filter((row) => {
|
|
521
|
+
const stage = typeof row.stage === "string" ? row.stage : null;
|
|
522
|
+
return stage === null || stage === currentStage;
|
|
523
|
+
})
|
|
524
|
+
.slice(-6)
|
|
525
|
+
.reverse()
|
|
526
|
+
.map((row) => {
|
|
527
|
+
const confidence = typeof row.confidence === "string" ? row.confidence : "unknown";
|
|
528
|
+
const stage = typeof row.stage === "string" ? row.stage : "global";
|
|
529
|
+
const domain = typeof row.domain === "string" ? row.domain : "general";
|
|
530
|
+
const trigger = typeof row.trigger === "string" ? row.trigger : "trigger";
|
|
531
|
+
const action = typeof row.action === "string" ? row.action : "action";
|
|
532
|
+
return "- [" + confidence + " • " + stage + " • " + domain + "] " + trigger + " -> " + action;
|
|
533
|
+
});
|
|
534
|
+
const body =
|
|
535
|
+
relevant.length > 0 ? relevant.join("\\n") : "(no matching entries for current stage)";
|
|
536
|
+
await fs.mkdir(path.dirname(digestFile), { recursive: true });
|
|
537
|
+
await fs.writeFile(
|
|
538
|
+
digestFile,
|
|
539
|
+
"# Knowledge digest (auto-generated)\\n\\n" + body + "\\n",
|
|
540
|
+
"utf8"
|
|
541
|
+
);
|
|
542
|
+
return {
|
|
543
|
+
digestLines: relevant,
|
|
544
|
+
learningsCount
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function readRecentActivityLines(activityFile) {
|
|
549
|
+
const raw = await readTextFile(activityFile, "");
|
|
550
|
+
const lines = raw.split(/\\r?\\n/gu).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
551
|
+
const tail = lines.slice(-5);
|
|
552
|
+
const out = [];
|
|
553
|
+
for (const line of tail) {
|
|
554
|
+
try {
|
|
555
|
+
const parsed = JSON.parse(line);
|
|
556
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
557
|
+
out.push(
|
|
558
|
+
"- " +
|
|
559
|
+
(typeof parsed.ts === "string" ? parsed.ts : "unknown") +
|
|
560
|
+
" [" +
|
|
561
|
+
(typeof parsed.phase === "string" ? parsed.phase : "unknown") +
|
|
562
|
+
"] " +
|
|
563
|
+
(typeof parsed.tool === "string" ? parsed.tool : "unknown") +
|
|
564
|
+
" (stage=" +
|
|
565
|
+
(typeof parsed.stage === "string" ? parsed.stage : "unknown") +
|
|
566
|
+
", run=" +
|
|
567
|
+
(typeof parsed.runId === "string" ? parsed.runId : "none") +
|
|
568
|
+
")"
|
|
569
|
+
);
|
|
570
|
+
} catch {
|
|
571
|
+
// ignore malformed activity lines
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function readLatestContextWarningLine(filePath) {
|
|
578
|
+
const raw = await readTextFile(filePath, "");
|
|
579
|
+
const lines = raw.split(/\\r?\\n/gu).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
580
|
+
const line = lines[lines.length - 1] || "";
|
|
581
|
+
if (line.length === 0) return "";
|
|
582
|
+
try {
|
|
583
|
+
const parsed = JSON.parse(line);
|
|
584
|
+
if (parsed && typeof parsed === "object" && typeof parsed.note === "string") {
|
|
585
|
+
return parsed.note;
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
// fallback
|
|
589
|
+
}
|
|
590
|
+
return line;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function handleSessionStart(runtime) {
|
|
594
|
+
const state = await readFlowState(runtime.root);
|
|
595
|
+
const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
|
|
596
|
+
const contextsDir = path.join(runtime.root, RUNTIME_ROOT, "contexts");
|
|
597
|
+
const activeFeatureFile = path.join(stateDir, "active-feature.json");
|
|
598
|
+
const checkpointFile = path.join(stateDir, "checkpoint.json");
|
|
599
|
+
const activityFile = path.join(stateDir, "stage-activity.jsonl");
|
|
600
|
+
const contextWarningsFile = path.join(stateDir, "context-warnings.jsonl");
|
|
601
|
+
const contextModeFile = path.join(stateDir, "context-mode.json");
|
|
602
|
+
const suggestionMemoryFile = path.join(stateDir, "suggestion-memory.json");
|
|
603
|
+
const ironLawsFile = path.join(stateDir, "iron-laws.json");
|
|
604
|
+
const sessionDigestFile = path.join(stateDir, "session-digest.md");
|
|
605
|
+
const metaSkillFile = path.join(runtime.root, RUNTIME_ROOT, "skills", "using-cclaw", "SKILL.md");
|
|
606
|
+
|
|
607
|
+
const activeFeatureObj = toObject(await readJsonFile(activeFeatureFile, {})) || {};
|
|
608
|
+
const activeFeature =
|
|
609
|
+
typeof activeFeatureObj.activeFeature === "string" && activeFeatureObj.activeFeature.length > 0
|
|
610
|
+
? activeFeatureObj.activeFeature
|
|
611
|
+
: "default";
|
|
612
|
+
|
|
613
|
+
const contextModeObj = toObject(await readJsonFile(contextModeFile, {})) || {};
|
|
614
|
+
const activeContextMode =
|
|
615
|
+
typeof contextModeObj.activeMode === "string" && contextModeObj.activeMode.length > 0
|
|
616
|
+
? contextModeObj.activeMode
|
|
617
|
+
: "default";
|
|
618
|
+
const contextGuidePath = path.join(contextsDir, activeContextMode + ".md");
|
|
619
|
+
const contextModeNote = (await fileExists(contextGuidePath))
|
|
620
|
+
? "Context mode: " +
|
|
621
|
+
activeContextMode +
|
|
622
|
+
" (guide: " +
|
|
623
|
+
RUNTIME_ROOT +
|
|
624
|
+
"/contexts/" +
|
|
625
|
+
activeContextMode +
|
|
626
|
+
".md)"
|
|
627
|
+
: "Context mode: " + activeContextMode;
|
|
628
|
+
|
|
629
|
+
const checkpointObj = toObject(await readJsonFile(checkpointFile, {})) || {};
|
|
630
|
+
const checkpointSummary = Object.keys(checkpointObj).length > 0
|
|
631
|
+
? formatCheckpointSummary(checkpointObj)
|
|
632
|
+
: "";
|
|
633
|
+
|
|
634
|
+
const sessionDigest = (await readTextFile(sessionDigestFile, "")).trim();
|
|
635
|
+
const activitySummary = await readRecentActivityLines(activityFile);
|
|
636
|
+
const contextWarning = await readLatestContextWarningLine(contextWarningsFile);
|
|
637
|
+
const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage);
|
|
638
|
+
|
|
639
|
+
const suggestionMemory = toObject(await readJsonFile(suggestionMemoryFile, {})) || {};
|
|
640
|
+
const suggestionsEnabled = suggestionMemory.enabled !== false;
|
|
641
|
+
const mutedStages = Array.isArray(suggestionMemory.mutedStages)
|
|
642
|
+
? suggestionMemory.mutedStages.filter((value) => typeof value === "string")
|
|
643
|
+
: [];
|
|
644
|
+
const stageMuted = mutedStages.includes(state.currentStage);
|
|
645
|
+
let stageHint = "";
|
|
646
|
+
if (suggestionsEnabled && !stageMuted) {
|
|
647
|
+
stageHint = stageSuggestion(state.currentStage);
|
|
648
|
+
if (stageHint.length > 0) {
|
|
649
|
+
const nextSuggestionMemory = {
|
|
650
|
+
enabled: suggestionsEnabled,
|
|
651
|
+
mutedStages,
|
|
652
|
+
lastSuggestedStage: state.currentStage,
|
|
653
|
+
lastSuggestedAt: new Date().toISOString()
|
|
654
|
+
};
|
|
655
|
+
await writeJsonFile(suggestionMemoryFile, nextSuggestionMemory);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const ironLawsObj = toObject(await readJsonFile(ironLawsFile, {})) || {};
|
|
660
|
+
const laws = Array.isArray(ironLawsObj.laws) ? ironLawsObj.laws : [];
|
|
661
|
+
const ironLawLines = laws
|
|
662
|
+
.filter((row) => row && typeof row === "object")
|
|
663
|
+
.slice(0, 6)
|
|
664
|
+
.map((row) => {
|
|
665
|
+
const strict = row.strict === true ? "strict" : "advisory";
|
|
666
|
+
const id = typeof row.id === "string" && row.id.length > 0 ? row.id : "law";
|
|
667
|
+
const rule = typeof row.rule === "string" ? row.rule : "";
|
|
668
|
+
return "- [" + strict + "] " + id + " -> " + rule;
|
|
669
|
+
});
|
|
670
|
+
const staleStages = toObject(state.raw.staleStages) || {};
|
|
671
|
+
const staleStageNames = Object.keys(staleStages);
|
|
672
|
+
const metaContent = (await readTextFile(metaSkillFile, "")).trim();
|
|
673
|
+
|
|
674
|
+
const parts = [
|
|
675
|
+
"cclaw loaded. Flow: stage=" +
|
|
676
|
+
state.currentStage +
|
|
677
|
+
" (" +
|
|
678
|
+
String(state.completedCount) +
|
|
679
|
+
"/8 completed, run=" +
|
|
680
|
+
state.activeRunId +
|
|
681
|
+
", feature=" +
|
|
682
|
+
activeFeature +
|
|
683
|
+
"). Active artifacts: " +
|
|
684
|
+
RUNTIME_ROOT +
|
|
685
|
+
"/artifacts/. Feature registry: " +
|
|
686
|
+
RUNTIME_ROOT +
|
|
687
|
+
"/state/worktrees.json (managed roots: " +
|
|
688
|
+
RUNTIME_ROOT +
|
|
689
|
+
"/worktrees/). Learnings: " +
|
|
690
|
+
String(knowledge.learningsCount) +
|
|
691
|
+
" entries."
|
|
692
|
+
];
|
|
693
|
+
parts.push(contextModeNote);
|
|
694
|
+
if (checkpointSummary.length > 0) {
|
|
695
|
+
parts.push(checkpointSummary);
|
|
696
|
+
}
|
|
697
|
+
if (sessionDigest.length > 0) {
|
|
698
|
+
parts.push("Last session:\\n" + sessionDigest);
|
|
699
|
+
}
|
|
700
|
+
if (activitySummary.length > 0) {
|
|
701
|
+
parts.push("Recent stage activity:\\n" + activitySummary.join("\\n"));
|
|
702
|
+
}
|
|
703
|
+
if (contextWarning.length > 0) {
|
|
704
|
+
parts.push("Latest context warning:\\n" + contextWarning);
|
|
705
|
+
}
|
|
706
|
+
if (stageHint.length > 0) {
|
|
707
|
+
parts.push(
|
|
708
|
+
stageHint +
|
|
709
|
+
"\\nTo disable suggestions persistently set " +
|
|
710
|
+
RUNTIME_ROOT +
|
|
711
|
+
"/state/suggestion-memory.json -> enabled=false."
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
if (staleStageNames.length > 0) {
|
|
715
|
+
parts.push(
|
|
716
|
+
"Stale stages pending acknowledgement: " +
|
|
717
|
+
staleStageNames.join(", ") +
|
|
718
|
+
" (use /cc-ops rewind --ack <stage> after redo)."
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (knowledge.digestLines.length > 0) {
|
|
722
|
+
parts.push(
|
|
723
|
+
"Knowledge digest (top relevant entries):\\n" +
|
|
724
|
+
knowledge.digestLines.join("\\n")
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
if (ironLawLines.length > 0) {
|
|
728
|
+
parts.push("Iron laws (enforced policy highlights):\\n" + ironLawLines.join("\\n"));
|
|
729
|
+
}
|
|
730
|
+
if (metaContent.length > 0) {
|
|
731
|
+
parts.push(metaContent);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const context = parts.join("\\n");
|
|
735
|
+
if (runtime.harness === "claude" || runtime.harness === "codex") {
|
|
736
|
+
runtime.writeJson({
|
|
737
|
+
hookSpecificOutput: {
|
|
738
|
+
hookEventName: "SessionStart",
|
|
739
|
+
additionalContext: context
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
return 0;
|
|
743
|
+
}
|
|
744
|
+
runtime.writeJson({ additional_context: context });
|
|
745
|
+
return 0;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function isGitDirty(root) {
|
|
749
|
+
return await new Promise((resolve) => {
|
|
750
|
+
const child = spawn("git", ["-C", root, "status", "--porcelain"], {
|
|
751
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
752
|
+
});
|
|
753
|
+
let output = "";
|
|
754
|
+
child.stdout.on("data", (chunk) => {
|
|
755
|
+
output += String(chunk);
|
|
756
|
+
});
|
|
757
|
+
child.on("error", () => resolve("unknown"));
|
|
758
|
+
child.on("close", (code) => {
|
|
759
|
+
if (code !== 0) {
|
|
760
|
+
resolve("unknown");
|
|
761
|
+
} else {
|
|
762
|
+
resolve(output.trim().length > 0 ? "dirty" : "clean");
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function stopLawIsStrict(ironLawsObj) {
|
|
769
|
+
if ((ironLawsObj.mode || "advisory") === "strict") return true;
|
|
770
|
+
const laws = Array.isArray(ironLawsObj.laws) ? ironLawsObj.laws : [];
|
|
771
|
+
return laws.some(
|
|
772
|
+
(row) =>
|
|
773
|
+
row &&
|
|
774
|
+
typeof row === "object" &&
|
|
775
|
+
row.id === "stop-clean-or-checkpointed" &&
|
|
776
|
+
row.strict === true
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function handleStopCheckpoint(runtime) {
|
|
781
|
+
const state = await readFlowState(runtime.root);
|
|
782
|
+
const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
|
|
783
|
+
const checkpointFile = path.join(stateDir, "checkpoint.json");
|
|
784
|
+
const ironLawsFile = path.join(stateDir, "iron-laws.json");
|
|
785
|
+
const input = toObject(runtime.inputData) || {};
|
|
786
|
+
const loopCount =
|
|
787
|
+
typeof input.loop_count === "number" && Number.isFinite(input.loop_count)
|
|
788
|
+
? Math.trunc(input.loop_count)
|
|
789
|
+
: 0;
|
|
790
|
+
|
|
791
|
+
const existing = toObject(await readJsonFile(checkpointFile, {})) || {};
|
|
792
|
+
const timestamp = new Date().toISOString();
|
|
793
|
+
const dirtyState = await isGitDirty(runtime.root);
|
|
794
|
+
const nextCheckpoint = {
|
|
795
|
+
...existing,
|
|
796
|
+
stage: state.currentStage,
|
|
797
|
+
runId: state.activeRunId,
|
|
798
|
+
status:
|
|
799
|
+
typeof existing.status === "string" && existing.status.trim().length > 0
|
|
800
|
+
? existing.status
|
|
801
|
+
: "in_progress",
|
|
802
|
+
dirtyState,
|
|
803
|
+
lastCompletedStep:
|
|
804
|
+
typeof existing.lastCompletedStep === "string" ? existing.lastCompletedStep : "",
|
|
805
|
+
remainingSteps: Array.isArray(existing.remainingSteps) ? existing.remainingSteps : [],
|
|
806
|
+
blockers: Array.isArray(existing.blockers) ? existing.blockers : [],
|
|
807
|
+
harness: runtime.harness,
|
|
808
|
+
timestamp
|
|
809
|
+
};
|
|
810
|
+
await writeJsonFile(checkpointFile, nextCheckpoint);
|
|
811
|
+
|
|
812
|
+
const strictStop = stopLawIsStrict(toObject(await readJsonFile(ironLawsFile, {})) || {});
|
|
813
|
+
if (dirtyState === "dirty" && strictStop) {
|
|
814
|
+
process.stderr.write(
|
|
815
|
+
'[cclaw] Stop blocked by iron law "stop-clean-or-checkpointed": working tree is dirty. Commit/revert changes or update checkpoint blockers before ending the session.\\n'
|
|
816
|
+
);
|
|
817
|
+
return 1;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const message =
|
|
821
|
+
"Cclaw: session ending (stage=" +
|
|
822
|
+
state.currentStage +
|
|
823
|
+
", run=" +
|
|
824
|
+
state.activeRunId +
|
|
825
|
+
"). Checkpoint updated at " +
|
|
826
|
+
RUNTIME_ROOT +
|
|
827
|
+
"/state/checkpoint.json. Run metadata sync removed; active artifacts stay in " +
|
|
828
|
+
RUNTIME_ROOT +
|
|
829
|
+
"/artifacts until /cc-ops archive (or cclaw archive runtime). Before stopping: (1) confirm flow-state reflects reality, (2) ensure artifact changes match current feature intent, (3) if you discovered a non-obvious rule/pattern, append one strict-schema JSON line to " +
|
|
830
|
+
RUNTIME_ROOT +
|
|
831
|
+
"/knowledge.jsonl, (4) commit or revert pending changes.";
|
|
832
|
+
|
|
833
|
+
if (runtime.harness === "cursor") {
|
|
834
|
+
if (loopCount === 0) {
|
|
835
|
+
runtime.writeJson({ followup_message: message });
|
|
836
|
+
} else {
|
|
837
|
+
runtime.writeJson({});
|
|
838
|
+
}
|
|
839
|
+
return 0;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
runtime.writeJson({ systemMessage: message });
|
|
843
|
+
return 0;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function handlePreCompact(runtime) {
|
|
847
|
+
const state = await readFlowState(runtime.root);
|
|
848
|
+
const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
|
|
849
|
+
const flow = state.raw;
|
|
850
|
+
const stage = state.currentStage;
|
|
851
|
+
const track = typeof flow.track === "string" ? flow.track : "standard";
|
|
852
|
+
const skipped = Array.isArray(flow.skippedStages)
|
|
853
|
+
? flow.skippedStages.filter((value) => typeof value === "string").join(",")
|
|
854
|
+
: "";
|
|
855
|
+
|
|
856
|
+
const stageGateCatalog = toObject(flow.stageGateCatalog) || {};
|
|
857
|
+
const stageGate = toObject(stageGateCatalog[stage]) || {};
|
|
858
|
+
const passed = Array.isArray(stageGate.passed)
|
|
859
|
+
? stageGate.passed.filter((value) => typeof value === "string").join(",")
|
|
860
|
+
: "";
|
|
861
|
+
const blocked = Array.isArray(stageGate.blocked)
|
|
862
|
+
? stageGate.blocked.filter((value) => typeof value === "string").join(",")
|
|
863
|
+
: "";
|
|
864
|
+
|
|
865
|
+
let delegationPending = "";
|
|
866
|
+
const delegationLog = await readJsonFile(path.join(stateDir, "delegation-log.json"), {});
|
|
867
|
+
const delegationObj = toObject(delegationLog) || {};
|
|
868
|
+
const entries = Array.isArray(delegationObj.entries) ? delegationObj.entries : [];
|
|
869
|
+
const pendingAgents = entries
|
|
870
|
+
.filter((row) => row && typeof row === "object")
|
|
871
|
+
.filter(
|
|
872
|
+
(row) =>
|
|
873
|
+
row.stage === stage &&
|
|
874
|
+
row.status !== "completed" &&
|
|
875
|
+
row.status !== "waived" &&
|
|
876
|
+
typeof row.agent === "string"
|
|
877
|
+
)
|
|
878
|
+
.map((row) => row.agent);
|
|
879
|
+
if (pendingAgents.length > 0) {
|
|
880
|
+
delegationPending = [...new Set(pendingAgents)].join(",");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const knowledgeRaw = await readTextFile(path.join(runtime.root, RUNTIME_ROOT, "knowledge.jsonl"), "");
|
|
884
|
+
const knowledgeTail = knowledgeRaw
|
|
885
|
+
.split(/\\r?\\n/gu)
|
|
886
|
+
.filter((line) => line.trim().length > 0)
|
|
887
|
+
.slice(-12)
|
|
888
|
+
.join("\\n");
|
|
889
|
+
|
|
890
|
+
let gitBranch = "unknown";
|
|
891
|
+
let gitHead = "unknown";
|
|
892
|
+
let gitDirty = "unknown";
|
|
893
|
+
await new Promise((resolve) => {
|
|
894
|
+
const child = spawn("git", ["-C", runtime.root, "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
895
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
896
|
+
});
|
|
897
|
+
let output = "";
|
|
898
|
+
child.stdout.on("data", (chunk) => {
|
|
899
|
+
output += String(chunk);
|
|
900
|
+
});
|
|
901
|
+
child.on("close", (code) => {
|
|
902
|
+
if (code === 0 && output.trim().length > 0) {
|
|
903
|
+
gitBranch = output.trim();
|
|
904
|
+
}
|
|
905
|
+
resolve(undefined);
|
|
906
|
+
});
|
|
907
|
+
child.on("error", () => resolve(undefined));
|
|
908
|
+
});
|
|
909
|
+
await new Promise((resolve) => {
|
|
910
|
+
const child = spawn("git", ["-C", runtime.root, "rev-parse", "--short", "HEAD"], {
|
|
911
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
912
|
+
});
|
|
913
|
+
let output = "";
|
|
914
|
+
child.stdout.on("data", (chunk) => {
|
|
915
|
+
output += String(chunk);
|
|
916
|
+
});
|
|
917
|
+
child.on("close", (code) => {
|
|
918
|
+
if (code === 0 && output.trim().length > 0) {
|
|
919
|
+
gitHead = output.trim();
|
|
920
|
+
}
|
|
921
|
+
resolve(undefined);
|
|
922
|
+
});
|
|
923
|
+
child.on("error", () => resolve(undefined));
|
|
924
|
+
});
|
|
925
|
+
gitDirty = await isGitDirty(runtime.root);
|
|
926
|
+
|
|
927
|
+
const timestamp = new Date().toISOString();
|
|
928
|
+
const digest = [
|
|
929
|
+
"# Session Digest",
|
|
930
|
+
"_Generated by pre-compact hook at " + timestamp + "_",
|
|
931
|
+
"",
|
|
932
|
+
"## Flow snapshot",
|
|
933
|
+
"- track: " + track,
|
|
934
|
+
"- current stage: " + stage,
|
|
935
|
+
"- completed: " + String(state.completedCount) + " stages",
|
|
936
|
+
"- skipped: " + (skipped.length > 0 ? skipped : "(none)"),
|
|
937
|
+
"- run: " + state.activeRunId,
|
|
938
|
+
"",
|
|
939
|
+
"## Gates (current stage)",
|
|
940
|
+
"- passed: " + (passed.length > 0 ? passed : "(none)"),
|
|
941
|
+
"- blocked: " + (blocked.length > 0 ? blocked : "(none)"),
|
|
942
|
+
"",
|
|
943
|
+
"## Outstanding delegations",
|
|
944
|
+
"- pending: " + (delegationPending.length > 0 ? delegationPending : "(none)"),
|
|
945
|
+
"",
|
|
946
|
+
"## Git",
|
|
947
|
+
"- branch: " + gitBranch,
|
|
948
|
+
"- head: " + gitHead,
|
|
949
|
+
"- worktree: " + gitDirty
|
|
950
|
+
];
|
|
951
|
+
if (knowledgeTail.length > 0) {
|
|
952
|
+
digest.push("", "## Knowledge tail", knowledgeTail);
|
|
953
|
+
}
|
|
954
|
+
const digestFile = path.join(stateDir, "session-digest.md");
|
|
955
|
+
await fs.mkdir(path.dirname(digestFile), { recursive: true });
|
|
956
|
+
await fs.writeFile(digestFile, digest.join("\\n") + "\\n", "utf8");
|
|
957
|
+
return 0;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function handlePromptGuard(runtime) {
|
|
961
|
+
const mode = process.env.PROMPT_GUARD_MODE === "strict"
|
|
962
|
+
? "strict"
|
|
963
|
+
: DEFAULT_PROMPT_GUARD_MODE;
|
|
964
|
+
const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
|
|
965
|
+
const guardLog = path.join(stateDir, "prompt-guard.jsonl");
|
|
966
|
+
|
|
967
|
+
const { tool, payloadText } = extractToolAndPayload(runtime.inputData, runtime.inputRaw);
|
|
968
|
+
const toolLower = toLower(tool);
|
|
969
|
+
const payloadLower = toLower(payloadText);
|
|
970
|
+
const reasons = [];
|
|
971
|
+
|
|
972
|
+
if (/^(write|edit|multiedit|multi_edit|delete|applypatch|runcommand|shell|terminal|execcommand)$/u.test(toolLower)) {
|
|
973
|
+
if (/\\.cclaw\\/(state|artifacts|hooks|skills|commands|agents|runs|knowledge)/u.test(payloadLower)) {
|
|
974
|
+
reasons.push("write_to_cclaw_runtime");
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (/(rm\\s+-rf\\s+\\.cclaw|curl\\s+.*https?:\\/\\/|wget\\s+.*https?:\\/\\/|base64\\s+-d|eval\\(|python\\s+-c)/u.test(payloadLower)) {
|
|
978
|
+
reasons.push("suspicious_payload_pattern");
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (reasons.length > 0) {
|
|
982
|
+
const note =
|
|
983
|
+
"Cclaw advisory: potential risky write intent detected for " +
|
|
984
|
+
RUNTIME_ROOT +
|
|
985
|
+
" runtime (" +
|
|
986
|
+
reasons.join(",") +
|
|
987
|
+
"). Prefer installer commands or explicit confirmation before mutating runtime internals.";
|
|
988
|
+
await appendJsonLine(guardLog, {
|
|
989
|
+
ts: new Date().toISOString(),
|
|
990
|
+
harness: runtime.harness,
|
|
991
|
+
tool,
|
|
992
|
+
reasons,
|
|
993
|
+
note
|
|
994
|
+
});
|
|
995
|
+
if (mode === "strict") {
|
|
996
|
+
process.stderr.write("[cclaw] " + note + " (blocked by strict mode)\\n");
|
|
997
|
+
return 1;
|
|
998
|
+
}
|
|
999
|
+
process.stderr.write("[cclaw] " + note + "\\n");
|
|
1000
|
+
}
|
|
1001
|
+
return 0;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function tddCycleCounts(stateDir, runId) {
|
|
1005
|
+
const filePath = path.join(stateDir, "tdd-cycle-log.jsonl");
|
|
1006
|
+
const raw = await readTextFile(filePath, "");
|
|
1007
|
+
const lines = raw.split(/\\r?\\n/gu).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1008
|
+
let red = 0;
|
|
1009
|
+
let green = 0;
|
|
1010
|
+
for (const line of lines) {
|
|
1011
|
+
try {
|
|
1012
|
+
const row = JSON.parse(line);
|
|
1013
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
1014
|
+
const rowRun = typeof row.runId === "string" && row.runId.length > 0 ? row.runId : runId;
|
|
1015
|
+
if (rowRun !== runId) continue;
|
|
1016
|
+
if (row.phase === "red") red += 1;
|
|
1017
|
+
if (row.phase === "green") green += 1;
|
|
1018
|
+
} catch {
|
|
1019
|
+
// ignore malformed rows
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return { red, green };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function tddCycleStateFromCounts(counts) {
|
|
1026
|
+
if (counts.red <= 0) return "need_red";
|
|
1027
|
+
if (counts.red > counts.green) return "red_open";
|
|
1028
|
+
return "green_done";
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function hasFailingRedEvidenceForPath(stateDir, runId, rawPath) {
|
|
1032
|
+
const normalizedTarget = normalizePathForMatch(rawPath);
|
|
1033
|
+
const cycleRaw = await readTextFile(path.join(stateDir, "tdd-cycle-log.jsonl"), "");
|
|
1034
|
+
for (const line of cycleRaw.split(/\\r?\\n/gu)) {
|
|
1035
|
+
const trimmed = line.trim();
|
|
1036
|
+
if (trimmed.length === 0) continue;
|
|
1037
|
+
try {
|
|
1038
|
+
const row = JSON.parse(trimmed);
|
|
1039
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
1040
|
+
const rowRun = typeof row.runId === "string" && row.runId.length > 0 ? row.runId : runId;
|
|
1041
|
+
if (rowRun !== runId) continue;
|
|
1042
|
+
if (row.phase !== "red") continue;
|
|
1043
|
+
const exitCode =
|
|
1044
|
+
typeof row.exitCode === "number" && Number.isFinite(row.exitCode)
|
|
1045
|
+
? Math.trunc(row.exitCode)
|
|
1046
|
+
: null;
|
|
1047
|
+
if (exitCode === 0) continue;
|
|
1048
|
+
const files = Array.isArray(row.files) ? row.files : [];
|
|
1049
|
+
for (const filePath of files) {
|
|
1050
|
+
if (typeof filePath !== "string") continue;
|
|
1051
|
+
if (normalizePathForMatch(filePath) === normalizedTarget) return true;
|
|
1052
|
+
}
|
|
1053
|
+
} catch {
|
|
1054
|
+
// ignore malformed line
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const autoRaw = await readTextFile(path.join(stateDir, "tdd-red-evidence.jsonl"), "");
|
|
1059
|
+
for (const line of autoRaw.split(/\\r?\\n/gu)) {
|
|
1060
|
+
const trimmed = line.trim();
|
|
1061
|
+
if (trimmed.length === 0) continue;
|
|
1062
|
+
try {
|
|
1063
|
+
const row = JSON.parse(trimmed);
|
|
1064
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
1065
|
+
const rowRun = typeof row.runId === "string" && row.runId.length > 0 ? row.runId : runId;
|
|
1066
|
+
if (rowRun !== runId) continue;
|
|
1067
|
+
const exitCode =
|
|
1068
|
+
typeof row.exitCode === "number" && Number.isFinite(row.exitCode)
|
|
1069
|
+
? Math.trunc(row.exitCode)
|
|
1070
|
+
: null;
|
|
1071
|
+
if (exitCode === 0) continue;
|
|
1072
|
+
const paths = Array.isArray(row.paths) ? row.paths : [];
|
|
1073
|
+
for (const filePath of paths) {
|
|
1074
|
+
if (typeof filePath !== "string") continue;
|
|
1075
|
+
if (normalizePathForMatch(filePath) === normalizedTarget) return true;
|
|
1076
|
+
}
|
|
1077
|
+
} catch {
|
|
1078
|
+
// ignore malformed line
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function reviewCoverageComplete(reviewArmy) {
|
|
1085
|
+
const root = toObject(reviewArmy) || {};
|
|
1086
|
+
const reconciliation = toObject(root.reconciliation) || {};
|
|
1087
|
+
const coverage = toObject(reconciliation.layerCoverage) || {};
|
|
1088
|
+
for (const key of [
|
|
1089
|
+
"spec",
|
|
1090
|
+
"correctness",
|
|
1091
|
+
"security",
|
|
1092
|
+
"performance",
|
|
1093
|
+
"architecture",
|
|
1094
|
+
"external-safety"
|
|
1095
|
+
]) {
|
|
1096
|
+
if (coverage[key] !== true) return false;
|
|
1097
|
+
}
|
|
1098
|
+
return true;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function strictLawSet(ironLaws) {
|
|
1102
|
+
const root = toObject(ironLaws) || {};
|
|
1103
|
+
const set = new Set();
|
|
1104
|
+
if ((root.mode || "advisory") === "strict") {
|
|
1105
|
+
set.add("*");
|
|
1106
|
+
}
|
|
1107
|
+
const laws = Array.isArray(root.laws) ? root.laws : [];
|
|
1108
|
+
for (const row of laws) {
|
|
1109
|
+
if (!row || typeof row !== "object") continue;
|
|
1110
|
+
if (row.strict === true && typeof row.id === "string" && row.id.length > 0) {
|
|
1111
|
+
set.add(row.id);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return set;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function lawIsStrict(strictSet, lawId) {
|
|
1118
|
+
return strictSet.has("*") || strictSet.has(lawId);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function isTestPayload(payloadTextLower, payloadPaths, testPatterns) {
|
|
1122
|
+
for (const rawPath of payloadPaths) {
|
|
1123
|
+
if (matchesPathPatterns(rawPath, testPatterns)) return true;
|
|
1124
|
+
}
|
|
1125
|
+
return /(\\/tests?\\/|\\/__tests__\\/|\\.test\\.)/u.test(payloadTextLower);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function isProductionPath(rawPath, testPatterns, productionPatterns) {
|
|
1129
|
+
const normalized = normalizePathForMatch(rawPath);
|
|
1130
|
+
if (normalized.includes("/.cclaw/") || normalized.startsWith(".cclaw/")) return false;
|
|
1131
|
+
if (matchesPathPatterns(normalized, testPatterns)) return false;
|
|
1132
|
+
if (productionPatterns.length > 0) {
|
|
1133
|
+
return matchesPathPatterns(normalized, productionPatterns);
|
|
1134
|
+
}
|
|
1135
|
+
return isCodeLikePath(normalized);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function handleWorkflowGuard(runtime) {
|
|
1139
|
+
const mode = process.env.CCLAW_WORKFLOW_GUARD_MODE === "strict"
|
|
1140
|
+
? "strict"
|
|
1141
|
+
: DEFAULT_WORKFLOW_GUARD_MODE;
|
|
1142
|
+
const maxAgeRaw = process.env.CCLAW_WORKFLOW_GUARD_MAX_AGE_SEC;
|
|
1143
|
+
const maxAgeSec =
|
|
1144
|
+
typeof maxAgeRaw === "string" && /^[0-9]+$/u.test(maxAgeRaw)
|
|
1145
|
+
? Number(maxAgeRaw)
|
|
1146
|
+
: 1800;
|
|
1147
|
+
const tddEnforcement = process.env.TDD_ENFORCEMENT_MODE === "strict"
|
|
1148
|
+
? "strict"
|
|
1149
|
+
: DEFAULT_TDD_ENFORCEMENT_MODE;
|
|
1150
|
+
|
|
1151
|
+
const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
|
|
1152
|
+
const guardStateFile = path.join(stateDir, "workflow-guard.json");
|
|
1153
|
+
const guardLogFile = path.join(stateDir, "workflow-guard.jsonl");
|
|
1154
|
+
const flowState = await readFlowState(runtime.root);
|
|
1155
|
+
const currentStage = flowState.currentStage;
|
|
1156
|
+
const currentRun = flowState.activeRunId || "active";
|
|
1157
|
+
const reviewArmyFile = path.join(runtime.root, RUNTIME_ROOT, "artifacts", "07-review-army.json");
|
|
1158
|
+
const ironLaws = await readJsonFile(path.join(stateDir, "iron-laws.json"), {});
|
|
1159
|
+
const strictLaws = strictLawSet(ironLaws);
|
|
1160
|
+
|
|
1161
|
+
const { tool, payload, payloadText } = extractToolAndPayload(runtime.inputData, runtime.inputRaw);
|
|
1162
|
+
const toolLower = toLower(tool);
|
|
1163
|
+
const payloadLower = toLower(payloadText);
|
|
1164
|
+
const payloadPaths = [...collectPaths(runtime.inputData)].filter((value) => typeof value === "string");
|
|
1165
|
+
const reasons = [];
|
|
1166
|
+
let missingRedPaths = [];
|
|
1167
|
+
|
|
1168
|
+
const targetStage = detectTargetStage(payloadLower);
|
|
1169
|
+
const flowCommandInvoked = isFlowProgressionCommand(payloadLower);
|
|
1170
|
+
|
|
1171
|
+
if (targetStage.length > 0 && currentStage !== "none") {
|
|
1172
|
+
const currentIndex = stageIndex(currentStage);
|
|
1173
|
+
const targetIndex = stageIndex(targetStage);
|
|
1174
|
+
if (currentIndex > 0 && targetIndex > 0 && targetIndex > currentIndex + 1) {
|
|
1175
|
+
reasons.push("stage_jump_" + currentStage + "_to_" + targetStage);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (isMutatingTool(toolLower) && /\\.cclaw\\/state\\/flow-state\\.json/u.test(payloadLower)) {
|
|
1180
|
+
reasons.push("direct_flow_state_edit");
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (isPreimplementationStage(currentStage) && isMutatingTool(toolLower)) {
|
|
1184
|
+
if (!/\\.cclaw\\//u.test(payloadLower)) {
|
|
1185
|
+
reasons.push("implementation_write_before_" + currentStage + "_completion");
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
1190
|
+
const guardState = toObject(await readJsonFile(guardStateFile, {})) || {};
|
|
1191
|
+
const lastFlowReadAtEpoch =
|
|
1192
|
+
typeof guardState.lastFlowReadAtEpoch === "number" && Number.isFinite(guardState.lastFlowReadAtEpoch)
|
|
1193
|
+
? Math.trunc(guardState.lastFlowReadAtEpoch)
|
|
1194
|
+
: 0;
|
|
1195
|
+
const staleFlowRead =
|
|
1196
|
+
lastFlowReadAtEpoch <= 0 || nowEpoch - lastFlowReadAtEpoch > maxAgeSec;
|
|
1197
|
+
|
|
1198
|
+
if (isMutatingTool(toolLower) && staleFlowRead) {
|
|
1199
|
+
reasons.push("mutating_without_recent_flow_read");
|
|
1200
|
+
}
|
|
1201
|
+
if ((targetStage.length > 0 || flowCommandInvoked) && staleFlowRead) {
|
|
1202
|
+
reasons.push("stage_invocation_without_recent_flow_read");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const shouldRecordFlowRead =
|
|
1206
|
+
/^(read|readfile|open|view|cat|shell|runcommand|run_command|execcommand|exec_command|terminal)$/u.test(
|
|
1207
|
+
toolLower
|
|
1208
|
+
) &&
|
|
1209
|
+
/(\\.cclaw\\/state\\/flow-state\\.json|cclaw doctor|cclaw sync)/u.test(payloadLower);
|
|
1210
|
+
if (shouldRecordFlowRead) {
|
|
1211
|
+
await writeJsonFile(guardStateFile, {
|
|
1212
|
+
...guardState,
|
|
1213
|
+
lastFlowReadAt: new Date().toISOString(),
|
|
1214
|
+
lastFlowReadAtEpoch: nowEpoch
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const testPatterns = DEFAULT_TDD_TEST_PATH_PATTERNS;
|
|
1219
|
+
const productionPatterns = DEFAULT_TDD_PRODUCTION_PATH_PATTERNS;
|
|
1220
|
+
|
|
1221
|
+
if (currentStage === "tdd" && isMutatingTool(toolLower)) {
|
|
1222
|
+
const productionPaths = payloadPaths.filter((rawPath) =>
|
|
1223
|
+
isProductionPath(rawPath, testPatterns, productionPatterns)
|
|
1224
|
+
);
|
|
1225
|
+
if (productionPaths.length > 0) {
|
|
1226
|
+
for (const productionPath of productionPaths) {
|
|
1227
|
+
const hasRed = await hasFailingRedEvidenceForPath(stateDir, currentRun, productionPath);
|
|
1228
|
+
if (!hasRed) {
|
|
1229
|
+
missingRedPaths.push(productionPath);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (missingRedPaths.length > 0) {
|
|
1233
|
+
reasons.push("tdd_write_without_red_for_path");
|
|
1234
|
+
}
|
|
1235
|
+
} else if (productionPatterns.length === 0 && !isTestPayload(payloadLower, payloadPaths, testPatterns)) {
|
|
1236
|
+
const counts = await tddCycleCounts(stateDir, currentRun);
|
|
1237
|
+
const cycleState = tddCycleStateFromCounts(counts);
|
|
1238
|
+
if (cycleState === "need_red") {
|
|
1239
|
+
reasons.push("tdd_write_without_open_red");
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (isPreimplementationStage(currentStage) && !isPlanModeSafeTool(toolLower)) {
|
|
1245
|
+
if (!isMutatingTool(toolLower) && !/\\.cclaw\\//u.test(payloadLower) && !isCclawCliPayload(payloadLower)) {
|
|
1246
|
+
reasons.push("non_safe_tool_in_plan_stage_" + currentStage);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (currentStage === "ship" && isExecutionOrMutatingTool(toolLower)) {
|
|
1251
|
+
if (/(npm publish|pnpm publish|yarn publish|gh release create|git push\\s+.*--tags|npm version)/u.test(payloadLower)) {
|
|
1252
|
+
const shipGate = toObject((toObject(flowState.raw.stageGateCatalog) || {}).ship) || {};
|
|
1253
|
+
const passed = Array.isArray(shipGate.passed) ? shipGate.passed : [];
|
|
1254
|
+
if (!passed.includes("ship_preflight_passed")) {
|
|
1255
|
+
reasons.push("ship_preflight_required");
|
|
1256
|
+
}
|
|
1257
|
+
const reviewArmy = await readJsonFile(reviewArmyFile, {});
|
|
1258
|
+
if (!reviewCoverageComplete(reviewArmy)) {
|
|
1259
|
+
reasons.push("ship_review_coverage_required");
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (isMutatingTool(toolLower) && /\\.cclaw\\/(state|hooks|skills)/u.test(payloadLower)) {
|
|
1265
|
+
if (!isCclawCliPayload(payloadLower)) {
|
|
1266
|
+
reasons.push("runtime_write_requires_managed_only");
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (reasons.length > 0) {
|
|
1271
|
+
let note =
|
|
1272
|
+
"Cclaw workflow guard: detected potential flow violation (" +
|
|
1273
|
+
reasons.join(",") +
|
|
1274
|
+
"). Re-read " +
|
|
1275
|
+
RUNTIME_ROOT +
|
|
1276
|
+
"/state/flow-state.json and align with stage constraints.";
|
|
1277
|
+
if (reasons.includes("tdd_write_without_red_for_path")) {
|
|
1278
|
+
note =
|
|
1279
|
+
"Cclaw workflow guard: missing failing RED evidence for production path(s): " +
|
|
1280
|
+
(missingRedPaths.length > 0 ? missingRedPaths.join(", ") : "unknown") +
|
|
1281
|
+
". Log failing tests before touching these files.";
|
|
1282
|
+
} else if (reasons.includes("tdd_write_without_open_red")) {
|
|
1283
|
+
note =
|
|
1284
|
+
"Cclaw workflow guard: Write a failing test first before editing production files during tdd stage.";
|
|
1285
|
+
} else if (reasons.includes("ship_preflight_required")) {
|
|
1286
|
+
note =
|
|
1287
|
+
"Cclaw workflow guard: ship finalization command detected before ship_preflight_passed gate.";
|
|
1288
|
+
} else if (reasons.includes("ship_review_coverage_required")) {
|
|
1289
|
+
note =
|
|
1290
|
+
"Cclaw workflow guard: ship finalization requires complete review layer coverage in 07-review-army.json.";
|
|
1291
|
+
} else if (reasons.includes("mutating_without_recent_flow_read")) {
|
|
1292
|
+
note =
|
|
1293
|
+
"Cclaw workflow guard: mutating action requires a fresh read of " +
|
|
1294
|
+
RUNTIME_ROOT +
|
|
1295
|
+
"/state/flow-state.json before edits.";
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
await appendJsonLine(guardLogFile, {
|
|
1299
|
+
ts: new Date().toISOString(),
|
|
1300
|
+
tool,
|
|
1301
|
+
currentStage,
|
|
1302
|
+
targetStage,
|
|
1303
|
+
reasons,
|
|
1304
|
+
note
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
let shouldBlock = false;
|
|
1308
|
+
if (mode === "strict") shouldBlock = true;
|
|
1309
|
+
if (
|
|
1310
|
+
(reasons.includes("tdd_write_without_open_red") || reasons.includes("tdd_write_without_red_for_path")) &&
|
|
1311
|
+
tddEnforcement === "strict"
|
|
1312
|
+
) {
|
|
1313
|
+
shouldBlock = true;
|
|
1314
|
+
}
|
|
1315
|
+
if (
|
|
1316
|
+
(reasons.includes("tdd_write_without_open_red") || reasons.includes("tdd_write_without_red_for_path")) &&
|
|
1317
|
+
lawIsStrict(strictLaws, "tdd-red-before-write")
|
|
1318
|
+
) {
|
|
1319
|
+
shouldBlock = true;
|
|
1320
|
+
}
|
|
1321
|
+
if (reasons.includes("ship_preflight_required") && lawIsStrict(strictLaws, "ship-preflight-required")) {
|
|
1322
|
+
shouldBlock = true;
|
|
1323
|
+
}
|
|
1324
|
+
if (
|
|
1325
|
+
reasons.includes("ship_review_coverage_required") &&
|
|
1326
|
+
lawIsStrict(strictLaws, "review-coverage-complete-before-ship")
|
|
1327
|
+
) {
|
|
1328
|
+
shouldBlock = true;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (shouldBlock) {
|
|
1332
|
+
process.stderr.write("[cclaw] " + note + " (blocked by workflow guard)\\n");
|
|
1333
|
+
return 1;
|
|
1334
|
+
}
|
|
1335
|
+
process.stderr.write("[cclaw] " + note + "\\n");
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
return 0;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
async function handleContextMonitor(runtime) {
|
|
1342
|
+
const stateDir = path.join(runtime.root, RUNTIME_ROOT, "state");
|
|
1343
|
+
const monitorStateFile = path.join(stateDir, "context-monitor.json");
|
|
1344
|
+
const warningsFile = path.join(stateDir, "context-warnings.jsonl");
|
|
1345
|
+
const autoEvidenceFile = path.join(stateDir, "tdd-red-evidence.jsonl");
|
|
1346
|
+
const flowState = await readFlowState(runtime.root);
|
|
1347
|
+
|
|
1348
|
+
const command = extractCommandFromPayload(runtime.inputData);
|
|
1349
|
+
const exitCode = extractExitCodeFromPayload(runtime.inputData);
|
|
1350
|
+
const commandLower = toLower(command);
|
|
1351
|
+
if (
|
|
1352
|
+
flowState.currentStage === "tdd" &&
|
|
1353
|
+
command.length > 0 &&
|
|
1354
|
+
exitCode !== null &&
|
|
1355
|
+
exitCode !== 0 &&
|
|
1356
|
+
/(npm test|npm run test|pnpm test|pnpm run test|yarn test|bun test|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|dotnet test)/u.test(
|
|
1357
|
+
commandLower
|
|
1358
|
+
)
|
|
1359
|
+
) {
|
|
1360
|
+
const textBlob = extractTextBlobs(runtime.inputData) + "\\n" + command;
|
|
1361
|
+
const paths = extractCodePathsFromText(textBlob);
|
|
1362
|
+
await appendJsonLine(autoEvidenceFile, {
|
|
1363
|
+
ts: new Date().toISOString(),
|
|
1364
|
+
runId: flowState.activeRunId || "active",
|
|
1365
|
+
stage: "tdd",
|
|
1366
|
+
source: "posttool-auto",
|
|
1367
|
+
command,
|
|
1368
|
+
tool: normalizeToolName(
|
|
1369
|
+
(toObject(runtime.inputData) || {}).tool_name ??
|
|
1370
|
+
(toObject(runtime.inputData) || {}).tool ??
|
|
1371
|
+
(toObject(toObject(runtime.inputData)?.input) || {}).tool ??
|
|
1372
|
+
""
|
|
1373
|
+
),
|
|
1374
|
+
exitCode,
|
|
1375
|
+
paths
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const remainingPercent = extractRemainingPercent(runtime.inputData);
|
|
1380
|
+
if (remainingPercent === null) return 0;
|
|
1381
|
+
|
|
1382
|
+
let band = "none";
|
|
1383
|
+
if (remainingPercent <= 20) {
|
|
1384
|
+
band = "critical";
|
|
1385
|
+
} else if (remainingPercent <= 35) {
|
|
1386
|
+
band = "warning";
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const ttlRaw = process.env.CCLAW_CONTEXT_MONITOR_TTL_SEC;
|
|
1390
|
+
const ttlSeconds =
|
|
1391
|
+
typeof ttlRaw === "string" && /^[0-9]+$/u.test(ttlRaw) ? Number(ttlRaw) : 900;
|
|
1392
|
+
const now = new Date();
|
|
1393
|
+
const nowEpoch = Math.floor(now.getTime() / 1000);
|
|
1394
|
+
const monitorState = toObject(await readJsonFile(monitorStateFile, {})) || {};
|
|
1395
|
+
const lastBand = typeof monitorState.lastBand === "string" ? monitorState.lastBand : "none";
|
|
1396
|
+
const lastAdvisoryBand =
|
|
1397
|
+
typeof monitorState.lastAdvisoryBand === "string"
|
|
1398
|
+
? monitorState.lastAdvisoryBand
|
|
1399
|
+
: lastBand;
|
|
1400
|
+
const lastAdvisoryAt =
|
|
1401
|
+
typeof monitorState.lastAdvisoryAt === "string" ? monitorState.lastAdvisoryAt : "";
|
|
1402
|
+
const lastAdvisoryEpoch = lastAdvisoryAt.length > 0
|
|
1403
|
+
? Math.floor(Date.parse(lastAdvisoryAt) / 1000) || 0
|
|
1404
|
+
: 0;
|
|
1405
|
+
|
|
1406
|
+
let shouldEmit = false;
|
|
1407
|
+
if (band !== "none") {
|
|
1408
|
+
if (band !== lastAdvisoryBand) {
|
|
1409
|
+
shouldEmit = true;
|
|
1410
|
+
} else if (ttlSeconds === 0) {
|
|
1411
|
+
shouldEmit = true;
|
|
1412
|
+
} else if (nowEpoch - lastAdvisoryEpoch >= ttlSeconds) {
|
|
1413
|
+
shouldEmit = true;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
let nextAdvisoryBand = lastAdvisoryBand;
|
|
1418
|
+
let nextAdvisoryAt = lastAdvisoryAt;
|
|
1419
|
+
if (shouldEmit) {
|
|
1420
|
+
const note =
|
|
1421
|
+
"Cclaw advisory: context remaining is " +
|
|
1422
|
+
String(remainingPercent.toFixed(2)) +
|
|
1423
|
+
"% (" +
|
|
1424
|
+
band +
|
|
1425
|
+
"). Consider checkpointing or compacting soon.";
|
|
1426
|
+
await appendJsonLine(warningsFile, {
|
|
1427
|
+
ts: now.toISOString(),
|
|
1428
|
+
harness: runtime.harness,
|
|
1429
|
+
band,
|
|
1430
|
+
remainingPercent,
|
|
1431
|
+
note
|
|
1432
|
+
});
|
|
1433
|
+
process.stderr.write("[cclaw] " + note + "\\n");
|
|
1434
|
+
nextAdvisoryBand = band;
|
|
1435
|
+
nextAdvisoryAt = now.toISOString();
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
await writeJsonFile(monitorStateFile, {
|
|
1439
|
+
lastUpdated: now.toISOString(),
|
|
1440
|
+
lastBand: band,
|
|
1441
|
+
lastRemainingPercent: remainingPercent,
|
|
1442
|
+
harness: runtime.harness,
|
|
1443
|
+
lastAdvisoryBand: nextAdvisoryBand,
|
|
1444
|
+
lastAdvisoryAt: nextAdvisoryAt
|
|
1445
|
+
});
|
|
1446
|
+
return 0;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function normalizeHookName(rawName) {
|
|
1450
|
+
const value = normalizeText(rawName).toLowerCase();
|
|
1451
|
+
if (value === "session-start" || value === "session-start.sh") return "session-start";
|
|
1452
|
+
if (value === "stop-checkpoint" || value === "stop-checkpoint.sh") return "stop-checkpoint";
|
|
1453
|
+
if (value === "pre-compact" || value === "pre-compact.sh") return "pre-compact";
|
|
1454
|
+
if (value === "prompt-guard" || value === "prompt-guard.sh") return "prompt-guard";
|
|
1455
|
+
if (value === "workflow-guard" || value === "workflow-guard.sh") return "workflow-guard";
|
|
1456
|
+
if (value === "context-monitor" || value === "context-monitor.sh") return "context-monitor";
|
|
1457
|
+
return "";
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
async function main() {
|
|
1461
|
+
const hookName = normalizeHookName(process.argv[2] || "");
|
|
1462
|
+
if (!hookName) {
|
|
1463
|
+
process.stderr.write(
|
|
1464
|
+
"[cclaw] run-hook: usage: node " +
|
|
1465
|
+
RUNTIME_ROOT +
|
|
1466
|
+
"/hooks/run-hook.mjs <session-start|stop-checkpoint|pre-compact|prompt-guard|workflow-guard|context-monitor>\\n"
|
|
1467
|
+
);
|
|
1468
|
+
process.exitCode = 1;
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const harness = detectHarness(process.env);
|
|
1473
|
+
const root = await detectRoot(process.env);
|
|
1474
|
+
const inputRaw = await readStdin();
|
|
1475
|
+
const inputData = safeParseJson(inputRaw, {});
|
|
1476
|
+
const runtime = {
|
|
1477
|
+
harness,
|
|
1478
|
+
root,
|
|
1479
|
+
inputRaw,
|
|
1480
|
+
inputData,
|
|
1481
|
+
writeJson(value) {
|
|
1482
|
+
process.stdout.write(JSON.stringify(value) + "\\n");
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
try {
|
|
1487
|
+
if (hookName === "session-start") {
|
|
1488
|
+
process.exitCode = await handleSessionStart(runtime);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (hookName === "stop-checkpoint") {
|
|
1492
|
+
process.exitCode = await handleStopCheckpoint(runtime);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (hookName === "pre-compact") {
|
|
1496
|
+
process.exitCode = await handlePreCompact(runtime);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
if (hookName === "prompt-guard") {
|
|
1500
|
+
process.exitCode = await handlePromptGuard(runtime);
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (hookName === "workflow-guard") {
|
|
1504
|
+
process.exitCode = await handleWorkflowGuard(runtime);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (hookName === "context-monitor") {
|
|
1508
|
+
process.exitCode = await handleContextMonitor(runtime);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
process.stderr.write("[cclaw] run-hook: unsupported hook " + hookName + "\\n");
|
|
1512
|
+
process.exitCode = 1;
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
process.stderr.write(
|
|
1515
|
+
"[cclaw] run-hook: " +
|
|
1516
|
+
hookName +
|
|
1517
|
+
" failed: " +
|
|
1518
|
+
(error instanceof Error ? error.message : String(error)) +
|
|
1519
|
+
"\\n"
|
|
1520
|
+
);
|
|
1521
|
+
process.exitCode = 1;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
void main();
|
|
1526
|
+
`;
|
|
1527
|
+
}
|