@gajae-code/coding-agent 0.2.4 → 0.3.0

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 (266) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +145 -2
  4. package/dist/types/commands/harness.d.ts +37 -0
  5. package/dist/types/config/settings-schema.d.ts +13 -3
  6. package/dist/types/config/settings.d.ts +3 -1
  7. package/dist/types/deep-interview/render-middleware.d.ts +5 -0
  8. package/dist/types/discovery/helpers.d.ts +1 -0
  9. package/dist/types/exec/bash-executor.d.ts +8 -1
  10. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  12. package/dist/types/extensibility/shared-events.d.ts +1 -0
  13. package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +2 -0
  14. package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
  15. package/dist/types/gjc-runtime/state-migrations.d.ts +24 -0
  16. package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
  17. package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
  18. package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
  19. package/dist/types/gjc-runtime/state-writer.d.ts +137 -0
  20. package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
  21. package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
  22. package/dist/types/harness-control-plane/classifier.d.ts +13 -0
  23. package/dist/types/harness-control-plane/control-endpoint.d.ts +30 -0
  24. package/dist/types/harness-control-plane/finalize.d.ts +47 -0
  25. package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
  26. package/dist/types/harness-control-plane/operate.d.ts +35 -0
  27. package/dist/types/harness-control-plane/owner.d.ts +46 -0
  28. package/dist/types/harness-control-plane/preserve.d.ts +19 -0
  29. package/dist/types/harness-control-plane/receipts.d.ts +88 -0
  30. package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
  31. package/dist/types/harness-control-plane/seams.d.ts +21 -0
  32. package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
  33. package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
  34. package/dist/types/harness-control-plane/storage.d.ts +53 -0
  35. package/dist/types/harness-control-plane/types.d.ts +162 -0
  36. package/dist/types/hooks/skill-keywords.d.ts +2 -1
  37. package/dist/types/hooks/skill-state.d.ts +2 -29
  38. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
  39. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  40. package/dist/types/modes/components/skill-hud/render.d.ts +1 -1
  41. package/dist/types/modes/interactive-mode.d.ts +2 -0
  42. package/dist/types/modes/theme/defaults/index.d.ts +45 -9477
  43. package/dist/types/modes/theme/theme.d.ts +1 -5
  44. package/dist/types/modes/types.d.ts +2 -0
  45. package/dist/types/sdk.d.ts +4 -0
  46. package/dist/types/session/agent-session.d.ts +8 -0
  47. package/dist/types/session/streaming-output.d.ts +11 -0
  48. package/dist/types/skill-state/active-state.d.ts +3 -0
  49. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  50. package/dist/types/skill-state/workflow-state-contract.d.ts +24 -0
  51. package/dist/types/task/executor.d.ts +3 -0
  52. package/dist/types/task/types.d.ts +56 -3
  53. package/dist/types/tools/bash-allowed-prefixes.d.ts +5 -0
  54. package/dist/types/tools/bash.d.ts +24 -0
  55. package/dist/types/tools/cron.d.ts +110 -0
  56. package/dist/types/tools/index.d.ts +4 -0
  57. package/dist/types/tools/monitor.d.ts +54 -0
  58. package/dist/types/tools/subagent.d.ts +11 -1
  59. package/dist/types/web/search/index.d.ts +1 -0
  60. package/dist/types/web/search/provider.d.ts +11 -4
  61. package/dist/types/web/search/providers/duckduckgo.d.ts +57 -0
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +7 -7
  64. package/src/async/job-manager.ts +522 -6
  65. package/src/cli/agents-cli.ts +3 -0
  66. package/src/cli/auth-broker-cli.ts +1 -0
  67. package/src/cli/config-cli.ts +10 -2
  68. package/src/cli.ts +2 -0
  69. package/src/commands/harness.ts +592 -0
  70. package/src/commands/team.ts +36 -39
  71. package/src/config/settings-schema.ts +15 -2
  72. package/src/config/settings.ts +49 -7
  73. package/src/deep-interview/render-middleware.ts +366 -0
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +9 -2
  75. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
  76. package/src/defaults/gjc/skills/team/SKILL.md +47 -21
  77. package/src/defaults/gjc/skills/ultragoal/SKILL.md +78 -11
  78. package/src/discovery/helpers.ts +5 -0
  79. package/src/eval/js/shared/rewrite-imports.ts +1 -2
  80. package/src/exec/bash-executor.ts +20 -9
  81. package/src/extensibility/custom-tools/types.ts +1 -0
  82. package/src/extensibility/extensions/types.ts +6 -0
  83. package/src/extensibility/shared-events.ts +1 -0
  84. package/src/gjc-runtime/deep-interview-runtime.ts +40 -21
  85. package/src/gjc-runtime/goal-mode-request.ts +11 -3
  86. package/src/gjc-runtime/ralplan-runtime.ts +27 -10
  87. package/src/gjc-runtime/restricted-role-agent-bash.ts +5 -0
  88. package/src/gjc-runtime/state-graph.ts +86 -0
  89. package/src/gjc-runtime/state-migrations.ts +132 -0
  90. package/src/gjc-runtime/state-renderer.ts +345 -0
  91. package/src/gjc-runtime/state-runtime.ts +733 -21
  92. package/src/gjc-runtime/state-validation.ts +49 -0
  93. package/src/gjc-runtime/state-writer.ts +718 -0
  94. package/src/gjc-runtime/team-runtime.ts +1083 -89
  95. package/src/gjc-runtime/ultragoal-runtime.ts +348 -19
  96. package/src/gjc-runtime/workflow-manifest.generated.json +1497 -0
  97. package/src/gjc-runtime/workflow-manifest.ts +425 -0
  98. package/src/harness-control-plane/classifier.ts +128 -0
  99. package/src/harness-control-plane/control-endpoint.ts +137 -0
  100. package/src/harness-control-plane/finalize.ts +222 -0
  101. package/src/harness-control-plane/frame-mapper.ts +286 -0
  102. package/src/harness-control-plane/operate.ts +225 -0
  103. package/src/harness-control-plane/owner.ts +553 -0
  104. package/src/harness-control-plane/preserve.ts +102 -0
  105. package/src/harness-control-plane/receipts.ts +216 -0
  106. package/src/harness-control-plane/rpc-adapter.ts +276 -0
  107. package/src/harness-control-plane/seams.ts +39 -0
  108. package/src/harness-control-plane/session-lease.ts +388 -0
  109. package/src/harness-control-plane/state-machine.ts +97 -0
  110. package/src/harness-control-plane/storage.ts +257 -0
  111. package/src/harness-control-plane/types.ts +214 -0
  112. package/src/hooks/skill-keywords.ts +4 -2
  113. package/src/hooks/skill-state.ts +25 -42
  114. package/src/internal-urls/docs-index.generated.ts +6 -4
  115. package/src/lsp/render.ts +1 -1
  116. package/src/modes/acp/acp-agent.ts +1 -1
  117. package/src/modes/acp/acp-client-bridge.ts +1 -1
  118. package/src/modes/components/agent-dashboard.ts +1 -1
  119. package/src/modes/components/assistant-message.ts +5 -1
  120. package/src/modes/components/diff.ts +2 -2
  121. package/src/modes/components/hook-selector.ts +72 -2
  122. package/src/modes/components/skill-hud/render.ts +7 -2
  123. package/src/modes/controllers/event-controller.ts +71 -6
  124. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  125. package/src/modes/controllers/input-controller.ts +19 -3
  126. package/src/modes/controllers/selector-controller.ts +3 -2
  127. package/src/modes/interactive-mode.ts +21 -2
  128. package/src/modes/theme/defaults/index.ts +0 -196
  129. package/src/modes/theme/theme.ts +35 -35
  130. package/src/modes/types.ts +2 -0
  131. package/src/prompts/agents/architect.md +5 -1
  132. package/src/prompts/agents/critic.md +5 -1
  133. package/src/prompts/agents/executor.md +13 -0
  134. package/src/prompts/agents/frontmatter.md +1 -0
  135. package/src/prompts/agents/planner.md +5 -1
  136. package/src/prompts/tools/bash.md +9 -0
  137. package/src/prompts/tools/cron.md +25 -0
  138. package/src/prompts/tools/monitor.md +30 -0
  139. package/src/prompts/tools/subagent.md +33 -3
  140. package/src/runtime-mcp/oauth-flow.ts +4 -2
  141. package/src/sdk.ts +7 -0
  142. package/src/session/agent-session.ts +247 -38
  143. package/src/session/session-manager.ts +13 -1
  144. package/src/session/streaming-output.ts +21 -0
  145. package/src/skill-state/active-state.ts +222 -78
  146. package/src/skill-state/deep-interview-mutation-guard.ts +91 -13
  147. package/src/skill-state/initial-phase.ts +2 -0
  148. package/src/skill-state/workflow-state-contract.ts +26 -0
  149. package/src/task/agents.ts +1 -0
  150. package/src/task/executor.ts +51 -8
  151. package/src/task/index.ts +120 -8
  152. package/src/task/render.ts +6 -3
  153. package/src/task/types.ts +57 -3
  154. package/src/tools/ask.ts +28 -7
  155. package/src/tools/bash-allowed-prefixes.ts +169 -0
  156. package/src/tools/bash.ts +190 -29
  157. package/src/tools/browser/tab-worker.ts +1 -1
  158. package/src/tools/cron.ts +665 -0
  159. package/src/tools/index.ts +20 -2
  160. package/src/tools/monitor.ts +136 -0
  161. package/src/tools/subagent.ts +255 -64
  162. package/src/vim/engine.ts +3 -3
  163. package/src/web/search/index.ts +31 -18
  164. package/src/web/search/provider.ts +57 -12
  165. package/src/web/search/providers/duckduckgo.ts +279 -0
  166. package/src/web/search/types.ts +2 -0
  167. package/src/modes/theme/dark.json +0 -95
  168. package/src/modes/theme/defaults/alabaster.json +0 -93
  169. package/src/modes/theme/defaults/amethyst.json +0 -96
  170. package/src/modes/theme/defaults/anthracite.json +0 -93
  171. package/src/modes/theme/defaults/basalt.json +0 -91
  172. package/src/modes/theme/defaults/birch.json +0 -95
  173. package/src/modes/theme/defaults/dark-abyss.json +0 -91
  174. package/src/modes/theme/defaults/dark-arctic.json +0 -104
  175. package/src/modes/theme/defaults/dark-aurora.json +0 -95
  176. package/src/modes/theme/defaults/dark-catppuccin.json +0 -107
  177. package/src/modes/theme/defaults/dark-cavern.json +0 -91
  178. package/src/modes/theme/defaults/dark-copper.json +0 -95
  179. package/src/modes/theme/defaults/dark-cosmos.json +0 -90
  180. package/src/modes/theme/defaults/dark-cyberpunk.json +0 -102
  181. package/src/modes/theme/defaults/dark-dracula.json +0 -98
  182. package/src/modes/theme/defaults/dark-eclipse.json +0 -91
  183. package/src/modes/theme/defaults/dark-ember.json +0 -95
  184. package/src/modes/theme/defaults/dark-equinox.json +0 -90
  185. package/src/modes/theme/defaults/dark-forest.json +0 -96
  186. package/src/modes/theme/defaults/dark-github.json +0 -105
  187. package/src/modes/theme/defaults/dark-gruvbox.json +0 -112
  188. package/src/modes/theme/defaults/dark-lavender.json +0 -95
  189. package/src/modes/theme/defaults/dark-lunar.json +0 -89
  190. package/src/modes/theme/defaults/dark-midnight.json +0 -95
  191. package/src/modes/theme/defaults/dark-monochrome.json +0 -94
  192. package/src/modes/theme/defaults/dark-monokai.json +0 -98
  193. package/src/modes/theme/defaults/dark-nebula.json +0 -90
  194. package/src/modes/theme/defaults/dark-nord.json +0 -97
  195. package/src/modes/theme/defaults/dark-ocean.json +0 -101
  196. package/src/modes/theme/defaults/dark-one.json +0 -100
  197. package/src/modes/theme/defaults/dark-poimandres.json +0 -141
  198. package/src/modes/theme/defaults/dark-rainforest.json +0 -91
  199. package/src/modes/theme/defaults/dark-reef.json +0 -91
  200. package/src/modes/theme/defaults/dark-retro.json +0 -92
  201. package/src/modes/theme/defaults/dark-rose-pine.json +0 -96
  202. package/src/modes/theme/defaults/dark-sakura.json +0 -95
  203. package/src/modes/theme/defaults/dark-slate.json +0 -95
  204. package/src/modes/theme/defaults/dark-solarized.json +0 -97
  205. package/src/modes/theme/defaults/dark-solstice.json +0 -90
  206. package/src/modes/theme/defaults/dark-starfall.json +0 -91
  207. package/src/modes/theme/defaults/dark-sunset.json +0 -99
  208. package/src/modes/theme/defaults/dark-swamp.json +0 -90
  209. package/src/modes/theme/defaults/dark-synthwave.json +0 -103
  210. package/src/modes/theme/defaults/dark-taiga.json +0 -91
  211. package/src/modes/theme/defaults/dark-terminal.json +0 -95
  212. package/src/modes/theme/defaults/dark-tokyo-night.json +0 -101
  213. package/src/modes/theme/defaults/dark-tundra.json +0 -91
  214. package/src/modes/theme/defaults/dark-twilight.json +0 -91
  215. package/src/modes/theme/defaults/dark-volcanic.json +0 -91
  216. package/src/modes/theme/defaults/graphite.json +0 -92
  217. package/src/modes/theme/defaults/light-arctic.json +0 -107
  218. package/src/modes/theme/defaults/light-aurora-day.json +0 -91
  219. package/src/modes/theme/defaults/light-canyon.json +0 -91
  220. package/src/modes/theme/defaults/light-catppuccin.json +0 -106
  221. package/src/modes/theme/defaults/light-cirrus.json +0 -90
  222. package/src/modes/theme/defaults/light-coral.json +0 -95
  223. package/src/modes/theme/defaults/light-cyberpunk.json +0 -96
  224. package/src/modes/theme/defaults/light-dawn.json +0 -90
  225. package/src/modes/theme/defaults/light-dunes.json +0 -91
  226. package/src/modes/theme/defaults/light-eucalyptus.json +0 -95
  227. package/src/modes/theme/defaults/light-forest.json +0 -100
  228. package/src/modes/theme/defaults/light-frost.json +0 -95
  229. package/src/modes/theme/defaults/light-github.json +0 -115
  230. package/src/modes/theme/defaults/light-glacier.json +0 -91
  231. package/src/modes/theme/defaults/light-gruvbox.json +0 -108
  232. package/src/modes/theme/defaults/light-haze.json +0 -90
  233. package/src/modes/theme/defaults/light-honeycomb.json +0 -95
  234. package/src/modes/theme/defaults/light-lagoon.json +0 -91
  235. package/src/modes/theme/defaults/light-lavender.json +0 -95
  236. package/src/modes/theme/defaults/light-meadow.json +0 -91
  237. package/src/modes/theme/defaults/light-mint.json +0 -95
  238. package/src/modes/theme/defaults/light-monochrome.json +0 -101
  239. package/src/modes/theme/defaults/light-ocean.json +0 -99
  240. package/src/modes/theme/defaults/light-one.json +0 -99
  241. package/src/modes/theme/defaults/light-opal.json +0 -91
  242. package/src/modes/theme/defaults/light-orchard.json +0 -91
  243. package/src/modes/theme/defaults/light-paper.json +0 -95
  244. package/src/modes/theme/defaults/light-poimandres.json +0 -141
  245. package/src/modes/theme/defaults/light-prism.json +0 -90
  246. package/src/modes/theme/defaults/light-retro.json +0 -98
  247. package/src/modes/theme/defaults/light-sand.json +0 -95
  248. package/src/modes/theme/defaults/light-savanna.json +0 -91
  249. package/src/modes/theme/defaults/light-solarized.json +0 -102
  250. package/src/modes/theme/defaults/light-soleil.json +0 -90
  251. package/src/modes/theme/defaults/light-sunset.json +0 -99
  252. package/src/modes/theme/defaults/light-synthwave.json +0 -98
  253. package/src/modes/theme/defaults/light-tokyo-night.json +0 -111
  254. package/src/modes/theme/defaults/light-wetland.json +0 -91
  255. package/src/modes/theme/defaults/light-zenith.json +0 -89
  256. package/src/modes/theme/defaults/limestone.json +0 -94
  257. package/src/modes/theme/defaults/mahogany.json +0 -97
  258. package/src/modes/theme/defaults/marble.json +0 -93
  259. package/src/modes/theme/defaults/obsidian.json +0 -91
  260. package/src/modes/theme/defaults/onyx.json +0 -91
  261. package/src/modes/theme/defaults/pearl.json +0 -93
  262. package/src/modes/theme/defaults/porcelain.json +0 -91
  263. package/src/modes/theme/defaults/quartz.json +0 -96
  264. package/src/modes/theme/defaults/sandstone.json +0 -95
  265. package/src/modes/theme/defaults/titanium.json +0 -90
  266. package/src/modes/theme/light.json +0 -93
@@ -1,5 +1,10 @@
1
- import * as fs from "node:fs/promises";
2
1
  import * as path from "node:path";
2
+ import {
3
+ type ActiveSessionScope,
4
+ rebuildActiveSnapshot,
5
+ removeActiveEntry,
6
+ writeActiveEntry,
7
+ } from "../gjc-runtime/state-writer";
3
8
  import type { WorkflowStateReceipt } from "./workflow-state-contract";
4
9
 
5
10
  export const SKILL_ACTIVE_STATE_FILE = "skill-active-state.json";
@@ -56,6 +61,8 @@ export interface SkillActiveState {
56
61
  session_id?: string;
57
62
  thread_id?: string;
58
63
  turn_id?: string;
64
+ initialized_mode?: CanonicalGjcWorkflowSkill;
65
+ initialized_state_path?: string;
59
66
  active_skills?: SkillActiveEntry[];
60
67
  [key: string]: unknown;
61
68
  }
@@ -286,14 +293,6 @@ export function getSkillActiveStatePaths(cwd: string, sessionId?: string): Skill
286
293
  };
287
294
  }
288
295
 
289
- async function readStateFile(filePath: string): Promise<SkillActiveState | null> {
290
- try {
291
- return normalizeSkillActiveState(JSON.parse(await Bun.file(filePath).text()));
292
- } catch {
293
- return null;
294
- }
295
- }
296
-
297
296
  /**
298
297
  * Raw read for handoff mutations. Returns the *unnormalized* parsed object so
299
298
  * inactive entries remain visible to `rawActiveEntries` — `normalizeSkillActiveState`
@@ -327,11 +326,33 @@ async function readRawActiveStateForHandoff(filePath: string, strict: boolean):
327
326
  }
328
327
 
329
328
  function rawActiveEntries(state: SkillActiveState | null): SkillActiveEntry[] {
330
- if (!state || !Array.isArray(state.active_skills)) return [];
329
+ if (!state) return [];
331
330
  const out: SkillActiveEntry[] = [];
332
- for (const candidate of state.active_skills) {
333
- const normalized = normalizeEntry(candidate);
334
- if (normalized) out.push(normalized);
331
+ if (Array.isArray(state.active_skills)) {
332
+ for (const candidate of state.active_skills) {
333
+ const normalized = normalizeEntry(candidate);
334
+ if (normalized) out.push(normalized);
335
+ }
336
+ }
337
+ // Legacy top-level fallback: pre-`active_skills` state files persisted a single
338
+ // active workflow as top-level `{ active: true, skill, phase, … }` with no
339
+ // `active_skills` array. `normalizeSkillActiveState` still synthesizes that row,
340
+ // so the raw read used by the HUD, mutation guard, and caller inference must do
341
+ // the same or it would treat a legacy active workflow as absent.
342
+ if (out.length === 0 && state.active === true) {
343
+ const skill = safeString(state.skill).trim();
344
+ if (skill) {
345
+ out.push({
346
+ skill,
347
+ phase: safeString(state.phase).trim() || undefined,
348
+ active: true,
349
+ activated_at: safeString(state.activated_at).trim() || undefined,
350
+ updated_at: safeString(state.updated_at).trim() || undefined,
351
+ session_id: safeString(state.session_id).trim() || undefined,
352
+ thread_id: safeString(state.thread_id).trim() || undefined,
353
+ turn_id: safeString(state.turn_id).trim() || undefined,
354
+ });
355
+ }
335
356
  }
336
357
  return out;
337
358
  }
@@ -345,24 +366,139 @@ function filterRootEntriesForSession(entries: SkillActiveEntry[], sessionId?: st
345
366
  });
346
367
  }
347
368
 
369
+ function entryRecency(entry: SkillActiveEntry): number {
370
+ const stamp = entry.handoff_at || entry.updated_at || entry.activated_at;
371
+ const ms = stamp ? Date.parse(stamp) : Number.NaN;
372
+ // NaN signals "no trustworthy timestamp" so comparisons can refuse to let an
373
+ // unknown-recency row win a tie; callers must treat NaN explicitly.
374
+ return ms;
375
+ }
376
+
377
+ /**
378
+ * Session ownership rank for a row visible to a `sessionId` read. When a concrete
379
+ * session is in scope, a row owned by that exact session outranks a session-less
380
+ * fallback row, which outranks a foreign-session row. Session-less rows are global
381
+ * fallbacks and must never override a session's own state. With no scope session,
382
+ * every row ranks equally.
383
+ */
384
+ function sessionScopeRank(entry: SkillActiveEntry, sessionId?: string): number {
385
+ const scope = safeString(sessionId).trim();
386
+ if (!scope) return 0;
387
+ const entrySession = safeString(entry.session_id).trim();
388
+ if (entrySession === scope) return 2;
389
+ if (entrySession.length === 0) return 1;
390
+ return 0;
391
+ }
392
+
393
+ /**
394
+ * Pick the surviving row for a single skill within a session-scoped visible set.
395
+ * Precedence, highest first:
396
+ * 1. exact-session ownership over a session-less fallback row,
397
+ * 2. a strictly-newer valid timestamp,
398
+ * 3. a valid timestamp over a missing/unparseable one,
399
+ * 4. active over inactive — so an untrustworthy inactive row can never hide an
400
+ * active row — then merge order for a total tie.
401
+ * A genuine handoff demotion still supersedes a stale active row of the same skill
402
+ * because, within one session scope, it carries the newest valid timestamp.
403
+ */
404
+ function moreVisibleEntry(
405
+ incumbent: SkillActiveEntry,
406
+ challenger: SkillActiveEntry,
407
+ sessionId?: string,
408
+ ): SkillActiveEntry {
409
+ const scopeDelta = sessionScopeRank(incumbent, sessionId) - sessionScopeRank(challenger, sessionId);
410
+ if (scopeDelta !== 0) return scopeDelta > 0 ? incumbent : challenger;
411
+ const ri = entryRecency(incumbent);
412
+ const rc = entryRecency(challenger);
413
+ const vi = Number.isFinite(ri);
414
+ const vc = Number.isFinite(rc);
415
+ if (vi && vc && ri !== rc) return ri > rc ? incumbent : challenger;
416
+ if (vi !== vc) return vi ? incumbent : challenger;
417
+ const incumbentActive = incumbent.active !== false;
418
+ const challengerActive = challenger.active !== false;
419
+ if (incumbentActive !== challengerActive) return incumbentActive ? incumbent : challenger;
420
+ return incumbent;
421
+ }
422
+
423
+ /**
424
+ * Collapse the merged, session-scoped entries down to a single row per skill.
425
+ * A handed-off skill can leave more than one row visible to a session — e.g. a
426
+ * row seeded without a session id (rendered globally by
427
+ * `filterRootEntriesForSession`) plus a later, session-scoped handoff demotion
428
+ * of the same skill. Without this collapse the HUD renders the same workflow
429
+ * twice and keeps showing a skill that has already handed control to its
430
+ * successor. `moreVisibleEntry` picks the winner so a handoff demotion supersedes
431
+ * an older stale `active:true` row (and is then dropped by the active filter
432
+ * below) while a session's own active row is never hidden by a session-less or
433
+ * untrustworthy-timestamp row.
434
+ */
435
+ function dedupeVisibleBySkill(entries: SkillActiveEntry[], sessionId?: string): SkillActiveEntry[] {
436
+ const winners = new Map<string, SkillActiveEntry>();
437
+ for (const entry of entries) {
438
+ const current = winners.get(entry.skill);
439
+ winners.set(entry.skill, current ? moreVisibleEntry(current, entry, sessionId) : entry);
440
+ }
441
+ return [...winners.values()];
442
+ }
443
+
444
+ /**
445
+ * The planning pipeline advances one stage at a time: `deep-interview →
446
+ * ralplan → ultragoal`. Each stage is activated through its own command path
447
+ * (`gjc deep-interview`, `gjc ralplan`, `gjc ultragoal`), and those activations
448
+ * do not demote the previous stage's row — only the explicit `handoff` verb
449
+ * does. Without this collapse, activating ultragoal while ralplan is still
450
+ * `active:true` would render both stages and keep showing a workflow that has
451
+ * already handed control forward. Keep only the most recently updated pipeline
452
+ * stage so the HUD reflects the single current workflow. `team` is intentionally
453
+ * excluded — it runs alongside ultragoal — and every non-pipeline skill is left
454
+ * untouched.
455
+ *
456
+ * This is a HUD-display policy only. It is applied by the skill HUD renderer and
457
+ * deliberately NOT folded into `readVisibleSkillActiveState`, whose callers (the
458
+ * deep-interview mutation guard and handoff caller inference) must keep seeing
459
+ * every genuinely-active skill rather than the single most-recent pipeline stage.
460
+ */
461
+ const PLANNING_PIPELINE_SKILLS = new Set<string>(["deep-interview", "ralplan", "ultragoal"]);
462
+
463
+ export function collapsePlanningPipeline(entries: readonly SkillActiveEntry[]): SkillActiveEntry[] {
464
+ const pipeline = entries.filter(entry => PLANNING_PIPELINE_SKILLS.has(entry.skill));
465
+ if (pipeline.length <= 1) return [...entries];
466
+ let current = pipeline[0];
467
+ let currentRecency = entryRecency(current);
468
+ for (const entry of pipeline) {
469
+ const recency = entryRecency(entry);
470
+ // Prefer a strictly-newer valid timestamp; a valid timestamp also beats a
471
+ // missing/unparseable one. Ties (or all-invalid) keep the first stage
472
+ // deterministically rather than letting an unknown-recency row win.
473
+ const better = Number.isFinite(recency) && (!Number.isFinite(currentRecency) || recency > currentRecency);
474
+ if (better) {
475
+ current = entry;
476
+ currentRecency = recency;
477
+ }
478
+ }
479
+ return entries.filter(entry => !PLANNING_PIPELINE_SKILLS.has(entry.skill) || entry === current);
480
+ }
481
+
348
482
  function mergeVisibleEntries(
349
483
  sessionState: SkillActiveState | null,
350
484
  rootState: SkillActiveState | null,
351
485
  sessionId?: string,
352
486
  ): SkillActiveEntry[] {
353
- const rootEntries = filterRootEntriesForSession(listActiveSkills(rootState), sessionId);
487
+ // Use the raw (active + inactive) rows so a handoff demotion stays visible
488
+ // long enough to supersede a stale same-skill row before the active filter.
489
+ const rootEntries = filterRootEntriesForSession(rawActiveEntries(rootState), sessionId);
354
490
  const merged = new Map(rootEntries.map(entry => [entryKey(entry), entry]));
355
- for (const entry of listActiveSkills(sessionState)) {
491
+ for (const entry of rawActiveEntries(sessionState)) {
356
492
  merged.set(entryKey(entry), entry);
357
493
  }
358
- return [...merged.values()];
494
+ return dedupeVisibleBySkill([...merged.values()], sessionId).filter(entry => entry.active !== false);
359
495
  }
360
496
 
361
497
  export async function readVisibleSkillActiveState(cwd: string, sessionId?: string): Promise<SkillActiveState | null> {
362
498
  const { rootPath, sessionPath } = getSkillActiveStatePaths(cwd, sessionId);
363
499
  const [rootState, sessionState] = await Promise.all([
364
- readStateFile(rootPath),
365
- sessionPath ? readStateFile(sessionPath) : Promise.resolve(null),
500
+ readRawActiveStateForHandoff(rootPath, false),
501
+ sessionPath ? readRawActiveStateForHandoff(sessionPath, false) : Promise.resolve(null),
366
502
  ]);
367
503
  const activeSkills = mergeVisibleEntries(sessionState, rootState, sessionId);
368
504
  if (activeSkills.length === 0) return null;
@@ -379,15 +515,41 @@ export async function readVisibleSkillActiveState(cwd: string, sessionId?: strin
379
515
  };
380
516
  }
381
517
 
382
- async function writeStateFile(filePath: string, state: SkillActiveState): Promise<void> {
383
- await fs.mkdir(path.dirname(filePath), { recursive: true });
384
- await Bun.write(filePath, `${JSON.stringify(state, null, 2)}\n`);
518
+ function activeStateWriterAudit(verb: string) {
519
+ return { category: "state" as const, verb, owner: "gjc-runtime" as const };
520
+ }
521
+
522
+ async function persistActiveEntry(
523
+ cwd: string,
524
+ sessionScope: ActiveSessionScope | undefined,
525
+ entry: SkillActiveEntry,
526
+ ): Promise<void> {
527
+ if (entry.active === false) {
528
+ await removeActiveEntry(cwd, sessionScope, entry.skill, {
529
+ cwd,
530
+ audit: activeStateWriterAudit("remove-active-entry"),
531
+ });
532
+ } else {
533
+ await writeActiveEntry(cwd, sessionScope, entry.skill, entry, {
534
+ cwd,
535
+ audit: activeStateWriterAudit("write-active-entry"),
536
+ });
537
+ }
538
+ }
539
+
540
+ async function writeHandoffEntry(
541
+ cwd: string,
542
+ sessionScope: ActiveSessionScope | undefined,
543
+ entry: SkillActiveEntry,
544
+ ): Promise<void> {
545
+ await writeActiveEntry(cwd, sessionScope, entry.skill, entry, {
546
+ cwd,
547
+ audit: activeStateWriterAudit("write-active-entry"),
548
+ });
385
549
  }
386
550
 
387
- function upsertEntry(entries: SkillActiveEntry[], entry: SkillActiveEntry, active: boolean): SkillActiveEntry[] {
388
- const key = entryKey(entry);
389
- const retained = entries.filter(candidate => entryKey(candidate) !== key);
390
- return active ? [...retained, entry] : retained;
551
+ async function rebuildActiveState(cwd: string, sessionScope?: ActiveSessionScope): Promise<void> {
552
+ await rebuildActiveSnapshot(cwd, sessionScope, { cwd, audit: activeStateWriterAudit("rebuild-active-snapshot") });
391
553
  }
392
554
 
393
555
  export async function syncSkillActiveState(options: SyncSkillActiveStateOptions): Promise<void> {
@@ -408,36 +570,13 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
408
570
  ...(hud ? { hud } : {}),
409
571
  ...(options.receipt ? { receipt: options.receipt } : {}),
410
572
  };
411
- const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, options.sessionId);
412
- const rootState = (await readStateFile(rootPath)) ?? { version: 1, active_skills: [] };
413
- const rootEntries = upsertEntry(listActiveSkills(rootState), entry, options.active);
414
- const nextRoot: SkillActiveState = {
415
- ...rootState,
416
- version: 1,
417
- active: rootEntries.length > 0,
418
- skill: rootEntries[0]?.skill ?? "",
419
- phase: rootEntries[0]?.phase ?? "",
420
- updated_at: nowIso,
421
- source: options.source,
422
- active_skills: rootEntries,
423
- };
424
- await writeStateFile(rootPath, nextRoot);
573
+ await persistActiveEntry(options.cwd, undefined, entry);
574
+ await rebuildActiveState(options.cwd);
425
575
 
426
- if (!sessionPath) return;
427
- const sessionState = (await readStateFile(sessionPath)) ?? { version: 1, active_skills: [] };
428
- const sessionEntries = upsertEntry(listActiveSkills(sessionState), entry, options.active);
429
- const nextSession: SkillActiveState = {
430
- ...sessionState,
431
- version: 1,
432
- active: sessionEntries.length > 0,
433
- skill: sessionEntries[0]?.skill ?? "",
434
- phase: sessionEntries[0]?.phase ?? "",
435
- session_id: options.sessionId,
436
- updated_at: nowIso,
437
- source: options.source,
438
- active_skills: sessionEntries,
439
- };
440
- await writeStateFile(sessionPath, nextSession);
576
+ if (!options.sessionId) return;
577
+ const sessionScope = { sessionId: options.sessionId };
578
+ await persistActiveEntry(options.cwd, sessionScope, entry);
579
+ await rebuildActiveState(options.cwd, sessionScope);
441
580
  }
442
581
 
443
582
  export interface ApplyHandoffOptions {
@@ -467,12 +606,27 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
467
606
  const sessionId = options.callee.sessionId ?? options.caller.sessionId;
468
607
  const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId);
469
608
  const readState = (filePath: string) => readRawActiveStateForHandoff(filePath, options.strict === true);
470
-
609
+ await Promise.all([readState(rootPath), ...(sessionPath ? [readState(sessionPath)] : [])]);
610
+
611
+ // A skill can hold more than one visible row in this session's scope — e.g.
612
+ // it was seeded without a session id (rendered globally) and is now handed
613
+ // off under a concrete session id. Supersede every same-session-scope row of
614
+ // the caller and callee skills, not just the exact `skill::session_id` key,
615
+ // so a stale `active:true` row cannot survive the demotion and keep showing
616
+ // in the HUD. Rows owned by other sessions are left untouched.
617
+ const handoffSession = safeString(sessionId).trim();
618
+ const reassignedSkills = new Set([callerEntry.skill, calleeEntry.skill]);
619
+ const supersedesVisible = (entry: SkillActiveEntry): boolean => {
620
+ if (!reassignedSkills.has(entry.skill)) return false;
621
+ const entrySession = safeString(entry.session_id).trim();
622
+ return entrySession.length === 0 || entrySession === handoffSession;
623
+ };
471
624
  const applyEntries = (entries: SkillActiveEntry[]): SkillActiveEntry[] => {
472
625
  const callerKey = entryKey(callerEntry);
473
- const calleeKey = entryKey(calleeEntry);
474
- const priorCaller = entries.find(e => entryKey(e) === callerKey);
475
- const kept = entries.filter(e => entryKey(e) !== callerKey && entryKey(e) !== calleeKey);
626
+ const priorCaller =
627
+ entries.find(e => entryKey(e) === callerKey) ??
628
+ entries.find(e => e.skill === callerEntry.skill && supersedesVisible(e) && Boolean(e.handoff_from));
629
+ const kept = entries.filter(e => !supersedesVisible(e));
476
630
  // Merge prior lineage into the demoted caller so multi-step handoff
477
631
  // chains preserve `handoff_from` from the previous transition while
478
632
  // the new `handoff_to`/`handoff_at` describe this one.
@@ -486,33 +640,23 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
486
640
  : callerEntry;
487
641
  return [...kept, mergedCaller, calleeEntry];
488
642
  };
489
- const buildNextState = (
643
+ const writeEntries = async (
644
+ sessionScope: ActiveSessionScope | undefined,
490
645
  prior: SkillActiveState | null,
491
- entries: SkillActiveEntry[],
492
- scope: "session" | "root",
493
- ): SkillActiveState => {
494
- const visible = entries.filter(e => e.active !== false);
495
- return {
496
- ...(prior ?? {}),
497
- version: 1,
498
- active: visible.length > 0,
499
- skill: visible[0]?.skill ?? "",
500
- phase: visible[0]?.phase ?? "",
501
- ...(scope === "session" ? { session_id: sessionId } : {}),
502
- updated_at: nowIso,
503
- source: options.callee.source ?? options.caller.source,
504
- active_skills: entries,
505
- };
646
+ ): Promise<void> => {
647
+ const nextEntries = applyEntries(rawActiveEntries(prior));
648
+ for (const entry of nextEntries) {
649
+ await writeHandoffEntry(options.cwd, sessionScope, entry);
650
+ }
651
+ await rebuildActiveState(options.cwd, sessionScope);
506
652
  };
507
653
 
508
654
  if (sessionPath) {
509
655
  const prior = await readState(sessionPath);
510
- const next = buildNextState(prior, applyEntries(rawActiveEntries(prior)), "session");
511
- await writeStateFile(sessionPath, next);
656
+ await writeEntries({ sessionId }, prior);
512
657
  }
513
658
  const priorRoot = await readState(rootPath);
514
- const nextRoot = buildNextState(priorRoot, applyEntries(rawActiveEntries(priorRoot)), "root");
515
- await writeStateFile(rootPath, nextRoot);
659
+ await writeEntries(undefined, priorRoot);
516
660
  }
517
661
 
518
662
  function buildSyncEntry(options: SyncSkillActiveStateOptions, nowIso: string): SkillActiveEntry {
@@ -14,12 +14,16 @@ import {
14
14
  export const DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE =
15
15
  "Deep-interview phase boundary: continue gathering context/questions/risks and emit a handoff/spec before code edits. Mutation tools and patch execution are blocked while deep-interview is active; finalize specs through `gjc deep-interview --write --stage final` or hand off to an execution phase.";
16
16
  export const WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE =
17
- "Workflow state JSON is runtime-owned. Use `gjc state <skill> read|write --input '<json>'` for deep-interview, ralplan, ultragoal, and team. Planning artifacts under `.gjc/specs/` and `.gjc/plans/` remain allowed.";
17
+ ".gjc workflow state and artifacts are runtime-owned. Agent mutation tools cannot edit `.gjc/**`; use the sanctioned `gjc` CLI instead.";
18
18
 
19
- const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit"]);
19
+ const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit", "bash"]);
20
20
  const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
21
21
  const INTERNAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
22
22
  const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\r|\n|$)/i;
23
+ const BASH_TOKEN_RE = /'[^']*'|"(?:\\.|[^"\\])*"|\S+/g;
24
+ const BASH_REDIRECT_RE = /^(?:\d*)>>?$/;
25
+ const BASH_HEREDOC_RE = /^(?:\d*)<<-?$/;
26
+ const BASH_MUTATION_COMMANDS = new Set(["rm", "mv", "cp", "touch", "mkdir", "ln", "tee"]);
23
27
 
24
28
  type ToolWithEditMode = AgentTool & {
25
29
  mode?: unknown;
@@ -219,10 +223,75 @@ function extractEditTargets(args: unknown, tool: ToolWithEditMode): ExtractedTar
219
223
  return targets;
220
224
  }
221
225
 
226
+ function extractBashTargets(args: unknown): ExtractedTargets {
227
+ const record = getRecord(args);
228
+ const command = safeString(record?.command).trim();
229
+ const targets: ExtractedTargets = { paths: [], unknown: false };
230
+ if (!command) {
231
+ targets.unknown = true;
232
+ return targets;
233
+ }
234
+ if (/^gjc(?:\s|$)/.test(command)) return targets;
235
+
236
+ const tokens = command.match(BASH_TOKEN_RE)?.map(unquoteBashToken) ?? [];
237
+ for (let index = 0; index < tokens.length; index++) {
238
+ const token = tokens[index] ?? "";
239
+ if (BASH_REDIRECT_RE.test(token)) {
240
+ addPath(targets, tokens[index + 1]);
241
+ index++;
242
+ continue;
243
+ }
244
+ const redirectMatch = token.match(/^(?:\d*)>>?(.+)$/);
245
+ if (redirectMatch?.[1]) {
246
+ addPath(targets, redirectMatch[1]);
247
+ continue;
248
+ }
249
+ if (BASH_HEREDOC_RE.test(token)) {
250
+ addPath(targets, tokens[index + 1]);
251
+ index++;
252
+ continue;
253
+ }
254
+ const heredocMatch = token.match(/^(?:\d*)<<-?(.+)$/);
255
+ if (heredocMatch?.[1]) {
256
+ addPath(targets, heredocMatch[1]);
257
+ continue;
258
+ }
259
+ if (isMutationBashCommand(tokens, index)) {
260
+ for (let targetIndex = index + 1; targetIndex < tokens.length; targetIndex++) {
261
+ const target = tokens[targetIndex] ?? "";
262
+ if (isBashCommandBoundary(target)) break;
263
+ if (target.startsWith("-")) continue;
264
+ addPath(targets, target);
265
+ }
266
+ }
267
+ }
268
+ return targets;
269
+ }
270
+
271
+ function unquoteBashToken(token: string): string {
272
+ if (token.length < 2) return token;
273
+ const quote = token[0];
274
+ if ((quote === "'" || quote === '"') && token.at(-1) === quote) return token.slice(1, -1);
275
+ return token;
276
+ }
277
+
278
+ function isBashCommandBoundary(token: string): boolean {
279
+ return [";", "&&", "||", "|"].includes(token);
280
+ }
281
+
282
+ function isMutationBashCommand(tokens: string[], index: number): boolean {
283
+ const token = path.basename(tokens[index] ?? "");
284
+ if (BASH_MUTATION_COMMANDS.has(token)) return true;
285
+ if (token !== "sed") return false;
286
+ const next = tokens[index + 1] ?? "";
287
+ return next === "-i" || next.startsWith("-i") || next.includes("i");
288
+ }
289
+
222
290
  function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
223
291
  if (tool.name === "write") return extractWriteTargets(args);
224
292
  if (tool.name === "ast_edit") return extractAstEditTargets(args);
225
293
  if (tool.name === "edit") return extractEditTargets(args, tool);
294
+ if (tool.name === "bash") return extractBashTargets(args);
226
295
  return { paths: [], unknown: true };
227
296
  }
228
297
 
@@ -289,6 +358,14 @@ function isAllowlistedPath(cwd: string, rawPath: string): boolean {
289
358
  if (segments?.[0] !== ".gjc") return false;
290
359
  return segments[1] === "specs" || segments[1] === "plans";
291
360
  }
361
+ function isBlockedGjcPath(cwd: string, rawPath: string): boolean {
362
+ const segments = relativeGjcSegments(cwd, rawPath);
363
+ return segments?.[0] === ".gjc";
364
+ }
365
+
366
+ function hasBlockedGjcTarget(cwd: string, targets: ExtractedTargets): boolean {
367
+ return targets.paths.some(rawPath => isBlockedGjcPath(cwd, rawPath));
368
+ }
292
369
 
293
370
  function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
294
371
  return (
@@ -315,18 +392,16 @@ export async function getDeepInterviewMutationDecision(
315
392
  ): Promise<DeepInterviewMutationDecision> {
316
393
  if (!BLOCKED_TOOL_NAMES.has(input.tool.name)) return { blocked: false, targets: [] };
317
394
  const targets = extractTargets(input.tool, input.args);
318
- if (input.enforceWorkflowState !== false) {
395
+ if (input.enforceWorkflowState !== false && hasBlockedGjcTarget(input.cwd, targets)) {
319
396
  const stateSkill = firstBlockedWorkflowStateSkill(input.cwd, targets);
320
- if (stateSkill) {
321
- const command = sanctionedWorkflowStateCommand(stateSkill);
322
- return {
323
- blocked: true,
324
- message: `${WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE}\nUse: ${command}`,
325
- targets: targets.paths,
326
- reason: "workflow-state-target",
327
- command,
328
- };
329
- }
397
+ const command = stateSkill ? sanctionedWorkflowStateCommand(stateSkill) : "gjc <workflow-command>";
398
+ return {
399
+ blocked: true,
400
+ message: `${WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE}\nUse: ${command}`,
401
+ targets: targets.paths,
402
+ reason: stateSkill ? "workflow-state-target" : "gjc-target",
403
+ command,
404
+ };
330
405
  }
331
406
  if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) {
332
407
  return { blocked: false, targets: [] };
@@ -340,6 +415,9 @@ export async function getDeepInterviewMutationDecision(
340
415
  reason: "unknown-target",
341
416
  };
342
417
  }
418
+ if (input.tool.name === "bash") {
419
+ return { blocked: false, targets: targets.paths };
420
+ }
343
421
  return {
344
422
  blocked: true,
345
423
  message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
@@ -13,5 +13,7 @@ import type { CanonicalGjcWorkflowSkill } from "./active-state";
13
13
  export function initialPhaseForSkill(skill: CanonicalGjcWorkflowSkill | string): string {
14
14
  if (skill === "deep-interview") return "interviewing";
15
15
  if (skill === "ultragoal") return "goal-planning";
16
+ if (skill === "ralplan") return "planner";
17
+ if (skill === "team") return "starting";
16
18
  return "planning";
17
19
  }
@@ -9,6 +9,13 @@ export const WORKFLOW_STATE_RECEIPT_FRESH_MS = 30 * 60 * 1000;
9
9
  export type WorkflowStateMutationOwner = "gjc-state-cli" | "gjc-runtime" | "gjc-hook";
10
10
  export type WorkflowStateReceiptStatus = "fresh" | "stale";
11
11
 
12
+ export interface WorkflowStateContentChecksum {
13
+ algorithm: "sha256";
14
+ value: string;
15
+ covered_path: string;
16
+ computed_at: string;
17
+ }
18
+
12
19
  export interface WorkflowStateReceipt {
13
20
  version: 1;
14
21
  skill: CanonicalGjcWorkflowSkill;
@@ -20,6 +27,25 @@ export interface WorkflowStateReceipt {
20
27
  fresh_until: string;
21
28
  status: WorkflowStateReceiptStatus;
22
29
  mutation_id: string;
30
+ verb?: string;
31
+ from_phase?: string;
32
+ to_phase?: string;
33
+ forced?: boolean;
34
+ paths?: string[];
35
+ content_sha256?: WorkflowStateContentChecksum;
36
+ }
37
+
38
+ export interface AuditEntry {
39
+ ts: string;
40
+ skill?: string;
41
+ category: string;
42
+ verb: string;
43
+ owner: WorkflowStateMutationOwner;
44
+ mutation_id: string;
45
+ from_phase?: string;
46
+ to_phase?: string;
47
+ forced: boolean;
48
+ paths: string[];
23
49
  }
24
50
 
25
51
  function safeString(value: unknown): string {
@@ -30,6 +30,7 @@ interface AgentFrontmatter {
30
30
  blocking?: boolean;
31
31
  hide?: boolean;
32
32
  forkContext?: "forbidden" | "allowed";
33
+ bashAllowedPrefixes?: string[];
33
34
  }
34
35
 
35
36
  interface EmbeddedAgentDef {