@gajae-code/coding-agent 0.2.2 → 0.2.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 (96) hide show
  1. package/CHANGELOG.md +45 -8600
  2. package/dist/types/cli/setup-cli.d.ts +1 -0
  3. package/dist/types/cli/update-cli.d.ts +3 -0
  4. package/dist/types/commands/deep-interview.d.ts +41 -0
  5. package/dist/types/commands/setup.d.ts +3 -0
  6. package/dist/types/config/settings-schema.d.ts +56 -0
  7. package/dist/types/defaults/gjc-defaults.d.ts +19 -6
  8. package/dist/types/discovery/helpers.d.ts +2 -0
  9. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  10. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +18 -0
  11. package/dist/types/hooks/skill-state.d.ts +5 -0
  12. package/dist/types/memories/index.d.ts +1 -1
  13. package/dist/types/memory-backend/local-backend.d.ts +3 -3
  14. package/dist/types/modes/components/hook-selector.d.ts +7 -0
  15. package/dist/types/modes/components/settings-selector.d.ts +3 -1
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +1 -0
  18. package/dist/types/modes/theme/defaults/index.d.ts +126 -0
  19. package/dist/types/modes/theme/theme.d.ts +5 -0
  20. package/dist/types/modes/types.d.ts +1 -0
  21. package/dist/types/modes/utils/context-usage.d.ts +6 -2
  22. package/dist/types/sdk.d.ts +6 -2
  23. package/dist/types/session/agent-session.d.ts +45 -1
  24. package/dist/types/session/session-manager.d.ts +3 -0
  25. package/dist/types/setup/model-onboarding-guidance.d.ts +1 -0
  26. package/dist/types/setup/provider-onboarding.d.ts +29 -5
  27. package/dist/types/skill-state/active-state.d.ts +26 -1
  28. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  29. package/dist/types/skill-state/initial-phase.d.ts +12 -0
  30. package/dist/types/task/executor.d.ts +2 -0
  31. package/dist/types/task/types.d.ts +11 -0
  32. package/dist/types/tools/index.d.ts +20 -1
  33. package/dist/types/tools/skill.d.ts +47 -0
  34. package/dist/types/utils/changelog.d.ts +18 -2
  35. package/package.json +7 -7
  36. package/src/cli/setup-cli.ts +26 -12
  37. package/src/cli/update-cli.ts +67 -16
  38. package/src/cli.ts +1 -0
  39. package/src/commands/deep-interview.ts +25 -2
  40. package/src/commands/setup.ts +2 -0
  41. package/src/commands/state.ts +1 -0
  42. package/src/config/settings-schema.ts +63 -0
  43. package/src/defaults/gjc/skills/deep-interview/SKILL.md +58 -5
  44. package/src/defaults/gjc/skills/deep-interview/auto-answer-uncertain.md +37 -0
  45. package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +42 -0
  46. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -0
  47. package/src/defaults/gjc/skills/team/SKILL.md +10 -0
  48. package/src/defaults/gjc/skills/ultragoal/SKILL.md +19 -6
  49. package/src/defaults/gjc-defaults.ts +68 -16
  50. package/src/discovery/helpers.ts +24 -1
  51. package/src/extensibility/extensions/types.ts +6 -0
  52. package/src/gjc-runtime/deep-interview-runtime.ts +312 -1
  53. package/src/gjc-runtime/state-runtime.ts +175 -5
  54. package/src/goals/tools/goal-tool.ts +5 -1
  55. package/src/hooks/skill-state.ts +8 -6
  56. package/src/internal-urls/docs-index.generated.ts +6 -4
  57. package/src/internal-urls/memory-protocol.ts +3 -2
  58. package/src/main.ts +2 -3
  59. package/src/memories/index.ts +6 -4
  60. package/src/memory-backend/local-backend.ts +14 -6
  61. package/src/modes/components/hook-selector.ts +156 -1
  62. package/src/modes/components/settings-selector.ts +16 -12
  63. package/src/modes/controllers/command-controller.ts +3 -4
  64. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  65. package/src/modes/controllers/selector-controller.ts +69 -9
  66. package/src/modes/interactive-mode.ts +14 -1
  67. package/src/modes/theme/defaults/blue-crab.json +126 -0
  68. package/src/modes/theme/defaults/index.ts +2 -0
  69. package/src/modes/theme/theme.ts +40 -1
  70. package/src/modes/types.ts +1 -0
  71. package/src/modes/utils/context-usage.ts +66 -17
  72. package/src/prompts/agents/architect.md +3 -0
  73. package/src/prompts/agents/executor.md +2 -0
  74. package/src/prompts/agents/frontmatter.md +1 -0
  75. package/src/prompts/memories/unavailable.md +9 -0
  76. package/src/prompts/system/subagent-system-prompt.md +6 -0
  77. package/src/prompts/tools/skill.md +28 -0
  78. package/src/prompts/tools/task.md +3 -0
  79. package/src/sdk.ts +54 -10
  80. package/src/session/agent-session.ts +204 -21
  81. package/src/session/session-manager.ts +9 -1
  82. package/src/setup/model-onboarding-guidance.ts +6 -3
  83. package/src/setup/provider-onboarding.ts +177 -16
  84. package/src/skill-state/active-state.ts +150 -25
  85. package/src/skill-state/deep-interview-mutation-guard.ts +11 -24
  86. package/src/skill-state/initial-phase.ts +17 -0
  87. package/src/slash-commands/builtin-registry.ts +62 -14
  88. package/src/slash-commands/helpers/context-report.ts +123 -13
  89. package/src/task/agents.ts +1 -0
  90. package/src/task/executor.ts +9 -1
  91. package/src/task/index.ts +91 -4
  92. package/src/task/types.ts +6 -0
  93. package/src/tools/ask.ts +2 -0
  94. package/src/tools/index.ts +23 -1
  95. package/src/tools/skill.ts +153 -0
  96. package/src/utils/changelog.ts +67 -44
@@ -1,5 +1,9 @@
1
1
  import * as path from "node:path";
2
2
  import { getAgentDir, isEnoent, parseFrontmatter } from "@gajae-code/utils";
3
+ import autoAnswerUncertainFragment from "./gjc/skills/deep-interview/auto-answer-uncertain.md" with { type: "text" };
4
+ import autoResearchGreenfieldFragment from "./gjc/skills/deep-interview/auto-research-greenfield.md" with {
5
+ type: "text",
6
+ };
3
7
  import deepInterviewSkill from "./gjc/skills/deep-interview/SKILL.md" with { type: "text" };
4
8
  import ralplanSkill from "./gjc/skills/ralplan/SKILL.md" with { type: "text" };
5
9
  import teamSkill from "./gjc/skills/team/SKILL.md" with { type: "text" };
@@ -7,7 +11,7 @@ import ultragoalSkill from "./gjc/skills/ultragoal/SKILL.md" with { type: "text"
7
11
 
8
12
  export const DEFAULT_GJC_DEFINITION_NAMES = ["deep-interview", "ralplan", "team", "ultragoal"] as const;
9
13
  export type DefaultGjcDefinitionName = (typeof DEFAULT_GJC_DEFINITION_NAMES)[number];
10
- export type DefaultGjcDefinitionKind = "skill";
14
+ export type DefaultGjcDefinitionKind = "skill" | "skill-fragment";
11
15
  export type EmbeddedDefaultGjcSkill = {
12
16
  name: DefaultGjcDefinitionName;
13
17
  description: string;
@@ -19,25 +23,41 @@ export type EmbeddedDefaultGjcSkill = {
19
23
  };
20
24
  export type DefaultGjcInstallStatus = "different" | "matching" | "missing" | "skipped" | "written";
21
25
 
22
- export interface DefaultGjcDefinition {
23
- kind: DefaultGjcDefinitionKind;
26
+ export interface DefaultGjcSkillDefinition {
27
+ kind: "skill";
24
28
  name: DefaultGjcDefinitionName;
25
29
  relativePath: string;
26
30
  content: string;
27
31
  }
28
32
 
33
+ export interface DefaultGjcSkillFragmentDefinition {
34
+ kind: "skill-fragment";
35
+ parentSkillName: DefaultGjcDefinitionName;
36
+ relativePath: string;
37
+ content: string;
38
+ }
39
+
40
+ export type DefaultGjcDefinition = DefaultGjcSkillDefinition | DefaultGjcSkillFragmentDefinition;
41
+
29
42
  export interface InstallDefaultGjcDefinitionsOptions {
30
43
  check?: boolean;
31
44
  force?: boolean;
32
45
  targetRoot?: string;
33
46
  }
34
47
 
35
- export interface DefaultGjcDefinitionInstallFile {
36
- kind: DefaultGjcDefinitionKind;
37
- name: DefaultGjcDefinitionName;
38
- path: string;
39
- status: DefaultGjcInstallStatus;
40
- }
48
+ export type DefaultGjcDefinitionInstallFile =
49
+ | {
50
+ kind: "skill";
51
+ name: DefaultGjcDefinitionName;
52
+ path: string;
53
+ status: DefaultGjcInstallStatus;
54
+ }
55
+ | {
56
+ kind: "skill-fragment";
57
+ parentSkillName: DefaultGjcDefinitionName;
58
+ path: string;
59
+ status: DefaultGjcInstallStatus;
60
+ };
41
61
 
42
62
  export interface DefaultGjcDefinitionInstallResult {
43
63
  targetRoot: string;
@@ -60,6 +80,18 @@ const DEFAULT_GJC_DEFINITIONS: readonly DefaultGjcDefinition[] = [
60
80
  { kind: "skill", name: "ralplan", relativePath: "skills/ralplan/SKILL.md", content: ralplanSkill },
61
81
  { kind: "skill", name: "team", relativePath: "skills/team/SKILL.md", content: teamSkill },
62
82
  { kind: "skill", name: "ultragoal", relativePath: "skills/ultragoal/SKILL.md", content: ultragoalSkill },
83
+ {
84
+ kind: "skill-fragment",
85
+ parentSkillName: "deep-interview",
86
+ relativePath: "skill-fragments/deep-interview/auto-research-greenfield.md",
87
+ content: autoResearchGreenfieldFragment,
88
+ },
89
+ {
90
+ kind: "skill-fragment",
91
+ parentSkillName: "deep-interview",
92
+ relativePath: "skill-fragments/deep-interview/auto-answer-uncertain.md",
93
+ content: autoAnswerUncertainFragment,
94
+ },
63
95
  ];
64
96
 
65
97
  export function getDefaultGjcDefinitions(): readonly DefaultGjcDefinition[] {
@@ -70,8 +102,19 @@ export function getDefaultGjcAgentDefinitions(): readonly DefaultGjcDefinition[]
70
102
  return [];
71
103
  }
72
104
 
105
+ export function getEmbeddedDefaultGjcSkillFragments(
106
+ parentSkillName: DefaultGjcDefinitionName,
107
+ ): DefaultGjcSkillFragmentDefinition[] {
108
+ return DEFAULT_GJC_DEFINITIONS.filter(
109
+ (definition): definition is DefaultGjcSkillFragmentDefinition =>
110
+ definition.kind === "skill-fragment" && definition.parentSkillName === parentSkillName,
111
+ );
112
+ }
113
+
73
114
  export function getEmbeddedDefaultGjcSkills(): EmbeddedDefaultGjcSkill[] {
74
- return DEFAULT_GJC_DEFINITIONS.filter(definition => definition.kind === "skill").map(definition => {
115
+ return DEFAULT_GJC_DEFINITIONS.filter(
116
+ (definition): definition is DefaultGjcSkillDefinition => definition.kind === "skill",
117
+ ).map(definition => {
75
118
  const { frontmatter } = parseFrontmatter(definition.content, {
76
119
  source: `embedded:gjc/${definition.relativePath}`,
77
120
  level: "warn",
@@ -110,12 +153,21 @@ export async function installDefaultGjcDefinitions(
110
153
  status = "written";
111
154
  }
112
155
 
113
- files.push({
114
- kind: definition.kind,
115
- name: definition.name,
116
- path: destination,
117
- status,
118
- });
156
+ if (definition.kind === "skill") {
157
+ files.push({
158
+ kind: definition.kind,
159
+ name: definition.name,
160
+ path: destination,
161
+ status,
162
+ });
163
+ } else {
164
+ files.push({
165
+ kind: definition.kind,
166
+ parentSkillName: definition.parentSkillName,
167
+ path: destination,
168
+ status,
169
+ });
170
+ }
119
171
  }
120
172
 
121
173
  return summarizeInstallResult(targetRoot, files);
@@ -8,6 +8,7 @@ import {
8
8
  getConfigDirName,
9
9
  getPluginsDir,
10
10
  getProjectDir,
11
+ logger,
11
12
  parseFrontmatter,
12
13
  tryParseJson,
13
14
  } from "@gajae-code/utils";
@@ -16,6 +17,7 @@ import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../ca
16
17
  import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
17
18
  import type { Skill, SkillFrontmatter } from "../capability/skill";
18
19
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
20
+ import type { ForkContextPolicy } from "../task/types";
19
21
  import { parseThinkingLevel } from "../thinking";
20
22
 
21
23
  import { buildPluginDirRoot } from "./plugin-dir-roots";
@@ -214,6 +216,7 @@ export interface ParsedAgentFields {
214
216
  autoloadSkills?: string[];
215
217
  blocking?: boolean;
216
218
  hide?: boolean;
219
+ forkContext?: ForkContextPolicy;
217
220
  }
218
221
 
219
222
  /**
@@ -267,10 +270,30 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
267
270
  const model = parseModelList(frontmatter.model);
268
271
  const blocking = parseBoolean(frontmatter.blocking);
269
272
  const hide = parseBoolean(frontmatter.hide);
273
+ const forkContext = parseForkContextPolicy(frontmatter.forkContext);
270
274
  const autoloadSkills = parseArrayOrCSV(frontmatter.autoloadSkills)
271
275
  ?.map(s => s.trim())
272
276
  .filter(Boolean);
273
- return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills, hide };
277
+ return {
278
+ name,
279
+ description,
280
+ tools,
281
+ spawns,
282
+ model,
283
+ output,
284
+ thinkingLevel,
285
+ blocking,
286
+ autoloadSkills,
287
+ hide,
288
+ forkContext,
289
+ };
290
+ }
291
+
292
+ function parseForkContextPolicy(value: unknown): ForkContextPolicy | undefined {
293
+ if (value === undefined) return undefined;
294
+ if (value === "forbidden" || value === "allowed") return value;
295
+ logger.warn("Invalid agent forkContext frontmatter; expected 'allowed' or 'forbidden', ignoring", { value });
296
+ return undefined;
274
297
  }
275
298
 
276
299
  async function globIf(
@@ -110,6 +110,12 @@ export interface ExtensionUIDialogOptions {
110
110
  onExternalEditor?: () => void;
111
111
  /** Optional footer hint text rendered by interactive selector */
112
112
  helpText?: string;
113
+ /**
114
+ * For interactive TUI select dialogs, render the focused option across
115
+ * multiple rows instead of truncating it. This is a select-only rendering
116
+ * hint; non-TUI bridges (RPC, ACP) drop it and do not serialize it.
117
+ */
118
+ wrapFocused?: boolean;
113
119
  }
114
120
 
115
121
  /** Raw terminal input listener for extensions. */
@@ -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,15 +75,108 @@ 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;
70
131
  thresholdSource: string;
71
132
  sessionId?: string;
72
133
  idea: string;
134
+ language?: DeepInterviewLanguagePreference;
73
135
  json: boolean;
74
136
  }
75
137
 
138
+ interface DeepInterviewLanguagePreference {
139
+ code: "en" | "ko";
140
+ label: "English" | "Korean";
141
+ source: "explicit-user-request" | "initial-idea";
142
+ instruction: string;
143
+ }
144
+
145
+ export interface ResolvedDeepInterviewSpecWriteArgs {
146
+ stage: "final";
147
+ slug: string;
148
+ spec: string;
149
+ sessionId?: string;
150
+ json: boolean;
151
+ deliberate: boolean;
152
+ handoff?: "ralplan";
153
+ }
154
+
155
+ export interface PersistedDeepInterviewSpec {
156
+ slug: string;
157
+ path: string;
158
+ stage: "final";
159
+ sha256: string;
160
+ createdAt: string;
161
+ statePath: string;
162
+ }
163
+
164
+ interface DeepInterviewSpecWriteSummary {
165
+ skill: "deep-interview";
166
+ stage: "final";
167
+ slug: string;
168
+ path: string;
169
+ sha256: string;
170
+ created_at: string;
171
+ state_path: string;
172
+ handoff?: {
173
+ to: "ralplan";
174
+ mode: "deliberate";
175
+ state_path?: string;
176
+ run_id?: string;
177
+ };
178
+ }
179
+
76
180
  async function readSettingsAmbiguityThreshold(
77
181
  settingsPath: string,
78
182
  ): Promise<{ threshold: number; source: string } | undefined> {
@@ -109,6 +213,97 @@ async function resolveConfiguredAmbiguityThreshold(
109
213
  return await readSettingsAmbiguityThreshold(userSettings);
110
214
  }
111
215
 
216
+ function englishLanguagePreference(): DeepInterviewLanguagePreference {
217
+ return {
218
+ code: "en",
219
+ label: "English",
220
+ source: "explicit-user-request",
221
+ instruction:
222
+ "Ask every user-facing deep-interview question in English because the user explicitly requested English.",
223
+ };
224
+ }
225
+
226
+ function resolveDeepInterviewLanguagePreference(idea: string): DeepInterviewLanguagePreference | undefined {
227
+ if (/\b(?:answer|ask|respond|reply|write|use|speak)\s+(?:only\s+)?in\s+English\b/i.test(idea)) {
228
+ return englishLanguagePreference();
229
+ }
230
+ if (/(?:영어로|영문으로|영어\s*(?:질문|답변|응답)|English\s+only)/i.test(idea)) {
231
+ return englishLanguagePreference();
232
+ }
233
+ if (/\p{Script=Hangul}/u.test(idea)) {
234
+ return {
235
+ code: "ko",
236
+ label: "Korean",
237
+ source: "initial-idea",
238
+ instruction:
239
+ "Ask every user-facing deep-interview question in Korean unless the user explicitly requests another language.",
240
+ };
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ function isDeepInterviewSpecWriteInvocation(args: readonly string[]): boolean {
246
+ return hasFlag(args, "--write");
247
+ }
248
+
249
+ async function resolveSpecWriteArgs(args: readonly string[], cwd: string): Promise<ResolvedDeepInterviewSpecWriteArgs> {
250
+ const stage = flagValue(args, "--stage")?.trim() || "final";
251
+ if (stage !== "final") {
252
+ throw new DeepInterviewCommandError(2, 'unknown --stage for deep-interview --write: expected "final"');
253
+ }
254
+
255
+ const slug = flagValue(args, "--slug")?.trim() || defaultSpecSlug();
256
+ assertSafePathComponent(slug, "slug");
257
+
258
+ const rawSpec = flagValue(args, "--spec");
259
+ if (rawSpec === undefined || rawSpec === "") {
260
+ throw new DeepInterviewCommandError(2, "--spec is required for deep-interview --write");
261
+ }
262
+
263
+ const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
264
+ if (sessionId) assertSafePathComponent(sessionId, "session-id");
265
+
266
+ const rawHandoff = flagValue(args, "--handoff")?.trim() || undefined;
267
+ if (rawHandoff && rawHandoff !== "ralplan") {
268
+ throw new DeepInterviewCommandError(2, 'unknown --handoff target: expected "ralplan"');
269
+ }
270
+
271
+ const allowedFlags = new Set([
272
+ "--write",
273
+ "--stage",
274
+ "--slug",
275
+ "--spec",
276
+ "--session-id",
277
+ "--handoff",
278
+ "--deliberate",
279
+ "--json",
280
+ ]);
281
+ let skipNext = false;
282
+ for (const arg of args) {
283
+ if (skipNext) {
284
+ skipNext = false;
285
+ continue;
286
+ }
287
+ if (["--stage", "--slug", "--spec", "--session-id", "--handoff"].includes(arg)) {
288
+ skipNext = true;
289
+ continue;
290
+ }
291
+ if (arg.startsWith("-") && !allowedFlags.has(arg)) {
292
+ throw new DeepInterviewCommandError(2, `unknown flag for gjc deep-interview --write: ${arg}`);
293
+ }
294
+ }
295
+
296
+ return {
297
+ stage: "final",
298
+ slug,
299
+ spec: await resolveSpecContent(rawSpec, cwd),
300
+ sessionId,
301
+ json: hasFlag(args, "--json"),
302
+ deliberate: hasFlag(args, "--deliberate"),
303
+ handoff: rawHandoff as "ralplan" | undefined,
304
+ };
305
+ }
306
+
112
307
  async function resolveDeepInterviewArgs(args: readonly string[], cwd: string): Promise<ResolvedDeepInterviewArgs> {
113
308
  const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
114
309
  if (sessionId) assertSafePathComponent(sessionId, "session-id");
@@ -169,10 +364,62 @@ async function resolveDeepInterviewArgs(args: readonly string[], cwd: string): P
169
364
  thresholdSource,
170
365
  sessionId,
171
366
  idea,
367
+ language: resolveDeepInterviewLanguagePreference(idea),
172
368
  json: hasFlag(args, "--json"),
173
369
  };
174
370
  }
175
371
 
372
+ export async function persistDeepInterviewSpec(
373
+ cwd: string,
374
+ resolved: ResolvedDeepInterviewSpecWriteArgs,
375
+ ): Promise<PersistedDeepInterviewSpec> {
376
+ const specsDir = path.join(cwd, ".gjc", "specs");
377
+ await fs.mkdir(specsDir, { recursive: true });
378
+ const specPath = path.join(specsDir, `deep-interview-${resolved.slug}.md`);
379
+ const content = resolved.spec.endsWith("\n") ? resolved.spec : `${resolved.spec}\n`;
380
+ await fs.writeFile(specPath, content);
381
+
382
+ const sha256 = createHash("sha256").update(content).digest("hex");
383
+ const createdAt = new Date().toISOString();
384
+ await fs.appendFile(
385
+ path.join(specsDir, "deep-interview-index.jsonl"),
386
+ `${JSON.stringify({ slug: resolved.slug, stage: resolved.stage, path: specPath, created_at: createdAt, sha256 })}\n`,
387
+ );
388
+
389
+ const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
390
+ const existing = await readJsonObject(statePath);
391
+ const payload: Record<string, unknown> = {
392
+ ...existing,
393
+ active: true,
394
+ current_phase: "handoff",
395
+ skill: "deep-interview",
396
+ version: typeof existing.version === "number" ? existing.version : 1,
397
+ spec_slug: resolved.slug,
398
+ spec_path: specPath,
399
+ spec_sha256: sha256,
400
+ spec_stage: resolved.stage,
401
+ spec_persisted_at: createdAt,
402
+ updated_at: createdAt,
403
+ };
404
+ if (resolved.sessionId) payload.session_id = resolved.sessionId;
405
+ await writeJsonAtomic(statePath, payload);
406
+ await syncDeepInterviewHud({
407
+ cwd,
408
+ sessionId: resolved.sessionId,
409
+ phase: "handoff",
410
+ specStatus: "persisted",
411
+ });
412
+
413
+ return {
414
+ slug: resolved.slug,
415
+ path: specPath,
416
+ stage: resolved.stage,
417
+ sha256,
418
+ createdAt,
419
+ statePath,
420
+ };
421
+ }
422
+
176
423
  async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepInterviewArgs): Promise<string> {
177
424
  const stateDir = resolved.sessionId
178
425
  ? path.join(cwd, ".gjc", "state", "sessions", encodeSessionSegment(resolved.sessionId))
@@ -196,6 +443,10 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
196
443
  },
197
444
  updated_at: now,
198
445
  };
446
+ if (resolved.language) {
447
+ payload.language = resolved.language;
448
+ (payload.state as Record<string, unknown>).language = resolved.language;
449
+ }
199
450
  if (resolved.sessionId) payload.session_id = resolved.sessionId;
200
451
  await fs.writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`);
201
452
  return statePath;
@@ -232,11 +483,70 @@ async function syncDeepInterviewHud(options: {
232
483
  }
233
484
  }
234
485
 
486
+ async function handleSpecWrite(args: readonly string[], cwd: string): Promise<DeepInterviewCommandResult> {
487
+ const resolved = await resolveSpecWriteArgs(args, cwd);
488
+ const persisted = await persistDeepInterviewSpec(cwd, resolved);
489
+ const shouldHandoff = resolved.deliberate || resolved.handoff === "ralplan";
490
+ const summary: DeepInterviewSpecWriteSummary = {
491
+ skill: "deep-interview",
492
+ stage: persisted.stage,
493
+ slug: persisted.slug,
494
+ path: persisted.path,
495
+ sha256: persisted.sha256,
496
+ created_at: persisted.createdAt,
497
+ state_path: persisted.statePath,
498
+ };
499
+
500
+ if (shouldHandoff) {
501
+ const ralplanArgs = ["--deliberate", "--json"];
502
+ if (resolved.sessionId) ralplanArgs.push("--session-id", resolved.sessionId);
503
+ ralplanArgs.push(persisted.path);
504
+ const ralplanResult = await runNativeRalplanCommand(ralplanArgs, cwd);
505
+ if (ralplanResult.status !== 0) {
506
+ throw new DeepInterviewCommandError(
507
+ ralplanResult.status,
508
+ ralplanResult.stderr?.trim() || "failed to seed ralplan",
509
+ );
510
+ }
511
+
512
+ const handoffArgs = ["handoff", "--mode", "deep-interview", "--to", "ralplan", "--json"];
513
+ if (resolved.sessionId) handoffArgs.push("--session-id", resolved.sessionId);
514
+ else handoffArgs.push("--session-id", "");
515
+ const handoffResult = await runNativeStateCommand(handoffArgs, cwd);
516
+ if (handoffResult.status !== 0) {
517
+ throw new DeepInterviewCommandError(
518
+ handoffResult.status,
519
+ handoffResult.stderr?.trim() || "failed to hand off deep-interview to ralplan",
520
+ );
521
+ }
522
+
523
+ const ralplanPayload = ralplanResult.stdout ? (JSON.parse(ralplanResult.stdout) as Record<string, unknown>) : {};
524
+ summary.handoff = {
525
+ to: "ralplan",
526
+ mode: "deliberate",
527
+ state_path: typeof ralplanPayload.state_path === "string" ? ralplanPayload.state_path : undefined,
528
+ run_id: typeof ralplanPayload.run_id === "string" ? ralplanPayload.run_id : undefined,
529
+ };
530
+ }
531
+
532
+ const stdout = resolved.json
533
+ ? `${JSON.stringify(summary, null, 2)}\n`
534
+ : [
535
+ `Persisted deep-interview ${persisted.stage} spec at ${persisted.path}.`,
536
+ shouldHandoff ? "Handed off deep-interview to ralplan (deliberate)." : undefined,
537
+ "",
538
+ ]
539
+ .filter((line): line is string => Boolean(line))
540
+ .join("\n");
541
+ return { status: 0, stdout };
542
+ }
543
+
235
544
  export async function runNativeDeepInterviewCommand(
236
545
  args: string[],
237
546
  cwd = process.cwd(),
238
547
  ): Promise<DeepInterviewCommandResult> {
239
548
  try {
549
+ if (isDeepInterviewSpecWriteInvocation(args)) return await handleSpecWrite(args, cwd);
240
550
  const resolved = await resolveDeepInterviewArgs(args, cwd);
241
551
  if (!resolved.idea) {
242
552
  throw new DeepInterviewCommandError(
@@ -260,6 +570,7 @@ export async function runNativeDeepInterviewCommand(
260
570
  threshold: resolved.threshold,
261
571
  threshold_source: resolved.thresholdSource,
262
572
  idea: resolved.idea,
573
+ language: resolved.language,
263
574
  state_path: statePath,
264
575
  handoff: "Run `/skill:deep-interview` inside the GJC agent to drive the Socratic interview loop.",
265
576
  };