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