@gajae-code/coding-agent 0.3.0 → 0.3.2

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 (213) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +7 -0
  4. package/dist/types/cli/args.d.ts +3 -1
  5. package/dist/types/commands/deep-interview.d.ts +3 -0
  6. package/dist/types/commands/launch.d.ts +6 -0
  7. package/dist/types/config/keybindings.d.ts +5 -0
  8. package/dist/types/config/model-profile-activation.d.ts +30 -0
  9. package/dist/types/config/model-profiles.d.ts +19 -0
  10. package/dist/types/config/model-registry.d.ts +8 -0
  11. package/dist/types/config/model-resolver.d.ts +1 -1
  12. package/dist/types/config/models-config-schema.d.ts +47 -0
  13. package/dist/types/config/settings-schema.d.ts +14 -4
  14. package/dist/types/debug/crash-diagnostics.d.ts +45 -0
  15. package/dist/types/debug/runtime-gauges.d.ts +6 -0
  16. package/dist/types/deep-interview/render-middleware.d.ts +1 -0
  17. package/dist/types/eval/py/executor.d.ts +2 -0
  18. package/dist/types/eval/py/kernel.d.ts +2 -0
  19. package/dist/types/exec/bash-executor.d.ts +10 -0
  20. package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
  21. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
  22. package/dist/types/gjc-runtime/state-migrations.d.ts +9 -0
  23. package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
  24. package/dist/types/gjc-runtime/state-writer.d.ts +10 -0
  25. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -1
  26. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
  27. package/dist/types/harness-control-plane/control-endpoint.d.ts +3 -2
  28. package/dist/types/hooks/skill-state.d.ts +21 -0
  29. package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
  30. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  31. package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
  32. package/dist/types/internal-urls/types.d.ts +4 -0
  33. package/dist/types/lsp/index.d.ts +10 -10
  34. package/dist/types/main.d.ts +10 -1
  35. package/dist/types/modes/bridge/auth.d.ts +12 -0
  36. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
  37. package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
  38. package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
  39. package/dist/types/modes/bridge/event-stream.d.ts +8 -0
  40. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  41. package/dist/types/modes/components/custom-provider-wizard.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
  43. package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
  44. package/dist/types/modes/components/model-selector.d.ts +6 -1
  45. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  46. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  47. package/dist/types/modes/components/status-line.d.ts +2 -0
  48. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  49. package/dist/types/modes/controllers/selector-controller.d.ts +9 -0
  50. package/dist/types/modes/index.d.ts +1 -0
  51. package/dist/types/modes/interactive-mode.d.ts +1 -0
  52. package/dist/types/modes/jobs-observer.d.ts +57 -0
  53. package/dist/types/modes/rpc/host-tools.d.ts +1 -16
  54. package/dist/types/modes/rpc/host-uris.d.ts +1 -38
  55. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
  56. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
  57. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
  58. package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
  59. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
  60. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
  61. package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
  62. package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
  63. package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
  64. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
  65. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
  66. package/dist/types/modes/types.d.ts +2 -0
  67. package/dist/types/sdk.d.ts +3 -1
  68. package/dist/types/session/agent-session.d.ts +11 -1
  69. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -2
  70. package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
  71. package/dist/types/task/executor.d.ts +1 -0
  72. package/dist/types/task/id.d.ts +7 -0
  73. package/dist/types/task/index.d.ts +5 -0
  74. package/dist/types/task/receipt.d.ts +85 -0
  75. package/dist/types/task/spawn-gate.d.ts +38 -0
  76. package/dist/types/task/types.d.ts +143 -11
  77. package/dist/types/tools/cron.d.ts +6 -0
  78. package/dist/types/tools/hindsight-recall.d.ts +0 -2
  79. package/dist/types/tools/hindsight-reflect.d.ts +0 -2
  80. package/dist/types/tools/hindsight-retain.d.ts +0 -2
  81. package/dist/types/tools/index.d.ts +6 -4
  82. package/dist/types/tools/path-utils.d.ts +1 -0
  83. package/dist/types/tools/subagent.d.ts +15 -0
  84. package/package.json +7 -7
  85. package/scripts/build-binary.ts +7 -0
  86. package/src/async/job-manager.ts +36 -0
  87. package/src/cli/args.ts +19 -2
  88. package/src/commands/deep-interview.ts +1 -0
  89. package/src/commands/harness.ts +289 -19
  90. package/src/commands/launch.ts +10 -2
  91. package/src/commands/state.ts +2 -1
  92. package/src/commands/team.ts +22 -4
  93. package/src/config/keybindings.ts +6 -0
  94. package/src/config/model-profile-activation.ts +157 -0
  95. package/src/config/model-profiles.ts +155 -0
  96. package/src/config/model-registry.ts +19 -0
  97. package/src/config/model-resolver.ts +3 -2
  98. package/src/config/models-config-schema.ts +36 -0
  99. package/src/config/settings-schema.ts +16 -3
  100. package/src/dap/client.ts +17 -3
  101. package/src/debug/crash-diagnostics.ts +223 -0
  102. package/src/debug/runtime-gauges.ts +20 -0
  103. package/src/deep-interview/render-middleware.ts +6 -0
  104. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  105. package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
  106. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -3
  107. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +61 -0
  108. package/src/defaults/gjc-defaults.ts +7 -0
  109. package/src/eval/py/executor.ts +21 -1
  110. package/src/eval/py/kernel.ts +15 -0
  111. package/src/exec/bash-executor.ts +41 -0
  112. package/src/gjc-runtime/cli-write-receipt.ts +31 -0
  113. package/src/gjc-runtime/deep-interview-runtime.ts +69 -32
  114. package/src/gjc-runtime/ralplan-runtime.ts +213 -36
  115. package/src/gjc-runtime/state-migrations.ts +54 -7
  116. package/src/gjc-runtime/state-runtime.ts +461 -64
  117. package/src/gjc-runtime/state-schema.ts +192 -0
  118. package/src/gjc-runtime/state-writer.ts +32 -1
  119. package/src/gjc-runtime/team-runtime.ts +177 -105
  120. package/src/gjc-runtime/ultragoal-runtime.ts +231 -38
  121. package/src/gjc-runtime/workflow-command-ref.ts +239 -0
  122. package/src/gjc-runtime/workflow-manifest.generated.json +108 -4
  123. package/src/gjc-runtime/workflow-manifest.ts +3 -1
  124. package/src/harness-control-plane/control-endpoint.ts +19 -8
  125. package/src/harness-control-plane/owner.ts +57 -10
  126. package/src/harness-control-plane/state-machine.ts +2 -1
  127. package/src/hooks/skill-state.ts +176 -26
  128. package/src/internal-urls/agent-protocol.ts +68 -21
  129. package/src/internal-urls/artifact-protocol.ts +12 -17
  130. package/src/internal-urls/docs-index.generated.ts +8 -10
  131. package/src/internal-urls/registry-helpers.ts +19 -16
  132. package/src/internal-urls/types.ts +4 -0
  133. package/src/lsp/client.ts +18 -2
  134. package/src/main.ts +88 -6
  135. package/src/modes/bridge/auth.ts +41 -0
  136. package/src/modes/bridge/bridge-client-bridge.ts +47 -0
  137. package/src/modes/bridge/bridge-mode.ts +520 -0
  138. package/src/modes/bridge/bridge-ui-context.ts +200 -0
  139. package/src/modes/bridge/event-stream.ts +70 -0
  140. package/src/modes/components/custom-editor.ts +101 -0
  141. package/src/modes/components/custom-provider-wizard.ts +318 -0
  142. package/src/modes/components/hook-selector.ts +61 -18
  143. package/src/modes/components/jobs-overlay-model.ts +109 -0
  144. package/src/modes/components/jobs-overlay.ts +172 -0
  145. package/src/modes/components/model-selector.ts +108 -18
  146. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  147. package/src/modes/components/status-line/presets.ts +7 -5
  148. package/src/modes/components/status-line/segments.ts +25 -0
  149. package/src/modes/components/status-line/types.ts +2 -0
  150. package/src/modes/components/status-line.ts +9 -1
  151. package/src/modes/controllers/extension-ui-controller.ts +39 -3
  152. package/src/modes/controllers/input-controller.ts +97 -9
  153. package/src/modes/controllers/selector-controller.ts +86 -1
  154. package/src/modes/index.ts +1 -0
  155. package/src/modes/interactive-mode.ts +27 -0
  156. package/src/modes/jobs-observer.ts +204 -0
  157. package/src/modes/rpc/host-tools.ts +1 -186
  158. package/src/modes/rpc/host-uris.ts +1 -235
  159. package/src/modes/rpc/rpc-client.ts +25 -10
  160. package/src/modes/rpc/rpc-mode.ts +12 -381
  161. package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
  162. package/src/modes/shared/agent-wire/command-validation.ts +131 -0
  163. package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
  164. package/src/modes/shared/agent-wire/handshake.ts +117 -0
  165. package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
  166. package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
  167. package/src/modes/shared/agent-wire/protocol.ts +96 -0
  168. package/src/modes/shared/agent-wire/responses.ts +17 -0
  169. package/src/modes/shared/agent-wire/scopes.ts +89 -0
  170. package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
  171. package/src/modes/shared/agent-wire/ui-result.ts +48 -0
  172. package/src/modes/types.ts +2 -0
  173. package/src/prompts/memories/consolidation.md +1 -1
  174. package/src/prompts/memories/read-path.md +6 -7
  175. package/src/prompts/memories/unavailable.md +2 -2
  176. package/src/prompts/tools/bash.md +1 -1
  177. package/src/prompts/tools/irc.md +1 -1
  178. package/src/prompts/tools/read.md +2 -2
  179. package/src/prompts/tools/recall.md +1 -0
  180. package/src/prompts/tools/reflect.md +1 -0
  181. package/src/prompts/tools/retain.md +1 -0
  182. package/src/prompts/tools/subagent.md +12 -7
  183. package/src/prompts/tools/task-summary.md +3 -9
  184. package/src/prompts/tools/task.md +5 -1
  185. package/src/sdk.ts +5 -1
  186. package/src/session/agent-session.ts +214 -38
  187. package/src/skill-state/deep-interview-mutation-guard.ts +23 -4
  188. package/src/skill-state/workflow-state-contract.ts +7 -4
  189. package/src/skill-state/workflow-state-version.ts +3 -0
  190. package/src/slash-commands/builtin-registry.ts +9 -1
  191. package/src/task/executor.ts +31 -5
  192. package/src/task/id.ts +33 -0
  193. package/src/task/index.ts +259 -67
  194. package/src/task/output-manager.ts +5 -4
  195. package/src/task/receipt.ts +297 -0
  196. package/src/task/render.ts +48 -131
  197. package/src/task/spawn-gate.ts +132 -0
  198. package/src/task/types.ts +48 -7
  199. package/src/tools/ask.ts +73 -33
  200. package/src/tools/ast-edit.ts +1 -0
  201. package/src/tools/ast-grep.ts +1 -0
  202. package/src/tools/bash.ts +1 -1
  203. package/src/tools/cron.ts +48 -0
  204. package/src/tools/find.ts +4 -1
  205. package/src/tools/hindsight-recall.ts +0 -2
  206. package/src/tools/hindsight-reflect.ts +0 -2
  207. package/src/tools/hindsight-retain.ts +0 -2
  208. package/src/tools/index.ts +6 -18
  209. package/src/tools/path-utils.ts +3 -2
  210. package/src/tools/read.ts +4 -3
  211. package/src/tools/search.ts +1 -0
  212. package/src/tools/skill.ts +6 -1
  213. package/src/tools/subagent.ts +237 -84
@@ -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";
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Protocol handler for artifact:// URLs.
3
3
  *
4
- * Resolves artifact IDs against the artifacts directories of every active
5
- * session. Unlike agent://, artifacts are raw text with no JSON extraction.
4
+ * Resolves artifact IDs only against artifacts directories explicitly authorized
5
+ * by the caller's ResolveContext. Unlike agent://, artifacts are raw text.
6
6
  *
7
7
  * URL form:
8
8
  * - artifact://<id> - Full artifact content
@@ -12,14 +12,14 @@
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import { isEnoent } from "@gajae-code/utils";
15
- import { artifactsDirsFromRegistry } from "./registry-helpers";
16
- import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
15
+ import { authorizedArtifactsDirsFromContext } from "./registry-helpers";
16
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext } from "./types";
17
17
 
18
18
  export class ArtifactProtocolHandler implements ProtocolHandler {
19
19
  readonly scheme = "artifact";
20
20
  readonly immutable = true;
21
21
 
22
- async resolve(url: InternalUrl): Promise<InternalResource> {
22
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
23
23
  const id = url.rawHost || url.hostname;
24
24
  if (!id) {
25
25
  throw new Error("artifact:// URL requires a numeric ID: artifact://0");
@@ -28,7 +28,7 @@ export class ArtifactProtocolHandler implements ProtocolHandler {
28
28
  throw new Error(`artifact:// ID must be numeric, got: ${id}`);
29
29
  }
30
30
 
31
- const dirs = artifactsDirsFromRegistry();
31
+ const dirs = authorizedArtifactsDirsFromContext(context);
32
32
 
33
33
  if (dirs.length === 0) {
34
34
  throw new Error("No session - artifacts unavailable");
@@ -36,7 +36,6 @@ export class ArtifactProtocolHandler implements ProtocolHandler {
36
36
 
37
37
  let foundPath: string | undefined;
38
38
  let anyDirExists = false;
39
- const availableIds = new Set<string>();
40
39
 
41
40
  for (const dir of dirs) {
42
41
  let files: string[];
@@ -47,14 +46,12 @@ export class ArtifactProtocolHandler implements ProtocolHandler {
47
46
  if (isEnoent(err)) continue;
48
47
  throw err;
49
48
  }
50
- const match = files.find(f => f.startsWith(`${id}.`));
51
- if (match) {
52
- foundPath = path.join(dir, match);
53
- break;
54
- }
55
49
  for (const f of files) {
56
- const m = f.match(/^(\d+)\./);
57
- if (m) availableIds.add(m[1]);
50
+ if (f.endsWith(".meta.json")) continue;
51
+ if (f.startsWith(`${id}.`)) {
52
+ if (foundPath) throw new Error(`artifact://${id} ambiguous id in authorized artifacts`);
53
+ foundPath = path.join(dir, f);
54
+ }
58
55
  }
59
56
  }
60
57
 
@@ -63,9 +60,7 @@ export class ArtifactProtocolHandler implements ProtocolHandler {
63
60
  }
64
61
 
65
62
  if (!foundPath) {
66
- const sorted = [...availableIds].sort((a, b) => Number(a) - Number(b));
67
- const availableStr = sorted.length > 0 ? sorted.join(", ") : "none";
68
- throw new Error(`Artifact ${id} not found. Available: ${availableStr}`);
63
+ throw new Error(`artifact://${id} not found`);
69
64
  }
70
65
 
71
66
  const content = await Bun.file(foundPath).text();