@gajae-code/coding-agent 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/dist/types/cli/setup-cli.d.ts +1 -0
  3. package/dist/types/commands/contribution-prep.d.ts +18 -0
  4. package/dist/types/commands/deep-interview.d.ts +41 -0
  5. package/dist/types/commands/session.d.ts +24 -0
  6. package/dist/types/commands/setup.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +2 -2
  8. package/dist/types/config/models-config-schema.d.ts +17 -9
  9. package/dist/types/config/settings-schema.d.ts +37 -24
  10. package/dist/types/discovery/helpers.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  12. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +33 -0
  13. package/dist/types/gjc-runtime/goal-mode-request.d.ts +1 -1
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +12 -11
  15. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +25 -0
  16. package/dist/types/gjc-runtime/state-runtime.d.ts +13 -0
  17. package/dist/types/gjc-runtime/team-runtime.d.ts +37 -5
  18. package/dist/types/gjc-runtime/tmux-common.d.ts +41 -0
  19. package/dist/types/gjc-runtime/tmux-sessions.d.ts +17 -0
  20. package/dist/types/goals/runtime.d.ts +3 -9
  21. package/dist/types/goals/state.d.ts +3 -6
  22. package/dist/types/goals/tools/goal-tool.d.ts +1 -69
  23. package/dist/types/hooks/skill-state.d.ts +5 -0
  24. package/dist/types/memories/index.d.ts +1 -1
  25. package/dist/types/memory-backend/local-backend.d.ts +3 -3
  26. package/dist/types/modes/components/hook-selector.d.ts +7 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +0 -2
  28. package/dist/types/modes/components/status-line/types.d.ts +0 -3
  29. package/dist/types/modes/components/status-line.d.ts +0 -3
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +1 -12
  32. package/dist/types/modes/theme/defaults/index.d.ts +0 -2
  33. package/dist/types/modes/theme/theme.d.ts +1 -2
  34. package/dist/types/modes/types.d.ts +1 -7
  35. package/dist/types/modes/utils/context-usage.d.ts +6 -2
  36. package/dist/types/sdk.d.ts +6 -2
  37. package/dist/types/session/agent-session.d.ts +47 -1
  38. package/dist/types/session/contribution-prep.d.ts +47 -0
  39. package/dist/types/session/session-manager.d.ts +3 -0
  40. package/dist/types/setup/model-onboarding-guidance.d.ts +1 -0
  41. package/dist/types/setup/provider-onboarding.d.ts +29 -5
  42. package/dist/types/skill-state/active-state.d.ts +30 -1
  43. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +6 -1
  44. package/dist/types/skill-state/initial-phase.d.ts +12 -0
  45. package/dist/types/skill-state/workflow-hud.d.ts +9 -4
  46. package/dist/types/skill-state/workflow-state-contract.d.ts +34 -0
  47. package/dist/types/task/executor.d.ts +2 -0
  48. package/dist/types/task/types.d.ts +11 -0
  49. package/dist/types/tools/index.d.ts +20 -1
  50. package/dist/types/tools/skill.d.ts +47 -0
  51. package/dist/types/utils/changelog.d.ts +18 -2
  52. package/package.json +7 -7
  53. package/src/cli/args.ts +3 -2
  54. package/src/cli/setup-cli.ts +26 -12
  55. package/src/cli.ts +7 -1
  56. package/src/commands/contribution-prep.ts +41 -0
  57. package/src/commands/deep-interview.ts +30 -23
  58. package/src/commands/launch.ts +10 -1
  59. package/src/commands/ralplan.ts +10 -22
  60. package/src/commands/session.ts +150 -0
  61. package/src/commands/setup.ts +2 -0
  62. package/src/commands/state.ts +15 -4
  63. package/src/commands/team.ts +23 -3
  64. package/src/config/model-registry.ts +10 -2
  65. package/src/config/models-config-schema.ts +120 -102
  66. package/src/config/settings-schema.ts +42 -25
  67. package/src/config.ts +1 -1
  68. package/src/defaults/gjc/skills/deep-interview/SKILL.md +32 -13
  69. package/src/defaults/gjc/skills/ralplan/SKILL.md +22 -2
  70. package/src/defaults/gjc/skills/team/SKILL.md +39 -7
  71. package/src/defaults/gjc/skills/ultragoal/SKILL.md +33 -25
  72. package/src/discovery/helpers.ts +24 -1
  73. package/src/eval/py/prelude.py +1 -1
  74. package/src/extensibility/extensions/types.ts +6 -0
  75. package/src/gjc-runtime/deep-interview-runtime.ts +546 -0
  76. package/src/gjc-runtime/goal-mode-request.ts +2 -19
  77. package/src/gjc-runtime/launch-tmux.ts +83 -43
  78. package/src/gjc-runtime/ralplan-runtime.ts +460 -0
  79. package/src/gjc-runtime/state-runtime.ts +731 -0
  80. package/src/gjc-runtime/team-runtime.ts +708 -52
  81. package/src/gjc-runtime/tmux-common.ts +119 -0
  82. package/src/gjc-runtime/tmux-sessions.ts +165 -0
  83. package/src/gjc-runtime/ultragoal-guard.ts +6 -3
  84. package/src/gjc-runtime/ultragoal-runtime.ts +5 -4
  85. package/src/goals/runtime.ts +38 -144
  86. package/src/goals/state.ts +36 -7
  87. package/src/goals/tools/goal-tool.ts +15 -172
  88. package/src/hooks/skill-state.ts +39 -18
  89. package/src/internal-urls/docs-index.generated.ts +5 -4
  90. package/src/internal-urls/memory-protocol.ts +3 -2
  91. package/src/main.ts +2 -3
  92. package/src/memories/index.ts +2 -1
  93. package/src/memory-backend/local-backend.ts +14 -6
  94. package/src/modes/components/hook-selector.ts +156 -1
  95. package/src/modes/components/settings-selector.ts +5 -12
  96. package/src/modes/components/skill-hud/render.ts +4 -0
  97. package/src/modes/components/status-line/segments.ts +5 -16
  98. package/src/modes/components/status-line/types.ts +0 -3
  99. package/src/modes/components/status-line.ts +0 -6
  100. package/src/modes/controllers/command-controller.ts +27 -4
  101. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  102. package/src/modes/controllers/input-controller.ts +0 -15
  103. package/src/modes/controllers/selector-controller.ts +4 -11
  104. package/src/modes/interactive-mode.ts +18 -219
  105. package/src/modes/theme/defaults/dark-poimandres.json +0 -1
  106. package/src/modes/theme/defaults/light-poimandres.json +0 -1
  107. package/src/modes/theme/theme.ts +0 -6
  108. package/src/modes/types.ts +1 -7
  109. package/src/modes/utils/context-usage.ts +66 -17
  110. package/src/prompts/agents/architect.md +3 -0
  111. package/src/prompts/agents/executor.md +2 -0
  112. package/src/prompts/agents/frontmatter.md +1 -0
  113. package/src/prompts/goals/goal-continuation.md +1 -4
  114. package/src/prompts/goals/goal-mode-active.md +3 -5
  115. package/src/prompts/system/subagent-system-prompt.md +6 -0
  116. package/src/prompts/system/system-prompt.md +5 -7
  117. package/src/prompts/tools/goal.md +4 -4
  118. package/src/prompts/tools/skill.md +28 -0
  119. package/src/prompts/tools/task.md +3 -0
  120. package/src/sdk.ts +51 -11
  121. package/src/session/agent-session.ts +222 -21
  122. package/src/session/contribution-prep.ts +320 -0
  123. package/src/session/session-manager.ts +9 -1
  124. package/src/setup/model-onboarding-guidance.ts +6 -3
  125. package/src/setup/provider-onboarding.ts +177 -16
  126. package/src/skill-state/active-state.ts +188 -25
  127. package/src/skill-state/deep-interview-mutation-guard.ts +72 -21
  128. package/src/skill-state/initial-phase.ts +17 -0
  129. package/src/skill-state/workflow-hud.ts +23 -5
  130. package/src/skill-state/workflow-state-contract.ts +121 -0
  131. package/src/slash-commands/builtin-registry.ts +75 -25
  132. package/src/slash-commands/helpers/context-report.ts +123 -13
  133. package/src/task/agents.ts +1 -0
  134. package/src/task/commands.ts +1 -5
  135. package/src/task/executor.ts +9 -1
  136. package/src/task/index.ts +91 -4
  137. package/src/task/types.ts +6 -0
  138. package/src/tools/ask.ts +2 -0
  139. package/src/tools/gh.ts +212 -2
  140. package/src/tools/index.ts +25 -6
  141. package/src/tools/skill.ts +153 -0
  142. package/src/utils/changelog.ts +67 -44
  143. package/dist/types/commands/gjc-runtime-bridge.d.ts +0 -30
  144. package/dist/types/commands/question.d.ts +0 -7
  145. package/dist/types/modes/loop-limit.d.ts +0 -22
  146. package/src/commands/gjc-runtime-bridge.ts +0 -227
  147. package/src/commands/question.ts +0 -12
  148. package/src/modes/loop-limit.ts +0 -140
  149. package/src/prompts/commands/orchestrate.md +0 -49
  150. package/src/prompts/goals/goal-budget-limit.md +0 -16
  151. package/src/prompts/tools/create-goal.md +0 -3
  152. package/src/prompts/tools/get-goal.md +0 -3
  153. package/src/prompts/tools/update-goal.md +0 -3
@@ -1,8 +1,8 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ import type { WorkflowStateReceipt } from "./workflow-state-contract";
3
4
 
4
5
  export const SKILL_ACTIVE_STATE_FILE = "skill-active-state.json";
5
- export const SKILL_ACTIVE_STALE_MS = 24 * 60 * 60 * 1000;
6
6
 
7
7
  export const CANONICAL_GJC_WORKFLOW_SKILLS = ["deep-interview", "ralplan", "ultragoal", "team"] as const;
8
8
 
@@ -25,6 +25,8 @@ export interface WorkflowHudSummary {
25
25
  updated_at?: string;
26
26
  }
27
27
 
28
+ export type { WorkflowStateReceipt } from "./workflow-state-contract";
29
+
28
30
  export interface SkillActiveEntry {
29
31
  skill: string;
30
32
  phase?: string;
@@ -36,6 +38,10 @@ export interface SkillActiveEntry {
36
38
  turn_id?: string;
37
39
  hud?: WorkflowHudSummary;
38
40
  stale?: boolean;
41
+ receipt?: WorkflowStateReceipt;
42
+ handoff_from?: string;
43
+ handoff_to?: string;
44
+ handoff_at?: string;
39
45
  }
40
46
 
41
47
  export interface SkillActiveState {
@@ -70,6 +76,10 @@ export interface SyncSkillActiveStateOptions {
70
76
  nowIso?: string;
71
77
  source?: string;
72
78
  hud?: WorkflowHudSummary;
79
+ receipt?: WorkflowStateReceipt;
80
+ handoff_from?: string;
81
+ handoff_to?: string;
82
+ handoff_at?: string;
73
83
  }
74
84
 
75
85
  const HUD_TEXT_LIMIT = 80;
@@ -142,6 +152,36 @@ export function normalizeWorkflowHudSummary(raw: unknown): WorkflowHudSummary |
142
152
  };
143
153
  }
144
154
 
155
+ function normalizeWorkflowStateReceipt(raw: unknown): WorkflowStateReceipt | undefined {
156
+ if (!raw || typeof raw !== "object") return undefined;
157
+ const record = raw as Record<string, unknown>;
158
+ if (record.version !== 1) return undefined;
159
+ const skill = safeString(record.skill).trim();
160
+ if (!isCanonicalGjcWorkflowSkill(skill)) return undefined;
161
+ const owner = safeString(record.owner).trim();
162
+ if (owner !== "gjc-state-cli" && owner !== "gjc-runtime" && owner !== "gjc-hook") return undefined;
163
+ const command = sanitizeHudString(record.command, 120);
164
+ const statePath = sanitizeHudString(record.state_path, 240);
165
+ const storagePath = sanitizeHudString(record.storage_path, 240);
166
+ const mutatedAt = sanitizeHudString(record.mutated_at, 40);
167
+ const freshUntil = sanitizeHudString(record.fresh_until, 40);
168
+ const status = safeString(record.status).trim();
169
+ const mutationId = sanitizeHudString(record.mutation_id, 120);
170
+ if (!command || !statePath || !storagePath || !mutatedAt || !freshUntil || !mutationId) return undefined;
171
+ return {
172
+ version: 1,
173
+ skill,
174
+ owner,
175
+ command,
176
+ state_path: statePath,
177
+ storage_path: storagePath,
178
+ mutated_at: mutatedAt,
179
+ fresh_until: freshUntil,
180
+ status: status === "stale" ? "stale" : "fresh",
181
+ mutation_id: mutationId,
182
+ };
183
+ }
184
+
145
185
  function encodePathSegment(value: string): string {
146
186
  return encodeURIComponent(value).replaceAll(".", "%2E");
147
187
  }
@@ -150,31 +190,13 @@ function entryKey(entry: Pick<SkillActiveEntry, "skill" | "session_id">): string
150
190
  return `${entry.skill}::${safeString(entry.session_id).trim()}`;
151
191
  }
152
192
 
153
- function timestampMs(value: string | undefined): number | null {
154
- if (!value) return null;
155
- const ms = Date.parse(value);
156
- return Number.isFinite(ms) ? ms : null;
157
- }
158
-
159
- function entryTimestampMs(entry: SkillActiveEntry): number | null {
160
- return timestampMs(entry.hud?.updated_at) ?? timestampMs(entry.updated_at) ?? timestampMs(entry.activated_at);
161
- }
162
-
163
- function isFreshEntry(entry: SkillActiveEntry, nowMs = Date.now()): boolean {
164
- const ms = entryTimestampMs(entry);
165
- return ms === null || nowMs - ms <= SKILL_ACTIVE_STALE_MS;
166
- }
167
-
168
- function withDerivedStale(entry: SkillActiveEntry, nowMs = Date.now()): SkillActiveEntry {
169
- return { ...entry, stale: !isFreshEntry(entry, nowMs) };
170
- }
171
-
172
193
  function normalizeEntry(raw: unknown): SkillActiveEntry | null {
173
194
  if (!raw || typeof raw !== "object") return null;
174
195
  const record = raw as Record<string, unknown>;
175
196
  const skill = safeString(record.skill).trim();
176
197
  if (!skill) return null;
177
198
  const hud = normalizeWorkflowHudSummary(record.hud);
199
+ const receipt = normalizeWorkflowStateReceipt(record.receipt);
178
200
  return {
179
201
  ...record,
180
202
  skill,
@@ -185,7 +207,11 @@ function normalizeEntry(raw: unknown): SkillActiveEntry | null {
185
207
  session_id: safeString(record.session_id).trim() || undefined,
186
208
  thread_id: safeString(record.thread_id).trim() || undefined,
187
209
  turn_id: safeString(record.turn_id).trim() || undefined,
210
+ handoff_from: safeString(record.handoff_from).trim() || undefined,
211
+ handoff_to: safeString(record.handoff_to).trim() || undefined,
212
+ handoff_at: safeString(record.handoff_at).trim() || undefined,
188
213
  ...(hud ? { hud } : {}),
214
+ ...(receipt ? { receipt } : {}),
189
215
  stale: undefined,
190
216
  };
191
217
  }
@@ -268,6 +294,48 @@ async function readStateFile(filePath: string): Promise<SkillActiveState | null>
268
294
  }
269
295
  }
270
296
 
297
+ /**
298
+ * Raw read for handoff mutations. Returns the *unnormalized* parsed object so
299
+ * inactive entries remain visible to `rawActiveEntries` — `normalizeSkillActiveState`
300
+ * delegates to `listActiveSkills`, which filters out `active:false` rows for HUD
301
+ * purposes. Handoff history (e.g. previously demoted callers carrying
302
+ * `handoff_to`/`handoff_at` lineage) must survive across successive handoffs,
303
+ * so the on-disk `active_skills` array is preserved verbatim and the next
304
+ * write recomputes the per-skill row from there.
305
+ *
306
+ * Strict semantics: tolerates ENOENT only. Corrupt JSON / non-ENOENT I/O
307
+ * errors propagate so callers can surface a non-zero CLI status.
308
+ */
309
+ async function readRawActiveStateForHandoff(filePath: string, strict: boolean): Promise<SkillActiveState | null> {
310
+ let raw: string;
311
+ try {
312
+ raw = await Bun.file(filePath).text();
313
+ } catch (err) {
314
+ const code = (err as NodeJS.ErrnoException).code;
315
+ if (code === "ENOENT") return null;
316
+ if (!strict) return null;
317
+ throw err;
318
+ }
319
+ try {
320
+ const parsed = JSON.parse(raw);
321
+ if (!parsed || typeof parsed !== "object") return null;
322
+ return parsed as SkillActiveState;
323
+ } catch (err) {
324
+ if (!strict) return null;
325
+ throw err;
326
+ }
327
+ }
328
+
329
+ function rawActiveEntries(state: SkillActiveState | null): SkillActiveEntry[] {
330
+ if (!state || !Array.isArray(state.active_skills)) return [];
331
+ const out: SkillActiveEntry[] = [];
332
+ for (const candidate of state.active_skills) {
333
+ const normalized = normalizeEntry(candidate);
334
+ if (normalized) out.push(normalized);
335
+ }
336
+ return out;
337
+ }
338
+
271
339
  function filterRootEntriesForSession(entries: SkillActiveEntry[], sessionId?: string): SkillActiveEntry[] {
272
340
  const normalizedSessionId = safeString(sessionId).trim();
273
341
  if (!normalizedSessionId) return entries;
@@ -282,12 +350,9 @@ function mergeVisibleEntries(
282
350
  rootState: SkillActiveState | null,
283
351
  sessionId?: string,
284
352
  ): SkillActiveEntry[] {
285
- const nowMs = Date.now();
286
- const rootEntries = filterRootEntriesForSession(listActiveSkills(rootState), sessionId).map(entry =>
287
- withDerivedStale(entry, nowMs),
288
- );
353
+ const rootEntries = filterRootEntriesForSession(listActiveSkills(rootState), sessionId);
289
354
  const merged = new Map(rootEntries.map(entry => [entryKey(entry), entry]));
290
- for (const entry of listActiveSkills(sessionState).map(candidate => withDerivedStale(candidate, nowMs))) {
355
+ for (const entry of listActiveSkills(sessionState)) {
291
356
  merged.set(entryKey(entry), entry);
292
357
  }
293
358
  return [...merged.values()];
@@ -337,7 +402,11 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
337
402
  session_id: options.sessionId,
338
403
  thread_id: options.threadId,
339
404
  turn_id: options.turnId,
405
+ ...(options.handoff_from ? { handoff_from: options.handoff_from } : {}),
406
+ ...(options.handoff_to ? { handoff_to: options.handoff_to } : {}),
407
+ ...(options.handoff_at ? { handoff_at: options.handoff_at } : {}),
340
408
  ...(hud ? { hud } : {}),
409
+ ...(options.receipt ? { receipt: options.receipt } : {}),
341
410
  };
342
411
  const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, options.sessionId);
343
412
  const rootState = (await readStateFile(rootPath)) ?? { version: 1, active_skills: [] };
@@ -370,3 +439,97 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
370
439
  };
371
440
  await writeStateFile(sessionPath, nextSession);
372
441
  }
442
+
443
+ export interface ApplyHandoffOptions {
444
+ cwd: string;
445
+ caller: SyncSkillActiveStateOptions;
446
+ callee: SyncSkillActiveStateOptions;
447
+ /** Shared timestamp; falls back to new Date().toISOString(). */
448
+ nowIso?: string;
449
+ /** When true, read errors other than ENOENT propagate. */
450
+ strict?: boolean;
451
+ }
452
+
453
+ /**
454
+ * Atomically apply a workflow-skill handoff to both the session-scoped and
455
+ * root `skill-active-state.json` files in a single write per file.
456
+ *
457
+ * Write order: **session first, root last**. The session file is the
458
+ * source of truth for HUD; the root aggregate must never lead the session
459
+ * during a handoff window. Each file is rewritten once with caller demoted
460
+ * to `active:false` (preserving `handoff_to`/`handoff_at` lineage) and
461
+ * callee promoted to `active:true` (with `handoff_from`/`handoff_at`).
462
+ */
463
+ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): Promise<void> {
464
+ const nowIso = options.nowIso ?? new Date().toISOString();
465
+ const callerEntry = buildSyncEntry(options.caller, nowIso);
466
+ const calleeEntry = buildSyncEntry(options.callee, nowIso);
467
+ const sessionId = options.callee.sessionId ?? options.caller.sessionId;
468
+ const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId);
469
+ const readState = (filePath: string) => readRawActiveStateForHandoff(filePath, options.strict === true);
470
+
471
+ const applyEntries = (entries: SkillActiveEntry[]): SkillActiveEntry[] => {
472
+ 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);
476
+ // Merge prior lineage into the demoted caller so multi-step handoff
477
+ // chains preserve `handoff_from` from the previous transition while
478
+ // the new `handoff_to`/`handoff_at` describe this one.
479
+ const mergedCaller: SkillActiveEntry = priorCaller
480
+ ? {
481
+ ...callerEntry,
482
+ ...(priorCaller.handoff_from && !callerEntry.handoff_from
483
+ ? { handoff_from: priorCaller.handoff_from }
484
+ : {}),
485
+ }
486
+ : callerEntry;
487
+ return [...kept, mergedCaller, calleeEntry];
488
+ };
489
+ const buildNextState = (
490
+ 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
+ };
506
+ };
507
+
508
+ if (sessionPath) {
509
+ const prior = await readState(sessionPath);
510
+ const next = buildNextState(prior, applyEntries(rawActiveEntries(prior)), "session");
511
+ await writeStateFile(sessionPath, next);
512
+ }
513
+ const priorRoot = await readState(rootPath);
514
+ const nextRoot = buildNextState(priorRoot, applyEntries(rawActiveEntries(priorRoot)), "root");
515
+ await writeStateFile(rootPath, nextRoot);
516
+ }
517
+
518
+ function buildSyncEntry(options: SyncSkillActiveStateOptions, nowIso: string): SkillActiveEntry {
519
+ const hud = normalizeWorkflowHudSummary(options.hud);
520
+ return {
521
+ skill: options.skill,
522
+ phase: options.phase,
523
+ active: options.active,
524
+ activated_at: nowIso,
525
+ updated_at: nowIso,
526
+ session_id: options.sessionId,
527
+ thread_id: options.threadId,
528
+ turn_id: options.turnId,
529
+ ...(options.handoff_from ? { handoff_from: options.handoff_from } : {}),
530
+ ...(options.handoff_to ? { handoff_to: options.handoff_to } : {}),
531
+ ...(options.handoff_at ? { handoff_at: options.handoff_at } : {}),
532
+ ...(hud ? { hud } : {}),
533
+ ...(options.receipt ? { receipt: options.receipt } : {}),
534
+ };
535
+ }
@@ -5,14 +5,20 @@ import { LocalProtocolHandler, resolveLocalUrlToPath } from "../internal-urls/lo
5
5
  import { resolveToCwd } from "../tools/path-utils";
6
6
  import { ToolError } from "../tools/tool-errors";
7
7
  import { listActiveSkills, readVisibleSkillActiveState, type SkillActiveEntry } from "./active-state";
8
+ import {
9
+ type CanonicalGjcWorkflowSkill,
10
+ sanctionedWorkflowStateCommand,
11
+ workflowModeStateFileName,
12
+ } from "./workflow-state-contract";
8
13
 
9
14
  export const DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE =
10
- "Deep-interview is active; either continue interviewing with `ask`, or write/finalize the pending spec under `.gjc/specs/` / update state under `.gjc/state/`. Do not edit product code until explicit execution approval.";
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
+ 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.";
11
18
 
12
19
  const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit"]);
13
20
  const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
14
21
  const INTERNAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
15
- const GLOB_META_RE = /[*?[\]{}]/;
16
22
  const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\r|\n|$)/i;
17
23
 
18
24
  type ToolWithEditMode = AgentTool & {
@@ -26,6 +32,8 @@ export interface DeepInterviewMutationGuardInput {
26
32
  threadId?: string;
27
33
  tool: ToolWithEditMode;
28
34
  args: unknown;
35
+ forceOverride?: boolean;
36
+ enforceWorkflowState?: boolean;
29
37
  }
30
38
 
31
39
  interface ExtractedTargets {
@@ -38,6 +46,7 @@ export interface DeepInterviewMutationDecision {
38
46
  message?: string;
39
47
  targets: string[];
40
48
  reason?: string;
49
+ command?: string;
41
50
  }
42
51
 
43
52
  interface ModeState {
@@ -79,7 +88,7 @@ async function readVisibleModeState(cwd: string, skill: string, sessionId?: stri
79
88
  }
80
89
 
81
90
  function isTerminalModeState(state: ModeState | null): boolean {
82
- if (!state || state.active !== true) return true;
91
+ if (state?.active !== true) return true;
83
92
  const phase = String(state.current_phase ?? "")
84
93
  .trim()
85
94
  .toLowerCase();
@@ -246,34 +255,57 @@ function resolveRawPath(cwd: string, rawPath: string): { absolutePath?: string;
246
255
  }
247
256
  }
248
257
 
249
- function isAllowlistedPath(cwd: string, rawPath: string): boolean {
258
+ function relativeGjcSegments(cwd: string, rawPath: string): string[] | null {
250
259
  const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
251
- if (unknown || !absolutePath) return false;
260
+ if (unknown || !absolutePath) return null;
252
261
  const relative = path.relative(path.resolve(cwd), path.resolve(absolutePath));
253
- if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return false;
254
- const segments = normalizePosix(relative).split("/").filter(Boolean);
255
- return segments[0] === ".gjc" && (segments[1] === "specs" || segments[1] === "state");
262
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
263
+ return normalizePosix(relative).split("/").filter(Boolean);
256
264
  }
257
265
 
258
- function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
259
- if (targets.unknown || targets.paths.length === 0) return false;
260
- return targets.paths.every(rawPath => {
261
- if (GLOB_META_RE.test(rawPath)) {
262
- return isAllowlistedPath(cwd, rawPath);
263
- }
264
- return isAllowlistedPath(cwd, rawPath);
265
- });
266
+ function blockedWorkflowStateSkill(cwd: string, rawPath: string): CanonicalGjcWorkflowSkill | null {
267
+ const segments = relativeGjcSegments(cwd, rawPath);
268
+ if (segments?.[0] !== ".gjc") return null;
269
+ if (segments[1] === "specs" || segments[1] === "plans") return null;
270
+ if (segments[1] !== "state") return null;
271
+ const fileName = segments.at(-1) ?? "";
272
+ for (const skillName of ["deep-interview", "ralplan", "ultragoal", "team"] as const) {
273
+ if (fileName === workflowModeStateFileName(skillName)) return skillName;
274
+ }
275
+ if (fileName === "skill-active-state.json") return "deep-interview";
276
+ return null;
266
277
  }
267
278
 
279
+ function firstBlockedWorkflowStateSkill(cwd: string, targets: ExtractedTargets): CanonicalGjcWorkflowSkill | null {
280
+ for (const rawPath of targets.paths) {
281
+ const skill = blockedWorkflowStateSkill(cwd, rawPath);
282
+ if (skill) return skill;
283
+ }
284
+ return null;
285
+ }
286
+
287
+ function isAllowlistedPath(cwd: string, rawPath: string): boolean {
288
+ const segments = relativeGjcSegments(cwd, rawPath);
289
+ if (segments?.[0] !== ".gjc") return false;
290
+ return segments[1] === "specs" || segments[1] === "plans";
291
+ }
292
+
293
+ function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
294
+ return (
295
+ !targets.unknown && targets.paths.length > 0 && targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath))
296
+ );
297
+ }
268
298
  export async function assertDeepInterviewMutationRawPathsAllowed(input: {
269
299
  cwd: string;
270
300
  sessionId?: string;
271
301
  threadId?: string;
272
302
  rawPaths: string[];
303
+ forceOverride?: boolean;
273
304
  }): Promise<void> {
305
+ if (input.forceOverride) return;
274
306
  if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) return;
275
307
  const targets: ExtractedTargets = { paths: input.rawPaths, unknown: input.rawPaths.length === 0 };
276
- if (!allTargetsAllowlisted(input.cwd, targets)) {
308
+ if (targets.unknown || targets.paths.length > 0) {
277
309
  throw new ToolError(DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE);
278
310
  }
279
311
  }
@@ -282,18 +314,37 @@ export async function getDeepInterviewMutationDecision(
282
314
  input: DeepInterviewMutationGuardInput,
283
315
  ): Promise<DeepInterviewMutationDecision> {
284
316
  if (!BLOCKED_TOOL_NAMES.has(input.tool.name)) return { blocked: false, targets: [] };
317
+ const targets = extractTargets(input.tool, input.args);
318
+ if (input.enforceWorkflowState !== false) {
319
+ 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
+ }
330
+ }
285
331
  if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) {
286
332
  return { blocked: false, targets: [] };
287
333
  }
288
- const targets = extractTargets(input.tool, input.args);
289
- if (allTargetsAllowlisted(input.cwd, targets)) {
290
- return { blocked: false, targets: targets.paths };
334
+ if (input.forceOverride) return { blocked: false, targets: [] };
335
+ if (targets.unknown) {
336
+ return {
337
+ blocked: true,
338
+ message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
339
+ targets: targets.paths,
340
+ reason: "unknown-target",
341
+ };
291
342
  }
292
343
  return {
293
344
  blocked: true,
294
345
  message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
295
346
  targets: targets.paths,
296
- reason: targets.unknown ? "unknown-target" : "product-target",
347
+ reason: allTargetsAllowlisted(input.cwd, targets) ? "handoff-artifact-tool-target" : "phase-boundary",
297
348
  };
298
349
  }
299
350
 
@@ -0,0 +1,17 @@
1
+ import type { CanonicalGjcWorkflowSkill } from "./active-state";
2
+
3
+ /**
4
+ * Canonical initial phase for each GJC workflow skill. Used by both
5
+ * `recordSkillActivation` (UserPromptSubmit hook seeding initial mode-state)
6
+ * and the `gjc state <caller> handoff --to <callee>` runtime when promoting
7
+ * the callee.
8
+ *
9
+ * Keeping this mapping in a neutral skill-state module avoids cycles between
10
+ * `gjc-runtime/state-runtime.ts` and `hooks/skill-state.ts` (which pulls in
11
+ * session-manager and ultragoal verification code).
12
+ */
13
+ export function initialPhaseForSkill(skill: CanonicalGjcWorkflowSkill | string): string {
14
+ if (skill === "deep-interview") return "interviewing";
15
+ if (skill === "ultragoal") return "goal-planning";
16
+ return "planning";
17
+ }
@@ -1,6 +1,12 @@
1
1
  import type { WorkflowHudChip, WorkflowHudSummary } from "./active-state";
2
2
 
3
- interface DeepInterviewHudState {
3
+ interface WorkflowGateHudState {
4
+ approvalStatus?: string;
5
+ blockedReason?: string;
6
+ nextAction?: string;
7
+ }
8
+
9
+ interface DeepInterviewHudState extends WorkflowGateHudState {
4
10
  phase?: string;
5
11
  ambiguity?: number;
6
12
  threshold?: number;
@@ -11,7 +17,7 @@ interface DeepInterviewHudState {
11
17
  updatedAt?: string;
12
18
  }
13
19
 
14
- interface RalplanHudState {
20
+ interface RalplanHudState extends WorkflowGateHudState {
15
21
  stage?: string;
16
22
  waiting?: string;
17
23
  iteration?: number;
@@ -27,7 +33,7 @@ interface UltragoalLikeGoal {
27
33
  status: string;
28
34
  }
29
35
 
30
- interface UltragoalHudState {
36
+ interface UltragoalHudState extends WorkflowGateHudState {
31
37
  status: string;
32
38
  currentGoal?: UltragoalLikeGoal;
33
39
  counts: Record<string, number>;
@@ -41,7 +47,7 @@ interface TeamHudWorker {
41
47
  status?: string;
42
48
  }
43
49
 
44
- interface TeamHudState {
50
+ interface TeamHudState extends WorkflowGateHudState {
45
51
  phase: string;
46
52
  task_total: number;
47
53
  task_counts: Record<string, number>;
@@ -66,6 +72,14 @@ function chip(
66
72
  return { label, value, priority, ...(severity ? { severity } : {}) };
67
73
  }
68
74
 
75
+ function gateChips(state: WorkflowGateHudState, gatePriority: number): Array<WorkflowHudChip | null> {
76
+ return [
77
+ chip("gate", state.approvalStatus, gatePriority, state.approvalStatus === "approved" ? "success" : "warning"),
78
+ chip("blocked", state.blockedReason, gatePriority + 10, "blocked"),
79
+ chip("next", state.nextAction, gatePriority + 20),
80
+ ];
81
+ }
82
+
69
83
  function compactChips(chips: Array<WorkflowHudChip | null>): WorkflowHudChip[] {
70
84
  return chips.filter((item): item is WorkflowHudChip => item !== null);
71
85
  }
@@ -74,6 +88,7 @@ export function buildDeepInterviewHudSummary(state: DeepInterviewHudState): Work
74
88
  return {
75
89
  version: 1,
76
90
  chips: compactChips([
91
+ ...gateChips(state, 5),
77
92
  chip("phase", state.phase, 10),
78
93
  chip("ambiguity", [percent(state.ambiguity), percent(state.threshold)].filter(Boolean).join("/"), 20),
79
94
  chip("round", state.roundCount === undefined ? undefined : String(state.roundCount), 30),
@@ -100,6 +115,7 @@ export function buildRalplanHudSummary(state: RalplanHudState): WorkflowHudSumma
100
115
  summary: state.latestSummary,
101
116
  chips: compactChips([
102
117
  state.pendingApproval ? { label: "pending", value: "approval", priority: 5, severity: "warning" } : null,
118
+ ...gateChips(state, 6),
103
119
  chip("stage", state.stage, 10),
104
120
  chip("waiting", state.waiting, 20),
105
121
  chip("iter", state.iteration === undefined ? undefined : String(state.iteration), 30),
@@ -120,6 +136,7 @@ export function buildUltragoalHudSummary(state: UltragoalHudState): WorkflowHudS
120
136
  chip("goals", `${complete}/${total}`, 10),
121
137
  chip("current", state.currentGoal ? `${state.currentGoal.id}:${state.currentGoal.title}` : state.status, 20),
122
138
  chip("status", state.status, 30, state.status === "complete" ? "success" : undefined),
139
+ ...gateChips(state, 40),
123
140
  ]),
124
141
  details: state.latestLedgerEvent
125
142
  ? compactChips([
@@ -153,7 +170,8 @@ export function buildTeamHudSummary(state: TeamHudState): WorkflowHudSummary {
153
170
  chip("phase", state.phase, 10),
154
171
  chip("workers", `${state.workers.length - failedWorkers}/${state.workers.length}`, 20),
155
172
  chip("tasks", `${completed}/${state.task_total}`, 30),
156
- chip("latest", latest, 50),
173
+ ...gateChips(state, 40),
174
+ chip("latest", latest, 70),
157
175
  ]),
158
176
  ...(state.updated_at ? { updated_at: state.updated_at } : {}),
159
177
  };
@@ -0,0 +1,121 @@
1
+ import * as path from "node:path";
2
+ import { CANONICAL_GJC_WORKFLOW_SKILLS, type CanonicalGjcWorkflowSkill, SKILL_ACTIVE_STATE_FILE } from "./active-state";
3
+
4
+ export type { CanonicalGjcWorkflowSkill };
5
+
6
+ export const WORKFLOW_STATE_RECEIPT_VERSION = 1;
7
+ export const WORKFLOW_STATE_RECEIPT_FRESH_MS = 30 * 60 * 1000;
8
+
9
+ export type WorkflowStateMutationOwner = "gjc-state-cli" | "gjc-runtime" | "gjc-hook";
10
+ export type WorkflowStateReceiptStatus = "fresh" | "stale";
11
+
12
+ export interface WorkflowStateReceipt {
13
+ version: 1;
14
+ skill: CanonicalGjcWorkflowSkill;
15
+ owner: WorkflowStateMutationOwner;
16
+ command: string;
17
+ state_path: string;
18
+ storage_path: string;
19
+ mutated_at: string;
20
+ fresh_until: string;
21
+ status: WorkflowStateReceiptStatus;
22
+ mutation_id: string;
23
+ }
24
+
25
+ function safeString(value: unknown): string {
26
+ return typeof value === "string" ? value : "";
27
+ }
28
+
29
+ function encodePathSegment(value: string): string {
30
+ return encodeURIComponent(value).replaceAll(".", "%2E");
31
+ }
32
+
33
+ export function workflowModeStateFileName(skill: CanonicalGjcWorkflowSkill): string {
34
+ return `${skill}-state.json`;
35
+ }
36
+
37
+ export function workflowStateStoragePath(cwd: string, skill: CanonicalGjcWorkflowSkill, sessionId?: string): string {
38
+ const normalizedSessionId = safeString(sessionId).trim();
39
+ if (normalizedSessionId) {
40
+ return path.join(
41
+ cwd,
42
+ ".gjc",
43
+ "state",
44
+ "sessions",
45
+ encodePathSegment(normalizedSessionId),
46
+ workflowModeStateFileName(skill),
47
+ );
48
+ }
49
+ return path.join(cwd, ".gjc", "state", workflowModeStateFileName(skill));
50
+ }
51
+
52
+ export function workflowActiveStatePath(cwd: string, sessionId?: string): string {
53
+ const normalizedSessionId = safeString(sessionId).trim();
54
+ if (normalizedSessionId) {
55
+ return path.join(
56
+ cwd,
57
+ ".gjc",
58
+ "state",
59
+ "sessions",
60
+ encodePathSegment(normalizedSessionId),
61
+ SKILL_ACTIVE_STATE_FILE,
62
+ );
63
+ }
64
+ return path.join(cwd, ".gjc", "state", SKILL_ACTIVE_STATE_FILE);
65
+ }
66
+
67
+ export function buildWorkflowStateReceipt(input: {
68
+ cwd: string;
69
+ skill: CanonicalGjcWorkflowSkill;
70
+ owner: WorkflowStateMutationOwner;
71
+ command: string;
72
+ sessionId?: string;
73
+ nowIso?: string;
74
+ mutationId?: string;
75
+ }): WorkflowStateReceipt {
76
+ const mutatedAt = input.nowIso ?? new Date().toISOString();
77
+ const freshUntil = new Date(Date.parse(mutatedAt) + WORKFLOW_STATE_RECEIPT_FRESH_MS).toISOString();
78
+ return {
79
+ version: WORKFLOW_STATE_RECEIPT_VERSION,
80
+ skill: input.skill,
81
+ owner: input.owner,
82
+ command: input.command,
83
+ state_path: workflowActiveStatePath(input.cwd, input.sessionId),
84
+ storage_path: workflowStateStoragePath(input.cwd, input.skill, input.sessionId),
85
+ mutated_at: mutatedAt,
86
+ fresh_until: freshUntil,
87
+ status: "fresh",
88
+ mutation_id: input.mutationId ?? `${input.skill}:${mutatedAt}`,
89
+ };
90
+ }
91
+
92
+ export function workflowReceiptStatus(
93
+ receipt: WorkflowStateReceipt | undefined,
94
+ nowMs = Date.now(),
95
+ ): WorkflowStateReceiptStatus | undefined {
96
+ if (!receipt) return undefined;
97
+ const freshUntilMs = Date.parse(receipt.fresh_until);
98
+ if (!Number.isFinite(freshUntilMs)) return "stale";
99
+ return nowMs <= freshUntilMs ? "fresh" : "stale";
100
+ }
101
+
102
+ export function canonicalWorkflowSkill(value: string): CanonicalGjcWorkflowSkill | null {
103
+ return (CANONICAL_GJC_WORKFLOW_SKILLS as readonly string[]).includes(value)
104
+ ? (value as CanonicalGjcWorkflowSkill)
105
+ : null;
106
+ }
107
+
108
+ export function sanctionedWorkflowStateCommand(skill: CanonicalGjcWorkflowSkill): string {
109
+ return `gjc state ${skill} write --input '<json>'`;
110
+ }
111
+
112
+ export function describeWorkflowStateContract(skill: CanonicalGjcWorkflowSkill): string[] {
113
+ return [
114
+ `Sanctioned mutation path: gjc state ${skill} read|write --input '<json>'`,
115
+ `Canonical active HUD state: .gjc/state/${SKILL_ACTIVE_STATE_FILE} and .gjc/state/sessions/<session>/${SKILL_ACTIVE_STATE_FILE}`,
116
+ `Skill mode state: .gjc/state/${workflowModeStateFileName(skill)} or .gjc/state/sessions/<session>/${workflowModeStateFileName(skill)}`,
117
+ "Receipts include version, skill, owner, command, state_path, storage_path, mutated_at, fresh_until, status, and mutation_id.",
118
+ "Receipts are fresh for 30 minutes; older receipts are stale and render as HUD warnings.",
119
+ "Planning artifacts under .gjc/specs/** and .gjc/plans/** remain writable outside the state command.",
120
+ ];
121
+ }