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.
@@ -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
- const raw = await readTextFile(knowledgeFile, "");
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
- const knowledge = await buildKnowledgeDigest(runtime.root, state.currentStage);
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 (_err) {
906
- // best-effort — a malformed cycle log should never break session-start.
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 readiness = await computeCompoundReadinessInline(runtime.root, {});
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 (_err) {
929
- // best-effort — a malformed knowledge.jsonl must never break session-start.
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
- const raw = await readTextFile(filePath, "");
1307
- const threshold = Number.isInteger(options && options.threshold) && options.threshold >= 1
1308
- ? options.threshold
1309
- : 3;
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: 1,
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().toISOString()
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
- 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.23",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {