clawspec 1.0.15 → 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.15",
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
  }
@@ -1,3 +1,4 @@
1
+
1
2
  export type ClawSpecKeywordKind =
2
3
  | "plan"
3
4
  | "work"
@@ -59,11 +60,26 @@ export function extractEmbeddedClawSpecKeyword(text: string): ClawSpecKeywordInt
59
60
  return direct;
60
61
  }
61
62
 
62
- for (const line of text.split(/\r?\n/)) {
63
- const parsed = parseClawSpecKeyword(line.trim());
63
+ const lines = text.split(/\r?\n/);
64
+ for (const line of lines) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed) continue;
67
+
68
+
69
+ // Try parsing the whole line first
70
+ const parsed = parseClawSpecKeyword(trimmed);
64
71
  if (parsed) {
65
72
  return parsed;
66
73
  }
74
+
75
+ // Try each word in the line
76
+ const words = trimmed.split(/\s+/);
77
+ for (const word of words) {
78
+ const wordParsed = parseClawSpecKeyword(word);
79
+ if (wordParsed) {
80
+ return wordParsed;
81
+ }
82
+ }
67
83
  }
68
84
 
69
85
  return null;
@@ -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,
@@ -6,7 +7,6 @@ import type {
6
7
  PluginCommandResult,
7
8
  PluginLogger,
8
9
  } from "openclaw/plugin-sdk";
9
- import { debugLog } from "../utils/debug-log.ts";
10
10
  import { ProjectMemoryStore } from "../memory/store.ts";
11
11
  import { OpenSpecClient, OpenSpecCommandError } from "../openspec/cli.ts";
12
12
  import { parseTasksFile } from "../openspec/tasks.ts";
@@ -237,6 +237,8 @@ export class ClawSpecService {
237
237
  return await this.archiveProject(channelKey);
238
238
  case "cancel":
239
239
  return await this.cancelProject(channelKey);
240
+ case "doctor":
241
+ return await this.runDoctor(rest);
240
242
  default:
241
243
  return errorReply(`Unknown subcommand \`${subcommand}\`.\n\n${buildHelpText()}`);
242
244
  }
@@ -250,9 +252,7 @@ export class ClawSpecService {
250
252
  event: PromptBuildEvent,
251
253
  ctx: PromptBuildContext,
252
254
  ): Promise<{ prependContext?: string; prependSystemContext?: string } | void> {
253
- debugLog(`[handleBeforePromptBuild] prompt="${event.prompt}", trigger=${ctx.trigger}`);
254
255
  if (!shouldHandleUserVisiblePrompt(ctx.trigger)) {
255
- debugLog(`[handleBeforePromptBuild] Skipped: non-user trigger`);
256
256
  this.logger.debug?.(
257
257
  `[clawspec] skipping prompt injection for non-user trigger "${ctx.trigger ?? "unknown"}".`,
258
258
  );
@@ -260,7 +260,6 @@ export class ClawSpecService {
260
260
  }
261
261
 
262
262
  const keyword = extractEmbeddedClawSpecKeyword(event.prompt);
263
- debugLog(`[handleBeforePromptBuild] keyword=${keyword?.command}`);
264
263
  if (keyword) {
265
264
  this.logger.debug?.(
266
265
  `[clawspec] detected control keyword "${keyword.command}" for prompt build (session=${ctx.sessionKey ?? "unknown"}).`,
@@ -320,21 +319,16 @@ export class ClawSpecService {
320
319
 
321
320
  switch (keyword.kind) {
322
321
  case "plan": {
323
- debugLog(`[cs-plan] Triggered for project: ${match?.project.changeName}`);
324
322
  if (!match?.project.repoPath || !match.project.changeName) {
325
- debugLog(`[cs-plan] Missing repoPath or changeName`);
326
323
  return this.buildPluginReplyInjection(
327
324
  event.prompt,
328
325
  "Select a project and create a change first with `/clawspec use <project-name>` and `/clawspec proposal <change-name> [description]`.",
329
326
  );
330
327
  }
331
- debugLog(`[cs-plan] Calling startVisiblePlanningSync`);
332
328
  const planningSync = await this.startVisiblePlanningSync(match.channelKey, match.project, ctx, event.prompt, "apply");
333
329
  if ("prependContext" in planningSync || "prependSystemContext" in planningSync) {
334
- debugLog(`[cs-plan] Returning prompt injection`);
335
330
  return planningSync;
336
331
  }
337
- debugLog(`[cs-plan] Returning plugin reply`);
338
332
  return this.buildPluginReplyInjection(event.prompt, planningSync.text ?? "");
339
333
  }
340
334
  case "work":
@@ -684,13 +678,6 @@ export class ClawSpecService {
684
678
  repoStatePaths.planningJournalSnapshotFile,
685
679
  project.planningJournal?.lastSyncedAt,
686
680
  );
687
- const snapshot = await journalStore.readSnapshot(repoStatePaths.planningJournalSnapshotFile);
688
- const digest = await journalStore.digest(project.changeName);
689
- this.logger.info(`[clawspec] cs-work check for ${project.changeName}:`);
690
- this.logger.info(` - hasUnsyncedChanges: ${hasUnsyncedChanges}`);
691
- this.logger.info(` - Snapshot: entryCount=${snapshot?.entryCount}, lastEntryAt=${snapshot?.lastEntryAt}, hash=${snapshot?.contentHash?.slice(0, 8)}`);
692
- this.logger.info(` - Current digest: entryCount=${digest.entryCount}, lastEntryAt=${digest.lastEntryAt}, hash=${digest.contentHash.slice(0, 8)}`);
693
- this.logger.info(` - fallbackLastSyncedAt: ${project.planningJournal?.lastSyncedAt}`);
694
681
  if (!hasUnsyncedChanges) {
695
682
  const isDetached = !isProjectContextAttached(project);
696
683
  if (isDetached) {
@@ -767,19 +754,15 @@ export class ClawSpecService {
767
754
  userPrompt: string,
768
755
  mode: ExecutionMode,
769
756
  ): Promise<{ prependContext?: string; prependSystemContext?: string } | PluginCommandResult> {
770
- debugLog(`[startVisiblePlanningSync] Called for ${project.changeName}`);
771
- this.logger.info(`[clawspec] startVisiblePlanningSync called for ${project.changeName}`);
772
757
  void project;
773
758
  void mode;
774
759
 
775
760
  const prepared = await this.preparePlanningSync(channelKey);
776
761
  if ("result" in prepared) {
777
- debugLog(`[startVisiblePlanningSync] preparePlanningSync failed`);
778
762
  this.logger.warn(`[clawspec] preparePlanningSync returned error: ${prepared.result.text}`);
779
763
  return prepared.result;
780
764
  }
781
765
 
782
- debugLog(`[startVisiblePlanningSync] Updating state to planning`);
783
766
  const startedAt = new Date().toISOString();
784
767
  const runningProject = await this.stateStore.updateProject(channelKey, (current) => ({
785
768
  ...current,
@@ -793,8 +776,6 @@ export class ClawSpecService {
793
776
  execution: undefined,
794
777
  lastExecutionAt: startedAt,
795
778
  }));
796
- debugLog(`[startVisiblePlanningSync] State updated: status=${runningProject.status}, phase=${runningProject.phase}, sessionKey=${runningProject.boundSessionKey}`);
797
- this.logger.info(`[clawspec] Project state updated: status=planning, phase=planning_sync, boundSessionKey=${runningProject.boundSessionKey}`);
798
779
 
799
780
  return await this.buildPlanningSyncInjection(runningProject, userPrompt, prepared.instructionResults);
800
781
  }
@@ -912,7 +893,6 @@ export class ClawSpecService {
912
893
  }
913
894
 
914
895
  async handleAgentEnd(event: AgentEndEvent, ctx: PromptBuildContext): Promise<void> {
915
- debugLog(`[agent_end] Called, sessionKey=${ctx.sessionKey}`);
916
896
  const runningProject = await this.findRunningProjectBySessionKey(ctx.sessionKey);
917
897
  if (runningProject?.repoPath && runningProject.changeName) {
918
898
  const project = runningProject;
@@ -977,10 +957,7 @@ export class ClawSpecService {
977
957
 
978
958
  const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey)
979
959
  ?? await this.findPlanningProjectByContext(ctx);
980
- debugLog(`[agent_end] Planning project: ${planningProject?.changeName}, status=${planningProject?.status}, phase=${planningProject?.phase}`);
981
960
  if (planningProject) {
982
- debugLog(`[agent_end] Calling finalizePlanningTurn`);
983
- this.logger.info(`[clawspec] agent_end: found planning project ${planningProject.changeName}, calling finalizePlanningTurn`);
984
961
  await this.finalizePlanningTurn(planningProject, event);
985
962
  return;
986
963
  }
@@ -1930,6 +1907,11 @@ export class ClawSpecService {
1930
1907
  );
1931
1908
  }
1932
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
+
1933
1915
  private async captureIncomingMessage(channelKey: string, project: ProjectState, text: string): Promise<ProjectState> {
1934
1916
  const trimmed = sanitizePlanningMessageText(text).trim();
1935
1917
  if (!trimmed || trimmed.startsWith("/clawspec") || !project.repoPath || !project.changeName) {
@@ -2428,10 +2410,7 @@ export class ClawSpecService {
2428
2410
  }
2429
2411
 
2430
2412
  private async finalizePlanningTurn(project: ProjectState, event: AgentEndEvent): Promise<void> {
2431
- debugLog(`[finalizePlanningTurn] Called for ${project.changeName}, success=${event.success}`);
2432
- this.logger.info(`[clawspec] finalizePlanningTurn called for ${project.changeName}, success=${event.success}`);
2433
2413
  if (!project.repoPath || !project.changeName) {
2434
- debugLog(`[finalizePlanningTurn] Skipped: missing repoPath or changeName`);
2435
2414
  this.logger.warn(`[clawspec] finalizePlanningTurn skipped: missing repoPath or changeName`);
2436
2415
  return;
2437
2416
  }
@@ -2491,11 +2470,7 @@ export class ClawSpecService {
2491
2470
  }
2492
2471
  }
2493
2472
 
2494
- debugLog(`[finalizePlanningTurn] Writing snapshot`);
2495
2473
  const snapshot = await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, project.changeName, timestamp);
2496
- debugLog(`[finalizePlanningTurn] Snapshot written: entryCount=${snapshot.entryCount}, lastEntryAt=${snapshot.lastEntryAt}`);
2497
- this.logger.info(`[clawspec] Planning snapshot written for ${project.changeName}: entryCount=${snapshot.entryCount}, lastEntryAt=${snapshot.lastEntryAt}`);
2498
- this.logger.info(`[clawspec] Updating project state: status=${status}, phase=${phase}, dirty=${journalDirty}`);
2499
2474
  await this.writeLatestSummary(repoStatePaths, latestSummary);
2500
2475
 
2501
2476
  const finalized = await this.stateStore.updateProject(project.channelKey, (current) => ({
@@ -3323,3 +3298,138 @@ function parseOptionalNumber(value: string): number | undefined {
3323
3298
  function isRecord(value: unknown): value is Record<string, unknown> {
3324
3299
  return typeof value === "object" && value !== null;
3325
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
  }
@@ -50,6 +50,7 @@ export class ClawSpecNotifier {
50
50
  });
51
51
  return;
52
52
  default:
53
+ // Webchat and other channels: log only (no direct API available)
53
54
  this.logger.info(`[clawspec] watcher update (${route.channel} ${route.channelId}): ${text}`);
54
55
  }
55
56
  } catch (error) {
@@ -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/);