@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.
- package/CHANGELOG.md +54 -0
- package/README.md +73 -1
- package/dist/types/cli/update-cli.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
- package/dist/types/lsp/startup-events.d.ts +1 -0
- package/dist/types/modes/components/welcome.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
- package/package.json +7 -7
- package/scripts/build-binary.ts +0 -7
- package/src/cli/setup-cli.ts +14 -1
- package/src/cli/update-cli.ts +53 -3
- package/src/commands/launch.ts +1 -1
- package/src/config/model-registry.ts +9 -2
- package/src/config/model-resolver.ts +13 -2
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +17 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -0
- package/src/exec/bash-executor.ts +3 -1
- package/src/gjc-runtime/launch-tmux.ts +62 -14
- package/src/gjc-runtime/state-runtime.ts +22 -14
- package/src/gjc-runtime/state-writer.ts +21 -1
- package/src/gjc-runtime/tmux-sessions.ts +36 -1
- package/src/internal-urls/docs-index.generated.ts +5 -6
- package/src/lsp/startup-events.ts +24 -0
- package/src/modes/components/welcome.ts +42 -9
- package/src/modes/controllers/input-controller.ts +21 -3
- package/src/modes/interactive-mode.ts +27 -19
- package/src/modes/prompt-action-autocomplete.ts +11 -1
- package/src/session/agent-session.ts +28 -20
- package/src/session/session-manager.ts +19 -2
- package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
- package/src/skill-state/active-state.ts +53 -30
- package/src/skill-state/deep-interview-mutation-guard.ts +238 -30
- package/src/slash-commands/builtin-registry.ts +8 -4
- package/src/system-prompt.ts +11 -9
- package/src/tools/ast-edit.ts +2 -2
- 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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const modeState = await readVisibleModeState(cwd,
|
|
142
|
-
if (
|
|
143
|
-
if (modeState
|
|
144
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
|
627
|
+
message,
|
|
434
628
|
targets: targets.paths,
|
|
435
629
|
reason: "unknown-target",
|
|
436
630
|
};
|
|
437
631
|
}
|
|
438
|
-
|
|
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
|
|
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
|
-
|
|
254
|
-
//
|
|
255
|
-
|
|
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 (
|
|
261
|
+
if (command.args) {
|
|
258
262
|
runtime.ctx.editor.addToHistory(command.text);
|
|
259
263
|
}
|
|
260
264
|
runtime.ctx.editor.setText("");
|
package/src/system-prompt.ts
CHANGED
|
@@ -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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
332
|
+
await assertWorkflowMutationRawPathsAllowed({
|
|
333
333
|
cwd: this.session.cwd,
|
|
334
334
|
sessionId: this.session.getSessionId?.() ?? undefined,
|
|
335
335
|
rawPaths: previewedFiles,
|
package/src/utils/edit-mode.ts
CHANGED