@gajae-code/coding-agent 0.6.0 → 0.6.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.
@@ -11,3 +11,27 @@ export type LspStartupEvent =
11
11
  type: "failed";
12
12
  error: string;
13
13
  };
14
+ const OPTIONAL_STARTUP_FAILURE_SERVERS = new Set(["rust-analyzer"]);
15
+
16
+ function isOptionalStartupFailure(server: LspStartupServerInfo): boolean {
17
+ return server.status === "error" && OPTIONAL_STARTUP_FAILURE_SERVERS.has(server.name);
18
+ }
19
+
20
+ export function getLspStartupWarningMessage(event: LspStartupEvent): string | null {
21
+ if (event.type === "failed") {
22
+ return "LSP startup failed. It will retry lazily on write.";
23
+ }
24
+
25
+ const failedServers = event.servers.filter(server => server.status === "error" && !isOptionalStartupFailure(server));
26
+
27
+ if (failedServers.length === 1) {
28
+ return `LSP startup failed for ${failedServers[0].name}. It will retry lazily on write.`;
29
+ }
30
+
31
+ if (failedServers.length > 1) {
32
+ const failedNames = failedServers.map(server => server.name).join(", ");
33
+ return `LSP startup failed for ${failedNames}. It will retry lazily on write.`;
34
+ }
35
+
36
+ return null;
37
+ }
@@ -44,7 +44,7 @@ import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slas
44
44
  import { consumePendingGoalModeRequest } from "../gjc-runtime/goal-mode-request";
45
45
  import { type Goal, type GoalModeState, normalizeGoal } from "../goals/state";
46
46
  import { resolveLocalUrlToPath } from "../internal-urls";
47
- import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
47
+ import { getLspStartupWarningMessage, LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
48
48
  import {
49
49
  humanizePlanTitle,
50
50
  type PlanApprovalDetails,
@@ -71,6 +71,7 @@ import { normalizeLocalScheme } from "../tools/path-utils";
71
71
  import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
72
72
  import { formatPhaseDisplayName } from "../tools/todo-write";
73
73
  import { ToolError } from "../tools/tool-errors";
74
+
74
75
  import type { EventBus } from "../utils/event-bus";
75
76
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
76
77
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
@@ -2061,23 +2062,9 @@ export class InteractiveMode implements InteractiveModeContext {
2061
2062
  #handleLspStartupEvent(event: LspStartupEvent): void {
2062
2063
  this.#updateWelcomeLspServers();
2063
2064
 
2064
- if (event.type === "failed") {
2065
- this.showWarning(`LSP startup failed: ${event.error}. It will retry lazily on write.`);
2066
- return;
2067
- }
2068
-
2069
- const failedServers = event.servers.filter(server => server.status === "error");
2070
-
2071
- if (failedServers.length === 1) {
2072
- const failedServer = failedServers[0];
2073
- const detail = failedServer.error ? `: ${failedServer.error}` : "";
2074
- this.showWarning(`LSP startup failed for ${failedServer.name}${detail}. It will retry lazily on write.`);
2075
- return;
2076
- }
2077
-
2078
- if (failedServers.length > 1) {
2079
- const failedNames = failedServers.map(server => server.name).join(", ");
2080
- this.showWarning(`LSP startup failed for ${failedNames}. It will retry lazily on write.`);
2065
+ const warningMessage = getLspStartupWarningMessage(event);
2066
+ if (warningMessage) {
2067
+ this.showWarning(warningMessage);
2081
2068
  }
2082
2069
  }
2083
2070
 
@@ -44,7 +44,6 @@ import {
44
44
  type EmergencyCompactionSample,
45
45
  emergencyCompactionReason,
46
46
  estimateMessageTokensHeuristic,
47
- estimateTokens,
48
47
  generateBranchSummary,
49
48
  generateHandoff,
50
49
  prepareCompaction,
@@ -225,7 +224,7 @@ import {
225
224
  readVisibleSkillActiveState,
226
225
  syncSkillActiveState,
227
226
  } from "../skill-state/active-state";
228
- import { assertDeepInterviewMutationAllowed } from "../skill-state/deep-interview-mutation-guard";
227
+ import { assertWorkflowMutationAllowed } from "../skill-state/deep-interview-mutation-guard";
229
228
  import { invalidateHostMetadata } from "../ssh/connection-manager";
230
229
  import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
231
230
  import {
@@ -1475,7 +1474,7 @@ export class AgentSession {
1475
1474
  }
1476
1475
  const sanitized = sanitizeMessage(providerMessages[i]!);
1477
1476
  if (!sanitized) continue;
1478
- const messageTokens = estimateTokens(sanitized);
1477
+ const messageTokens = estimateMessageTokensHeuristic(sanitized);
1479
1478
  if (maxTokens > 0 && approximateTokens + messageTokens > maxTokens) {
1480
1479
  recordSkip("token-limit");
1481
1480
  continue;
@@ -3775,7 +3774,7 @@ export class AgentSession {
3775
3774
  onUpdate: never,
3776
3775
  ctx: never,
3777
3776
  ) => {
3778
- await assertDeepInterviewMutationAllowed({
3777
+ await assertWorkflowMutationAllowed({
3779
3778
  cwd: this.sessionManager.getCwd(),
3780
3779
  sessionId: this.sessionManager.getSessionId(),
3781
3780
  tool: target,
@@ -9901,9 +9900,9 @@ export class AgentSession {
9901
9900
  #estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
9902
9901
  tokens: number;
9903
9902
  } {
9904
- const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageNativeContextTokens(message));
9903
+ const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageCompactionDeltaTokens(message));
9905
9904
  return {
9906
- tokens: estimate.tokens + this.#estimateMessagesNativeContextTokens(pendingMessages),
9905
+ tokens: estimate.tokens + this.#estimateMessagesCompactionDeltaTokens(pendingMessages),
9907
9906
  };
9908
9907
  }
9909
9908
 
@@ -9949,10 +9948,10 @@ export class AgentSession {
9949
9948
  };
9950
9949
  }
9951
9950
 
9952
- #estimateMessagesNativeContextTokens(messages: readonly AgentMessage[]): number {
9951
+ #estimateMessagesCompactionDeltaTokens(messages: readonly AgentMessage[]): number {
9953
9952
  let tokens = 0;
9954
9953
  for (const message of messages) {
9955
- tokens += this.#estimateMessageNativeContextTokens(message);
9954
+ tokens += this.#estimateMessageCompactionDeltaTokens(message);
9956
9955
  }
9957
9956
  return tokens;
9958
9957
  }
@@ -9965,11 +9964,17 @@ export class AgentSession {
9965
9964
  return tokens;
9966
9965
  }
9967
9966
 
9968
- #nativeTokenCache = new WeakMap<AgentMessage, { len: number; tokens: number }>();
9967
+ /**
9968
+ * Conservative inflation applied to the native-free chars/4 estimate of the
9969
+ * UNSENT context delta. chars/4 undercounts dense code/CJK, so we bias high
9970
+ * to compact slightly early rather than overflow the model window before the
9971
+ * next provider response re-anchors the exact count.
9972
+ */
9973
+ #compactionDeltaInflation = 1.2;
9974
+ #compactionDeltaTokenCache = new WeakMap<AgentMessage, { len: number; tokens: number }>();
9969
9975
 
9970
- /** Cheap content-size signal to invalidate the native token cache on mutation (growth). */
9971
9976
  /**
9972
- * Cheap content-size signal to invalidate the native token cache on mutation. Recursively
9977
+ * Cheap content-size signal to invalidate the compaction-delta token cache on mutation. Recursively
9973
9978
  * sums string lengths across the whole message (depth-bounded), so it covers every
9974
9979
  * provider-visible shape (text/thinking/tool args, toolResult output, tool names, etc.)
9975
9980
  * without allocating a serialized copy. A size-preserving in-place edit yields only a
@@ -9992,19 +9997,22 @@ export class AgentSession {
9992
9997
  return 0;
9993
9998
  }
9994
9999
 
9995
- #estimateMessageNativeContextTokens(message: AgentMessage): number {
9996
- // F10/F22: cache the expensive native token count per message object, invalidated by a
9997
- // cheap content-size signal, so unchanged (stable-size) messages are not re-tokenized on
9998
- // every pre-prompt estimate. A rare size-preserving in-place edit yields only a benign
9999
- // token-estimate drift, never wrong output.
10000
+ #estimateMessageCompactionDeltaTokens(message: AgentMessage): number {
10001
+ // Provider usage anchors the already-sent context (see calculatePromptTokens); this
10002
+ // estimates only the UNSENT delta with the native-free chars/4 heuristic, inflated by
10003
+ // #compactionDeltaInflation so dense input cannot undercount us past the compaction
10004
+ // threshold before the next provider response re-anchors the exact count. Cached per
10005
+ // message object, invalidated by a cheap content-size signal; a rare size-preserving
10006
+ // in-place edit yields only a benign estimate drift, never wrong output.
10000
10007
  const len = this.#messageTokenSize(message);
10001
- const cached = this.#nativeTokenCache.get(message);
10008
+ const cached = this.#compactionDeltaTokenCache.get(message);
10002
10009
  if (cached && cached.len === len) return cached.tokens;
10003
- let tokens = 0;
10010
+ let heuristic = 0;
10004
10011
  for (const llmMessage of convertToLlm([message])) {
10005
- tokens += estimateTokens(llmMessage);
10012
+ heuristic += estimateMessageTokensHeuristic(llmMessage);
10006
10013
  }
10007
- this.#nativeTokenCache.set(message, { len, tokens });
10014
+ const tokens = Math.ceil(heuristic * this.#compactionDeltaInflation);
10015
+ this.#compactionDeltaTokenCache.set(message, { len, tokens });
10008
10016
  return tokens;
10009
10017
  }
10010
10018
 
@@ -559,39 +559,44 @@ function dedupeVisibleBySkill(entries: SkillActiveEntry[], sessionId?: string):
559
559
 
560
560
  /**
561
561
  * The planning pipeline advances one stage at a time: `deep-interview →
562
- * ralplan → ultragoal`. Each stage is activated through its own command path
563
- * (`gjc deep-interview`, `gjc ralplan`, `gjc ultragoal`), and those activations
564
- * do not demote the previous stage's row only the explicit `handoff` verb
565
- * does. Without this collapse, activating ultragoal while ralplan is still
566
- * `active:true` would render both stages and keep showing a workflow that has
567
- * already handed control forward. Keep only the most recently updated pipeline
568
- * stage so the HUD reflects the single current workflow. `team` is intentionally
569
- * excluded — it runs alongside ultragoal — and every non-pipeline skill is left
570
- * untouched.
571
- *
572
- * This is a HUD-display policy only. It is applied by the skill HUD renderer and
573
- * deliberately NOT folded into `readVisibleSkillActiveState`, whose callers (the
574
- * deep-interview mutation guard and handoff caller inference) must keep seeing
575
- * every genuinely-active skill rather than the single most-recent pipeline stage.
562
+ * ralplan → ultragoal`. Activating a downstream stage supersedes upstream
563
+ * stages so stale rows cannot keep owning the HUD, gate, or primary active
564
+ * snapshot. `team` is intentionally excludedit runs alongside ultragoal
565
+ * and every non-pipeline skill is left untouched.
576
566
  */
577
567
  const PLANNING_PIPELINE_SKILLS = new Set<string>(["deep-interview", "ralplan", "ultragoal"]);
568
+ const PLANNING_PIPELINE_RANK = new Map<string, number>([
569
+ ["deep-interview", 0],
570
+ ["ralplan", 1],
571
+ ["ultragoal", 2],
572
+ ]);
573
+
574
+ function planningPipelineRank(skill: string): number | undefined {
575
+ return PLANNING_PIPELINE_RANK.get(skill);
576
+ }
577
+
578
+ function comparePipelineEntry(a: SkillActiveEntry, b: SkillActiveEntry): number {
579
+ const aRank = planningPipelineRank(a.skill);
580
+ const bRank = planningPipelineRank(b.skill);
581
+ if (aRank !== undefined || bRank !== undefined) return (bRank ?? -1) - (aRank ?? -1);
582
+ const aRecency = entryRecency(a);
583
+ const bRecency = entryRecency(b);
584
+ if (Number.isFinite(aRecency) || Number.isFinite(bRecency)) return (bRecency || 0) - (aRecency || 0);
585
+ return 0;
586
+ }
587
+
588
+ function upstreamPlanningPipelineSkills(skill: string): string[] {
589
+ const rank = planningPipelineRank(skill);
590
+ if (rank === undefined) return [];
591
+ return [...PLANNING_PIPELINE_RANK.entries()]
592
+ .filter(([, candidateRank]) => candidateRank < rank)
593
+ .map(([candidate]) => candidate);
594
+ }
578
595
 
579
596
  export function collapsePlanningPipeline(entries: readonly SkillActiveEntry[]): SkillActiveEntry[] {
580
597
  const pipeline = entries.filter(entry => PLANNING_PIPELINE_SKILLS.has(entry.skill));
581
598
  if (pipeline.length <= 1) return [...entries];
582
- let current = pipeline[0];
583
- let currentRecency = entryRecency(current);
584
- for (const entry of pipeline) {
585
- const recency = entryRecency(entry);
586
- // Prefer a strictly-newer valid timestamp; a valid timestamp also beats a
587
- // missing/unparseable one. Ties (or all-invalid) keep the first stage
588
- // deterministically rather than letting an unknown-recency row win.
589
- const better = Number.isFinite(recency) && (!Number.isFinite(currentRecency) || recency > currentRecency);
590
- if (better) {
591
- current = entry;
592
- currentRecency = recency;
593
- }
594
- }
599
+ const current = pipeline.toSorted(comparePipelineEntry)[0];
595
600
  return entries.filter(entry => !PLANNING_PIPELINE_SKILLS.has(entry.skill) || entry === current);
596
601
  }
597
602
 
@@ -618,9 +623,11 @@ async function mergeVisibleEntries(
618
623
  merged.set(entryKey(entry), entry);
619
624
  }
620
625
  const canonicalRalplanPhase = await readModeStatePhase(cwd, sessionId, "ralplan");
621
- return dedupeVisibleBySkill([...merged.values()], sessionId)
622
- .filter(entry => entry.active !== false)
623
- .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase));
626
+ return collapsePlanningPipeline(
627
+ dedupeVisibleBySkill([...merged.values()], sessionId)
628
+ .filter(entry => entry.active !== false)
629
+ .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase)),
630
+ );
624
631
  }
625
632
 
626
633
  export async function readVisibleSkillActiveState(cwd: string, sessionId?: string): Promise<SkillActiveState | null> {
@@ -682,6 +689,20 @@ async function rebuildActiveState(cwd: string, sessionScope?: ActiveSessionScope
682
689
  await rebuildActiveSnapshot(cwd, sessionScope, { cwd, audit: activeStateWriterAudit("rebuild-active-snapshot") });
683
690
  }
684
691
 
692
+ async function removeSupersededPlanningPipelineEntries(
693
+ cwd: string,
694
+ sessionScope: ActiveSessionScope | undefined,
695
+ entry: SkillActiveEntry,
696
+ ): Promise<void> {
697
+ if (entry.active === false) return;
698
+ for (const skill of upstreamPlanningPipelineSkills(entry.skill)) {
699
+ await removeActiveEntry(cwd, sessionScope, skill, {
700
+ cwd,
701
+ audit: activeStateWriterAudit("remove-superseded-pipeline-entry"),
702
+ });
703
+ }
704
+ }
705
+
685
706
  async function activeSubskillsForExistingEntry(
686
707
  cwd: string,
687
708
  sessionId: string | undefined,
@@ -725,11 +746,13 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
725
746
  ? { active_subskills: preservedActiveSubskills }
726
747
  : {}),
727
748
  };
749
+ await removeSupersededPlanningPipelineEntries(options.cwd, undefined, entry);
728
750
  await persistActiveEntry(options.cwd, undefined, entry);
729
751
  await rebuildActiveState(options.cwd);
730
752
 
731
753
  if (!options.sessionId) return;
732
754
  const sessionScope = { sessionId: options.sessionId };
755
+ await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry);
733
756
  await persistActiveEntry(options.cwd, sessionScope, entry);
734
757
  await rebuildActiveState(options.cwd, sessionScope);
735
758
  }
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
1
3
  import * as path from "node:path";
2
4
  import type { AgentTool } from "@gajae-code/agent-core";
3
5
  import { logger } from "@gajae-code/utils";
@@ -17,6 +19,17 @@ export const DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE =
17
19
  "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.";
18
20
  export const WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE =
19
21
  ".gjc workflow state and artifacts are runtime-owned. Agent mutation tools cannot edit `.gjc/**`; use the sanctioned `gjc` CLI instead.";
22
+ export const RALPLAN_MUTATION_BLOCK_MESSAGE =
23
+ "Ralplan planning phase boundary: keep refining the consensus plan and persist plan artifacts through `gjc ralplan --write` (stage scratch files under a temp dir if needed). Product-code mutation tools and patch execution are blocked while ralplan is active; mutate only after the plan is approved and execution begins.";
24
+ export const ULTRAGOAL_GOAL_PLANNING_MUTATION_BLOCK_MESSAGE =
25
+ "Ultragoal goal-planning phase boundary: finish goal planning and record goals through `gjc ultragoal` before editing code. Product-code mutation tools and patch execution are blocked until goal planning completes and execution begins.";
26
+
27
+ /** Resolve the phase-boundary block message for the active planning skill. */
28
+ function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
29
+ if (skill === "ralplan") return RALPLAN_MUTATION_BLOCK_MESSAGE;
30
+ if (skill === "ultragoal") return ULTRAGOAL_GOAL_PLANNING_MUTATION_BLOCK_MESSAGE;
31
+ return DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE;
32
+ }
20
33
 
21
34
  const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit", "bash"]);
22
35
  const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
@@ -25,6 +38,19 @@ const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\
25
38
  const BASH_TOKEN_RE = /'[^']*'|"(?:\\.|[^"\\])*"|\S+/g;
26
39
  const BASH_REDIRECT_RE = /^(?:\d*)>>?$/;
27
40
  const BASH_HEREDOC_RE = /^(?:\d*)<<-?$/;
41
+ // Shell command-list / redirection / substitution operators. Includes `\r` and
42
+ // `\n` because the shell treats a newline as a command separator and tool command
43
+ // strings can be multiline (e.g. heredocs).
44
+ const BASH_CONTROL_OPERATOR_RE = /[;&|<>`\r\n]|\$\(/;
45
+ // Best-effort, defense-in-depth bash mutation detection. The authoritative
46
+ // planning-phase guard is the dedicated `write`/`edit`/`ast_edit` tools (fully
47
+ // pathed); this catches the common shell mutators plus all redirect targets so a
48
+ // cooperative agent cannot trivially side-step those tools. It is deliberately
49
+ // NOT exhaustive: arbitrary interpreters (`python -c`, `node -e`) and the
50
+ // `key=value` operand forms of utilities like `dd of=` are not parsed, and path
51
+ // classification is lexical (no realpath), matching the rest of this guard and
52
+ // the broader `.gjc` path handling. Hardening any of these would require a real
53
+ // shell parser / symlink resolution and is out of scope for the planning rails.
28
54
  const BASH_MUTATION_COMMANDS = new Set(["rm", "mv", "cp", "touch", "mkdir", "ln", "tee"]);
29
55
 
30
56
  type ToolWithEditMode = AgentTool & {
@@ -111,13 +137,13 @@ async function readVisibleModeState(cwd: string, skill: string, sessionId?: stri
111
137
  return await readValidatedModeState(modeStatePath(cwd, skill));
112
138
  }
113
139
 
114
- function isTerminalModeState(state: ModeState | null): boolean {
115
- if (state?.active !== true) return true;
116
- const phase = String(state.current_phase ?? "")
117
- .trim()
118
- .toLowerCase();
119
- return ["complete", "completed", "failed", "cancelled", "canceled", "inactive"].includes(phase);
120
- }
140
+ /**
141
+ * Phases that genuinely finish a workflow skill. Mirrors the Stop hook's
142
+ * `STOP_RELEASING_PHASES` (`hooks/skill-state.ts`): `handoff` is intentionally
143
+ * absent so a handoff-required planning skill (deep-interview/ralplan) keeps
144
+ * blocking through its handoff/ask window until it is demoted or cleared.
145
+ */
146
+ const WORKFLOW_FINISHED_PHASES = new Set(["complete", "completed", "failed", "cancelled", "canceled", "inactive"]);
121
147
 
122
148
  function entryMatchesContext(entry: SkillActiveEntry, sessionId?: string, threadId?: string): boolean {
123
149
  if (sessionId && entry.session_id && entry.session_id !== sessionId) return false;
@@ -131,17 +157,90 @@ function modeStateMatchesContext(state: ModeState, sessionId?: string, threadId?
131
157
  return true;
132
158
  }
133
159
 
134
- async function isActiveDeepInterview(cwd: string, sessionId?: string, threadId?: string): Promise<boolean> {
160
+ /** Workflow skills that have a pre-approval planning posture this guard enforces. `team` never does. */
161
+ function isPlanningSkill(skill: string): skill is "deep-interview" | "ralplan" | "ultragoal" {
162
+ return skill === "deep-interview" || skill === "ralplan" || skill === "ultragoal";
163
+ }
164
+
165
+ /**
166
+ * Whether `skill` in `phase` is a pre-approval planning posture that must block
167
+ * product-code mutation. `deep-interview` and `ralplan` are wholly pre-approval
168
+ * (every phase blocks except a genuinely-finished one — `handoff` and ralplan's
169
+ * `final` keep blocking until execution is approved and the skill is demoted).
170
+ * `ultragoal` only blocks during `goal-planning`; once goals are created it is an
171
+ * executor and mutates freely.
172
+ */
173
+ function isBlockingPlanningPhase(skill: "deep-interview" | "ralplan" | "ultragoal", phase: string): boolean {
174
+ const normalized = phase.trim().toLowerCase();
175
+ if (skill === "ultragoal") return normalized === "goal-planning";
176
+ return !WORKFLOW_FINISHED_PHASES.has(normalized);
177
+ }
178
+
179
+ interface ActivePlanningSkill {
180
+ skill: "deep-interview" | "ralplan" | "ultragoal";
181
+ phase: string;
182
+ }
183
+
184
+ /**
185
+ * Pick the single CURRENT workflow entry among active entries.
186
+ *
187
+ * Steady state has exactly one active workflow skill (handoff demotes the prior
188
+ * to `active:false`, which `listActiveSkills` already filters out). If several
189
+ * are momentarily active, prefer the most-recently-updated entry so a stale
190
+ * planning row (e.g. a still-active ralplan `final`) can never be selected over a
191
+ * newer executor (ultragoal/team), and a planning *return* (newer `updated_at`)
192
+ * reliably wins. Ties fall back to the resolved top-level `skill`, then to the
193
+ * first entry, matching how the HUD/chain guard pick `activeSkills[0]`.
194
+ */
195
+ function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
196
+ const ts = (entry: SkillActiveEntry): number => {
197
+ const value = Date.parse(safeString(entry.updated_at) || safeString(entry.activated_at));
198
+ return Number.isNaN(value) ? -1 : value;
199
+ };
200
+ let best = entries[0];
201
+ for (const entry of entries) {
202
+ const delta = ts(entry) - ts(best);
203
+ if (delta > 0) best = entry;
204
+ else if (delta === 0 && topLevelSkill && entry.skill === topLevelSkill) best = entry;
205
+ }
206
+ return best;
207
+ }
208
+
209
+ /**
210
+ * Resolve the single active pre-approval planning skill for this context, or null.
211
+ *
212
+ * Transition/return safety: this keys off the ONE canonical current workflow
213
+ * skill (the resolved top-level `skill` that the HUD and the skill-tool chain
214
+ * guard treat as active), not an independent scan of every skill. A handoff
215
+ * atomically demotes the prior skill and promotes the callee, and a return
216
+ * (e.g. re-entering ralplan/deep-interview after an ultragoal goal completes)
217
+ * re-activates the planning skill — in every case "whatever skill is current"
218
+ * governs, so a stale planning entry can never block while an executor runs and
219
+ * a resumed planning phase reliably re-blocks.
220
+ *
221
+ * Fail-open contract: a missing or invalid durable mode-state releases the block
222
+ * (a corrupt state file must not lock all mutation), matching the guard's
223
+ * historical behavior — this is intentionally looser than the Stop hook, which
224
+ * fails closed for handoff-required skills.
225
+ */
226
+ async function getActivePlanningSkill(
227
+ cwd: string,
228
+ sessionId?: string,
229
+ threadId?: string,
230
+ ): Promise<ActivePlanningSkill | null> {
135
231
  const skillState = await readVisibleSkillActiveState(cwd, sessionId);
136
- const activeDeepInterview = listActiveSkills(skillState).find(
137
- entry => entry.skill === "deep-interview" && entryMatchesContext(entry, sessionId, threadId),
138
- );
139
- if (!activeDeepInterview) return false;
140
-
141
- const modeState = await readVisibleModeState(cwd, "deep-interview", sessionId);
142
- if (isTerminalModeState(modeState)) return false;
143
- if (modeState && !modeStateMatchesContext(modeState, sessionId, threadId)) return false;
144
- return true;
232
+ if (!skillState) return null;
233
+ const activeEntries = listActiveSkills(skillState).filter(entry => entryMatchesContext(entry, sessionId, threadId));
234
+ if (activeEntries.length === 0) return null;
235
+ const current = resolveCurrentWorkflowEntry(activeEntries, safeString(skillState.skill).trim());
236
+ if (!isPlanningSkill(current.skill)) return null;
237
+ const modeState = await readVisibleModeState(cwd, current.skill, sessionId);
238
+ if (!modeState) return null;
239
+ if (modeState.active !== true) return null;
240
+ if (!modeStateMatchesContext(modeState, sessionId, threadId)) return null;
241
+ const phase = String(modeState.current_phase ?? current.phase ?? "").trim();
242
+ if (!isBlockingPlanningPhase(current.skill, phase)) return null;
243
+ return { skill: current.skill, phase };
145
244
  }
146
245
 
147
246
  function normalizePosix(value: string): string {
@@ -251,7 +350,13 @@ function extractBashTargets(args: unknown): ExtractedTargets {
251
350
  targets.unknown = true;
252
351
  return targets;
253
352
  }
254
- if (/^gjc(?:\s|$)/.test(command)) return targets;
353
+ // Fast path for a sanctioned `gjc …` invocation, but ONLY when it is a single
354
+ // command with no shell control operators or redirects. Otherwise a compound
355
+ // like `gjc … ; tee src/x` or `gjc … > .gjc/state/foo` would skip scanning and
356
+ // bypass both the planning block and the always-on `.gjc/**` block, so fall
357
+ // through to full token scanning (which leaves the `gjc` segment's own args
358
+ // unextracted but still catches the trailing mutation/redirect).
359
+ if (/^gjc(?:\s|$)/.test(command) && !BASH_CONTROL_OPERATOR_RE.test(command)) return targets;
255
360
 
256
361
  const tokens = command.match(BASH_TOKEN_RE)?.map(unquoteBashToken) ?? [];
257
362
  for (let index = 0; index < tokens.length; index++) {
@@ -266,14 +371,14 @@ function extractBashTargets(args: unknown): ExtractedTargets {
266
371
  addPath(targets, redirectMatch[1]);
267
372
  continue;
268
373
  }
374
+ // A heredoc delimiter (`<<EOF`) is a here-document word, NOT a filesystem
375
+ // target. Consume it without recording a target so a legitimate
376
+ // `cat <<EOF > /tmp/scratch.md` is judged solely by its redirect target.
269
377
  if (BASH_HEREDOC_RE.test(token)) {
270
- addPath(targets, tokens[index + 1]);
271
378
  index++;
272
379
  continue;
273
380
  }
274
- const heredocMatch = token.match(/^(?:\d*)<<-?(.+)$/);
275
- if (heredocMatch?.[1]) {
276
- addPath(targets, heredocMatch[1]);
381
+ if (/^(?:\d*)<<-?.+$/.test(token)) {
277
382
  continue;
278
383
  }
279
384
  if (isMutationBashCommand(tokens, index)) {
@@ -392,6 +497,84 @@ function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean
392
497
  !targets.unknown && targets.paths.length > 0 && targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath))
393
498
  );
394
499
  }
500
+
501
+ function neutralTempRoots(): string[] {
502
+ const roots = new Set<string>();
503
+ const add = (value: string | undefined): void => {
504
+ const trimmed = value?.trim();
505
+ if (trimmed) roots.add(path.resolve(trimmed));
506
+ };
507
+ add(os.tmpdir());
508
+ add(process.env.TMPDIR);
509
+ for (const fixed of ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp"]) add(fixed);
510
+ return [...roots];
511
+ }
512
+
513
+ function isPathWithin(root: string, target: string): boolean {
514
+ const rel = path.relative(root, target);
515
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
516
+ }
517
+
518
+ async function realpathOrSelf(target: string): Promise<string> {
519
+ try {
520
+ return await fs.realpath(target);
521
+ } catch {
522
+ return target;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Canonicalize a target whose leaf may not exist yet (we are about to write it):
528
+ * realpath the nearest existing ancestor and re-append the not-yet-existing
529
+ * suffix, so a symlinked ancestor (or macOS `/tmp` → `/private/tmp` alias) is
530
+ * resolved to its real location.
531
+ */
532
+ async function canonicalizeForContainment(absolutePath: string): Promise<string> {
533
+ const suffix: string[] = [];
534
+ let current = absolutePath;
535
+ for (let depth = 0; depth < 64; depth++) {
536
+ try {
537
+ const real = await fs.realpath(current);
538
+ return suffix.length > 0 ? path.join(real, ...suffix.reverse()) : real;
539
+ } catch {
540
+ const parent = path.dirname(current);
541
+ if (parent === current) break;
542
+ suffix.push(path.basename(current));
543
+ current = parent;
544
+ }
545
+ }
546
+ return absolutePath;
547
+ }
548
+
549
+ /**
550
+ * A neutral scratch path the planning-phase block tolerates: it resolves to a
551
+ * system temp directory and lives OUTSIDE the project cwd. Files inside the
552
+ * project tree (product code, `.gjc/**`) are never neutral, even when the cwd
553
+ * itself is rooted under a temp dir. The lexical checks run first; a canonical
554
+ * (symlink/alias-resolved) re-check then ensures the REAL target is still outside
555
+ * the project and inside a real temp root, defeating a temp symlink that points
556
+ * back into the repo or `.gjc/`.
557
+ */
558
+ async function isNeutralTempPath(cwd: string, rawPath: string): Promise<boolean> {
559
+ const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
560
+ if (unknown || !absolutePath) return false;
561
+ const resolvedCwd = path.resolve(cwd);
562
+ if (isPathWithin(resolvedCwd, absolutePath)) return false;
563
+ if (!neutralTempRoots().some(root => isPathWithin(root, absolutePath))) return false;
564
+ const realTarget = await canonicalizeForContainment(absolutePath);
565
+ if (isPathWithin(await realpathOrSelf(resolvedCwd), realTarget)) return false;
566
+ const realRoots = await Promise.all(neutralTempRoots().map(realpathOrSelf));
567
+ return realRoots.some(root => isPathWithin(root, realTarget));
568
+ }
569
+
570
+ /** Targets that remain disallowed during a planning phase (excludes neutral temp scratch). */
571
+ async function planningBlockedTargets(cwd: string, targets: ExtractedTargets): Promise<string[]> {
572
+ const blocked: string[] = [];
573
+ for (const rawPath of targets.paths) {
574
+ if (!(await isNeutralTempPath(cwd, rawPath))) blocked.push(rawPath);
575
+ }
576
+ return blocked;
577
+ }
395
578
  export async function assertDeepInterviewMutationRawPathsAllowed(input: {
396
579
  cwd: string;
397
580
  sessionId?: string;
@@ -399,12 +582,21 @@ export async function assertDeepInterviewMutationRawPathsAllowed(input: {
399
582
  rawPaths: string[];
400
583
  forceOverride?: boolean;
401
584
  }): Promise<void> {
402
- if (input.forceOverride) return;
403
- if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) return;
404
585
  const targets: ExtractedTargets = { paths: input.rawPaths, unknown: input.rawPaths.length === 0 };
405
- if (targets.unknown || targets.paths.length > 0) {
406
- throw new ToolError(DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE);
586
+ // Always-on `.gjc/**` runtime-owned block, in parity with getDeepInterviewMutationDecision
587
+ // and ahead of forceOverride: a deferred ast_edit apply must not reach `.gjc/**` either.
588
+ if (hasBlockedGjcTarget(input.cwd, targets)) {
589
+ const stateSkill = firstBlockedWorkflowStateSkill(input.cwd, targets);
590
+ const command = stateSkill ? sanctionedWorkflowStateCommand(stateSkill) : "gjc <workflow-command>";
591
+ throw new ToolError(`${WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE}\nUse: ${command}`);
407
592
  }
593
+ if (input.forceOverride) return;
594
+ const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId);
595
+ if (!planning) return;
596
+ const message = planningPhaseBlockMessage(planning.skill);
597
+ if (input.rawPaths.length === 0) throw new ToolError(message);
598
+ const blocked = await planningBlockedTargets(input.cwd, targets);
599
+ if (blocked.length > 0) throw new ToolError(message);
408
600
  }
409
601
 
410
602
  export async function getDeepInterviewMutationDecision(
@@ -423,24 +615,30 @@ export async function getDeepInterviewMutationDecision(
423
615
  command,
424
616
  };
425
617
  }
426
- if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) {
618
+ const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId);
619
+ if (!planning) {
427
620
  return { blocked: false, targets: [] };
428
621
  }
429
622
  if (input.forceOverride) return { blocked: false, targets: [] };
623
+ const message = planningPhaseBlockMessage(planning.skill);
430
624
  if (targets.unknown) {
431
625
  return {
432
626
  blocked: true,
433
- message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
627
+ message,
434
628
  targets: targets.paths,
435
629
  reason: "unknown-target",
436
630
  };
437
631
  }
438
- if (input.tool.name === "bash") {
632
+ // Neutral temp scratch (outside the project tree) stays writable so agents can
633
+ // stage artifacts and feed their path to the sanctioned `gjc ... --write` CLIs.
634
+ // Read-only / `gjc` bash extract no targets and fall through to allowed here.
635
+ const blockedTargets = await planningBlockedTargets(input.cwd, targets);
636
+ if (blockedTargets.length === 0) {
439
637
  return { blocked: false, targets: targets.paths };
440
638
  }
441
639
  return {
442
640
  blocked: true,
443
- message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
641
+ message,
444
642
  targets: targets.paths,
445
643
  reason: allTargetsAllowlisted(input.cwd, targets) ? "handoff-artifact-tool-target" : "phase-boundary",
446
644
  };
@@ -450,3 +648,13 @@ export async function assertDeepInterviewMutationAllowed(input: DeepInterviewMut
450
648
  const decision = await getDeepInterviewMutationDecision(input);
451
649
  if (decision.blocked) throw new ToolError(decision.message ?? DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE);
452
650
  }
651
+
652
+ /*
653
+ * Generic cross-workflow names for this planning-phase mutation guard. The guard
654
+ * now governs deep-interview, ralplan, and ultragoal goal-planning, so new
655
+ * callers SHOULD use these names; the `*DeepInterview*` exports above remain as
656
+ * compatibility aliases (and are still what the test-suite imports).
657
+ */
658
+ export const getWorkflowMutationDecision = getDeepInterviewMutationDecision;
659
+ export const assertWorkflowMutationAllowed = assertDeepInterviewMutationAllowed;
660
+ export const assertWorkflowMutationRawPathsAllowed = assertDeepInterviewMutationRawPathsAllowed;