cclaw-cli 0.48.21 → 0.48.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,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;
@@ -752,10 +767,14 @@ function stageSuggestion(stage) {
752
767
  return map[stage] || "";
753
768
  }
754
769
 
755
- async function buildKnowledgeDigest(root, currentStage) {
770
+ async function buildKnowledgeDigest(root, currentStage, prereadRaw) {
756
771
  const knowledgeFile = path.join(root, RUNTIME_ROOT, "knowledge.jsonl");
757
772
  const digestFile = path.join(root, RUNTIME_ROOT, "state", "knowledge-digest.md");
758
- const raw = await readTextFile(knowledgeFile, "");
773
+ // Caller may supply pre-read raw bytes to avoid re-reading knowledge.jsonl.
774
+ // Falls back to a local read if nothing is passed in.
775
+ const raw = typeof prereadRaw === "string"
776
+ ? prereadRaw
777
+ : await readTextFile(knowledgeFile, "");
759
778
  const lines = raw.split(/\\r?\\n/gu).map((line) => line.trim()).filter((line) => line.length > 0);
760
779
  let learningsCount = 0;
761
780
  const parsedRows = [];
@@ -885,7 +904,11 @@ async function handleSessionStart(runtime) {
885
904
  const sessionDigest = (await readTextFile(sessionDigestFile, "")).trim();
886
905
  const activitySummary = await readRecentActivityLines(activityFile);
887
906
  const contextWarning = await readLatestContextWarningLine(contextWarningsFile);
888
- const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage);
907
+ // Read knowledge.jsonl exactly once per session-start; both the digest
908
+ // and compound-readiness derive from the same snapshot.
909
+ const knowledgeFilePath = path.join(runtime.root, RUNTIME_ROOT, "knowledge.jsonl");
910
+ const knowledgeRaw = await readTextFile(knowledgeFilePath, "");
911
+ const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage, knowledgeRaw);
889
912
 
890
913
  // Refresh Ralph Loop status each session-start so /cc-next and the model
891
914
  // both read a consistent "iter=N, acClosed=[...]" snapshot. Runs only when
@@ -912,7 +935,11 @@ async function handleSessionStart(runtime) {
912
935
  // where lifting becomes relevant; earlier stages update the file silently.
913
936
  let compoundReadinessLine = "";
914
937
  try {
915
- const readiness = await computeCompoundReadinessInline(runtime.root, {});
938
+ const archivedRunsCount = await countArchivedRunsInline(runtime.root);
939
+ const readiness = await computeCompoundReadinessInline(runtime.root, {
940
+ prereadRaw: knowledgeRaw,
941
+ ...(typeof archivedRunsCount === "number" ? { archivedRunsCount } : {})
942
+ });
916
943
  await writeJsonFile(path.join(stateDir, "compound-readiness.json"), readiness);
917
944
  if (state.currentStage === "review" || state.currentStage === "ship") {
918
945
  if (readiness.readyCount === 0) {
@@ -1297,16 +1324,53 @@ async function handlePromptGuard(runtime) {
1297
1324
  return 0;
1298
1325
  }
1299
1326
 
1327
+ function normalizeCompoundLastUpdatedAt(date) {
1328
+ return date.toISOString().replace(/\\.\\d{3}Z$/u, "Z");
1329
+ }
1330
+
1331
+ // Count archived runs as sub-directories under \`.cclaw/runs/\`. Missing
1332
+ // dir returns 0; unexpected errors return undefined so the caller can
1333
+ // skip the small-project relaxation rather than guess.
1334
+ async function countArchivedRunsInline(root) {
1335
+ const dir = path.join(root, RUNTIME_ROOT, "runs");
1336
+ try {
1337
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1338
+ return entries.filter((entry) => entry.isDirectory()).length;
1339
+ } catch (error) {
1340
+ const code = error && typeof error === "object" && "code" in error ? error.code : null;
1341
+ if (code === "ENOENT") return 0;
1342
+ return undefined;
1343
+ }
1344
+ }
1345
+
1300
1346
  // Mirrors src/knowledge-store.ts::computeCompoundReadiness — kept inline so
1301
1347
  // SessionStart can refresh compound-readiness.json without the CLI binary.
1302
1348
  // Any schema change must update src/knowledge-store.ts::computeCompoundReadiness
1303
- // and src/internal/compound-readiness.ts in lockstep.
1349
+ // and src/internal/compound-readiness.ts in lockstep. Parity is enforced by
1350
+ // tests/unit/ralph-loop-parity.test.ts.
1304
1351
  async function computeCompoundReadinessInline(root, options) {
1305
1352
  const filePath = path.join(root, RUNTIME_ROOT, "knowledge.jsonl");
1306
- const raw = await readTextFile(filePath, "");
1307
- const threshold = Number.isInteger(options && options.threshold) && options.threshold >= 1
1308
- ? options.threshold
1309
- : 3;
1353
+ // Caller may supply pre-read raw to avoid double-reading knowledge.jsonl.
1354
+ const raw = typeof (options && options.prereadRaw) === "string"
1355
+ ? options.prereadRaw
1356
+ : await readTextFile(filePath, "");
1357
+ const baseThresholdRaw = options && options.threshold;
1358
+ const baseThreshold = Number.isInteger(baseThresholdRaw) && baseThresholdRaw >= 1
1359
+ ? baseThresholdRaw
1360
+ : COMPOUND_RECURRENCE_THRESHOLD;
1361
+ const archivedRunsCount =
1362
+ typeof (options && options.archivedRunsCount) === "number" &&
1363
+ Number.isFinite(options.archivedRunsCount) &&
1364
+ options.archivedRunsCount >= 0
1365
+ ? Math.floor(options.archivedRunsCount)
1366
+ : undefined;
1367
+ const smallProjectRelaxationApplied =
1368
+ archivedRunsCount !== undefined &&
1369
+ archivedRunsCount < SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD &&
1370
+ baseThreshold > SMALL_PROJECT_RECURRENCE_THRESHOLD;
1371
+ const threshold = smallProjectRelaxationApplied
1372
+ ? SMALL_PROJECT_RECURRENCE_THRESHOLD
1373
+ : baseThreshold;
1310
1374
  const maxReady = Number.isInteger(options && options.maxReady) && options.maxReady >= 1
1311
1375
  ? options.maxReady
1312
1376
  : 10;
@@ -1386,12 +1450,15 @@ async function computeCompoundReadinessInline(root, options) {
1386
1450
  return String(a.trigger).localeCompare(String(b.trigger));
1387
1451
  });
1388
1452
  return {
1389
- schemaVersion: 1,
1453
+ schemaVersion: 2,
1390
1454
  threshold,
1455
+ baseThreshold,
1456
+ ...(archivedRunsCount !== undefined ? { archivedRunsCount } : {}),
1457
+ smallProjectRelaxationApplied,
1391
1458
  clusterCount: buckets.size,
1392
1459
  readyCount: ready.length,
1393
1460
  ready: ready.slice(0, maxReady),
1394
- lastUpdatedAt: new Date().toISOString()
1461
+ lastUpdatedAt: normalizeCompoundLastUpdatedAt(new Date())
1395
1462
  };
1396
1463
  }
1397
1464
 
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
- const config = await readConfig(projectRoot).catch(() => null);
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 { entries } = await readKnowledgeSafely(projectRoot, { lockAware: false });
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: 1;
102
- /** Effective recurrence threshold applied to this computation. */
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
@@ -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 threshold = Number.isInteger(thresholdRaw) && thresholdRaw >= 1
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: 1,
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.21",
3
+ "version": "0.48.22",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {