@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.
- package/CHANGELOG.md +28 -0
- package/dist/types/cli/setup-cli.d.ts +1 -0
- package/dist/types/commands/deep-interview.d.ts +41 -0
- package/dist/types/commands/setup.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +36 -0
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/extensibility/extensions/types.d.ts +6 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +18 -0
- package/dist/types/hooks/skill-state.d.ts +5 -0
- package/dist/types/memories/index.d.ts +1 -1
- package/dist/types/memory-backend/local-backend.d.ts +3 -3
- package/dist/types/modes/components/hook-selector.d.ts +7 -0
- package/dist/types/modes/components/settings-selector.d.ts +0 -2
- package/dist/types/modes/utils/context-usage.d.ts +6 -2
- package/dist/types/sdk.d.ts +6 -2
- package/dist/types/session/agent-session.d.ts +45 -1
- package/dist/types/session/session-manager.d.ts +3 -0
- package/dist/types/setup/model-onboarding-guidance.d.ts +1 -0
- package/dist/types/setup/provider-onboarding.d.ts +29 -5
- package/dist/types/skill-state/active-state.d.ts +26 -1
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
- package/dist/types/skill-state/initial-phase.d.ts +12 -0
- package/dist/types/task/executor.d.ts +2 -0
- package/dist/types/task/types.d.ts +11 -0
- package/dist/types/tools/index.d.ts +20 -1
- package/dist/types/tools/skill.d.ts +47 -0
- package/dist/types/utils/changelog.d.ts +18 -2
- package/package.json +7 -7
- package/src/cli/setup-cli.ts +26 -12
- package/src/cli.ts +1 -0
- package/src/commands/deep-interview.ts +25 -2
- package/src/commands/setup.ts +2 -0
- package/src/commands/state.ts +1 -0
- package/src/config/settings-schema.ts +41 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +19 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -0
- package/src/defaults/gjc/skills/team/SKILL.md +10 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +10 -0
- package/src/discovery/helpers.ts +24 -1
- package/src/extensibility/extensions/types.ts +6 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +268 -1
- package/src/gjc-runtime/state-runtime.ts +173 -4
- package/src/hooks/skill-state.ts +8 -6
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/internal-urls/memory-protocol.ts +3 -2
- package/src/main.ts +2 -3
- package/src/memories/index.ts +2 -1
- package/src/memory-backend/local-backend.ts +14 -6
- package/src/modes/components/hook-selector.ts +156 -1
- package/src/modes/components/settings-selector.ts +5 -12
- package/src/modes/controllers/command-controller.ts +2 -3
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/selector-controller.ts +4 -11
- package/src/modes/utils/context-usage.ts +66 -17
- package/src/prompts/agents/architect.md +3 -0
- package/src/prompts/agents/executor.md +2 -0
- package/src/prompts/agents/frontmatter.md +1 -0
- package/src/prompts/system/subagent-system-prompt.md +6 -0
- package/src/prompts/tools/skill.md +28 -0
- package/src/prompts/tools/task.md +3 -0
- package/src/sdk.ts +50 -10
- package/src/session/agent-session.ts +204 -21
- package/src/session/session-manager.ts +9 -1
- package/src/setup/model-onboarding-guidance.ts +6 -3
- package/src/setup/provider-onboarding.ts +177 -16
- package/src/skill-state/active-state.ts +150 -25
- package/src/skill-state/deep-interview-mutation-guard.ts +11 -24
- package/src/skill-state/initial-phase.ts +17 -0
- package/src/slash-commands/builtin-registry.ts +51 -13
- package/src/slash-commands/helpers/context-report.ts +123 -13
- package/src/task/agents.ts +1 -0
- package/src/task/executor.ts +9 -1
- package/src/task/index.ts +91 -4
- package/src/task/types.ts +6 -0
- package/src/tools/ask.ts +2 -0
- package/src/tools/index.ts +23 -1
- package/src/tools/skill.ts +153 -0
- 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
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
compatibility?: ProviderCompatibility;
|
|
12
|
+
preset?: string;
|
|
13
|
+
providerId?: string;
|
|
14
|
+
baseUrl?: string;
|
|
13
15
|
apiKey?: string;
|
|
14
16
|
apiKeyEnv?: string;
|
|
15
|
-
models
|
|
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:
|
|
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):
|
|
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
|
|
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 =
|
|
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 =
|
|
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 ??
|
|
252
|
+
const apiKey = apiKeyEnv ?? resolved.apiKey?.trim() ?? "";
|
|
105
253
|
if (!apiKey) throw new Error("API key is required.");
|
|
106
254
|
|
|
107
|
-
const models = parseModelList(
|
|
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 {
|
|
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:
|
|
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
|
|
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)
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
+
}
|