cclaw-cli 0.42.0 → 0.44.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
@@ -135,10 +135,31 @@ Plus harness-specific shims:
135
135
  folders are auto-cleaned on sync.)
136
136
  - `AGENTS.md` with a managed routing block (includes a Codex-specific note)
137
137
 
138
- `.cclaw/config.yaml` holds every tunable key (prompt guard strictness,
139
- TDD enforcement, git-hook guards, language rule packs, track heuristics).
140
- Edit it directly — `cclaw-cli upgrade` preserves your changes. Full key
141
- reference: [`docs/config.md`](./docs/config.md).
138
+ ### `.cclaw/config.yaml` the minimal surface
139
+
140
+ `cclaw init` writes five keys, on purpose:
141
+
142
+ ```yaml
143
+ version: 0.44.0
144
+ flowVersion: 1.0.0
145
+ harnesses:
146
+ - codex
147
+ strictness: advisory # advisory | strict — one knob for prompt-guard + TDD
148
+ gitHookGuards: false # opt in to managed .git/hooks/pre-commit + pre-push
149
+ ```
150
+
151
+ If cclaw detects a Node / Python / Go project at init time, a sixth
152
+ `languageRulePacks` line appears (auto-populated from `package.json`,
153
+ `pyproject.toml` / `requirements.txt`, `go.mod`). That is the full
154
+ default surface — a new user sees nothing they need to understand yet.
155
+
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.
161
+
162
+ Full key-by-key reference: [`docs/config.md`](./docs/config.md).
142
163
 
143
164
  ---
144
165
 
package/dist/cli.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import type { FlowTrack, HarnessId } from "./types.js";
3
3
  import type { EvalMode } from "./eval/types.js";
4
- type CommandName = "init" | "sync" | "doctor" | "upgrade" | "uninstall" | "archive" | "eval";
4
+ type CommandName = "init" | "sync" | "doctor" | "upgrade" | "uninstall" | "archive" | "eval" | "internal";
5
5
  interface ParsedArgs {
6
6
  command?: CommandName;
7
7
  harnesses?: HarnessId[];
@@ -33,6 +33,8 @@ interface ParsedArgs {
33
33
  evalArgs?: string[];
34
34
  evalBackground?: boolean;
35
35
  evalCompareModel?: string;
36
+ /** Hidden plumbing command (`cclaw internal ...`) arguments. */
37
+ internalArgs?: string[];
36
38
  showHelp?: boolean;
37
39
  showVersion?: boolean;
38
40
  }
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { formatDiffMarkdown, runEvalDiff } from "./eval/diff.js";
24
24
  import { ensureRunDir, generateRunId, isRunAlive, listRuns, readRunStatus, resolveRunId, runLogPath, writeRunStatus } from "./eval/runs.js";
25
25
  import { parseModeInput } from "./eval/mode.js";
26
26
  import { FLOW_STAGES } from "./types.js";
27
+ import { runInternalCommand } from "./internal/advance-stage.js";
27
28
  const INSTALLER_COMMANDS = [
28
29
  "init",
29
30
  "sync",
@@ -31,7 +32,8 @@ const INSTALLER_COMMANDS = [
31
32
  "upgrade",
32
33
  "uninstall",
33
34
  "archive",
34
- "eval"
35
+ "eval",
36
+ "internal"
35
37
  ];
36
38
  export function usage() {
37
39
  return `cclaw - installer-first flow toolkit
@@ -418,6 +420,12 @@ function parseArgs(argv) {
418
420
  parsed.command = INSTALLER_COMMANDS.includes(commandRaw)
419
421
  ? commandRaw
420
422
  : undefined;
423
+ // Hidden maintainer surface for runtime guards/helpers. Keep raw positional
424
+ // args untouched so subcommand-level parsing can evolve independently.
425
+ if (parsed.command === "internal") {
426
+ parsed.internalArgs = [...rest];
427
+ return parsed;
428
+ }
421
429
  // For `eval`, the next non-flag argument is an optional subcommand. Any
422
430
  // subsequent non-flag tokens are captured as evalArgs (consumed by the
423
431
  // subcommand handler). This preserves backwards compat: callers that run
@@ -796,6 +804,9 @@ async function runCommand(parsed, ctx) {
796
804
  if (!command) {
797
805
  return printNoArgsHint(ctx);
798
806
  }
807
+ if (command === "internal") {
808
+ return runInternalCommand(ctx.cwd, parsed.internalArgs ?? [], ctx);
809
+ }
799
810
  if (command === "init") {
800
811
  const resolved = await resolveInitInputs(parsed, ctx);
801
812
  const effectiveTrack = resolved.track;
@@ -827,6 +838,11 @@ async function runCommand(parsed, ctx) {
827
838
  }
828
839
  const trackNote = effectiveTrack ? ` (track=${effectiveTrack})` : "";
829
840
  info(ctx, `Initialized .cclaw runtime and generated harness shims${trackNote}`);
841
+ // Point new users at the one config surface they might actually flip —
842
+ // `strictness` and `gitHookGuards` — without overselling the other knobs
843
+ // (those live behind docs/config.md until someone needs them).
844
+ info(ctx, "Config: .cclaw/config.yaml (strictness=advisory, gitHookGuards=false).");
845
+ info(ctx, "Need stricter guards or language rule packs? See docs/config.md.");
830
846
  await maybeEnableCodexHooksFlag(effectiveHarnesses, parsed, ctx);
831
847
  return 0;
832
848
  }
package/dist/config.d.ts CHANGED
@@ -1,5 +1,66 @@
1
- import type { FlowTrack, HarnessId, VibyConfig } from "./types.js";
1
+ import type { FlowTrack, HarnessId, LanguageRulePack, VibyConfig } from "./types.js";
2
2
  export declare function configPath(projectRoot: string): string;
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.
10
+ */
11
+ export declare const DEFAULT_TDD_TEST_GLOBS: readonly string[];
12
+ /**
13
+ * Populated runtime view of config values that downstream callers (install,
14
+ * observe, doctor) consume. Always has the derived guard modes populated,
15
+ * regardless of whether the user wrote `strictness`, the legacy keys, both,
16
+ * or neither.
17
+ */
3
18
  export declare function createDefaultConfig(harnesses?: HarnessId[], defaultTrack?: FlowTrack): VibyConfig;
19
+ /**
20
+ * Probe common project-root manifests to infer which language rule packs the
21
+ * user would reasonably want. Pure-functional best-effort: any filesystem
22
+ * error is swallowed, producing an empty list — the user can always override
23
+ * by hand.
24
+ *
25
+ * Called from `cclaw init` only (not `readConfig`), so subsequent upgrades
26
+ * never surprise a user who intentionally cleared the list.
27
+ */
28
+ export declare function detectLanguageRulePacks(projectRoot: string): Promise<LanguageRulePack[]>;
4
29
  export declare function readConfig(projectRoot: string): Promise<VibyConfig>;
5
- export declare function writeConfig(projectRoot: string, config: VibyConfig): Promise<void>;
30
+ /**
31
+ * Fields that live on the populated runtime `VibyConfig` but are considered
32
+ * "advanced" — we keep them in the in-memory object so downstream callers
33
+ * don't have to branch, but we do **not** write them to `config.yaml` unless
34
+ * the user set them explicitly. Keeps the default template small and honest:
35
+ * only knobs a new user would meaningfully flip show up.
36
+ */
37
+ type AdvancedConfigKey = "promptGuardMode" | "tddEnforcement" | "tddTestGlobs" | "defaultTrack" | "languageRulePacks" | "trackHeuristics" | "sliceReview";
38
+ /**
39
+ * Options controlling the serialisation shape of `config.yaml`.
40
+ *
41
+ * - `"full"` (default): write every field on the `VibyConfig` object that
42
+ * isn't `undefined`. Preserves existing shapes and keeps legacy callers
43
+ * working without migration.
44
+ * - `"minimal"`: write only the user-facing knobs (`MINIMAL_CONFIG_KEYS`)
45
+ * plus any non-empty `languageRulePacks` (so auto-detected values survive
46
+ * a fresh `cclaw init`). Use this when generating the default template;
47
+ * power users can still add advanced keys by hand.
48
+ *
49
+ * `advancedKeysPresent` upgrades an otherwise-minimal serialisation by
50
+ * including the listed advanced keys. `cclaw upgrade` uses it to preserve
51
+ * the exact shape a user hand-authored, while still re-minimising configs
52
+ * where the user stayed at defaults.
53
+ */
54
+ export interface WriteConfigOptions {
55
+ mode?: "full" | "minimal";
56
+ advancedKeysPresent?: ReadonlySet<AdvancedConfigKey>;
57
+ }
58
+ export declare function writeConfig(projectRoot: string, config: VibyConfig, options?: WriteConfigOptions): Promise<void>;
59
+ /**
60
+ * Enumerate which advanced keys are currently set in the on-disk config.
61
+ * Used by `cclaw upgrade` to preserve the user's existing shape — if they
62
+ * wrote `tddTestGlobs` by hand, the upgrade keeps it; if they didn't, the
63
+ * upgrade stays minimal.
64
+ */
65
+ export declare function detectAdvancedKeys(projectRoot: string): Promise<ReadonlySet<AdvancedConfigKey>>;
66
+ export {};
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ const ALLOWED_CONFIG_KEYS = new Set([
15
15
  "version",
16
16
  "flowVersion",
17
17
  "harnesses",
18
+ "strictness",
18
19
  "promptGuardMode",
19
20
  "tddEnforcement",
20
21
  "tddTestGlobs",
@@ -24,6 +25,21 @@ const ALLOWED_CONFIG_KEYS = new Set([
24
25
  "trackHeuristics",
25
26
  "sliceReview"
26
27
  ]);
28
+ /**
29
+ * Config keys always present in the minimal init template. Everything else
30
+ * is "advanced" — parsed when present, but not pre-populated by `cclaw init`.
31
+ *
32
+ * Deliberately small: a first-time user should only see knobs they might
33
+ * actually flip. Power users override by adding more keys by hand; the
34
+ * reference lives in `docs/config.md`.
35
+ */
36
+ const MINIMAL_CONFIG_KEYS = [
37
+ "version",
38
+ "flowVersion",
39
+ "harnesses",
40
+ "strictness",
41
+ "gitHookGuards"
42
+ ];
27
43
  const DEFAULT_SLICE_REVIEW_THRESHOLD = 5;
28
44
  const DEFAULT_SLICE_REVIEW_TRACKS = ["standard"];
29
45
  function configFixExample() {
@@ -57,19 +73,78 @@ function validateStringArray(value, fieldName, configFilePath) {
57
73
  export function configPath(projectRoot) {
58
74
  return path.join(projectRoot, CONFIG_PATH);
59
75
  }
76
+ /**
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.
83
+ */
84
+ export const DEFAULT_TDD_TEST_GLOBS = [
85
+ "**/*.test.*",
86
+ "**/*.spec.*",
87
+ "**/test/**"
88
+ ];
89
+ /**
90
+ * Populated runtime view of config values that downstream callers (install,
91
+ * observe, doctor) consume. Always has the derived guard modes populated,
92
+ * regardless of whether the user wrote `strictness`, the legacy keys, both,
93
+ * or neither.
94
+ */
60
95
  export function createDefaultConfig(harnesses = DEFAULT_HARNESSES, defaultTrack = "standard") {
61
96
  return {
62
97
  version: CCLAW_VERSION,
63
98
  flowVersion: FLOW_VERSION,
64
99
  harnesses,
100
+ strictness: "advisory",
65
101
  promptGuardMode: "advisory",
66
102
  tddEnforcement: "advisory",
67
- tddTestGlobs: ["**/*.test.*", "**/*.spec.*", "**/test/**"],
103
+ tddTestGlobs: [...DEFAULT_TDD_TEST_GLOBS],
68
104
  gitHookGuards: false,
69
105
  defaultTrack,
70
106
  languageRulePacks: []
71
107
  };
72
108
  }
109
+ /**
110
+ * Probe common project-root manifests to infer which language rule packs the
111
+ * user would reasonably want. Pure-functional best-effort: any filesystem
112
+ * error is swallowed, producing an empty list — the user can always override
113
+ * by hand.
114
+ *
115
+ * Called from `cclaw init` only (not `readConfig`), so subsequent upgrades
116
+ * never surprise a user who intentionally cleared the list.
117
+ */
118
+ export async function detectLanguageRulePacks(projectRoot) {
119
+ const detected = [];
120
+ const pkgPath = path.join(projectRoot, "package.json");
121
+ if (await exists(pkgPath)) {
122
+ try {
123
+ const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
124
+ const deps = {
125
+ ...pkg.dependencies,
126
+ ...pkg.devDependencies
127
+ };
128
+ if ("typescript" in deps || typeof pkg.types === "string") {
129
+ detected.push("typescript");
130
+ }
131
+ }
132
+ catch {
133
+ // Malformed package.json — skip; user can set the pack manually later.
134
+ }
135
+ }
136
+ const pythonMarkers = ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"];
137
+ for (const marker of pythonMarkers) {
138
+ if (await exists(path.join(projectRoot, marker))) {
139
+ detected.push("python");
140
+ break;
141
+ }
142
+ }
143
+ if (await exists(path.join(projectRoot, "go.mod"))) {
144
+ detected.push("go");
145
+ }
146
+ return [...new Set(detected)];
147
+ }
73
148
  export async function readConfig(projectRoot) {
74
149
  const fullPath = configPath(projectRoot);
75
150
  if (!(await exists(fullPath))) {
@@ -105,23 +180,39 @@ export async function readConfig(projectRoot) {
105
180
  const harnesses = hasHarnessesField
106
181
  ? [...new Set(validatedHarnesses)]
107
182
  : DEFAULT_HARNESSES;
183
+ const strictnessRaw = parsed.strictness;
184
+ if (Object.prototype.hasOwnProperty.call(parsed, "strictness") &&
185
+ strictnessRaw !== "advisory" &&
186
+ strictnessRaw !== "strict") {
187
+ throw configValidationError(fullPath, `"strictness" must be "advisory" or "strict"`);
188
+ }
189
+ const strictness = strictnessRaw === "strict" ? "strict" : "advisory";
190
+ // Legacy guard fields — keep honouring explicit values for power users who
191
+ // want asymmetric behaviour (e.g. strict prompt guard + advisory TDD).
192
+ // When the user only set `strictness`, both axes inherit from it.
193
+ const hasExplicitPromptGuard = Object.prototype.hasOwnProperty.call(parsed, "promptGuardMode");
108
194
  const promptGuardModeRaw = parsed.promptGuardMode;
109
- if (Object.prototype.hasOwnProperty.call(parsed, "promptGuardMode") &&
195
+ if (hasExplicitPromptGuard &&
110
196
  promptGuardModeRaw !== "advisory" &&
111
197
  promptGuardModeRaw !== "strict") {
112
198
  throw configValidationError(fullPath, `"promptGuardMode" must be "advisory" or "strict"`);
113
199
  }
114
- const promptGuardMode = promptGuardModeRaw === "strict" ? "strict" : "advisory";
200
+ const promptGuardMode = hasExplicitPromptGuard
201
+ ? (promptGuardModeRaw === "strict" ? "strict" : "advisory")
202
+ : strictness;
203
+ const hasExplicitTddEnforcement = Object.prototype.hasOwnProperty.call(parsed, "tddEnforcement");
115
204
  const tddEnforcementRaw = parsed.tddEnforcement;
116
- if (Object.prototype.hasOwnProperty.call(parsed, "tddEnforcement") &&
205
+ if (hasExplicitTddEnforcement &&
117
206
  tddEnforcementRaw !== "advisory" &&
118
207
  tddEnforcementRaw !== "strict") {
119
208
  throw configValidationError(fullPath, `"tddEnforcement" must be "advisory" or "strict"`);
120
209
  }
121
- const tddEnforcement = tddEnforcementRaw === "strict" ? "strict" : "advisory";
210
+ const tddEnforcement = hasExplicitTddEnforcement
211
+ ? (tddEnforcementRaw === "strict" ? "strict" : "advisory")
212
+ : strictness;
122
213
  const tddTestGlobsRaw = parsed.tddTestGlobs;
123
214
  const tddTestGlobs = validateStringArray(tddTestGlobsRaw, "tddTestGlobs", fullPath)
124
- ?? ["**/*.test.*", "**/*.spec.*", "**/test/**"];
215
+ ?? [...DEFAULT_TDD_TEST_GLOBS];
125
216
  const gitHookGuardsRaw = parsed.gitHookGuards;
126
217
  if (Object.prototype.hasOwnProperty.call(parsed, "gitHookGuards") &&
127
218
  typeof gitHookGuardsRaw !== "boolean") {
@@ -232,6 +323,7 @@ export async function readConfig(projectRoot) {
232
323
  version: parsed.version ?? CCLAW_VERSION,
233
324
  flowVersion: parsed.flowVersion ?? FLOW_VERSION,
234
325
  harnesses,
326
+ strictness,
235
327
  promptGuardMode,
236
328
  tddEnforcement,
237
329
  tddTestGlobs,
@@ -242,6 +334,88 @@ export async function readConfig(projectRoot) {
242
334
  sliceReview
243
335
  };
244
336
  }
245
- export async function writeConfig(projectRoot, config) {
246
- await writeFileSafe(configPath(projectRoot), stringify(config));
337
+ function isMinimalKey(key) {
338
+ return MINIMAL_CONFIG_KEYS.includes(key);
339
+ }
340
+ function buildSerializableConfig(config, options = {}) {
341
+ const mode = options.mode ?? "full";
342
+ const advanced = options.advancedKeysPresent;
343
+ const output = {};
344
+ const ordered = [
345
+ "version",
346
+ "flowVersion",
347
+ "harnesses",
348
+ "strictness",
349
+ "promptGuardMode",
350
+ "tddEnforcement",
351
+ "tddTestGlobs",
352
+ "gitHookGuards",
353
+ "defaultTrack",
354
+ "languageRulePacks",
355
+ "trackHeuristics",
356
+ "sliceReview"
357
+ ];
358
+ for (const key of ordered) {
359
+ const value = config[key];
360
+ if (value === undefined)
361
+ continue;
362
+ if (mode === "full") {
363
+ output[key] = value;
364
+ continue;
365
+ }
366
+ // Minimal mode: always include the short list; advanced keys only when
367
+ // the caller explicitly opted in, or for auto-detected non-empty
368
+ // `languageRulePacks`.
369
+ if (isMinimalKey(key)) {
370
+ output[key] = value;
371
+ continue;
372
+ }
373
+ if (advanced?.has(key)) {
374
+ output[key] = value;
375
+ continue;
376
+ }
377
+ if (key === "languageRulePacks" && Array.isArray(value) && value.length > 0) {
378
+ output[key] = value;
379
+ }
380
+ }
381
+ return output;
382
+ }
383
+ export async function writeConfig(projectRoot, config, options = {}) {
384
+ const serialisable = buildSerializableConfig(config, options);
385
+ await writeFileSafe(configPath(projectRoot), stringify(serialisable));
386
+ }
387
+ /**
388
+ * Enumerate which advanced keys are currently set in the on-disk config.
389
+ * Used by `cclaw upgrade` to preserve the user's existing shape — if they
390
+ * wrote `tddTestGlobs` by hand, the upgrade keeps it; if they didn't, the
391
+ * upgrade stays minimal.
392
+ */
393
+ export async function detectAdvancedKeys(projectRoot) {
394
+ const fullPath = configPath(projectRoot);
395
+ if (!(await exists(fullPath)))
396
+ return new Set();
397
+ try {
398
+ const parsedUnknown = parse(await fs.readFile(fullPath, "utf8"));
399
+ if (!isRecord(parsedUnknown))
400
+ return new Set();
401
+ const advancedCandidates = [
402
+ "promptGuardMode",
403
+ "tddEnforcement",
404
+ "tddTestGlobs",
405
+ "defaultTrack",
406
+ "languageRulePacks",
407
+ "trackHeuristics",
408
+ "sliceReview"
409
+ ];
410
+ const present = new Set();
411
+ for (const key of advancedCandidates) {
412
+ if (Object.prototype.hasOwnProperty.call(parsedUnknown, key)) {
413
+ present.add(key);
414
+ }
415
+ }
416
+ return present;
417
+ }
418
+ catch {
419
+ return new Set();
420
+ }
247
421
  }
@@ -232,10 +232,12 @@ Codex CLI has a different shape from Claude/Cursor:
232
232
  - **Tool interception is Bash-only.** Codex's \`PreToolUse\` and
233
233
  \`PostToolUse\` events only fire for the \`Bash\` tool. \`Write\`,
234
234
  \`Edit\`, \`WebSearch\`, and MCP tool calls are **not** gated by hooks.
235
- cclaw partially compensates by also wiring \`UserPromptSubmit\` to
236
- \`prompt-guard.sh\` so the stage routing check fires before the turn
237
- executes, but workflow-guard (TDD red-first, artifact presence) only
238
- fires on Bash turns. See the hook coverage matrix below.
235
+ cclaw partially compensates by wiring \`UserPromptSubmit\` to both
236
+ \`prompt-guard.sh\` and a non-blocking
237
+ \`cclaw internal verify-current-state --quiet\` nudge that emits
238
+ unmet-delegation / missing-evidence warnings before the turn executes.
239
+ This is still a nudge, not a hard block: workflow-guard (TDD red-first,
240
+ artifact presence) only fires on Bash turns. See the hook coverage matrix below.
239
241
  - **Legacy paths.** \`.codex/commands/*\` was never consumed by Codex and
240
242
  is removed on every \`cclaw sync\`. The v0.39.x \`.agents/skills/cclaw-cc*/\`
241
243
  layout is replaced by \`.agents/skills/cc*/\` and the old folders are
@@ -289,6 +291,8 @@ disabled in v0.33 and remains off.
289
291
  - \`/use cc\` — open the \`/cc\` skill and pick a track.
290
292
  - \`/use cc-next\` — advance the flow one stage.
291
293
  - \`/use cc-ops\` — compound / archive / rewind.
294
+ - \`bash .cclaw/hooks/stage-complete.sh <stage>\` — canonical stage closeout helper;
295
+ validates delegations + gate evidence before mutating \`flow-state.json\`.
292
296
  - Typing \`/cc …\` or \`/cc-next …\` in plain text also works: Codex
293
297
  matches the skill descriptions (which spell out these tokens) and
294
298
  auto-loads the right skill body.
@@ -316,6 +320,7 @@ continue to work regardless.
316
320
  |-------------|---------------|----------|
317
321
  | SessionStart rehydration | \`SessionStart\` matcher \`startup|resume\` → \`session-start.sh\` | Full. |
318
322
  | PreToolUse prompt-guard | \`PreToolUse\` matcher \`Bash\` + \`UserPromptSubmit\` → \`prompt-guard.sh\` | Bash tool calls are gated inline; \`UserPromptSubmit\` catches prompts before any tool fires, so non-Bash writes (\`Write\`/\`Edit\`) are still prompt-guarded at the turn boundary. |
323
+ | UserPromptSubmit state nudge | \`UserPromptSubmit\` → \`cclaw internal verify-current-state --quiet\` | Non-blocking warning only. Prints unmet mandatory delegation / gate-evidence counts before the turn; cannot block non-Bash \`Write\`/\`Edit\`. |
319
324
  | PreToolUse workflow-guard | \`PreToolUse\` matcher \`Bash\` → \`workflow-guard.sh\` | Bash-only. For \`Write\`/\`Edit\` calls the agent performs the TDD-order / artifact check in-turn (see the stage skill). |
320
325
  | PostToolUse context-monitor | \`PostToolUse\` matcher \`Bash\` → \`context-monitor.sh\` | Bash-only. Other tool calls get context-monitored at end-of-turn via \`.cclaw/references/protocols/ethos.md\`. |
321
326
  | Stop checkpoint | \`Stop\` → \`stop-checkpoint.sh\` | Full. |
@@ -68,6 +68,11 @@ ${hookRows}
68
68
  - \`tier1\`: full native delegation + structured asks + full hook surface.
69
69
  - \`tier2\`: usable flow with capability gaps; mandatory delegation can require waivers.
70
70
  - \`tier3\`: manual-only fallback; no native automation guarantees.
71
+ - Codex-specific ceiling: \`PreToolUse\` can only intercept \`Bash\`. Direct
72
+ \`Write\`/\`Edit\` to \`.cclaw/state/flow-state.json\` cannot be hard-blocked
73
+ at hook level, so the canonical path is
74
+ \`bash .cclaw/hooks/stage-complete.sh <stage>\` plus the non-blocking
75
+ \`UserPromptSubmit\` state nudge.
71
76
 
72
77
  ## Shared command contract
73
78
 
@@ -11,6 +11,7 @@ export interface HookRuntimeOptions {
11
11
  export declare const RUNTIME_SHELL_DETECT_ROOT = "HARNESS=\"codex\"\nif [ -n \"${CLAUDE_PROJECT_DIR:-}\" ]; then\n HARNESS=\"claude\"\nelif [ -n \"${CURSOR_PROJECT_DIR:-}\" ] || [ -n \"${CURSOR_PROJECT_ROOT:-}\" ]; then\n HARNESS=\"cursor\"\nelif [ -n \"${OPENCODE_PROJECT_DIR:-}\" ] || [ -n \"${OPENCODE_PROJECT_ROOT:-}\" ]; then\n HARNESS=\"opencode\"\nfi\n\nROOT=\"\"\nfor candidate in \"${CCLAW_PROJECT_ROOT:-}\" \"${CLAUDE_PROJECT_DIR:-}\" \"${CURSOR_PROJECT_DIR:-}\" \"${CURSOR_PROJECT_ROOT:-}\" \"${OPENCODE_PROJECT_DIR:-}\" \"${OPENCODE_PROJECT_ROOT:-}\" \"${PWD:-}\"; do\n if [ -n \"$candidate\" ] && [ -d \"$candidate/.cclaw\" ]; then\n ROOT=\"$candidate\"\n break\n fi\ndone\nif [ -z \"$ROOT\" ]; then\n ROOT=\"${CCLAW_PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-${CURSOR_PROJECT_ROOT:-${OPENCODE_PROJECT_DIR:-${OPENCODE_PROJECT_ROOT:-${PWD}}}}}}}\"\nfi";
12
12
  export declare function sessionStartScript(_options?: HookRuntimeOptions): string;
13
13
  export declare function stopCheckpointScript(): string;
14
+ export declare function stageCompleteScript(): string;
14
15
  export declare function preCompactScript(): string;
15
16
  export { claudeHooksJsonWithObservation as claudeHooksJson } from "./observe.js";
16
17
  export { cursorHooksJsonWithObservation as cursorHooksJson } from "./observe.js";
@@ -769,6 +769,35 @@ case "$HARNESS" in
769
769
  esac
770
770
  `;
771
771
  }
772
+ export function stageCompleteScript() {
773
+ return `#!/usr/bin/env bash
774
+ # cclaw stage-complete helper — generated by cclaw sync
775
+ # Canonical helper for stage closeout: delegates validation + flow-state
776
+ # mutation to \`cclaw internal advance-stage\`.
777
+ set -euo pipefail
778
+
779
+ ${DETECT_ROOT}
780
+
781
+ if [ "$#" -lt 1 ]; then
782
+ printf 'Usage: bash ${RUNTIME_ROOT}/hooks/stage-complete.sh <stage> [--passed=...] [--evidence-json=...] [--waive-delegation=...] [--waiver-reason=...]\\n' >&2
783
+ exit 1
784
+ fi
785
+
786
+ if [ ! -d "$ROOT/${RUNTIME_ROOT}" ]; then
787
+ printf '[cclaw] stage-complete: runtime root not found at %s\\n' "$ROOT/${RUNTIME_ROOT}" >&2
788
+ exit 1
789
+ fi
790
+
791
+ STAGE="$1"
792
+ shift || true
793
+
794
+ if command -v cclaw >/dev/null 2>&1; then
795
+ exec cclaw internal advance-stage "$STAGE" "$@"
796
+ fi
797
+
798
+ exec npx -y cclaw-cli internal advance-stage "$STAGE" "$@"
799
+ `;
800
+ }
772
801
  export function preCompactScript() {
773
802
  return `#!/usr/bin/env bash
774
803
  # cclaw pre-compact hook — generated by cclaw sync
@@ -1109,14 +1138,15 @@ export default function cclawPlugin(ctx) {
1109
1138
  const scriptPath = join(root, "${RUNTIME_ROOT}/hooks/" + scriptFileName);
1110
1139
  const input = typeof payload === "string" ? payload : JSON.stringify(payload ?? {});
1111
1140
  try {
1112
- spawnSync("bash", [scriptPath], {
1141
+ const result = spawnSync("bash", [scriptPath], {
1113
1142
  cwd: root,
1114
1143
  timeout: 20000,
1115
1144
  stdio: ["pipe", "ignore", "ignore"],
1116
1145
  input
1117
1146
  });
1147
+ return typeof result.status === "number" ? result.status === 0 : false;
1118
1148
  } catch {
1119
- // advisory-only runtime path
1149
+ return false;
1120
1150
  }
1121
1151
  }
1122
1152
 
@@ -1167,8 +1197,13 @@ export default function cclawPlugin(ctx) {
1167
1197
  }
1168
1198
  if (eventType === "tool.execute.before") {
1169
1199
  const toolPayload = normalizeToolPayload(eventData, undefined);
1170
- await runHookScript("prompt-guard.sh", toolPayload);
1171
- await runHookScript("workflow-guard.sh", toolPayload);
1200
+ const promptOk = await runHookScript("prompt-guard.sh", toolPayload);
1201
+ const workflowOk = await runHookScript("workflow-guard.sh", toolPayload);
1202
+ if (!promptOk || !workflowOk) {
1203
+ throw new Error(
1204
+ "cclaw OpenCode guard blocked tool.execute.before (prompt/workflow guard non-zero exit)."
1205
+ );
1206
+ }
1172
1207
  }
1173
1208
  if (eventType === "tool.execute.after") {
1174
1209
  const toolPayload = normalizeToolPayload(eventData, undefined);
@@ -1177,8 +1212,13 @@ export default function cclawPlugin(ctx) {
1177
1212
  },
1178
1213
  "tool.execute.before": async (input, output) => {
1179
1214
  const payload = normalizeToolPayload(input, output);
1180
- await runHookScript("prompt-guard.sh", payload);
1181
- await runHookScript("workflow-guard.sh", payload);
1215
+ const promptOk = await runHookScript("prompt-guard.sh", payload);
1216
+ const workflowOk = await runHookScript("workflow-guard.sh", payload);
1217
+ if (!promptOk || !workflowOk) {
1218
+ throw new Error(
1219
+ "cclaw OpenCode guard blocked tool.execute.before (prompt/workflow guard non-zero exit)."
1220
+ );
1221
+ }
1182
1222
  },
1183
1223
  "tool.execute.after": async (input, output) => {
1184
1224
  const payload = normalizeToolPayload(input, output);
@@ -44,13 +44,14 @@ This is the only progression command the user needs to drive the entire flow. St
44
44
  5. Let \`catalog\` = \`stageGateCatalog[currentStage]\` from flow state.
45
45
  6. **Satisfied** for gate id \`g\`: \`g\` in \`catalog.passed\` and \`g\` not in \`catalog.blocked\`.
46
46
  7. Let \`M\` = \`mandatoryDelegations\` for \`currentStage\`.
47
- 8. If \`M\` is non-empty, inspect **\`${delegationPath}\`**. Treat as satisfied only if the agent is **completed** or **waived**.
47
+ 8. If \`M\` is non-empty, inspect **\`${delegationPath}\`**. Treat as satisfied only if each mandatory agent is **completed** or **waived**.
48
+ 9. If any mandatory delegation is missing and no waiver exists: **STOP** and ask the user whether to dispatch now or waive with rationale. Do not mark gates passed while delegation is unresolved.
48
49
 
49
50
  ### Path A: Current stage is NOT complete (any gate unmet or delegation missing)
50
51
 
51
52
  → Load **\`${RUNTIME_ROOT}/skills/<skillFolder>/SKILL.md\`** and **\`${RUNTIME_ROOT}/commands/<currentStage>.md\`** for the current stage.
52
53
  → Execute that stage's protocol. The stage skill handles the full interaction including STOP points and gate tracking.
53
- When the stage completes, the Stage Completion Protocol in the skill updates \`flow-state.json\` automatically.
54
+ Stage completion must use \`bash .cclaw/hooks/stage-complete.sh <currentStage>\` (canonical), which validates delegations + gate evidence before mutating \`flow-state.json\`.
54
55
 
55
56
  ### Path B: Current stage IS complete (all gates passed, all delegations satisfied)
56
57
 
@@ -152,6 +153,8 @@ For each gate id in \`requiredGates\` for \`currentStage\`:
152
153
  - **Unmet** otherwise.
153
154
 
154
155
  Check \`mandatoryDelegations\` via **\`${delegationPath}\`** — satisfied only if **completed** or **waived**.
156
+ If a mandatory delegation is missing and no waiver exists, **STOP** and ask:
157
+ (A) dispatch now, (B) waive with rationale, (C) cancel stage advance.
155
158
 
156
159
  ### Step 3: Act
157
160
 
@@ -161,7 +164,7 @@ Load the current stage's skill and command contract:
161
164
  - \`${RUNTIME_ROOT}/skills/<skillFolder>/SKILL.md\`
162
165
  - \`${RUNTIME_ROOT}/commands/<currentStage>.md\`
163
166
 
164
- Execute the stage protocol. The stage skill handles interaction, STOP points, gate tracking, and the Stage Completion Protocol (updates \`flow-state.json\` when done).
167
+ Execute the stage protocol. The stage skill handles interaction, STOP points, gate tracking, and stage completion via \`bash .cclaw/hooks/stage-complete.sh <stage>\` (canonical flow-state mutation path).
165
168
 
166
169
  **Path B — stage IS complete (all gates met, all delegations done):**
167
170
 
@@ -10,6 +10,7 @@ export interface PromptGuardOptions {
10
10
  }
11
11
  export declare function promptGuardScript(options?: PromptGuardOptions): string;
12
12
  export interface WorkflowGuardOptions {
13
+ workflowGuardMode?: "advisory" | "strict";
13
14
  tddEnforcementMode?: "advisory" | "strict";
14
15
  tddTestGlobs?: string[];
15
16
  }