cclaw-cli 0.48.0 → 0.48.1

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.
@@ -1,4 +1,4 @@
1
- import { type FlowStage } from "./types.js";
1
+ import { type FlowStage, type FlowTrack } from "./types.js";
2
2
  export interface LintFinding {
3
3
  section: string;
4
4
  required: boolean;
@@ -46,7 +46,7 @@ export interface LearningsParseResult {
46
46
  details: string;
47
47
  }
48
48
  export declare function parseLearningsSection(sectionBody: string): LearningsParseResult;
49
- export declare function lintArtifact(projectRoot: string, stage: FlowStage): Promise<LintResult>;
49
+ export declare function lintArtifact(projectRoot: string, stage: FlowStage, track?: FlowTrack): Promise<LintResult>;
50
50
  export declare function validateReviewArmy(projectRoot: string): Promise<{
51
51
  valid: boolean;
52
52
  errors: string[];
@@ -795,8 +795,8 @@ function validateSectionBody(sectionBody, rule, sectionName) {
795
795
  details: "Section heading and content satisfy lint heuristics."
796
796
  };
797
797
  }
798
- export async function lintArtifact(projectRoot, stage) {
799
- const schema = stageSchema(stage);
798
+ export async function lintArtifact(projectRoot, stage, track = "standard") {
799
+ const schema = stageSchema(stage, track);
800
800
  const { absPath: absFile, relPath: relFile } = await resolveArtifactPath(projectRoot, schema.artifactFile);
801
801
  const findings = [];
802
802
  if (!(await exists(absFile))) {
@@ -24,8 +24,60 @@ export interface AgentDefinition {
24
24
  }
25
25
  /**
26
26
  * Canonical specialist roster (core-5) materialized under `.cclaw/agents/`.
27
+ *
28
+ * Declared with `satisfies` so the array retains literal `name` types for
29
+ * downstream type-level consumers (e.g. `AgentName`), while still being
30
+ * checked against the `AgentDefinition` shape at compile time. Do not add
31
+ * an explicit `AgentDefinition[]` annotation here — it would widen `name`
32
+ * to `string` and break the compile-time drift guard.
33
+ */
34
+ export declare const CCLAW_AGENTS: readonly [{
35
+ readonly name: "planner";
36
+ readonly description: "MANDATORY for scope/design/plan and PROACTIVE for high-ambiguity work. MUST BE USED when sequencing, dependency mapping, or risk trade-offs are required before coding.";
37
+ readonly tools: ["Read", "Grep", "Glob", "WebSearch"];
38
+ readonly model: "deep";
39
+ readonly activation: "mandatory";
40
+ readonly relatedStages: ["brainstorm", "scope", "design", "spec", "plan"];
41
+ readonly body: string;
42
+ }, {
43
+ readonly name: "reviewer";
44
+ readonly description: "MANDATORY during review. MUST BE USED to run a two-pass audit: spec compliance first, then correctness/maintainability/performance/architecture.";
45
+ readonly tools: ["Read", "Grep", "Glob"];
46
+ readonly model: "balanced";
47
+ readonly activation: "mandatory";
48
+ readonly relatedStages: ["spec", "review", "ship"];
49
+ readonly body: string;
50
+ }, {
51
+ readonly name: "security-reviewer";
52
+ readonly description: "MANDATORY during review; PROACTIVE during design/ship for trust-boundary changes. Always produce an explicit no-change attestation when no security-relevant surface moved.";
53
+ readonly tools: ["Read", "Grep", "Glob"];
54
+ readonly model: "balanced";
55
+ readonly activation: "mandatory";
56
+ readonly relatedStages: ["design", "review", "ship"];
57
+ readonly body: string;
58
+ }, {
59
+ readonly name: "test-author";
60
+ readonly description: "MANDATORY in TDD stage. MUST BE USED for RED -> GREEN -> REFACTOR with evidence-first discipline.";
61
+ readonly tools: ["Read", "Write", "Edit", "Grep", "Glob", "Bash"];
62
+ readonly model: "balanced";
63
+ readonly activation: "mandatory";
64
+ readonly relatedStages: ["tdd"];
65
+ readonly body: string;
66
+ }, {
67
+ readonly name: "doc-updater";
68
+ readonly description: "MANDATORY at ship and PROACTIVE when behavior/config/public API changes. Keep docs and runbooks in lockstep with shipped behavior.";
69
+ readonly tools: ["Read", "Write", "Edit", "Grep", "Glob"];
70
+ readonly model: "fast";
71
+ readonly activation: "mandatory";
72
+ readonly relatedStages: ["tdd", "ship"];
73
+ readonly body: string;
74
+ }];
75
+ /**
76
+ * Union of known agent names (compile-time). Use this in content that
77
+ * references agents by name so the TypeScript compiler catches renames
78
+ * and typos instead of letting them slip into generated artifacts.
27
79
  */
28
- export declare const CCLAW_AGENTS: AgentDefinition[];
80
+ export type AgentName = (typeof CCLAW_AGENTS)[number]["name"];
29
81
  /**
30
82
  * Render a complete cclaw agent markdown file (YAML frontmatter + body).
31
83
  */
@@ -15,6 +15,12 @@ function yamlFlowSequence(values) {
15
15
  }
16
16
  /**
17
17
  * Canonical specialist roster (core-5) materialized under `.cclaw/agents/`.
18
+ *
19
+ * Declared with `satisfies` so the array retains literal `name` types for
20
+ * downstream type-level consumers (e.g. `AgentName`), while still being
21
+ * checked against the `AgentDefinition` shape at compile time. Do not add
22
+ * an explicit `AgentDefinition[]` annotation here — it would widen `name`
23
+ * to `string` and break the compile-time drift guard.
18
24
  */
19
25
  export const CCLAW_AGENTS = [
20
26
  {
@@ -1094,11 +1094,20 @@ export function claudeHooksJsonWithObservation() {
1094
1094
  }]
1095
1095
  }],
1096
1096
  PreToolUse: [{
1097
+ // `prompt-guard.sh` inspects tool inputs across all tool calls;
1098
+ // it has to stay on `*` so it sees MCP/Edit/Write/WebSearch
1099
+ // traffic too. `workflow-guard.sh`, however, only checks TDD
1100
+ // ordering on write-like operations — it is a no-op for reads.
1101
+ // Splitting the two matchers cuts Claude's per-read hook
1102
+ // overhead in half without reducing coverage on write paths.
1097
1103
  matcher: "*",
1098
1104
  hooks: [{
1099
1105
  type: "command",
1100
1106
  command: hookDispatcherCommand("prompt-guard.sh")
1101
- }, {
1107
+ }]
1108
+ }, {
1109
+ matcher: "Write|Edit|MultiEdit|NotebookEdit|Bash",
1110
+ hooks: [{
1102
1111
  type: "command",
1103
1112
  command: hookDispatcherCommand("workflow-guard.sh")
1104
1113
  }]
@@ -1196,6 +1205,18 @@ export function codexHooksJsonWithObservation() {
1196
1205
  hooks: [{
1197
1206
  type: "command",
1198
1207
  command: hookDispatcherCommand("prompt-guard.sh")
1208
+ }, {
1209
+ // `workflow-guard.sh` also runs here because Codex's PreToolUse
1210
+ // only sees Bash; Write/Edit/MCP writes never reach the hook
1211
+ // surface. Running workflow-guard on UserPromptSubmit catches
1212
+ // TDD-order violations that originate from the user's prompt
1213
+ // text (e.g. "edit X.ts to ..."). Payload is a prompt envelope,
1214
+ // not a tool call, so the script's TOOL extraction falls back
1215
+ // to "unknown" and advisory mode is a no-op by design — the
1216
+ // value is that prompt text is scanned for write-shaped intent
1217
+ // via the existing PAYLOAD_LOWER heuristics.
1218
+ type: "command",
1219
+ command: hookDispatcherCommand("workflow-guard.sh")
1199
1220
  }, {
1200
1221
  type: "command",
1201
1222
  command: "bash -lc 'if command -v cclaw >/dev/null 2>&1; then cclaw internal verify-current-state --quiet >/dev/null || true; else npx -y cclaw-cli internal verify-current-state --quiet >/dev/null || true; fi'"
@@ -233,11 +233,15 @@ export default function cclawPlugin(ctx) {
233
233
  eventType === "session.created" ||
234
234
  eventType === "session.resumed" ||
235
235
  eventType === "session.compacted" ||
236
- eventType === "session.cleared"
236
+ eventType === "session.cleared" ||
237
+ eventType === "session.updated"
237
238
  ) {
238
239
  // Avoid writing directly to stdout in lifecycle hooks because it can
239
240
  // interfere with OpenCode TUI rendering. Bootstrap is injected via
240
241
  // the system transform hook instead.
242
+ // session.updated covers config reloads and artifact/rules edits
243
+ // that happen mid-session; without it the cache would stay stale
244
+ // until the next compaction or restart.
241
245
  refreshBootstrapCache();
242
246
  }
243
247
  if (eventType === "session.compacted") {
@@ -5,6 +5,7 @@ import { tddStageForTrack } from "./stages/tdd.js";
5
5
  const ARTIFACT_STAGE_BY_PATH = {
6
6
  ".cclaw/artifacts/01-brainstorm.md": "brainstorm",
7
7
  ".cclaw/artifacts/02-scope.md": "scope",
8
+ ".cclaw/artifacts/02a-research.md": "design",
8
9
  ".cclaw/artifacts/03-design.md": "design",
9
10
  ".cclaw/artifacts/04-spec.md": "spec",
10
11
  ".cclaw/artifacts/05-plan.md": "plan",
@@ -46,6 +47,7 @@ const REQUIRED_GATE_IDS = {
46
47
  "tdd_green_full_suite",
47
48
  "tdd_refactor_completed",
48
49
  "tdd_verified_before_complete",
50
+ "tdd_docs_drift_check",
49
51
  ...(track === "quick" ? [] : ["tdd_traceable_to_plan"])
50
52
  ],
51
53
  review: (track) => [
@@ -1,10 +1,11 @@
1
+ import { CCLAW_VERSION } from "../constants.js";
1
2
  import { orderedStageSchemas } from "./stage-schema.js";
2
3
  import { FLOW_STAGES } from "../types.js";
3
4
  export const ARTIFACT_TEMPLATES = {
4
5
  "01-brainstorm.md": `---
5
6
  stage: brainstorm
6
7
  schema_version: 1
7
- version: 0.18.0
8
+ version: ${CCLAW_VERSION}
8
9
  feature: <feature-id>
9
10
  locked_decisions: []
10
11
  inputs_hash: sha256:pending
@@ -52,7 +53,7 @@ inputs_hash: sha256:pending
52
53
  "02-scope.md": `---
53
54
  stage: scope
54
55
  schema_version: 1
55
- version: 0.18.0
56
+ version: ${CCLAW_VERSION}
56
57
  feature: <feature-id>
57
58
  locked_decisions: []
58
59
  inputs_hash: sha256:pending
@@ -158,7 +159,7 @@ inputs_hash: sha256:pending
158
159
  "02a-research.md": `---
159
160
  stage: design
160
161
  schema_version: 1
161
- version: 0.18.0
162
+ version: ${CCLAW_VERSION}
162
163
  feature: <feature-id>
163
164
  locked_decisions: []
164
165
  inputs_hash: sha256:pending
@@ -199,7 +200,7 @@ inputs_hash: sha256:pending
199
200
  "03-design.md": `---
200
201
  stage: design
201
202
  schema_version: 1
202
- version: 0.18.0
203
+ version: ${CCLAW_VERSION}
203
204
  feature: <feature-id>
204
205
  locked_decisions: []
205
206
  inputs_hash: sha256:pending
@@ -303,7 +304,7 @@ inputs_hash: sha256:pending
303
304
  "04-spec.md": `---
304
305
  stage: spec
305
306
  schema_version: 1
306
- version: 0.18.0
307
+ version: ${CCLAW_VERSION}
307
308
  feature: <feature-id>
308
309
  locked_decisions: []
309
310
  inputs_hash: sha256:pending
@@ -359,7 +360,7 @@ inputs_hash: sha256:pending
359
360
  "05-plan.md": `---
360
361
  stage: plan
361
362
  schema_version: 1
362
- version: 0.18.0
363
+ version: ${CCLAW_VERSION}
363
364
  feature: <feature-id>
364
365
  locked_decisions: []
365
366
  inputs_hash: sha256:pending
@@ -438,7 +439,7 @@ Execution rule: complete and verify each batch before starting the next batch.
438
439
  "06-tdd.md": `---
439
440
  stage: tdd
440
441
  schema_version: 1
441
- version: 0.18.0
442
+ version: ${CCLAW_VERSION}
442
443
  feature: <feature-id>
443
444
  locked_decisions: []
444
445
  inputs_hash: sha256:pending
@@ -505,7 +506,7 @@ inputs_hash: sha256:pending
505
506
  "07-review.md": `---
506
507
  stage: review
507
508
  schema_version: 1
508
- version: 0.18.0
509
+ version: ${CCLAW_VERSION}
509
510
  feature: <feature-id>
510
511
  locked_decisions: []
511
512
  inputs_hash: sha256:pending
@@ -614,7 +615,7 @@ inputs_hash: sha256:pending
614
615
  "08-ship.md": `---
615
616
  stage: ship
616
617
  schema_version: 1
617
- version: 0.18.0
618
+ version: ${CCLAW_VERSION}
618
619
  feature: <feature-id>
619
620
  locked_decisions: []
620
621
  inputs_hash: sha256:pending
@@ -669,7 +670,7 @@ inputs_hash: sha256:pending
669
670
  "09-retro.md": `---
670
671
  stage: retro
671
672
  schema_version: 1
672
- version: 0.18.0
673
+ version: ${CCLAW_VERSION}
673
674
  feature: <feature-id>
674
675
  locked_decisions: []
675
676
  inputs_hash: sha256:pending
@@ -48,4 +48,10 @@ export declare const LANGUAGE_RULE_PACK_GENERATORS: Record<string, () => string>
48
48
  */
49
49
  export declare const LEGACY_LANGUAGE_RULE_PACK_FOLDERS: readonly ["language-typescript", "language-python", "language-go"];
50
50
  export declare const UTILITY_SKILL_FOLDERS: readonly ["security", "debugging", "performance", "ci-cd", "docs", "executing-plans", "verification-before-completion", "finishing-a-development-branch", "context-engineering", "source-driven-development", "frontend-accessibility", "landscape-check", "adversarial-review", "security-audit", "knowledge-curation", "retrospective", "document-review", "receiving-code-review"];
51
- export declare const UTILITY_SKILL_MAP: Record<string, () => string>;
51
+ export type UtilitySkillFolder = (typeof UTILITY_SKILL_FOLDERS)[number];
52
+ /**
53
+ * One entry per `UTILITY_SKILL_FOLDERS` slot. Typed via the tuple so that
54
+ * adding a folder without a generator (or vice versa) is a TypeScript
55
+ * error — keeps the two sources of truth in lockstep at compile time.
56
+ */
57
+ export declare const UTILITY_SKILL_MAP: Record<UtilitySkillFolder, () => string>;
@@ -1735,6 +1735,11 @@ export const UTILITY_SKILL_FOLDERS = [
1735
1735
  "document-review",
1736
1736
  "receiving-code-review"
1737
1737
  ];
1738
+ /**
1739
+ * One entry per `UTILITY_SKILL_FOLDERS` slot. Typed via the tuple so that
1740
+ * adding a folder without a generator (or vice versa) is a TypeScript
1741
+ * error — keeps the two sources of truth in lockstep at compile time.
1742
+ */
1738
1743
  export const UTILITY_SKILL_MAP = {
1739
1744
  security: securityReviewSkill,
1740
1745
  debugging: debuggingSkill,
@@ -66,6 +66,16 @@ export type DelegationLedger = {
66
66
  runId: string;
67
67
  entries: DelegationEntry[];
68
68
  };
69
+ /**
70
+ * Heuristic: does a changed file path strongly imply a trust-boundary
71
+ * surface? Used to gate adversarial-reviewer requirements on review.
72
+ *
73
+ * Matches authN/Z, credentials, crypto, policy, or explicit sanitization
74
+ * or injection handling. Intentionally excludes broad terms like `input`
75
+ * and `validation` because they match innocuous paths such as
76
+ * `form-input.ts` or `number-validation.ts` and produce false positives.
77
+ */
78
+ export declare function isTrustBoundaryPath(filePath: string): boolean;
69
79
  export declare function readDelegationLedger(projectRoot: string): Promise<DelegationLedger>;
70
80
  export declare function appendDelegation(projectRoot: string, entry: DelegationEntry): Promise<void>;
71
81
  /**
@@ -53,6 +53,18 @@ async function resolveReviewDiffBase(projectRoot) {
53
53
  return null;
54
54
  }
55
55
  }
56
+ /**
57
+ * Heuristic: does a changed file path strongly imply a trust-boundary
58
+ * surface? Used to gate adversarial-reviewer requirements on review.
59
+ *
60
+ * Matches authN/Z, credentials, crypto, policy, or explicit sanitization
61
+ * or injection handling. Intentionally excludes broad terms like `input`
62
+ * and `validation` because they match innocuous paths such as
63
+ * `form-input.ts` or `number-validation.ts` and produce false positives.
64
+ */
65
+ export function isTrustBoundaryPath(filePath) {
66
+ return /(auth|security|secret|token|credential|permission|acl|policy|oauth|session|encrypt|decrypt|sanitize|untrusted|csrf|xss|injection|taint)/iu.test(filePath);
67
+ }
56
68
  async function detectReviewTriggers(projectRoot) {
57
69
  const empty = {
58
70
  changedFiles: 0,
@@ -81,7 +93,7 @@ async function detectReviewTriggers(projectRoot) {
81
93
  .split(/\r?\n/gu)
82
94
  .map((line) => line.trim())
83
95
  .filter((line) => line.length > 0);
84
- const trustBoundaryChanged = changedPaths.some((filePath) => /(auth|security|secret|token|credential|permission|acl|policy|oauth|session|encrypt|decrypt|input|validation)/iu.test(filePath));
96
+ const trustBoundaryChanged = changedPaths.some((p) => isTrustBoundaryPath(p));
85
97
  const requireAdversarialReviewer = changedLines > 100 || changedFiles > 10 || trustBoundaryChanged;
86
98
  return {
87
99
  changedFiles,
package/dist/doctor.js CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { promisify } from "node:util";
6
- import { REQUIRED_DIRS, RUNTIME_ROOT } from "./constants.js";
6
+ import { REQUIRED_DIRS, RUNTIME_ROOT, UTILITY_COMMANDS } from "./constants.js";
7
7
  import { CCLAW_AGENTS } from "./content/core-agents.js";
8
8
  import { detectAdvancedKeys, readConfig } from "./config.js";
9
9
  import { exists } from "./fs-utils.js";
@@ -547,8 +547,8 @@ export async function doctorChecks(projectRoot, options = {}) {
547
547
  ok: agentsBlockOk,
548
548
  details: `${agentsFile} must contain the managed cclaw marker block with routing, verification, and minimal detail pointer`
549
549
  });
550
- // Utility commands
551
- for (const cmd of ["learn", "next", "ideate", "status", "tree", "diff", "feature", "tdd-log", "retro", "compound", "rewind"]) {
550
+ // Utility commands — keep in sync with UTILITY_COMMANDS (src/constants.ts)
551
+ for (const cmd of UTILITY_COMMANDS) {
552
552
  const cmdPath = path.join(projectRoot, RUNTIME_ROOT, "commands", `${cmd}.md`);
553
553
  checks.push({
554
554
  name: `utility_command:${cmd}`,
@@ -815,7 +815,7 @@ export async function doctorChecks(projectRoot, options = {}) {
815
815
  });
816
816
  checks.push({
817
817
  name: `shim:codex:${skillName}:frontmatter`,
818
- ok,
818
+ ok: frontmatterOk,
819
819
  details: frontmatterOk
820
820
  ? `${skillPath} has \`name: ${skillName}\` frontmatter`
821
821
  : ok
@@ -94,6 +94,6 @@ export declare function skippedStagesForTrack(track: FlowTrack): FlowStage[];
94
94
  export declare function firstStageForTrack(track: FlowTrack): FlowStage;
95
95
  export declare function createInitialFlowState(activeRunIdOrOptions?: string | InitialFlowStateOptions, maybeTrack?: FlowTrack): FlowState;
96
96
  export declare function canTransition(from: FlowStage, to: FlowStage): boolean;
97
- export declare function getTransitionGuards(from: FlowStage, to: FlowStage): string[];
97
+ export declare function getTransitionGuards(from: FlowStage, to: FlowStage, track?: FlowTrack): string[];
98
98
  export declare function nextStage(stage: FlowStage, track?: FlowTrack): FlowStage | null;
99
99
  export declare function previousStage(stage: FlowStage, track?: FlowTrack): FlowStage | null;
@@ -97,7 +97,21 @@ export function createInitialFlowState(activeRunIdOrOptions = "active", maybeTra
97
97
  export function canTransition(from, to) {
98
98
  return TRANSITION_RULES.some((rule) => rule.from === from && rule.to === to);
99
99
  }
100
- export function getTransitionGuards(from, to) {
100
+ export function getTransitionGuards(from, to, track = "standard") {
101
+ // Natural forward edge on this track: derive guards fresh from the
102
+ // track-specific gate schema. `TRANSITION_RULES` collapses shared edges
103
+ // across tracks (first-registered wins), so reading guards directly
104
+ // from the track-aware schema avoids silently dropping gates that only
105
+ // the current track requires (e.g. `tdd_traceable_to_plan` on standard
106
+ // gets lost if quick was registered first).
107
+ const ordered = TRACK_STAGES[track];
108
+ const fromIdx = ordered.indexOf(from);
109
+ if (fromIdx >= 0 && ordered[fromIdx + 1] === to) {
110
+ return stageGateIds(from, track);
111
+ }
112
+ // Non-neighbour edges (e.g. `review -> tdd` with `review_verdict_blocked`)
113
+ // carry special guards not derivable from a stage's gate catalog; fall
114
+ // back to the pre-computed rule table.
101
115
  const match = TRANSITION_RULES.find((rule) => rule.from === from && rule.to === to);
102
116
  return match ? [...match.guards] : [];
103
117
  }
@@ -1,4 +1,13 @@
1
1
  export declare function ensureDir(dirPath: string): Promise<void>;
2
+ /**
3
+ * Strip a leading UTF-8 BOM (U+FEFF) if present. Many editors (VS Code on
4
+ * Windows, Notepad, some CI tools) silently prepend a BOM when saving
5
+ * UTF-8; when the file is then split on `\n` the first line keeps the
6
+ * invisible BOM and `JSON.parse` rejects it, which caused the first
7
+ * knowledge.jsonl entry to be silently dropped on load. Treat BOM as a
8
+ * no-op at read time so the rest of the pipeline sees clean UTF-8.
9
+ */
10
+ export declare function stripBom(text: string): string;
2
11
  export interface DirectoryLockOptions {
3
12
  retries?: number;
4
13
  retryDelayMs?: number;
package/dist/fs-utils.js CHANGED
@@ -3,6 +3,17 @@ import path from "node:path";
3
3
  export async function ensureDir(dirPath) {
4
4
  await fs.mkdir(dirPath, { recursive: true });
5
5
  }
6
+ /**
7
+ * Strip a leading UTF-8 BOM (U+FEFF) if present. Many editors (VS Code on
8
+ * Windows, Notepad, some CI tools) silently prepend a BOM when saving
9
+ * UTF-8; when the file is then split on `\n` the first line keeps the
10
+ * invisible BOM and `JSON.parse` rejects it, which caused the first
11
+ * knowledge.jsonl entry to be silently dropped on load. Treat BOM as a
12
+ * no-op at read time so the rest of the pipeline sees clean UTF-8.
13
+ */
14
+ export function stripBom(text) {
15
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
16
+ }
6
17
  function sleep(ms) {
7
18
  return new Promise((resolve) => setTimeout(resolve, ms));
8
19
  }
@@ -54,7 +65,30 @@ export async function writeFileSafe(filePath, content) {
54
65
  await ensureDir(path.dirname(filePath));
55
66
  const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
56
67
  await fs.writeFile(tempPath, content, "utf8");
57
- await fs.rename(tempPath, filePath);
68
+ try {
69
+ await fs.rename(tempPath, filePath);
70
+ }
71
+ catch (error) {
72
+ const code = error?.code;
73
+ // `rename` fails with EXDEV when the temp file and target live on
74
+ // different filesystems (container bind mounts, tmpfs + rootfs,
75
+ // cross-volume setups). Fall back to copy + unlink so atomic writes
76
+ // still work — copyFile is not fully atomic but is the best we can
77
+ // do across devices, and we remove the temp even if copy fails.
78
+ if (code === "EXDEV") {
79
+ try {
80
+ await fs.copyFile(tempPath, filePath);
81
+ }
82
+ finally {
83
+ await fs.unlink(tempPath).catch(() => undefined);
84
+ }
85
+ return;
86
+ }
87
+ // Other errors: try to clean up the temp to avoid littering the
88
+ // directory with orphaned `.tmp-<pid>-*` files, then rethrow.
89
+ await fs.unlink(tempPath).catch(() => undefined);
90
+ throw error;
91
+ }
58
92
  }
59
93
  export async function exists(filePath) {
60
94
  try {
@@ -212,7 +212,7 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
212
212
  const artifactPresent = await currentStageArtifactExists(projectRoot, stage, flowState.track);
213
213
  const shouldValidateArtifact = artifactPresent || catalog.passed.length > 0 || flowState.completedStages.includes(stage);
214
214
  if (shouldValidateArtifact) {
215
- const lint = await lintArtifact(projectRoot, stage);
215
+ const lint = await lintArtifact(projectRoot, stage, flowState.track);
216
216
  if (!lint.passed) {
217
217
  const failedRequired = lint.findings
218
218
  .filter((finding) => finding.required && !finding.found)
package/dist/gitignore.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { REQUIRED_GITIGNORE_PATTERNS } from "./constants.js";
4
- import { exists } from "./fs-utils.js";
4
+ import { exists, writeFileSafe } from "./fs-utils.js";
5
5
  export async function ensureGitignore(projectRoot) {
6
6
  const gitignorePath = path.join(projectRoot, ".gitignore");
7
7
  const currentContent = (await exists(gitignorePath))
@@ -15,7 +15,10 @@ export async function ensureGitignore(projectRoot) {
15
15
  }
16
16
  const base = lines.join("\n").replace(/\s+$/u, "");
17
17
  const suffix = `${base.length > 0 ? "\n" : ""}${missing.join("\n")}\n`;
18
- await fs.writeFile(gitignorePath, `${base}${suffix}`, "utf8");
18
+ // `writeFileSafe` performs a tmp-file + rename so a crash mid-write
19
+ // cannot leave `.gitignore` in a half-written state; the previous
20
+ // direct `fs.writeFile` could truncate the file on SIGKILL.
21
+ await writeFileSafe(gitignorePath, `${base}${suffix}`);
19
22
  }
20
23
  export async function removeGitignorePatterns(projectRoot) {
21
24
  const gitignorePath = path.join(projectRoot, ".gitignore");
@@ -30,7 +33,7 @@ export async function removeGitignorePatterns(projectRoot) {
30
33
  await fs.rm(gitignorePath, { force: true });
31
34
  }
32
35
  else {
33
- await fs.writeFile(gitignorePath, `${result}\n`, "utf8");
36
+ await writeFileSafe(gitignorePath, `${result}\n`);
34
37
  }
35
38
  }
36
39
  export async function gitignoreHasRequiredPatterns(projectRoot) {
@@ -54,7 +54,12 @@ const LEGACY_CODEX_SKILL_NAMES = [
54
54
  "cclaw-cc-next",
55
55
  "cclaw-cc-view",
56
56
  "cclaw-cc-ops",
57
- "cclaw-cc-ideate"
57
+ "cclaw-cc-ideate",
58
+ // Pre-v0.40 installed `/cc-learn` as a top-level skill before it was
59
+ // folded into `/cc-ops`. Without this entry the orphan stays behind
60
+ // after upgrade and Codex lists both the new in-thread workflow and
61
+ // the legacy slash command.
62
+ "cclaw-cc-learn"
58
63
  ];
59
64
  /**
60
65
  * Shims that older cclaw versions installed as top-level slash commands but
@@ -417,6 +422,11 @@ what the hook surface does and does not cover.
417
422
  are **not** gated by hooks — read
418
423
  \`.cclaw/references/harnesses/codex-playbook.md\` for what cclaw
419
424
  substitutes with in-turn agent steps for those call classes.
425
+ - Codex's \`SessionStart\` matcher only supports \`startup|resume\`. Claude
426
+ and Cursor also fire on \`clear\` and \`compact\`, so mid-session
427
+ context resets there re-inject cclaw's bootstrap automatically. In
428
+ Codex you must re-announce the active stage yourself after any
429
+ \`/clear\` or compaction — the skill does not reload implicitly.
420
430
  `;
421
431
  }
422
432
  function codexSkillMarkdown(command, skillName, skillFolder, commandFile) {
package/dist/install.js CHANGED
@@ -44,7 +44,8 @@ import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
44
44
  import { ensureGitignore, removeGitignorePatterns } from "./gitignore.js";
45
45
  import { HARNESS_ADAPTERS, harnessShimFileNames, harnessTier, syncHarnessShims, removeCclawFromAgentsMd } from "./harness-adapters.js";
46
46
  import { validateHookDocument } from "./hook-schema.js";
47
- import { ensureRunSystem, readFlowState } from "./runs.js";
47
+ import { detectHarnesses } from "./init-detect.js";
48
+ import { CorruptFlowStateError, ensureRunSystem, readFlowState } from "./runs.js";
48
49
  import { FLOW_STAGES } from "./types.js";
49
50
  const OPENCODE_PLUGIN_REL_PATH = ".opencode/plugins/cclaw-plugin.mjs";
50
51
  const CURSOR_RULE_REL_PATH = ".cursor/rules/cclaw-workflow.mdc";
@@ -853,7 +854,24 @@ Drop this section if no hard rule applies. Keep it crisp:
853
854
  async function ensureSessionStateFiles(projectRoot) {
854
855
  const stateDir = runtimePath(projectRoot, "state");
855
856
  await ensureDir(stateDir);
856
- const flow = await readFlowState(projectRoot);
857
+ // If flow-state.json is corrupt, `readFlowState` quarantines the bad
858
+ // file and throws. During install we'd rather continue than abort:
859
+ // the user just asked to set up cclaw, and the corrupt file is already
860
+ // preserved next to the original path. Fall back to a fresh initial
861
+ // state so the rest of install completes and the user can inspect the
862
+ // `.corrupt-<timestamp>.json` quarantine afterwards.
863
+ let flow;
864
+ try {
865
+ flow = await readFlowState(projectRoot);
866
+ }
867
+ catch (err) {
868
+ if (err instanceof CorruptFlowStateError) {
869
+ flow = createInitialFlowState();
870
+ }
871
+ else {
872
+ throw err;
873
+ }
874
+ }
857
875
  const activityPath = path.join(stateDir, "stage-activity.jsonl");
858
876
  if (!(await exists(activityPath))) {
859
877
  await writeFileSafe(activityPath, "");
@@ -1021,6 +1039,14 @@ async function writeHarnessGapsState(projectRoot, harnesses) {
1021
1039
  break;
1022
1040
  }
1023
1041
  for (const event of missingHookEvents) {
1042
+ if (harness === "codex" && event === "precompact_digest") {
1043
+ // Codex CLI has no PreCompact event. Generic "schedule the script
1044
+ // manually" copy doesn't help; instead, point the agent at the
1045
+ // in-thread substitute that already exists in cclaw content
1046
+ // (`/cc-ops retro` reads the same digest the hook would emit).
1047
+ remediation.push("hook event precompact_digest → Codex has no PreCompact event; run `/cc-ops retro` in-thread before compaction instead of relying on a hook");
1048
+ continue;
1049
+ }
1024
1050
  remediation.push(`hook event ${event} → schedule the corresponding script manually or accept reduced observability`);
1025
1051
  }
1026
1052
  return {
@@ -1210,9 +1236,19 @@ export async function initCclaw(options) {
1210
1236
  }
1211
1237
  export async function syncCclaw(projectRoot) {
1212
1238
  const configExists = await exists(configPath(projectRoot));
1213
- const config = await readConfig(projectRoot);
1239
+ let config = await readConfig(projectRoot);
1214
1240
  if (!configExists) {
1215
- await writeConfig(projectRoot, createDefaultConfig(config.harnesses));
1241
+ // Prefer detected harness markers over the hardcoded default list.
1242
+ // Without this, a user running `cclaw sync` in a `.claude`-only
1243
+ // project ends up with a config that also enables cursor/opencode/
1244
+ // codex, which then fails doctor checks for missing shim folders.
1245
+ // Fall back to the previous default (config.harnesses) if no markers
1246
+ // are found so brand-new projects still bootstrap cleanly.
1247
+ const detected = await detectHarnesses(projectRoot);
1248
+ const harnesses = detected.length > 0 ? detected : config.harnesses;
1249
+ const defaultConfig = createDefaultConfig(harnesses);
1250
+ await writeConfig(projectRoot, defaultConfig);
1251
+ config = defaultConfig;
1216
1252
  }
1217
1253
  await materializeRuntime(projectRoot, config, false);
1218
1254
  }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "./constants.js";
4
- import { withDirectoryLock } from "./fs-utils.js";
4
+ import { stripBom, withDirectoryLock } from "./fs-utils.js";
5
5
  import { FLOW_STAGES } from "./types.js";
6
6
  const KNOWLEDGE_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
7
7
  const KNOWLEDGE_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
@@ -179,7 +179,7 @@ export function materializeKnowledgeEntry(seed, defaults = {}) {
179
179
  async function readExistingKnowledgeKeys(filePath) {
180
180
  const keys = new Set();
181
181
  try {
182
- const raw = await fs.readFile(filePath, "utf8");
182
+ const raw = stripBom(await fs.readFile(filePath, "utf8"));
183
183
  const lines = raw.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0);
184
184
  for (const line of lines) {
185
185
  try {
@@ -1,14 +1,20 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "./constants.js";
4
- import { exists } from "./fs-utils.js";
4
+ import { exists, stripBom } from "./fs-utils.js";
5
5
  function activeArtifactsPath(projectRoot) {
6
6
  return path.join(projectRoot, RUNTIME_ROOT, "artifacts");
7
7
  }
8
8
  function retroArtifactPath(projectRoot) {
9
9
  return path.join(activeArtifactsPath(projectRoot), "09-retro.md");
10
10
  }
11
- const RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
11
+ // Fallback window for compound-entry scanning when `retroDraftedAt` /
12
+ // `retroAcceptedAt` are not set (legacy runs or imports): use the retro
13
+ // artifact's mtime ± 7 days. 24h was too narrow for long-running retros
14
+ // that are edited over several days or runs imported from another
15
+ // machine with slightly different clocks; 7 days is still tight enough
16
+ // that entries from an unrelated future run are excluded.
17
+ const RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
12
18
  function parseIsoTimestamp(value) {
13
19
  if (!value || value.trim().length === 0)
14
20
  return null;
@@ -58,7 +64,7 @@ export async function evaluateRetroGate(projectRoot, state) {
58
64
  const knowledgeFile = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
59
65
  if (shouldFallbackScan && (await exists(knowledgeFile))) {
60
66
  try {
61
- const raw = await fs.readFile(knowledgeFile, "utf8");
67
+ const raw = stripBom(await fs.readFile(knowledgeFile, "utf8"));
62
68
  compoundEntries = 0;
63
69
  for (const line of raw.split(/\r?\n/)) {
64
70
  const trimmed = line.trim();
@@ -90,17 +96,20 @@ export async function evaluateRetroGate(projectRoot, state) {
90
96
  compoundEntries = 0;
91
97
  }
92
98
  }
93
- // A retro is considered complete when either:
94
- // - at least one compound learning was promoted during the retro window, or
95
- // - the operator explicitly skipped retro or compound (`retroSkipped` /
96
- // `compoundSkipped` recorded in the closeout substate) after reviewing
97
- // the draft. Previously the gate required `compoundEntries > 0`
98
- // unconditionally, which dead-locked ship closeout whenever the retro
99
- // yielded no new patterns worth promoting.
100
- const explicitSkip = Boolean(state.closeout.retroSkipped || state.closeout.compoundSkipped);
101
- const completed = required
102
- ? hasRetroArtifact && (compoundEntries > 0 || explicitSkip)
103
- : true;
99
+ // A retro is considered complete when any of:
100
+ // - the retro artifact exists AND (at least one compound learning was
101
+ // promoted during the retro window OR compound was explicitly skipped
102
+ // after reviewing the draft), or
103
+ // - the operator explicitly skipped the retro step itself
104
+ // (`retroSkipped === true` with a reason). `retroSkipped` is an
105
+ // operator-level override of the artifact requirement, so it must
106
+ // bypass `hasRetroArtifact` otherwise a run that legitimately had
107
+ // nothing worth retro-ing dead-locks at closeout waiting for a
108
+ // file that will never exist.
109
+ const retroSkipped = state.closeout.retroSkipped === true;
110
+ const compoundSkipped = state.closeout.compoundSkipped === true;
111
+ const artifactPathComplete = hasRetroArtifact && (compoundEntries > 0 || compoundSkipped);
112
+ const completed = required ? retroSkipped || artifactPathComplete : true;
104
113
  return {
105
114
  required,
106
115
  completed,
@@ -204,40 +204,70 @@ export async function archiveRun(projectRoot, featureName, options = {}) {
204
204
  compoundEntries: retroGate.compoundEntries
205
205
  };
206
206
  await ensureDir(archivePath);
207
- await fs.rename(artifactsDir, archiveArtifactsPath);
208
- await ensureDir(artifactsDir);
209
- const archiveStatePath = path.join(archivePath, "state");
210
- const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
211
- const resetState = createInitialFlowState();
212
- await writeFlowState(projectRoot, resetState, { allowReset: true });
213
- await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
207
+ // Drop an `.archive-in-progress` sentinel immediately so that a crash
208
+ // between the artifact rename and the final manifest write leaves a
209
+ // recoverable marker (doctor surfaces these; re-running archive on an
210
+ // orphan attempts to complete or roll back). The sentinel is removed
211
+ // only after the manifest lands successfully.
212
+ const sentinelPath = path.join(archivePath, ".archive-in-progress");
214
213
  const archivedAt = new Date().toISOString();
215
- const manifest = {
216
- version: 1,
217
- archiveId,
218
- archivedAt,
219
- featureName: feature,
220
- activeFeature,
221
- sourceRunId: sourceState.activeRunId,
222
- sourceCurrentStage: sourceState.currentStage,
223
- sourceCompletedStages: sourceState.completedStages,
224
- snapshottedStateFiles,
225
- retro: retroSummary
226
- };
227
- await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
228
- const knowledgeStats = await readKnowledgeStats(projectRoot);
229
- await syncActiveFeatureSnapshot(projectRoot);
230
- return {
231
- archiveId,
232
- archivePath,
233
- archivedAt,
234
- featureName: feature,
235
- activeFeature,
236
- resetState,
237
- snapshottedStateFiles,
238
- knowledge: knowledgeStats,
239
- retro: retroSummary
240
- };
214
+ await writeFileSafe(sentinelPath, `${JSON.stringify({ archiveId, startedAt: archivedAt, sourceRunId: sourceState.activeRunId }, null, 2)}\n`);
215
+ let artifactsMoved = false;
216
+ try {
217
+ await fs.rename(artifactsDir, archiveArtifactsPath);
218
+ artifactsMoved = true;
219
+ await ensureDir(artifactsDir);
220
+ const archiveStatePath = path.join(archivePath, "state");
221
+ const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
222
+ const resetState = createInitialFlowState();
223
+ await writeFlowState(projectRoot, resetState, { allowReset: true });
224
+ await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
225
+ const manifest = {
226
+ version: 1,
227
+ archiveId,
228
+ archivedAt,
229
+ featureName: feature,
230
+ activeFeature,
231
+ sourceRunId: sourceState.activeRunId,
232
+ sourceCurrentStage: sourceState.currentStage,
233
+ sourceCompletedStages: sourceState.completedStages,
234
+ snapshottedStateFiles,
235
+ retro: retroSummary
236
+ };
237
+ await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
238
+ // Manifest landed — sentinel is no longer needed.
239
+ await fs.unlink(sentinelPath).catch(() => undefined);
240
+ const knowledgeStats = await readKnowledgeStats(projectRoot);
241
+ await syncActiveFeatureSnapshot(projectRoot);
242
+ return {
243
+ archiveId,
244
+ archivePath,
245
+ archivedAt,
246
+ featureName: feature,
247
+ activeFeature,
248
+ resetState,
249
+ snapshottedStateFiles,
250
+ knowledge: knowledgeStats,
251
+ retro: retroSummary
252
+ };
253
+ }
254
+ catch (err) {
255
+ // Best-effort rollback: if artifacts were moved but the subsequent
256
+ // steps failed, put artifacts back so the user is not left without
257
+ // a working run. The sentinel is intentionally left behind for
258
+ // inspection; doctor surfaces it.
259
+ if (artifactsMoved) {
260
+ try {
261
+ await fs.rm(artifactsDir, { recursive: true, force: true });
262
+ await fs.rename(archiveArtifactsPath, artifactsDir);
263
+ }
264
+ catch {
265
+ // Rollback failed — sentinel + orphaned archive dir will be
266
+ // surfaced by doctor and can be reconciled manually.
267
+ }
268
+ }
269
+ throw err;
270
+ }
241
271
  }
242
272
  const KNOWLEDGE_SOFT_THRESHOLD = 50;
243
273
  async function readKnowledgeStats(projectRoot) {
@@ -24,6 +24,13 @@ function validateFlowTransition(prev, next) {
24
24
  // New run — only reset paths may change the runId, but those set allowReset.
25
25
  throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `cannot change activeRunId from "${prev.activeRunId}" to "${next.activeRunId}" without allowReset.`);
26
26
  }
27
+ // Track is immutable within a single run: stage schemas, gate sets, and
28
+ // cross-stage reads all branch on track. Silently flipping the track
29
+ // mid-run would let completed stages satisfy one gate tier and the
30
+ // current stage re-read the catalog under a different tier.
31
+ if (prev.track !== next.track) {
32
+ throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `cannot change track from "${prev.track}" to "${next.track}" mid-run (activeRunId="${prev.activeRunId}"). Archive the run and start a new one to switch tracks.`);
33
+ }
27
34
  for (const completed of prev.completedStages) {
28
35
  if (!next.completedStages.includes(completed)) {
29
36
  throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `completedStages must be monotonic: stage "${completed}" was previously completed but is missing from the new state.`);
package/dist/tdd-cycle.js CHANGED
@@ -1,6 +1,10 @@
1
1
  export function parseTddCycleLog(text) {
2
2
  const out = [];
3
- for (const raw of text.split(/\r?\n/)) {
3
+ // Strip a leading UTF-8 BOM on the whole blob so the first line parses
4
+ // cleanly; `trim()` handles BOM on subsequent lines through the same
5
+ // codepath (empty/whitespace-only lines are skipped).
6
+ const normalized = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
7
+ for (const raw of normalized.split(/\r?\n/)) {
4
8
  const line = raw.trim();
5
9
  if (!line)
6
10
  continue;
@@ -100,6 +104,7 @@ export function validateTddCycleOrder(entries, options = {}) {
100
104
  }
101
105
  if (state !== "green_done") {
102
106
  issues.push(`slice ${slice}: refactor logged before green`);
107
+ continue;
103
108
  }
104
109
  state = "need_red";
105
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.0",
3
+ "version": "0.48.1",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {