@gajae-code/coding-agent 0.2.5 → 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 (234) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/async/job-manager.d.ts +91 -2
  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/commands/harness.d.ts +37 -0
  6. package/dist/types/config/keybindings.d.ts +5 -0
  7. package/dist/types/config/settings-schema.d.ts +10 -4
  8. package/dist/types/config/settings.d.ts +2 -0
  9. package/dist/types/debug/crash-diagnostics.d.ts +45 -0
  10. package/dist/types/debug/runtime-gauges.d.ts +6 -0
  11. package/dist/types/deep-interview/render-middleware.d.ts +6 -0
  12. package/dist/types/eval/py/executor.d.ts +2 -0
  13. package/dist/types/eval/py/kernel.d.ts +2 -0
  14. package/dist/types/exec/bash-executor.d.ts +10 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  17. package/dist/types/extensibility/shared-events.d.ts +1 -0
  18. package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
  19. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
  20. package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
  21. package/dist/types/gjc-runtime/state-migrations.d.ts +33 -0
  22. package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
  23. package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
  25. package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
  26. package/dist/types/gjc-runtime/state-writer.d.ts +147 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
  28. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
  29. package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
  30. package/dist/types/harness-control-plane/classifier.d.ts +13 -0
  31. package/dist/types/harness-control-plane/control-endpoint.d.ts +31 -0
  32. package/dist/types/harness-control-plane/finalize.d.ts +47 -0
  33. package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
  34. package/dist/types/harness-control-plane/operate.d.ts +35 -0
  35. package/dist/types/harness-control-plane/owner.d.ts +46 -0
  36. package/dist/types/harness-control-plane/preserve.d.ts +19 -0
  37. package/dist/types/harness-control-plane/receipts.d.ts +88 -0
  38. package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
  39. package/dist/types/harness-control-plane/seams.d.ts +21 -0
  40. package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
  41. package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
  42. package/dist/types/harness-control-plane/storage.d.ts +53 -0
  43. package/dist/types/harness-control-plane/types.d.ts +162 -0
  44. package/dist/types/hooks/skill-keywords.d.ts +2 -1
  45. package/dist/types/hooks/skill-state.d.ts +23 -29
  46. package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
  47. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  48. package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
  49. package/dist/types/internal-urls/types.d.ts +4 -0
  50. package/dist/types/lsp/index.d.ts +10 -10
  51. package/dist/types/modes/bridge/auth.d.ts +12 -0
  52. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
  53. package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
  54. package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
  55. package/dist/types/modes/bridge/event-stream.d.ts +8 -0
  56. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  57. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  58. package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
  59. package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
  60. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  61. package/dist/types/modes/components/status-line.d.ts +2 -0
  62. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  63. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  64. package/dist/types/modes/index.d.ts +1 -0
  65. package/dist/types/modes/interactive-mode.d.ts +2 -0
  66. package/dist/types/modes/jobs-observer.d.ts +57 -0
  67. package/dist/types/modes/rpc/host-tools.d.ts +1 -16
  68. package/dist/types/modes/rpc/host-uris.d.ts +1 -38
  69. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
  70. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
  71. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
  72. package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
  73. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
  74. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
  75. package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
  76. package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
  77. package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
  78. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
  79. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
  80. package/dist/types/modes/types.d.ts +2 -0
  81. package/dist/types/sdk.d.ts +4 -0
  82. package/dist/types/session/agent-session.d.ts +19 -1
  83. package/dist/types/skill-state/active-state.d.ts +2 -0
  84. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  85. package/dist/types/skill-state/workflow-state-contract.d.ts +25 -2
  86. package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
  87. package/dist/types/task/executor.d.ts +3 -0
  88. package/dist/types/task/id.d.ts +7 -0
  89. package/dist/types/task/index.d.ts +5 -0
  90. package/dist/types/task/receipt.d.ts +85 -0
  91. package/dist/types/task/spawn-gate.d.ts +38 -0
  92. package/dist/types/task/types.d.ts +198 -14
  93. package/dist/types/tools/cron.d.ts +6 -0
  94. package/dist/types/tools/index.d.ts +2 -0
  95. package/dist/types/tools/path-utils.d.ts +1 -0
  96. package/dist/types/tools/subagent.d.ts +26 -1
  97. package/package.json +7 -7
  98. package/scripts/build-binary.ts +7 -0
  99. package/src/async/job-manager.ts +334 -6
  100. package/src/cli/args.ts +9 -2
  101. package/src/cli/auth-broker-cli.ts +1 -0
  102. package/src/cli/config-cli.ts +10 -2
  103. package/src/cli.ts +2 -0
  104. package/src/commands/deep-interview.ts +1 -0
  105. package/src/commands/harness.ts +862 -0
  106. package/src/commands/launch.ts +2 -2
  107. package/src/commands/state.ts +2 -1
  108. package/src/commands/team.ts +54 -39
  109. package/src/config/keybindings.ts +6 -0
  110. package/src/config/settings-schema.ts +13 -3
  111. package/src/config/settings.ts +5 -0
  112. package/src/dap/client.ts +17 -3
  113. package/src/debug/crash-diagnostics.ts +223 -0
  114. package/src/debug/runtime-gauges.ts +20 -0
  115. package/src/deep-interview/render-middleware.ts +372 -0
  116. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  117. package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
  118. package/src/defaults/gjc/skills/team/SKILL.md +47 -21
  119. package/src/defaults/gjc/skills/ultragoal/SKILL.md +106 -13
  120. package/src/eval/py/executor.ts +21 -1
  121. package/src/eval/py/kernel.ts +15 -0
  122. package/src/exec/bash-executor.ts +41 -0
  123. package/src/extensibility/custom-tools/types.ts +1 -0
  124. package/src/extensibility/extensions/types.ts +6 -0
  125. package/src/extensibility/shared-events.ts +1 -0
  126. package/src/gjc-runtime/cli-write-receipt.ts +31 -0
  127. package/src/gjc-runtime/deep-interview-runtime.ts +98 -42
  128. package/src/gjc-runtime/goal-mode-request.ts +11 -3
  129. package/src/gjc-runtime/ralplan-runtime.ts +235 -43
  130. package/src/gjc-runtime/state-graph.ts +86 -0
  131. package/src/gjc-runtime/state-migrations.ts +179 -0
  132. package/src/gjc-runtime/state-renderer.ts +345 -0
  133. package/src/gjc-runtime/state-runtime.ts +1155 -46
  134. package/src/gjc-runtime/state-schema.ts +192 -0
  135. package/src/gjc-runtime/state-validation.ts +49 -0
  136. package/src/gjc-runtime/state-writer.ts +749 -0
  137. package/src/gjc-runtime/team-runtime.ts +1255 -189
  138. package/src/gjc-runtime/ultragoal-runtime.ts +460 -43
  139. package/src/gjc-runtime/workflow-command-ref.ts +239 -0
  140. package/src/gjc-runtime/workflow-manifest.generated.json +1601 -0
  141. package/src/gjc-runtime/workflow-manifest.ts +427 -0
  142. package/src/harness-control-plane/classifier.ts +128 -0
  143. package/src/harness-control-plane/control-endpoint.ts +148 -0
  144. package/src/harness-control-plane/finalize.ts +222 -0
  145. package/src/harness-control-plane/frame-mapper.ts +286 -0
  146. package/src/harness-control-plane/operate.ts +225 -0
  147. package/src/harness-control-plane/owner.ts +600 -0
  148. package/src/harness-control-plane/preserve.ts +102 -0
  149. package/src/harness-control-plane/receipts.ts +216 -0
  150. package/src/harness-control-plane/rpc-adapter.ts +276 -0
  151. package/src/harness-control-plane/seams.ts +39 -0
  152. package/src/harness-control-plane/session-lease.ts +388 -0
  153. package/src/harness-control-plane/state-machine.ts +98 -0
  154. package/src/harness-control-plane/storage.ts +257 -0
  155. package/src/harness-control-plane/types.ts +214 -0
  156. package/src/hooks/skill-keywords.ts +4 -2
  157. package/src/hooks/skill-state.ts +197 -64
  158. package/src/internal-urls/agent-protocol.ts +68 -21
  159. package/src/internal-urls/artifact-protocol.ts +12 -17
  160. package/src/internal-urls/docs-index.generated.ts +3 -2
  161. package/src/internal-urls/registry-helpers.ts +19 -16
  162. package/src/internal-urls/types.ts +4 -0
  163. package/src/lsp/client.ts +18 -2
  164. package/src/main.ts +21 -5
  165. package/src/modes/bridge/auth.ts +41 -0
  166. package/src/modes/bridge/bridge-client-bridge.ts +47 -0
  167. package/src/modes/bridge/bridge-mode.ts +520 -0
  168. package/src/modes/bridge/bridge-ui-context.ts +200 -0
  169. package/src/modes/bridge/event-stream.ts +70 -0
  170. package/src/modes/components/assistant-message.ts +5 -1
  171. package/src/modes/components/custom-editor.ts +101 -0
  172. package/src/modes/components/hook-selector.ts +133 -20
  173. package/src/modes/components/jobs-overlay-model.ts +109 -0
  174. package/src/modes/components/jobs-overlay.ts +172 -0
  175. package/src/modes/components/status-line/presets.ts +7 -5
  176. package/src/modes/components/status-line/segments.ts +25 -0
  177. package/src/modes/components/status-line/types.ts +2 -0
  178. package/src/modes/components/status-line.ts +9 -1
  179. package/src/modes/controllers/event-controller.ts +71 -6
  180. package/src/modes/controllers/extension-ui-controller.ts +43 -1
  181. package/src/modes/controllers/input-controller.ts +105 -9
  182. package/src/modes/controllers/selector-controller.ts +31 -1
  183. package/src/modes/index.ts +1 -0
  184. package/src/modes/interactive-mode.ts +28 -0
  185. package/src/modes/jobs-observer.ts +204 -0
  186. package/src/modes/rpc/host-tools.ts +1 -186
  187. package/src/modes/rpc/host-uris.ts +1 -235
  188. package/src/modes/rpc/rpc-client.ts +25 -10
  189. package/src/modes/rpc/rpc-mode.ts +12 -381
  190. package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
  191. package/src/modes/shared/agent-wire/command-validation.ts +131 -0
  192. package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
  193. package/src/modes/shared/agent-wire/handshake.ts +117 -0
  194. package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
  195. package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
  196. package/src/modes/shared/agent-wire/protocol.ts +96 -0
  197. package/src/modes/shared/agent-wire/responses.ts +17 -0
  198. package/src/modes/shared/agent-wire/scopes.ts +89 -0
  199. package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
  200. package/src/modes/shared/agent-wire/ui-result.ts +48 -0
  201. package/src/modes/types.ts +2 -0
  202. package/src/prompts/agents/executor.md +13 -0
  203. package/src/prompts/tools/subagent.md +39 -4
  204. package/src/prompts/tools/task-summary.md +3 -9
  205. package/src/prompts/tools/task.md +5 -1
  206. package/src/sdk.ts +8 -0
  207. package/src/session/agent-session.ts +445 -71
  208. package/src/session/session-manager.ts +13 -1
  209. package/src/skill-state/active-state.ts +58 -65
  210. package/src/skill-state/deep-interview-mutation-guard.ts +114 -17
  211. package/src/skill-state/initial-phase.ts +2 -0
  212. package/src/skill-state/workflow-state-contract.ts +33 -4
  213. package/src/skill-state/workflow-state-version.ts +3 -0
  214. package/src/slash-commands/builtin-registry.ts +8 -0
  215. package/src/task/executor.ts +79 -13
  216. package/src/task/id.ts +33 -0
  217. package/src/task/index.ts +376 -74
  218. package/src/task/output-manager.ts +5 -4
  219. package/src/task/receipt.ts +297 -0
  220. package/src/task/render.ts +54 -134
  221. package/src/task/spawn-gate.ts +132 -0
  222. package/src/task/types.ts +104 -10
  223. package/src/tools/ask.ts +88 -27
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash.ts +1 -1
  227. package/src/tools/cron.ts +48 -0
  228. package/src/tools/find.ts +4 -1
  229. package/src/tools/index.ts +2 -0
  230. package/src/tools/path-utils.ts +3 -2
  231. package/src/tools/read.ts +1 -0
  232. package/src/tools/search.ts +1 -0
  233. package/src/tools/skill.ts +6 -1
  234. package/src/tools/subagent.ts +423 -79
@@ -1,9 +1,15 @@
1
- import * as fs from "node:fs/promises";
2
1
  import * as path from "node:path";
3
2
  import type { SkillDiscoverySettings } from "../config/skill-settings-defaults";
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
- import type { SkillActiveEntry as CanonicalSkillActiveEntry, WorkflowHudSummary } from "../skill-state/active-state";
7
+ import {
8
+ readVisibleSkillActiveState as readCanonicalVisibleSkillActiveState,
9
+ type SkillActiveEntry,
10
+ type SkillActiveState,
11
+ } from "../skill-state/active-state";
12
+ import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
7
13
  import {
8
14
  compareSkillKeywordMatches,
9
15
  GJC_SKILL_KEYWORD_DEFINITIONS,
@@ -74,35 +80,7 @@ export interface SkillKeywordMatch {
74
80
  priority: number;
75
81
  }
76
82
 
77
- export interface SkillActiveEntry extends Omit<CanonicalSkillActiveEntry, "skill"> {
78
- skill: GjcWorkflowSkill;
79
- phase?: string;
80
- active?: boolean;
81
- activated_at?: string;
82
- updated_at?: string;
83
- session_id?: string;
84
- thread_id?: string;
85
- turn_id?: string;
86
- hud?: WorkflowHudSummary;
87
- stale?: boolean;
88
- }
89
-
90
- export interface SkillActiveState {
91
- version: number;
92
- active: boolean;
93
- skill: GjcWorkflowSkill;
94
- keyword: string;
95
- phase: string;
96
- activated_at: string;
97
- updated_at: string;
98
- source: "gjc-skill-state-hook";
99
- session_id?: string;
100
- thread_id?: string;
101
- turn_id?: string;
102
- initialized_mode?: GjcWorkflowSkill;
103
- initialized_state_path?: string;
104
- active_skills: SkillActiveEntry[];
105
- }
83
+ export type { SkillActiveEntry, SkillActiveState } from "../skill-state/active-state";
106
84
 
107
85
  export interface ModeState {
108
86
  active?: boolean;
@@ -242,19 +220,43 @@ function skillStatePath(stateDir: string, sessionId?: string): string {
242
220
  return path.join(stateDir, SKILL_ACTIVE_STATE_FILE);
243
221
  }
244
222
 
245
- 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;
246
233
  try {
247
- const raw = await Bun.file(filePath).text();
248
- return JSON.parse(raw) as T;
234
+ raw = await Bun.file(filePath).text();
249
235
  } catch (error) {
250
236
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return null;
237
+ warnInvalidState(kind, filePath, `read error: ${(error as Error).message}`);
238
+ return null;
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);
251
250
  return null;
252
251
  }
252
+ return value;
253
253
  }
254
254
 
255
- async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
256
- await fs.mkdir(path.dirname(filePath), { recursive: true });
257
- await Bun.write(filePath, `${JSON.stringify(value, null, 2)}\n`);
255
+ async function writeJsonFile(filePath: string, value: unknown, cwd: string): Promise<void> {
256
+ await writeJsonAtomic(filePath, value, {
257
+ cwd,
258
+ audit: { category: "state", verb: "write", owner: "gjc-hook" },
259
+ });
258
260
  }
259
261
 
260
262
  function entryMatchesContext(
@@ -272,7 +274,11 @@ function entryMatchesContext(
272
274
 
273
275
  function listActiveSkills(state: SkillActiveState | null): SkillActiveEntry[] {
274
276
  if (!state?.active) return [];
275
- return state.active_skills.filter(entry => entry.active !== false);
277
+ return (state.active_skills ?? []).filter(entry => entry.active !== false);
278
+ }
279
+
280
+ function isWorkflowActiveEntry(entry: SkillActiveEntry): entry is SkillActiveEntry & { skill: GjcWorkflowSkill } {
281
+ return isGjcWorkflowSkill(entry.skill);
276
282
  }
277
283
 
278
284
  export async function readVisibleSkillActiveState(
@@ -280,24 +286,44 @@ export async function readVisibleSkillActiveState(
280
286
  sessionId?: string,
281
287
  stateDir?: string,
282
288
  ): Promise<SkillActiveState | null> {
289
+ if (!stateDir) return await readCanonicalVisibleSkillActiveState(cwd, sessionId);
283
290
  const resolvedStateDir = resolveGjcStateDir(cwd, stateDir);
284
291
  if (sessionId) {
285
- 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
+ );
286
297
  if (sessionState) return sessionState;
287
298
  }
288
- return await readJsonFile<SkillActiveState>(skillStatePath(resolvedStateDir));
299
+ return await readValidatedJsonFile<SkillActiveState>(
300
+ skillStatePath(resolvedStateDir),
301
+ "skill-active-state",
302
+ SkillActiveStateSchema,
303
+ );
289
304
  }
290
305
 
291
- export async function recordSkillActivation(input: RecordSkillActivationInput): Promise<SkillActiveState | null> {
292
- const match = detectPrimarySkillKeyword(input.text);
293
- 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
+ }
294
314
 
315
+ async function seedSkillActivationState(
316
+ skill: GjcWorkflowSkill,
317
+ keyword: string,
318
+ source: string,
319
+ input: SeedSkillActivationStateInput,
320
+ ): Promise<SkillActiveState> {
295
321
  const resolvedStateDir = resolveGjcStateDir(input.cwd, input.stateDir);
296
322
  const nowIso = input.nowIso ?? new Date().toISOString();
297
- const phase = initialPhaseForSkill(match.skill);
298
- const initializedStatePath = modeStatePath(resolvedStateDir, match.skill, input.sessionId);
323
+ const phase = initialPhaseForSkill(skill);
324
+ const initializedStatePath = modeStatePath(resolvedStateDir, skill, input.sessionId);
299
325
  const entry: SkillActiveEntry = {
300
- skill: match.skill,
326
+ skill,
301
327
  phase,
302
328
  active: true,
303
329
  activated_at: nowIso,
@@ -309,41 +335,102 @@ export async function recordSkillActivation(input: RecordSkillActivationInput):
309
335
  const state: SkillActiveState = {
310
336
  version: 1,
311
337
  active: true,
312
- skill: match.skill,
313
- keyword: match.keyword,
338
+ skill,
339
+ keyword,
314
340
  phase,
315
341
  activated_at: nowIso,
316
342
  updated_at: nowIso,
317
- source: "gjc-skill-state-hook",
343
+ source,
318
344
  ...(input.sessionId ? { session_id: input.sessionId } : {}),
319
345
  ...(input.threadId ? { thread_id: input.threadId } : {}),
320
346
  ...(input.turnId ? { turn_id: input.turnId } : {}),
321
- initialized_mode: match.skill,
347
+ initialized_mode: skill,
322
348
  initialized_state_path: initializedStatePath,
323
349
  active_skills: [entry],
324
350
  };
325
351
  const modeState: ModeState = {
326
352
  active: true,
353
+ version: WORKFLOW_STATE_VERSION,
327
354
  current_phase: phase,
328
- skill: match.skill,
355
+ skill,
329
356
  cwd: input.cwd,
330
357
  updated_at: nowIso,
331
358
  ...(input.sessionId ? { session_id: input.sessionId } : {}),
332
359
  ...(input.threadId ? { thread_id: input.threadId } : {}),
333
360
  ...(input.turnId ? { turn_id: input.turnId } : {}),
334
361
  };
335
- if (match.skill === "deep-interview") {
362
+ if (skill === "deep-interview") {
336
363
  modeState.threshold = DEFAULT_DEEP_INTERVIEW_AMBIGUITY_THRESHOLD;
337
364
  modeState.threshold_source = "default";
338
365
  }
339
366
 
340
- await writeJsonFile(initializedStatePath, modeState);
341
- await writeJsonFile(skillStatePath(resolvedStateDir, input.sessionId), state);
342
- if (!input.sessionId) return state;
343
- await writeJsonFile(skillStatePath(resolvedStateDir), state);
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
+ });
378
+ await writeJsonFile(skillStatePath(resolvedStateDir, input.sessionId), state, input.cwd);
379
+ if (input.sessionId) {
380
+ await writeJsonFile(skillStatePath(resolvedStateDir), state, input.cwd);
381
+ }
344
382
  return state;
345
383
  }
346
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
+
347
434
  function isTerminalModeState(state: ModeState | null): boolean {
348
435
  if (state?.active !== true) return true;
349
436
  const phase = String(state.current_phase ?? "")
@@ -352,6 +439,45 @@ function isTerminalModeState(state: ModeState | null): boolean {
352
439
  return ["complete", "completed", "handoff", "failed", "cancelled", "canceled", "inactive"].includes(phase);
353
440
  }
354
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
+
355
481
  async function readVisibleModeState(
356
482
  cwd: string,
357
483
  skill: GjcWorkflowSkill,
@@ -361,11 +487,11 @@ async function readVisibleModeState(
361
487
  const resolvedStateDir = resolveGjcStateDir(cwd, stateDir);
362
488
  if (sessionId) {
363
489
  const sessionStatePath = modeStatePath(resolvedStateDir, skill, sessionId);
364
- const sessionState = await readJsonFile<ModeState>(sessionStatePath);
490
+ const sessionState = await readValidatedJsonFile<ModeState>(sessionStatePath, "mode-state", ModeStateSchema);
365
491
  if (sessionState) return { state: sessionState, statePath: sessionStatePath };
366
492
  }
367
493
  const rootStatePath = modeStatePath(resolvedStateDir, skill);
368
- const rootState = await readJsonFile<ModeState>(rootStatePath);
494
+ const rootState = await readValidatedJsonFile<ModeState>(rootStatePath, "mode-state", ModeStateSchema);
369
495
  if (!rootState) return null;
370
496
  return { state: rootState, statePath: rootStatePath };
371
497
  }
@@ -432,14 +558,19 @@ export async function buildActiveUltragoalPromptContext(input: UserPromptSubmitS
432
558
  export async function buildSkillStopOutput(input: StopHookInput): Promise<Record<string, unknown> | null> {
433
559
  const resolvedStateDir = resolveGjcStateDir(input.cwd, input.stateDir);
434
560
  const skillState = await readVisibleSkillActiveState(input.cwd, input.sessionId, input.stateDir);
435
- const activeEntries = listActiveSkills(skillState).filter(entry =>
436
- skillState ? entryMatchesContext(entry, skillState, input.sessionId, input.threadId) : false,
437
- );
561
+ const activeEntries = listActiveSkills(skillState)
562
+ .filter(isWorkflowActiveEntry)
563
+ .filter(entry => (skillState ? entryMatchesContext(entry, skillState, input.sessionId, input.threadId) : false));
438
564
  if (!skillState || activeEntries.length === 0) return null;
439
565
 
440
566
  for (const entry of activeEntries) {
441
- const modeState = await readJsonFile<ModeState>(modeStatePath(resolvedStateDir, entry.skill, input.sessionId));
442
- 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;
443
574
  const phase = String(modeState?.current_phase ?? entry.phase ?? skillState.phase ?? "active");
444
575
  const statePath = modeStatePath(resolvedStateDir, entry.skill, input.sessionId);
445
576
  if (entry.skill === "ultragoal") {
@@ -467,7 +598,9 @@ export async function buildSkillStopOutput(input: StopHookInput): Promise<Record
467
598
  }
468
599
  }
469
600
  }
470
- 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.`;
471
604
  return {
472
605
  decision: "block",
473
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();