cclaw-cli 0.48.21 → 0.48.23
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/node-hooks.d.ts +7 -0
- package/dist/content/node-hooks.js +132 -16
- package/dist/fs-utils.js +13 -1
- package/dist/install.js +2 -1
- package/dist/internal/compound-readiness.d.ts +8 -0
- package/dist/internal/compound-readiness.js +44 -3
- package/dist/knowledge-store.d.ts +41 -2
- package/dist/knowledge-store.js +31 -2
- package/package.json +1 -1
|
@@ -8,6 +8,13 @@ export interface NodeHookRuntimeOptions {
|
|
|
8
8
|
strictness?: "advisory" | "strict";
|
|
9
9
|
tddTestPathPatterns?: string[];
|
|
10
10
|
tddProductionPathPatterns?: string[];
|
|
11
|
+
/**
|
|
12
|
+
* Baked-in default recurrence threshold for compound-readiness computed
|
|
13
|
+
* by the session-start hook. Derived from
|
|
14
|
+
* `config.compound.recurrenceThreshold` at install time; re-run
|
|
15
|
+
* `cclaw sync` after changing the config value so hook and CLI agree.
|
|
16
|
+
*/
|
|
17
|
+
compoundRecurrenceThreshold?: number;
|
|
11
18
|
}
|
|
12
19
|
/**
|
|
13
20
|
* Node-only hook runtime (single entrypoint).
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { DEFAULT_COMPOUND_RECURRENCE_THRESHOLD } from "../config.js";
|
|
1
2
|
import { RUNTIME_ROOT } from "../constants.js";
|
|
3
|
+
import { SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD, SMALL_PROJECT_RECURRENCE_THRESHOLD } from "../knowledge-store.js";
|
|
2
4
|
function normalizePatterns(patterns, fallback) {
|
|
3
5
|
if (!patterns || patterns.length === 0)
|
|
4
6
|
return [...fallback];
|
|
@@ -18,6 +20,11 @@ export function nodeHookRuntimeScript(options = {}) {
|
|
|
18
20
|
"**/__tests__/**"
|
|
19
21
|
]);
|
|
20
22
|
const tddProductionPathPatterns = normalizePatterns(options.tddProductionPathPatterns, []);
|
|
23
|
+
const compoundRecurrenceThreshold = typeof options.compoundRecurrenceThreshold === "number" &&
|
|
24
|
+
Number.isInteger(options.compoundRecurrenceThreshold) &&
|
|
25
|
+
options.compoundRecurrenceThreshold >= 1
|
|
26
|
+
? options.compoundRecurrenceThreshold
|
|
27
|
+
: DEFAULT_COMPOUND_RECURRENCE_THRESHOLD;
|
|
21
28
|
return `#!/usr/bin/env node
|
|
22
29
|
import fs from "node:fs/promises";
|
|
23
30
|
import path from "node:path";
|
|
@@ -31,6 +38,14 @@ const RUNTIME_ROOT = ${JSON.stringify(RUNTIME_ROOT)};
|
|
|
31
38
|
const DEFAULT_STRICTNESS = ${JSON.stringify(strictness)};
|
|
32
39
|
const DEFAULT_TDD_TEST_PATH_PATTERNS = ${JSON.stringify(tddTestPathPatterns)};
|
|
33
40
|
const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS = ${JSON.stringify(tddProductionPathPatterns)};
|
|
41
|
+
// Compound-readiness recurrence threshold. Baked from
|
|
42
|
+
// \`config.compound.recurrenceThreshold\` at install time so the hook and
|
|
43
|
+
// \`cclaw internal compound-readiness\` agree on the same number. The
|
|
44
|
+
// small-project relaxation rule (<${SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD} archived runs
|
|
45
|
+
// -> min(base, ${SMALL_PROJECT_RECURRENCE_THRESHOLD})) is applied at runtime.
|
|
46
|
+
const COMPOUND_RECURRENCE_THRESHOLD = ${JSON.stringify(compoundRecurrenceThreshold)};
|
|
47
|
+
const SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD = ${JSON.stringify(SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD)};
|
|
48
|
+
const SMALL_PROJECT_RECURRENCE_THRESHOLD = ${JSON.stringify(SMALL_PROJECT_RECURRENCE_THRESHOLD)};
|
|
34
49
|
|
|
35
50
|
function resolveStrictness() {
|
|
36
51
|
return process.env.CCLAW_STRICTNESS === "strict" ? "strict" : DEFAULT_STRICTNESS;
|
|
@@ -88,11 +103,20 @@ async function withDirectoryLockInline(lockPath, fn, options = {}) {
|
|
|
88
103
|
}
|
|
89
104
|
try {
|
|
90
105
|
const stat = await fs.stat(lockPath);
|
|
106
|
+
if (!stat.isDirectory()) {
|
|
107
|
+
throw new Error("Lock path exists but is not a directory: " + lockPath);
|
|
108
|
+
}
|
|
91
109
|
if (Date.now() - stat.mtimeMs > staleAfterMs) {
|
|
92
110
|
await fs.rm(lockPath, { recursive: true, force: true });
|
|
93
111
|
continue;
|
|
94
112
|
}
|
|
95
|
-
} catch {
|
|
113
|
+
} catch (statError) {
|
|
114
|
+
if (
|
|
115
|
+
statError instanceof Error &&
|
|
116
|
+
statError.message.startsWith("Lock path exists but is not a directory")
|
|
117
|
+
) {
|
|
118
|
+
throw statError;
|
|
119
|
+
}
|
|
96
120
|
// lock vanished between retries
|
|
97
121
|
}
|
|
98
122
|
await hookSleep(retryDelayMs);
|
|
@@ -239,6 +263,24 @@ async function readTextFile(filePath, fallback = "") {
|
|
|
239
263
|
}
|
|
240
264
|
}
|
|
241
265
|
|
|
266
|
+
// CLI-compatible knowledge lock. Must match
|
|
267
|
+
// src/knowledge-store.ts::knowledgeLockPath exactly so the hook and the
|
|
268
|
+
// CLI serialize on the same mutex when reading / appending
|
|
269
|
+
// knowledge.jsonl. Drift here re-introduces the race we just closed.
|
|
270
|
+
function knowledgeLockPathInline(root) {
|
|
271
|
+
return path.join(root, RUNTIME_ROOT, "state", ".knowledge.lock");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function readTextFileLocked(lockPath, filePath, fallback = "") {
|
|
275
|
+
return withDirectoryLockInline(lockPath, async () => {
|
|
276
|
+
try {
|
|
277
|
+
return await fs.readFile(filePath, "utf8");
|
|
278
|
+
} catch {
|
|
279
|
+
return fallback;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
242
284
|
async function appendJsonLine(filePath, value) {
|
|
243
285
|
const payload = JSON.stringify(value) + "\\n";
|
|
244
286
|
await withDirectoryLockInline(lockPathFor(filePath), async () => {
|
|
@@ -752,10 +794,14 @@ function stageSuggestion(stage) {
|
|
|
752
794
|
return map[stage] || "";
|
|
753
795
|
}
|
|
754
796
|
|
|
755
|
-
async function buildKnowledgeDigest(root, currentStage) {
|
|
797
|
+
async function buildKnowledgeDigest(root, currentStage, prereadRaw) {
|
|
756
798
|
const knowledgeFile = path.join(root, RUNTIME_ROOT, "knowledge.jsonl");
|
|
757
799
|
const digestFile = path.join(root, RUNTIME_ROOT, "state", "knowledge-digest.md");
|
|
758
|
-
|
|
800
|
+
// Caller may supply pre-read raw bytes to avoid re-reading knowledge.jsonl.
|
|
801
|
+
// Falls back to a local read if nothing is passed in.
|
|
802
|
+
const raw = typeof prereadRaw === "string"
|
|
803
|
+
? prereadRaw
|
|
804
|
+
: await readTextFile(knowledgeFile, "");
|
|
759
805
|
const lines = raw.split(/\\r?\\n/gu).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
760
806
|
let learningsCount = 0;
|
|
761
807
|
const parsedRows = [];
|
|
@@ -885,7 +931,17 @@ async function handleSessionStart(runtime) {
|
|
|
885
931
|
const sessionDigest = (await readTextFile(sessionDigestFile, "")).trim();
|
|
886
932
|
const activitySummary = await readRecentActivityLines(activityFile);
|
|
887
933
|
const contextWarning = await readLatestContextWarningLine(contextWarningsFile);
|
|
888
|
-
|
|
934
|
+
// Read knowledge.jsonl exactly once per session-start while holding the
|
|
935
|
+
// SAME lock CLI writers acquire in \`appendKnowledge\`. Guarantees we never
|
|
936
|
+
// see a partial (mid-write) snapshot. Both the digest and
|
|
937
|
+
// compound-readiness derive from this single read.
|
|
938
|
+
const knowledgeFilePath = path.join(runtime.root, RUNTIME_ROOT, "knowledge.jsonl");
|
|
939
|
+
const knowledgeRaw = await readTextFileLocked(
|
|
940
|
+
knowledgeLockPathInline(runtime.root),
|
|
941
|
+
knowledgeFilePath,
|
|
942
|
+
""
|
|
943
|
+
);
|
|
944
|
+
const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage, knowledgeRaw);
|
|
889
945
|
|
|
890
946
|
// Refresh Ralph Loop status each session-start so /cc-next and the model
|
|
891
947
|
// both read a consistent "iter=N, acClosed=[...]" snapshot. Runs only when
|
|
@@ -902,8 +958,16 @@ async function handleSessionStart(runtime) {
|
|
|
902
958
|
", slices=" + String(ralphStatus.sliceCount) +
|
|
903
959
|
", acClosed=" + String(ralphStatus.acClosed.length) +
|
|
904
960
|
", redOpen=" + redOpen;
|
|
905
|
-
} catch (
|
|
906
|
-
//
|
|
961
|
+
} catch (err) {
|
|
962
|
+
// Best-effort — a malformed cycle log should never break
|
|
963
|
+
// session-start. But we DO leave a breadcrumb in
|
|
964
|
+
// hook-errors.jsonl so \`cclaw doctor\` can surface chronic
|
|
965
|
+
// failures (previously this was a silent swallow).
|
|
966
|
+
await recordHookError(
|
|
967
|
+
runtime.root,
|
|
968
|
+
"session-start:ralph-loop",
|
|
969
|
+
err instanceof Error ? err.message : String(err)
|
|
970
|
+
);
|
|
907
971
|
}
|
|
908
972
|
}
|
|
909
973
|
|
|
@@ -912,7 +976,11 @@ async function handleSessionStart(runtime) {
|
|
|
912
976
|
// where lifting becomes relevant; earlier stages update the file silently.
|
|
913
977
|
let compoundReadinessLine = "";
|
|
914
978
|
try {
|
|
915
|
-
const
|
|
979
|
+
const archivedRunsCount = await countArchivedRunsInline(runtime.root);
|
|
980
|
+
const readiness = await computeCompoundReadinessInline(runtime.root, {
|
|
981
|
+
prereadRaw: knowledgeRaw,
|
|
982
|
+
...(typeof archivedRunsCount === "number" ? { archivedRunsCount } : {})
|
|
983
|
+
});
|
|
916
984
|
await writeJsonFile(path.join(stateDir, "compound-readiness.json"), readiness);
|
|
917
985
|
if (state.currentStage === "review" || state.currentStage === "ship") {
|
|
918
986
|
if (readiness.readyCount === 0) {
|
|
@@ -925,8 +993,16 @@ async function handleSessionStart(runtime) {
|
|
|
925
993
|
", ready=" + String(readiness.readyCount) + criticalSuffix;
|
|
926
994
|
}
|
|
927
995
|
}
|
|
928
|
-
} catch (
|
|
929
|
-
//
|
|
996
|
+
} catch (err) {
|
|
997
|
+
// Best-effort — a malformed knowledge.jsonl must never break
|
|
998
|
+
// session-start. But we DO leave a breadcrumb in
|
|
999
|
+
// hook-errors.jsonl so config/IO problems become visible in
|
|
1000
|
+
// \`cclaw doctor\` instead of silently degrading readiness output.
|
|
1001
|
+
await recordHookError(
|
|
1002
|
+
runtime.root,
|
|
1003
|
+
"session-start:compound-readiness",
|
|
1004
|
+
err instanceof Error ? err.message : String(err)
|
|
1005
|
+
);
|
|
930
1006
|
}
|
|
931
1007
|
|
|
932
1008
|
const suggestionMemory = toObject(await readJsonFile(suggestionMemoryFile, {})) || {};
|
|
@@ -1297,16 +1373,53 @@ async function handlePromptGuard(runtime) {
|
|
|
1297
1373
|
return 0;
|
|
1298
1374
|
}
|
|
1299
1375
|
|
|
1376
|
+
function normalizeCompoundLastUpdatedAt(date) {
|
|
1377
|
+
return date.toISOString().replace(/\\.\\d{3}Z$/u, "Z");
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Count archived runs as sub-directories under \`.cclaw/runs/\`. Missing
|
|
1381
|
+
// dir returns 0; unexpected errors return undefined so the caller can
|
|
1382
|
+
// skip the small-project relaxation rather than guess.
|
|
1383
|
+
async function countArchivedRunsInline(root) {
|
|
1384
|
+
const dir = path.join(root, RUNTIME_ROOT, "runs");
|
|
1385
|
+
try {
|
|
1386
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1387
|
+
return entries.filter((entry) => entry.isDirectory()).length;
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : null;
|
|
1390
|
+
if (code === "ENOENT") return 0;
|
|
1391
|
+
return undefined;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1300
1395
|
// Mirrors src/knowledge-store.ts::computeCompoundReadiness — kept inline so
|
|
1301
1396
|
// SessionStart can refresh compound-readiness.json without the CLI binary.
|
|
1302
1397
|
// Any schema change must update src/knowledge-store.ts::computeCompoundReadiness
|
|
1303
|
-
// and src/internal/compound-readiness.ts in lockstep.
|
|
1398
|
+
// and src/internal/compound-readiness.ts in lockstep. Parity is enforced by
|
|
1399
|
+
// tests/unit/ralph-loop-parity.test.ts.
|
|
1304
1400
|
async function computeCompoundReadinessInline(root, options) {
|
|
1305
1401
|
const filePath = path.join(root, RUNTIME_ROOT, "knowledge.jsonl");
|
|
1306
|
-
|
|
1307
|
-
const
|
|
1308
|
-
? options.
|
|
1309
|
-
:
|
|
1402
|
+
// Caller may supply pre-read raw to avoid double-reading knowledge.jsonl.
|
|
1403
|
+
const raw = typeof (options && options.prereadRaw) === "string"
|
|
1404
|
+
? options.prereadRaw
|
|
1405
|
+
: await readTextFile(filePath, "");
|
|
1406
|
+
const baseThresholdRaw = options && options.threshold;
|
|
1407
|
+
const baseThreshold = Number.isInteger(baseThresholdRaw) && baseThresholdRaw >= 1
|
|
1408
|
+
? baseThresholdRaw
|
|
1409
|
+
: COMPOUND_RECURRENCE_THRESHOLD;
|
|
1410
|
+
const archivedRunsCount =
|
|
1411
|
+
typeof (options && options.archivedRunsCount) === "number" &&
|
|
1412
|
+
Number.isFinite(options.archivedRunsCount) &&
|
|
1413
|
+
options.archivedRunsCount >= 0
|
|
1414
|
+
? Math.floor(options.archivedRunsCount)
|
|
1415
|
+
: undefined;
|
|
1416
|
+
const smallProjectRelaxationApplied =
|
|
1417
|
+
archivedRunsCount !== undefined &&
|
|
1418
|
+
archivedRunsCount < SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD &&
|
|
1419
|
+
baseThreshold > SMALL_PROJECT_RECURRENCE_THRESHOLD;
|
|
1420
|
+
const threshold = smallProjectRelaxationApplied
|
|
1421
|
+
? SMALL_PROJECT_RECURRENCE_THRESHOLD
|
|
1422
|
+
: baseThreshold;
|
|
1310
1423
|
const maxReady = Number.isInteger(options && options.maxReady) && options.maxReady >= 1
|
|
1311
1424
|
? options.maxReady
|
|
1312
1425
|
: 10;
|
|
@@ -1386,12 +1499,15 @@ async function computeCompoundReadinessInline(root, options) {
|
|
|
1386
1499
|
return String(a.trigger).localeCompare(String(b.trigger));
|
|
1387
1500
|
});
|
|
1388
1501
|
return {
|
|
1389
|
-
schemaVersion:
|
|
1502
|
+
schemaVersion: 2,
|
|
1390
1503
|
threshold,
|
|
1504
|
+
baseThreshold,
|
|
1505
|
+
...(archivedRunsCount !== undefined ? { archivedRunsCount } : {}),
|
|
1506
|
+
smallProjectRelaxationApplied,
|
|
1391
1507
|
clusterCount: buckets.size,
|
|
1392
1508
|
readyCount: ready.length,
|
|
1393
1509
|
ready: ready.slice(0, maxReady),
|
|
1394
|
-
lastUpdatedAt: new Date()
|
|
1510
|
+
lastUpdatedAt: normalizeCompoundLastUpdatedAt(new Date())
|
|
1395
1511
|
};
|
|
1396
1512
|
}
|
|
1397
1513
|
|
package/dist/fs-utils.js
CHANGED
|
@@ -42,12 +42,24 @@ export async function withDirectoryLock(lockPath, fn, options = {}) {
|
|
|
42
42
|
}
|
|
43
43
|
try {
|
|
44
44
|
const stat = await fs.stat(lockPath);
|
|
45
|
+
if (!stat.isDirectory()) {
|
|
46
|
+
// A non-directory lives at the lock path (e.g. a stray file).
|
|
47
|
+
// Retrying mkdir() will keep returning EEXIST forever, and no
|
|
48
|
+
// other cclaw process is holding the lock - the path is simply
|
|
49
|
+
// unusable. Fail loudly rather than burning the full retry
|
|
50
|
+
// budget, so the caller sees a deterministic error.
|
|
51
|
+
throw new Error(`Lock path exists but is not a directory: ${lockPath}`);
|
|
52
|
+
}
|
|
45
53
|
if (Date.now() - stat.mtimeMs > staleAfterMs) {
|
|
46
54
|
await fs.rm(lockPath, { recursive: true, force: true });
|
|
47
55
|
continue;
|
|
48
56
|
}
|
|
49
57
|
}
|
|
50
|
-
catch {
|
|
58
|
+
catch (statError) {
|
|
59
|
+
if (statError instanceof Error &&
|
|
60
|
+
statError.message.startsWith("Lock path exists but is not a directory")) {
|
|
61
|
+
throw statError;
|
|
62
|
+
}
|
|
51
63
|
// Lock directory disappeared between retries.
|
|
52
64
|
}
|
|
53
65
|
await sleep(retryDelayMs);
|
package/dist/install.js
CHANGED
|
@@ -731,7 +731,8 @@ async function writeHooks(projectRoot, config) {
|
|
|
731
731
|
await writeFileSafe(path.join(hooksDir, "run-hook.mjs"), nodeHookRuntimeScript({
|
|
732
732
|
strictness: effectiveStrictness,
|
|
733
733
|
tddTestPathPatterns: config.tdd?.testPathPatterns ?? config.tddTestGlobs,
|
|
734
|
-
tddProductionPathPatterns: config.tdd?.productionPathPatterns
|
|
734
|
+
tddProductionPathPatterns: config.tdd?.productionPathPatterns,
|
|
735
|
+
compoundRecurrenceThreshold: config.compound?.recurrenceThreshold
|
|
735
736
|
}));
|
|
736
737
|
const opencodePluginSource = opencodePluginJs();
|
|
737
738
|
await writeFileSafe(path.join(hooksDir, "opencode-plugin.mjs"), opencodePluginSource);
|
|
@@ -4,6 +4,14 @@ interface InternalIo {
|
|
|
4
4
|
stdout: Writable;
|
|
5
5
|
stderr: Writable;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Count archived runs as sub-directories under `.cclaw/runs/`. Missing
|
|
9
|
+
* dir / ENOENT is interpreted as zero — callers should NOT conflate
|
|
10
|
+
* that with "unknown" (undefined); we only return undefined on
|
|
11
|
+
* unexpected errors so the caller can choose to skip the relaxation
|
|
12
|
+
* rather than guess a number.
|
|
13
|
+
*/
|
|
14
|
+
export declare function countArchivedRunsSafely(projectRoot: string): Promise<number | undefined>;
|
|
7
15
|
/**
|
|
8
16
|
* Compact one-liner for session-digest / bootstrap surfaces.
|
|
9
17
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { RUNTIME_ROOT } from "../constants.js";
|
|
3
4
|
import { readConfig } from "../config.js";
|
|
@@ -19,6 +20,11 @@ function parseArgs(tokens) {
|
|
|
19
20
|
const value = tokens[i + 1];
|
|
20
21
|
if (!value)
|
|
21
22
|
throw new Error("--threshold requires a numeric value");
|
|
23
|
+
// Strict: reject "2abc", "2.9", "-1", "" — parseInt would silently
|
|
24
|
+
// accept the first two and produce surprising behavior.
|
|
25
|
+
if (!/^\d+$/u.test(value)) {
|
|
26
|
+
throw new Error(`--threshold must be a positive integer, got ${value}`);
|
|
27
|
+
}
|
|
22
28
|
const parsed = Number.parseInt(value, 10);
|
|
23
29
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
24
30
|
throw new Error(`--threshold must be a positive integer, got ${value}`);
|
|
@@ -35,6 +41,29 @@ function parseArgs(tokens) {
|
|
|
35
41
|
function stateDir(projectRoot) {
|
|
36
42
|
return path.join(projectRoot, RUNTIME_ROOT, "state");
|
|
37
43
|
}
|
|
44
|
+
function archiveRunsDir(projectRoot) {
|
|
45
|
+
return path.join(projectRoot, RUNTIME_ROOT, "runs");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Count archived runs as sub-directories under `.cclaw/runs/`. Missing
|
|
49
|
+
* dir / ENOENT is interpreted as zero — callers should NOT conflate
|
|
50
|
+
* that with "unknown" (undefined); we only return undefined on
|
|
51
|
+
* unexpected errors so the caller can choose to skip the relaxation
|
|
52
|
+
* rather than guess a number.
|
|
53
|
+
*/
|
|
54
|
+
export async function countArchivedRunsSafely(projectRoot) {
|
|
55
|
+
const dir = archiveRunsDir(projectRoot);
|
|
56
|
+
try {
|
|
57
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
58
|
+
return entries.filter((entry) => entry.isDirectory()).length;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const code = error?.code;
|
|
62
|
+
if (code === "ENOENT")
|
|
63
|
+
return 0;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
38
67
|
/**
|
|
39
68
|
* Compact one-liner for session-digest / bootstrap surfaces.
|
|
40
69
|
*
|
|
@@ -51,14 +80,26 @@ export function formatCompoundReadinessLine(status) {
|
|
|
51
80
|
}
|
|
52
81
|
export async function runCompoundReadinessCommand(projectRoot, argv, io) {
|
|
53
82
|
const args = parseArgs(argv);
|
|
54
|
-
|
|
83
|
+
// Reading config is best-effort — but DO surface a stderr warning so
|
|
84
|
+
// mis-wired / malformed config shows up in hook-errors / CI logs
|
|
85
|
+
// instead of silently degrading to default threshold.
|
|
86
|
+
let config = null;
|
|
87
|
+
try {
|
|
88
|
+
config = await readConfig(projectRoot);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
92
|
+
io.stderr.write(`[cclaw] compound-readiness: failed to read config (${detail}); falling back to default threshold\n`);
|
|
93
|
+
}
|
|
55
94
|
const threshold = args.threshold ??
|
|
56
95
|
(typeof config?.compound?.recurrenceThreshold === "number"
|
|
57
96
|
? config.compound.recurrenceThreshold
|
|
58
97
|
: undefined);
|
|
59
|
-
const
|
|
98
|
+
const archivedRunsCount = await countArchivedRunsSafely(projectRoot);
|
|
99
|
+
const { entries } = await readKnowledgeSafely(projectRoot, { lockAware: true });
|
|
60
100
|
const status = computeCompoundReadiness(entries, {
|
|
61
|
-
...(typeof threshold === "number" ? { threshold } : {})
|
|
101
|
+
...(typeof threshold === "number" ? { threshold } : {}),
|
|
102
|
+
...(typeof archivedRunsCount === "number" ? { archivedRunsCount } : {})
|
|
62
103
|
});
|
|
63
104
|
if (args.write) {
|
|
64
105
|
const target = path.join(stateDir(projectRoot), "compound-readiness.json");
|
|
@@ -98,9 +98,28 @@ export interface CompoundReadinessCluster {
|
|
|
98
98
|
maturity: KnowledgeEntryMaturity[];
|
|
99
99
|
}
|
|
100
100
|
export interface CompoundReadiness {
|
|
101
|
-
schemaVersion:
|
|
102
|
-
/**
|
|
101
|
+
schemaVersion: 2;
|
|
102
|
+
/**
|
|
103
|
+
* Effective recurrence threshold actually used. When
|
|
104
|
+
* `archivedRunsCount < SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD`, this is
|
|
105
|
+
* `min(baseThreshold, SMALL_PROJECT_RECURRENCE_THRESHOLD)` — otherwise
|
|
106
|
+
* it equals `baseThreshold`.
|
|
107
|
+
*/
|
|
103
108
|
threshold: number;
|
|
109
|
+
/** Base threshold from config/CLI before small-project relaxation. */
|
|
110
|
+
baseThreshold: number;
|
|
111
|
+
/**
|
|
112
|
+
* Archived-run count observed at compute time (used to gate the
|
|
113
|
+
* small-project relaxation). Optional — the computation can run
|
|
114
|
+
* without knowing this and then no relaxation is applied.
|
|
115
|
+
*/
|
|
116
|
+
archivedRunsCount?: number;
|
|
117
|
+
/**
|
|
118
|
+
* True iff the effective threshold was lowered by the small-project
|
|
119
|
+
* relaxation rule. Always false when `archivedRunsCount` is not
|
|
120
|
+
* supplied.
|
|
121
|
+
*/
|
|
122
|
+
smallProjectRelaxationApplied: boolean;
|
|
104
123
|
/** Total number of (trigger, action) clusters seen, regardless of threshold. */
|
|
105
124
|
clusterCount: number;
|
|
106
125
|
/** Number of clusters that passed the threshold or critical override. */
|
|
@@ -117,7 +136,27 @@ export interface ComputeCompoundReadinessOptions {
|
|
|
117
136
|
/** Hard cap on `ready[]` to keep the surface digest concise. Default 10. */
|
|
118
137
|
maxReady?: number;
|
|
119
138
|
now?: Date;
|
|
139
|
+
/**
|
|
140
|
+
* Count of archived runs under `.cclaw/runs/`. When supplied and
|
|
141
|
+
* `< SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD`, the effective threshold
|
|
142
|
+
* is lowered to `min(threshold, SMALL_PROJECT_RECURRENCE_THRESHOLD)`.
|
|
143
|
+
* Matches the rule documented in `src/content/compound-command.ts`
|
|
144
|
+
* and `docs/config.md`.
|
|
145
|
+
*/
|
|
146
|
+
archivedRunsCount?: number;
|
|
120
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Single source of truth for the small-project relaxation rule.
|
|
150
|
+
*
|
|
151
|
+
* Kept exported so the inline hook mirror, the CLI command, and
|
|
152
|
+
* the `/cc-ops compound` skill all agree on the same numbers.
|
|
153
|
+
*/
|
|
154
|
+
export declare const SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD = 5;
|
|
155
|
+
export declare const SMALL_PROJECT_RECURRENCE_THRESHOLD = 2;
|
|
156
|
+
export declare function effectiveCompoundThreshold(baseThreshold: number, archivedRunsCount: number | undefined): {
|
|
157
|
+
threshold: number;
|
|
158
|
+
relaxationApplied: boolean;
|
|
159
|
+
};
|
|
121
160
|
/**
|
|
122
161
|
* Pure function — no filesystem side effects. Callers pass entries from
|
|
123
162
|
* `readKnowledgeSafely` and get a derived readiness snapshot suitable
|
package/dist/knowledge-store.js
CHANGED
|
@@ -5,6 +5,26 @@ import { RUNTIME_ROOT } from "./constants.js";
|
|
|
5
5
|
import { stripBom, withDirectoryLock } from "./fs-utils.js";
|
|
6
6
|
import { FLOW_STAGES } from "./types.js";
|
|
7
7
|
const DEFAULT_COMPOUND_READINESS_MAX_READY = 10;
|
|
8
|
+
/**
|
|
9
|
+
* Single source of truth for the small-project relaxation rule.
|
|
10
|
+
*
|
|
11
|
+
* Kept exported so the inline hook mirror, the CLI command, and
|
|
12
|
+
* the `/cc-ops compound` skill all agree on the same numbers.
|
|
13
|
+
*/
|
|
14
|
+
export const SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD = 5;
|
|
15
|
+
export const SMALL_PROJECT_RECURRENCE_THRESHOLD = 2;
|
|
16
|
+
export function effectiveCompoundThreshold(baseThreshold, archivedRunsCount) {
|
|
17
|
+
if (typeof archivedRunsCount === "number" &&
|
|
18
|
+
Number.isFinite(archivedRunsCount) &&
|
|
19
|
+
archivedRunsCount < SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD &&
|
|
20
|
+
baseThreshold > SMALL_PROJECT_RECURRENCE_THRESHOLD) {
|
|
21
|
+
return {
|
|
22
|
+
threshold: SMALL_PROJECT_RECURRENCE_THRESHOLD,
|
|
23
|
+
relaxationApplied: true
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { threshold: baseThreshold, relaxationApplied: false };
|
|
27
|
+
}
|
|
8
28
|
/**
|
|
9
29
|
* Pure function — no filesystem side effects. Callers pass entries from
|
|
10
30
|
* `readKnowledgeSafely` and get a derived readiness snapshot suitable
|
|
@@ -17,7 +37,7 @@ const DEFAULT_COMPOUND_READINESS_MAX_READY = 10;
|
|
|
17
37
|
*/
|
|
18
38
|
export function computeCompoundReadiness(entries, options = {}) {
|
|
19
39
|
const thresholdRaw = options.threshold ?? DEFAULT_COMPOUND_RECURRENCE_THRESHOLD;
|
|
20
|
-
const
|
|
40
|
+
const baseThreshold = Number.isInteger(thresholdRaw) && thresholdRaw >= 1
|
|
21
41
|
? thresholdRaw
|
|
22
42
|
: DEFAULT_COMPOUND_RECURRENCE_THRESHOLD;
|
|
23
43
|
const maxReadyRaw = options.maxReady ?? DEFAULT_COMPOUND_READINESS_MAX_READY;
|
|
@@ -25,6 +45,12 @@ export function computeCompoundReadiness(entries, options = {}) {
|
|
|
25
45
|
? maxReadyRaw
|
|
26
46
|
: DEFAULT_COMPOUND_READINESS_MAX_READY;
|
|
27
47
|
const now = options.now ?? new Date();
|
|
48
|
+
const archivedRunsCount = typeof options.archivedRunsCount === "number" &&
|
|
49
|
+
Number.isFinite(options.archivedRunsCount) &&
|
|
50
|
+
options.archivedRunsCount >= 0
|
|
51
|
+
? Math.floor(options.archivedRunsCount)
|
|
52
|
+
: undefined;
|
|
53
|
+
const { threshold, relaxationApplied } = effectiveCompoundThreshold(baseThreshold, archivedRunsCount);
|
|
28
54
|
const buckets = new Map();
|
|
29
55
|
for (const entry of entries) {
|
|
30
56
|
if (entry.maturity === "lifted-to-enforcement")
|
|
@@ -102,8 +128,11 @@ export function computeCompoundReadiness(entries, options = {}) {
|
|
|
102
128
|
return a.trigger.localeCompare(b.trigger);
|
|
103
129
|
});
|
|
104
130
|
return {
|
|
105
|
-
schemaVersion:
|
|
131
|
+
schemaVersion: 2,
|
|
106
132
|
threshold,
|
|
133
|
+
baseThreshold,
|
|
134
|
+
...(archivedRunsCount !== undefined ? { archivedRunsCount } : {}),
|
|
135
|
+
smallProjectRelaxationApplied: relaxationApplied,
|
|
107
136
|
clusterCount: buckets.size,
|
|
108
137
|
readyCount: ready.length,
|
|
109
138
|
ready: ready.slice(0, maxReady),
|