cclaw-cli 0.46.14 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,10 +154,11 @@ If cclaw detects a Node / Python / Go project at init time, a sixth
154
154
  default surface — a new user sees nothing they need to understand yet.
155
155
 
156
156
  Advanced knobs (`promptGuardMode` / `tddEnforcement` per-axis overrides,
157
- `tddTestGlobs`, `defaultTrack`, `trackHeuristics`, `sliceReview`) are
158
- **opt-in**: add them by hand when you need them. `cclaw upgrade`
159
- preserves exactly what you wrote it never silently reintroduces
160
- defaults you removed.
157
+ `tdd.testPathPatterns` / `tdd.productionPathPatterns`,
158
+ `compound.recurrenceThreshold`, `defaultTrack`, `trackHeuristics`,
159
+ `sliceReview`) are **opt-in**: add them by hand when you need them.
160
+ `cclaw upgrade` preserves exactly what you wrote — it never silently
161
+ reintroduces defaults you removed.
161
162
 
162
163
  Full key-by-key reference: [`docs/config.md`](./docs/config.md).
163
164
 
@@ -240,7 +241,7 @@ the flow matches the task.
240
241
  |---|---|---|
241
242
  | **quick** | `spec → tdd → review → ship` | `bug`, `hotfix`, `typo`, `rename`, `bump`, `docs only`, one-liners |
242
243
  | **medium** | `brainstorm → spec → plan → tdd → review → ship` | `add endpoint`, `add field`, `extend existing`, `wire integration` |
243
- | **standard** _(default)_ | all 8 stages | `new feature`, `refactor`, `migration`, `platform`, `schema`, `architecture` |
244
+ | **standard** _(default)_ | all 8 stages (+ mandatory design-time parallel research fleet) | `new feature`, `refactor`, `migration`, `platform`, `schema`, `architecture` |
244
245
 
245
246
  **Every track ends with the same auto-closeout chain.** Once ship
246
247
  completes, `/cc-next` automatically drives
@@ -250,10 +251,11 @@ without re-drafting retros or re-asking structured questions. See
250
251
  [Ship and closeout](#ship-and-closeout--automatic-resumable).
251
252
 
252
253
  Each critical-path stage produces a dated artifact under
253
- `.cclaw/artifacts/`: `00-idea.md` (seed), `01-brainstorm.md` through
254
+ `.cclaw/artifacts/`: `00-idea.md` (seed), `01-brainstorm.md`, `02-scope.md`,
255
+ `02a-research.md` (design research fleet synthesis), `03-design.md` through
254
256
  `08-ship.md`. Closeout adds `09-retro.md`; archive then rolls the whole
255
- bundle into `.cclaw/runs/<YYYY-MM-DD-slug>/` and resets the active flow
256
- for the next feature.
257
+ bundle into `.cclaw/runs/<YYYY-MM-DD-slug>/` and resets the active flow for
258
+ the next feature.
257
259
 
258
260
  ### Track heuristics are configurable (advisory)
259
261
 
@@ -312,9 +314,12 @@ it into ceremony:
312
314
  protocol emits typed entries (`rule` / `pattern` / `lesson`) to
313
315
  `.cclaw/knowledge.jsonl` as the flow progresses — not only at retro.
314
316
  Retro itself adds a `compound` entry, and the automatic compound pass
315
- after ship promotes recurring entries (≥ 3) into first-class
316
- rules/protocols/skills so the **next** run is easier. Strict JSONL
317
- schema keeps the whole thing machine-queryable.
317
+ after ship promotes recurring entries into first-class
318
+ rules/protocols/skills (base threshold from
319
+ `compound.recurrenceThreshold`, temporarily lowered to 2 for repositories
320
+ with <5 archived runs, plus a critical-severity single-hit override) so
321
+ the **next** run is easier. Strict JSONL schema keeps the whole thing
322
+ machine-queryable.
318
323
  - **Automatic integrity checks.** Runtime health is verified on every
319
324
  stage transition — no command you need to remember to run.
320
325
 
@@ -15,6 +15,7 @@ export interface LintResult {
15
15
  export declare function extractMarkdownSectionBody(markdown: string, section: string): string | null;
16
16
  export type LearningEntryType = "rule" | "pattern" | "lesson" | "compound";
17
17
  export type LearningConfidence = "high" | "medium" | "low";
18
+ export type LearningSeverity = "critical" | "important" | "suggestion";
18
19
  export type LearningUniversality = "project" | "personal" | "universal";
19
20
  export type LearningMaturity = "raw" | "lifted-to-rule" | "lifted-to-enforcement";
20
21
  export type LearningSource = "stage" | "retro" | "compound" | "ideate" | "manual";
@@ -23,6 +24,7 @@ export interface LearningSeedEntry {
23
24
  trigger: string;
24
25
  action: string;
25
26
  confidence: LearningConfidence;
27
+ severity?: LearningSeverity;
26
28
  domain?: string | null;
27
29
  stage?: FlowStage | null;
28
30
  origin_stage?: FlowStage | null;
@@ -12,25 +12,52 @@ async function resolveArtifactPath(projectRoot, fileName) {
12
12
  function normalizeHeadingTitle(title) {
13
13
  return title.trim().replace(/\s+/g, " ");
14
14
  }
15
- /** Collect H2 sections and body content (`## Section Name`). */
15
+ /**
16
+ * Collect H2 sections and body content (`## Section Name`).
17
+ *
18
+ * - Ignores lines that live inside fenced code blocks (``` / ~~~) so a
19
+ * commented `## Approaches` inside an example doesn't open a phantom
20
+ * section and swallow real content.
21
+ * - When the same heading appears more than once at the top level we
22
+ * concatenate the bodies rather than silently overwriting the earlier
23
+ * occurrence. This keeps lint rules honest when authors split a section
24
+ * into multiple passes.
25
+ */
16
26
  function extractH2Sections(markdown) {
17
27
  const sections = new Map();
18
28
  const lines = markdown.split(/\r?\n/);
19
29
  let currentHeading = null;
20
30
  let buffer = [];
31
+ let fenced = null;
21
32
  const flush = () => {
22
33
  if (currentHeading === null)
23
34
  return;
24
- sections.set(currentHeading, buffer.join("\n"));
35
+ const existing = sections.get(currentHeading);
36
+ const body = buffer.join("\n");
37
+ sections.set(currentHeading, existing === undefined ? body : `${existing}\n${body}`);
25
38
  };
26
39
  for (const line of lines) {
27
- const match = /^##\s+(.+)$/u.exec(line);
28
- if (match) {
29
- flush();
30
- currentHeading = normalizeHeadingTitle(match[1] ?? "");
31
- buffer = [];
40
+ const fenceMatch = /^(```|~~~)/u.exec(line);
41
+ if (fenceMatch) {
42
+ if (fenced === null) {
43
+ fenced = fenceMatch[1] ?? null;
44
+ }
45
+ else if (line.startsWith(fenced)) {
46
+ fenced = null;
47
+ }
48
+ if (currentHeading !== null)
49
+ buffer.push(line);
32
50
  continue;
33
51
  }
52
+ if (fenced === null) {
53
+ const match = /^##\s+(.+)$/u.exec(line);
54
+ if (match) {
55
+ flush();
56
+ currentHeading = normalizeHeadingTitle(match[1] ?? "");
57
+ buffer = [];
58
+ continue;
59
+ }
60
+ }
34
61
  if (currentHeading !== null) {
35
62
  buffer.push(line);
36
63
  }
@@ -299,6 +326,7 @@ function validateVerificationLadder(sectionBody) {
299
326
  }
300
327
  const LEARNING_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
301
328
  const LEARNING_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
329
+ const LEARNING_SEVERITY_SET = new Set(["critical", "important", "suggestion"]);
302
330
  const LEARNING_UNIVERSALITY_SET = new Set(["project", "personal", "universal"]);
303
331
  const LEARNING_MATURITY_SET = new Set(["raw", "lifted-to-rule", "lifted-to-enforcement"]);
304
332
  const LEARNING_SOURCE_SET = new Set([
@@ -314,6 +342,7 @@ const LEARNING_ALLOWED_KEYS = new Set([
314
342
  "trigger",
315
343
  "action",
316
344
  "confidence",
345
+ "severity",
317
346
  "domain",
318
347
  "stage",
319
348
  "origin_stage",
@@ -377,6 +406,13 @@ function parseLearningSeedEntry(raw, index) {
377
406
  error: `Learnings bullet #${index} must set confidence to high|medium|low.`
378
407
  };
379
408
  }
409
+ const severity = typeof obj.severity === "string" ? obj.severity.toLowerCase() : undefined;
410
+ if (severity !== undefined && !LEARNING_SEVERITY_SET.has(severity)) {
411
+ return {
412
+ ok: false,
413
+ error: `Learnings bullet #${index} field "severity" must be critical|important|suggestion.`
414
+ };
415
+ }
380
416
  if (obj.domain !== undefined && !isNullableString(obj.domain)) {
381
417
  return { ok: false, error: `Learnings bullet #${index} field "domain" must be string or null.` };
382
418
  }
@@ -443,7 +479,8 @@ function parseLearningSeedEntry(raw, index) {
443
479
  type: type,
444
480
  trigger,
445
481
  action,
446
- confidence: confidence
482
+ confidence: confidence,
483
+ ...(severity ? { severity: severity } : {})
447
484
  }
448
485
  };
449
486
  }
@@ -859,6 +896,49 @@ export async function lintArtifact(projectRoot, stage) {
859
896
  details: learnings.details
860
897
  });
861
898
  }
899
+ if (stage === "brainstorm") {
900
+ // Brainstorm Iron Law: "NO ARTIFACT IS COMPLETE WITHOUT AN EXPLICITLY
901
+ // APPROVED DIRECTION — SILENCE IS NOT APPROVAL." Previously this was
902
+ // prose-only — nothing failed when the Selected Direction section
903
+ // omitted an approval marker, or when the Approaches table collapsed
904
+ // to a single row (defeating the "2-3 distinct approaches" gate).
905
+ const approachesBody = sectionBodyByName(sections, "Approaches");
906
+ if (approachesBody !== null) {
907
+ const tableRows = approachesBody
908
+ .split(/\r?\n/u)
909
+ .map((line) => line.trim())
910
+ .filter((line) => line.startsWith("|"))
911
+ .filter((line) => !/^\|\s*[-: |]+\|\s*$/u.test(line))
912
+ .filter((line) => !/^\|\s*approach\b/iu.test(line));
913
+ const bulletRows = approachesBody
914
+ .split(/\r?\n/u)
915
+ .map((line) => line.trim())
916
+ .filter((line) => /^(?:[-*]|\d+\.)\s+\S/u.test(line));
917
+ const rowCount = Math.max(tableRows.length, bulletRows.length);
918
+ findings.push({
919
+ section: "Distinct Approaches Enforcement",
920
+ required: true,
921
+ rule: "Approaches section must document at least 2 distinct approaches so the Iron Law comparison is meaningful.",
922
+ found: rowCount >= 2,
923
+ details: rowCount >= 2
924
+ ? `Detected ${rowCount} approach row(s).`
925
+ : `Detected ${rowCount} approach row(s); at least 2 required.`
926
+ });
927
+ }
928
+ const directionBody = sectionBodyByName(sections, "Selected Direction");
929
+ if (directionBody !== null) {
930
+ const approvalMarker = /\bapprov(?:ed|al)\b/iu.test(directionBody);
931
+ findings.push({
932
+ section: "Direction Approval Marker",
933
+ required: true,
934
+ rule: "Selected Direction section must state an explicit approval marker (for example `Approval: approved` or `Approved by: user`).",
935
+ found: approvalMarker,
936
+ details: approvalMarker
937
+ ? "Approval marker present in Selected Direction."
938
+ : "No explicit `approved`/`approval` marker found in Selected Direction."
939
+ });
940
+ }
941
+ }
862
942
  if (stage === "plan") {
863
943
  const strictPlanGuards = parsedFrontmatter.hasFrontmatter ||
864
944
  headingPresent(sections, "No-Placeholder Scan") ||
@@ -904,12 +984,13 @@ export async function lintArtifact(projectRoot, stage) {
904
984
  });
905
985
  }
906
986
  if (stage === "scope") {
987
+ const lockedDecisionsBody = sectionBodyByName(sections, "Locked Decisions (D-XX)") ?? "";
907
988
  const strictScopeGuards = parsedFrontmatter.hasFrontmatter ||
908
989
  headingPresent(sections, "Locked Decisions (D-XX)");
909
990
  const scopeSections = [
910
991
  sectionBodyByName(sections, "In Scope / Out of Scope") ?? "",
911
992
  sectionBodyByName(sections, "Scope Summary") ?? "",
912
- sectionBodyByName(sections, "Locked Decisions (D-XX)") ?? ""
993
+ lockedDecisionsBody
913
994
  ].join("\n");
914
995
  const reductionHits = collectPatternHits(scopeSections, SCOPE_REDUCTION_PATTERNS);
915
996
  findings.push({
@@ -921,6 +1002,45 @@ export async function lintArtifact(projectRoot, stage) {
921
1002
  ? "No scope-reduction phrases detected in scope boundary sections."
922
1003
  : `Detected scope-reduction phrase(s): ${reductionHits.join(", ")}.`
923
1004
  });
1005
+ // When the Locked Decisions section is present we must enforce the
1006
+ // D-XX ID contract at runtime (previously this was prose-only in the
1007
+ // artifactValidation rule). Empty body, missing IDs, and duplicate
1008
+ // IDs all fail the lint; absence of the section remains advisory so
1009
+ // scope stays optional for small/quick tracks.
1010
+ if (headingPresent(sections, "Locked Decisions (D-XX)")) {
1011
+ const decisionIds = extractDecisionIds(lockedDecisionsBody);
1012
+ const bulletLines = lockedDecisionsBody
1013
+ .split(/\r?\n/u)
1014
+ .map((line) => line.trim())
1015
+ .filter((line) => /^(?:[-*]|\|)\s+\S/u.test(line));
1016
+ const orphanBullets = bulletLines.filter((line) => !/\bD-\d+\b/u.test(line));
1017
+ const duplicateIds = (() => {
1018
+ const all = lockedDecisionsBody.match(/\bD-\d+\b/gu) ?? [];
1019
+ const counts = new Map();
1020
+ for (const id of all)
1021
+ counts.set(id, (counts.get(id) ?? 0) + 1);
1022
+ return [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
1023
+ })();
1024
+ const issues = [];
1025
+ if (decisionIds.length === 0 && bulletLines.length === 0) {
1026
+ issues.push("section is empty");
1027
+ }
1028
+ if (orphanBullets.length > 0) {
1029
+ issues.push(`${orphanBullets.length} bullet(s) missing a D-XX ID`);
1030
+ }
1031
+ if (duplicateIds.length > 0) {
1032
+ issues.push(`duplicate IDs: ${duplicateIds.join(", ")}`);
1033
+ }
1034
+ findings.push({
1035
+ section: "Locked Decisions ID Integrity",
1036
+ required: true,
1037
+ rule: "Locked Decisions section must list each decision with a unique stable D-XX ID.",
1038
+ found: issues.length === 0,
1039
+ details: issues.length === 0
1040
+ ? `${decisionIds.length} decision ID(s) recorded with no duplicates.`
1041
+ : issues.join("; ")
1042
+ });
1043
+ }
924
1044
  }
925
1045
  const passed = findings.every((f) => !f.required || f.found);
926
1046
  return { stage, file: relFile, passed, findings };
@@ -1188,6 +1308,14 @@ export async function checkReviewVerdictConsistency(projectRoot) {
1188
1308
  if (finalVerdict === "APPROVED" && (openCriticalCount > 0 || shipBlockerCount > 0)) {
1189
1309
  errors.push(`Final Verdict is APPROVED but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Use BLOCKED or APPROVED_WITH_CONCERNS.`);
1190
1310
  }
1311
+ // APPROVED_WITH_CONCERNS is intended for Important/Suggestion findings
1312
+ // the author has accepted. An *open* Critical finding or an active
1313
+ // shipBlocker must route through BLOCKED (review_verdict_blocked gate)
1314
+ // rather than pass as a concession — previously this slipped through.
1315
+ if (finalVerdict === "APPROVED_WITH_CONCERNS" &&
1316
+ (openCriticalCount > 0 || shipBlockerCount > 0)) {
1317
+ errors.push(`Final Verdict is APPROVED_WITH_CONCERNS but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Resolve them or use BLOCKED.`);
1318
+ }
1191
1319
  return {
1192
1320
  ok: errors.length === 0,
1193
1321
  errors,
package/dist/config.d.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import type { FlowTrack, HarnessId, LanguageRulePack, VibyConfig } from "./types.js";
2
2
  export declare function configPath(projectRoot: string): string;
3
3
  /**
4
- * Default test-file globs used by workflow-guard.sh to detect when a write
5
- * targets a test file during TDD. Users rarely need to override this — the
6
- * defaults cover TypeScript / JavaScript / Python / Go / Rust / Java layouts.
7
- * Exposed so `install.ts` can reuse the same list when seeding the shell
8
- * guard script, even though the field is no longer written to the default
9
- * `config.yaml` template.
4
+ * Default test-path patterns used by workflow-guard.sh to classify TDD writes.
5
+ *
6
+ * Scope is intentionally narrow and language-agnostic; users can extend this
7
+ * list in config when their repository uses different conventions.
8
+ */
9
+ export declare const DEFAULT_TDD_TEST_PATH_PATTERNS: readonly string[];
10
+ /**
11
+ * Legacy alias kept for backwards compatibility with `tddTestGlobs`.
12
+ * Prefer `tdd.testPathPatterns` in new configurations.
10
13
  */
11
14
  export declare const DEFAULT_TDD_TEST_GLOBS: readonly string[];
15
+ export declare const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS: readonly string[];
16
+ export declare const DEFAULT_COMPOUND_RECURRENCE_THRESHOLD = 3;
12
17
  /**
13
18
  * Populated runtime view of config values that downstream callers (install,
14
19
  * observe, doctor) consume. Always has the derived guard modes populated,
@@ -34,7 +39,7 @@ export declare function readConfig(projectRoot: string): Promise<VibyConfig>;
34
39
  * the user set them explicitly. Keeps the default template small and honest:
35
40
  * only knobs a new user would meaningfully flip show up.
36
41
  */
37
- type AdvancedConfigKey = "promptGuardMode" | "tddEnforcement" | "tddTestGlobs" | "defaultTrack" | "languageRulePacks" | "trackHeuristics" | "sliceReview";
42
+ type AdvancedConfigKey = "promptGuardMode" | "tddEnforcement" | "tddTestGlobs" | "tdd" | "compound" | "defaultTrack" | "languageRulePacks" | "trackHeuristics" | "sliceReview";
38
43
  /**
39
44
  * Options controlling the serialisation shape of `config.yaml`.
40
45
  *
package/dist/config.js CHANGED
@@ -19,6 +19,8 @@ const ALLOWED_CONFIG_KEYS = new Set([
19
19
  "promptGuardMode",
20
20
  "tddEnforcement",
21
21
  "tddTestGlobs",
22
+ "tdd",
23
+ "compound",
22
24
  "gitHookGuards",
23
25
  "defaultTrack",
24
26
  "languageRulePacks",
@@ -74,18 +76,23 @@ export function configPath(projectRoot) {
74
76
  return path.join(projectRoot, CONFIG_PATH);
75
77
  }
76
78
  /**
77
- * Default test-file globs used by workflow-guard.sh to detect when a write
78
- * targets a test file during TDD. Users rarely need to override this — the
79
- * defaults cover TypeScript / JavaScript / Python / Go / Rust / Java layouts.
80
- * Exposed so `install.ts` can reuse the same list when seeding the shell
81
- * guard script, even though the field is no longer written to the default
82
- * `config.yaml` template.
79
+ * Default test-path patterns used by workflow-guard.sh to classify TDD writes.
80
+ *
81
+ * Scope is intentionally narrow and language-agnostic; users can extend this
82
+ * list in config when their repository uses different conventions.
83
83
  */
84
- export const DEFAULT_TDD_TEST_GLOBS = [
84
+ export const DEFAULT_TDD_TEST_PATH_PATTERNS = [
85
85
  "**/*.test.*",
86
- "**/*.spec.*",
87
- "**/test/**"
86
+ "**/tests/**",
87
+ "**/__tests__/**"
88
88
  ];
89
+ /**
90
+ * Legacy alias kept for backwards compatibility with `tddTestGlobs`.
91
+ * Prefer `tdd.testPathPatterns` in new configurations.
92
+ */
93
+ export const DEFAULT_TDD_TEST_GLOBS = [...DEFAULT_TDD_TEST_PATH_PATTERNS];
94
+ export const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS = [];
95
+ export const DEFAULT_COMPOUND_RECURRENCE_THRESHOLD = 3;
89
96
  /**
90
97
  * Populated runtime view of config values that downstream callers (install,
91
98
  * observe, doctor) consume. Always has the derived guard modes populated,
@@ -93,6 +100,8 @@ export const DEFAULT_TDD_TEST_GLOBS = [
93
100
  * or neither.
94
101
  */
95
102
  export function createDefaultConfig(harnesses = DEFAULT_HARNESSES, defaultTrack = "standard") {
103
+ const tddTestPathPatterns = [...DEFAULT_TDD_TEST_PATH_PATTERNS];
104
+ const tddProductionPathPatterns = [...DEFAULT_TDD_PRODUCTION_PATH_PATTERNS];
96
105
  return {
97
106
  version: CCLAW_VERSION,
98
107
  flowVersion: FLOW_VERSION,
@@ -100,7 +109,14 @@ export function createDefaultConfig(harnesses = DEFAULT_HARNESSES, defaultTrack
100
109
  strictness: "advisory",
101
110
  promptGuardMode: "advisory",
102
111
  tddEnforcement: "advisory",
103
- tddTestGlobs: [...DEFAULT_TDD_TEST_GLOBS],
112
+ tddTestGlobs: [...tddTestPathPatterns],
113
+ tdd: {
114
+ testPathPatterns: tddTestPathPatterns,
115
+ productionPathPatterns: tddProductionPathPatterns
116
+ },
117
+ compound: {
118
+ recurrenceThreshold: DEFAULT_COMPOUND_RECURRENCE_THRESHOLD
119
+ },
104
120
  gitHookGuards: false,
105
121
  defaultTrack,
106
122
  languageRulePacks: []
@@ -213,6 +229,48 @@ export async function readConfig(projectRoot) {
213
229
  const tddTestGlobsRaw = parsed.tddTestGlobs;
214
230
  const tddTestGlobs = validateStringArray(tddTestGlobsRaw, "tddTestGlobs", fullPath)
215
231
  ?? [...DEFAULT_TDD_TEST_GLOBS];
232
+ const hasTddField = Object.prototype.hasOwnProperty.call(parsed, "tdd");
233
+ const tddRaw = parsed.tdd;
234
+ let explicitTddTestPathPatterns;
235
+ let explicitTddProductionPathPatterns;
236
+ if (hasTddField) {
237
+ if (!isRecord(tddRaw)) {
238
+ throw configValidationError(fullPath, `"tdd" must be an object`);
239
+ }
240
+ const unknownTddKeys = Object.keys(tddRaw).filter((key) => key !== "testPathPatterns" && key !== "productionPathPatterns");
241
+ if (unknownTddKeys.length > 0) {
242
+ throw configValidationError(fullPath, `"tdd" has unknown key(s): ${unknownTddKeys.join(", ")}`);
243
+ }
244
+ explicitTddTestPathPatterns = validateStringArray(tddRaw.testPathPatterns, "tdd.testPathPatterns", fullPath);
245
+ explicitTddProductionPathPatterns = validateStringArray(tddRaw.productionPathPatterns, "tdd.productionPathPatterns", fullPath);
246
+ }
247
+ const resolvedTddTestPathPatterns = [
248
+ ...(explicitTddTestPathPatterns ?? tddTestGlobs ?? DEFAULT_TDD_TEST_PATH_PATTERNS)
249
+ ];
250
+ const resolvedTddProductionPathPatterns = [
251
+ ...(explicitTddProductionPathPatterns ?? DEFAULT_TDD_PRODUCTION_PATH_PATTERNS)
252
+ ];
253
+ const hasCompoundField = Object.prototype.hasOwnProperty.call(parsed, "compound");
254
+ const compoundRaw = parsed.compound;
255
+ let compoundRecurrenceThreshold = DEFAULT_COMPOUND_RECURRENCE_THRESHOLD;
256
+ if (hasCompoundField) {
257
+ if (!isRecord(compoundRaw)) {
258
+ throw configValidationError(fullPath, `"compound" must be an object`);
259
+ }
260
+ const unknownCompoundKeys = Object.keys(compoundRaw).filter((key) => key !== "recurrenceThreshold");
261
+ if (unknownCompoundKeys.length > 0) {
262
+ throw configValidationError(fullPath, `"compound" has unknown key(s): ${unknownCompoundKeys.join(", ")}`);
263
+ }
264
+ if (compoundRaw.recurrenceThreshold !== undefined &&
265
+ (typeof compoundRaw.recurrenceThreshold !== "number" ||
266
+ !Number.isInteger(compoundRaw.recurrenceThreshold) ||
267
+ compoundRaw.recurrenceThreshold < 1)) {
268
+ throw configValidationError(fullPath, `"compound.recurrenceThreshold" must be a positive integer`);
269
+ }
270
+ if (typeof compoundRaw.recurrenceThreshold === "number") {
271
+ compoundRecurrenceThreshold = compoundRaw.recurrenceThreshold;
272
+ }
273
+ }
216
274
  const gitHookGuardsRaw = parsed.gitHookGuards;
217
275
  if (Object.prototype.hasOwnProperty.call(parsed, "gitHookGuards") &&
218
276
  typeof gitHookGuardsRaw !== "boolean") {
@@ -327,6 +385,13 @@ export async function readConfig(projectRoot) {
327
385
  promptGuardMode,
328
386
  tddEnforcement,
329
387
  tddTestGlobs,
388
+ tdd: {
389
+ testPathPatterns: resolvedTddTestPathPatterns,
390
+ productionPathPatterns: resolvedTddProductionPathPatterns
391
+ },
392
+ compound: {
393
+ recurrenceThreshold: compoundRecurrenceThreshold
394
+ },
330
395
  gitHookGuards,
331
396
  defaultTrack,
332
397
  languageRulePacks,
@@ -349,6 +414,8 @@ function buildSerializableConfig(config, options = {}) {
349
414
  "promptGuardMode",
350
415
  "tddEnforcement",
351
416
  "tddTestGlobs",
417
+ "tdd",
418
+ "compound",
352
419
  "gitHookGuards",
353
420
  "defaultTrack",
354
421
  "languageRulePacks",
@@ -402,6 +469,8 @@ export async function detectAdvancedKeys(projectRoot) {
402
469
  "promptGuardMode",
403
470
  "tddEnforcement",
404
471
  "tddTestGlobs",
472
+ "tdd",
473
+ "compound",
405
474
  "defaultTrack",
406
475
  "languageRulePacks",
407
476
  "trackHeuristics",
@@ -1,2 +1,5 @@
1
- export declare function compoundCommandContract(): string;
2
- export declare function compoundCommandSkillMarkdown(): string;
1
+ export interface CompoundCommandOptions {
2
+ recurrenceThreshold?: number;
3
+ }
4
+ export declare function compoundCommandContract(options?: CompoundCommandOptions): string;
5
+ export declare function compoundCommandSkillMarkdown(options?: CompoundCommandOptions): string;
@@ -1,7 +1,18 @@
1
1
  import { RUNTIME_ROOT } from "../constants.js";
2
2
  const COMPOUND_SKILL_FOLDER = "flow-compound";
3
3
  const COMPOUND_SKILL_NAME = "flow-compound";
4
- export function compoundCommandContract() {
4
+ const DEFAULT_RECURRENCE_THRESHOLD = 3;
5
+ const SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD = 5;
6
+ const SMALL_PROJECT_RECURRENCE_THRESHOLD = 2;
7
+ function resolveRecurrenceThreshold(options) {
8
+ const threshold = options.recurrenceThreshold;
9
+ if (typeof threshold === "number" && Number.isInteger(threshold) && threshold >= 1) {
10
+ return threshold;
11
+ }
12
+ return DEFAULT_RECURRENCE_THRESHOLD;
13
+ }
14
+ export function compoundCommandContract(options = {}) {
15
+ const recurrenceThreshold = resolveRecurrenceThreshold(options);
5
16
  return `# /cc-ops compound
6
17
 
7
18
  ## Purpose
@@ -29,39 +40,48 @@ the user can approve individual lifts, accept-all, or skip.
29
40
 
30
41
  1. Read \`${RUNTIME_ROOT}/knowledge.jsonl\` (strict JSONL, one entry per line).
31
42
  2. Cluster entries by \`trigger\` + \`action\` similarity.
32
- 3. Filter candidates whose recurrence count >= 3.
33
- 4. If **no candidates** exist:
43
+ 3. Resolve recurrence policy:
44
+ - base threshold = \`${recurrenceThreshold}\` (from \`config.compound.recurrenceThreshold\`),
45
+ - count archived runs under \`${RUNTIME_ROOT}/runs/\`,
46
+ - if archived run count is < ${SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD}, use
47
+ effective threshold = \`min(base threshold, ${SMALL_PROJECT_RECURRENCE_THRESHOLD})\` for this pass.
48
+ 4. Filter candidates that satisfy at least one trigger:
49
+ - recurrence count >= effective threshold, or
50
+ - any knowledge entry in the cluster has \`severity: "critical"\`
51
+ (critical override, recurrence can be 1).
52
+ 5. If **no candidates** exist:
34
53
  - set \`closeout.compoundCompletedAt = <ISO>\`,
35
54
  - set \`closeout.compoundPromoted = 0\`,
36
55
  - set \`closeout.shipSubstate = "ready_to_archive"\`,
37
56
  - emit \`compound: no candidates | next: /cc-next\` and stop.
38
- 5. **Drift check** each surviving candidate before presenting it (see
57
+ 6. **Drift check** each surviving candidate before presenting it (see
39
58
  "Drift check" section in the skill): confirm the lift target file is
40
59
  current, spot-check the repo for contradictions, demote stale clusters
41
60
  into a new superseding entry instead of a lift.
42
- 6. Otherwise, present **one** structured ask via the harness's native ask
61
+ 7. Otherwise, present **one** structured ask via the harness's native ask
43
62
  tool (\`AskUserQuestion\` / \`AskQuestion\` / \`question\` /
44
63
  \`request_user_input\`; plain-text lettered list as fallback) summarising
45
64
  all candidates at once:
46
65
  - \`apply-all\` (default) — apply every listed lift,
47
66
  - \`apply-selected\` — prompt per-candidate,
48
67
  - \`skip\` — record a skip reason and advance without changes.
49
- 7. Apply approved lifts to the target file(s). Each lift also appends a
68
+ 8. Apply approved lifts to the target file(s). Each lift also appends a
50
69
  \`type: "compound"\` entry back to \`${RUNTIME_ROOT}/knowledge.jsonl\`
51
70
  summarising what was lifted.
52
- 8. Update flow-state:
71
+ 9. Update flow-state:
53
72
  - \`closeout.compoundCompletedAt = <ISO>\`,
54
73
  - \`closeout.compoundPromoted = <count>\`,
55
74
  - \`closeout.compoundSkipped = true\` if user picked skip,
56
75
  - \`closeout.shipSubstate = "ready_to_archive"\`.
57
- 9. Emit one-line summary: \`compound: promoted=<N> skipped=<bool> | next: /cc-next\`.
76
+ 10. Emit one-line summary: \`compound: promoted=<N> skipped=<bool> | next: /cc-next\`.
58
77
 
59
78
  ## Primary skill
60
79
 
61
80
  **${RUNTIME_ROOT}/skills/${COMPOUND_SKILL_FOLDER}/SKILL.md**
62
81
  `;
63
82
  }
64
- export function compoundCommandSkillMarkdown() {
83
+ export function compoundCommandSkillMarkdown(options = {}) {
84
+ const recurrenceThreshold = resolveRecurrenceThreshold(options);
65
85
  return `---
66
86
  name: ${COMPOUND_SKILL_NAME}
67
87
  description: "Lift repeated learnings into durable rules/protocols/skills. Auto-triggered after retro accept."
@@ -83,13 +103,20 @@ empty pass is allowed and must advance \`closeout.shipSubstate\` to
83
103
 
84
104
  1. Parse \`.cclaw/knowledge.jsonl\` and group repeated lessons by
85
105
  trigger+action similarity.
86
- 2. Keep only candidates with recurrence >= 3 and an actionable lift path.
87
- 3. If none qualify, record an empty pass:
106
+ 2. Resolve recurrence policy:
107
+ - base threshold = \`${recurrenceThreshold}\` from \`config.compound.recurrenceThreshold\`,
108
+ - count archived runs under \`.cclaw/runs/\`,
109
+ - if archived run count is < ${SMALL_PROJECT_ARCHIVE_RUNS_THRESHOLD}, use
110
+ effective threshold = \`min(base threshold, ${SMALL_PROJECT_RECURRENCE_THRESHOLD})\` for this pass.
111
+ 3. Keep only candidates that meet at least one trigger:
112
+ - recurrence >= effective threshold and actionable lift path, or
113
+ - a cluster entry with \`severity: critical\` (critical override, recurrence can be 1).
114
+ 4. If none qualify, record an empty pass:
88
115
  - \`closeout.compoundCompletedAt = <ISO>\`,
89
116
  - \`closeout.compoundPromoted = 0\`,
90
117
  - \`closeout.shipSubstate = "ready_to_archive"\`,
91
118
  - announce \`compound: no candidates\` and stop.
92
- 4. **Drift check — run before presenting any candidate.** Knowledge lines
119
+ 5. **Drift check — run before presenting any candidate.** Knowledge lines
93
120
  are append-only, so textual repetition alone does not prove the rule is
94
121
  still true. For every cluster that survives the recurrence filter:
95
122
 
@@ -111,13 +138,17 @@ empty pass is allowed and must advance \`closeout.shipSubstate\` to
111
138
  - **Cite line IDs.** Every surviving candidate must list the concrete
112
139
  knowledge line indices (1-based) that back it, not just a
113
140
  summary string. This is what makes the lift auditable.
141
+ - **Include qualification reason.** Mark each candidate as
142
+ \`recurrence\` or \`critical_override\` so reviewers can see why it passed
143
+ the filter.
114
144
  - Optionally invoke the \`knowledge-curation\` utility skill's
115
145
  stale/duplicate/supersede heuristics if you want a second pass.
116
146
 
117
- 5. Otherwise, render each candidate as:
147
+ 6. Otherwise, render each candidate as:
118
148
 
119
149
  \`\`\`
120
150
  Candidate: <short title>
151
+ Qualification: <recurrence|critical_override>
121
152
  Evidence: <knowledge line-ids>
122
153
  Freshness: <newest last_seen_ts among evidence lines>
123
154
  Lift target: <rule/protocol/skill file>
@@ -125,17 +156,17 @@ Change type: <add/update/remove>
125
156
  Expected benefit: <what regressions this prevents>
126
157
  \`\`\`
127
158
 
128
- 6. Present **one** structured question with three options:
159
+ 7. Present **one** structured question with three options:
129
160
  - \`apply-all\` (default) — apply every candidate,
130
161
  - \`apply-selected\` — prompt per-candidate approval next,
131
162
  - \`skip\` — record a skip reason and advance.
132
163
 
133
- 7. For approved candidates:
164
+ 8. For approved candidates:
134
165
  - edit the target file(s) with the lift,
135
166
  - append a \`type: "compound"\` entry to \`.cclaw/knowledge.jsonl\`
136
167
  describing what was promoted, including the source line IDs.
137
168
 
138
- 8. Update flow-state \`closeout\`:
169
+ 9. Update flow-state \`closeout\`:
139
170
  - \`compoundCompletedAt\`,
140
171
  - \`compoundPromoted\` (count),
141
172
  - \`compoundSkipped\` (boolean) + \`compoundSkipReason\` when applicable,
@@ -34,7 +34,7 @@ ${schema.hardGate}
34
34
  2. Resolve active artifact root: \`.cclaw/artifacts/\`.
35
35
  3. Load required upstream artifacts for this stage:
36
36
  ${hydrationLines}
37
- 4. Stream \`.cclaw/knowledge.jsonl\` and apply relevant JSON-line entries (strict schema: type, trigger, action, confidence, domain, stage, origin_stage, origin_feature, frequency, universality, maturity, created, first_seen_ts, last_seen_ts, project).
37
+ 4. Stream \`.cclaw/knowledge.jsonl\` and apply relevant JSON-line entries (strict schema: type, trigger, action, confidence, domain, stage, origin_stage, origin_feature, frequency, universality, maturity, created, first_seen_ts, last_seen_ts, project; optional: source, severity).
38
38
  5. Write stage output to ${writeStepPaths}.
39
39
  6. Do NOT copy artifacts into \`.cclaw/runs/\`; archival is handled by \`/cc-ops archive\` (agent-facing wrapper over archive runtime).
40
40
 
@@ -16,4 +16,5 @@ export declare function stageExamplesReferenceMarkdown(stage: FlowStage): string
16
16
  */
17
17
  export declare function stageExamples(stage: FlowStage): string;
18
18
  export type ExampleDomain = "web" | "cli" | "library" | "data-pipeline";
19
+ export declare const RESEARCH_FLEET_USAGE_EXAMPLE: string;
19
20
  export declare function stageDomainExamples(stage: FlowStage): string;