cclaw-cli 0.1.0 → 0.2.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.
Files changed (49) hide show
  1. package/README.md +28 -0
  2. package/dist/artifact-linter.d.ts +20 -0
  3. package/dist/artifact-linter.js +368 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +22 -5
  6. package/dist/config.d.ts +4 -4
  7. package/dist/config.js +55 -3
  8. package/dist/constants.d.ts +4 -4
  9. package/dist/constants.js +6 -3
  10. package/dist/content/autoplan.js +51 -4
  11. package/dist/content/contexts.d.ts +9 -0
  12. package/dist/content/contexts.js +65 -0
  13. package/dist/content/hooks.d.ts +6 -2
  14. package/dist/content/hooks.js +448 -16
  15. package/dist/content/meta-skill.js +26 -0
  16. package/dist/content/next-command.d.ts +9 -0
  17. package/dist/content/next-command.js +138 -0
  18. package/dist/content/observe.d.ts +5 -1
  19. package/dist/content/observe.js +506 -24
  20. package/dist/content/skills.js +126 -0
  21. package/dist/content/stage-schema.d.ts +7 -0
  22. package/dist/content/stage-schema.js +70 -12
  23. package/dist/content/subagents.js +33 -0
  24. package/dist/content/templates.d.ts +1 -0
  25. package/dist/content/templates.js +182 -77
  26. package/dist/content/utility-skills.d.ts +5 -1
  27. package/dist/content/utility-skills.js +208 -2
  28. package/dist/delegation.d.ts +21 -0
  29. package/dist/delegation.js +94 -0
  30. package/dist/doctor.d.ts +5 -1
  31. package/dist/doctor.js +274 -29
  32. package/dist/fs-utils.d.ts +10 -0
  33. package/dist/fs-utils.js +47 -0
  34. package/dist/gate-evidence.d.ts +26 -0
  35. package/dist/gate-evidence.js +157 -0
  36. package/dist/harness-adapters.js +10 -26
  37. package/dist/hook-schema.d.ts +6 -0
  38. package/dist/hook-schema.js +45 -0
  39. package/dist/hook-schemas/claude-hooks.v1.json +12 -0
  40. package/dist/hook-schemas/codex-hooks.v1.json +12 -0
  41. package/dist/hook-schemas/cursor-hooks.v1.json +15 -0
  42. package/dist/install.js +395 -15
  43. package/dist/policy.d.ts +5 -1
  44. package/dist/policy.js +52 -1
  45. package/dist/runs.js +8 -3
  46. package/dist/trace-matrix.d.ts +13 -0
  47. package/dist/trace-matrix.js +182 -0
  48. package/dist/types.d.ts +11 -1
  49. package/package.json +2 -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.plugins) ? parsed.plugins : [];
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,19 +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 hasFileMap = content.includes("File Map");
227
- const hasLearnings = content.includes("Learnings Store");
228
- const hasAutoplan = content.includes("Autoplan Orchestrator");
229
- const hasAgents = content.includes("Agent Specialists");
230
- const hasSubagents = content.includes("Subagent Orchestration");
231
- const hasSessionProtocols = content.includes("Session Guidelines");
232
- const hasHooks = content.includes("Hooks");
233
- agentsBlockOk = hasMarkers && hasAllCommands && hasRouting && hasVerification && hasFileMap && hasLearnings && hasAutoplan && hasAgents && hasSubagents && hasSessionProtocols && hasHooks;
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;
234
338
  }
235
339
  checks.push({
236
340
  name: "agents:cclaw_block",
237
341
  ok: agentsBlockOk,
238
- details: `${agentsFile} must contain cclaw marker block with routing, verification, and file map`
342
+ details: `${agentsFile} must contain the managed cclaw marker block with routing, verification, and minimal detail pointer`
239
343
  });
240
344
  // Utility commands
241
345
  for (const cmd of ["learn", "autoplan"]) {
@@ -262,14 +366,8 @@ export async function doctorChecks(projectRoot) {
262
366
  details: skillPath
263
367
  });
264
368
  }
265
- // New utility skills (security, debugging, performance, ci-cd, docs)
266
- for (const folder of [
267
- "security",
268
- "debugging",
269
- "performance",
270
- "ci-cd",
271
- "docs"
272
- ]) {
369
+ // Extended utility skills generated from utility skill map.
370
+ for (const folder of UTILITY_SKILL_FOLDERS) {
273
371
  const skillPath = path.join(projectRoot, RUNTIME_ROOT, "skills", folder, "SKILL.md");
274
372
  checks.push({
275
373
  name: `utility_skill:${folder}`,
@@ -296,6 +394,7 @@ export async function doctorChecks(projectRoot) {
296
394
  "session-start.sh",
297
395
  "stop-checkpoint.sh",
298
396
  "prompt-guard.sh",
397
+ "workflow-guard.sh",
299
398
  "context-monitor.sh",
300
399
  "observe.sh",
301
400
  "summarize-observations.sh",
@@ -349,14 +448,33 @@ export async function doctorChecks(projectRoot) {
349
448
  ok: hookOk,
350
449
  details: fullPath
351
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
+ }
352
461
  }
353
462
  }
354
- // OpenCode plugin
463
+ // OpenCode plugin source + deployed path
355
464
  checks.push({
356
- name: "hook:opencode_plugin",
465
+ name: "hook:opencode_plugin_source",
357
466
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "hooks", "opencode-plugin.mjs")),
358
467
  details: `${RUNTIME_ROOT}/hooks/opencode-plugin.mjs`
359
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
+ });
360
478
  if (configuredHarnesses.includes("claude")) {
361
479
  const file = path.join(projectRoot, ".claude/hooks/hooks.json");
362
480
  const parsed = await readHookDocument(file);
@@ -374,6 +492,7 @@ export async function doctorChecks(projectRoot) {
374
492
  const stopCommands = collectHookCommands(hooks.Stop);
375
493
  const wiringOk = sessionCommands.some((cmd) => cmd.includes("session-start.sh")) &&
376
494
  preCommands.some((cmd) => cmd.includes("prompt-guard.sh")) &&
495
+ preCommands.some((cmd) => cmd.includes("workflow-guard.sh")) &&
377
496
  preCommands.some((cmd) => cmd.includes("observe.sh pre")) &&
378
497
  postCommands.some((cmd) => cmd.includes("observe.sh post")) &&
379
498
  postCommands.some((cmd) => cmd.includes("context-monitor.sh")) &&
@@ -382,7 +501,7 @@ export async function doctorChecks(projectRoot) {
382
501
  checks.push({
383
502
  name: "hook:wiring:claude",
384
503
  ok: wiringOk,
385
- 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`
386
505
  });
387
506
  }
388
507
  if (configuredHarnesses.includes("cursor")) {
@@ -409,6 +528,7 @@ export async function doctorChecks(projectRoot) {
409
528
  const stopCommands = collectHookCommands(hooks.stop);
410
529
  const wiringOk = sessionCommands.some((cmd) => cmd.includes("session-start.sh")) &&
411
530
  preCommands.some((cmd) => cmd.includes("prompt-guard.sh")) &&
531
+ preCommands.some((cmd) => cmd.includes("workflow-guard.sh")) &&
412
532
  preCommands.some((cmd) => cmd.includes("observe.sh pre")) &&
413
533
  postCommands.some((cmd) => cmd.includes("observe.sh post")) &&
414
534
  postCommands.some((cmd) => cmd.includes("context-monitor.sh")) &&
@@ -417,7 +537,21 @@ export async function doctorChecks(projectRoot) {
417
537
  checks.push({
418
538
  name: "hook:wiring:cursor",
419
539
  ok: wiringOk,
420
- 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`
421
555
  });
422
556
  }
423
557
  if (configuredHarnesses.includes("codex")) {
@@ -437,6 +571,7 @@ export async function doctorChecks(projectRoot) {
437
571
  const stopCommands = collectHookCommands(hooks.Stop);
438
572
  const wiringOk = sessionCommands.some((cmd) => cmd.includes("session-start.sh")) &&
439
573
  preCommands.some((cmd) => cmd.includes("prompt-guard.sh")) &&
574
+ preCommands.some((cmd) => cmd.includes("workflow-guard.sh")) &&
440
575
  preCommands.some((cmd) => cmd.includes("observe.sh pre")) &&
441
576
  postCommands.some((cmd) => cmd.includes("observe.sh post")) &&
442
577
  postCommands.some((cmd) => cmd.includes("context-monitor.sh")) &&
@@ -445,25 +580,37 @@ export async function doctorChecks(projectRoot) {
445
580
  checks.push({
446
581
  name: "hook:wiring:codex",
447
582
  ok: wiringOk,
448
- 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`
449
584
  });
450
585
  }
451
586
  if (configuredHarnesses.includes("opencode")) {
452
- const file = path.join(projectRoot, RUNTIME_ROOT, "hooks", "opencode-plugin.mjs");
587
+ const file = path.join(projectRoot, ".opencode/plugins/cclaw-plugin.mjs");
453
588
  let ok = false;
454
589
  if (await exists(file)) {
455
590
  const content = await fs.readFile(file, "utf8");
456
591
  ok =
457
- 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"') &&
458
600
  content.includes('"session.resumed"') &&
459
- content.includes('"session.compacted"') &&
460
601
  content.includes('"session.cleared"') &&
461
602
  content.includes('"experimental.chat.system.transform"');
462
603
  }
463
604
  checks.push({
464
605
  name: "lifecycle:opencode:rehydration_events",
465
606
  ok,
466
- 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
467
614
  });
468
615
  }
469
616
  const hasBash = await commandAvailable("bash");
@@ -516,7 +663,56 @@ export async function doctorChecks(projectRoot) {
516
663
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "state", "suggestion-memory.json")),
517
664
  details: `${RUNTIME_ROOT}/state/suggestion-memory.json must exist for proactive suggestion memory`
518
665
  });
519
- 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
+ }
520
716
  checks.push({
521
717
  name: "flow_state:active_run_id",
522
718
  ok: typeof flowState.activeRunId === "string" && flowState.activeRunId.trim().length > 0,
@@ -537,6 +733,55 @@ export async function doctorChecks(projectRoot) {
537
733
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "runs", flowState.activeRunId, "00-handoff.md")),
538
734
  details: `${RUNTIME_ROOT}/runs/${flowState.activeRunId}/00-handoff.md must exist`
539
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
+ });
540
785
  // Utility shims in harness dirs
541
786
  for (const harness of configuredHarnesses) {
542
787
  const adapter = HARNESS_ADAPTERS[harness];
@@ -601,7 +846,7 @@ export async function doctorChecks(projectRoot) {
601
846
  ok: hasRules,
602
847
  details: rulesJsonPath
603
848
  });
604
- const policy = await policyChecks(projectRoot);
849
+ const policy = await policyChecks(projectRoot, { harnesses: configuredHarnesses });
605
850
  checks.push(...policy);
606
851
  return checks;
607
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>;