@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
@@ -5,14 +5,16 @@ import { YAML } from "bun";
5
5
  import { type ModelsConfig, ModelsConfigSchema } from "../config/models-config-schema";
6
6
 
7
7
  export type ProviderCompatibility = "openai" | "anthropic";
8
+ export type ProviderSetupApi = "openai-responses" | "openai-completions" | "anthropic-messages";
8
9
 
9
10
  export interface ProviderSetupInput {
10
- compatibility: ProviderCompatibility;
11
- providerId: string;
12
- baseUrl: string;
11
+ compatibility?: ProviderCompatibility;
12
+ preset?: string;
13
+ providerId?: string;
14
+ baseUrl?: string;
13
15
  apiKey?: string;
14
16
  apiKeyEnv?: string;
15
- models: string[];
17
+ models?: string[];
16
18
  modelsPath?: string;
17
19
  force?: boolean;
18
20
  }
@@ -20,19 +22,93 @@ export interface ProviderSetupInput {
20
22
  export interface ProviderSetupResult {
21
23
  providerId: string;
22
24
  compatibility: ProviderCompatibility;
23
- api: "openai-responses" | "anthropic-messages";
25
+ api: ProviderSetupApi;
24
26
  baseUrl: string;
25
27
  modelIds: string[];
26
28
  modelsPath: string;
27
29
  redactedApiKey: string;
28
30
  credentialSource: "literal" | "env";
31
+ preset?: string;
32
+ presetName?: string;
29
33
  }
30
34
 
31
35
  type ProviderConfig = NonNullable<NonNullable<ModelsConfig["providers"]>[string]>;
36
+ type ProviderCompatConfig = NonNullable<ProviderConfig["compat"]>;
37
+
38
+ interface ProviderPreset {
39
+ id: string;
40
+ aliases: readonly string[];
41
+ name: string;
42
+ description: string;
43
+ compatibility: ProviderCompatibility;
44
+ api: ProviderSetupApi;
45
+ providerId: string;
46
+ baseUrl: string;
47
+ apiKeyEnv: string;
48
+ models: readonly string[];
49
+ compat?: ProviderCompatConfig;
50
+ }
32
51
 
33
52
  const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
34
53
  const REDACT_PREFIX = 4;
35
54
  const REDACT_SUFFIX = 4;
55
+ // Preset compat values are onboarding snapshots for generated models.yml entries.
56
+ // Keep them aligned with provider descriptor behavior without importing descriptor internals into setup UX.
57
+ const MINIMAX_OPENAI_COMPAT: ProviderCompatConfig = {
58
+ supportsStore: false,
59
+ supportsDeveloperRole: false,
60
+ supportsReasoningEffort: false,
61
+ reasoningContentField: "reasoning_content",
62
+ };
63
+
64
+ const GLM_OPENAI_COMPAT: ProviderCompatConfig = {
65
+ supportsDeveloperRole: false,
66
+ supportsReasoningEffort: false,
67
+ thinkingFormat: "zai",
68
+ reasoningContentField: "reasoning_content",
69
+ };
70
+
71
+ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
72
+ {
73
+ id: "minimax",
74
+ aliases: ["minimax-code"],
75
+ name: "MiniMax Coding Plan",
76
+ description: "OpenAI-compatible MiniMax Coding Plan endpoint",
77
+ compatibility: "openai",
78
+ api: "openai-completions",
79
+ providerId: "minimax-code",
80
+ baseUrl: "https://api.minimax.io/v1",
81
+ apiKeyEnv: "MINIMAX_CODE_API_KEY",
82
+ models: ["MiniMax-M2.5"],
83
+ compat: MINIMAX_OPENAI_COMPAT,
84
+ },
85
+ {
86
+ id: "minimax-cn",
87
+ aliases: ["minimax-code-cn", "minimaxi"],
88
+ name: "MiniMax Coding Plan (China)",
89
+ description: "OpenAI-compatible MiniMax China endpoint",
90
+ compatibility: "openai",
91
+ api: "openai-completions",
92
+ providerId: "minimax-code-cn",
93
+ baseUrl: "https://api.minimaxi.com/v1",
94
+ apiKeyEnv: "MINIMAX_CODE_CN_API_KEY",
95
+ models: ["MiniMax-M2.5"],
96
+ compat: MINIMAX_OPENAI_COMPAT,
97
+ },
98
+ {
99
+ id: "glm",
100
+ aliases: ["zai", "z-ai", "bigmodel"],
101
+ name: "GLM / zAI",
102
+ description: "OpenAI-compatible GLM endpoint from zAI/BigModel",
103
+ compatibility: "openai",
104
+ api: "openai-completions",
105
+ providerId: "glm-proxy",
106
+ baseUrl: "https://api.z.ai/api/paas/v4",
107
+ apiKeyEnv: "ZAI_API_KEY",
108
+ models: ["glm-4.6"],
109
+ compat: GLM_OPENAI_COMPAT,
110
+ },
111
+ ];
36
112
 
37
113
  export function getDefaultModelsPath(): string {
38
114
  return path.join(getAgentDir(), "models.yml");
@@ -51,6 +127,19 @@ export function parseProviderCompatibility(value: string): ProviderCompatibility
51
127
  throw new Error("Provider compatibility must be 'openai' or 'anthropic'.");
52
128
  }
53
129
 
130
+ export function findProviderPreset(value: string | undefined): ProviderPreset | undefined {
131
+ const normalized = value?.trim().toLowerCase();
132
+ if (!normalized) return undefined;
133
+ return PROVIDER_PRESETS.find(preset => preset.id === normalized || preset.aliases.includes(normalized));
134
+ }
135
+
136
+ export function formatProviderPresetList(): string {
137
+ return PROVIDER_PRESETS.map(preset => {
138
+ const aliases = preset.aliases.length > 0 ? ` (aliases: ${preset.aliases.join(", ")})` : "";
139
+ return `${preset.id}${aliases}: ${preset.description}`;
140
+ }).join("\n");
141
+ }
142
+
54
143
  export function parseModelList(values: readonly string[]): string[] {
55
144
  const models = values
56
145
  .flatMap(value => value.split(","))
@@ -65,23 +154,82 @@ export function redactSecret(secret: string): string {
65
154
  return `${trimmed.slice(0, REDACT_PREFIX)}…${trimmed.slice(-REDACT_SUFFIX)}`;
66
155
  }
67
156
 
68
- function apiForCompatibility(compatibility: ProviderCompatibility): ProviderSetupResult["api"] {
157
+ function apiForCompatibility(compatibility: ProviderCompatibility): ProviderSetupApi {
69
158
  return compatibility === "openai" ? "openai-responses" : "anthropic-messages";
70
159
  }
71
160
 
161
+ function resolvePresetInput(input: ProviderSetupInput): {
162
+ compatibility: ProviderCompatibility;
163
+ preset?: ProviderPreset;
164
+ providerId?: string;
165
+ baseUrl?: string;
166
+ apiKey?: string;
167
+ apiKeyEnv?: string;
168
+ models: readonly string[];
169
+ api: ProviderSetupApi;
170
+ compat?: ProviderCompatConfig;
171
+ } {
172
+ const preset = input.preset ? findProviderPreset(input.preset) : undefined;
173
+ if (input.preset && !preset) {
174
+ throw new Error(`Unknown provider preset '${input.preset}'. Available presets:\n${formatProviderPresetList()}`);
175
+ }
176
+ if (preset && input.compatibility && input.compatibility !== preset.compatibility) {
177
+ throw new Error(
178
+ `Provider preset '${preset.id}' is ${preset.compatibility}-compatible; omit --compat or use '${preset.compatibility}'.`,
179
+ );
180
+ }
181
+ if (preset && input.baseUrl !== undefined) {
182
+ throw new Error(
183
+ `Provider preset '${preset.id}' uses a fixed base URL; omit --base-url or use --compat openai for a custom provider.`,
184
+ );
185
+ }
186
+ if (preset && input.models && input.models.length > 0) {
187
+ throw new Error(
188
+ `Provider preset '${preset.id}' uses fixed model ids; omit --model or use --compat openai for a custom provider.`,
189
+ );
190
+ }
191
+ if (preset && input.apiKeyEnv !== undefined && input.apiKeyEnv.trim() !== preset.apiKeyEnv) {
192
+ throw new Error(
193
+ `Provider preset '${preset.id}' uses ${preset.apiKeyEnv}; omit --api-key-env or use --compat openai for a custom provider.`,
194
+ );
195
+ }
196
+ const compatibility = preset?.compatibility ?? input.compatibility;
197
+ if (!compatibility) {
198
+ throw new Error("Provider compatibility is required unless --preset is used.");
199
+ }
200
+ return {
201
+ compatibility,
202
+ preset,
203
+ providerId: input.providerId ?? preset?.providerId,
204
+ baseUrl: input.baseUrl ?? preset?.baseUrl,
205
+ apiKey: input.apiKey,
206
+ apiKeyEnv: input.apiKeyEnv ?? preset?.apiKeyEnv,
207
+ models: input.models && input.models.length > 0 ? input.models : (preset?.models ?? []),
208
+ api: preset?.api ?? apiForCompatibility(compatibility),
209
+ compat: preset?.compat,
210
+ };
211
+ }
212
+
72
213
  function validateSetupInput(input: ProviderSetupInput): {
73
214
  providerId: string;
74
215
  baseUrl: string;
75
216
  apiKey: string;
76
217
  credentialSource: ProviderSetupResult["credentialSource"];
77
218
  models: string[];
219
+ compatibility: ProviderCompatibility;
220
+ api: ProviderSetupApi;
221
+ compat?: ProviderCompatConfig;
222
+ preset?: ProviderPreset;
78
223
  } {
79
- const providerId = normalizeProviderId(input.providerId);
224
+ const resolved = resolvePresetInput(input);
225
+ if (!resolved.providerId) throw new Error("Provider id is required.");
226
+ if (!resolved.baseUrl) throw new Error("Base URL is required.");
227
+ const providerId = normalizeProviderId(resolved.providerId);
80
228
  if (!PROVIDER_ID_PATTERN.test(providerId)) {
81
229
  throw new Error("Provider id must use lowercase letters, numbers, dots, underscores, or hyphens.");
82
230
  }
83
231
 
84
- const baseUrl = input.baseUrl.trim();
232
+ const baseUrl = resolved.baseUrl.trim();
85
233
  let url: URL;
86
234
  try {
87
235
  url = new URL(baseUrl);
@@ -95,19 +243,29 @@ function validateSetupInput(input: ProviderSetupInput): {
95
243
  throw new Error("Base URL must use https unless it targets localhost or a loopback address.");
96
244
  }
97
245
 
98
- const apiKeyEnv = input.apiKeyEnv?.trim();
246
+ const apiKeyEnv = resolved.apiKeyEnv?.trim();
99
247
  if (apiKeyEnv) {
100
248
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) {
101
249
  throw new Error("API key environment variable must be a valid environment variable name.");
102
250
  }
103
251
  }
104
- const apiKey = apiKeyEnv ?? input.apiKey?.trim() ?? "";
252
+ const apiKey = apiKeyEnv ?? resolved.apiKey?.trim() ?? "";
105
253
  if (!apiKey) throw new Error("API key is required.");
106
254
 
107
- const models = parseModelList(input.models);
255
+ const models = parseModelList(resolved.models);
108
256
  if (models.length === 0) throw new Error("At least one model id is required.");
109
257
 
110
- return { providerId, baseUrl, apiKey, credentialSource: apiKeyEnv ? "env" : "literal", models };
258
+ return {
259
+ providerId,
260
+ baseUrl,
261
+ apiKey,
262
+ credentialSource: apiKeyEnv ? "env" : "literal",
263
+ models,
264
+ compatibility: resolved.compatibility,
265
+ api: resolved.api,
266
+ compat: resolved.compat,
267
+ preset: resolved.preset,
268
+ };
111
269
  }
112
270
 
113
271
  async function readModelsConfig(modelsPath: string): Promise<ModelsConfig> {
@@ -140,16 +298,16 @@ export async function addApiCompatibleProvider(input: ProviderSetupInput): Promi
140
298
  const validated = validateSetupInput(input);
141
299
  const modelsPath = input.modelsPath ?? getDefaultModelsPath();
142
300
  const existing = await readModelsConfig(modelsPath);
143
- const api = apiForCompatibility(input.compatibility);
144
301
  if (existing.providers?.[validated.providerId] && !input.force) {
145
302
  throw new Error(`Provider '${validated.providerId}' already exists. Use --force to replace it.`);
146
303
  }
147
304
  const provider: ProviderConfig = {
148
305
  baseUrl: validated.baseUrl,
149
- api,
306
+ api: validated.api,
150
307
  auth: "apiKey",
151
308
  models: validated.models.map(id => ({ id })),
152
309
  };
310
+ if (validated.compat) provider.compat = validated.compat;
153
311
  if (validated.credentialSource === "env") {
154
312
  provider.apiKeyEnv = validated.apiKey;
155
313
  } else {
@@ -165,13 +323,15 @@ export async function addApiCompatibleProvider(input: ProviderSetupInput): Promi
165
323
  await writeModelsConfig(modelsPath, next);
166
324
  return {
167
325
  providerId: validated.providerId,
168
- compatibility: input.compatibility,
169
- api,
326
+ compatibility: validated.compatibility,
327
+ api: validated.api,
170
328
  baseUrl: validated.baseUrl,
171
329
  modelIds: validated.models,
172
330
  modelsPath,
173
331
  redactedApiKey: redactSecret(validated.apiKey),
174
332
  credentialSource: validated.credentialSource,
333
+ preset: validated.preset?.id,
334
+ presetName: validated.preset?.name,
175
335
  };
176
336
  }
177
337
 
@@ -189,6 +349,7 @@ function isLocalHttpHost(hostname: string): boolean {
189
349
  export function formatProviderSetupResult(result: ProviderSetupResult): string {
190
350
  return [
191
351
  `Provider '${result.providerId}' configured as ${result.compatibility}-compatible.`,
352
+ ...(result.presetName ? [`Preset: ${result.presetName}`] : []),
192
353
  `Models: ${result.modelIds.join(", ")}`,
193
354
  `Base URL: ${result.baseUrl}`,
194
355
  `API key: ${result.credentialSource === "env" ? `${result.redactedApiKey} (environment variable)` : result.redactedApiKey}`,
@@ -3,7 +3,6 @@ import * as path from "node:path";
3
3
  import type { WorkflowStateReceipt } from "./workflow-state-contract";
4
4
 
5
5
  export const SKILL_ACTIVE_STATE_FILE = "skill-active-state.json";
6
- export const SKILL_ACTIVE_STALE_MS = 24 * 60 * 60 * 1000;
7
6
 
8
7
  export const CANONICAL_GJC_WORKFLOW_SKILLS = ["deep-interview", "ralplan", "ultragoal", "team"] as const;
9
8
 
@@ -40,6 +39,9 @@ export interface SkillActiveEntry {
40
39
  hud?: WorkflowHudSummary;
41
40
  stale?: boolean;
42
41
  receipt?: WorkflowStateReceipt;
42
+ handoff_from?: string;
43
+ handoff_to?: string;
44
+ handoff_at?: string;
43
45
  }
44
46
 
45
47
  export interface SkillActiveState {
@@ -75,6 +77,9 @@ export interface SyncSkillActiveStateOptions {
75
77
  source?: string;
76
78
  hud?: WorkflowHudSummary;
77
79
  receipt?: WorkflowStateReceipt;
80
+ handoff_from?: string;
81
+ handoff_to?: string;
82
+ handoff_at?: string;
78
83
  }
79
84
 
80
85
  const HUD_TEXT_LIMIT = 80;
@@ -185,25 +190,6 @@ function entryKey(entry: Pick<SkillActiveEntry, "skill" | "session_id">): string
185
190
  return `${entry.skill}::${safeString(entry.session_id).trim()}`;
186
191
  }
187
192
 
188
- function timestampMs(value: string | undefined): number | null {
189
- if (!value) return null;
190
- const ms = Date.parse(value);
191
- return Number.isFinite(ms) ? ms : null;
192
- }
193
-
194
- function entryTimestampMs(entry: SkillActiveEntry): number | null {
195
- return timestampMs(entry.hud?.updated_at) ?? timestampMs(entry.updated_at) ?? timestampMs(entry.activated_at);
196
- }
197
-
198
- function isFreshEntry(entry: SkillActiveEntry, nowMs = Date.now()): boolean {
199
- const ms = entryTimestampMs(entry);
200
- return ms === null || nowMs - ms <= SKILL_ACTIVE_STALE_MS;
201
- }
202
-
203
- function withDerivedStale(entry: SkillActiveEntry, nowMs = Date.now()): SkillActiveEntry {
204
- return { ...entry, stale: !isFreshEntry(entry, nowMs) };
205
- }
206
-
207
193
  function normalizeEntry(raw: unknown): SkillActiveEntry | null {
208
194
  if (!raw || typeof raw !== "object") return null;
209
195
  const record = raw as Record<string, unknown>;
@@ -221,6 +207,9 @@ function normalizeEntry(raw: unknown): SkillActiveEntry | null {
221
207
  session_id: safeString(record.session_id).trim() || undefined,
222
208
  thread_id: safeString(record.thread_id).trim() || undefined,
223
209
  turn_id: safeString(record.turn_id).trim() || undefined,
210
+ handoff_from: safeString(record.handoff_from).trim() || undefined,
211
+ handoff_to: safeString(record.handoff_to).trim() || undefined,
212
+ handoff_at: safeString(record.handoff_at).trim() || undefined,
224
213
  ...(hud ? { hud } : {}),
225
214
  ...(receipt ? { receipt } : {}),
226
215
  stale: undefined,
@@ -305,6 +294,48 @@ async function readStateFile(filePath: string): Promise<SkillActiveState | null>
305
294
  }
306
295
  }
307
296
 
297
+ /**
298
+ * Raw read for handoff mutations. Returns the *unnormalized* parsed object so
299
+ * inactive entries remain visible to `rawActiveEntries` — `normalizeSkillActiveState`
300
+ * delegates to `listActiveSkills`, which filters out `active:false` rows for HUD
301
+ * purposes. Handoff history (e.g. previously demoted callers carrying
302
+ * `handoff_to`/`handoff_at` lineage) must survive across successive handoffs,
303
+ * so the on-disk `active_skills` array is preserved verbatim and the next
304
+ * write recomputes the per-skill row from there.
305
+ *
306
+ * Strict semantics: tolerates ENOENT only. Corrupt JSON / non-ENOENT I/O
307
+ * errors propagate so callers can surface a non-zero CLI status.
308
+ */
309
+ async function readRawActiveStateForHandoff(filePath: string, strict: boolean): Promise<SkillActiveState | null> {
310
+ let raw: string;
311
+ try {
312
+ raw = await Bun.file(filePath).text();
313
+ } catch (err) {
314
+ const code = (err as NodeJS.ErrnoException).code;
315
+ if (code === "ENOENT") return null;
316
+ if (!strict) return null;
317
+ throw err;
318
+ }
319
+ try {
320
+ const parsed = JSON.parse(raw);
321
+ if (!parsed || typeof parsed !== "object") return null;
322
+ return parsed as SkillActiveState;
323
+ } catch (err) {
324
+ if (!strict) return null;
325
+ throw err;
326
+ }
327
+ }
328
+
329
+ function rawActiveEntries(state: SkillActiveState | null): SkillActiveEntry[] {
330
+ if (!state || !Array.isArray(state.active_skills)) return [];
331
+ const out: SkillActiveEntry[] = [];
332
+ for (const candidate of state.active_skills) {
333
+ const normalized = normalizeEntry(candidate);
334
+ if (normalized) out.push(normalized);
335
+ }
336
+ return out;
337
+ }
338
+
308
339
  function filterRootEntriesForSession(entries: SkillActiveEntry[], sessionId?: string): SkillActiveEntry[] {
309
340
  const normalizedSessionId = safeString(sessionId).trim();
310
341
  if (!normalizedSessionId) return entries;
@@ -319,12 +350,9 @@ function mergeVisibleEntries(
319
350
  rootState: SkillActiveState | null,
320
351
  sessionId?: string,
321
352
  ): SkillActiveEntry[] {
322
- const nowMs = Date.now();
323
- const rootEntries = filterRootEntriesForSession(listActiveSkills(rootState), sessionId).map(entry =>
324
- withDerivedStale(entry, nowMs),
325
- );
353
+ const rootEntries = filterRootEntriesForSession(listActiveSkills(rootState), sessionId);
326
354
  const merged = new Map(rootEntries.map(entry => [entryKey(entry), entry]));
327
- for (const entry of listActiveSkills(sessionState).map(candidate => withDerivedStale(candidate, nowMs))) {
355
+ for (const entry of listActiveSkills(sessionState)) {
328
356
  merged.set(entryKey(entry), entry);
329
357
  }
330
358
  return [...merged.values()];
@@ -374,6 +402,9 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
374
402
  session_id: options.sessionId,
375
403
  thread_id: options.threadId,
376
404
  turn_id: options.turnId,
405
+ ...(options.handoff_from ? { handoff_from: options.handoff_from } : {}),
406
+ ...(options.handoff_to ? { handoff_to: options.handoff_to } : {}),
407
+ ...(options.handoff_at ? { handoff_at: options.handoff_at } : {}),
377
408
  ...(hud ? { hud } : {}),
378
409
  ...(options.receipt ? { receipt: options.receipt } : {}),
379
410
  };
@@ -408,3 +439,97 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
408
439
  };
409
440
  await writeStateFile(sessionPath, nextSession);
410
441
  }
442
+
443
+ export interface ApplyHandoffOptions {
444
+ cwd: string;
445
+ caller: SyncSkillActiveStateOptions;
446
+ callee: SyncSkillActiveStateOptions;
447
+ /** Shared timestamp; falls back to new Date().toISOString(). */
448
+ nowIso?: string;
449
+ /** When true, read errors other than ENOENT propagate. */
450
+ strict?: boolean;
451
+ }
452
+
453
+ /**
454
+ * Atomically apply a workflow-skill handoff to both the session-scoped and
455
+ * root `skill-active-state.json` files in a single write per file.
456
+ *
457
+ * Write order: **session first, root last**. The session file is the
458
+ * source of truth for HUD; the root aggregate must never lead the session
459
+ * during a handoff window. Each file is rewritten once with caller demoted
460
+ * to `active:false` (preserving `handoff_to`/`handoff_at` lineage) and
461
+ * callee promoted to `active:true` (with `handoff_from`/`handoff_at`).
462
+ */
463
+ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): Promise<void> {
464
+ const nowIso = options.nowIso ?? new Date().toISOString();
465
+ const callerEntry = buildSyncEntry(options.caller, nowIso);
466
+ const calleeEntry = buildSyncEntry(options.callee, nowIso);
467
+ const sessionId = options.callee.sessionId ?? options.caller.sessionId;
468
+ const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId);
469
+ const readState = (filePath: string) => readRawActiveStateForHandoff(filePath, options.strict === true);
470
+
471
+ const applyEntries = (entries: SkillActiveEntry[]): SkillActiveEntry[] => {
472
+ const callerKey = entryKey(callerEntry);
473
+ const calleeKey = entryKey(calleeEntry);
474
+ const priorCaller = entries.find(e => entryKey(e) === callerKey);
475
+ const kept = entries.filter(e => entryKey(e) !== callerKey && entryKey(e) !== calleeKey);
476
+ // Merge prior lineage into the demoted caller so multi-step handoff
477
+ // chains preserve `handoff_from` from the previous transition while
478
+ // the new `handoff_to`/`handoff_at` describe this one.
479
+ const mergedCaller: SkillActiveEntry = priorCaller
480
+ ? {
481
+ ...callerEntry,
482
+ ...(priorCaller.handoff_from && !callerEntry.handoff_from
483
+ ? { handoff_from: priorCaller.handoff_from }
484
+ : {}),
485
+ }
486
+ : callerEntry;
487
+ return [...kept, mergedCaller, calleeEntry];
488
+ };
489
+ const buildNextState = (
490
+ prior: SkillActiveState | null,
491
+ entries: SkillActiveEntry[],
492
+ scope: "session" | "root",
493
+ ): SkillActiveState => {
494
+ const visible = entries.filter(e => e.active !== false);
495
+ return {
496
+ ...(prior ?? {}),
497
+ version: 1,
498
+ active: visible.length > 0,
499
+ skill: visible[0]?.skill ?? "",
500
+ phase: visible[0]?.phase ?? "",
501
+ ...(scope === "session" ? { session_id: sessionId } : {}),
502
+ updated_at: nowIso,
503
+ source: options.callee.source ?? options.caller.source,
504
+ active_skills: entries,
505
+ };
506
+ };
507
+
508
+ if (sessionPath) {
509
+ const prior = await readState(sessionPath);
510
+ const next = buildNextState(prior, applyEntries(rawActiveEntries(prior)), "session");
511
+ await writeStateFile(sessionPath, next);
512
+ }
513
+ const priorRoot = await readState(rootPath);
514
+ const nextRoot = buildNextState(priorRoot, applyEntries(rawActiveEntries(priorRoot)), "root");
515
+ await writeStateFile(rootPath, nextRoot);
516
+ }
517
+
518
+ function buildSyncEntry(options: SyncSkillActiveStateOptions, nowIso: string): SkillActiveEntry {
519
+ const hud = normalizeWorkflowHudSummary(options.hud);
520
+ return {
521
+ skill: options.skill,
522
+ phase: options.phase,
523
+ active: options.active,
524
+ activated_at: nowIso,
525
+ updated_at: nowIso,
526
+ session_id: options.sessionId,
527
+ thread_id: options.threadId,
528
+ turn_id: options.turnId,
529
+ ...(options.handoff_from ? { handoff_from: options.handoff_from } : {}),
530
+ ...(options.handoff_to ? { handoff_to: options.handoff_to } : {}),
531
+ ...(options.handoff_at ? { handoff_at: options.handoff_at } : {}),
532
+ ...(hud ? { hud } : {}),
533
+ ...(options.receipt ? { receipt: options.receipt } : {}),
534
+ };
535
+ }
@@ -12,7 +12,7 @@ import {
12
12
  } from "./workflow-state-contract";
13
13
 
14
14
  export const DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE =
15
- "Deep-interview is active; continue interviewing with `ask`, write/finalize pending specs through the required GJC workflow CLI, or use an explicit force override. Direct `.gjc/` and product-code edits are blocked until explicit execution approval.";
15
+ "Deep-interview phase boundary: continue gathering context/questions/risks and emit a handoff/spec before code edits. Mutation tools and patch execution are blocked while deep-interview is active; finalize specs through `gjc deep-interview --write --stage final` or hand off to an execution phase.";
16
16
  export const WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE =
17
17
  "Workflow state JSON is runtime-owned. Use `gjc state <skill> read|write --input '<json>'` for deep-interview, ralplan, ultragoal, and team. Planning artifacts under `.gjc/specs/` and `.gjc/plans/` remain allowed.";
18
18
 
@@ -88,7 +88,7 @@ async function readVisibleModeState(cwd: string, skill: string, sessionId?: stri
88
88
  }
89
89
 
90
90
  function isTerminalModeState(state: ModeState | null): boolean {
91
- if (!state || state.active !== true) return true;
91
+ if (state?.active !== true) return true;
92
92
  const phase = String(state.current_phase ?? "")
93
93
  .trim()
94
94
  .toLowerCase();
@@ -265,7 +265,7 @@ function relativeGjcSegments(cwd: string, rawPath: string): string[] | null {
265
265
 
266
266
  function blockedWorkflowStateSkill(cwd: string, rawPath: string): CanonicalGjcWorkflowSkill | null {
267
267
  const segments = relativeGjcSegments(cwd, rawPath);
268
- if (!segments || segments[0] !== ".gjc") return null;
268
+ if (segments?.[0] !== ".gjc") return null;
269
269
  if (segments[1] === "specs" || segments[1] === "plans") return null;
270
270
  if (segments[1] !== "state") return null;
271
271
  const fileName = segments.at(-1) ?? "";
@@ -284,22 +284,12 @@ function firstBlockedWorkflowStateSkill(cwd: string, targets: ExtractedTargets):
284
284
  return null;
285
285
  }
286
286
 
287
- function isGjcManagedPath(cwd: string, rawPath: string): boolean {
288
- const segments = relativeGjcSegments(cwd, rawPath);
289
- return segments?.[0] === ".gjc";
290
- }
291
-
292
287
  function isAllowlistedPath(cwd: string, rawPath: string): boolean {
293
288
  const segments = relativeGjcSegments(cwd, rawPath);
294
- if (!segments || segments[0] !== ".gjc") return false;
289
+ if (segments?.[0] !== ".gjc") return false;
295
290
  return segments[1] === "specs" || segments[1] === "plans";
296
291
  }
297
292
 
298
- function hasGjcManagedTarget(cwd: string, targets: ExtractedTargets): boolean {
299
- if (targets.unknown || targets.paths.length === 0) return false;
300
- return targets.paths.some(rawPath => isGjcManagedPath(cwd, rawPath));
301
- }
302
-
303
293
  function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
304
294
  return (
305
295
  !targets.unknown && targets.paths.length > 0 && targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath))
@@ -315,7 +305,7 @@ export async function assertDeepInterviewMutationRawPathsAllowed(input: {
315
305
  if (input.forceOverride) return;
316
306
  if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) return;
317
307
  const targets: ExtractedTargets = { paths: input.rawPaths, unknown: input.rawPaths.length === 0 };
318
- if (hasGjcManagedTarget(input.cwd, targets)) {
308
+ if (targets.unknown || targets.paths.length > 0) {
319
309
  throw new ToolError(DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE);
320
310
  }
321
311
  }
@@ -350,15 +340,12 @@ export async function getDeepInterviewMutationDecision(
350
340
  reason: "unknown-target",
351
341
  };
352
342
  }
353
- if (hasGjcManagedTarget(input.cwd, targets) && !allTargetsAllowlisted(input.cwd, targets)) {
354
- return {
355
- blocked: true,
356
- message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
357
- targets: targets.paths,
358
- reason: "gjc-managed-target",
359
- };
360
- }
361
- return { blocked: false, targets: targets.paths };
343
+ return {
344
+ blocked: true,
345
+ message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
346
+ targets: targets.paths,
347
+ reason: allTargetsAllowlisted(input.cwd, targets) ? "handoff-artifact-tool-target" : "phase-boundary",
348
+ };
362
349
  }
363
350
 
364
351
  export async function assertDeepInterviewMutationAllowed(input: DeepInterviewMutationGuardInput): Promise<void> {
@@ -0,0 +1,17 @@
1
+ import type { CanonicalGjcWorkflowSkill } from "./active-state";
2
+
3
+ /**
4
+ * Canonical initial phase for each GJC workflow skill. Used by both
5
+ * `recordSkillActivation` (UserPromptSubmit hook seeding initial mode-state)
6
+ * and the `gjc state <caller> handoff --to <callee>` runtime when promoting
7
+ * the callee.
8
+ *
9
+ * Keeping this mapping in a neutral skill-state module avoids cycles between
10
+ * `gjc-runtime/state-runtime.ts` and `hooks/skill-state.ts` (which pulls in
11
+ * session-manager and ultragoal verification code).
12
+ */
13
+ export function initialPhaseForSkill(skill: CanonicalGjcWorkflowSkill | string): string {
14
+ if (skill === "deep-interview") return "interviewing";
15
+ if (skill === "ultragoal") return "goal-planning";
16
+ return "planning";
17
+ }