@gajae-code/coding-agent 0.6.1 → 0.6.4

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 (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +73 -1
  3. package/dist/types/cli/update-cli.d.ts +3 -0
  4. package/dist/types/config/model-registry.d.ts +3 -0
  5. package/dist/types/config/models-config-schema.d.ts +5 -0
  6. package/dist/types/config/settings-schema.d.ts +27 -0
  7. package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
  8. package/dist/types/lsp/startup-events.d.ts +1 -0
  9. package/dist/types/modes/components/welcome.d.ts +3 -1
  10. package/dist/types/modes/interactive-mode.d.ts +3 -0
  11. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  12. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
  13. package/package.json +7 -7
  14. package/scripts/build-binary.ts +0 -7
  15. package/src/cli/setup-cli.ts +14 -1
  16. package/src/cli/update-cli.ts +53 -3
  17. package/src/commands/launch.ts +1 -1
  18. package/src/config/model-registry.ts +9 -2
  19. package/src/config/model-resolver.ts +13 -2
  20. package/src/config/models-config-schema.ts +1 -0
  21. package/src/config/settings-schema.ts +17 -0
  22. package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -1
  23. package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -0
  24. package/src/exec/bash-executor.ts +3 -1
  25. package/src/gjc-runtime/launch-tmux.ts +62 -14
  26. package/src/gjc-runtime/state-runtime.ts +22 -14
  27. package/src/gjc-runtime/state-writer.ts +21 -1
  28. package/src/gjc-runtime/tmux-sessions.ts +36 -1
  29. package/src/internal-urls/docs-index.generated.ts +5 -6
  30. package/src/lsp/startup-events.ts +24 -0
  31. package/src/modes/components/welcome.ts +42 -9
  32. package/src/modes/controllers/input-controller.ts +21 -3
  33. package/src/modes/interactive-mode.ts +27 -19
  34. package/src/modes/prompt-action-autocomplete.ts +11 -1
  35. package/src/session/agent-session.ts +28 -20
  36. package/src/session/session-manager.ts +19 -2
  37. package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
  38. package/src/skill-state/active-state.ts +53 -30
  39. package/src/skill-state/deep-interview-mutation-guard.ts +238 -30
  40. package/src/slash-commands/builtin-registry.ts +8 -4
  41. package/src/system-prompt.ts +11 -9
  42. package/src/tools/ast-edit.ts +2 -2
  43. package/src/utils/edit-mode.ts +1 -1
@@ -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;
@@ -250,11 +250,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
250
250
  inlineHint: "[objective]",
251
251
  allowArgs: true,
252
252
  handleTui: async (command, runtime) => {
253
- const hadArgs = !!command.args;
254
- // Capture state BEFORE the call (see /plan above for rationale).
255
- const wasGoalModeEnabled = runtime.ctx.goalModeEnabled;
253
+ // The goal command always consumes the typed input: it either submits
254
+ // the bare objective (never the literal `/goal …` text the user typed)
255
+ // or shows a warning, so the normal submission path never records it in
256
+ // input history. Preserve the typed command whenever args were supplied
257
+ // — including the first-time `/goal set <objective>` case where goal
258
+ // mode was not yet active. A previous `wasGoalModeEnabled` guard dropped
259
+ // that first-time case from history (up/down-arrow recall).
256
260
  await runtime.ctx.handleGoalModeCommand(command.args || undefined);
257
- if (hadArgs && wasGoalModeEnabled) {
261
+ if (command.args) {
258
262
  runtime.ctx.editor.addToHistory(command.text);
259
263
  }
260
264
  runtime.ctx.editor.setText("");
@@ -253,15 +253,17 @@ export async function loadProjectContextFiles(
253
253
 
254
254
  const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd });
255
255
 
256
- // Convert ContextFile items and preserve depth info
257
- const files = result.items.map(item => {
258
- const contextFile = item as ContextFile;
259
- return {
260
- path: contextFile.path,
261
- content: contextFile.content,
262
- depth: contextFile.depth,
263
- };
264
- });
256
+ // Convert project-level ContextFile items and preserve depth info
257
+ const files = result.items
258
+ .filter(item => (item as ContextFile).level === "project")
259
+ .map(item => {
260
+ const contextFile = item as ContextFile;
261
+ return {
262
+ path: contextFile.path,
263
+ content: contextFile.content,
264
+ depth: contextFile.depth,
265
+ };
266
+ });
265
267
 
266
268
  // Sort by depth (descending): higher depth (farther from cwd) comes first,
267
269
  // so files closer to cwd appear later and are more prominent
@@ -9,7 +9,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
9
  import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
10
10
  import type { Theme } from "../modes/theme/theme";
11
11
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
12
- import { assertDeepInterviewMutationRawPathsAllowed } from "../skill-state/deep-interview-mutation-guard";
12
+ import { assertWorkflowMutationRawPathsAllowed } from "../skill-state/deep-interview-mutation-guard";
13
13
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
@@ -329,7 +329,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
329
329
  label: `AST Edit: ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}`,
330
330
  sourceToolName: this.name,
331
331
  apply: async (_reason: string) => {
332
- await assertDeepInterviewMutationRawPathsAllowed({
332
+ await assertWorkflowMutationRawPathsAllowed({
333
333
  cwd: this.session.cwd,
334
334
  sessionId: this.session.getSessionId?.() ?? undefined,
335
335
  rawPaths: previewedFiles,
@@ -1,4 +1,4 @@
1
- import { $env } from "../../../utils/src/env";
1
+ import { $env } from "@gajae-code/utils/env";
2
2
 
3
3
  export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch";
4
4