@sayknow-cli/coding-agent 0.2.4 → 0.2.6

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 (150) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/cli/migrate-cli.d.ts +20 -0
  3. package/dist/types/cli/update-cli.d.ts +3 -0
  4. package/dist/types/commands/migrate.d.ts +33 -0
  5. package/dist/types/config/keybindings.d.ts +4 -0
  6. package/dist/types/config/model-registry.d.ts +3 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/config/settings-schema.d.ts +27 -0
  9. package/dist/types/harness-control-plane/storage.d.ts +2 -1
  10. package/dist/types/hooks/skill-state.d.ts +12 -4
  11. package/dist/types/lsp/startup-events.d.ts +1 -0
  12. package/dist/types/migrate/action-planner.d.ts +11 -0
  13. package/dist/types/migrate/adapters/claude-code.d.ts +2 -0
  14. package/dist/types/migrate/adapters/codex.d.ts +5 -0
  15. package/dist/types/migrate/adapters/index.d.ts +45 -0
  16. package/dist/types/migrate/adapters/opencode.d.ts +2 -0
  17. package/dist/types/migrate/executor.d.ts +2 -0
  18. package/dist/types/migrate/mcp-mapper.d.ts +20 -0
  19. package/dist/types/migrate/report.d.ts +18 -0
  20. package/dist/types/migrate/skill-normalizer.d.ts +27 -0
  21. package/dist/types/migrate/types.d.ts +126 -0
  22. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  23. package/dist/types/modes/components/welcome.d.ts +3 -1
  24. package/dist/types/modes/interactive-mode.d.ts +3 -0
  25. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  26. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +1 -1
  27. package/dist/types/research-plan/index.d.ts +1 -0
  28. package/dist/types/research-plan/ledger.d.ts +33 -0
  29. package/dist/types/rlm/artifacts.d.ts +1 -1
  30. package/dist/types/runtime-mcp/config-writer.d.ts +26 -0
  31. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +2 -0
  32. package/dist/types/skc-runtime/deep-interview-runtime.d.ts +2 -2
  33. package/dist/types/skc-runtime/goal-mode-request.d.ts +1 -1
  34. package/dist/types/skc-runtime/session-layout.d.ts +59 -0
  35. package/dist/types/skc-runtime/session-resolution.d.ts +47 -0
  36. package/dist/types/skc-runtime/state-graph.d.ts +1 -1
  37. package/dist/types/skc-runtime/state-runtime.d.ts +5 -4
  38. package/dist/types/skc-runtime/state-schema.d.ts +2 -0
  39. package/dist/types/skc-runtime/state-writer.d.ts +36 -7
  40. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  41. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +7 -4
  42. package/dist/types/skc-runtime/workflow-command-ref.d.ts +1 -1
  43. package/dist/types/skc-runtime/workflow-manifest.d.ts +1 -1
  44. package/dist/types/skill-state/active-state.d.ts +6 -11
  45. package/dist/types/skill-state/canonical-skills.d.ts +3 -0
  46. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
  47. package/dist/types/skill-state/workflow-hud.d.ts +2 -0
  48. package/dist/types/task/spawn-gate.d.ts +1 -10
  49. package/package.json +7 -7
  50. package/scripts/build-binary.ts +0 -7
  51. package/src/cli/migrate-cli.ts +106 -0
  52. package/src/cli/setup-cli.ts +14 -1
  53. package/src/cli/update-cli.ts +53 -3
  54. package/src/cli.ts +1 -0
  55. package/src/commands/deep-interview.ts +2 -2
  56. package/src/commands/launch.ts +1 -1
  57. package/src/commands/migrate.ts +46 -0
  58. package/src/commands/state.ts +2 -1
  59. package/src/commands/team.ts +7 -3
  60. package/src/config/model-registry.ts +9 -2
  61. package/src/config/model-resolver.ts +13 -2
  62. package/src/config/models-config-schema.ts +1 -0
  63. package/src/config/settings-schema.ts +17 -0
  64. package/src/coordinator-mcp/policy.ts +10 -2
  65. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +0 -1
  66. package/src/defaults/skc/skills/deep-interview/SKILL.md +30 -24
  67. package/src/defaults/skc/skills/ralplan/SKILL.md +10 -4
  68. package/src/defaults/skc/skills/team/SKILL.md +51 -47
  69. package/src/defaults/skc/skills/ultragoal/SKILL.md +17 -13
  70. package/src/exec/bash-executor.ts +3 -1
  71. package/src/extensibility/custom-commands/loader.ts +0 -7
  72. package/src/extensibility/skc-plugins/injection.ts +23 -4
  73. package/src/extensibility/skc-plugins/state.ts +16 -1
  74. package/src/harness-control-plane/storage.ts +14 -4
  75. package/src/hooks/native-skill-hook.ts +38 -12
  76. package/src/hooks/skill-state.ts +178 -83
  77. package/src/internal-urls/docs-index.generated.ts +11 -9
  78. package/src/lsp/startup-events.ts +24 -0
  79. package/src/migrate/action-planner.ts +318 -0
  80. package/src/migrate/adapters/claude-code.ts +39 -0
  81. package/src/migrate/adapters/codex.ts +70 -0
  82. package/src/migrate/adapters/index.ts +277 -0
  83. package/src/migrate/adapters/opencode.ts +52 -0
  84. package/src/migrate/executor.ts +81 -0
  85. package/src/migrate/mcp-mapper.ts +152 -0
  86. package/src/migrate/report.ts +104 -0
  87. package/src/migrate/skill-normalizer.ts +80 -0
  88. package/src/migrate/types.ts +163 -0
  89. package/src/modes/bridge/bridge-mode.ts +2 -2
  90. package/src/modes/components/custom-editor.ts +30 -20
  91. package/src/modes/components/welcome.ts +20 -9
  92. package/src/modes/controllers/input-controller.ts +21 -3
  93. package/src/modes/interactive-mode.ts +27 -19
  94. package/src/modes/prompt-action-autocomplete.ts +11 -1
  95. package/src/modes/rpc/rpc-mode.ts +2 -2
  96. package/src/modes/shared/agent-wire/unattended-audit.ts +3 -2
  97. package/src/prompts/agents/init.md +1 -1
  98. package/src/prompts/system/plan-mode-active.md +1 -1
  99. package/src/prompts/tools/ast-grep.md +1 -1
  100. package/src/prompts/tools/search.md +1 -1
  101. package/src/prompts/tools/task.md +1 -2
  102. package/src/research-plan/index.ts +1 -0
  103. package/src/research-plan/ledger.ts +177 -0
  104. package/src/rlm/artifacts.ts +12 -3
  105. package/src/rlm/index.ts +7 -0
  106. package/src/runtime-mcp/config-writer.ts +46 -0
  107. package/src/session/agent-session.ts +43 -41
  108. package/src/session/session-manager.ts +19 -2
  109. package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
  110. package/src/setup/hermes-setup.ts +1 -1
  111. package/src/skc-runtime/deep-interview-recorder.ts +51 -18
  112. package/src/skc-runtime/deep-interview-runtime.ts +49 -23
  113. package/src/skc-runtime/goal-mode-request.ts +26 -11
  114. package/src/skc-runtime/launch-tmux.ts +68 -15
  115. package/src/skc-runtime/ralplan-runtime.ts +79 -50
  116. package/src/skc-runtime/session-layout.ts +180 -0
  117. package/src/skc-runtime/session-resolution.ts +217 -0
  118. package/src/skc-runtime/state-graph.ts +1 -2
  119. package/src/skc-runtime/state-migrations.ts +1 -0
  120. package/src/skc-runtime/state-runtime.ts +237 -114
  121. package/src/skc-runtime/state-schema.ts +2 -0
  122. package/src/skc-runtime/state-writer.ts +310 -42
  123. package/src/skc-runtime/team-runtime.ts +43 -19
  124. package/src/skc-runtime/tmux-sessions.ts +43 -2
  125. package/src/skc-runtime/ultragoal-guard.ts +45 -2
  126. package/src/skc-runtime/ultragoal-runtime.ts +121 -41
  127. package/src/skc-runtime/workflow-command-ref.ts +1 -2
  128. package/src/skc-runtime/workflow-manifest.ts +1 -2
  129. package/src/skill-state/active-state.ts +116 -129
  130. package/src/skill-state/canonical-skills.ts +4 -0
  131. package/src/skill-state/deep-interview-mutation-guard.ts +238 -111
  132. package/src/skill-state/workflow-hud.ts +4 -2
  133. package/src/skill-state/workflow-state-contract.ts +3 -3
  134. package/src/slash-commands/builtin-registry.ts +8 -4
  135. package/src/system-prompt.ts +11 -9
  136. package/src/task/agents.ts +1 -22
  137. package/src/task/index.ts +1 -41
  138. package/src/task/spawn-gate.ts +1 -38
  139. package/src/task/types.ts +1 -1
  140. package/src/tools/ask.ts +34 -12
  141. package/src/tools/ast-edit.ts +2 -2
  142. package/src/tools/computer.ts +58 -4
  143. package/src/utils/edit-mode.ts +1 -1
  144. package/dist/types/extensibility/custom-commands/bundled/review/index.d.ts +0 -10
  145. package/src/extensibility/custom-commands/bundled/review/index.ts +0 -456
  146. package/src/prompts/agents/explore.md +0 -58
  147. package/src/prompts/agents/plan.md +0 -49
  148. package/src/prompts/agents/reviewer.md +0 -141
  149. package/src/prompts/agents/task.md +0 -16
  150. package/src/prompts/review-request.md +0 -70
@@ -1,8 +1,12 @@
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 "@sayknow-cli/agent-core";
3
5
  import { logger } from "@sayknow-cli/utils";
4
6
  import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
5
7
  import { LocalProtocolHandler, resolveLocalUrlToPath } from "../internal-urls/local-protocol";
8
+ import { SKC_SESSION_PREFIX, modeStatePath as sessionModeStatePath } from "../skc-runtime/session-layout";
9
+ import { resolveSkcSessionForRead } from "../skc-runtime/session-resolution";
6
10
  import { ModeStateSchema } from "../skc-runtime/state-schema";
7
11
  import { resolveToCwd } from "../tools/path-utils";
8
12
  import { ToolError } from "../tools/tool-errors";
@@ -17,15 +21,22 @@ export const DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE =
17
21
  "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 `skc deep-interview --write --stage final` or hand off to an execution phase.";
18
22
  export const WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE =
19
23
  ".skc workflow state and artifacts are runtime-owned. Agent mutation tools cannot edit `.skc/**`; use the sanctioned `skc` CLI instead.";
24
+ export const RALPLAN_MUTATION_BLOCK_MESSAGE =
25
+ "Ralplan planning phase boundary: keep refining the consensus plan and persist plan artifacts through `skc 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.";
26
+ export const ULTRAGOAL_GOAL_PLANNING_MUTATION_BLOCK_MESSAGE =
27
+ "Ultragoal goal-planning phase boundary: finish goal planning and record goals through `skc ultragoal` before editing code. Product-code mutation tools and patch execution are blocked until goal planning completes and execution begins.";
20
28
 
21
- const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit", "bash"]);
29
+ /** Resolve the phase-boundary block message for the active planning skill. */
30
+ function planningPhaseBlockMessage(skill: CanonicalSkcWorkflowSkill): string {
31
+ if (skill === "ralplan") return RALPLAN_MUTATION_BLOCK_MESSAGE;
32
+ if (skill === "ultragoal") return ULTRAGOAL_GOAL_PLANNING_MUTATION_BLOCK_MESSAGE;
33
+ return DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE;
34
+ }
35
+
36
+ const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit"]);
22
37
  const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
23
38
  const INTERNAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
24
39
  const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\r|\n|$)/i;
25
- const BASH_TOKEN_RE = /'[^']*'|"(?:\\.|[^"\\])*"|\S+/g;
26
- const BASH_REDIRECT_RE = /^(?:\d*)>>?$/;
27
- const BASH_HEREDOC_RE = /^(?:\d*)<<-?$/;
28
- const BASH_MUTATION_COMMANDS = new Set(["rm", "mv", "cp", "touch", "mkdir", "ln", "tee"]);
29
40
 
30
41
  type ToolWithEditMode = AgentTool & {
31
42
  mode?: unknown;
@@ -67,15 +78,18 @@ function safeString(value: unknown): string {
67
78
  return typeof value === "string" ? value : "";
68
79
  }
69
80
 
70
- function encodePathSegment(value: string): string {
71
- return encodeURIComponent(value).replaceAll(".", "%2E");
81
+ async function resolveBoundarySessionId(cwd: string, sessionId?: string): Promise<string | null> {
82
+ const normalizedSessionId = sessionId?.trim();
83
+ if (normalizedSessionId) return normalizedSessionId;
84
+ try {
85
+ return (await resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID })).skcSessionId;
86
+ } catch {
87
+ return null;
88
+ }
72
89
  }
73
90
 
74
- function modeStatePath(cwd: string, skill: string, sessionId?: string): string {
75
- const stateDir = path.join(cwd, ".skc", "state");
76
- const fileName = `${skill}-state.json`;
77
- if (sessionId) return path.join(stateDir, "sessions", encodePathSegment(sessionId), fileName);
78
- return path.join(stateDir, fileName);
91
+ function modeStatePath(cwd: string, skill: string, sessionId: string): string {
92
+ return sessionModeStatePath(cwd, sessionId, skill);
79
93
  }
80
94
 
81
95
  function warnInvalidModeState(filePath: string, error: string): void {
@@ -103,21 +117,17 @@ async function readValidatedModeState(filePath: string): Promise<ModeState | nul
103
117
  }
104
118
  return state;
105
119
  }
106
- async function readVisibleModeState(cwd: string, skill: string, sessionId?: string): Promise<ModeState | null> {
107
- if (sessionId) {
108
- const sessionState = await readValidatedModeState(modeStatePath(cwd, skill, sessionId));
109
- if (sessionState) return sessionState;
110
- }
111
- return await readValidatedModeState(modeStatePath(cwd, skill));
120
+ async function readVisibleModeState(cwd: string, skill: string, sessionId: string): Promise<ModeState | null> {
121
+ return await readValidatedModeState(modeStatePath(cwd, skill, sessionId));
112
122
  }
113
123
 
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
- }
124
+ /**
125
+ * Phases that genuinely finish a workflow skill. Mirrors the Stop hook's
126
+ * `STOP_RELEASING_PHASES` (`hooks/skill-state.ts`): `handoff` is intentionally
127
+ * absent so a handoff-required planning skill (deep-interview/ralplan) keeps
128
+ * blocking through its handoff/ask window until it is demoted or cleared.
129
+ */
130
+ const WORKFLOW_FINISHED_PHASES = new Set(["complete", "completed", "failed", "cancelled", "canceled", "inactive"]);
121
131
 
122
132
  function entryMatchesContext(entry: SkillActiveEntry, sessionId?: string, threadId?: string): boolean {
123
133
  if (sessionId && entry.session_id && entry.session_id !== sessionId) return false;
@@ -131,17 +141,94 @@ function modeStateMatchesContext(state: ModeState, sessionId?: string, threadId?
131
141
  return true;
132
142
  }
133
143
 
134
- async function isActiveDeepInterview(cwd: string, sessionId?: string, threadId?: string): Promise<boolean> {
135
- const skillState = await readVisibleSkillActiveState(cwd, sessionId);
136
- const activeDeepInterview = listActiveSkills(skillState).find(
137
- entry => entry.skill === "deep-interview" && entryMatchesContext(entry, sessionId, threadId),
144
+ /** Workflow skills that have a pre-approval planning posture this guard enforces. `team` never does. */
145
+ function isPlanningSkill(skill: string): skill is "deep-interview" | "ralplan" | "ultragoal" {
146
+ return skill === "deep-interview" || skill === "ralplan" || skill === "ultragoal";
147
+ }
148
+
149
+ /**
150
+ * Whether `skill` in `phase` is a pre-approval planning posture that must block
151
+ * product-code mutation. `deep-interview` and `ralplan` are wholly pre-approval
152
+ * (every phase blocks except a genuinely-finished one — `handoff` and ralplan's
153
+ * `final` keep blocking until execution is approved and the skill is demoted).
154
+ * `ultragoal` only blocks during `goal-planning`; once goals are created it is an
155
+ * executor and mutates freely.
156
+ */
157
+ function isBlockingPlanningPhase(skill: "deep-interview" | "ralplan" | "ultragoal", phase: string): boolean {
158
+ const normalized = phase.trim().toLowerCase();
159
+ if (skill === "ultragoal") return normalized === "goal-planning";
160
+ return !WORKFLOW_FINISHED_PHASES.has(normalized);
161
+ }
162
+
163
+ interface ActivePlanningSkill {
164
+ skill: "deep-interview" | "ralplan" | "ultragoal";
165
+ phase: string;
166
+ }
167
+
168
+ /**
169
+ * Pick the single CURRENT workflow entry among active entries.
170
+ *
171
+ * Steady state has exactly one active workflow skill (handoff demotes the prior
172
+ * to `active:false`, which `listActiveSkills` already filters out). If several
173
+ * are momentarily active, prefer the most-recently-updated entry so a stale
174
+ * planning row (e.g. a still-active ralplan `final`) can never be selected over a
175
+ * newer executor (ultragoal/team), and a planning *return* (newer `updated_at`)
176
+ * reliably wins. Ties fall back to the resolved top-level `skill`, then to the
177
+ * first entry, matching how the HUD/chain guard pick `activeSkills[0]`.
178
+ */
179
+ function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
180
+ const ts = (entry: SkillActiveEntry): number => {
181
+ const value = Date.parse(safeString(entry.updated_at) || safeString(entry.activated_at));
182
+ return Number.isNaN(value) ? -1 : value;
183
+ };
184
+ let best = entries[0];
185
+ for (const entry of entries) {
186
+ const delta = ts(entry) - ts(best);
187
+ if (delta > 0) best = entry;
188
+ else if (delta === 0 && topLevelSkill && entry.skill === topLevelSkill) best = entry;
189
+ }
190
+ return best;
191
+ }
192
+
193
+ /**
194
+ * Resolve the single active pre-approval planning skill for this context, or null.
195
+ *
196
+ * Transition/return safety: this keys off the ONE canonical current workflow
197
+ * skill (the resolved top-level `skill` that the HUD and the skill-tool chain
198
+ * guard treat as active), not an independent scan of every skill. A handoff
199
+ * atomically demotes the prior skill and promotes the callee, and a return
200
+ * (e.g. re-entering ralplan/deep-interview after an ultragoal goal completes)
201
+ * re-activates the planning skill — in every case "whatever skill is current"
202
+ * governs, so a stale planning entry can never block while an executor runs and
203
+ * a resumed planning phase reliably re-blocks.
204
+ *
205
+ * Fail-open contract: a missing or invalid durable mode-state releases the block
206
+ * (a corrupt state file must not lock all mutation), matching the guard's
207
+ * historical behavior — this is intentionally looser than the Stop hook, which
208
+ * fails closed for handoff-required skills.
209
+ */
210
+ async function getActivePlanningSkill(
211
+ cwd: string,
212
+ sessionId?: string,
213
+ threadId?: string,
214
+ ): Promise<ActivePlanningSkill | null> {
215
+ const resolvedSessionId = await resolveBoundarySessionId(cwd, sessionId);
216
+ if (!resolvedSessionId) return null;
217
+ const skillState = await readVisibleSkillActiveState(cwd, resolvedSessionId);
218
+ if (!skillState) return null;
219
+ const activeEntries = listActiveSkills(skillState).filter(entry =>
220
+ entryMatchesContext(entry, resolvedSessionId, threadId),
138
221
  );
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;
222
+ if (activeEntries.length === 0) return null;
223
+ const current = resolveCurrentWorkflowEntry(activeEntries, safeString(skillState.skill).trim());
224
+ if (!isPlanningSkill(current.skill)) return null;
225
+ const modeState = await readVisibleModeState(cwd, current.skill, resolvedSessionId);
226
+ if (!modeState) return null;
227
+ if (modeState.active !== true) return null;
228
+ if (!modeStateMatchesContext(modeState, resolvedSessionId, threadId)) return null;
229
+ const phase = String(modeState.current_phase ?? current.phase ?? "").trim();
230
+ if (!isBlockingPlanningPhase(current.skill, phase)) return null;
231
+ return { skill: current.skill, phase };
145
232
  }
146
233
 
147
234
  function normalizePosix(value: string): string {
@@ -243,75 +330,10 @@ function extractEditTargets(args: unknown, tool: ToolWithEditMode): ExtractedTar
243
330
  return targets;
244
331
  }
245
332
 
246
- function extractBashTargets(args: unknown): ExtractedTargets {
247
- const record = getRecord(args);
248
- const command = safeString(record?.command).trim();
249
- const targets: ExtractedTargets = { paths: [], unknown: false };
250
- if (!command) {
251
- targets.unknown = true;
252
- return targets;
253
- }
254
- if (/^skc(?:\s|$)/.test(command)) return targets;
255
-
256
- const tokens = command.match(BASH_TOKEN_RE)?.map(unquoteBashToken) ?? [];
257
- for (let index = 0; index < tokens.length; index++) {
258
- const token = tokens[index] ?? "";
259
- if (BASH_REDIRECT_RE.test(token)) {
260
- addPath(targets, tokens[index + 1]);
261
- index++;
262
- continue;
263
- }
264
- const redirectMatch = token.match(/^(?:\d*)>>?(.+)$/);
265
- if (redirectMatch?.[1]) {
266
- addPath(targets, redirectMatch[1]);
267
- continue;
268
- }
269
- if (BASH_HEREDOC_RE.test(token)) {
270
- addPath(targets, tokens[index + 1]);
271
- index++;
272
- continue;
273
- }
274
- const heredocMatch = token.match(/^(?:\d*)<<-?(.+)$/);
275
- if (heredocMatch?.[1]) {
276
- addPath(targets, heredocMatch[1]);
277
- continue;
278
- }
279
- if (isMutationBashCommand(tokens, index)) {
280
- for (let targetIndex = index + 1; targetIndex < tokens.length; targetIndex++) {
281
- const target = tokens[targetIndex] ?? "";
282
- if (isBashCommandBoundary(target)) break;
283
- if (target.startsWith("-")) continue;
284
- addPath(targets, target);
285
- }
286
- }
287
- }
288
- return targets;
289
- }
290
-
291
- function unquoteBashToken(token: string): string {
292
- if (token.length < 2) return token;
293
- const quote = token[0];
294
- if ((quote === "'" || quote === '"') && token.at(-1) === quote) return token.slice(1, -1);
295
- return token;
296
- }
297
-
298
- function isBashCommandBoundary(token: string): boolean {
299
- return [";", "&&", "||", "|"].includes(token);
300
- }
301
-
302
- function isMutationBashCommand(tokens: string[], index: number): boolean {
303
- const token = path.basename(tokens[index] ?? "");
304
- if (BASH_MUTATION_COMMANDS.has(token)) return true;
305
- if (token !== "sed") return false;
306
- const next = tokens[index + 1] ?? "";
307
- return next === "-i" || next.startsWith("-i") || next.includes("i");
308
- }
309
-
310
333
  function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
311
334
  if (tool.name === "write") return extractWriteTargets(args);
312
335
  if (tool.name === "ast_edit") return extractAstEditTargets(args);
313
336
  if (tool.name === "edit") return extractEditTargets(args, tool);
314
- if (tool.name === "bash") return extractBashTargets(args);
315
337
  return { paths: [], unknown: true };
316
338
  }
317
339
 
@@ -355,8 +377,9 @@ function relativeSkcSegments(cwd: string, rawPath: string): string[] | null {
355
377
  function blockedWorkflowStateSkill(cwd: string, rawPath: string): CanonicalSkcWorkflowSkill | null {
356
378
  const segments = relativeSkcSegments(cwd, rawPath);
357
379
  if (segments?.[0] !== ".skc") return null;
358
- if (segments[1] === "specs" || segments[1] === "plans") return null;
359
- if (segments[1] !== "state") return null;
380
+ const generatedRoot = segments[1]?.startsWith(SKC_SESSION_PREFIX) ? segments[2] : segments[1];
381
+ if (generatedRoot === "specs" || generatedRoot === "plans") return null;
382
+ if (generatedRoot !== "state") return null;
360
383
  const fileName = segments.at(-1) ?? "";
361
384
  for (const skillName of ["deep-interview", "ralplan", "ultragoal", "team"] as const) {
362
385
  if (fileName === workflowModeStateFileName(skillName)) return skillName;
@@ -376,7 +399,8 @@ function firstBlockedWorkflowStateSkill(cwd: string, targets: ExtractedTargets):
376
399
  function isAllowlistedPath(cwd: string, rawPath: string): boolean {
377
400
  const segments = relativeSkcSegments(cwd, rawPath);
378
401
  if (segments?.[0] !== ".skc") return false;
379
- return segments[1] === "specs" || segments[1] === "plans";
402
+ const generatedRoot = segments[1]?.startsWith(SKC_SESSION_PREFIX) ? segments[2] : segments[1];
403
+ return generatedRoot === "specs" || generatedRoot === "plans";
380
404
  }
381
405
  function isBlockedSkcPath(cwd: string, rawPath: string): boolean {
382
406
  const segments = relativeSkcSegments(cwd, rawPath);
@@ -392,6 +416,84 @@ function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean
392
416
  !targets.unknown && targets.paths.length > 0 && targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath))
393
417
  );
394
418
  }
419
+
420
+ function neutralTempRoots(): string[] {
421
+ const roots = new Set<string>();
422
+ const add = (value: string | undefined): void => {
423
+ const trimmed = value?.trim();
424
+ if (trimmed) roots.add(path.resolve(trimmed));
425
+ };
426
+ add(os.tmpdir());
427
+ add(process.env.TMPDIR);
428
+ for (const fixed of ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp"]) add(fixed);
429
+ return [...roots];
430
+ }
431
+
432
+ function isPathWithin(root: string, target: string): boolean {
433
+ const rel = path.relative(root, target);
434
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
435
+ }
436
+
437
+ async function realpathOrSelf(target: string): Promise<string> {
438
+ try {
439
+ return await fs.realpath(target);
440
+ } catch {
441
+ return target;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Canonicalize a target whose leaf may not exist yet (we are about to write it):
447
+ * realpath the nearest existing ancestor and re-append the not-yet-existing
448
+ * suffix, so a symlinked ancestor (or macOS `/tmp` → `/private/tmp` alias) is
449
+ * resolved to its real location.
450
+ */
451
+ async function canonicalizeForContainment(absolutePath: string): Promise<string> {
452
+ const suffix: string[] = [];
453
+ let current = absolutePath;
454
+ for (let depth = 0; depth < 64; depth++) {
455
+ try {
456
+ const real = await fs.realpath(current);
457
+ return suffix.length > 0 ? path.join(real, ...suffix.reverse()) : real;
458
+ } catch {
459
+ const parent = path.dirname(current);
460
+ if (parent === current) break;
461
+ suffix.push(path.basename(current));
462
+ current = parent;
463
+ }
464
+ }
465
+ return absolutePath;
466
+ }
467
+
468
+ /**
469
+ * A neutral scratch path the planning-phase block tolerates: it resolves to a
470
+ * system temp directory and lives OUTSIDE the project cwd. Files inside the
471
+ * project tree (product code, `.skc/**`) are never neutral, even when the cwd
472
+ * itself is rooted under a temp dir. The lexical checks run first; a canonical
473
+ * (symlink/alias-resolved) re-check then ensures the REAL target is still outside
474
+ * the project and inside a real temp root, defeating a temp symlink that points
475
+ * back into the repo or `.skc/`.
476
+ */
477
+ async function isNeutralTempPath(cwd: string, rawPath: string): Promise<boolean> {
478
+ const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
479
+ if (unknown || !absolutePath) return false;
480
+ const resolvedCwd = path.resolve(cwd);
481
+ if (isPathWithin(resolvedCwd, absolutePath)) return false;
482
+ if (!neutralTempRoots().some(root => isPathWithin(root, absolutePath))) return false;
483
+ const realTarget = await canonicalizeForContainment(absolutePath);
484
+ if (isPathWithin(await realpathOrSelf(resolvedCwd), realTarget)) return false;
485
+ const realRoots = await Promise.all(neutralTempRoots().map(realpathOrSelf));
486
+ return realRoots.some(root => isPathWithin(root, realTarget));
487
+ }
488
+
489
+ /** Targets that remain disallowed during a planning phase (excludes neutral temp scratch). */
490
+ async function planningBlockedTargets(cwd: string, targets: ExtractedTargets): Promise<string[]> {
491
+ const blocked: string[] = [];
492
+ for (const rawPath of targets.paths) {
493
+ if (!(await isNeutralTempPath(cwd, rawPath))) blocked.push(rawPath);
494
+ }
495
+ return blocked;
496
+ }
395
497
  export async function assertDeepInterviewMutationRawPathsAllowed(input: {
396
498
  cwd: string;
397
499
  sessionId?: string;
@@ -399,12 +501,21 @@ export async function assertDeepInterviewMutationRawPathsAllowed(input: {
399
501
  rawPaths: string[];
400
502
  forceOverride?: boolean;
401
503
  }): Promise<void> {
402
- if (input.forceOverride) return;
403
- if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) return;
404
504
  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);
505
+ // Always-on `.skc/**` runtime-owned block, in parity with getDeepInterviewMutationDecision
506
+ // and ahead of forceOverride: a deferred ast_edit apply must not reach `.skc/**` either.
507
+ if (hasBlockedSkcTarget(input.cwd, targets)) {
508
+ const stateSkill = firstBlockedWorkflowStateSkill(input.cwd, targets);
509
+ const command = stateSkill ? sanctionedWorkflowStateCommand(stateSkill) : "skc <workflow-command>";
510
+ throw new ToolError(`${WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE}\nUse: ${command}`);
407
511
  }
512
+ if (input.forceOverride) return;
513
+ const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId);
514
+ if (!planning) return;
515
+ const message = planningPhaseBlockMessage(planning.skill);
516
+ if (input.rawPaths.length === 0) throw new ToolError(message);
517
+ const blocked = await planningBlockedTargets(input.cwd, targets);
518
+ if (blocked.length > 0) throw new ToolError(message);
408
519
  }
409
520
 
410
521
  export async function getDeepInterviewMutationDecision(
@@ -423,24 +534,30 @@ export async function getDeepInterviewMutationDecision(
423
534
  command,
424
535
  };
425
536
  }
426
- if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) {
537
+ const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId);
538
+ if (!planning) {
427
539
  return { blocked: false, targets: [] };
428
540
  }
429
541
  if (input.forceOverride) return { blocked: false, targets: [] };
542
+ const message = planningPhaseBlockMessage(planning.skill);
430
543
  if (targets.unknown) {
431
544
  return {
432
545
  blocked: true,
433
- message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
546
+ message,
434
547
  targets: targets.paths,
435
548
  reason: "unknown-target",
436
549
  };
437
550
  }
438
- if (input.tool.name === "bash") {
551
+ // Neutral temp scratch (outside the project tree) stays writable so agents can
552
+ // stage artifacts and feed their path to the sanctioned `skc ... --write` CLIs.
553
+ // Read-only / `skc` bash extract no targets and fall through to allowed here.
554
+ const blockedTargets = await planningBlockedTargets(input.cwd, targets);
555
+ if (blockedTargets.length === 0) {
439
556
  return { blocked: false, targets: targets.paths };
440
557
  }
441
558
  return {
442
559
  blocked: true,
443
- message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
560
+ message,
444
561
  targets: targets.paths,
445
562
  reason: allTargetsAllowlisted(input.cwd, targets) ? "handoff-artifact-tool-target" : "phase-boundary",
446
563
  };
@@ -450,3 +567,13 @@ export async function assertDeepInterviewMutationAllowed(input: DeepInterviewMut
450
567
  const decision = await getDeepInterviewMutationDecision(input);
451
568
  if (decision.blocked) throw new ToolError(decision.message ?? DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE);
452
569
  }
570
+
571
+ /*
572
+ * Generic cross-workflow names for this planning-phase mutation guard. The guard
573
+ * now governs deep-interview, ralplan, and ultragoal goal-planning, so new
574
+ * callers SHOULD use these names; the `*DeepInterview*` exports above remain as
575
+ * compatibility aliases (and are still what the test-suite imports).
576
+ */
577
+ export const getWorkflowMutationDecision = getDeepInterviewMutationDecision;
578
+ export const assertWorkflowMutationAllowed = assertDeepInterviewMutationAllowed;
579
+ export const assertWorkflowMutationRawPathsAllowed = assertDeepInterviewMutationRawPathsAllowed;
@@ -40,7 +40,7 @@ interface UltragoalHudState extends WorkflowGateHudState {
40
40
  currentGoal?: UltragoalLikeGoal;
41
41
  counts: Record<string, number>;
42
42
  goals: UltragoalLikeGoal[];
43
- latestLedgerEvent?: { event?: string; goalId?: string; timestamp?: string };
43
+ latestLedgerEvent?: { event?: string; goalId?: string; timestamp?: string; kind?: string; evidence?: string };
44
44
  updatedAt?: string;
45
45
  }
46
46
 
@@ -237,7 +237,9 @@ export function buildUltragoalHudSummary(state: UltragoalHudState): WorkflowHudS
237
237
  chip(
238
238
  "ledger",
239
239
  state.latestLedgerEvent?.event
240
- ? [state.latestLedgerEvent.event, state.latestLedgerEvent.goalId].filter(Boolean).join(":")
240
+ ? [state.latestLedgerEvent.event, state.latestLedgerEvent.kind, state.latestLedgerEvent.goalId]
241
+ .filter(Boolean)
242
+ .join(":")
241
243
  : undefined,
242
244
  35,
243
245
  ),
@@ -141,10 +141,10 @@ export function sanctionedWorkflowStateCommand(skill: CanonicalSkcWorkflowSkill)
141
141
  export function describeWorkflowStateContract(skill: CanonicalSkcWorkflowSkill): string[] {
142
142
  return [
143
143
  `Sanctioned mutation path: skc state ${skill} read|write --input '<json>'`,
144
- `Canonical active HUD state: .skc/state/${SKILL_ACTIVE_STATE_FILE} and .skc/state/sessions/<session>/${SKILL_ACTIVE_STATE_FILE}`,
145
- `Skill mode state: .skc/state/${workflowModeStateFileName(skill)} or .skc/state/sessions/<session>/${workflowModeStateFileName(skill)}`,
144
+ `Canonical active HUD state: .skc/_session-{sessionid}/state/${SKILL_ACTIVE_STATE_FILE}`,
145
+ `Skill mode state: .skc/_session-{sessionid}/state/${workflowModeStateFileName(skill)}`,
146
146
  "Receipts include version, skill, owner, command, state_path, storage_path, mutated_at, fresh_until, status, and mutation_id.",
147
147
  "Receipts are fresh for 30 minutes; older receipts are stale and render as HUD warnings.",
148
- "Planning artifacts under .skc/specs/** and .skc/plans/** remain writable outside the state command.",
148
+ "Planning artifacts under .skc/_session-{sessionid}/specs/** and .skc/_session-{sessionid}/plans/** remain writable outside the state command.",
149
149
  ];
150
150
  }
@@ -251,11 +251,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
251
251
  inlineHint: "[objective]",
252
252
  allowArgs: true,
253
253
  handleTui: async (command, runtime) => {
254
- const hadArgs = !!command.args;
255
- // Capture state BEFORE the call (see /plan above for rationale).
256
- const wasGoalModeEnabled = runtime.ctx.goalModeEnabled;
254
+ // The goal command always consumes the typed input: it either submits
255
+ // the bare objective (never the literal `/goal …` text the user typed)
256
+ // or shows a warning, so the normal submission path never records it in
257
+ // input history. Preserve the typed command whenever args were supplied
258
+ // — including the first-time `/goal set <objective>` case where goal
259
+ // mode was not yet active. A previous `wasGoalModeEnabled` guard dropped
260
+ // that first-time case from history (up/down-arrow recall).
257
261
  await runtime.ctx.handleGoalModeCommand(command.args || undefined);
258
- if (hadArgs && wasGoalModeEnabled) {
262
+ if (command.args) {
259
263
  runtime.ctx.editor.addToHistory(command.text);
260
264
  }
261
265
  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
@@ -3,20 +3,14 @@
3
3
  *
4
4
  * Agents are embedded at build time via Bun's import with { type: "text" }.
5
5
  */
6
- import { Effort } from "@sayknow-cli/ai";
7
6
  import { parseFrontmatter, prompt } from "@sayknow-cli/utils";
8
7
  import { parseAgentFields } from "../discovery/helpers";
8
+ // Embed agent markdown files at build time
9
9
  import architectMd from "../prompts/agents/architect.md" with { type: "text" };
10
10
  import criticMd from "../prompts/agents/critic.md" with { type: "text" };
11
11
  import executorMd from "../prompts/agents/executor.md" with { type: "text" };
12
- import exploreMd from "../prompts/agents/explore.md" with { type: "text" };
13
- // Embed agent markdown files at build time
14
12
  import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
15
-
16
- import planMd from "../prompts/agents/plan.md" with { type: "text" };
17
13
  import plannerMd from "../prompts/agents/planner.md" with { type: "text" };
18
- import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
19
- import taskMd from "../prompts/agents/task.md" with { type: "text" };
20
14
 
21
15
  import type { AgentDefinition, AgentSource } from "./types";
22
16
 
@@ -50,21 +44,6 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
50
44
  { fileName: "architect.md", template: architectMd },
51
45
  { fileName: "planner.md", template: plannerMd },
52
46
  { fileName: "critic.md", template: criticMd },
53
- { fileName: "explore.md", template: exploreMd },
54
- { fileName: "plan.md", template: planMd },
55
- { fileName: "reviewer.md", template: reviewerMd },
56
- {
57
- fileName: "task.md",
58
- frontmatter: {
59
- name: "task",
60
- description: "General-purpose subagent with full capabilities for delegated multi-step tasks",
61
- spawns: "*",
62
- model: "pi/default",
63
- thinkingLevel: Effort.Medium,
64
- hide: true,
65
- },
66
- template: taskMd,
67
- },
68
47
  ];
69
48
 
70
49
  // Computed lazily on first loadBundledAgents() call to avoid eager prompt.render at module load.
package/src/task/index.ts CHANGED
@@ -55,7 +55,7 @@ import { assertNoRawTaskFields, buildTaskReceipt, buildTaskRoiSummary } from "./
55
55
  import { renderResult, renderCall as renderTaskCall } from "./render";
56
56
  import { reconcileSpawnRoi } from "./roi-reconciliation";
57
57
  import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
58
- import { DEFAULT_SPAWN_THRESHOLD, evaluateReviewerExploreGate, evaluateSpawnGate } from "./spawn-gate";
58
+ import { DEFAULT_SPAWN_THRESHOLD, evaluateSpawnGate } from "./spawn-gate";
59
59
  import {
60
60
  applyNestedPatches,
61
61
  captureBaseline,
@@ -359,7 +359,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
359
359
  readonly renderResult = renderResult;
360
360
  readonly #discoveredAgents: AgentDefinition[];
361
361
  readonly #blockedAgent: string | undefined;
362
- readonly #spawningAgentType: string | undefined;
363
362
 
364
363
  get parameters(): TaskToolSchemaInstance {
365
364
  const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
@@ -391,7 +390,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
391
390
  discoveredAgents: AgentDefinition[],
392
391
  ) {
393
392
  this.#blockedAgent = $env.PI_BLOCKED_AGENT;
394
- this.#spawningAgentType = session.currentAgentType;
395
393
  this.#discoveredAgents = discoveredAgents;
396
394
  }
397
395
 
@@ -478,23 +476,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
478
476
  };
479
477
  }
480
478
 
481
- const reviewerExploreDecision = evaluateReviewerExploreGate({
482
- spawningAgentType: this.#spawningAgentType,
483
- targetAgent: params.agent,
484
- plan: params.spawnPlan,
485
- });
486
- if (reviewerExploreDecision.outcome === "rejected") {
487
- return {
488
- content: [
489
- {
490
- type: "text",
491
- text: `Task spawn gate rejected reviewer->explore: ${reviewerExploreDecision.reason}. Provide spawnPlan fields: ${reviewerExploreDecision.missingFields.join(", ")}.`,
492
- },
493
- ],
494
- details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
495
- };
496
- }
497
-
498
479
  const manager = AsyncJobManager.instance();
499
480
  if (!manager) {
500
481
  return {
@@ -1083,27 +1064,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1083
1064
  };
1084
1065
  }
1085
1066
 
1086
- const reviewerExploreDecision = evaluateReviewerExploreGate({
1087
- spawningAgentType: this.#spawningAgentType,
1088
- targetAgent: agentName,
1089
- plan: params.spawnPlan,
1090
- });
1091
- if (reviewerExploreDecision.outcome === "rejected") {
1092
- return {
1093
- content: [
1094
- {
1095
- type: "text",
1096
- text: `Task spawn gate rejected reviewer->explore: ${reviewerExploreDecision.reason}. Provide spawnPlan fields: ${reviewerExploreDecision.missingFields.join(", ")}.`,
1097
- },
1098
- ],
1099
- details: {
1100
- projectAgentsDir,
1101
- results: [],
1102
- totalDurationMs: Date.now() - startTime,
1103
- },
1104
- };
1105
- }
1106
-
1107
1067
  let repoRoot: string | null = null;
1108
1068
  let baseline: WorktreeBaseline | null = null;
1109
1069
  if (isIsolated) {