@gajae-code/coding-agent 0.3.0 → 0.3.1

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 (175) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/types/async/job-manager.d.ts +7 -0
  3. package/dist/types/cli/args.d.ts +1 -1
  4. package/dist/types/commands/deep-interview.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +5 -0
  6. package/dist/types/config/settings-schema.d.ts +4 -4
  7. package/dist/types/debug/crash-diagnostics.d.ts +45 -0
  8. package/dist/types/debug/runtime-gauges.d.ts +6 -0
  9. package/dist/types/deep-interview/render-middleware.d.ts +1 -0
  10. package/dist/types/eval/py/executor.d.ts +2 -0
  11. package/dist/types/eval/py/kernel.d.ts +2 -0
  12. package/dist/types/exec/bash-executor.d.ts +10 -0
  13. package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
  14. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
  15. package/dist/types/gjc-runtime/state-migrations.d.ts +9 -0
  16. package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
  17. package/dist/types/gjc-runtime/state-writer.d.ts +10 -0
  18. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
  19. package/dist/types/harness-control-plane/control-endpoint.d.ts +3 -2
  20. package/dist/types/hooks/skill-state.d.ts +21 -0
  21. package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
  22. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  23. package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
  24. package/dist/types/internal-urls/types.d.ts +4 -0
  25. package/dist/types/lsp/index.d.ts +10 -10
  26. package/dist/types/modes/bridge/auth.d.ts +12 -0
  27. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
  28. package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
  29. package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
  30. package/dist/types/modes/bridge/event-stream.d.ts +8 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  32. package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
  33. package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
  34. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  35. package/dist/types/modes/components/status-line.d.ts +2 -0
  36. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  37. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  38. package/dist/types/modes/index.d.ts +1 -0
  39. package/dist/types/modes/interactive-mode.d.ts +1 -0
  40. package/dist/types/modes/jobs-observer.d.ts +57 -0
  41. package/dist/types/modes/rpc/host-tools.d.ts +1 -16
  42. package/dist/types/modes/rpc/host-uris.d.ts +1 -38
  43. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
  44. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
  45. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
  46. package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
  47. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
  48. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
  49. package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
  50. package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
  51. package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
  52. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
  53. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
  54. package/dist/types/modes/types.d.ts +1 -0
  55. package/dist/types/sdk.d.ts +2 -0
  56. package/dist/types/session/agent-session.d.ts +11 -1
  57. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -2
  58. package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
  59. package/dist/types/task/id.d.ts +7 -0
  60. package/dist/types/task/index.d.ts +5 -0
  61. package/dist/types/task/receipt.d.ts +85 -0
  62. package/dist/types/task/spawn-gate.d.ts +38 -0
  63. package/dist/types/task/types.d.ts +143 -11
  64. package/dist/types/tools/cron.d.ts +6 -0
  65. package/dist/types/tools/index.d.ts +2 -0
  66. package/dist/types/tools/path-utils.d.ts +1 -0
  67. package/dist/types/tools/subagent.d.ts +15 -0
  68. package/package.json +7 -7
  69. package/scripts/build-binary.ts +7 -0
  70. package/src/async/job-manager.ts +36 -0
  71. package/src/cli/args.ts +9 -2
  72. package/src/commands/deep-interview.ts +1 -0
  73. package/src/commands/harness.ts +289 -19
  74. package/src/commands/launch.ts +2 -2
  75. package/src/commands/state.ts +2 -1
  76. package/src/commands/team.ts +22 -4
  77. package/src/config/keybindings.ts +6 -0
  78. package/src/config/settings-schema.ts +6 -3
  79. package/src/dap/client.ts +17 -3
  80. package/src/debug/crash-diagnostics.ts +223 -0
  81. package/src/debug/runtime-gauges.ts +20 -0
  82. package/src/deep-interview/render-middleware.ts +6 -0
  83. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  84. package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
  85. package/src/defaults/gjc/skills/ultragoal/SKILL.md +28 -2
  86. package/src/eval/py/executor.ts +21 -1
  87. package/src/eval/py/kernel.ts +15 -0
  88. package/src/exec/bash-executor.ts +41 -0
  89. package/src/gjc-runtime/cli-write-receipt.ts +31 -0
  90. package/src/gjc-runtime/deep-interview-runtime.ts +69 -32
  91. package/src/gjc-runtime/ralplan-runtime.ts +213 -36
  92. package/src/gjc-runtime/state-migrations.ts +54 -7
  93. package/src/gjc-runtime/state-runtime.ts +461 -64
  94. package/src/gjc-runtime/state-schema.ts +192 -0
  95. package/src/gjc-runtime/state-writer.ts +32 -1
  96. package/src/gjc-runtime/team-runtime.ts +177 -105
  97. package/src/gjc-runtime/ultragoal-runtime.ts +114 -26
  98. package/src/gjc-runtime/workflow-command-ref.ts +239 -0
  99. package/src/gjc-runtime/workflow-manifest.generated.json +108 -4
  100. package/src/gjc-runtime/workflow-manifest.ts +3 -1
  101. package/src/harness-control-plane/control-endpoint.ts +19 -8
  102. package/src/harness-control-plane/owner.ts +57 -10
  103. package/src/harness-control-plane/state-machine.ts +2 -1
  104. package/src/hooks/skill-state.ts +176 -26
  105. package/src/internal-urls/agent-protocol.ts +68 -21
  106. package/src/internal-urls/artifact-protocol.ts +12 -17
  107. package/src/internal-urls/docs-index.generated.ts +3 -2
  108. package/src/internal-urls/registry-helpers.ts +19 -16
  109. package/src/internal-urls/types.ts +4 -0
  110. package/src/lsp/client.ts +18 -2
  111. package/src/main.ts +21 -5
  112. package/src/modes/bridge/auth.ts +41 -0
  113. package/src/modes/bridge/bridge-client-bridge.ts +47 -0
  114. package/src/modes/bridge/bridge-mode.ts +520 -0
  115. package/src/modes/bridge/bridge-ui-context.ts +200 -0
  116. package/src/modes/bridge/event-stream.ts +70 -0
  117. package/src/modes/components/custom-editor.ts +101 -0
  118. package/src/modes/components/hook-selector.ts +61 -18
  119. package/src/modes/components/jobs-overlay-model.ts +109 -0
  120. package/src/modes/components/jobs-overlay.ts +172 -0
  121. package/src/modes/components/status-line/presets.ts +7 -5
  122. package/src/modes/components/status-line/segments.ts +25 -0
  123. package/src/modes/components/status-line/types.ts +2 -0
  124. package/src/modes/components/status-line.ts +9 -1
  125. package/src/modes/controllers/extension-ui-controller.ts +39 -3
  126. package/src/modes/controllers/input-controller.ts +97 -9
  127. package/src/modes/controllers/selector-controller.ts +29 -0
  128. package/src/modes/index.ts +1 -0
  129. package/src/modes/interactive-mode.ts +27 -0
  130. package/src/modes/jobs-observer.ts +204 -0
  131. package/src/modes/rpc/host-tools.ts +1 -186
  132. package/src/modes/rpc/host-uris.ts +1 -235
  133. package/src/modes/rpc/rpc-client.ts +25 -10
  134. package/src/modes/rpc/rpc-mode.ts +12 -381
  135. package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
  136. package/src/modes/shared/agent-wire/command-validation.ts +131 -0
  137. package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
  138. package/src/modes/shared/agent-wire/handshake.ts +117 -0
  139. package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
  140. package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
  141. package/src/modes/shared/agent-wire/protocol.ts +96 -0
  142. package/src/modes/shared/agent-wire/responses.ts +17 -0
  143. package/src/modes/shared/agent-wire/scopes.ts +89 -0
  144. package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
  145. package/src/modes/shared/agent-wire/ui-result.ts +48 -0
  146. package/src/modes/types.ts +1 -0
  147. package/src/prompts/tools/subagent.md +12 -7
  148. package/src/prompts/tools/task-summary.md +3 -9
  149. package/src/prompts/tools/task.md +5 -1
  150. package/src/sdk.ts +4 -0
  151. package/src/session/agent-session.ts +214 -38
  152. package/src/skill-state/deep-interview-mutation-guard.ts +23 -4
  153. package/src/skill-state/workflow-state-contract.ts +7 -4
  154. package/src/skill-state/workflow-state-version.ts +3 -0
  155. package/src/slash-commands/builtin-registry.ts +8 -0
  156. package/src/task/executor.ts +29 -5
  157. package/src/task/id.ts +33 -0
  158. package/src/task/index.ts +257 -67
  159. package/src/task/output-manager.ts +5 -4
  160. package/src/task/receipt.ts +297 -0
  161. package/src/task/render.ts +48 -131
  162. package/src/task/spawn-gate.ts +132 -0
  163. package/src/task/types.ts +48 -7
  164. package/src/tools/ask.ts +73 -33
  165. package/src/tools/ast-edit.ts +1 -0
  166. package/src/tools/ast-grep.ts +1 -0
  167. package/src/tools/bash.ts +1 -1
  168. package/src/tools/cron.ts +48 -0
  169. package/src/tools/find.ts +4 -1
  170. package/src/tools/index.ts +2 -0
  171. package/src/tools/path-utils.ts +3 -2
  172. package/src/tools/read.ts +1 -0
  173. package/src/tools/search.ts +1 -0
  174. package/src/tools/skill.ts +6 -1
  175. package/src/tools/subagent.ts +237 -84
@@ -88,13 +88,16 @@ export class ControlServer {
88
88
  }
89
89
 
90
90
  export class EndpointUnreachableError extends Error {
91
- constructor(readonly socketPath: string) {
92
- super(`endpoint_unreachable:${socketPath}`);
91
+ constructor(
92
+ readonly socketPath: string,
93
+ readonly reason = "unreachable",
94
+ ) {
95
+ super(`endpoint_${reason}:${socketPath}`);
93
96
  this.name = "EndpointUnreachableError";
94
97
  }
95
98
  }
96
99
 
97
- /** Call the owner's control endpoint. Rejects with {@link EndpointUnreachableError} when no owner listens. */
100
+ /** Call the owner's control endpoint. Rejects with {@link EndpointUnreachableError} when no owner listens or responds. */
98
101
  export function callEndpoint(socketPath: string, req: EndpointRequest, timeoutMs = 5_000): Promise<unknown> {
99
102
  return new Promise((resolve, reject) => {
100
103
  const socket = net.connect(socketPath);
@@ -107,7 +110,10 @@ export function callEndpoint(socketPath: string, req: EndpointRequest, timeoutMs
107
110
  socket.destroy();
108
111
  fn();
109
112
  };
110
- const timer = setTimeout(() => done(() => reject(new Error(`endpoint_timeout:${socketPath}`))), timeoutMs);
113
+ const timer = setTimeout(
114
+ () => done(() => reject(new EndpointUnreachableError(socketPath, "timeout"))),
115
+ timeoutMs,
116
+ );
111
117
  socket.setEncoding("utf8");
112
118
  socket.on("connect", () => socket.write(frame(req)));
113
119
  socket.on("data", (chunk: string) => {
@@ -118,16 +124,21 @@ export function callEndpoint(socketPath: string, req: EndpointRequest, timeoutMs
118
124
  done(() => {
119
125
  try {
120
126
  resolve(JSON.parse(line));
121
- } catch (error) {
122
- reject(error instanceof Error ? error : new Error(String(error)));
127
+ } catch {
128
+ reject(new EndpointUnreachableError(socketPath, "bad_frame"));
123
129
  }
124
130
  });
125
131
  }
126
132
  });
127
133
  socket.on("error", (error: NodeJS.ErrnoException) => {
128
134
  done(() => {
129
- if (error.code === "ENOENT" || error.code === "ECONNREFUSED") {
130
- reject(new EndpointUnreachableError(socketPath));
135
+ if (
136
+ error.code === "ENOENT" ||
137
+ error.code === "ECONNREFUSED" ||
138
+ error.code === "ECONNRESET" ||
139
+ error.code === "EPIPE"
140
+ ) {
141
+ reject(new EndpointUnreachableError(socketPath, error.code.toLowerCase()));
131
142
  } else {
132
143
  reject(error);
133
144
  }
@@ -52,6 +52,31 @@ import {
52
52
  import type { EventEnvelope, GitDelta, Observation, PrimitiveResponse, SessionState, Severity } from "./types";
53
53
  import { DEFAULT_RETRY_BUDGET, OBSERVED_SIGNALS } from "./types";
54
54
 
55
+ function isStartupLivenessBlocker(blocker: string): boolean {
56
+ return blocker === "detached-owner-not-live";
57
+ }
58
+
59
+ function isOwnerVanishedBlocker(blocker: string): boolean {
60
+ return blocker.startsWith("owner-vanished:");
61
+ }
62
+
63
+ function reconcileLiveOwnerState(state: SessionState): { state: SessionState; reconciled: boolean } {
64
+ const blockers = state.blockers.filter(blocker => !isStartupLivenessBlocker(blocker));
65
+ const hadLivenessBlocker = blockers.length !== state.blockers.length;
66
+ const lifecycle =
67
+ hadLivenessBlocker && state.lifecycle === "blocked" && blockers.length === 0 ? "observing" : state.lifecycle;
68
+ if (!hadLivenessBlocker && lifecycle === state.lifecycle) return { state, reconciled: false };
69
+ return {
70
+ state: {
71
+ ...state,
72
+ lifecycle,
73
+ blockers,
74
+ updatedAt: new Date().toISOString(),
75
+ },
76
+ reconciled: true,
77
+ };
78
+ }
79
+
55
80
  export interface OwnerOptions {
56
81
  root: string;
57
82
  sessionId: string;
@@ -139,6 +164,11 @@ export class RuntimeOwner {
139
164
  async #loadState(): Promise<SessionState> {
140
165
  const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
141
166
  if (!state) throw new Error(`session_not_found:${this.#opts.sessionId}`);
167
+ const reconciled = reconcileLiveOwnerState(state);
168
+ if (reconciled.reconciled) {
169
+ await writeSessionState(this.#opts.root, reconciled.state);
170
+ return reconciled.state;
171
+ }
142
172
  return state;
143
173
  }
144
174
 
@@ -369,17 +399,22 @@ export class RuntimeOwner {
369
399
 
370
400
  async #recover(): Promise<PrimitiveResponse> {
371
401
  const obs = await this.#observeGit();
372
- const decision = classifyRecovery({ observation: obs, retryBudget: { ...DEFAULT_RETRY_BUDGET } });
402
+ const state = await this.#loadState();
403
+ const recoveringPriorVanish = state.blockers.some(isOwnerVanishedBlocker);
404
+ const recoveryObservation: Observation = recoveringPriorVanish
405
+ ? { ...obs, ownerLive: false, risk: obs.gitDelta === "dirty" ? "vanished-dirty" : obs.risk }
406
+ : obs;
407
+ const decision = classifyRecovery({ observation: recoveryObservation, retryBudget: { ...DEFAULT_RETRY_BUDGET } });
373
408
  let vanishReceiptId: string | null = null;
374
409
  if (requiresVanishBeforeAction(decision.classification)) {
375
- const dirty = obs.gitDelta === "dirty" || obs.gitDelta === "unknown";
376
- const p = dirty ? preserveDirtyWorktree(obs.cwd) : null;
410
+ const dirty = recoveryObservation.gitDelta === "dirty" || recoveryObservation.gitDelta === "unknown";
411
+ const p = dirty ? preserveDirtyWorktree(recoveryObservation.cwd) : null;
377
412
  const evidence: VanishEvidence = {
378
413
  classification: decision.classification,
379
- gitDelta: obs.gitDelta,
414
+ gitDelta: recoveryObservation.gitDelta,
380
415
  gitStatusPorcelain: p
381
416
  ? `tracked:${p.trackedDiffSha256};untracked:${p.untrackedManifest.length}`
382
- : obs.observedSignals.join(","),
417
+ : recoveryObservation.observedSignals.join(","),
383
418
  untrackedManifest: p?.untrackedManifest ?? [],
384
419
  preservation: p?.stashRef ? "stash" : "snapshot",
385
420
  stashRef: p?.stashRef ?? null,
@@ -391,15 +426,25 @@ export class RuntimeOwner {
391
426
  sessionId: this.#opts.sessionId,
392
427
  family: "vanish",
393
428
  source: "owner",
394
- subject: { workspace: obs.cwd, branch: obs.branch, head: null, commit: null },
429
+ subject: {
430
+ workspace: recoveryObservation.cwd,
431
+ branch: recoveryObservation.branch,
432
+ head: null,
433
+ commit: null,
434
+ },
395
435
  evidence,
396
436
  });
397
437
  await writeReceiptImmutable(this.#opts.root, this.#opts.sessionId, "vanish", receipt.receiptId, receipt);
398
438
  vanishReceiptId = receipt.receiptId;
399
439
  }
400
- const state = await this.#loadState();
440
+ if (vanishReceiptId) {
441
+ state.blockers = state.blockers.filter(blocker => !isOwnerVanishedBlocker(blocker));
442
+ state.lifecycle = state.blockers.length === 0 ? "observing" : state.lifecycle;
443
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
444
+ await writeSessionState(this.#opts.root, state);
445
+ }
401
446
  await this.#emit(decision.severity, "recover_classified", { classification: decision.classification });
402
- return this.#response(state, { decision, observation: obs, vanishReceiptId });
447
+ return this.#response(state, { decision, observation: recoveryObservation, vanishReceiptId });
403
448
  }
404
449
 
405
450
  async #operate(input: Record<string, unknown>): Promise<PrimitiveResponse> {
@@ -475,12 +520,14 @@ export class RuntimeOwner {
475
520
 
476
521
  async #submit(input: Record<string, unknown>): Promise<PrimitiveResponse> {
477
522
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
523
+ const state = await this.#loadState();
478
524
  if (!prompt) {
479
- const state = await this.#loadState();
480
525
  return this.#response(state, { accepted: false, reason: "empty-prompt" }, false);
481
526
  }
527
+ if (state.lifecycle === "blocked") {
528
+ return this.#response(state, { accepted: false, reason: "lifecycle-blocked" }, false);
529
+ }
482
530
  const result = await singleFlightAccept(this.#opts.rpc, prompt, this.#opts.acceptanceTimeoutMs);
483
- const state = await this.#loadState();
484
531
  if (result.accepted) {
485
532
  state.lifecycle = "observing";
486
533
  state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
@@ -57,8 +57,9 @@ export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boole
57
57
  // `start` creates a new session; never re-applicable to an existing record.
58
58
  add("start", false, "session-already-exists");
59
59
 
60
- // `submit` is owner-routed: it requires a live owner and a non-terminal lifecycle.
60
+ // `submit` is owner-routed: it requires a live owner and a non-blocked, non-terminal lifecycle.
61
61
  if (terminal) add("submit", false, `lifecycle-terminal:${lifecycle}`);
62
+ else if (lifecycle === "blocked") add("submit", false, "lifecycle-blocked");
62
63
  else if (!ownerLive) add("submit", false, "owner-not-live");
63
64
  else add("submit", true);
64
65
 
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import type { SkillDiscoverySettings } from "../config/skill-settings-defaults";
3
- import { writeJsonAtomic } from "../gjc-runtime/state-writer";
3
+ import { ModeStateSchema, SkillActiveStateSchema } from "../gjc-runtime/state-schema";
4
+ import { writeJsonAtomic, writeWorkflowEnvelopeAtomic } from "../gjc-runtime/state-writer";
4
5
  import { isUltragoalBypassPrompt, readUltragoalVerificationState } from "../gjc-runtime/ultragoal-guard";
5
6
  import { buildSessionContext, loadEntriesFromFile, type SessionEntry } from "../session/session-manager";
6
7
  import {
@@ -8,6 +9,7 @@ import {
8
9
  type SkillActiveEntry,
9
10
  type SkillActiveState,
10
11
  } from "../skill-state/active-state";
12
+ import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
11
13
  import {
12
14
  compareSkillKeywordMatches,
13
15
  GJC_SKILL_KEYWORD_DEFINITIONS,
@@ -218,14 +220,36 @@ function skillStatePath(stateDir: string, sessionId?: string): string {
218
220
  return path.join(stateDir, SKILL_ACTIVE_STATE_FILE);
219
221
  }
220
222
 
221
- async function readJsonFile<T>(filePath: string): Promise<T | null> {
223
+ function warnInvalidState(kind: string, filePath: string, error: string): void {
224
+ console.warn(`gjc skill-state: invalid ${kind} at ${filePath}: ${error}`);
225
+ }
226
+
227
+ async function readValidatedJsonFile<T>(
228
+ filePath: string,
229
+ kind: string,
230
+ schema: { safeParse: (value: unknown) => { success: true } | { success: false; error: { message: string } } },
231
+ ): Promise<T | null> {
232
+ let raw: string;
222
233
  try {
223
- const raw = await Bun.file(filePath).text();
224
- return JSON.parse(raw) as T;
234
+ raw = await Bun.file(filePath).text();
225
235
  } catch (error) {
226
236
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return null;
237
+ warnInvalidState(kind, filePath, `read error: ${(error as Error).message}`);
227
238
  return null;
228
239
  }
240
+ let value: T;
241
+ try {
242
+ value = JSON.parse(raw) as T;
243
+ } catch (error) {
244
+ warnInvalidState(kind, filePath, `invalid JSON: ${(error as Error).message}`);
245
+ return null;
246
+ }
247
+ const parsed = schema.safeParse(value);
248
+ if (!parsed.success) {
249
+ warnInvalidState(kind, filePath, parsed.error.message);
250
+ return null;
251
+ }
252
+ return value;
229
253
  }
230
254
 
231
255
  async function writeJsonFile(filePath: string, value: unknown, cwd: string): Promise<void> {
@@ -265,22 +289,41 @@ export async function readVisibleSkillActiveState(
265
289
  if (!stateDir) return await readCanonicalVisibleSkillActiveState(cwd, sessionId);
266
290
  const resolvedStateDir = resolveGjcStateDir(cwd, stateDir);
267
291
  if (sessionId) {
268
- const sessionState = await readJsonFile<SkillActiveState>(skillStatePath(resolvedStateDir, sessionId));
292
+ const sessionState = await readValidatedJsonFile<SkillActiveState>(
293
+ skillStatePath(resolvedStateDir, sessionId),
294
+ "skill-active-state",
295
+ SkillActiveStateSchema,
296
+ );
269
297
  if (sessionState) return sessionState;
270
298
  }
271
- return await readJsonFile<SkillActiveState>(skillStatePath(resolvedStateDir));
299
+ return await readValidatedJsonFile<SkillActiveState>(
300
+ skillStatePath(resolvedStateDir),
301
+ "skill-active-state",
302
+ SkillActiveStateSchema,
303
+ );
272
304
  }
273
305
 
274
- export async function recordSkillActivation(input: RecordSkillActivationInput): Promise<SkillActiveState | null> {
275
- const match = detectPrimarySkillKeyword(input.text);
276
- if (!match) return null;
306
+ interface SeedSkillActivationStateInput {
307
+ cwd: string;
308
+ sessionId?: string;
309
+ threadId?: string;
310
+ turnId?: string;
311
+ nowIso?: string;
312
+ stateDir?: string;
313
+ }
277
314
 
315
+ async function seedSkillActivationState(
316
+ skill: GjcWorkflowSkill,
317
+ keyword: string,
318
+ source: string,
319
+ input: SeedSkillActivationStateInput,
320
+ ): Promise<SkillActiveState> {
278
321
  const resolvedStateDir = resolveGjcStateDir(input.cwd, input.stateDir);
279
322
  const nowIso = input.nowIso ?? new Date().toISOString();
280
- const phase = initialPhaseForSkill(match.skill);
281
- const initializedStatePath = modeStatePath(resolvedStateDir, match.skill, input.sessionId);
323
+ const phase = initialPhaseForSkill(skill);
324
+ const initializedStatePath = modeStatePath(resolvedStateDir, skill, input.sessionId);
282
325
  const entry: SkillActiveEntry = {
283
- skill: match.skill,
326
+ skill,
284
327
  phase,
285
328
  active: true,
286
329
  activated_at: nowIso,
@@ -292,41 +335,102 @@ export async function recordSkillActivation(input: RecordSkillActivationInput):
292
335
  const state: SkillActiveState = {
293
336
  version: 1,
294
337
  active: true,
295
- skill: match.skill,
296
- keyword: match.keyword,
338
+ skill,
339
+ keyword,
297
340
  phase,
298
341
  activated_at: nowIso,
299
342
  updated_at: nowIso,
300
- source: "gjc-skill-state-hook",
343
+ source,
301
344
  ...(input.sessionId ? { session_id: input.sessionId } : {}),
302
345
  ...(input.threadId ? { thread_id: input.threadId } : {}),
303
346
  ...(input.turnId ? { turn_id: input.turnId } : {}),
304
- initialized_mode: match.skill,
347
+ initialized_mode: skill,
305
348
  initialized_state_path: initializedStatePath,
306
349
  active_skills: [entry],
307
350
  };
308
351
  const modeState: ModeState = {
309
352
  active: true,
353
+ version: WORKFLOW_STATE_VERSION,
310
354
  current_phase: phase,
311
- skill: match.skill,
355
+ skill,
312
356
  cwd: input.cwd,
313
357
  updated_at: nowIso,
314
358
  ...(input.sessionId ? { session_id: input.sessionId } : {}),
315
359
  ...(input.threadId ? { thread_id: input.threadId } : {}),
316
360
  ...(input.turnId ? { turn_id: input.turnId } : {}),
317
361
  };
318
- if (match.skill === "deep-interview") {
362
+ if (skill === "deep-interview") {
319
363
  modeState.threshold = DEFAULT_DEEP_INTERVIEW_AMBIGUITY_THRESHOLD;
320
364
  modeState.threshold_source = "default";
321
365
  }
322
366
 
323
- await writeJsonFile(initializedStatePath, modeState, input.cwd);
367
+ await writeWorkflowEnvelopeAtomic(initializedStatePath, modeState, {
368
+ cwd: input.cwd,
369
+ receipt: {
370
+ cwd: input.cwd,
371
+ skill,
372
+ owner: "gjc-hook",
373
+ command: source,
374
+ sessionId: input.sessionId,
375
+ },
376
+ audit: { category: "state", verb: "write", owner: "gjc-hook", skill },
377
+ });
324
378
  await writeJsonFile(skillStatePath(resolvedStateDir, input.sessionId), state, input.cwd);
325
- if (!input.sessionId) return state;
326
- await writeJsonFile(skillStatePath(resolvedStateDir), state, input.cwd);
379
+ if (input.sessionId) {
380
+ await writeJsonFile(skillStatePath(resolvedStateDir), state, input.cwd);
381
+ }
327
382
  return state;
328
383
  }
329
384
 
385
+ export async function recordSkillActivation(input: RecordSkillActivationInput): Promise<SkillActiveState | null> {
386
+ const match = detectPrimarySkillKeyword(input.text);
387
+ if (!match) return null;
388
+ return await seedSkillActivationState(match.skill, match.keyword, "gjc-skill-state-hook", input);
389
+ }
390
+
391
+ export interface EnsureWorkflowSkillActivationInput {
392
+ cwd: string;
393
+ skill: string;
394
+ sessionId?: string;
395
+ threadId?: string;
396
+ turnId?: string;
397
+ nowIso?: string;
398
+ stateDir?: string;
399
+ }
400
+
401
+ /**
402
+ * Idempotently seed `.gjc/state` for a workflow skill that was invoked directly
403
+ * (e.g. via `/skill:<name>`) rather than through keyword detection. This ensures
404
+ * the mutation guard and Stop hook engage the moment a workflow skill becomes
405
+ * active, instead of relying on the skill prompt to run its own state-init steps.
406
+ *
407
+ * The seed is non-destructive: if an active entry for this skill already exists
408
+ * (for example after a `gjc state handoff` promotion that carries
409
+ * `handoff_from`/`handoff_at` lineage), nothing is written so lineage is
410
+ * preserved. Non-workflow skills are ignored.
411
+ */
412
+ export async function ensureWorkflowSkillActivationState(
413
+ input: EnsureWorkflowSkillActivationInput,
414
+ ): Promise<SkillActiveState | null> {
415
+ const skill = input.skill.trim();
416
+ if (!isGjcWorkflowSkill(skill)) return null;
417
+ const existing = await readVisibleSkillActiveState(input.cwd, input.sessionId, input.stateDir);
418
+ const alreadyActive = listActiveSkills(existing).some(
419
+ entry =>
420
+ entry.skill === skill &&
421
+ (existing ? entryMatchesContext(entry, existing, input.sessionId, input.threadId) : true),
422
+ );
423
+ if (alreadyActive) return existing;
424
+ return await seedSkillActivationState(skill, `/skill:${skill}`, "gjc-skill-invocation", {
425
+ cwd: input.cwd,
426
+ sessionId: input.sessionId,
427
+ threadId: input.threadId,
428
+ turnId: input.turnId,
429
+ nowIso: input.nowIso,
430
+ stateDir: input.stateDir,
431
+ });
432
+ }
433
+
330
434
  function isTerminalModeState(state: ModeState | null): boolean {
331
435
  if (state?.active !== true) return true;
332
436
  const phase = String(state.current_phase ?? "")
@@ -335,6 +439,45 @@ function isTerminalModeState(state: ModeState | null): boolean {
335
439
  return ["complete", "completed", "handoff", "failed", "cancelled", "canceled", "inactive"].includes(phase);
336
440
  }
337
441
 
442
+ /**
443
+ * Phases that genuinely finish a skill and release the Stop block. Note that
444
+ * "handoff" is intentionally absent: a skill sitting in the handoff phase has
445
+ * declared it is ready to chain but has not yet been demoted/cleared, so it
446
+ * must keep blocking until the chain (or an explicit clear) removes it.
447
+ */
448
+ const STOP_RELEASING_PHASES = ["complete", "completed", "failed", "cancelled", "canceled", "inactive"] as const;
449
+
450
+ /**
451
+ * Handoff workflows must never stop silently — they always have to offer the
452
+ * user a next step (refine, hand off, or finish) via the ask tool. The Stop
453
+ * hook keeps blocking these even in the "handoff" phase until they are demoted
454
+ * (active:false) or cleared.
455
+ */
456
+ function isHandoffRequiredSkill(skill: GjcWorkflowSkill): boolean {
457
+ return skill === "deep-interview" || skill === "ralplan";
458
+ }
459
+
460
+ /**
461
+ * Decide whether an active-state entry's mode-state releases the Stop block.
462
+ *
463
+ * For handoff-required skills a missing or unreadable mode-state does NOT
464
+ * release the block: those workflows must always end by offering the user a
465
+ * next step, so the `skill-active-state.json` entry stays authoritative until
466
+ * the skill is demoted or cleared. For other skills a missing/corrupt
467
+ * mode-state preserves the historical fail-open behavior so a broken state file
468
+ * cannot lock a session.
469
+ */
470
+ function modeStateReleasesStop(state: ModeState | null, handoffRequired: boolean): boolean {
471
+ if (!state) return !handoffRequired;
472
+ if (state.active !== true) return true;
473
+ const phase = String(state.current_phase ?? "")
474
+ .trim()
475
+ .toLowerCase();
476
+ if ((STOP_RELEASING_PHASES as readonly string[]).includes(phase)) return true;
477
+ if (!handoffRequired && phase === "handoff") return true;
478
+ return false;
479
+ }
480
+
338
481
  async function readVisibleModeState(
339
482
  cwd: string,
340
483
  skill: GjcWorkflowSkill,
@@ -344,11 +487,11 @@ async function readVisibleModeState(
344
487
  const resolvedStateDir = resolveGjcStateDir(cwd, stateDir);
345
488
  if (sessionId) {
346
489
  const sessionStatePath = modeStatePath(resolvedStateDir, skill, sessionId);
347
- const sessionState = await readJsonFile<ModeState>(sessionStatePath);
490
+ const sessionState = await readValidatedJsonFile<ModeState>(sessionStatePath, "mode-state", ModeStateSchema);
348
491
  if (sessionState) return { state: sessionState, statePath: sessionStatePath };
349
492
  }
350
493
  const rootStatePath = modeStatePath(resolvedStateDir, skill);
351
- const rootState = await readJsonFile<ModeState>(rootStatePath);
494
+ const rootState = await readValidatedJsonFile<ModeState>(rootStatePath, "mode-state", ModeStateSchema);
352
495
  if (!rootState) return null;
353
496
  return { state: rootState, statePath: rootStatePath };
354
497
  }
@@ -421,8 +564,13 @@ export async function buildSkillStopOutput(input: StopHookInput): Promise<Record
421
564
  if (!skillState || activeEntries.length === 0) return null;
422
565
 
423
566
  for (const entry of activeEntries) {
424
- const modeState = await readJsonFile<ModeState>(modeStatePath(resolvedStateDir, entry.skill, input.sessionId));
425
- if (isTerminalModeState(modeState)) continue;
567
+ const modeState = await readValidatedJsonFile<ModeState>(
568
+ modeStatePath(resolvedStateDir, entry.skill, input.sessionId),
569
+ "mode-state",
570
+ ModeStateSchema,
571
+ );
572
+ const handoffRequired = isHandoffRequiredSkill(entry.skill);
573
+ if (modeStateReleasesStop(modeState, handoffRequired)) continue;
426
574
  const phase = String(modeState?.current_phase ?? entry.phase ?? skillState.phase ?? "active");
427
575
  const statePath = modeStatePath(resolvedStateDir, entry.skill, input.sessionId);
428
576
  if (entry.skill === "ultragoal") {
@@ -450,7 +598,9 @@ export async function buildSkillStopOutput(input: StopHookInput): Promise<Record
450
598
  }
451
599
  }
452
600
  }
453
- const systemMessage = `GJC skill "${entry.skill}" is still active (phase: ${phase}; state: ${statePath}). Continue or explicitly finish/cancel the skill before stopping.`;
601
+ const systemMessage = handoffRequired
602
+ ? `GJC handoff skill "${entry.skill}" must not stop without offering a next step (phase: ${phase}; state: ${statePath}). Use the ask tool to present the next handoff step — e.g. refine further, hand off to ralplan/team/ultragoal, or finish — then chain or explicitly clear the skill before stopping.`
603
+ : `GJC skill "${entry.skill}" is still active (phase: ${phase}; state: ${statePath}). Continue or explicitly finish/cancel the skill before stopping.`;
454
604
  return {
455
605
  decision: "block",
456
606
  reason: systemMessage,
@@ -1,23 +1,72 @@
1
1
  /**
2
2
  * Protocol handler for agent:// URLs.
3
3
  *
4
- * Resolves agent output IDs against the artifacts directories of every active
5
- * session. Parents and subagents share outputs via this registry: a subagent
6
- * can read its parent's output IDs because both sessions are registered in
7
- * the shared context.
4
+ * Resolves agent output IDs only against artifacts directories explicitly
5
+ * authorized by the caller's ResolveContext. Parents and subagents can share
6
+ * outputs by passing their tree's artifacts dir at that API boundary.
8
7
  *
9
8
  * URL forms:
10
9
  * - agent://<id> - Full output content
11
10
  * - agent://<id>/<path> - JSON extraction via path form
12
11
  * - agent://<id>?q=<query> - JSON extraction via query form
13
12
  */
13
+ import { createHash } from "node:crypto";
14
14
  import * as fs from "node:fs/promises";
15
15
  import * as path from "node:path";
16
16
  import { isEnoent } from "@gajae-code/utils";
17
17
  import { applyQuery, pathToQuery } from "./json-query";
18
- import { artifactsDirsFromRegistry } from "./registry-helpers";
19
- import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
18
+ import { authorizedArtifactsDirsFromContext } from "./registry-helpers";
19
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext } from "./types";
20
20
 
21
+ interface AgentOutputMetadata {
22
+ id: string;
23
+ kind: "agent-output";
24
+ sizeBytes: number;
25
+ lineCount: number;
26
+ sha256: string;
27
+ createdAt: string;
28
+ }
29
+
30
+ function isAgentOutputMetadata(value: unknown, outputId: string): value is AgentOutputMetadata {
31
+ if (!value || typeof value !== "object") return false;
32
+ const meta = value as Record<string, unknown>;
33
+ return (
34
+ meta.id === outputId &&
35
+ meta.kind === "agent-output" &&
36
+ typeof meta.sizeBytes === "number" &&
37
+ typeof meta.lineCount === "number" &&
38
+ typeof meta.sha256 === "string" &&
39
+ typeof meta.createdAt === "string"
40
+ );
41
+ }
42
+
43
+ async function verifyAgentOutputMetadata(outputId: string, foundPath: string, bytes: Buffer): Promise<void> {
44
+ const metaPath = `${foundPath}.meta.json`;
45
+ let metaRaw: string;
46
+ try {
47
+ metaRaw = await Bun.file(metaPath).text();
48
+ } catch (err) {
49
+ if (isEnoent(err)) throw new Error(`agent://${outputId} missing metadata`);
50
+ throw err;
51
+ }
52
+ let parsed: unknown;
53
+ try {
54
+ parsed = JSON.parse(metaRaw);
55
+ } catch {
56
+ throw new Error(`agent://${outputId} malformed metadata`);
57
+ }
58
+ if (!isAgentOutputMetadata(parsed, outputId)) {
59
+ throw new Error(`agent://${outputId} malformed metadata`);
60
+ }
61
+ const stat = await fs.stat(foundPath);
62
+ if (stat.size !== parsed.sizeBytes || bytes.byteLength !== parsed.sizeBytes) {
63
+ throw new Error(`agent://${outputId} size mismatch`);
64
+ }
65
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
66
+ if (sha256 !== parsed.sha256) {
67
+ throw new Error(`agent://${outputId} hash mismatch`);
68
+ }
69
+ }
21
70
  /**
22
71
  * Handler for agent:// URLs.
23
72
  *
@@ -28,11 +77,17 @@ export class AgentProtocolHandler implements ProtocolHandler {
28
77
  readonly scheme = "agent";
29
78
  readonly immutable = true;
30
79
 
31
- async resolve(url: InternalUrl): Promise<InternalResource> {
80
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
32
81
  const outputId = url.rawHost || url.hostname;
33
82
  if (!outputId) {
34
83
  throw new Error("agent:// URL requires an output ID: agent://<id>");
35
84
  }
85
+ // Output IDs address a single file inside a session artifacts dir. Reject
86
+ // path separators / traversal so a crafted id cannot escape the dir via
87
+ // path.join(dir, `${outputId}.md`).
88
+ if (outputId.includes("/") || outputId.includes("\\") || outputId.includes("..")) {
89
+ throw new Error(`agent://${outputId} invalid id: path separators are not allowed`);
90
+ }
36
91
 
37
92
  const urlPath = url.pathname;
38
93
  const queryParam = url.searchParams.get("q");
@@ -43,7 +98,7 @@ export class AgentProtocolHandler implements ProtocolHandler {
43
98
  throw new Error("agent:// URL cannot combine path extraction with ?q=");
44
99
  }
45
100
 
46
- const dirs = artifactsDirsFromRegistry();
101
+ const dirs = authorizedArtifactsDirsFromContext(context);
47
102
 
48
103
  if (dirs.length === 0) {
49
104
  throw new Error("No session - agent outputs unavailable");
@@ -51,7 +106,6 @@ export class AgentProtocolHandler implements ProtocolHandler {
51
106
 
52
107
  let foundPath: string | undefined;
53
108
  let anyDirExists = false;
54
- const availableIds = new Set<string>();
55
109
 
56
110
  for (const dir of dirs) {
57
111
  try {
@@ -64,18 +118,10 @@ export class AgentProtocolHandler implements ProtocolHandler {
64
118
  const candidate = path.join(dir, `${outputId}.md`);
65
119
  try {
66
120
  await fs.stat(candidate);
121
+ if (foundPath) throw new Error(`agent://${outputId} ambiguous id in authorized artifacts`);
67
122
  foundPath = candidate;
68
- break;
69
123
  } catch (err) {
70
124
  if (!isEnoent(err)) throw err;
71
- try {
72
- const files = await fs.readdir(dir);
73
- for (const f of files) {
74
- if (f.endsWith(".md")) availableIds.add(f.replace(/\.md$/, ""));
75
- }
76
- } catch {
77
- // Listing failures are non-fatal; continue searching.
78
- }
79
125
  }
80
126
  }
81
127
 
@@ -84,11 +130,12 @@ export class AgentProtocolHandler implements ProtocolHandler {
84
130
  }
85
131
 
86
132
  if (!foundPath) {
87
- const availableStr = availableIds.size > 0 ? [...availableIds].join(", ") : "none";
88
- throw new Error(`Not found: ${outputId}\nAvailable: ${availableStr}`);
133
+ throw new Error(`agent://${outputId} not found`);
89
134
  }
90
135
 
91
- const rawContent = await Bun.file(foundPath).text();
136
+ const rawBytes = Buffer.from(await Bun.file(foundPath).arrayBuffer());
137
+ await verifyAgentOutputMetadata(outputId, foundPath, rawBytes);
138
+ const rawContent = rawBytes.toString("utf8");
92
139
  const notes: string[] = [];
93
140
  let content = rawContent;
94
141
  let contentType: InternalResource["contentType"] = "text/markdown";