@gajae-code/coding-agent 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/dist/types/cli/skills-cli.d.ts +9 -0
  3. package/dist/types/commands/contribution-prep.d.ts +18 -0
  4. package/dist/types/commands/session.d.ts +24 -0
  5. package/dist/types/commands/skills.d.ts +26 -0
  6. package/dist/types/config/model-registry.d.ts +33 -4
  7. package/dist/types/config/models-config-schema.d.ts +52 -5
  8. package/dist/types/config/settings-schema.d.ts +1 -24
  9. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +15 -0
  10. package/dist/types/gjc-runtime/goal-mode-request.d.ts +1 -1
  11. package/dist/types/gjc-runtime/launch-tmux.d.ts +12 -11
  12. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +25 -0
  13. package/dist/types/gjc-runtime/state-runtime.d.ts +13 -0
  14. package/dist/types/gjc-runtime/team-runtime.d.ts +37 -5
  15. package/dist/types/gjc-runtime/tmux-common.d.ts +41 -0
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +17 -0
  17. package/dist/types/goals/runtime.d.ts +3 -9
  18. package/dist/types/goals/state.d.ts +3 -6
  19. package/dist/types/goals/tools/goal-tool.d.ts +1 -69
  20. package/dist/types/modes/components/model-selector.d.ts +21 -1
  21. package/dist/types/modes/components/status-line/types.d.ts +0 -3
  22. package/dist/types/modes/components/status-line.d.ts +0 -3
  23. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -12
  25. package/dist/types/modes/theme/defaults/index.d.ts +0 -2
  26. package/dist/types/modes/theme/theme.d.ts +1 -2
  27. package/dist/types/modes/types.d.ts +1 -7
  28. package/dist/types/session/agent-session.d.ts +2 -0
  29. package/dist/types/session/contribution-prep.d.ts +47 -0
  30. package/dist/types/skill-state/active-state.d.ts +4 -0
  31. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +6 -1
  32. package/dist/types/skill-state/workflow-hud.d.ts +9 -4
  33. package/dist/types/skill-state/workflow-state-contract.d.ts +34 -0
  34. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  35. package/package.json +7 -7
  36. package/src/cli/args.ts +17 -2
  37. package/src/cli/skills-cli.ts +88 -0
  38. package/src/cli.ts +7 -1
  39. package/src/commands/contribution-prep.ts +41 -0
  40. package/src/commands/deep-interview.ts +6 -22
  41. package/src/commands/launch.ts +10 -1
  42. package/src/commands/ralplan.ts +10 -22
  43. package/src/commands/session.ts +150 -0
  44. package/src/commands/skills.ts +48 -0
  45. package/src/commands/state.ts +14 -4
  46. package/src/commands/team.ts +23 -3
  47. package/src/commit/agentic/index.ts +1 -0
  48. package/src/commit/pipeline.ts +1 -0
  49. package/src/config/model-registry.ts +269 -10
  50. package/src/config/models-config-schema.ts +124 -88
  51. package/src/config/settings-schema.ts +1 -25
  52. package/src/config.ts +1 -1
  53. package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -13
  54. package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -2
  55. package/src/defaults/gjc/skills/team/SKILL.md +29 -7
  56. package/src/defaults/gjc/skills/ultragoal/SKILL.md +23 -25
  57. package/src/eval/py/prelude.py +1 -1
  58. package/src/gjc-runtime/deep-interview-runtime.ts +279 -0
  59. package/src/gjc-runtime/goal-mode-request.ts +2 -19
  60. package/src/gjc-runtime/launch-tmux.ts +83 -43
  61. package/src/gjc-runtime/ralplan-runtime.ts +460 -0
  62. package/src/gjc-runtime/state-runtime.ts +562 -0
  63. package/src/gjc-runtime/team-runtime.ts +708 -52
  64. package/src/gjc-runtime/tmux-common.ts +119 -0
  65. package/src/gjc-runtime/tmux-sessions.ts +165 -0
  66. package/src/gjc-runtime/ultragoal-guard.ts +6 -3
  67. package/src/gjc-runtime/ultragoal-runtime.ts +5 -4
  68. package/src/goals/runtime.ts +38 -144
  69. package/src/goals/state.ts +36 -7
  70. package/src/goals/tools/goal-tool.ts +15 -172
  71. package/src/hooks/skill-state.ts +31 -12
  72. package/src/internal-urls/docs-index.generated.ts +4 -3
  73. package/src/main.ts +10 -1
  74. package/src/modes/components/model-selector.ts +109 -28
  75. package/src/modes/components/skill-hud/render.ts +4 -0
  76. package/src/modes/components/status-line/segments.ts +5 -16
  77. package/src/modes/components/status-line/types.ts +0 -3
  78. package/src/modes/components/status-line.ts +0 -6
  79. package/src/modes/controllers/command-controller.ts +25 -1
  80. package/src/modes/controllers/input-controller.ts +0 -15
  81. package/src/modes/controllers/selector-controller.ts +42 -2
  82. package/src/modes/interactive-mode.ts +18 -219
  83. package/src/modes/theme/defaults/dark-poimandres.json +0 -1
  84. package/src/modes/theme/defaults/light-poimandres.json +0 -1
  85. package/src/modes/theme/theme.ts +0 -6
  86. package/src/modes/types.ts +1 -7
  87. package/src/prompts/goals/goal-continuation.md +1 -4
  88. package/src/prompts/goals/goal-mode-active.md +3 -5
  89. package/src/prompts/system/system-prompt.md +5 -7
  90. package/src/prompts/tools/goal.md +4 -4
  91. package/src/sdk.ts +2 -1
  92. package/src/session/agent-session.ts +18 -0
  93. package/src/session/contribution-prep.ts +320 -0
  94. package/src/setup/provider-onboarding.ts +2 -0
  95. package/src/skill-state/active-state.ts +38 -0
  96. package/src/skill-state/deep-interview-mutation-guard.ts +88 -24
  97. package/src/skill-state/workflow-hud.ts +23 -5
  98. package/src/skill-state/workflow-state-contract.ts +121 -0
  99. package/src/slash-commands/acp-builtins.ts +11 -2
  100. package/src/slash-commands/builtin-registry.ts +40 -13
  101. package/src/task/commands.ts +1 -5
  102. package/src/tools/gh.ts +212 -2
  103. package/src/tools/index.ts +2 -5
  104. package/dist/types/commands/gjc-runtime-bridge.d.ts +0 -30
  105. package/dist/types/commands/question.d.ts +0 -7
  106. package/dist/types/modes/loop-limit.d.ts +0 -22
  107. package/src/commands/gjc-runtime-bridge.ts +0 -227
  108. package/src/commands/question.ts +0 -12
  109. package/src/modes/loop-limit.ts +0 -140
  110. package/src/prompts/commands/orchestrate.md +0 -49
  111. package/src/prompts/goals/goal-budget-limit.md +0 -16
  112. package/src/prompts/tools/create-goal.md +0 -3
  113. package/src/prompts/tools/get-goal.md +0 -3
  114. package/src/prompts/tools/update-goal.md +0 -3
@@ -0,0 +1,320 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { AgentMessage } from "@gajae-code/agent-core";
5
+ import type { AssistantMessage, ToolResultMessage, UserMessage } from "@gajae-code/ai";
6
+ import { $ } from "bun";
7
+ import { resolveGjcCommand } from "../task/gjc-command";
8
+ import { shortenPath } from "../tools/render-utils";
9
+
10
+ export const CONTRIBUTION_PREP_SCHEMA_VERSION = 1;
11
+
12
+ const MAX_TRANSCRIPT_MESSAGES = 20;
13
+ const MAX_TEXT_CHARS = 12000;
14
+ const MAX_GIT_OUTPUT_CHARS = 60000;
15
+
16
+ export interface ContributionPrepArtifact {
17
+ path: string;
18
+ description: string;
19
+ }
20
+
21
+ export interface ContributionPrepManifest {
22
+ schema_version: number;
23
+ source_session_id: string;
24
+ created_at: string;
25
+ cwd: string;
26
+ git_head: string | null;
27
+ changed_files: string[];
28
+ artifacts: ContributionPrepArtifact[];
29
+ redactions: string[];
30
+ recommended_output: string[];
31
+ worker_prompt_path: string;
32
+ }
33
+
34
+ export interface ContributionPrepResult {
35
+ manifestPath: string;
36
+ workerPromptPath: string;
37
+ artifactDir: string;
38
+ changedFiles: string[];
39
+ spawned: boolean;
40
+ }
41
+
42
+ export interface ContributionPrepOptions {
43
+ customInstructions?: string;
44
+ spawnWorker?: boolean;
45
+ artifactRoot?: string;
46
+ now?: Date;
47
+ spawn?: (args: string[], cwd: string, shell: boolean) => Promise<void>;
48
+ }
49
+
50
+ export interface ContributionPrepContext {
51
+ sessionId: string;
52
+ cwd: string;
53
+ sessionFile?: string;
54
+ messages: AgentMessage[];
55
+ customInstructions?: string;
56
+ now?: Date;
57
+ }
58
+
59
+ interface RedactionState {
60
+ labels: Set<string>;
61
+ }
62
+
63
+ function limitText(text: string, maxChars = MAX_TEXT_CHARS): string {
64
+ if (text.length <= maxChars) return text;
65
+ return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} chars]`;
66
+ }
67
+
68
+ function replaceRegex(text: string, regex: RegExp, replacement: string, state: RedactionState, label: string): string {
69
+ if (!regex.test(text)) return text;
70
+ state.labels.add(label);
71
+ regex.lastIndex = 0;
72
+ return text.replace(regex, replacement);
73
+ }
74
+
75
+ export function redactContributionPrepText(
76
+ text: string,
77
+ cwd: string,
78
+ state: RedactionState = { labels: new Set() },
79
+ ): string {
80
+ let redacted = text;
81
+ redacted = replaceRegex(
82
+ redacted,
83
+ /\b(?:sk|pk|rk|xox[baprs])-[-_A-Za-z0-9]{12,}\b/g,
84
+ "[REDACTED_TOKEN]",
85
+ state,
86
+ "tokens",
87
+ );
88
+ redacted = replaceRegex(
89
+ redacted,
90
+ /\b(?:ghp_[A-Za-z0-9_]{12,}|gho_[A-Za-z0-9_]{12,}|github_pat_[A-Za-z0-9_]{12,})\b/g,
91
+ "[REDACTED_TOKEN]",
92
+ state,
93
+ "tokens",
94
+ );
95
+ redacted = replaceRegex(
96
+ redacted,
97
+ /\b((?:ANTHROPIC|OPENAI|GITHUB|GOOGLE|GEMINI|KAGI|TAVILY|EXA|PERPLEXITY|ZAI|KIMI|BRAVE|SEARXNG|AWS)_[A-Z0-9_]*(?:KEY|TOKEN|SECRET|COOKIE|PASSWORD))\s*=\s*[^\s\n]+/gi,
98
+ "$1=[REDACTED_SECRET]",
99
+ state,
100
+ "provider_keys",
101
+ );
102
+ redacted = replaceRegex(
103
+ redacted,
104
+ /\b(Authorization|Proxy-Authorization)\s*:\s*(?:Bearer|Basic|Token)\s+[^\s\n]+/gi,
105
+ "$1: [REDACTED_AUTH_HEADER]",
106
+ state,
107
+ "auth_headers",
108
+ );
109
+ redacted = replaceRegex(redacted, /\b(Cookie|Set-Cookie)\s*:\s*[^\n]+/gi, "$1: [REDACTED_COOKIE]", state, "cookies");
110
+ redacted = replaceRegex(
111
+ redacted,
112
+ /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})[^\s)>'"]*/gi,
113
+ "[REDACTED_PRIVATE_ENDPOINT]",
114
+ state,
115
+ "private_endpoints",
116
+ );
117
+ const home = os.homedir();
118
+ if (home && redacted.includes(home)) {
119
+ state.labels.add("home_paths");
120
+ redacted = redacted.split(home).join("~");
121
+ }
122
+ const normalizedCwd = path.resolve(cwd);
123
+ if (normalizedCwd && redacted.includes(normalizedCwd)) {
124
+ state.labels.add("cwd_paths");
125
+ redacted = redacted.split(normalizedCwd).join(shortenPath(normalizedCwd));
126
+ }
127
+ return redacted;
128
+ }
129
+
130
+ function contentText(content: UserMessage["content"] | AssistantMessage["content"]): string {
131
+ if (typeof content === "string") return content;
132
+ return content
133
+ .map(part => {
134
+ if (part.type === "text") return part.text;
135
+ if (part.type === "toolCall") return `[tool call: ${part.name}] ${JSON.stringify(part.arguments)}`;
136
+ if (part.type === "image") return "[image]";
137
+ return `[${part.type}]`;
138
+ })
139
+ .join("\n");
140
+ }
141
+
142
+ function formatMessage(message: AgentMessage): string {
143
+ if (message.role === "user" || message.role === "assistant") {
144
+ return `## ${message.role}\n\n${contentText(message.content)}\n`;
145
+ }
146
+ if (message.role === "toolResult") {
147
+ const tool = message as ToolResultMessage;
148
+ return `## toolResult: ${tool.toolName}\n\n${typeof tool.content === "string" ? tool.content : JSON.stringify(tool.content)}\n`;
149
+ }
150
+ return `## ${message.role}\n\n${JSON.stringify(message)}\n`;
151
+ }
152
+
153
+ async function gitOutput(cwd: string, args: string[], maxChars = MAX_GIT_OUTPUT_CHARS): Promise<string> {
154
+ try {
155
+ const output = await $`git ${args}`.cwd(cwd).quiet().text();
156
+ return limitText(output.trim(), maxChars);
157
+ } catch {
158
+ return "";
159
+ }
160
+ }
161
+
162
+ async function changedFiles(cwd: string): Promise<string[]> {
163
+ const output = await gitOutput(cwd, ["status", "--short"]);
164
+ return output
165
+ .split("\n")
166
+ .map(line => line.trim())
167
+ .filter(Boolean)
168
+ .map(line => line.replace(/^..\s+/, ""));
169
+ }
170
+
171
+ async function writeArtifact(
172
+ dir: string,
173
+ name: string,
174
+ description: string,
175
+ text: string,
176
+ ): Promise<ContributionPrepArtifact> {
177
+ const filePath = path.join(dir, name);
178
+ await Bun.write(filePath, `${text.trimEnd()}\n`);
179
+ return { path: filePath, description };
180
+ }
181
+
182
+ export function buildContributionPrepWorkerPrompt(manifestPath: string): string {
183
+ return [
184
+ "Prepare a maintainer-friendly contribution draft from the redacted context dump.",
185
+ "Read the manifest and referenced artifact file pointers. Do not assume transcript context was inlined here.",
186
+ `Manifest: ${manifestPath}`,
187
+ "Produce structured markdown with: title, problem summary, reproduction/context, proposed fix or implementation plan, affected files, tests to run, and uncertainty/remaining risks.",
188
+ "Do not create GitHub issues, open PRs, push branches, or perform remote writes unless the user explicitly confirms that action in this fresh session.",
189
+ ].join("\n");
190
+ }
191
+
192
+ export async function prepareContributionPrep(
193
+ context: ContributionPrepContext,
194
+ options: ContributionPrepOptions = {},
195
+ ): Promise<ContributionPrepResult> {
196
+ const createdAt = (options.now ?? context.now ?? new Date()).toISOString();
197
+ const safeTimestamp = createdAt.replace(/[:.]/g, "-");
198
+ const artifactDir = path.join(
199
+ options.artifactRoot ?? path.join(context.cwd, ".gjc", "contribution-prep"),
200
+ safeTimestamp,
201
+ );
202
+ await fs.mkdir(artifactDir, { recursive: true });
203
+
204
+ const redactions: RedactionState = { labels: new Set() };
205
+ const recentMessages = context.messages.slice(-MAX_TRANSCRIPT_MESSAGES);
206
+ const artifacts: ContributionPrepArtifact[] = [];
207
+ const redact = (text: string) => redactContributionPrepText(text, context.cwd, redactions);
208
+
209
+ artifacts.push(
210
+ await writeArtifact(
211
+ artifactDir,
212
+ "transcript.md",
213
+ "Redacted recent transcript window",
214
+ redact(recentMessages.map(formatMessage).join("\n---\n")),
215
+ ),
216
+ );
217
+ artifacts.push(
218
+ await writeArtifact(
219
+ artifactDir,
220
+ "summary.md",
221
+ "Current session summary and operator instructions",
222
+ redact(
223
+ [
224
+ `# Contribution prep context`,
225
+ `Source session: ${context.sessionId}`,
226
+ `Session file: ${context.sessionFile ?? "(none)"}`,
227
+ `Working directory: ${context.cwd}`,
228
+ options.customInstructions || context.customInstructions
229
+ ? `Custom instructions: ${options.customInstructions ?? context.customInstructions}`
230
+ : "Custom instructions: (none)",
231
+ ].join("\n"),
232
+ ),
233
+ ),
234
+ );
235
+
236
+ const gitHead = (await gitOutput(context.cwd, ["rev-parse", "HEAD"])) || null;
237
+ const files = await changedFiles(context.cwd);
238
+ artifacts.push(
239
+ await writeArtifact(artifactDir, "changed-files.txt", "Changed files from git status", redact(files.join("\n"))),
240
+ );
241
+ artifacts.push(
242
+ await writeArtifact(
243
+ artifactDir,
244
+ "git-diff.patch",
245
+ "Bounded redacted git diff",
246
+ redact(await gitOutput(context.cwd, ["diff", "--no-ext-diff"])),
247
+ ),
248
+ );
249
+ artifacts.push(
250
+ await writeArtifact(
251
+ artifactDir,
252
+ "environment.md",
253
+ "Redacted environment and reproduction metadata",
254
+ redact(
255
+ [
256
+ `cwd: ${context.cwd}`,
257
+ `git_head: ${gitHead ?? "unknown"}`,
258
+ `platform: ${process.platform}`,
259
+ `arch: ${process.arch}`,
260
+ `bun: ${Bun.version}`,
261
+ ].join("\n"),
262
+ ),
263
+ ),
264
+ );
265
+
266
+ const manifestPath = path.join(artifactDir, "manifest.json");
267
+ const workerPromptPath = path.join(artifactDir, "worker-prompt.md");
268
+ await Bun.write(workerPromptPath, `${buildContributionPrepWorkerPrompt(manifestPath)}\n`);
269
+
270
+ const manifest: ContributionPrepManifest = {
271
+ schema_version: CONTRIBUTION_PREP_SCHEMA_VERSION,
272
+ source_session_id: context.sessionId,
273
+ created_at: createdAt,
274
+ cwd: redact(context.cwd),
275
+ git_head: gitHead,
276
+ changed_files: files,
277
+ artifacts,
278
+ redactions: [...redactions.labels].sort(),
279
+ recommended_output: [
280
+ "title",
281
+ "problem summary",
282
+ "reproduction/context",
283
+ "proposed fix or implementation plan",
284
+ "affected files",
285
+ "tests to run",
286
+ "uncertainty / remaining risks",
287
+ ],
288
+ worker_prompt_path: workerPromptPath,
289
+ };
290
+ await Bun.write(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
291
+
292
+ let spawned = false;
293
+ if (options.spawnWorker) {
294
+ const spawn =
295
+ options.spawn ??
296
+ (async (args, cwd, shell) => {
297
+ if (shell) {
298
+ Bun.spawn({
299
+ cmd: args,
300
+ cwd,
301
+ stdout: "inherit",
302
+ stderr: "inherit",
303
+ stdin: "inherit",
304
+ windowsVerbatimArguments: true,
305
+ });
306
+ return;
307
+ }
308
+ Bun.spawn(args, { cwd, stdout: "inherit", stderr: "inherit", stdin: "inherit" });
309
+ });
310
+ const command = resolveGjcCommand();
311
+ await spawn(
312
+ [command.cmd, ...command.args, "--no-skills", "--", `@${workerPromptPath}`],
313
+ context.cwd,
314
+ command.shell,
315
+ );
316
+ spawned = true;
317
+ }
318
+
319
+ return { manifestPath, workerPromptPath, artifactDir, changedFiles: files, spawned };
320
+ }
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import { getAgentDir } from "@gajae-code/utils";
3
4
  import { YAML } from "bun";
@@ -131,6 +132,7 @@ async function writeModelsConfig(modelsPath: string, config: ModelsConfig): Prom
131
132
  const where = first?.path.length ? `/${first.path.map(String).join("/")}` : "root";
132
133
  throw new Error(`Generated models config is invalid at ${where}: ${first?.message ?? "unknown schema error"}`);
133
134
  }
135
+ await fs.mkdir(path.dirname(modelsPath), { recursive: true });
134
136
  await Bun.write(modelsPath, YAML.stringify(checked.data, null, 2));
135
137
  }
136
138
 
@@ -1,5 +1,6 @@
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
6
  export const SKILL_ACTIVE_STALE_MS = 24 * 60 * 60 * 1000;
@@ -25,6 +26,8 @@ export interface WorkflowHudSummary {
25
26
  updated_at?: string;
26
27
  }
27
28
 
29
+ export type { WorkflowStateReceipt } from "./workflow-state-contract";
30
+
28
31
  export interface SkillActiveEntry {
29
32
  skill: string;
30
33
  phase?: string;
@@ -36,6 +39,7 @@ export interface SkillActiveEntry {
36
39
  turn_id?: string;
37
40
  hud?: WorkflowHudSummary;
38
41
  stale?: boolean;
42
+ receipt?: WorkflowStateReceipt;
39
43
  }
40
44
 
41
45
  export interface SkillActiveState {
@@ -70,6 +74,7 @@ export interface SyncSkillActiveStateOptions {
70
74
  nowIso?: string;
71
75
  source?: string;
72
76
  hud?: WorkflowHudSummary;
77
+ receipt?: WorkflowStateReceipt;
73
78
  }
74
79
 
75
80
  const HUD_TEXT_LIMIT = 80;
@@ -142,6 +147,36 @@ export function normalizeWorkflowHudSummary(raw: unknown): WorkflowHudSummary |
142
147
  };
143
148
  }
144
149
 
150
+ function normalizeWorkflowStateReceipt(raw: unknown): WorkflowStateReceipt | undefined {
151
+ if (!raw || typeof raw !== "object") return undefined;
152
+ const record = raw as Record<string, unknown>;
153
+ if (record.version !== 1) return undefined;
154
+ const skill = safeString(record.skill).trim();
155
+ if (!isCanonicalGjcWorkflowSkill(skill)) return undefined;
156
+ const owner = safeString(record.owner).trim();
157
+ if (owner !== "gjc-state-cli" && owner !== "gjc-runtime" && owner !== "gjc-hook") return undefined;
158
+ const command = sanitizeHudString(record.command, 120);
159
+ const statePath = sanitizeHudString(record.state_path, 240);
160
+ const storagePath = sanitizeHudString(record.storage_path, 240);
161
+ const mutatedAt = sanitizeHudString(record.mutated_at, 40);
162
+ const freshUntil = sanitizeHudString(record.fresh_until, 40);
163
+ const status = safeString(record.status).trim();
164
+ const mutationId = sanitizeHudString(record.mutation_id, 120);
165
+ if (!command || !statePath || !storagePath || !mutatedAt || !freshUntil || !mutationId) return undefined;
166
+ return {
167
+ version: 1,
168
+ skill,
169
+ owner,
170
+ command,
171
+ state_path: statePath,
172
+ storage_path: storagePath,
173
+ mutated_at: mutatedAt,
174
+ fresh_until: freshUntil,
175
+ status: status === "stale" ? "stale" : "fresh",
176
+ mutation_id: mutationId,
177
+ };
178
+ }
179
+
145
180
  function encodePathSegment(value: string): string {
146
181
  return encodeURIComponent(value).replaceAll(".", "%2E");
147
182
  }
@@ -175,6 +210,7 @@ function normalizeEntry(raw: unknown): SkillActiveEntry | null {
175
210
  const skill = safeString(record.skill).trim();
176
211
  if (!skill) return null;
177
212
  const hud = normalizeWorkflowHudSummary(record.hud);
213
+ const receipt = normalizeWorkflowStateReceipt(record.receipt);
178
214
  return {
179
215
  ...record,
180
216
  skill,
@@ -186,6 +222,7 @@ function normalizeEntry(raw: unknown): SkillActiveEntry | null {
186
222
  thread_id: safeString(record.thread_id).trim() || undefined,
187
223
  turn_id: safeString(record.turn_id).trim() || undefined,
188
224
  ...(hud ? { hud } : {}),
225
+ ...(receipt ? { receipt } : {}),
189
226
  stale: undefined,
190
227
  };
191
228
  }
@@ -338,6 +375,7 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
338
375
  thread_id: options.threadId,
339
376
  turn_id: options.turnId,
340
377
  ...(hud ? { hud } : {}),
378
+ ...(options.receipt ? { receipt: options.receipt } : {}),
341
379
  };
342
380
  const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, options.sessionId);
343
381
  const rootState = (await readStateFile(rootPath)) ?? { version: 1, active_skills: [] };
@@ -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 is active; continue interviewing with `ask`, write/finalize pending specs through the required GJC workflow CLI, or use an explicit force override. Direct `.gjc/` and product-code edits are blocked until explicit execution approval.";
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 {
@@ -246,34 +255,67 @@ 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 {
266
+ function blockedWorkflowStateSkill(cwd: string, rawPath: string): CanonicalGjcWorkflowSkill | null {
267
+ const segments = relativeGjcSegments(cwd, rawPath);
268
+ if (!segments || 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;
277
+ }
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 isGjcManagedPath(cwd: string, rawPath: string): boolean {
288
+ const segments = relativeGjcSegments(cwd, rawPath);
289
+ return segments?.[0] === ".gjc";
290
+ }
291
+
292
+ function isAllowlistedPath(cwd: string, rawPath: string): boolean {
293
+ const segments = relativeGjcSegments(cwd, rawPath);
294
+ if (!segments || segments[0] !== ".gjc") return false;
295
+ return segments[1] === "specs" || segments[1] === "plans";
296
+ }
297
+
298
+ function hasGjcManagedTarget(cwd: string, targets: ExtractedTargets): boolean {
259
299
  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
- });
300
+ return targets.paths.some(rawPath => isGjcManagedPath(cwd, rawPath));
266
301
  }
267
302
 
303
+ function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
304
+ return (
305
+ !targets.unknown && targets.paths.length > 0 && targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath))
306
+ );
307
+ }
268
308
  export async function assertDeepInterviewMutationRawPathsAllowed(input: {
269
309
  cwd: string;
270
310
  sessionId?: string;
271
311
  threadId?: string;
272
312
  rawPaths: string[];
313
+ forceOverride?: boolean;
273
314
  }): Promise<void> {
315
+ if (input.forceOverride) return;
274
316
  if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) return;
275
317
  const targets: ExtractedTargets = { paths: input.rawPaths, unknown: input.rawPaths.length === 0 };
276
- if (!allTargetsAllowlisted(input.cwd, targets)) {
318
+ if (hasGjcManagedTarget(input.cwd, targets)) {
277
319
  throw new ToolError(DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE);
278
320
  }
279
321
  }
@@ -282,19 +324,41 @@ export async function getDeepInterviewMutationDecision(
282
324
  input: DeepInterviewMutationGuardInput,
283
325
  ): Promise<DeepInterviewMutationDecision> {
284
326
  if (!BLOCKED_TOOL_NAMES.has(input.tool.name)) return { blocked: false, targets: [] };
327
+ const targets = extractTargets(input.tool, input.args);
328
+ if (input.enforceWorkflowState !== false) {
329
+ const stateSkill = firstBlockedWorkflowStateSkill(input.cwd, targets);
330
+ if (stateSkill) {
331
+ const command = sanctionedWorkflowStateCommand(stateSkill);
332
+ return {
333
+ blocked: true,
334
+ message: `${WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE}\nUse: ${command}`,
335
+ targets: targets.paths,
336
+ reason: "workflow-state-target",
337
+ command,
338
+ };
339
+ }
340
+ }
285
341
  if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) {
286
342
  return { blocked: false, targets: [] };
287
343
  }
288
- const targets = extractTargets(input.tool, input.args);
289
- if (allTargetsAllowlisted(input.cwd, targets)) {
290
- return { blocked: false, targets: targets.paths };
344
+ if (input.forceOverride) return { blocked: false, targets: [] };
345
+ if (targets.unknown) {
346
+ return {
347
+ blocked: true,
348
+ message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
349
+ targets: targets.paths,
350
+ reason: "unknown-target",
351
+ };
352
+ }
353
+ if (hasGjcManagedTarget(input.cwd, targets) && !allTargetsAllowlisted(input.cwd, targets)) {
354
+ return {
355
+ blocked: true,
356
+ message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
357
+ targets: targets.paths,
358
+ reason: "gjc-managed-target",
359
+ };
291
360
  }
292
- return {
293
- blocked: true,
294
- message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
295
- targets: targets.paths,
296
- reason: targets.unknown ? "unknown-target" : "product-target",
297
- };
361
+ return { blocked: false, targets: targets.paths };
298
362
  }
299
363
 
300
364
  export async function assertDeepInterviewMutationAllowed(input: DeepInterviewMutationGuardInput): Promise<void> {
@@ -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
  };