@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.
- package/CHANGELOG.md +32 -0
- 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/lsp/startup-events.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/update-cli.ts +53 -3
- package/src/config/models-config-schema.ts +1 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -0
- package/src/gjc-runtime/state-runtime.ts +22 -14
- package/src/gjc-runtime/state-writer.ts +21 -1
- package/src/internal-urls/docs-index.generated.ts +3 -4
- package/src/lsp/startup-events.ts +24 -0
- package/src/modes/interactive-mode.ts +5 -18
- package/src/session/agent-session.ts +28 -20
- package/src/skill-state/active-state.ts +53 -30
- package/src/skill-state/deep-interview-mutation-guard.ts +238 -30
- package/src/tools/ast-edit.ts +2 -2
- package/src/utils/edit-mode.ts +1 -1
|
@@ -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
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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.#
|
|
9903
|
+
const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageCompactionDeltaTokens(message));
|
|
9905
9904
|
return {
|
|
9906
|
-
tokens: estimate.tokens + this.#
|
|
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
|
-
#
|
|
9951
|
+
#estimateMessagesCompactionDeltaTokens(messages: readonly AgentMessage[]): number {
|
|
9953
9952
|
let tokens = 0;
|
|
9954
9953
|
for (const message of messages) {
|
|
9955
|
-
tokens += this.#
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
9996
|
-
//
|
|
9997
|
-
//
|
|
9998
|
-
//
|
|
9999
|
-
//
|
|
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.#
|
|
10008
|
+
const cached = this.#compactionDeltaTokenCache.get(message);
|
|
10002
10009
|
if (cached && cached.len === len) return cached.tokens;
|
|
10003
|
-
let
|
|
10010
|
+
let heuristic = 0;
|
|
10004
10011
|
for (const llmMessage of convertToLlm([message])) {
|
|
10005
|
-
|
|
10012
|
+
heuristic += estimateMessageTokensHeuristic(llmMessage);
|
|
10006
10013
|
}
|
|
10007
|
-
|
|
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`.
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
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 excluded — it 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
|
-
|
|
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
|
|
622
|
-
.
|
|
623
|
-
|
|
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
|
-
|
|
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;
|