clawspec 1.0.16 → 1.0.19

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
@@ -552,6 +552,8 @@ That loop is the normal operating model:
552
552
  | `/clawspec status` | Any time you want a reconciled project snapshot | Renders current workspace, project, change, lifecycle, task counts, journal status, and next step |
553
553
  | `/clawspec archive` | After all tasks are complete and you want to close the change | Validates and archives the finished OpenSpec change, then clears active change state |
554
554
  | `/clawspec cancel` | When you want to abandon the active change | Restores tracked files from snapshots, removes the change, stops execution state, and clears the active change |
555
+ | `/clawspec doctor` | When something isn't working as expected | Runs automated diagnostics on your environment and configuration, reports detected issues with suggested fixes |
556
+ | `/clawspec doctor fix` | After `/clawspec doctor` reports a fixable issue | Automatically applies safe fixes for all detected issues that support auto-repair |
555
557
 
556
558
  Auxiliary host CLI:
557
559
 
@@ -858,6 +860,20 @@ Use one of:
858
860
 
859
861
  That is by design. Cancel restores tracked files from the ClawSpec snapshot baseline for the active change. It is intentionally narrower than a blanket Git restore.
860
862
 
863
+ ### Worker fails with "stdin is not a terminal" or session creation returns no result
864
+
865
+ Cause:
866
+
867
+ - `~/.acpx/config.json` has a custom agent entry (e.g. `agents.codex` pointing to a local CLI binary)
868
+ - acpx cannot spawn the agent in a non-interactive environment when it is configured as a raw CLI command
869
+
870
+ What to do:
871
+
872
+ 1. Run `/clawspec doctor` to check for known configuration issues.
873
+ 2. If `/clawspec doctor` reports custom agent entries, run `/clawspec doctor fix` to clear them automatically.
874
+ 3. Alternatively, edit `~/.acpx/config.json` manually and set `"agents": {}`.
875
+ 4. Retry `cs-work` or `/clawspec continue`.
876
+
861
877
  ## Development And Validation
862
878
 
863
879
  Useful checks while editing the plugin:
package/README.zh-CN.md CHANGED
@@ -556,6 +556,8 @@ cs-work
556
556
  | `/clawspec status` | 任何时候想看一次统一状态快照时 | 输出当前 workspace、project、change、生命周期、任务计数、journal 状态和下一步建议 |
557
557
  | `/clawspec archive` | 所有任务完成,准备收尾时 | 校验并归档当前 OpenSpec change,同时清理活动 change 状态 |
558
558
  | `/clawspec cancel` | 想放弃当前 change 时 | 按快照恢复已跟踪文件、删除 change、停止执行状态,并清掉活动 change |
559
+ | `/clawspec doctor` | 遇到异常行为时 | 对环境和配置进行自动化诊断,报告检测到的问题并给出修复建议 |
560
+ | `/clawspec doctor fix` | `/clawspec doctor` 检测到可修复问题后 | 自动应用所有支持自动修复的安全修复 |
559
561
 
560
562
  辅助 host CLI:
561
563
 
@@ -869,6 +871,20 @@ cs-detach
869
871
 
870
872
  这是设计如此。Cancel 只会恢复 ClawSpec 为当前 change 跟踪的文件快照,而不是做整仓库级别的 Git 还原。
871
873
 
874
+ ### Worker 报错 "stdin is not a terminal" 或 session 创建无结果
875
+
876
+ 原因:
877
+
878
+ - `~/.acpx/config.json` 中的 `agents` 字段存在自定义配置(例如 `agents.codex` 指向了本地 CLI 路径)
879
+ - acpx 在非交互式环境下无法通过 raw CLI 方式启动 agent
880
+
881
+ 解决方法:
882
+
883
+ 1. 运行 `/clawspec doctor` 检查配置问题。
884
+ 2. 如果 doctor 报告了自定义 agent 配置,运行 `/clawspec doctor fix` 自动修复。
885
+ 3. 也可以手动编辑 `~/.acpx/config.json`,将 `"agents"` 设置为 `{}`。
886
+ 4. 然后重新运行 `cs-work` 或 `/clawspec continue`。
887
+
872
888
  ## 开发与验证
873
889
 
874
890
  开发时常用检查:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawspec",
3
- "version": "1.0.16",
3
+ "version": "1.0.19",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
6
6
  "keywords": [
@@ -46,7 +46,7 @@
46
46
  "scripts": {
47
47
  "check": "node --experimental-strip-types -e \"import('./index.ts')\"",
48
48
  "test": "node --experimental-strip-types --test --test-reporter spec test/*.test.ts",
49
- "test:fast": "node --experimental-strip-types --test --test-reporter spec test/acp-client.test.ts test/acpx-dependency.test.ts test/assistant-journal.test.ts test/command-surface.test.ts test/config.test.ts test/detach-attach.test.ts test/file-lock.test.ts test/fs-utils.test.ts test/helpers.test.ts test/keywords.test.ts test/notifier.test.ts test/openspec-dependency.test.ts test/paths-utils.test.ts test/pause-cancel.test.ts test/planning-journal.test.ts test/plugin-registration.test.ts test/project-memory.test.ts test/proposal.test.ts test/queue-planning.test.ts test/queue-work.test.ts test/service-archive.test.ts test/shell-command.test.ts test/state-store.test.ts test/tasks-and-checkpoint.test.ts test/use-project.test.ts test/worker-command.test.ts test/worker-io-helper.test.ts test/worker-skills.test.ts",
49
+ "test:fast": "node --experimental-strip-types --test --test-reporter spec test/acp-client.test.ts test/acpx-dependency.test.ts test/assistant-journal.test.ts test/command-surface.test.ts test/config.test.ts test/detach-attach.test.ts test/doctor.test.ts test/file-lock.test.ts test/fs-utils.test.ts test/helpers.test.ts test/keywords.test.ts test/notifier.test.ts test/openspec-dependency.test.ts test/paths-utils.test.ts test/pause-cancel.test.ts test/planning-journal.test.ts test/plugin-registration.test.ts test/project-memory.test.ts test/proposal.test.ts test/queue-planning.test.ts test/queue-work.test.ts test/service-archive.test.ts test/shell-command.test.ts test/state-store.test.ts test/tasks-and-checkpoint.test.ts test/use-project.test.ts test/worker-command.test.ts test/worker-io-helper.test.ts test/worker-skills.test.ts",
50
50
  "test:slow": "node --experimental-strip-types --test --test-reporter spec test/watcher-planning.test.ts test/watcher-work.test.ts test/recovery.test.ts"
51
51
  },
52
52
  "engines": {
package/src/acp/client.ts CHANGED
@@ -141,7 +141,7 @@ export class AcpWorkerClient {
141
141
  allowErrorCodes: ["NO_SESSION"],
142
142
  });
143
143
 
144
- if (events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION")) {
144
+ if (events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION") || events.length === 0) {
145
145
  events = await this.runControlCommand({
146
146
  agentId: descriptor.agentId,
147
147
  cwd: descriptor.cwd,
@@ -149,6 +149,14 @@ export class AcpWorkerClient {
149
149
  });
150
150
  }
151
151
 
152
+ if (events.length === 0 || !extractSessionIdentifiers(events).backendSessionId) {
153
+ throw new Error(
154
+ `Failed to create acpx session for agent "${descriptor.agentId}".\n`
155
+ + `The session command returned no result. This usually means the agent is misconfigured.\n\n`
156
+ + `Run \`/clawspec doctor\` to diagnose and fix common acpx configuration issues.`,
157
+ );
158
+ }
159
+
152
160
  const identifiers = extractSessionIdentifiers(events);
153
161
  const handle: AcpWorkerHandle = {
154
162
  sessionKey: descriptor.sessionKey,
@@ -186,6 +194,13 @@ export class AcpWorkerClient {
186
194
  cwd: descriptor.cwd,
187
195
  env: this.env,
188
196
  });
197
+
198
+ const args = this.buildPromptArgs({
199
+ agentId: descriptor.agentId,
200
+ cwd: descriptor.cwd,
201
+ sessionKey: descriptor.sessionKey,
202
+ });
203
+
189
204
  child.stdout.setEncoding("utf8");
190
205
  child.stderr.setEncoding("utf8");
191
206
  child.stdin.end(params.text);
@@ -250,6 +265,7 @@ export class AcpWorkerClient {
250
265
 
251
266
  const exit = await waitForExit(child);
252
267
  this.recordSessionExit(params.sessionKey, descriptor, child.pid, exit.code, exit.signal, stderr);
268
+
253
269
  if (exit.error) {
254
270
  throw exit.error;
255
271
  }
@@ -125,6 +125,7 @@ export function buildHelpText(): string {
125
125
  "- `/clawspec status`",
126
126
  "- `/clawspec archive`",
127
127
  "- `/clawspec cancel`",
128
+ "- `/clawspec doctor`",
128
129
  "",
129
130
  "Visible chat keywords:",
130
131
  "- `cs-plan`",
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
3
  import type {
3
4
  OpenClawConfig,
@@ -236,6 +237,8 @@ export class ClawSpecService {
236
237
  return await this.archiveProject(channelKey);
237
238
  case "cancel":
238
239
  return await this.cancelProject(channelKey);
240
+ case "doctor":
241
+ return await this.runDoctor(rest);
239
242
  default:
240
243
  return errorReply(`Unknown subcommand \`${subcommand}\`.\n\n${buildHelpText()}`);
241
244
  }
@@ -675,13 +678,6 @@ export class ClawSpecService {
675
678
  repoStatePaths.planningJournalSnapshotFile,
676
679
  project.planningJournal?.lastSyncedAt,
677
680
  );
678
- const snapshot = await journalStore.readSnapshot(repoStatePaths.planningJournalSnapshotFile);
679
- const digest = await journalStore.digest(project.changeName);
680
- this.logger.info(`[clawspec] cs-work check for ${project.changeName}:`);
681
- this.logger.info(` - hasUnsyncedChanges: ${hasUnsyncedChanges}`);
682
- this.logger.info(` - Snapshot: entryCount=${snapshot?.entryCount}, lastEntryAt=${snapshot?.lastEntryAt}, hash=${snapshot?.contentHash?.slice(0, 8)}`);
683
- this.logger.info(` - Current digest: entryCount=${digest.entryCount}, lastEntryAt=${digest.lastEntryAt}, hash=${digest.contentHash.slice(0, 8)}`);
684
- this.logger.info(` - fallbackLastSyncedAt: ${project.planningJournal?.lastSyncedAt}`);
685
681
  if (!hasUnsyncedChanges) {
686
682
  const isDetached = !isProjectContextAttached(project);
687
683
  if (isDetached) {
@@ -758,7 +754,6 @@ export class ClawSpecService {
758
754
  userPrompt: string,
759
755
  mode: ExecutionMode,
760
756
  ): Promise<{ prependContext?: string; prependSystemContext?: string } | PluginCommandResult> {
761
- this.logger.info(`[clawspec] startVisiblePlanningSync called for ${project.changeName}`);
762
757
  void project;
763
758
  void mode;
764
759
 
@@ -781,7 +776,6 @@ export class ClawSpecService {
781
776
  execution: undefined,
782
777
  lastExecutionAt: startedAt,
783
778
  }));
784
- this.logger.info(`[clawspec] Project state updated: status=planning, phase=planning_sync, boundSessionKey=${runningProject.boundSessionKey}`);
785
779
 
786
780
  return await this.buildPlanningSyncInjection(runningProject, userPrompt, prepared.instructionResults);
787
781
  }
@@ -964,7 +958,6 @@ export class ClawSpecService {
964
958
  const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey)
965
959
  ?? await this.findPlanningProjectByContext(ctx);
966
960
  if (planningProject) {
967
- this.logger.info(`[clawspec] agent_end: found planning project ${planningProject.changeName}, calling finalizePlanningTurn`);
968
961
  await this.finalizePlanningTurn(planningProject, event);
969
962
  return;
970
963
  }
@@ -1914,6 +1907,11 @@ export class ClawSpecService {
1914
1907
  );
1915
1908
  }
1916
1909
 
1910
+ async runDoctor(rawArgs?: string): Promise<PluginCommandResult> {
1911
+ const acpxConfigPath = path.join(os.homedir(), ".acpx", "config.json");
1912
+ return await runDoctorCommand(acpxConfigPath, rawArgs);
1913
+ }
1914
+
1917
1915
  private async captureIncomingMessage(channelKey: string, project: ProjectState, text: string): Promise<ProjectState> {
1918
1916
  const trimmed = sanitizePlanningMessageText(text).trim();
1919
1917
  if (!trimmed || trimmed.startsWith("/clawspec") || !project.repoPath || !project.changeName) {
@@ -2412,7 +2410,6 @@ export class ClawSpecService {
2412
2410
  }
2413
2411
 
2414
2412
  private async finalizePlanningTurn(project: ProjectState, event: AgentEndEvent): Promise<void> {
2415
- this.logger.info(`[clawspec] finalizePlanningTurn called for ${project.changeName}, success=${event.success}`);
2416
2413
  if (!project.repoPath || !project.changeName) {
2417
2414
  this.logger.warn(`[clawspec] finalizePlanningTurn skipped: missing repoPath or changeName`);
2418
2415
  return;
@@ -2474,8 +2471,6 @@ export class ClawSpecService {
2474
2471
  }
2475
2472
 
2476
2473
  const snapshot = await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, project.changeName, timestamp);
2477
- this.logger.info(`[clawspec] Planning snapshot written for ${project.changeName}: entryCount=${snapshot.entryCount}, lastEntryAt=${snapshot.lastEntryAt}`);
2478
- this.logger.info(`[clawspec] Updating project state: status=${status}, phase=${phase}, dirty=${journalDirty}`);
2479
2474
  await this.writeLatestSummary(repoStatePaths, latestSummary);
2480
2475
 
2481
2476
  const finalized = await this.stateStore.updateProject(project.channelKey, (current) => ({
@@ -3303,3 +3298,138 @@ function parseOptionalNumber(value: string): number | undefined {
3303
3298
  function isRecord(value: unknown): value is Record<string, unknown> {
3304
3299
  return typeof value === "object" && value !== null;
3305
3300
  }
3301
+
3302
+ type DoctorIssue = {
3303
+ id: string;
3304
+ message: string;
3305
+ fix: string;
3306
+ };
3307
+
3308
+ type DoctorContext = {
3309
+ acpxConfigPath: string;
3310
+ acpxConfig: Record<string, unknown> | null;
3311
+ acpxConfigError: boolean;
3312
+ };
3313
+
3314
+ type DoctorCheck = {
3315
+ id: string;
3316
+ check: (ctx: DoctorContext) => DoctorIssue | undefined;
3317
+ applyFix?: (ctx: DoctorContext) => Promise<void>;
3318
+ };
3319
+
3320
+ const DOCTOR_CHECKS: DoctorCheck[] = [
3321
+ {
3322
+ id: "acpx-config-json",
3323
+ check: (ctx) => {
3324
+ if (ctx.acpxConfigError) {
3325
+ return {
3326
+ id: "acpx-config-json",
3327
+ message: `\`${ctx.acpxConfigPath}\` contains invalid JSON. acpx may fail to start.`,
3328
+ fix: `Fix the JSON syntax in \`${ctx.acpxConfigPath}\`, or delete the file to use defaults.`,
3329
+ };
3330
+ }
3331
+ return undefined;
3332
+ },
3333
+ },
3334
+ {
3335
+ id: "acpx-custom-agents",
3336
+ check: (ctx) => {
3337
+ if (!ctx.acpxConfig) return undefined;
3338
+ const agents = ctx.acpxConfig.agents;
3339
+ if (agents && typeof agents === "object" && Object.keys(agents as object).length > 0) {
3340
+ const agentKeys = Object.keys(agents as object).map((k) => `\`${k}\``).join(", ");
3341
+ return {
3342
+ id: "acpx-custom-agents",
3343
+ message:
3344
+ `\`${ctx.acpxConfigPath}\` has custom agent entries: ${agentKeys}.\n`
3345
+ + ` Custom agent configurations (e.g. pointing to a local CLI) can cause session creation to fail with "stdin is not a terminal".`,
3346
+ fix:
3347
+ `Set \`"agents": {}\` in \`${ctx.acpxConfigPath}\` to use the default agent launcher.\n`
3348
+ + ` Run \`/clawspec doctor fix\` to apply this fix automatically.`,
3349
+ };
3350
+ }
3351
+ return undefined;
3352
+ },
3353
+ applyFix: async (ctx) => {
3354
+ if (ctx.acpxConfig) {
3355
+ ctx.acpxConfig.agents = {};
3356
+ await writeJsonFile(ctx.acpxConfigPath, ctx.acpxConfig);
3357
+ }
3358
+ },
3359
+ },
3360
+ ];
3361
+
3362
+ export async function runDoctorCommand(acpxConfigPath: string, rawArgs?: string): Promise<PluginCommandResult> {
3363
+ const action = rawArgs?.trim().toLowerCase();
3364
+
3365
+ let acpxConfig: Record<string, unknown> | null = null;
3366
+ let acpxConfigError = false;
3367
+ const raw = await tryReadUtf8(acpxConfigPath);
3368
+ if (raw !== undefined && raw.trim() !== "") {
3369
+ try {
3370
+ acpxConfig = JSON.parse(raw) as Record<string, unknown>;
3371
+ } catch {
3372
+ acpxConfigError = true;
3373
+ }
3374
+ }
3375
+
3376
+ const ctx: DoctorContext = { acpxConfigPath, acpxConfig, acpxConfigError };
3377
+ const issues: DoctorIssue[] = [];
3378
+ for (const check of DOCTOR_CHECKS) {
3379
+ const issue = check.check(ctx);
3380
+ if (issue) {
3381
+ issues.push(issue);
3382
+ }
3383
+ }
3384
+
3385
+ if (action === "fix") {
3386
+ if (issues.length === 0) {
3387
+ return okReply("Nothing to fix. acpx configuration is already clean.");
3388
+ }
3389
+ const fixable = DOCTOR_CHECKS.filter((c) => c.applyFix && issues.some((i) => i.id === c.id));
3390
+ if (fixable.length === 0) {
3391
+ return errorReply(
3392
+ "Cannot auto-fix the detected issues. Please fix them manually:\n\n"
3393
+ + issues.map((i) => `- ${i.fix}`).join("\n"),
3394
+ );
3395
+ }
3396
+ for (const check of fixable) {
3397
+ await check.applyFix!(ctx);
3398
+ }
3399
+ return okReply(
3400
+ [
3401
+ heading("Doctor Fix Applied"),
3402
+ "",
3403
+ ...fixable.map((c) => {
3404
+ const issue = issues.find((i) => i.id === c.id);
3405
+ return `Fixed: ${issue?.message.split("\n")[0] ?? c.id}`;
3406
+ }),
3407
+ "",
3408
+ "Please retry your previous command.",
3409
+ ].join("\n"),
3410
+ );
3411
+ }
3412
+
3413
+ if (issues.length === 0) {
3414
+ return okReply(
3415
+ [
3416
+ heading("Doctor"),
3417
+ "",
3418
+ "No issues found. acpx configuration looks healthy.",
3419
+ ].join("\n"),
3420
+ );
3421
+ }
3422
+
3423
+ return okReply(
3424
+ [
3425
+ heading("Doctor"),
3426
+ "",
3427
+ `Found ${issues.length} issue${issues.length > 1 ? "s" : ""}:`,
3428
+ "",
3429
+ ...issues.flatMap((issue, i) => [`**${i + 1}.** ${issue.message}`, ""]),
3430
+ "Suggested fixes:",
3431
+ "",
3432
+ ...issues.flatMap((issue, i) => [`**${i + 1}.** ${issue.fix}`, ""]),
3433
+ ].join("\n"),
3434
+ );
3435
+ }
@@ -823,7 +823,7 @@ class ExecutionWatcher {
823
823
  },
824
824
  };
825
825
  });
826
- await this.writeExecutionControl(this.channelKey);
826
+ await this.writeExecutionControl(queued);
827
827
  const nextPlanningStep = nextForcedArtifactId
828
828
  ? (nextPlanningArtifactId(forcedArtifactIds, selectedArtifactId) ?? await this.describeNextPlanningStep(queued))
829
829
  : await this.describeNextPlanningStep(queued);
@@ -906,6 +906,7 @@ class ExecutionWatcher {
906
906
  await writeUtf8(repoStatePaths.workerProgressFile, "");
907
907
 
908
908
  const importedSkills = await loadClawSpecSkillBundle(["apply"]);
909
+
909
910
  ({ runError } = await this.runAcpTurnWithTracking(
910
911
  runningProject,
911
912
  repoStatePaths,
@@ -1023,7 +1024,7 @@ class ExecutionWatcher {
1023
1024
  },
1024
1025
  };
1025
1026
  });
1026
- await this.writeExecutionControl(this.channelKey);
1027
+ await this.writeExecutionControl(queued);
1027
1028
  return true;
1028
1029
  }
1029
1030
 
@@ -1205,6 +1206,17 @@ class ExecutionWatcher {
1205
1206
  await flushProgress();
1206
1207
  return { runError };
1207
1208
  }
1209
+
1210
+ this.logger.debug?.(
1211
+ `[clawspec] calling acpClient.runTurn: ${JSON.stringify({
1212
+ sessionKey,
1213
+ cwd: project.repoPath,
1214
+ agentId: workerAgentId,
1215
+ promptLength: prompt.length,
1216
+ acpxCommand: this.acpClient.command,
1217
+ })}`,
1218
+ );
1219
+
1208
1220
  const runTurnPromise = this.acpClient.runTurn({
1209
1221
  sessionKey,
1210
1222
  cwd: project.repoPath!,
@@ -1637,7 +1649,7 @@ class ExecutionWatcher {
1637
1649
  },
1638
1650
  }));
1639
1651
 
1640
- await this.writeExecutionControl(this.channelKey);
1652
+ await this.writeExecutionControl(recovered);
1641
1653
  await this.notify(
1642
1654
  recovered,
1643
1655
  buildWatcherStatusMessage(
@@ -1698,7 +1710,7 @@ class ExecutionWatcher {
1698
1710
  lastFailure: current.execution?.lastFailure,
1699
1711
  },
1700
1712
  }));
1701
- await this.writeExecutionControl(this.channelKey);
1713
+ await this.writeExecutionControl(updated);
1702
1714
  return updated;
1703
1715
  }
1704
1716
 
@@ -1942,8 +1954,10 @@ class ExecutionWatcher {
1942
1954
  };
1943
1955
  }
1944
1956
 
1945
- private async writeExecutionControl(channelKey: string): Promise<void> {
1946
- const project = await this.stateStore.getActiveProject(channelKey);
1957
+ private async writeExecutionControl(projectOrChannelKey: string | ProjectState): Promise<void> {
1958
+ const project = typeof projectOrChannelKey === "string"
1959
+ ? await this.stateStore.getActiveProject(projectOrChannelKey)
1960
+ : projectOrChannelKey;
1947
1961
  if (!project?.repoPath || !project.changeName || !project.execution) {
1948
1962
  return;
1949
1963
  }
@@ -118,9 +118,10 @@ export async function ensureWorkerIoHelper(repoStatePaths: RepoStatePaths): Prom
118
118
  }
119
119
 
120
120
  export function buildWorkerIoEventCommandPrefix(repoStatePaths: RepoStatePaths): string {
121
- return `node ${quoteForShell(repoStatePaths.workerIoFile)} event`;
122
- }
123
-
124
- function quoteForShell(value: string): string {
125
- return `"${value.replace(/"/g, '\\"')}"`;
121
+ const ioPath = repoStatePaths.workerIoFile;
122
+ // Use single quotes on POSIX, double quotes on Windows for cross-platform compatibility
123
+ if (process.platform === "win32") {
124
+ return `node "${ioPath.replace(/"/g, '""')}" event`;
125
+ }
126
+ return `node '${ioPath.replace(/'/g, `'\\''`)}' event`;
126
127
  }
@@ -8,6 +8,7 @@ test("help text only advertises the clawspec command surface", () => {
8
8
  assert.match(help, /\/clawspec workspace/);
9
9
  assert.match(help, /\/clawspec proposal <change-name> \[description\]/);
10
10
  assert.match(help, /\/clawspec continue/);
11
+ assert.match(help, /\/clawspec doctor/);
11
12
  assert.match(help, /\/clawspec detach/);
12
13
  assert.match(help, /cs-detach/);
13
14
  assert.match(help, /legacy aliases/);
@@ -0,0 +1,142 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtemp, mkdir } from "node:fs/promises";
6
+ import { runDoctorCommand } from "../src/orchestrator/service.ts";
7
+ import { writeJsonFile, readJsonFile } from "../src/utils/fs.ts";
8
+
9
+ test("doctor reports no issues when config file does not exist", async () => {
10
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
11
+ const configPath = path.join(tempRoot, ".acpx", "config.json");
12
+
13
+ const result = await runDoctorCommand(configPath);
14
+
15
+ assert.equal(result.isError, undefined);
16
+ assert.match(result.text ?? "", /No issues found/);
17
+ });
18
+
19
+ test("doctor reports no issues when agents is empty", async () => {
20
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
21
+ const configDir = path.join(tempRoot, ".acpx");
22
+ await mkdir(configDir, { recursive: true });
23
+ const configPath = path.join(configDir, "config.json");
24
+
25
+ await writeJsonFile(configPath, {
26
+ defaultAgent: "codex",
27
+ agents: {},
28
+ });
29
+
30
+ const result = await runDoctorCommand(configPath);
31
+
32
+ assert.equal(result.isError, undefined);
33
+ assert.match(result.text ?? "", /No issues found/);
34
+ });
35
+
36
+ test("doctor detects custom agent entries", async () => {
37
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
38
+ const configDir = path.join(tempRoot, ".acpx");
39
+ await mkdir(configDir, { recursive: true });
40
+ const configPath = path.join(configDir, "config.json");
41
+
42
+ await writeJsonFile(configPath, {
43
+ defaultAgent: "codex",
44
+ agents: {
45
+ codex: { command: "/usr/local/bin/codex" },
46
+ },
47
+ });
48
+
49
+ const result = await runDoctorCommand(configPath);
50
+
51
+ assert.equal(result.isError, undefined);
52
+ assert.match(result.text ?? "", /custom agent entries/);
53
+ assert.match(result.text ?? "", /`codex`/);
54
+ assert.match(result.text ?? "", /doctor fix/);
55
+ });
56
+
57
+ test("doctor reports no issues when config file is empty", async () => {
58
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
59
+ const configDir = path.join(tempRoot, ".acpx");
60
+ await mkdir(configDir, { recursive: true });
61
+ const configPath = path.join(configDir, "config.json");
62
+
63
+ const { writeUtf8 } = await import("../src/utils/fs.ts");
64
+ await writeUtf8(configPath, "");
65
+
66
+ const result = await runDoctorCommand(configPath);
67
+
68
+ assert.equal(result.isError, undefined);
69
+ assert.match(result.text ?? "", /No issues found/);
70
+ });
71
+
72
+ test("doctor detects invalid JSON", async () => {
73
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
74
+ const configDir = path.join(tempRoot, ".acpx");
75
+ await mkdir(configDir, { recursive: true });
76
+ const configPath = path.join(configDir, "config.json");
77
+
78
+ const { writeUtf8 } = await import("../src/utils/fs.ts");
79
+ await writeUtf8(configPath, '{ "agents": { broken }');
80
+
81
+ const result = await runDoctorCommand(configPath);
82
+
83
+ assert.equal(result.isError, undefined);
84
+ assert.match(result.text ?? "", /invalid JSON/);
85
+ });
86
+
87
+ test("doctor fix clears custom agent entries", async () => {
88
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
89
+ const configDir = path.join(tempRoot, ".acpx");
90
+ await mkdir(configDir, { recursive: true });
91
+ const configPath = path.join(configDir, "config.json");
92
+
93
+ await writeJsonFile(configPath, {
94
+ defaultAgent: "codex",
95
+ authPolicy: "skip",
96
+ agents: {
97
+ codex: { command: "/usr/local/bin/codex" },
98
+ },
99
+ });
100
+
101
+ const result = await runDoctorCommand(configPath, "fix");
102
+
103
+ assert.equal(result.isError, undefined);
104
+ assert.match(result.text ?? "", /Doctor Fix Applied/);
105
+
106
+ const updated = await readJsonFile<Record<string, unknown>>(configPath, {});
107
+ assert.deepEqual(updated.agents, {});
108
+ assert.equal(updated.defaultAgent, "codex");
109
+ assert.equal(updated.authPolicy, "skip");
110
+ });
111
+
112
+ test("doctor fix reports nothing to fix when agents is already empty", async () => {
113
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
114
+ const configDir = path.join(tempRoot, ".acpx");
115
+ await mkdir(configDir, { recursive: true });
116
+ const configPath = path.join(configDir, "config.json");
117
+
118
+ await writeJsonFile(configPath, {
119
+ defaultAgent: "codex",
120
+ agents: {},
121
+ });
122
+
123
+ const result = await runDoctorCommand(configPath, "fix");
124
+
125
+ assert.equal(result.isError, undefined);
126
+ assert.match(result.text ?? "", /Nothing to fix/);
127
+ });
128
+
129
+ test("doctor fix reports error when config has invalid JSON", async () => {
130
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-doctor-"));
131
+ const configDir = path.join(tempRoot, ".acpx");
132
+ await mkdir(configDir, { recursive: true });
133
+ const configPath = path.join(configDir, "config.json");
134
+
135
+ const { writeUtf8 } = await import("../src/utils/fs.ts");
136
+ await writeUtf8(configPath, '{ broken json');
137
+
138
+ const result = await runDoctorCommand(configPath, "fix");
139
+
140
+ assert.equal(result.isError, true);
141
+ assert.match(result.text ?? "", /Cannot auto-fix/);
142
+ });
@@ -55,13 +55,17 @@ export function createLogger() {
55
55
  };
56
56
  }
57
57
 
58
- export async function waitFor(check: () => Promise<boolean>, timeoutMs = 4_000): Promise<void> {
58
+ export async function waitFor(
59
+ check: () => Promise<boolean>,
60
+ timeoutMs = 4_000,
61
+ pollIntervalMs = 250,
62
+ ): Promise<void> {
59
63
  const startedAt = Date.now();
60
64
  while (Date.now() - startedAt < timeoutMs) {
61
65
  if (await check()) {
62
66
  return;
63
67
  }
64
- await new Promise((resolve) => setTimeout(resolve, 25));
68
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
65
69
  }
66
70
  throw new Error("Timed out waiting for test condition.");
67
71
  }
@@ -1,15 +1,18 @@
1
- import test from "node:test";
1
+ import test, { type TestContext } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { mkdtemp, mkdir } from "node:fs/promises";
6
- import { pathExists, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
6
+ import { pathExists, readJsonFile, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
7
7
  import { getRepoStatePaths } from "../src/utils/paths.ts";
8
8
  import { RollbackStore } from "../src/rollback/store.ts";
9
9
  import { ProjectStateStore } from "../src/state/store.ts";
10
10
  import { WatcherManager } from "../src/watchers/manager.ts";
11
11
  import { createLogger, waitFor } from "./helpers/harness.ts";
12
12
 
13
+ const TEST_WATCHER_POLL_INTERVAL_MS = 250;
14
+ const TEST_WAIT_TIMEOUT_MS = 15_000;
15
+
13
16
  function createWorkOpenSpec(changeDir: string, tasksPath: string) {
14
17
  return {
15
18
  instructionsApply: async (cwd: string, cn: string) => {
@@ -39,7 +42,25 @@ function createWorkOpenSpec(changeDir: string, tasksPath: string) {
39
42
  } as any;
40
43
  }
41
44
 
42
- test("recovery from orphaned state (armed, no execution field)", async () => {
45
+ function registerManagerCleanup(t: TestContext, manager: WatcherManager): () => Promise<void> {
46
+ let stopped = false;
47
+ const stop = async () => {
48
+ if (stopped) {
49
+ return;
50
+ }
51
+ stopped = true;
52
+ await manager.stop();
53
+ };
54
+ t.after(stop);
55
+ return stop;
56
+ }
57
+
58
+ async function waitForProjectStatus(repoPath: string, status: "done", timeoutMs = TEST_WAIT_TIMEOUT_MS): Promise<void> {
59
+ const stateFile = getRepoStatePaths(repoPath, "archives").stateFile;
60
+ await waitFor(async () => (await readJsonFile<any>(stateFile, null))?.status === status, timeoutMs);
61
+ }
62
+
63
+ test("recovery from orphaned state (armed, no execution field)", async (t) => {
43
64
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-orphan-"));
44
65
  const workspacePath = path.join(tempRoot, "workspace");
45
66
  const repoPath = path.join(workspacePath, "demo-app");
@@ -105,12 +126,13 @@ test("recovery from orphaned state (armed, no execution field)", async () => {
105
126
  logger: createLogger(),
106
127
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
107
128
  acpClient: fakeAcpClient as any,
108
- pollIntervalMs: 25,
129
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
109
130
  });
131
+ const stopManager = registerManagerCleanup(t, manager);
110
132
 
111
133
  await manager.start();
112
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
113
- await manager.stop();
134
+ await waitForProjectStatus(repoPath, "done");
135
+ await stopManager();
114
136
 
115
137
  const project = await stateStore.getActiveProject(channelKey);
116
138
  assert.equal(project?.status, "done");
@@ -120,7 +142,7 @@ test("recovery from orphaned state (armed, no execution field)", async () => {
120
142
  assert.equal(await pathExists(path.join(clawspecDir, "state.json.12345.999999.tmp")), false);
121
143
  });
122
144
 
123
- test("recovery from mid-crash running state", async () => {
145
+ test("recovery from mid-crash running state", async (t) => {
124
146
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-midcrash-"));
125
147
  const workspacePath = path.join(tempRoot, "workspace");
126
148
  const repoPath = path.join(workspacePath, "demo-app");
@@ -178,19 +200,20 @@ test("recovery from mid-crash running state", async () => {
178
200
  logger: createLogger(),
179
201
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
180
202
  acpClient: fakeAcpClient as any,
181
- pollIntervalMs: 25,
203
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
182
204
  });
205
+ const stopManager = registerManagerCleanup(t, manager);
183
206
 
184
207
  await manager.start();
185
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
186
- await manager.stop();
208
+ await waitForProjectStatus(repoPath, "done");
209
+ await stopManager();
187
210
 
188
211
  const project = await stateStore.getActiveProject(channelKey);
189
212
  assert.equal(project?.status, "done");
190
213
  assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
191
214
  });
192
215
 
193
- test("startup recovery adopts a live implementation session instead of spawning a new worker", async () => {
216
+ test("startup recovery adopts a live implementation session instead of spawning a new worker", async (t) => {
194
217
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-adopt-live-"));
195
218
  const workspacePath = path.join(tempRoot, "workspace");
196
219
  const repoPath = path.join(workspacePath, "demo-app");
@@ -274,8 +297,9 @@ test("startup recovery adopts a live implementation session instead of spawning
274
297
  logger: createLogger(),
275
298
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
276
299
  acpClient: fakeAcpClient as any,
277
- pollIntervalMs: 25,
300
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
278
301
  });
302
+ const stopManager = registerManagerCleanup(t, manager);
279
303
 
280
304
  setTimeout(() => {
281
305
  void (async () => {
@@ -308,8 +332,8 @@ test("startup recovery adopts a live implementation session instead of spawning
308
332
  }, 120);
309
333
 
310
334
  await manager.start();
311
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
312
- await manager.stop();
335
+ await waitForProjectStatus(repoPath, "done");
336
+ await stopManager();
313
337
 
314
338
  const project = await stateStore.getActiveProject(channelKey);
315
339
  assert.equal(project?.status, "done");
@@ -328,7 +352,7 @@ test("startup recovery adopts a live implementation session instead of spawning
328
352
  assert.equal(notifierMessages.some((m) => m.includes("Watcher active. Starting codex worker")), false);
329
353
  });
330
354
 
331
- test("recovery from recoverable blocked implementation state", async () => {
355
+ test("recovery from recoverable blocked implementation state", async (t) => {
332
356
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-blocked-"));
333
357
  const workspacePath = path.join(tempRoot, "workspace");
334
358
  const repoPath = path.join(workspacePath, "demo-app");
@@ -421,12 +445,13 @@ test("recovery from recoverable blocked implementation state", async () => {
421
445
  logger: createLogger(),
422
446
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
423
447
  acpClient: fakeAcpClient as any,
424
- pollIntervalMs: 25,
448
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
425
449
  });
450
+ const stopManager = registerManagerCleanup(t, manager);
426
451
 
427
452
  await manager.start();
428
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
429
- await manager.stop();
453
+ await waitForProjectStatus(repoPath, "done");
454
+ await stopManager();
430
455
 
431
456
  const project = await stateStore.getActiveProject(channelKey);
432
457
  assert.equal(project?.status, "done");
@@ -435,7 +460,7 @@ test("recovery from recoverable blocked implementation state", async () => {
435
460
  assert.equal(notifierMessages.some((m) => m.includes("All tasks complete")), true);
436
461
  });
437
462
 
438
- test("rearm never drops execution field (batch mode)", async () => {
463
+ test("rearm never drops execution field (batch mode)", async (t) => {
439
464
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-rearm-safe-"));
440
465
  const workspacePath = path.join(tempRoot, "workspace");
441
466
  const repoPath = path.join(workspacePath, "demo-app");
@@ -497,13 +522,14 @@ test("rearm never drops execution field (batch mode)", async () => {
497
522
  logger: createLogger(),
498
523
  notifier: { send: async () => undefined } as any,
499
524
  acpClient: fakeAcpClient as any,
500
- pollIntervalMs: 25,
525
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
501
526
  });
527
+ const stopManager = registerManagerCleanup(t, manager);
502
528
 
503
529
  await manager.start();
504
530
  await manager.wake(channelKey);
505
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
506
- await manager.stop();
531
+ await waitForProjectStatus(repoPath, "done");
532
+ await stopManager();
507
533
 
508
534
  const project = await stateStore.getActiveProject(channelKey);
509
535
  assert.equal(project?.status, "done");
@@ -511,7 +537,7 @@ test("rearm never drops execution field (batch mode)", async () => {
511
537
  assert.equal(turnCount, 1);
512
538
  });
513
539
 
514
- test("startup recovery does not spawn a worker for visible chat planning", async () => {
540
+ test("startup recovery does not spawn a worker for visible chat planning", async (t) => {
515
541
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-visible-plan-"));
516
542
  const workspacePath = path.join(tempRoot, "workspace");
517
543
  const repoPath = path.join(workspacePath, "demo-app");
@@ -561,12 +587,13 @@ test("startup recovery does not spawn a worker for visible chat planning", async
561
587
  logger: createLogger(),
562
588
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
563
589
  acpClient: fakeAcpClient as any,
564
- pollIntervalMs: 25,
590
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
565
591
  });
592
+ const stopManager = registerManagerCleanup(t, manager);
566
593
 
567
594
  await manager.start();
568
595
  await new Promise((resolve) => setTimeout(resolve, 150));
569
- await manager.stop();
596
+ await stopManager();
570
597
 
571
598
  const project = await stateStore.getActiveProject(channelKey);
572
599
  assert.equal(runCount, 0);
@@ -1,15 +1,18 @@
1
- import test from "node:test";
1
+ import test, { type TestContext } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { mkdtemp, mkdir } from "node:fs/promises";
6
- import { pathExists, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
6
+ import { pathExists, readJsonFile, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
7
7
  import { PlanningJournalStore } from "../src/planning/journal.ts";
8
8
  import { getRepoStatePaths } from "../src/utils/paths.ts";
9
9
  import { ProjectStateStore } from "../src/state/store.ts";
10
10
  import { WatcherManager } from "../src/watchers/manager.ts";
11
11
  import { createLogger, waitFor } from "./helpers/harness.ts";
12
12
 
13
+ const TEST_WATCHER_POLL_INTERVAL_MS = 1_000;
14
+ const TEST_WAIT_TIMEOUT_MS = 35_000;
15
+
13
16
  function createPlanningOpenSpec(changeDir: string, proposalPath: string) {
14
17
  return {
15
18
  status: async (cwd: string, changeName: string) => ({
@@ -71,7 +74,29 @@ function createPlanningOpenSpec(changeDir: string, proposalPath: string) {
71
74
  } as any;
72
75
  }
73
76
 
74
- test("watcher planning flow completes", async () => {
77
+ function registerManagerCleanup(t: TestContext, manager: WatcherManager): () => Promise<void> {
78
+ let stopped = false;
79
+ const stop = async () => {
80
+ if (stopped) {
81
+ return;
82
+ }
83
+ stopped = true;
84
+ await manager.stop();
85
+ };
86
+ t.after(stop);
87
+ return stop;
88
+ }
89
+
90
+ async function waitForProjectStatus(
91
+ repoPath: string,
92
+ status: "ready" | "done" | "blocked" | "planning" | "armed" | "running",
93
+ timeoutMs = TEST_WAIT_TIMEOUT_MS,
94
+ ): Promise<void> {
95
+ const stateFile = getRepoStatePaths(repoPath, "archives").stateFile;
96
+ await waitFor(async () => (await readJsonFile<any>(stateFile, null))?.status === status, timeoutMs);
97
+ }
98
+
99
+ test("watcher planning flow completes", async (t) => {
75
100
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-"));
76
101
  const workspacePath = path.join(tempRoot, "workspace");
77
102
  const repoPath = path.join(workspacePath, "demo-app");
@@ -113,8 +138,9 @@ test("watcher planning flow completes", async () => {
113
138
  logger: createLogger(),
114
139
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
115
140
  acpClient: fakeAcpClient as any,
116
- pollIntervalMs: 25,
141
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
117
142
  });
143
+ const stopManager = registerManagerCleanup(t, manager);
118
144
 
119
145
  const channelKey = "discord:watch-plan:default:main";
120
146
  await stateStore.createProject(channelKey);
@@ -143,8 +169,8 @@ test("watcher planning flow completes", async () => {
143
169
 
144
170
  await manager.start();
145
171
  await manager.wake(channelKey);
146
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "ready");
147
- await manager.stop();
172
+ await waitForProjectStatus(repoPath, "ready");
173
+ await stopManager();
148
174
 
149
175
  const project = await stateStore.getActiveProject(channelKey);
150
176
  assert.equal(project?.status, "ready");
@@ -155,7 +181,7 @@ test("watcher planning flow completes", async () => {
155
181
  assert.equal(notifierMessages.some((m) => m.includes("Planning ready") && m.includes("Next: run `cs-work` to start implementation")), true);
156
182
  });
157
183
 
158
- test("planning fallback normalizes artifact path", async () => {
184
+ test("planning fallback normalizes artifact path", async (t) => {
159
185
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-fallback-"));
160
186
  const workspacePath = path.join(tempRoot, "workspace");
161
187
  const repoPath = path.join(workspacePath, "demo-app");
@@ -215,8 +241,9 @@ test("planning fallback normalizes artifact path", async () => {
215
241
  cancelSession: async () => undefined,
216
242
  closeSession: async () => undefined,
217
243
  } as any,
218
- pollIntervalMs: 25,
244
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
219
245
  });
246
+ const stopManager = registerManagerCleanup(t, manager);
220
247
 
221
248
  const channelKey = "discord:watch-plan-fallback:default:main";
222
249
  await stateStore.createProject(channelKey);
@@ -230,14 +257,14 @@ test("planning fallback normalizes artifact path", async () => {
230
257
 
231
258
  await manager.start();
232
259
  await manager.wake(channelKey);
233
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "ready");
234
- await manager.stop();
260
+ await waitForProjectStatus(repoPath, "ready");
261
+ await stopManager();
235
262
 
236
263
  const project = await stateStore.getActiveProject(channelKey);
237
264
  assert.deepEqual(project?.lastExecution?.changedFiles, ["openspec/changes/watch-plan-fallback/proposal.md"]);
238
265
  });
239
266
 
240
- test("watcher force-refreshes planning artifacts when journal is dirty and OpenSpec reports all artifacts done", async () => {
267
+ test("watcher force-refreshes planning artifacts when journal is dirty and OpenSpec reports all artifacts done", async (t) => {
241
268
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-forced-"));
242
269
  const workspacePath = path.join(tempRoot, "workspace");
243
270
  const repoPath = path.join(workspacePath, "demo-app");
@@ -358,8 +385,9 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
358
385
  logger: createLogger(),
359
386
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
360
387
  acpClient: fakeAcpClient as any,
361
- pollIntervalMs: 25,
388
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
362
389
  });
390
+ const stopManager = registerManagerCleanup(t, manager);
363
391
 
364
392
  const channelKey = "discord:watch-plan-forced:default:main";
365
393
  await stateStore.createProject(channelKey);
@@ -398,8 +426,8 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
398
426
 
399
427
  await manager.start();
400
428
  await manager.wake(channelKey);
401
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "ready");
402
- await manager.stop();
429
+ await waitForProjectStatus(repoPath, "ready");
430
+ await stopManager();
403
431
 
404
432
  const project = await stateStore.getActiveProject(channelKey);
405
433
  assert.deepEqual(runOrder, ["proposal", "specs", "design", "tasks"]);
@@ -412,7 +440,7 @@ test("watcher force-refreshes planning artifacts when journal is dirty and OpenS
412
440
  );
413
441
  });
414
442
 
415
- test("watcher restarts planning worker after ACP runtime exit", async () => {
443
+ test("watcher restarts planning worker after ACP runtime exit", async (t) => {
416
444
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-restart-"));
417
445
  const workspacePath = path.join(tempRoot, "workspace");
418
446
  const repoPath = path.join(workspacePath, "demo-app");
@@ -460,8 +488,9 @@ test("watcher restarts planning worker after ACP runtime exit", async () => {
460
488
  logger: createLogger(),
461
489
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
462
490
  acpClient: fakeAcpClient as any,
463
- pollIntervalMs: 25,
491
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
464
492
  });
493
+ const stopManager = registerManagerCleanup(t, manager);
465
494
 
466
495
  const channelKey = "discord:watch-plan-restart:default:main";
467
496
  await stateStore.createProject(channelKey);
@@ -490,8 +519,8 @@ test("watcher restarts planning worker after ACP runtime exit", async () => {
490
519
 
491
520
  await manager.start();
492
521
  await manager.wake(channelKey);
493
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "ready", 8_000);
494
- await manager.stop();
522
+ await waitForProjectStatus(repoPath, "ready");
523
+ await stopManager();
495
524
 
496
525
  const project = await stateStore.getActiveProject(channelKey);
497
526
  assert.equal(project?.status, "ready");
@@ -3,17 +3,32 @@ import assert from "node:assert/strict";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { mkdtemp, mkdir } from "node:fs/promises";
6
- import { pathExists, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
6
+ import { pathExists, readJsonFile, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
7
7
  import { getRepoStatePaths } from "../src/utils/paths.ts";
8
8
  import { RollbackStore } from "../src/rollback/store.ts";
9
9
  import { ProjectStateStore } from "../src/state/store.ts";
10
10
  import { WatcherManager, describeWorkerStartupTimeout, shouldAbortWorkerStartup } from "../src/watchers/manager.ts";
11
11
  import { createLogger, waitFor } from "./helpers/harness.ts";
12
12
 
13
+ const TEST_WATCHER_POLL_INTERVAL_MS = 1_000;
14
+ const TEST_WAIT_TIMEOUT_MS = 60_000;
15
+
13
16
  function hasMessage(messages: string[], ...parts: string[]): boolean {
14
17
  return messages.some((message) => parts.every((part) => message.includes(part)));
15
18
  }
16
19
 
20
+ async function readProjectState(repoPath: string) {
21
+ return await readJsonFile<any>(getRepoStatePaths(repoPath, "archives").stateFile, null);
22
+ }
23
+
24
+ async function waitForProjectState(
25
+ repoPath: string,
26
+ predicate: (project: any) => boolean,
27
+ timeoutMs = TEST_WAIT_TIMEOUT_MS,
28
+ ): Promise<void> {
29
+ await waitFor(async () => predicate(await readProjectState(repoPath)), timeoutMs);
30
+ }
31
+
17
32
  test("queue owner unavailable is treated as a non-fatal startup state", () => {
18
33
  const status = {
19
34
  summary: "status=dead acpxRecordId=session-1",
@@ -129,7 +144,7 @@ test("watcher work flow completes", async (t) => {
129
144
  logger: createLogger(),
130
145
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
131
146
  acpClient: fakeAcpClient as any,
132
- pollIntervalMs: 25,
147
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
133
148
  });
134
149
  t.after(async () => {
135
150
  await manager.stop();
@@ -166,9 +181,11 @@ test("watcher work flow completes", async (t) => {
166
181
 
167
182
  await manager.start();
168
183
  await manager.wake(channelKey);
169
- await waitFor(async () =>
170
- (await stateStore.getActiveProject(channelKey))?.status === "done"
171
- && hasMessage(notifierMessages, "demo-app-watch-work", "All tasks complete", "/clawspec archive"),
184
+ await waitForProjectState(
185
+ repoPath,
186
+ (project) =>
187
+ project?.status === "done"
188
+ && hasMessage(notifierMessages, "demo-app-watch-work", "All tasks complete", "/clawspec archive"),
172
189
  );
173
190
 
174
191
  const project = await stateStore.getActiveProject(channelKey);
@@ -325,7 +342,7 @@ test("worker progress events keep running state in sync before execution finishe
325
342
  logger: createLogger(),
326
343
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
327
344
  acpClient: fakeAcpClient as any,
328
- pollIntervalMs: 25,
345
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
329
346
  });
330
347
  t.after(async () => {
331
348
  await manager.stop();
@@ -362,20 +379,17 @@ test("worker progress events keep running state in sync before execution finishe
362
379
 
363
380
  await manager.start();
364
381
  await manager.wake(channelKey);
365
- await waitFor(async () => {
366
- const project = await stateStore.getActiveProject(channelKey);
367
- return project?.status === "running"
382
+ await waitForProjectState(repoPath, (project) =>
383
+ project?.status === "running"
368
384
  && project.taskCounts?.complete === 1
369
385
  && project.taskCounts?.remaining === 1
370
386
  && project.currentTask === "1.2 Add multipart parsing"
371
387
  && project.latestSummary?.includes("Start 1.2") === true
372
- && project.execution?.currentTaskId === "1.2";
373
- });
388
+ && project.execution?.currentTaskId === "1.2",
389
+ );
374
390
 
375
391
  releaseFinalStep();
376
- await waitFor(async () =>
377
- (await stateStore.getActiveProject(channelKey))?.status === "done",
378
- );
392
+ await waitForProjectState(repoPath, (project) => project?.status === "done");
379
393
 
380
394
  assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-progress-sync", "1/2", "Done 1.1"), true);
381
395
  assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-progress-sync", "2/2", "Start 1.2"), true);
@@ -483,7 +497,7 @@ test("watcher restarts implementation worker after ACP runtime exit", async (t)
483
497
  logger: createLogger(),
484
498
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
485
499
  acpClient: fakeAcpClient as any,
486
- pollIntervalMs: 25,
500
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
487
501
  });
488
502
  t.after(async () => {
489
503
  await manager.stop();
@@ -520,10 +534,11 @@ test("watcher restarts implementation worker after ACP runtime exit", async (t)
520
534
 
521
535
  await manager.start();
522
536
  await manager.wake(channelKey);
523
- await waitFor(async () =>
524
- (await stateStore.getActiveProject(channelKey))?.status === "done"
525
- && hasMessage(notifierMessages, "demo-app-watch-work-restart", "All tasks complete", "/clawspec archive"),
526
- 8_000,
537
+ await waitForProjectState(
538
+ repoPath,
539
+ (project) =>
540
+ project?.status === "done"
541
+ && hasMessage(notifierMessages, "demo-app-watch-work-restart", "All tasks complete", "/clawspec archive"),
527
542
  );
528
543
 
529
544
  const project = await stateStore.getActiveProject(channelKey);
@@ -651,7 +666,7 @@ test("watcher restarts a dead ACP session after progress stalls", async (t) => {
651
666
  logger: createLogger(),
652
667
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
653
668
  acpClient: fakeAcpClient as any,
654
- pollIntervalMs: 25,
669
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
655
670
  });
656
671
  t.after(async () => {
657
672
  await manager.stop();
@@ -688,9 +703,11 @@ test("watcher restarts a dead ACP session after progress stalls", async (t) => {
688
703
 
689
704
  await manager.start();
690
705
  await manager.wake(channelKey);
691
- await waitFor(async () =>
692
- (await stateStore.getActiveProject(channelKey))?.status === "done",
693
- 8_000,
706
+ await waitForProjectState(
707
+ repoPath,
708
+ (project) =>
709
+ project?.status === "done"
710
+ && hasMessage(notifierMessages, "demo-app-watch-work-dead-session", "All tasks complete", "/clawspec archive"),
694
711
  );
695
712
 
696
713
  const project = await stateStore.getActiveProject(channelKey);
@@ -798,7 +815,7 @@ test("watcher restarts a dead ACP session that dies before first progress", asyn
798
815
  logger: createLogger(),
799
816
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
800
817
  acpClient: fakeAcpClient as any,
801
- pollIntervalMs: 25,
818
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
802
819
  });
803
820
  t.after(async () => {
804
821
  await manager.stop();
@@ -835,9 +852,11 @@ test("watcher restarts a dead ACP session that dies before first progress", asyn
835
852
 
836
853
  await manager.start();
837
854
  await manager.wake(channelKey);
838
- await waitFor(async () =>
839
- (await stateStore.getActiveProject(channelKey))?.status === "done",
840
- 8_000,
855
+ await waitForProjectState(
856
+ repoPath,
857
+ (project) =>
858
+ project?.status === "done"
859
+ && hasMessage(notifierMessages, "demo-app-watch-work-dead-startup", "All tasks complete", "/clawspec archive"),
841
860
  );
842
861
 
843
862
  const project = await stateStore.getActiveProject(channelKey);
@@ -958,7 +977,7 @@ test("status-only ACP heartbeats do not keep a dead session alive", async (t) =>
958
977
  logger: createLogger(),
959
978
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
960
979
  acpClient: fakeAcpClient as any,
961
- pollIntervalMs: 25,
980
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
962
981
  });
963
982
  t.after(async () => {
964
983
  await manager.stop();
@@ -995,9 +1014,11 @@ test("status-only ACP heartbeats do not keep a dead session alive", async (t) =>
995
1014
 
996
1015
  await manager.start();
997
1016
  await manager.wake(channelKey);
998
- await waitFor(async () =>
999
- (await stateStore.getActiveProject(channelKey))?.status === "done",
1000
- 8_000,
1017
+ await waitForProjectState(
1018
+ repoPath,
1019
+ (project) =>
1020
+ project?.status === "done"
1021
+ && hasMessage(notifierMessages, "demo-app-watch-work-status-heartbeats", "All tasks complete"),
1001
1022
  );
1002
1023
 
1003
1024
  const project = await stateStore.getActiveProject(channelKey);
@@ -1124,7 +1145,7 @@ test("dead ACP session that ignores abort is restarted without hanging the watch
1124
1145
  logger: createLogger(),
1125
1146
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1126
1147
  acpClient: fakeAcpClient as any,
1127
- pollIntervalMs: 25,
1148
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
1128
1149
  });
1129
1150
  t.after(async () => {
1130
1151
  if (heartbeatTimer) {
@@ -1165,9 +1186,11 @@ test("dead ACP session that ignores abort is restarted without hanging the watch
1165
1186
 
1166
1187
  await manager.start();
1167
1188
  await manager.wake(channelKey);
1168
- await waitFor(async () =>
1169
- (await stateStore.getActiveProject(channelKey))?.status === "done",
1170
- 8_000,
1189
+ await waitForProjectState(
1190
+ repoPath,
1191
+ (project) =>
1192
+ project?.status === "done"
1193
+ && hasMessage(notifierMessages, "demo-app-watch-work-hung-dead-session", "All tasks complete"),
1171
1194
  );
1172
1195
 
1173
1196
  const project = await stateStore.getActiveProject(channelKey);
@@ -1205,7 +1228,7 @@ test("manager stop closes active worker sessions and rearms project recovery sta
1205
1228
  closedSessions.push({ sessionKey, reason });
1206
1229
  },
1207
1230
  } as any,
1208
- pollIntervalMs: 25,
1231
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
1209
1232
  });
1210
1233
 
1211
1234
  const channelKey = "discord:watch-stop:default:main";
@@ -1309,7 +1332,7 @@ test("watcher stops retrying after 10 ACP restart attempts", async (t) => {
1309
1332
  logger: createLogger(),
1310
1333
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1311
1334
  acpClient: fakeAcpClient as any,
1312
- pollIntervalMs: 25,
1335
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
1313
1336
  });
1314
1337
  t.after(async () => {
1315
1338
  await manager.stop();
@@ -1347,9 +1370,11 @@ test("watcher stops retrying after 10 ACP restart attempts", async (t) => {
1347
1370
  }));
1348
1371
 
1349
1372
  await manager.wake(channelKey);
1350
- await waitFor(async () =>
1351
- (await stateStore.getActiveProject(channelKey))?.status === "blocked",
1352
- 8_000,
1373
+ await waitForProjectState(
1374
+ repoPath,
1375
+ (project) =>
1376
+ project?.status === "blocked"
1377
+ && hasMessage(notifierMessages, "demo-app-watch-work-restart-cap", "Blocked after 10 ACP restart attempts"),
1353
1378
  );
1354
1379
 
1355
1380
  const project = await stateStore.getActiveProject(channelKey);
@@ -1415,7 +1440,7 @@ test("watcher blocked message includes ACPX setup guidance when backend stays un
1415
1440
  logger: createLogger(),
1416
1441
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1417
1442
  acpClient: fakeAcpClient as any,
1418
- pollIntervalMs: 25,
1443
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
1419
1444
  });
1420
1445
  t.after(async () => {
1421
1446
  await manager.stop();
@@ -1453,9 +1478,11 @@ test("watcher blocked message includes ACPX setup guidance when backend stays un
1453
1478
  }));
1454
1479
 
1455
1480
  await manager.wake(channelKey);
1456
- await waitFor(async () =>
1457
- (await stateStore.getActiveProject(channelKey))?.status === "blocked",
1458
- 8_000,
1481
+ await waitForProjectState(
1482
+ repoPath,
1483
+ (project) =>
1484
+ project?.status === "blocked"
1485
+ && hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "Blocked: ACPX backend unavailable"),
1459
1486
  );
1460
1487
 
1461
1488
  const project = await stateStore.getActiveProject(channelKey);
@@ -1545,7 +1572,7 @@ test("watcher retries when ACP runtime backend is temporarily unavailable", asyn
1545
1572
  logger: createLogger(),
1546
1573
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1547
1574
  acpClient: fakeAcpClient as any,
1548
- pollIntervalMs: 25,
1575
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
1549
1576
  });
1550
1577
  t.after(async () => {
1551
1578
  await manager.stop();
@@ -1581,9 +1608,11 @@ test("watcher retries when ACP runtime backend is temporarily unavailable", asyn
1581
1608
  }));
1582
1609
 
1583
1610
  await manager.wake(channelKey);
1584
- await waitFor(async () =>
1585
- (await stateStore.getActiveProject(channelKey))?.status === "done",
1586
- 8_000,
1611
+ await waitForProjectState(
1612
+ repoPath,
1613
+ (project) =>
1614
+ project?.status === "done"
1615
+ && hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "All tasks complete"),
1587
1616
  );
1588
1617
 
1589
1618
  const project = await stateStore.getActiveProject(channelKey);
@@ -1689,7 +1718,7 @@ test("watcher finalizes when terminal result exists before ACP turn exits", asyn
1689
1718
  logger: createLogger(),
1690
1719
  notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
1691
1720
  acpClient: fakeAcpClient as any,
1692
- pollIntervalMs: 25,
1721
+ pollIntervalMs: TEST_WATCHER_POLL_INTERVAL_MS,
1693
1722
  });
1694
1723
  t.after(async () => {
1695
1724
  await manager.stop();
@@ -1726,10 +1755,11 @@ test("watcher finalizes when terminal result exists before ACP turn exits", asyn
1726
1755
 
1727
1756
  await manager.start();
1728
1757
  await manager.wake(channelKey);
1729
- await waitFor(async () =>
1730
- (await stateStore.getActiveProject(channelKey))?.status === "done"
1731
- && hasMessage(notifierMessages, "demo-app-watch-work-terminal", "All tasks complete"),
1732
- 8_000,
1758
+ await waitForProjectState(
1759
+ repoPath,
1760
+ (project) =>
1761
+ project?.status === "done"
1762
+ && hasMessage(notifierMessages, "demo-app-watch-work-terminal", "All tasks complete"),
1733
1763
  );
1734
1764
 
1735
1765
  const project = await stateStore.getActiveProject(channelKey);
@@ -93,5 +93,5 @@ test("implementation prompt instructs the worker to use the helper", async () =>
93
93
  });
94
94
 
95
95
  assert.match(prompt, /Use the worker IO helper instead of editing .*worker-progress\.jsonl directly\./);
96
- assert.match(prompt, /worker_io\.mjs" event --kind <status\|task_start\|task_done\|blocked>/);
96
+ assert.match(prompt, /worker_io\.mjs['"] event --kind <status\|task_start\|task_done\|blocked>/);
97
97
  });