@gajae-code/coding-agent 0.5.2 → 0.5.4

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 (99) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/config/model-profiles.d.ts +10 -0
  4. package/dist/types/dap/client.d.ts +2 -1
  5. package/dist/types/edit/read-file.d.ts +6 -0
  6. package/dist/types/eval/js/context-manager.d.ts +3 -0
  7. package/dist/types/eval/js/executor.d.ts +1 -0
  8. package/dist/types/exec/bash-executor.d.ts +2 -0
  9. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  10. package/dist/types/lsp/types.d.ts +2 -0
  11. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  12. package/dist/types/modes/components/model-selector.d.ts +2 -0
  13. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  14. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  16. package/dist/types/modes/interactive-mode.d.ts +1 -0
  17. package/dist/types/modes/types.d.ts +1 -0
  18. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  19. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  20. package/dist/types/runtime-mcp/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +29 -1
  22. package/dist/types/session/artifacts.d.ts +4 -1
  23. package/dist/types/session/streaming-output.d.ts +12 -0
  24. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  25. package/dist/types/tools/bash.d.ts +1 -0
  26. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  27. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  28. package/dist/types/web/search/providers/codex.d.ts +4 -4
  29. package/package.json +7 -7
  30. package/src/async/job-manager.ts +181 -43
  31. package/src/config/file-lock.ts +9 -1
  32. package/src/config/model-profile-activation.ts +71 -3
  33. package/src/config/model-profiles.ts +39 -14
  34. package/src/dap/client.ts +105 -64
  35. package/src/dap/session.ts +44 -7
  36. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -2
  37. package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -2
  38. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -2
  39. package/src/edit/read-file.ts +19 -1
  40. package/src/eval/js/context-manager.ts +228 -65
  41. package/src/eval/js/executor.ts +2 -0
  42. package/src/eval/js/index.ts +1 -0
  43. package/src/eval/js/worker-core.ts +10 -6
  44. package/src/eval/py/executor.ts +68 -19
  45. package/src/eval/py/kernel.ts +46 -22
  46. package/src/eval/py/runner.py +68 -14
  47. package/src/exec/bash-executor.ts +49 -13
  48. package/src/gjc-runtime/deep-interview-runtime.ts +14 -13
  49. package/src/gjc-runtime/ralplan-runtime.ts +10 -0
  50. package/src/gjc-runtime/state-runtime.ts +73 -0
  51. package/src/gjc-runtime/tmux-gc.ts +86 -37
  52. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  53. package/src/gjc-runtime/ultragoal-runtime.ts +8 -4
  54. package/src/internal-urls/artifact-protocol.ts +10 -1
  55. package/src/internal-urls/docs-index.generated.ts +2 -2
  56. package/src/lsp/client.ts +64 -26
  57. package/src/lsp/index.ts +2 -1
  58. package/src/lsp/lspmux.ts +33 -9
  59. package/src/lsp/types.ts +2 -0
  60. package/src/modes/bridge/bridge-mode.ts +21 -0
  61. package/src/modes/components/assistant-message.ts +10 -2
  62. package/src/modes/components/bash-execution.ts +5 -1
  63. package/src/modes/components/eval-execution.ts +5 -1
  64. package/src/modes/components/model-selector.ts +34 -2
  65. package/src/modes/components/oauth-selector.ts +5 -0
  66. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  67. package/src/modes/components/skill-message.ts +24 -16
  68. package/src/modes/components/tool-execution.ts +6 -0
  69. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  70. package/src/modes/controllers/input-controller.ts +19 -0
  71. package/src/modes/controllers/selector-controller.ts +6 -1
  72. package/src/modes/interactive-mode.ts +13 -0
  73. package/src/modes/types.ts +1 -0
  74. package/src/modes/utils/ui-helpers.ts +5 -2
  75. package/src/prompts/agents/executor.md +1 -1
  76. package/src/runtime/process-lifecycle.ts +400 -0
  77. package/src/runtime-mcp/manager.ts +164 -50
  78. package/src/runtime-mcp/transports/http.ts +12 -11
  79. package/src/runtime-mcp/transports/stdio.ts +64 -38
  80. package/src/runtime-mcp/types.ts +3 -0
  81. package/src/sdk.ts +27 -0
  82. package/src/session/agent-session.ts +271 -25
  83. package/src/session/artifacts.ts +17 -2
  84. package/src/session/blob-store.ts +36 -2
  85. package/src/session/session-manager.ts +29 -13
  86. package/src/session/streaming-output.ts +95 -3
  87. package/src/setup/model-onboarding-guidance.ts +10 -3
  88. package/src/skill-state/active-state.ts +79 -7
  89. package/src/slash-commands/builtin-registry.ts +30 -3
  90. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  91. package/src/tools/archive-reader.ts +10 -1
  92. package/src/tools/bash.ts +11 -4
  93. package/src/tools/browser/registry.ts +17 -1
  94. package/src/tools/browser/tab-supervisor.ts +22 -0
  95. package/src/tools/browser.ts +38 -4
  96. package/src/tools/cron.ts +2 -6
  97. package/src/tools/read.ts +11 -12
  98. package/src/tools/sqlite-reader.ts +19 -5
  99. package/src/web/search/providers/codex.ts +6 -5
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Speaks NDJSON with `runner.py` over stdin/stdout. One subprocess per kernel
5
5
  * instance; sessions reuse a single subprocess across executions. Cancellation
6
- * is `kill("SIGINT")` which raises a real `KeyboardInterrupt` inside user
7
- * code. Shutdown writes `{"type":"exit"}` and escalates to SIGTERM/SIGKILL on
6
+ * sends SIGINT to the runner process group, which raises a real
7
+ * `KeyboardInterrupt` inside user code and any foreground magic subprocess.
8
+ * Shutdown writes `{"type":"exit"}` and escalates to SIGTERM/SIGKILL on
8
9
  * timeout.
9
10
  */
10
11
  import * as fs from "node:fs";
@@ -173,6 +174,7 @@ interface PendingExecution {
173
174
  kernelKilled: boolean;
174
175
  settled: boolean;
175
176
  escalationTimer?: NodeJS.Timeout;
177
+ finalize?: () => void;
176
178
  }
177
179
 
178
180
  export class PythonKernel {
@@ -227,6 +229,9 @@ export class PythonKernel {
227
229
  stdout: "pipe",
228
230
  stderr: "pipe",
229
231
  windowsHide: true,
232
+ // Run as its own session/process-group leader so SIGINT/SIGTERM can be
233
+ // delivered to the whole runner tree (incl. foreground magic subprocesses).
234
+ detached: true,
230
235
  });
231
236
  kernel.#proc = proc;
232
237
  kernel.#stdin = proc.stdin;
@@ -377,10 +382,30 @@ export class PythonKernel {
377
382
 
378
383
  async interrupt(): Promise<void> {
379
384
  if (!this.#proc || this.#disposed) return;
385
+ this.#signalProcessGroup("SIGINT");
386
+ }
387
+
388
+ #signalProcessGroup(signal: NodeJS.Signals): void {
389
+ const proc = this.#proc;
390
+ if (!proc) return;
391
+ try {
392
+ if (process.platform !== "win32") {
393
+ process.kill(-proc.pid, signal);
394
+ return;
395
+ }
396
+ } catch (err) {
397
+ logger.warn("Failed to signal python runner process group", {
398
+ signal,
399
+ error: err instanceof Error ? err.message : String(err),
400
+ });
401
+ }
380
402
  try {
381
- this.#proc.kill("SIGINT");
403
+ proc.kill(signal);
382
404
  } catch (err) {
383
- logger.warn("Failed to interrupt python runner", { error: err instanceof Error ? err.message : String(err) });
405
+ logger.warn("Failed to signal python runner", {
406
+ signal,
407
+ error: err instanceof Error ? err.message : String(err),
408
+ });
384
409
  }
385
410
  }
386
411
 
@@ -412,24 +437,16 @@ export class PythonKernel {
412
437
 
413
438
  const exited = this.#waitForExitWithTimeout(timeoutMs);
414
439
  let result = await exited;
415
- if (!result) {
416
- try {
417
- proc.kill("SIGTERM");
418
- } catch {
419
- /* ignore */
420
- }
440
+ if (result === null) {
441
+ this.#signalProcessGroup("SIGTERM");
421
442
  result = await this.#waitForExitWithTimeout(timeoutMs);
422
443
  }
423
- if (!result) {
424
- try {
425
- proc.kill("SIGKILL");
426
- } catch {
427
- /* ignore */
428
- }
444
+ if (result === null) {
445
+ this.#signalProcessGroup("SIGKILL");
429
446
  result = await this.#waitForExitWithTimeout(timeoutMs);
430
447
  }
431
448
 
432
- const confirmed = !!result;
449
+ const confirmed = result !== null;
433
450
  this.#shutdownConfirmed = confirmed;
434
451
  this.#disposed = true;
435
452
  return { confirmed };
@@ -441,17 +458,21 @@ export class PythonKernel {
441
458
  this.#pending.clear();
442
459
  const kernelKilledDefault = options?.kernelKilled ?? false;
443
460
  for (const entry of pending) {
461
+ entry.cancelled = true;
462
+ entry.status = "error";
463
+ entry.kernelKilled = entry.kernelKilled || kernelKilledDefault;
464
+ entry.finalize?.();
444
465
  if (entry.settled) continue;
445
466
  entry.settled = true;
446
467
  void entry.options?.onChunk?.(`[kernel] ${reason}\n`);
447
468
  entry.resolve({
448
- status: "error",
449
- cancelled: true,
469
+ status: entry.status,
470
+ cancelled: entry.cancelled,
450
471
  timedOut: entry.timedOut,
451
472
  stdinRequested: entry.stdinRequested,
452
473
  executionCount: entry.executionCount,
453
474
  error: entry.error,
454
- kernelKilled: entry.kernelKilled || kernelKilledDefault,
475
+ kernelKilled: entry.kernelKilled,
455
476
  });
456
477
  }
457
478
  }
@@ -655,11 +676,14 @@ export class PythonKernel {
655
676
  #waitForExitWithTimeout(timeoutMs: number): Promise<number | null> {
656
677
  if (!this.#exitedPromise) return Promise.resolve(0);
657
678
  const exitedPromise = this.#exitedPromise;
658
- const timeout = new Promise<null>(resolve => {
679
+ return new Promise(resolve => {
659
680
  const timer = setTimeout(() => resolve(null), Math.max(0, timeoutMs));
660
681
  timer.unref?.();
682
+ exitedPromise.then(code => {
683
+ clearTimeout(timer);
684
+ resolve(code);
685
+ });
661
686
  });
662
- return Promise.race([exitedPromise.then(code => code as number | null), timeout]);
663
687
  }
664
688
  }
665
689
 
@@ -290,6 +290,44 @@ def _emit_status(op: str, **data: Any) -> None:
290
290
  _emit({"type": "display", "id": rid, "bundle": bundle})
291
291
 
292
292
 
293
+ def _terminate_process_group(proc: subprocess.Popen[Any]) -> None:
294
+ if proc.poll() is not None:
295
+ return
296
+ try:
297
+ if os.name == "posix":
298
+ os.killpg(proc.pid, signal.SIGTERM)
299
+ else:
300
+ proc.terminate()
301
+ except ProcessLookupError:
302
+ return
303
+ except Exception:
304
+ try:
305
+ proc.terminate()
306
+ except Exception:
307
+ pass
308
+ try:
309
+ proc.wait(timeout=1)
310
+ return
311
+ except subprocess.TimeoutExpired:
312
+ pass
313
+ try:
314
+ if os.name == "posix":
315
+ os.killpg(proc.pid, signal.SIGKILL)
316
+ else:
317
+ proc.kill()
318
+ except ProcessLookupError:
319
+ return
320
+ except Exception:
321
+ try:
322
+ proc.kill()
323
+ except Exception:
324
+ pass
325
+ try:
326
+ proc.wait(timeout=1)
327
+ except Exception:
328
+ pass
329
+
330
+
293
331
  @line_magic("pip")
294
332
  def _magic_pip(args: str) -> None:
295
333
  argv = shlex.split(args) if args else ["--help"]
@@ -300,18 +338,26 @@ def _magic_pip(args: str) -> None:
300
338
  stderr=subprocess.STDOUT,
301
339
  text=True,
302
340
  bufsize=1,
341
+ start_new_session=(os.name == "posix"),
303
342
  )
304
343
  installed_packages: list[str] = []
305
- assert proc.stdout is not None
306
- for raw_line in proc.stdout:
307
- sys.stdout.write(raw_line)
308
- m = re.search(r"Successfully installed\s+(.+)$", raw_line)
309
- if m:
310
- for token in m.group(1).split():
311
- # Token is name-version; drop the version suffix.
312
- pkg = token.rsplit("-", 1)[0]
313
- installed_packages.append(pkg.replace("_", "-"))
314
- proc.wait()
344
+ try:
345
+ assert proc.stdout is not None
346
+ try:
347
+ for raw_line in proc.stdout:
348
+ sys.stdout.write(raw_line)
349
+ m = re.search(r"Successfully installed\s+(.+)$", raw_line)
350
+ if m:
351
+ for token in m.group(1).split():
352
+ # Token is name-version; drop the version suffix.
353
+ pkg = token.rsplit("-", 1)[0]
354
+ installed_packages.append(pkg.replace("_", "-"))
355
+ finally:
356
+ proc.stdout.close()
357
+ proc.wait()
358
+ except KeyboardInterrupt:
359
+ _terminate_process_group(proc)
360
+ raise
315
361
  if installed_packages:
316
362
  import importlib
317
363
 
@@ -500,11 +546,19 @@ def _run_shell_body(body: str, *, shell_arg: str) -> int:
500
546
  stderr=subprocess.STDOUT,
501
547
  text=True,
502
548
  bufsize=1,
549
+ start_new_session=(os.name == "posix"),
503
550
  )
504
- assert proc.stdout is not None
505
- for raw_line in proc.stdout:
506
- sys.stdout.write(raw_line)
507
- proc.wait()
551
+ try:
552
+ assert proc.stdout is not None
553
+ try:
554
+ for raw_line in proc.stdout:
555
+ sys.stdout.write(raw_line)
556
+ finally:
557
+ proc.stdout.close()
558
+ proc.wait()
559
+ except KeyboardInterrupt:
560
+ _terminate_process_group(proc)
561
+ raise
508
562
  return proc.returncode
509
563
 
510
564
 
@@ -32,6 +32,8 @@ export interface BashExecutorOptions {
32
32
  /** Artifact path/id for full output storage */
33
33
  artifactPath?: string;
34
34
  artifactId?: string;
35
+ /** Execute without retaining a native Shell in the persistent session registry. */
36
+ oneShot?: boolean;
35
37
  /**
36
38
  * Invoked when the native minimizer rewrote the command's output, giving
37
39
  * the caller a chance to persist the lossless original capture (typically
@@ -59,6 +61,8 @@ export interface BashResult {
59
61
 
60
62
  const shellSessions = new Map<string, Shell>();
61
63
  const brokenShellSessions = new Set<string>();
64
+ const retiringShellSessions = new Set<Shell>();
65
+ const CANCEL_CLEANUP_WAIT_MS = 250;
62
66
 
63
67
  /** Number of persistent shell sessions currently retained (owner gauge). */
64
68
  export function getShellSessionCount(): number {
@@ -76,10 +80,14 @@ export async function disposeAllShellSessions(): Promise<void> {
76
80
  // Snapshot and drop strong references up front so concurrent callers cannot
77
81
  // reuse a session that is being torn down, then await every native abort so
78
82
  // shutdown/signal cleanup does not return before resources are released.
79
- const sessions = [...shellSessions.values()];
83
+ // Include retiring shells whose JS call returned after bounded abort cleanup
84
+ // while the native run is still unwinding; they are no longer reusable but
85
+ // remain owned until their run promise settles.
86
+ const sessions = new Set([...shellSessions.values(), ...retiringShellSessions]);
80
87
  shellSessions.clear();
88
+ retiringShellSessions.clear();
81
89
  brokenShellSessions.clear();
82
- await Promise.allSettled(sessions.map(session => session.abort()));
90
+ await Promise.allSettled([...sessions].map(session => session.abort()));
83
91
  }
84
92
 
85
93
  postmortem.register("bash-executor:shell-sessions", () => disposeAllShellSessions());
@@ -151,14 +159,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
151
159
  };
152
160
  }
153
161
 
162
+ const usePersistentShell = options?.oneShot !== true;
154
163
  const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
155
- const persistentSessionBroken = brokenShellSessions.has(sessionKey);
156
- if (persistentSessionBroken) {
157
- shellSessions.delete(sessionKey);
158
- }
164
+ const persistentSessionBroken = usePersistentShell && brokenShellSessions.has(sessionKey);
159
165
 
160
- let shellSession = persistentSessionBroken ? undefined : shellSessions.get(sessionKey);
161
- if (!shellSession && !persistentSessionBroken) {
166
+ let shellSession = persistentSessionBroken || !usePersistentShell ? undefined : shellSessions.get(sessionKey);
167
+ if (!shellSession && !persistentSessionBroken && usePersistentShell) {
162
168
  shellSession = new Shell({
163
169
  sessionEnv: shellEnv,
164
170
  snapshotPath: snapshotPath ?? undefined,
@@ -172,16 +178,29 @@ export async function executeBash(command: string, options?: BashExecutorOptions
172
178
  if (!runAbortController.signal.aborted) {
173
179
  runAbortController.abort();
174
180
  }
175
- if (shellSession) {
176
- // Native abort is async; fire-and-forget because the caller races the command separately.
177
- void shellSession.abort();
181
+ if (shellSession && !abortPromise) {
182
+ abortPromise = shellSession.abort();
178
183
  }
179
184
  };
180
185
  const abortDeferred = Promise.withResolvers<"abort">();
186
+ let abortPromise: Promise<unknown> | undefined;
181
187
  const abortHandler = () => {
182
188
  abortCurrentExecution();
183
189
  abortDeferred.resolve("abort");
184
190
  };
191
+ const awaitAbortCleanup = async (runPromise: Promise<unknown>): Promise<boolean> => {
192
+ const settled = await Promise.race([
193
+ runPromise.then(
194
+ () => true,
195
+ () => true,
196
+ ),
197
+ Bun.sleep(CANCEL_CLEANUP_WAIT_MS).then(() => false),
198
+ ]);
199
+ if (abortPromise) {
200
+ await Promise.race([abortPromise.catch(() => undefined), Bun.sleep(CANCEL_CLEANUP_WAIT_MS)]);
201
+ }
202
+ return settled;
203
+ };
185
204
  if (userSignal) {
186
205
  userSignal.addEventListener("abort", abortHandler, { once: true });
187
206
  }
@@ -198,6 +217,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
198
217
  }
199
218
 
200
219
  let resetSession = false;
220
+ let runSettled = false;
201
221
 
202
222
  try {
203
223
  const runPromise = shellSession
@@ -243,8 +263,24 @@ export async function executeBash(command: string, options?: BashExecutorOptions
243
263
  acceptingChunks = false;
244
264
  if (shellSession) {
245
265
  resetSession = true;
266
+ retiringShellSessions.add(shellSession);
246
267
  brokenShellSessions.add(sessionKey);
247
- void runPromise.finally(() => brokenShellSessions.delete(sessionKey)).catch(() => undefined);
268
+ shellSessions.delete(sessionKey);
269
+ runSettled = await awaitAbortCleanup(runPromise);
270
+ if (runSettled) {
271
+ brokenShellSessions.delete(sessionKey);
272
+ retiringShellSessions.delete(shellSession);
273
+ } else {
274
+ void runPromise
275
+ .finally(() => {
276
+ brokenShellSessions.delete(sessionKey);
277
+ retiringShellSessions.delete(shellSession);
278
+ if (shellSessions.get(sessionKey) === shellSession) {
279
+ shellSessions.delete(sessionKey);
280
+ }
281
+ })
282
+ .catch(() => undefined);
283
+ }
248
284
  } else {
249
285
  void runPromise.catch(() => undefined);
250
286
  }
@@ -337,7 +373,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
337
373
  if (userSignal) {
338
374
  userSignal.removeEventListener("abort", abortHandler);
339
375
  }
340
- if (resetSession) {
376
+ if (resetSession && runSettled && shellSessions.get(sessionKey) === shellSession) {
341
377
  shellSessions.delete(sessionKey);
342
378
  }
343
379
  }
@@ -124,8 +124,8 @@ interface ResolvedDeepInterviewArgs {
124
124
  }
125
125
 
126
126
  interface DeepInterviewLanguagePreference {
127
- code: "en" | "ko";
128
- label: "English" | "Korean";
127
+ code: "en" | "user";
128
+ label: "English" | "User language";
129
129
  source: "explicit-user-request" | "initial-idea";
130
130
  instruction: string;
131
131
  }
@@ -239,21 +239,22 @@ function englishLanguagePreference(): DeepInterviewLanguagePreference {
239
239
  };
240
240
  }
241
241
 
242
+ function userLanguagePreference(): DeepInterviewLanguagePreference {
243
+ return {
244
+ code: "user",
245
+ label: "User language",
246
+ source: "initial-idea",
247
+ instruction:
248
+ "Ask every user-facing deep-interview question in the user/session language inferred from the initial idea unless the user explicitly requests another language. Keep code identifiers, file paths, commands, settings/JSON keys, library/API names, and quoted source text unchanged when appropriate.",
249
+ };
250
+ }
251
+
242
252
  function resolveDeepInterviewLanguagePreference(idea: string): DeepInterviewLanguagePreference | undefined {
243
253
  if (/\b(?:answer|ask|respond|reply|write|use|speak)\s+(?:only\s+)?in\s+English\b/i.test(idea)) {
244
254
  return englishLanguagePreference();
245
255
  }
246
- if (/(?:영어로|영문으로|영어\s*(?:질문|답변|응답)|English\s+only)/i.test(idea)) {
247
- return englishLanguagePreference();
248
- }
249
- if (/\p{Script=Hangul}/u.test(idea)) {
250
- return {
251
- code: "ko",
252
- label: "Korean",
253
- source: "initial-idea",
254
- instruction:
255
- "Ask every user-facing deep-interview question in Korean unless the user explicitly requests another language.",
256
- };
256
+ if (/[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u.test(idea)) {
257
+ return userLanguagePreference();
257
258
  }
258
259
  return undefined;
259
260
  }
@@ -13,6 +13,7 @@ import {
13
13
  } from "./ledger-event-renderer";
14
14
  import { isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
15
15
  import { migrateWorkflowState } from "./state-migrations";
16
+ import { runNativeStateCommand } from "./state-runtime";
16
17
  import {
17
18
  appendJsonlIdempotent,
18
19
  readExistingStateForMutation,
@@ -104,6 +105,10 @@ export function isRalplanArtifactWriteInvocation(args: readonly string[]): boole
104
105
  return hasFlag(args, "--write");
105
106
  }
106
107
 
108
+ function isRalplanDoctorInvocation(args: readonly string[]): boolean {
109
+ return args[0] === "doctor";
110
+ }
111
+
107
112
  function assertSafePathComponent(value: string, label: string): void {
108
113
  if (!PATH_COMPONENT_RE.test(value) || value.includes("..")) {
109
114
  throw new RalplanCommandError(2, `invalid path component for --${label}: ${value}`);
@@ -854,10 +859,15 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
854
859
  return { status: 0, stdout };
855
860
  }
856
861
 
862
+ async function handleDoctor(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
863
+ return await runNativeStateCommand(["doctor", "--skill", "ralplan", ...args.slice(1)], cwd);
864
+ }
865
+
857
866
  /* -------------------------------- entry --------------------------------- */
858
867
 
859
868
  export async function runNativeRalplanCommand(args: string[], cwd = process.cwd()): Promise<RalplanCommandResult> {
860
869
  try {
870
+ if (isRalplanDoctorInvocation(args)) return await handleDoctor(args, cwd);
861
871
  if (isRalplanArtifactWriteInvocation(args)) return await handleArtifactWrite(args, cwd);
862
872
  return await handleConsensusHandoff(args, cwd);
863
873
  } catch (error) {
@@ -450,6 +450,52 @@ function activeFlag(value: unknown): boolean {
450
450
  return isPlainObject(value) && value.active !== false;
451
451
  }
452
452
 
453
+ function phaseFromActiveValue(value: unknown): string | undefined {
454
+ if (!isPlainObject(value) || typeof value.phase !== "string") return undefined;
455
+ const phase = value.phase.trim();
456
+ return phase || undefined;
457
+ }
458
+
459
+ const RALPLAN_CANONICAL_PHASE_OVERRIDES = new Set([
460
+ "final",
461
+ "handoff",
462
+ "complete",
463
+ "completed",
464
+ "failed",
465
+ "cancelled",
466
+ "canceled",
467
+ "inactive",
468
+ ]);
469
+
470
+ function modeStatePhase(value: unknown): string | undefined {
471
+ if (!isPlainObject(value) || typeof value.current_phase !== "string") return undefined;
472
+ const phase = value.current_phase.trim();
473
+ if (!phase) return undefined;
474
+ if (value.active === false && !RALPLAN_CANONICAL_PHASE_OVERRIDES.has(phase)) return undefined;
475
+ return phase;
476
+ }
477
+
478
+ function pushPhaseDriftProblem(options: {
479
+ problems: DoctorProblem[];
480
+ pathValue: string;
481
+ skill: CanonicalGjcWorkflowSkill;
482
+ entryKind: "active entry" | "active snapshot";
483
+ entrySkill: string;
484
+ entryPhase: string | undefined;
485
+ statePhase: string | undefined;
486
+ }): void {
487
+ if (!options.entryPhase || !options.statePhase || options.entryPhase === options.statePhase) return;
488
+ options.problems.push(
489
+ doctorProblem(
490
+ "stale_active_state",
491
+ options.pathValue,
492
+ `${options.entryKind} for ${options.entrySkill} phase ${options.entryPhase} differs from canonical mode-state phase ${options.statePhase}`,
493
+ `gjc state ${options.skill} clear`,
494
+ options.skill,
495
+ ),
496
+ );
497
+ }
498
+
453
499
  async function collectDoctorSummary(
454
500
  cwd: string,
455
501
  skill: CanonicalGjcWorkflowSkill | undefined,
@@ -460,6 +506,7 @@ async function collectDoctorSummary(
460
506
  const problems: DoctorProblem[] = [];
461
507
  let filesScanned = 0;
462
508
  let journalsScanned = 0;
509
+ const invalidModeStates = new Set<string>();
463
510
 
464
511
  for (const currentSkill of skills) {
465
512
  const filePath = modeStateFile(cwd, currentSkill, sessionId);
@@ -476,6 +523,7 @@ async function collectDoctorSummary(
476
523
  currentSkill,
477
524
  ),
478
525
  );
526
+ invalidModeStates.add(currentSkill);
479
527
  continue;
480
528
  }
481
529
  const validation = validateWorkflowStateEnvelope(currentSkill, raw.value);
@@ -489,6 +537,7 @@ async function collectDoctorSummary(
489
537
  currentSkill,
490
538
  ),
491
539
  );
540
+ invalidModeStates.add(currentSkill);
492
541
  }
493
542
  const mismatch = await detectWorkflowEnvelopeIntegrityMismatch(filePath);
494
543
  if (mismatch) {
@@ -501,6 +550,7 @@ async function collectDoctorSummary(
501
550
  currentSkill,
502
551
  ),
503
552
  );
553
+ invalidModeStates.add(currentSkill);
504
554
  }
505
555
  }
506
556
 
@@ -553,6 +603,17 @@ async function collectDoctorSummary(
553
603
  ),
554
604
  );
555
605
  }
606
+ if (canonical && activeFlag(entry.value) && !invalidModeStates.has(canonical)) {
607
+ pushPhaseDriftProblem({
608
+ problems,
609
+ pathValue: entryPath,
610
+ skill: canonical,
611
+ entryKind: "active entry",
612
+ entrySkill,
613
+ entryPhase: phaseFromActiveValue(entry.value),
614
+ statePhase: modeStatePhase(state.value),
615
+ });
616
+ }
556
617
  }
557
618
  if (isPlainObject(snapshot.value)) {
558
619
  const activeSkills = Array.isArray(snapshot.value.active_skills) ? snapshot.value.active_skills : [];
@@ -572,6 +633,18 @@ async function collectDoctorSummary(
572
633
  ),
573
634
  );
574
635
  }
636
+ if (canonical && activeFlag(entry) && !invalidModeStates.has(canonical)) {
637
+ const state = await readRawJson(modeStateFile(cwd, canonical, scopeSessionId));
638
+ pushPhaseDriftProblem({
639
+ problems,
640
+ pathValue: snapshotPath,
641
+ skill: canonical,
642
+ entryKind: "active snapshot",
643
+ entrySkill,
644
+ entryPhase: phaseFromActiveValue(entry),
645
+ statePhase: modeStatePhase(state.value),
646
+ });
647
+ }
575
648
  }
576
649
  }
577
650
  };