@gajae-code/coding-agent 0.2.2 → 0.2.3

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 (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/cli/setup-cli.d.ts +1 -0
  3. package/dist/types/commands/deep-interview.d.ts +41 -0
  4. package/dist/types/commands/setup.d.ts +3 -0
  5. package/dist/types/config/settings-schema.d.ts +36 -0
  6. package/dist/types/discovery/helpers.d.ts +2 -0
  7. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  8. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +18 -0
  9. package/dist/types/hooks/skill-state.d.ts +5 -0
  10. package/dist/types/memories/index.d.ts +1 -1
  11. package/dist/types/memory-backend/local-backend.d.ts +3 -3
  12. package/dist/types/modes/components/hook-selector.d.ts +7 -0
  13. package/dist/types/modes/components/settings-selector.d.ts +0 -2
  14. package/dist/types/modes/utils/context-usage.d.ts +6 -2
  15. package/dist/types/sdk.d.ts +6 -2
  16. package/dist/types/session/agent-session.d.ts +45 -1
  17. package/dist/types/session/session-manager.d.ts +3 -0
  18. package/dist/types/setup/model-onboarding-guidance.d.ts +1 -0
  19. package/dist/types/setup/provider-onboarding.d.ts +29 -5
  20. package/dist/types/skill-state/active-state.d.ts +26 -1
  21. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  22. package/dist/types/skill-state/initial-phase.d.ts +12 -0
  23. package/dist/types/task/executor.d.ts +2 -0
  24. package/dist/types/task/types.d.ts +11 -0
  25. package/dist/types/tools/index.d.ts +20 -1
  26. package/dist/types/tools/skill.d.ts +47 -0
  27. package/dist/types/utils/changelog.d.ts +18 -2
  28. package/package.json +7 -7
  29. package/src/cli/setup-cli.ts +26 -12
  30. package/src/cli.ts +1 -0
  31. package/src/commands/deep-interview.ts +25 -2
  32. package/src/commands/setup.ts +2 -0
  33. package/src/commands/state.ts +1 -0
  34. package/src/config/settings-schema.ts +41 -0
  35. package/src/defaults/gjc/skills/deep-interview/SKILL.md +19 -1
  36. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -0
  37. package/src/defaults/gjc/skills/team/SKILL.md +10 -0
  38. package/src/defaults/gjc/skills/ultragoal/SKILL.md +10 -0
  39. package/src/discovery/helpers.ts +24 -1
  40. package/src/extensibility/extensions/types.ts +6 -0
  41. package/src/gjc-runtime/deep-interview-runtime.ts +268 -1
  42. package/src/gjc-runtime/state-runtime.ts +173 -4
  43. package/src/hooks/skill-state.ts +8 -6
  44. package/src/internal-urls/docs-index.generated.ts +2 -2
  45. package/src/internal-urls/memory-protocol.ts +3 -2
  46. package/src/main.ts +2 -3
  47. package/src/memories/index.ts +2 -1
  48. package/src/memory-backend/local-backend.ts +14 -6
  49. package/src/modes/components/hook-selector.ts +156 -1
  50. package/src/modes/components/settings-selector.ts +5 -12
  51. package/src/modes/controllers/command-controller.ts +2 -3
  52. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  53. package/src/modes/controllers/selector-controller.ts +4 -11
  54. package/src/modes/utils/context-usage.ts +66 -17
  55. package/src/prompts/agents/architect.md +3 -0
  56. package/src/prompts/agents/executor.md +2 -0
  57. package/src/prompts/agents/frontmatter.md +1 -0
  58. package/src/prompts/system/subagent-system-prompt.md +6 -0
  59. package/src/prompts/tools/skill.md +28 -0
  60. package/src/prompts/tools/task.md +3 -0
  61. package/src/sdk.ts +50 -10
  62. package/src/session/agent-session.ts +204 -21
  63. package/src/session/session-manager.ts +9 -1
  64. package/src/setup/model-onboarding-guidance.ts +6 -3
  65. package/src/setup/provider-onboarding.ts +177 -16
  66. package/src/skill-state/active-state.ts +150 -25
  67. package/src/skill-state/deep-interview-mutation-guard.ts +11 -24
  68. package/src/skill-state/initial-phase.ts +17 -0
  69. package/src/slash-commands/builtin-registry.ts +51 -13
  70. package/src/slash-commands/helpers/context-report.ts +123 -13
  71. package/src/task/agents.ts +1 -0
  72. package/src/task/executor.ts +9 -1
  73. package/src/task/index.ts +91 -4
  74. package/src/task/types.ts +6 -0
  75. package/src/tools/ask.ts +2 -0
  76. package/src/tools/index.ts +23 -1
  77. package/src/tools/skill.ts +153 -0
  78. package/src/utils/changelog.ts +67 -44
@@ -1,8 +1,11 @@
1
+ import { createHash, randomBytes } from "node:crypto";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { syncSkillActiveState } from "../skill-state/active-state";
5
6
  import { buildDeepInterviewHudSummary } from "../skill-state/workflow-hud";
7
+ import { runNativeRalplanCommand } from "./ralplan-runtime";
8
+ import { runNativeStateCommand } from "./state-runtime";
6
9
 
7
10
  /**
8
11
  * Native implementation of `gjc deep-interview`.
@@ -42,7 +45,15 @@ class DeepInterviewCommandError extends Error {
42
45
  }
43
46
  }
44
47
 
45
- const VALUE_FLAGS = new Set(["--session-id", "--threshold", "--threshold-source"]);
48
+ const VALUE_FLAGS = new Set([
49
+ "--session-id",
50
+ "--threshold",
51
+ "--threshold-source",
52
+ "--stage",
53
+ "--slug",
54
+ "--spec",
55
+ "--handoff",
56
+ ]);
46
57
 
47
58
  function flagValue(args: readonly string[], flag: string): string | undefined {
48
59
  const index = args.indexOf(flag);
@@ -64,6 +75,56 @@ function encodeSessionSegment(value: string): string {
64
75
  return encodeURIComponent(value).replaceAll(".", "%2E");
65
76
  }
66
77
 
78
+ function defaultSpecSlug(now: Date = new Date()): string {
79
+ const yyyy = now.getUTCFullYear().toString().padStart(4, "0");
80
+ const mm = (now.getUTCMonth() + 1).toString().padStart(2, "0");
81
+ const dd = now.getUTCDate().toString().padStart(2, "0");
82
+ const hh = now.getUTCHours().toString().padStart(2, "0");
83
+ const min = now.getUTCMinutes().toString().padStart(2, "0");
84
+ return `${yyyy}-${mm}-${dd}-${hh}${min}-${randomBytes(2).toString("hex")}`;
85
+ }
86
+
87
+ function stateDirFor(cwd: string, sessionId: string | undefined): string {
88
+ return sessionId
89
+ ? path.join(cwd, ".gjc", "state", "sessions", encodeSessionSegment(sessionId))
90
+ : path.join(cwd, ".gjc", "state");
91
+ }
92
+
93
+ function deepInterviewStatePath(cwd: string, sessionId: string | undefined): string {
94
+ return path.join(stateDirFor(cwd, sessionId), "deep-interview-state.json");
95
+ }
96
+
97
+ async function readJsonObject(filePath: string): Promise<Record<string, unknown>> {
98
+ try {
99
+ const parsed = JSON.parse(await fs.readFile(filePath, "utf-8"));
100
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
101
+ } catch {
102
+ // Missing/corrupt state should not prevent the sanctioned persistence CLI from writing a receipt.
103
+ }
104
+ return {};
105
+ }
106
+
107
+ async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
108
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
109
+ const tmp = `${filePath}.tmp-${randomBytes(6).toString("hex")}`;
110
+ await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`);
111
+ await fs.rename(tmp, filePath);
112
+ }
113
+
114
+ async function resolveSpecContent(rawSpec: string, cwd: string): Promise<string> {
115
+ const candidate = path.isAbsolute(rawSpec) ? rawSpec : path.resolve(cwd, rawSpec);
116
+ try {
117
+ const stat = await fs.stat(candidate);
118
+ if (stat.isFile()) return await fs.readFile(candidate, "utf-8");
119
+ } catch (error) {
120
+ const err = error as NodeJS.ErrnoException;
121
+ if (err.code !== "ENOENT" && err.code !== "ENOTDIR") {
122
+ throw new DeepInterviewCommandError(2, `failed to read --spec ${candidate}: ${err.message}`);
123
+ }
124
+ }
125
+ return rawSpec;
126
+ }
127
+
67
128
  interface ResolvedDeepInterviewArgs {
68
129
  resolution: DeepInterviewResolution;
69
130
  threshold: number;
@@ -73,6 +134,41 @@ interface ResolvedDeepInterviewArgs {
73
134
  json: boolean;
74
135
  }
75
136
 
137
+ export interface ResolvedDeepInterviewSpecWriteArgs {
138
+ stage: "final";
139
+ slug: string;
140
+ spec: string;
141
+ sessionId?: string;
142
+ json: boolean;
143
+ deliberate: boolean;
144
+ handoff?: "ralplan";
145
+ }
146
+
147
+ export interface PersistedDeepInterviewSpec {
148
+ slug: string;
149
+ path: string;
150
+ stage: "final";
151
+ sha256: string;
152
+ createdAt: string;
153
+ statePath: string;
154
+ }
155
+
156
+ interface DeepInterviewSpecWriteSummary {
157
+ skill: "deep-interview";
158
+ stage: "final";
159
+ slug: string;
160
+ path: string;
161
+ sha256: string;
162
+ created_at: string;
163
+ state_path: string;
164
+ handoff?: {
165
+ to: "ralplan";
166
+ mode: "deliberate";
167
+ state_path?: string;
168
+ run_id?: string;
169
+ };
170
+ }
171
+
76
172
  async function readSettingsAmbiguityThreshold(
77
173
  settingsPath: string,
78
174
  ): Promise<{ threshold: number; source: string } | undefined> {
@@ -109,6 +205,68 @@ async function resolveConfiguredAmbiguityThreshold(
109
205
  return await readSettingsAmbiguityThreshold(userSettings);
110
206
  }
111
207
 
208
+ function isDeepInterviewSpecWriteInvocation(args: readonly string[]): boolean {
209
+ return hasFlag(args, "--write");
210
+ }
211
+
212
+ async function resolveSpecWriteArgs(args: readonly string[], cwd: string): Promise<ResolvedDeepInterviewSpecWriteArgs> {
213
+ const stage = flagValue(args, "--stage")?.trim() || "final";
214
+ if (stage !== "final") {
215
+ throw new DeepInterviewCommandError(2, 'unknown --stage for deep-interview --write: expected "final"');
216
+ }
217
+
218
+ const slug = flagValue(args, "--slug")?.trim() || defaultSpecSlug();
219
+ assertSafePathComponent(slug, "slug");
220
+
221
+ const rawSpec = flagValue(args, "--spec");
222
+ if (rawSpec === undefined || rawSpec === "") {
223
+ throw new DeepInterviewCommandError(2, "--spec is required for deep-interview --write");
224
+ }
225
+
226
+ const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
227
+ if (sessionId) assertSafePathComponent(sessionId, "session-id");
228
+
229
+ const rawHandoff = flagValue(args, "--handoff")?.trim() || undefined;
230
+ if (rawHandoff && rawHandoff !== "ralplan") {
231
+ throw new DeepInterviewCommandError(2, 'unknown --handoff target: expected "ralplan"');
232
+ }
233
+
234
+ const allowedFlags = new Set([
235
+ "--write",
236
+ "--stage",
237
+ "--slug",
238
+ "--spec",
239
+ "--session-id",
240
+ "--handoff",
241
+ "--deliberate",
242
+ "--json",
243
+ ]);
244
+ let skipNext = false;
245
+ for (const arg of args) {
246
+ if (skipNext) {
247
+ skipNext = false;
248
+ continue;
249
+ }
250
+ if (["--stage", "--slug", "--spec", "--session-id", "--handoff"].includes(arg)) {
251
+ skipNext = true;
252
+ continue;
253
+ }
254
+ if (arg.startsWith("-") && !allowedFlags.has(arg)) {
255
+ throw new DeepInterviewCommandError(2, `unknown flag for gjc deep-interview --write: ${arg}`);
256
+ }
257
+ }
258
+
259
+ return {
260
+ stage: "final",
261
+ slug,
262
+ spec: await resolveSpecContent(rawSpec, cwd),
263
+ sessionId,
264
+ json: hasFlag(args, "--json"),
265
+ deliberate: hasFlag(args, "--deliberate"),
266
+ handoff: rawHandoff as "ralplan" | undefined,
267
+ };
268
+ }
269
+
112
270
  async function resolveDeepInterviewArgs(args: readonly string[], cwd: string): Promise<ResolvedDeepInterviewArgs> {
113
271
  const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
114
272
  if (sessionId) assertSafePathComponent(sessionId, "session-id");
@@ -173,6 +331,57 @@ async function resolveDeepInterviewArgs(args: readonly string[], cwd: string): P
173
331
  };
174
332
  }
175
333
 
334
+ export async function persistDeepInterviewSpec(
335
+ cwd: string,
336
+ resolved: ResolvedDeepInterviewSpecWriteArgs,
337
+ ): Promise<PersistedDeepInterviewSpec> {
338
+ const specsDir = path.join(cwd, ".gjc", "specs");
339
+ await fs.mkdir(specsDir, { recursive: true });
340
+ const specPath = path.join(specsDir, `deep-interview-${resolved.slug}.md`);
341
+ const content = resolved.spec.endsWith("\n") ? resolved.spec : `${resolved.spec}\n`;
342
+ await fs.writeFile(specPath, content);
343
+
344
+ const sha256 = createHash("sha256").update(content).digest("hex");
345
+ const createdAt = new Date().toISOString();
346
+ await fs.appendFile(
347
+ path.join(specsDir, "deep-interview-index.jsonl"),
348
+ `${JSON.stringify({ slug: resolved.slug, stage: resolved.stage, path: specPath, created_at: createdAt, sha256 })}\n`,
349
+ );
350
+
351
+ const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
352
+ const existing = await readJsonObject(statePath);
353
+ const payload: Record<string, unknown> = {
354
+ ...existing,
355
+ active: true,
356
+ current_phase: "handoff",
357
+ skill: "deep-interview",
358
+ version: typeof existing.version === "number" ? existing.version : 1,
359
+ spec_slug: resolved.slug,
360
+ spec_path: specPath,
361
+ spec_sha256: sha256,
362
+ spec_stage: resolved.stage,
363
+ spec_persisted_at: createdAt,
364
+ updated_at: createdAt,
365
+ };
366
+ if (resolved.sessionId) payload.session_id = resolved.sessionId;
367
+ await writeJsonAtomic(statePath, payload);
368
+ await syncDeepInterviewHud({
369
+ cwd,
370
+ sessionId: resolved.sessionId,
371
+ phase: "handoff",
372
+ specStatus: "persisted",
373
+ });
374
+
375
+ return {
376
+ slug: resolved.slug,
377
+ path: specPath,
378
+ stage: resolved.stage,
379
+ sha256,
380
+ createdAt,
381
+ statePath,
382
+ };
383
+ }
384
+
176
385
  async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepInterviewArgs): Promise<string> {
177
386
  const stateDir = resolved.sessionId
178
387
  ? path.join(cwd, ".gjc", "state", "sessions", encodeSessionSegment(resolved.sessionId))
@@ -232,11 +441,69 @@ async function syncDeepInterviewHud(options: {
232
441
  }
233
442
  }
234
443
 
444
+ async function handleSpecWrite(args: readonly string[], cwd: string): Promise<DeepInterviewCommandResult> {
445
+ const resolved = await resolveSpecWriteArgs(args, cwd);
446
+ const persisted = await persistDeepInterviewSpec(cwd, resolved);
447
+ const shouldHandoff = resolved.deliberate || resolved.handoff === "ralplan";
448
+ const summary: DeepInterviewSpecWriteSummary = {
449
+ skill: "deep-interview",
450
+ stage: persisted.stage,
451
+ slug: persisted.slug,
452
+ path: persisted.path,
453
+ sha256: persisted.sha256,
454
+ created_at: persisted.createdAt,
455
+ state_path: persisted.statePath,
456
+ };
457
+
458
+ if (shouldHandoff) {
459
+ const ralplanArgs = ["--deliberate", "--json"];
460
+ if (resolved.sessionId) ralplanArgs.push("--session-id", resolved.sessionId);
461
+ ralplanArgs.push(persisted.path);
462
+ const ralplanResult = await runNativeRalplanCommand(ralplanArgs, cwd);
463
+ if (ralplanResult.status !== 0) {
464
+ throw new DeepInterviewCommandError(
465
+ ralplanResult.status,
466
+ ralplanResult.stderr?.trim() || "failed to seed ralplan",
467
+ );
468
+ }
469
+
470
+ const handoffArgs = ["handoff", "--mode", "deep-interview", "--to", "ralplan", "--json"];
471
+ if (resolved.sessionId) handoffArgs.push("--session-id", resolved.sessionId);
472
+ const handoffResult = await runNativeStateCommand(handoffArgs, cwd);
473
+ if (handoffResult.status !== 0) {
474
+ throw new DeepInterviewCommandError(
475
+ handoffResult.status,
476
+ handoffResult.stderr?.trim() || "failed to hand off deep-interview to ralplan",
477
+ );
478
+ }
479
+
480
+ const ralplanPayload = ralplanResult.stdout ? (JSON.parse(ralplanResult.stdout) as Record<string, unknown>) : {};
481
+ summary.handoff = {
482
+ to: "ralplan",
483
+ mode: "deliberate",
484
+ state_path: typeof ralplanPayload.state_path === "string" ? ralplanPayload.state_path : undefined,
485
+ run_id: typeof ralplanPayload.run_id === "string" ? ralplanPayload.run_id : undefined,
486
+ };
487
+ }
488
+
489
+ const stdout = resolved.json
490
+ ? `${JSON.stringify(summary, null, 2)}\n`
491
+ : [
492
+ `Persisted deep-interview ${persisted.stage} spec at ${persisted.path}.`,
493
+ shouldHandoff ? "Handed off deep-interview to ralplan (deliberate)." : undefined,
494
+ "",
495
+ ]
496
+ .filter((line): line is string => Boolean(line))
497
+ .join("\n");
498
+ return { status: 0, stdout };
499
+ }
500
+
235
501
  export async function runNativeDeepInterviewCommand(
236
502
  args: string[],
237
503
  cwd = process.cwd(),
238
504
  ): Promise<DeepInterviewCommandResult> {
239
505
  try {
506
+ if (isDeepInterviewSpecWriteInvocation(args)) return await handleSpecWrite(args, cwd);
240
507
  const resolved = await resolveDeepInterviewArgs(args, cwd);
241
508
  if (!resolved.idea) {
242
509
  throw new DeepInterviewCommandError(
@@ -3,12 +3,14 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import type { WorkflowHudSummary } from "../skill-state/active-state";
5
5
  import {
6
+ applyHandoffToActiveState,
6
7
  CANONICAL_GJC_WORKFLOW_SKILLS,
7
8
  type CanonicalGjcWorkflowSkill,
8
9
  listActiveSkills,
9
10
  readVisibleSkillActiveState,
10
11
  syncSkillActiveState,
11
12
  } from "../skill-state/active-state";
13
+ import { initialPhaseForSkill } from "../skill-state/initial-phase";
12
14
  import {
13
15
  buildDeepInterviewHudSummary,
14
16
  buildRalplanHudSummary,
@@ -60,11 +62,11 @@ function hasFlag(args: readonly string[], flag: string): boolean {
60
62
  return args.includes(flag);
61
63
  }
62
64
 
63
- const FLAGS_WITH_VALUES = new Set(["--input", "--mode", "--session-id", "--thread-id", "--turn-id"]);
64
- const ACTION_NAMES = new Set(["read", "write", "clear", "contract"]);
65
+ const FLAGS_WITH_VALUES = new Set(["--input", "--mode", "--session-id", "--thread-id", "--turn-id", "--to"]);
66
+ const ACTION_NAMES = new Set(["read", "write", "clear", "contract", "handoff"]);
65
67
 
66
68
  interface ParsedInvocation {
67
- action: "read" | "write" | "clear" | "contract";
69
+ action: "read" | "write" | "clear" | "contract" | "handoff";
68
70
  positionalSkill?: string;
69
71
  }
70
72
 
@@ -169,10 +171,19 @@ async function resolveSelectors(
169
171
  }
170
172
  if (mode) assertKnownMode(mode);
171
173
 
174
+ // Session-id resolution order: explicit --session-id flag, then payload
175
+ // session_id, then GJC_SESSION_ID env var (set by AgentSession.sdk for
176
+ // agent-initiated CLI invocations). The env-var default keeps shell
177
+ // snippets in skill docs short while still routing writes/reads to the
178
+ // caller's session-scoped state files.
172
179
  let sessionId = flagValue(args, "--session-id")?.trim() || undefined;
173
180
  if (!sessionId && payload && typeof payload.session_id === "string") {
174
181
  sessionId = payload.session_id.trim() || undefined;
175
182
  }
183
+ if (!sessionId) {
184
+ const envSessionId = process.env.GJC_SESSION_ID?.trim();
185
+ if (envSessionId) sessionId = envSessionId;
186
+ }
176
187
  if (sessionId) assertSafePathComponent(sessionId, "session-id");
177
188
 
178
189
  const threadId = flagValue(args, "--thread-id")?.trim() || undefined;
@@ -396,7 +407,6 @@ async function syncWorkflowSkillState(options: {
396
407
  // HUD sync is best-effort and must not change command semantics.
397
408
  }
398
409
  }
399
-
400
410
  async function handleRead(
401
411
  args: readonly string[],
402
412
  cwd: string,
@@ -527,6 +537,163 @@ async function handleClear(
527
537
  return { status: 0, stdout: `${JSON.stringify(cleared, null, 2)}\n` };
528
538
  }
529
539
 
540
+ /**
541
+ * `handoff` exists in two distinct roles:
542
+ * - As a verb: this CLI action, which atomically transitions caller→callee.
543
+ * Writes the callee mode-state first, the caller mode-state second, then
544
+ * syncs both `skill-active-state.json` files. Every intermediate crashed
545
+ * state remains HUD-coherent: the active-state file either reflects the
546
+ * old skill entirely or the new skill entirely, never both as active.
547
+ * - As a phase: `current_phase: "handoff"` is set by this verb when demoting
548
+ * the caller. Agents writing `current_phase: "handoff"` manually via
549
+ * `gjc state <skill> write` are declaring "I am ready to be handed off";
550
+ * the next agent-initiated `skill` tool call will then satisfy the phase
551
+ * guard and may chain.
552
+ *
553
+ * `handoff` is in the terminal-phase set used by `isTerminalModeState` and by
554
+ * the skill tool's chain guard. A manual `current_phase: "handoff"` write does
555
+ * NOT mark `active: false` — only this verb does that — so a skill that wrote
556
+ * the phase remains in `skill-active-state.json` until a chain call (or
557
+ * explicit `clear`) demotes it.
558
+ */
559
+ async function handleHandoff(
560
+ args: readonly string[],
561
+ cwd: string,
562
+ positionalSkill: string | undefined,
563
+ ): Promise<StateCommandResult> {
564
+ const selectors = await resolveSelectors(args, cwd, positionalSkill);
565
+ const { sessionId, threadId, turnId } = selectors;
566
+ const caller = selectors.mode ?? (await inferModeFromActiveState(cwd, sessionId));
567
+ if (!caller) {
568
+ throw new StateCommandError(
569
+ 2,
570
+ "gjc state handoff requires --mode <caller>, positional <caller>, input.skill, or an active workflow in .gjc/state/skill-active-state.json",
571
+ );
572
+ }
573
+ const calleeRaw = flagValue(args, "--to")?.trim();
574
+ if (!calleeRaw) {
575
+ throw new StateCommandError(2, "gjc state handoff requires --to <callee>");
576
+ }
577
+ assertKnownMode(calleeRaw);
578
+ const callee = calleeRaw as CanonicalGjcWorkflowSkill;
579
+ if (callee === caller) {
580
+ throw new StateCommandError(2, `gjc state handoff: --to must differ from caller (both are "${caller}")`);
581
+ }
582
+
583
+ const callerPath = modeStateFile(cwd, caller, sessionId);
584
+ const calleePath = modeStateFile(cwd, callee, sessionId);
585
+ const existingCaller = await readJsonFile(callerPath);
586
+ if (!existingCaller) {
587
+ throw new StateCommandError(
588
+ 2,
589
+ `gjc state ${caller} handoff: caller is not active (no mode-state file at ${callerPath})`,
590
+ );
591
+ }
592
+ const existingCallee = (await readJsonFile(calleePath)) ?? {};
593
+
594
+ const handoffAt = nowIso();
595
+ const callerReceipt = buildWorkflowStateReceipt({
596
+ cwd,
597
+ skill: caller,
598
+ owner: "gjc-state-cli",
599
+ command: `gjc state ${caller} handoff --to ${callee}`,
600
+ sessionId,
601
+ nowIso: handoffAt,
602
+ });
603
+ const calleeReceipt = buildWorkflowStateReceipt({
604
+ cwd,
605
+ skill: callee,
606
+ owner: "gjc-state-cli",
607
+ command: `gjc state ${caller} handoff --to ${callee}`,
608
+ sessionId,
609
+ nowIso: handoffAt,
610
+ });
611
+
612
+ const calleeInitial = initialPhaseForSkill(callee);
613
+ const mergedCalleeState: Record<string, unknown> = {
614
+ ...existingCallee,
615
+ skill: callee,
616
+ version: typeof existingCallee.version === "number" ? existingCallee.version : 1,
617
+ active: true,
618
+ current_phase: calleeInitial,
619
+ handoff_from: caller,
620
+ handoff_at: handoffAt,
621
+ updated_at: handoffAt,
622
+ receipt: calleeReceipt,
623
+ };
624
+ if (sessionId && typeof mergedCalleeState.session_id !== "string") {
625
+ mergedCalleeState.session_id = sessionId;
626
+ }
627
+ const mergedCallerState: Record<string, unknown> = {
628
+ ...existingCaller,
629
+ skill: caller,
630
+ active: false,
631
+ current_phase: "handoff",
632
+ handoff_to: callee,
633
+ handoff_at: handoffAt,
634
+ updated_at: handoffAt,
635
+ receipt: callerReceipt,
636
+ };
637
+
638
+ // Atomic write order (architecture blocker AR-3): mode-state files first,
639
+ // then a single atomic active-state mutation per file (session before root)
640
+ // via applyHandoffToActiveState. The single-write transaction prevents the
641
+ // HUD from observing a window where neither caller nor callee is active,
642
+ // and write order keeps the session-scoped source of truth ahead of the
643
+ // root aggregate. strict:true on the active-state read tolerates ENOENT
644
+ // only; corrupt JSON / IO failures propagate as non-zero CLI status.
645
+ await writeJsonAtomic(calleePath, mergedCalleeState);
646
+ await writeJsonAtomic(callerPath, mergedCallerState);
647
+ await applyHandoffToActiveState({
648
+ cwd,
649
+ nowIso: handoffAt,
650
+ strict: true,
651
+ caller: {
652
+ cwd,
653
+ skill: caller,
654
+ active: false,
655
+ phase: "handoff",
656
+ sessionId,
657
+ threadId,
658
+ turnId,
659
+ source: "gjc-state-cli",
660
+ hud: buildHudForMode(caller, mergedCallerState),
661
+ handoff_to: callee,
662
+ handoff_at: handoffAt,
663
+ receipt: callerReceipt,
664
+ },
665
+ callee: {
666
+ cwd,
667
+ skill: callee,
668
+ active: true,
669
+ phase: calleeInitial,
670
+ sessionId,
671
+ threadId,
672
+ turnId,
673
+ source: "gjc-state-cli",
674
+ hud: buildHudForMode(callee, mergedCalleeState),
675
+ handoff_from: caller,
676
+ handoff_at: handoffAt,
677
+ receipt: calleeReceipt,
678
+ },
679
+ });
680
+
681
+ return {
682
+ status: 0,
683
+ stdout: `${JSON.stringify(
684
+ {
685
+ from: caller,
686
+ to: callee,
687
+ handoff_at: handoffAt,
688
+ caller_state: mergedCallerState,
689
+ callee_state: mergedCalleeState,
690
+ },
691
+ null,
692
+ 2,
693
+ )}\n`,
694
+ };
695
+ }
696
+
530
697
  async function handleContract(
531
698
  args: readonly string[],
532
699
  cwd: string,
@@ -552,6 +719,8 @@ export async function runNativeStateCommand(args: string[], cwd = process.cwd())
552
719
  return await handleClear(args, cwd, parsed.positionalSkill);
553
720
  case "contract":
554
721
  return await handleContract(args, cwd, parsed.positionalSkill);
722
+ case "handoff":
723
+ return await handleHandoff(args, cwd, parsed.positionalSkill);
555
724
  default:
556
725
  return { status: 2, stderr: `Unknown gjc state command: ${parsed.action}\n` };
557
726
  }
@@ -112,6 +112,9 @@ export interface ModeState {
112
112
  thread_id?: string;
113
113
  cwd?: string;
114
114
  updated_at?: string;
115
+ handoff_from?: string;
116
+ handoff_to?: string;
117
+ handoff_at?: string;
115
118
  [key: string]: unknown;
116
119
  }
117
120
 
@@ -220,11 +223,10 @@ function encodeStatePathSegment(value: string): string {
220
223
  return encodeURIComponent(value).replaceAll(".", "%2E");
221
224
  }
222
225
 
223
- function initialPhaseForSkill(skill: GjcWorkflowSkill): string {
224
- if (skill === "deep-interview") return "interviewing";
225
- if (skill === "ultragoal") return "goal-planning";
226
- return "planning";
227
- }
226
+ import { initialPhaseForSkill } from "../skill-state/initial-phase";
227
+
228
+ // Re-export for existing callers and tests that imported it from this module.
229
+ export { initialPhaseForSkill };
228
230
 
229
231
  function modeStateFileName(skill: GjcWorkflowSkill): string {
230
232
  return `${skill}-state.json`;
@@ -347,7 +349,7 @@ function isTerminalModeState(state: ModeState | null): boolean {
347
349
  const phase = String(state.current_phase ?? "")
348
350
  .trim()
349
351
  .toLowerCase();
350
- return ["complete", "completed", "failed", "cancelled", "canceled", "inactive"].includes(phase);
352
+ return ["complete", "completed", "handoff", "failed", "cancelled", "canceled", "inactive"].includes(phase);
351
353
  }
352
354
 
353
355
  async function readVisibleModeState(