cclaw-cli 0.1.1 → 0.2.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.
Files changed (48) hide show
  1. package/dist/artifact-linter.d.ts +20 -0
  2. package/dist/artifact-linter.js +368 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +8 -2
  5. package/dist/config.d.ts +4 -4
  6. package/dist/config.js +56 -5
  7. package/dist/constants.d.ts +4 -4
  8. package/dist/constants.js +6 -3
  9. package/dist/content/autoplan.js +51 -4
  10. package/dist/content/contexts.d.ts +9 -0
  11. package/dist/content/contexts.js +65 -0
  12. package/dist/content/hooks.d.ts +6 -2
  13. package/dist/content/hooks.js +448 -16
  14. package/dist/content/meta-skill.js +26 -0
  15. package/dist/content/next-command.d.ts +9 -0
  16. package/dist/content/next-command.js +138 -0
  17. package/dist/content/observe.d.ts +5 -1
  18. package/dist/content/observe.js +506 -24
  19. package/dist/content/skills.js +126 -0
  20. package/dist/content/stage-schema.d.ts +7 -0
  21. package/dist/content/stage-schema.js +70 -12
  22. package/dist/content/subagents.js +33 -0
  23. package/dist/content/templates.d.ts +1 -0
  24. package/dist/content/templates.js +182 -77
  25. package/dist/content/utility-skills.d.ts +5 -1
  26. package/dist/content/utility-skills.js +208 -2
  27. package/dist/delegation.d.ts +21 -0
  28. package/dist/delegation.js +94 -0
  29. package/dist/doctor.d.ts +5 -1
  30. package/dist/doctor.js +274 -23
  31. package/dist/fs-utils.d.ts +10 -0
  32. package/dist/fs-utils.js +47 -0
  33. package/dist/gate-evidence.d.ts +26 -0
  34. package/dist/gate-evidence.js +157 -0
  35. package/dist/harness-adapters.js +2 -0
  36. package/dist/hook-schema.d.ts +6 -0
  37. package/dist/hook-schema.js +45 -0
  38. package/dist/hook-schemas/claude-hooks.v1.json +12 -0
  39. package/dist/hook-schemas/codex-hooks.v1.json +12 -0
  40. package/dist/hook-schemas/cursor-hooks.v1.json +15 -0
  41. package/dist/install.js +431 -16
  42. package/dist/policy.d.ts +5 -1
  43. package/dist/policy.js +52 -1
  44. package/dist/runs.js +8 -3
  45. package/dist/trace-matrix.d.ts +13 -0
  46. package/dist/trace-matrix.js +182 -0
  47. package/dist/types.d.ts +11 -1
  48. package/package.json +1 -1
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { RUNTIME_ROOT } from "./constants.js";
4
+ import { exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
5
+ import { readFlowState } from "./runs.js";
6
+ import { stageSchema } from "./content/stage-schema.js";
7
+ function delegationLogPath(projectRoot, runId) {
8
+ return path.join(projectRoot, RUNTIME_ROOT, "runs", runId, "delegation-log.json");
9
+ }
10
+ function delegationLockPath(projectRoot, runId) {
11
+ return path.join(projectRoot, RUNTIME_ROOT, "runs", runId, ".delegation.lock");
12
+ }
13
+ function isDelegationEntry(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ return false;
16
+ const o = value;
17
+ const modeOk = o.mode === "mandatory" || o.mode === "proactive";
18
+ const statusOk = o.status === "scheduled" ||
19
+ o.status === "completed" ||
20
+ o.status === "failed" ||
21
+ o.status === "waived";
22
+ return (typeof o.stage === "string" &&
23
+ typeof o.agent === "string" &&
24
+ modeOk &&
25
+ statusOk &&
26
+ typeof o.ts === "string" &&
27
+ (o.taskId === undefined || typeof o.taskId === "string") &&
28
+ (o.waiverReason === undefined || typeof o.waiverReason === "string"));
29
+ }
30
+ function parseLedger(raw, runId) {
31
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
32
+ return { runId, entries: [] };
33
+ }
34
+ const o = raw;
35
+ const entriesRaw = o.entries;
36
+ const entries = [];
37
+ if (Array.isArray(entriesRaw)) {
38
+ for (const item of entriesRaw) {
39
+ if (isDelegationEntry(item)) {
40
+ entries.push(item);
41
+ }
42
+ }
43
+ }
44
+ return { runId, entries };
45
+ }
46
+ export async function readDelegationLedger(projectRoot) {
47
+ const { activeRunId } = await readFlowState(projectRoot);
48
+ const filePath = delegationLogPath(projectRoot, activeRunId);
49
+ if (!(await exists(filePath))) {
50
+ return { runId: activeRunId, entries: [] };
51
+ }
52
+ try {
53
+ const text = await fs.readFile(filePath, "utf8");
54
+ const parsed = JSON.parse(text);
55
+ return parseLedger(parsed, activeRunId);
56
+ }
57
+ catch {
58
+ return { runId: activeRunId, entries: [] };
59
+ }
60
+ }
61
+ export async function appendDelegation(projectRoot, entry) {
62
+ const { activeRunId } = await readFlowState(projectRoot);
63
+ await withDirectoryLock(delegationLockPath(projectRoot, activeRunId), async () => {
64
+ const filePath = delegationLogPath(projectRoot, activeRunId);
65
+ const prior = await readDelegationLedger(projectRoot);
66
+ const ledger = {
67
+ runId: activeRunId,
68
+ entries: [...prior.entries, entry]
69
+ };
70
+ await writeFileSafe(filePath, `${JSON.stringify(ledger, null, 2)}\n`);
71
+ });
72
+ }
73
+ export async function checkMandatoryDelegations(projectRoot, stage) {
74
+ const mandatory = stageSchema(stage).mandatoryDelegations;
75
+ const ledger = await readDelegationLedger(projectRoot);
76
+ const forStage = ledger.entries.filter((e) => e.stage === stage);
77
+ const missing = [];
78
+ const waived = [];
79
+ for (const agent of mandatory) {
80
+ const rows = forStage.filter((e) => e.agent === agent);
81
+ const ok = rows.some((e) => e.status === "completed" || e.status === "waived");
82
+ if (!ok) {
83
+ missing.push(agent);
84
+ }
85
+ else if (rows.some((e) => e.status === "waived")) {
86
+ waived.push(agent);
87
+ }
88
+ }
89
+ return {
90
+ satisfied: missing.length === 0,
91
+ missing,
92
+ waived
93
+ };
94
+ }
package/dist/doctor.d.ts CHANGED
@@ -3,5 +3,9 @@ export interface DoctorCheck {
3
3
  ok: boolean;
4
4
  details: string;
5
5
  }
6
- export declare function doctorChecks(projectRoot: string): Promise<DoctorCheck[]>;
6
+ export interface DoctorOptions {
7
+ /** When true, normalize current-stage gate catalog and persist reconciliation before checks. */
8
+ reconcileCurrentStageGates?: boolean;
9
+ }
10
+ export declare function doctorChecks(projectRoot: string, options?: DoctorOptions): Promise<DoctorCheck[]>;
7
11
  export declare function doctorSucceeded(checks: DoctorCheck[]): boolean;
package/dist/doctor.js CHANGED
@@ -10,7 +10,13 @@ import { gitignoreHasRequiredPatterns } from "./gitignore.js";
10
10
  import { HARNESS_ADAPTERS, CCLAW_MARKER_START, CCLAW_MARKER_END } from "./harness-adapters.js";
11
11
  import { policyChecks } from "./policy.js";
12
12
  import { readFlowState } from "./runs.js";
13
+ import { checkMandatoryDelegations } from "./delegation.js";
14
+ import { buildTraceMatrix } from "./trace-matrix.js";
15
+ import { reconcileAndWriteCurrentStageGateCatalog, verifyCurrentStageGateEvidence } from "./gate-evidence.js";
13
16
  import { stageSkillFolder } from "./content/skills.js";
17
+ import { UTILITY_SKILL_FOLDERS } from "./content/utility-skills.js";
18
+ import { CONTEXT_MODES, DEFAULT_CONTEXT_MODE } from "./content/contexts.js";
19
+ import { validateHookDocument } from "./hook-schema.js";
14
20
  const execFileAsync = promisify(execFile);
15
21
  async function isGitRepo(projectRoot) {
16
22
  try {
@@ -21,6 +27,19 @@ async function isGitRepo(projectRoot) {
21
27
  return false;
22
28
  }
23
29
  }
30
+ async function resolveGitHooksDir(projectRoot) {
31
+ try {
32
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", "hooks"], { cwd: projectRoot });
33
+ const rel = stdout.trim();
34
+ if (rel.length === 0) {
35
+ return null;
36
+ }
37
+ return path.resolve(projectRoot, rel);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
24
43
  async function gitIgnoresRuntime(projectRoot) {
25
44
  try {
26
45
  await execFileAsync("git", ["check-ignore", "-q", `${RUNTIME_ROOT}/`], { cwd: projectRoot });
@@ -128,7 +147,46 @@ async function readHookDocument(filePath) {
128
147
  return null;
129
148
  }
130
149
  }
131
- export async function doctorChecks(projectRoot) {
150
+ function normalizeOpenCodePluginEntry(entry) {
151
+ if (typeof entry === "string" && entry.trim().length > 0)
152
+ return entry.trim();
153
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
154
+ return null;
155
+ const obj = entry;
156
+ for (const key of ["path", "src", "plugin"]) {
157
+ const value = obj[key];
158
+ if (typeof value === "string" && value.trim().length > 0) {
159
+ return value.trim();
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ async function opencodeRegistrationCheck(projectRoot) {
165
+ const expected = ".opencode/plugins/cclaw-plugin.mjs";
166
+ const candidates = [
167
+ path.join(projectRoot, "opencode.json"),
168
+ path.join(projectRoot, "opencode.jsonc"),
169
+ path.join(projectRoot, ".opencode/opencode.json"),
170
+ path.join(projectRoot, ".opencode/opencode.jsonc")
171
+ ];
172
+ for (const configPath of candidates) {
173
+ if (!(await exists(configPath))) {
174
+ continue;
175
+ }
176
+ const parsed = await readHookDocument(configPath);
177
+ if (!parsed) {
178
+ continue;
179
+ }
180
+ const plugins = Array.isArray(parsed.plugin) ? parsed.plugin : [];
181
+ const registered = plugins.some((entry) => normalizeOpenCodePluginEntry(entry) === expected);
182
+ if (registered) {
183
+ return { ok: true, details: `${path.relative(projectRoot, configPath)} registers ${expected}` };
184
+ }
185
+ return { ok: false, details: `${path.relative(projectRoot, configPath)} missing plugin ${expected}` };
186
+ }
187
+ return { ok: false, details: `No opencode.json/opencode.jsonc found with plugin ${expected}` };
188
+ }
189
+ export async function doctorChecks(projectRoot, options = {}) {
132
190
  const checks = [];
133
191
  for (const dir of REQUIRED_DIRS) {
134
192
  const fullPath = path.join(projectRoot, dir);
@@ -169,8 +227,10 @@ export async function doctorChecks(projectRoot) {
169
227
  details: ".gitignore must include cclaw ignore block"
170
228
  });
171
229
  let configuredHarnesses = [];
230
+ let parsedConfig = null;
172
231
  try {
173
232
  const config = await readConfig(projectRoot);
233
+ parsedConfig = config;
174
234
  configuredHarnesses = config.harnesses;
175
235
  checks.push({
176
236
  name: "config:valid",
@@ -185,6 +245,55 @@ export async function doctorChecks(projectRoot) {
185
245
  details: error instanceof Error ? error.message : "Invalid config"
186
246
  });
187
247
  }
248
+ if (parsedConfig) {
249
+ const expectedMode = parsedConfig.promptGuardMode === "strict" ? "strict" : "advisory";
250
+ const promptGuardPath = path.join(projectRoot, RUNTIME_ROOT, "hooks", "prompt-guard.sh");
251
+ let promptGuardModeOk = false;
252
+ if (await exists(promptGuardPath)) {
253
+ const promptGuardContent = await fs.readFile(promptGuardPath, "utf8");
254
+ promptGuardModeOk = promptGuardContent.includes(`PROMPT_GUARD_MODE="${expectedMode}"`);
255
+ }
256
+ checks.push({
257
+ name: "hook:prompt_guard:mode",
258
+ ok: promptGuardModeOk,
259
+ details: `${promptGuardPath} must match promptGuardMode=${expectedMode}`
260
+ });
261
+ if (parsedConfig.gitHookGuards === true) {
262
+ const runtimePreCommit = path.join(projectRoot, RUNTIME_ROOT, "hooks", "git", "pre-commit.sh");
263
+ const runtimePrePush = path.join(projectRoot, RUNTIME_ROOT, "hooks", "git", "pre-push.sh");
264
+ const runtimeScriptsOk = (await exists(runtimePreCommit)) && (await exists(runtimePrePush));
265
+ checks.push({
266
+ name: "git_hooks:managed:runtime_scripts",
267
+ ok: runtimeScriptsOk,
268
+ details: `${RUNTIME_ROOT}/hooks/git/pre-commit.sh and pre-push.sh must exist when gitHookGuards=true`
269
+ });
270
+ const gitHooksDir = await resolveGitHooksDir(projectRoot);
271
+ if (!gitHooksDir) {
272
+ checks.push({
273
+ name: "git_hooks:managed:relays",
274
+ ok: true,
275
+ details: "git repository not detected; relay hook check skipped"
276
+ });
277
+ }
278
+ else {
279
+ const preCommitHookPath = path.join(gitHooksDir, "pre-commit");
280
+ const prePushHookPath = path.join(gitHooksDir, "pre-push");
281
+ let relaysOk = false;
282
+ if ((await exists(preCommitHookPath)) && (await exists(prePushHookPath))) {
283
+ const preCommitHook = await fs.readFile(preCommitHookPath, "utf8");
284
+ const prePushHook = await fs.readFile(prePushHookPath, "utf8");
285
+ relaysOk =
286
+ preCommitHook.includes("cclaw-managed-git-hook") &&
287
+ prePushHook.includes("cclaw-managed-git-hook");
288
+ }
289
+ checks.push({
290
+ name: "git_hooks:managed:relays",
291
+ ok: relaysOk,
292
+ details: `${path.relative(projectRoot, gitHooksDir)}/pre-commit and pre-push must contain managed relay marker`
293
+ });
294
+ }
295
+ }
296
+ }
188
297
  for (const harness of configuredHarnesses) {
189
298
  const adapter = HARNESS_ADAPTERS[harness];
190
299
  if (!adapter) {
@@ -223,13 +332,14 @@ export async function doctorChecks(projectRoot) {
223
332
  const hasAllCommands = COMMAND_FILE_ORDER.every((stage) => content.includes(`/cc-${stage}`));
224
333
  const hasRouting = content.includes("Intent → Stage Routing") || content.includes("Intent → Stage");
225
334
  const hasVerification = content.includes("Verification Discipline");
226
- const hasDetailLevel = content.includes("Detail Level");
227
- agentsBlockOk = hasMarkers && hasAllCommands && hasRouting && hasVerification && hasDetailLevel;
335
+ const hasMinimalMarker = content.includes("intentionally minimal for cross-project use");
336
+ const hasMetaSkillPointer = content.includes(".cclaw/skills/using-cclaw/SKILL.md");
337
+ agentsBlockOk = hasMarkers && hasAllCommands && hasRouting && hasVerification && hasMinimalMarker && hasMetaSkillPointer;
228
338
  }
229
339
  checks.push({
230
340
  name: "agents:cclaw_block",
231
341
  ok: agentsBlockOk,
232
- details: `${agentsFile} must contain cclaw marker block with compact routing and verification guidance`
342
+ details: `${agentsFile} must contain the managed cclaw marker block with routing, verification, and minimal detail pointer`
233
343
  });
234
344
  // Utility commands
235
345
  for (const cmd of ["learn", "autoplan"]) {
@@ -256,14 +366,8 @@ export async function doctorChecks(projectRoot) {
256
366
  details: skillPath
257
367
  });
258
368
  }
259
- // New utility skills (security, debugging, performance, ci-cd, docs)
260
- for (const folder of [
261
- "security",
262
- "debugging",
263
- "performance",
264
- "ci-cd",
265
- "docs"
266
- ]) {
369
+ // Extended utility skills generated from utility skill map.
370
+ for (const folder of UTILITY_SKILL_FOLDERS) {
267
371
  const skillPath = path.join(projectRoot, RUNTIME_ROOT, "skills", folder, "SKILL.md");
268
372
  checks.push({
269
373
  name: `utility_skill:${folder}`,
@@ -290,6 +394,7 @@ export async function doctorChecks(projectRoot) {
290
394
  "session-start.sh",
291
395
  "stop-checkpoint.sh",
292
396
  "prompt-guard.sh",
397
+ "workflow-guard.sh",
293
398
  "context-monitor.sh",
294
399
  "observe.sh",
295
400
  "summarize-observations.sh",
@@ -343,14 +448,33 @@ export async function doctorChecks(projectRoot) {
343
448
  ok: hookOk,
344
449
  details: fullPath
345
450
  });
451
+ if (harness === "claude" || harness === "cursor" || harness === "codex") {
452
+ const schema = validateHookDocument(harness, parsed);
453
+ checks.push({
454
+ name: `hook:schema:${harness}`,
455
+ ok: schema.ok,
456
+ details: schema.ok
457
+ ? `${fullPath} matches cclaw hook schema v1`
458
+ : `${fullPath} schema issues: ${schema.errors.join("; ")}`
459
+ });
460
+ }
346
461
  }
347
462
  }
348
- // OpenCode plugin
463
+ // OpenCode plugin source + deployed path
349
464
  checks.push({
350
- name: "hook:opencode_plugin",
465
+ name: "hook:opencode_plugin_source",
351
466
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "hooks", "opencode-plugin.mjs")),
352
467
  details: `${RUNTIME_ROOT}/hooks/opencode-plugin.mjs`
353
468
  });
469
+ const opencodeEnabled = configuredHarnesses.includes("opencode");
470
+ const opencodeDeployed = await exists(path.join(projectRoot, ".opencode/plugins/cclaw-plugin.mjs"));
471
+ checks.push({
472
+ name: "hook:opencode_plugin_deployed",
473
+ ok: opencodeEnabled ? opencodeDeployed : true,
474
+ details: opencodeEnabled
475
+ ? ".opencode/plugins/cclaw-plugin.mjs"
476
+ : "opencode harness disabled; deployed plugin check skipped"
477
+ });
354
478
  if (configuredHarnesses.includes("claude")) {
355
479
  const file = path.join(projectRoot, ".claude/hooks/hooks.json");
356
480
  const parsed = await readHookDocument(file);
@@ -368,6 +492,7 @@ export async function doctorChecks(projectRoot) {
368
492
  const stopCommands = collectHookCommands(hooks.Stop);
369
493
  const wiringOk = sessionCommands.some((cmd) => cmd.includes("session-start.sh")) &&
370
494
  preCommands.some((cmd) => cmd.includes("prompt-guard.sh")) &&
495
+ preCommands.some((cmd) => cmd.includes("workflow-guard.sh")) &&
371
496
  preCommands.some((cmd) => cmd.includes("observe.sh pre")) &&
372
497
  postCommands.some((cmd) => cmd.includes("observe.sh post")) &&
373
498
  postCommands.some((cmd) => cmd.includes("context-monitor.sh")) &&
@@ -376,7 +501,7 @@ export async function doctorChecks(projectRoot) {
376
501
  checks.push({
377
502
  name: "hook:wiring:claude",
378
503
  ok: wiringOk,
379
- details: `${file} must wire session-start/prompt-guard/observe/context-monitor/summarize/stop-checkpoint`
504
+ details: `${file} must wire session-start/prompt-guard/workflow-guard/observe/context-monitor/summarize/stop-checkpoint`
380
505
  });
381
506
  }
382
507
  if (configuredHarnesses.includes("cursor")) {
@@ -403,6 +528,7 @@ export async function doctorChecks(projectRoot) {
403
528
  const stopCommands = collectHookCommands(hooks.stop);
404
529
  const wiringOk = sessionCommands.some((cmd) => cmd.includes("session-start.sh")) &&
405
530
  preCommands.some((cmd) => cmd.includes("prompt-guard.sh")) &&
531
+ preCommands.some((cmd) => cmd.includes("workflow-guard.sh")) &&
406
532
  preCommands.some((cmd) => cmd.includes("observe.sh pre")) &&
407
533
  postCommands.some((cmd) => cmd.includes("observe.sh post")) &&
408
534
  postCommands.some((cmd) => cmd.includes("context-monitor.sh")) &&
@@ -411,7 +537,21 @@ export async function doctorChecks(projectRoot) {
411
537
  checks.push({
412
538
  name: "hook:wiring:cursor",
413
539
  ok: wiringOk,
414
- details: `${file} must wire session-start/prompt-guard/observe/context-monitor/summarize/stop-checkpoint`
540
+ details: `${file} must wire session-start/prompt-guard/workflow-guard/observe/context-monitor/summarize/stop-checkpoint`
541
+ });
542
+ const cursorRulePath = path.join(projectRoot, ".cursor/rules/cclaw-workflow.mdc");
543
+ let cursorRuleOk = false;
544
+ if (await exists(cursorRulePath)) {
545
+ const content = await fs.readFile(cursorRulePath, "utf8");
546
+ cursorRuleOk =
547
+ content.includes("cclaw-managed-cursor-workflow-rule") &&
548
+ content.includes(".cclaw/state/flow-state.json") &&
549
+ content.includes("/cc-next");
550
+ }
551
+ checks.push({
552
+ name: "rules:cursor:workflow",
553
+ ok: cursorRuleOk,
554
+ details: `${cursorRulePath} must include managed marker and core cclaw workflow guardrails`
415
555
  });
416
556
  }
417
557
  if (configuredHarnesses.includes("codex")) {
@@ -431,6 +571,7 @@ export async function doctorChecks(projectRoot) {
431
571
  const stopCommands = collectHookCommands(hooks.Stop);
432
572
  const wiringOk = sessionCommands.some((cmd) => cmd.includes("session-start.sh")) &&
433
573
  preCommands.some((cmd) => cmd.includes("prompt-guard.sh")) &&
574
+ preCommands.some((cmd) => cmd.includes("workflow-guard.sh")) &&
434
575
  preCommands.some((cmd) => cmd.includes("observe.sh pre")) &&
435
576
  postCommands.some((cmd) => cmd.includes("observe.sh post")) &&
436
577
  postCommands.some((cmd) => cmd.includes("context-monitor.sh")) &&
@@ -439,25 +580,37 @@ export async function doctorChecks(projectRoot) {
439
580
  checks.push({
440
581
  name: "hook:wiring:codex",
441
582
  ok: wiringOk,
442
- details: `${file} must wire session-start/prompt-guard/observe/context-monitor/summarize/stop-checkpoint`
583
+ details: `${file} must wire session-start/prompt-guard/workflow-guard/observe/context-monitor/summarize/stop-checkpoint`
443
584
  });
444
585
  }
445
586
  if (configuredHarnesses.includes("opencode")) {
446
- const file = path.join(projectRoot, RUNTIME_ROOT, "hooks", "opencode-plugin.mjs");
587
+ const file = path.join(projectRoot, ".opencode/plugins/cclaw-plugin.mjs");
447
588
  let ok = false;
448
589
  if (await exists(file)) {
449
590
  const content = await fs.readFile(file, "utf8");
450
591
  ok =
451
- content.includes('"session.created"') &&
592
+ content.includes("event: async") &&
593
+ content.includes('"tool.execute.before"') &&
594
+ content.includes('"tool.execute.after"') &&
595
+ content.includes("prompt-guard.sh") &&
596
+ content.includes("workflow-guard.sh") &&
597
+ content.includes("context-monitor.sh") &&
598
+ content.includes('"session.idle"') &&
599
+ content.includes('"session.updated"') &&
452
600
  content.includes('"session.resumed"') &&
453
- content.includes('"session.compacted"') &&
454
601
  content.includes('"session.cleared"') &&
455
602
  content.includes('"experimental.chat.system.transform"');
456
603
  }
457
604
  checks.push({
458
605
  name: "lifecycle:opencode:rehydration_events",
459
606
  ok,
460
- details: `${file} must include created/resumed/compacted/cleared and transform rehydration handlers`
607
+ details: `${file} must include event lifecycle handler, tool.execute.before/after with prompt/workflow/context hooks, session.idle summarization, and transform rehydration`
608
+ });
609
+ const registration = await opencodeRegistrationCheck(projectRoot);
610
+ checks.push({
611
+ name: "hook:opencode:config_registration",
612
+ ok: registration.ok,
613
+ details: registration.details
461
614
  });
462
615
  }
463
616
  const hasBash = await commandAvailable("bash");
@@ -510,7 +663,56 @@ export async function doctorChecks(projectRoot) {
510
663
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "state", "suggestion-memory.json")),
511
664
  details: `${RUNTIME_ROOT}/state/suggestion-memory.json must exist for proactive suggestion memory`
512
665
  });
513
- const flowState = await readFlowState(projectRoot);
666
+ const contextModeStatePath = path.join(projectRoot, RUNTIME_ROOT, "state", "context-mode.json");
667
+ checks.push({
668
+ name: "state:context_mode_exists",
669
+ ok: await exists(contextModeStatePath),
670
+ details: `${RUNTIME_ROOT}/state/context-mode.json must exist for context mode switching`
671
+ });
672
+ if (await exists(contextModeStatePath)) {
673
+ let contextModeOk = false;
674
+ try {
675
+ const parsed = JSON.parse(await fs.readFile(contextModeStatePath, "utf8"));
676
+ const activeMode = typeof parsed.activeMode === "string" ? parsed.activeMode : "";
677
+ contextModeOk = activeMode.length > 0 && Object.prototype.hasOwnProperty.call(CONTEXT_MODES, activeMode);
678
+ }
679
+ catch {
680
+ contextModeOk = false;
681
+ }
682
+ checks.push({
683
+ name: "state:context_mode_valid",
684
+ ok: contextModeOk,
685
+ details: `${RUNTIME_ROOT}/state/context-mode.json must reference one of: ${Object.keys(CONTEXT_MODES).join(", ")} (default=${DEFAULT_CONTEXT_MODE})`
686
+ });
687
+ }
688
+ for (const mode of Object.keys(CONTEXT_MODES)) {
689
+ const modePath = path.join(projectRoot, RUNTIME_ROOT, "contexts", `${mode}.md`);
690
+ checks.push({
691
+ name: `contexts:mode:${mode}`,
692
+ ok: await exists(modePath),
693
+ details: modePath
694
+ });
695
+ }
696
+ let flowState = await readFlowState(projectRoot);
697
+ if (options.reconcileCurrentStageGates === true) {
698
+ const reconciliation = await reconcileAndWriteCurrentStageGateCatalog(projectRoot);
699
+ if (reconciliation.wrote) {
700
+ flowState = {
701
+ ...flowState,
702
+ stageGateCatalog: {
703
+ ...flowState.stageGateCatalog,
704
+ [reconciliation.stage]: reconciliation.after
705
+ }
706
+ };
707
+ }
708
+ checks.push({
709
+ name: "gates:reconcile:writeback",
710
+ ok: true,
711
+ details: reconciliation.wrote
712
+ ? `reconciled gate catalog for stage "${reconciliation.stage}": ${reconciliation.notes.join("; ")}`
713
+ : `no gate reconciliation changes needed for stage "${reconciliation.stage}"`
714
+ });
715
+ }
514
716
  checks.push({
515
717
  name: "flow_state:active_run_id",
516
718
  ok: typeof flowState.activeRunId === "string" && flowState.activeRunId.trim().length > 0,
@@ -531,6 +733,55 @@ export async function doctorChecks(projectRoot) {
531
733
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "runs", flowState.activeRunId, "00-handoff.md")),
532
734
  details: `${RUNTIME_ROOT}/runs/${flowState.activeRunId}/00-handoff.md must exist`
533
735
  });
736
+ const delegation = await checkMandatoryDelegations(projectRoot, flowState.currentStage);
737
+ checks.push({
738
+ name: "delegation:mandatory:current_stage",
739
+ ok: delegation.satisfied,
740
+ details: delegation.satisfied
741
+ ? `All mandatory delegations satisfied for stage "${flowState.currentStage}"`
742
+ : `Missing mandatory delegations for stage "${flowState.currentStage}": ${delegation.missing.join(", ")}`
743
+ });
744
+ checks.push({
745
+ name: "warning:delegation:waived",
746
+ ok: true,
747
+ details: delegation.waived.length > 0
748
+ ? `warning: waived mandatory delegations for stage "${flowState.currentStage}": ${delegation.waived.join(", ")}`
749
+ : "no waived mandatory delegations for current stage"
750
+ });
751
+ const trace = await buildTraceMatrix(projectRoot);
752
+ const traceHasSignal = trace.entries.length > 0 ||
753
+ trace.orphanedCriteria.length > 0 ||
754
+ trace.orphanedTasks.length > 0 ||
755
+ trace.orphanedTests.length > 0;
756
+ checks.push({
757
+ name: "trace:criteria_coverage",
758
+ ok: !traceHasSignal || trace.orphanedCriteria.length === 0,
759
+ details: trace.orphanedCriteria.length === 0
760
+ ? "all spec criteria are linked to plan tasks"
761
+ : `orphaned criteria: ${trace.orphanedCriteria.join(", ")}`
762
+ });
763
+ checks.push({
764
+ name: "trace:task_to_test_coverage",
765
+ ok: !traceHasSignal || trace.orphanedTasks.length === 0,
766
+ details: trace.orphanedTasks.length === 0
767
+ ? "all plan tasks are linked to test slices"
768
+ : `orphaned tasks: ${trace.orphanedTasks.join(", ")}`
769
+ });
770
+ checks.push({
771
+ name: "trace:test_to_criteria_coverage",
772
+ ok: !traceHasSignal || trace.orphanedTests.length === 0,
773
+ details: trace.orphanedTests.length === 0
774
+ ? "all test slices map to acceptance-linked tasks"
775
+ : `orphaned test slices: ${trace.orphanedTests.join(", ")}`
776
+ });
777
+ const gateEvidence = await verifyCurrentStageGateEvidence(projectRoot, flowState);
778
+ checks.push({
779
+ name: "gates:evidence:current_stage",
780
+ ok: gateEvidence.ok,
781
+ details: gateEvidence.ok
782
+ ? `stage "${gateEvidence.stage}" gate evidence is consistent (required=${gateEvidence.requiredCount}, passed=${gateEvidence.passedCount}, blocked=${gateEvidence.blockedCount})`
783
+ : gateEvidence.issues.join(" ")
784
+ });
534
785
  // Utility shims in harness dirs
535
786
  for (const harness of configuredHarnesses) {
536
787
  const adapter = HARNESS_ADAPTERS[harness];
@@ -595,7 +846,7 @@ export async function doctorChecks(projectRoot) {
595
846
  ok: hasRules,
596
847
  details: rulesJsonPath
597
848
  });
598
- const policy = await policyChecks(projectRoot);
849
+ const policy = await policyChecks(projectRoot, { harnesses: configuredHarnesses });
599
850
  checks.push(...policy);
600
851
  return checks;
601
852
  }
@@ -1,4 +1,14 @@
1
1
  export declare function ensureDir(dirPath: string): Promise<void>;
2
+ export interface DirectoryLockOptions {
3
+ retries?: number;
4
+ retryDelayMs?: number;
5
+ staleAfterMs?: number;
6
+ }
7
+ /**
8
+ * Acquire a lightweight lock by creating a directory.
9
+ * The lock is removed in a finally block.
10
+ */
11
+ export declare function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>, options?: DirectoryLockOptions): Promise<T>;
2
12
  export declare function writeFileSafe(filePath: string, content: string): Promise<void>;
3
13
  export declare function exists(filePath: string): Promise<boolean>;
4
14
  export declare function removeIfExists(targetPath: string): Promise<void>;
package/dist/fs-utils.js CHANGED
@@ -3,6 +3,53 @@ import path from "node:path";
3
3
  export async function ensureDir(dirPath) {
4
4
  await fs.mkdir(dirPath, { recursive: true });
5
5
  }
6
+ function sleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+ /**
10
+ * Acquire a lightweight lock by creating a directory.
11
+ * The lock is removed in a finally block.
12
+ */
13
+ export async function withDirectoryLock(lockPath, fn, options = {}) {
14
+ const retries = options.retries ?? 200;
15
+ const retryDelayMs = options.retryDelayMs ?? 20;
16
+ const staleAfterMs = options.staleAfterMs ?? 60_000;
17
+ await ensureDir(path.dirname(lockPath));
18
+ let acquired = false;
19
+ for (let attempt = 0; attempt < retries; attempt += 1) {
20
+ try {
21
+ await fs.mkdir(lockPath);
22
+ acquired = true;
23
+ break;
24
+ }
25
+ catch (error) {
26
+ const code = error?.code;
27
+ if (code !== "EEXIST") {
28
+ throw error;
29
+ }
30
+ try {
31
+ const stat = await fs.stat(lockPath);
32
+ if (Date.now() - stat.mtimeMs > staleAfterMs) {
33
+ await fs.rm(lockPath, { recursive: true, force: true });
34
+ continue;
35
+ }
36
+ }
37
+ catch {
38
+ // Lock directory disappeared between retries.
39
+ }
40
+ await sleep(retryDelayMs);
41
+ }
42
+ }
43
+ if (!acquired) {
44
+ throw new Error(`Failed to acquire lock: ${lockPath}`);
45
+ }
46
+ try {
47
+ return await fn();
48
+ }
49
+ finally {
50
+ await fs.rm(lockPath, { recursive: true, force: true }).catch(() => { });
51
+ }
52
+ }
6
53
  export async function writeFileSafe(filePath, content) {
7
54
  await ensureDir(path.dirname(filePath));
8
55
  const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
@@ -0,0 +1,26 @@
1
+ import type { FlowState, StageGateState } from "./flow-state.js";
2
+ import type { FlowStage } from "./types.js";
3
+ export interface GateEvidenceCheckResult {
4
+ ok: boolean;
5
+ stage: FlowStage;
6
+ issues: string[];
7
+ requiredCount: number;
8
+ passedCount: number;
9
+ blockedCount: number;
10
+ }
11
+ export declare function verifyCurrentStageGateEvidence(projectRoot: string, flowState: FlowState): Promise<GateEvidenceCheckResult>;
12
+ export interface GateReconciliationResult {
13
+ stage: FlowStage;
14
+ changed: boolean;
15
+ before: StageGateState;
16
+ after: StageGateState;
17
+ notes: string[];
18
+ }
19
+ export interface GateReconciliationWritebackResult extends GateReconciliationResult {
20
+ wrote: boolean;
21
+ }
22
+ export declare function reconcileCurrentStageGateCatalog(flowState: FlowState): {
23
+ nextState: FlowState;
24
+ reconciliation: GateReconciliationResult;
25
+ };
26
+ export declare function reconcileAndWriteCurrentStageGateCatalog(projectRoot: string): Promise<GateReconciliationWritebackResult>;