@sayknow-cli/coding-agent 0.2.3 → 0.2.5

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 (159) hide show
  1. package/CHANGELOG.md +15 -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/i18n/messages/en.d.ts +14 -0
  12. package/dist/types/lsp/startup-events.d.ts +1 -0
  13. package/dist/types/migrate/action-planner.d.ts +11 -0
  14. package/dist/types/migrate/adapters/claude-code.d.ts +2 -0
  15. package/dist/types/migrate/adapters/codex.d.ts +5 -0
  16. package/dist/types/migrate/adapters/index.d.ts +45 -0
  17. package/dist/types/migrate/adapters/opencode.d.ts +2 -0
  18. package/dist/types/migrate/executor.d.ts +2 -0
  19. package/dist/types/migrate/mcp-mapper.d.ts +20 -0
  20. package/dist/types/migrate/report.d.ts +18 -0
  21. package/dist/types/migrate/skill-normalizer.d.ts +27 -0
  22. package/dist/types/migrate/types.d.ts +126 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  24. package/dist/types/modes/components/welcome.d.ts +3 -1
  25. package/dist/types/modes/interactive-mode.d.ts +3 -0
  26. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  27. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +1 -1
  28. package/dist/types/research-plan/index.d.ts +1 -0
  29. package/dist/types/research-plan/ledger.d.ts +33 -0
  30. package/dist/types/rlm/artifacts.d.ts +1 -1
  31. package/dist/types/runtime-mcp/config-writer.d.ts +26 -0
  32. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +2 -0
  33. package/dist/types/skc-runtime/deep-interview-runtime.d.ts +2 -2
  34. package/dist/types/skc-runtime/goal-mode-request.d.ts +1 -1
  35. package/dist/types/skc-runtime/session-layout.d.ts +59 -0
  36. package/dist/types/skc-runtime/session-resolution.d.ts +47 -0
  37. package/dist/types/skc-runtime/state-graph.d.ts +1 -1
  38. package/dist/types/skc-runtime/state-runtime.d.ts +5 -4
  39. package/dist/types/skc-runtime/state-schema.d.ts +2 -0
  40. package/dist/types/skc-runtime/state-writer.d.ts +36 -7
  41. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  42. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +7 -4
  43. package/dist/types/skc-runtime/workflow-command-ref.d.ts +1 -1
  44. package/dist/types/skc-runtime/workflow-manifest.d.ts +1 -1
  45. package/dist/types/skill-state/active-state.d.ts +6 -11
  46. package/dist/types/skill-state/canonical-skills.d.ts +3 -0
  47. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
  48. package/dist/types/skill-state/workflow-hud.d.ts +2 -0
  49. package/dist/types/task/spawn-gate.d.ts +1 -10
  50. package/package.json +7 -7
  51. package/scripts/build-binary.ts +0 -7
  52. package/src/cli/migrate-cli.ts +106 -0
  53. package/src/cli/setup-cli.ts +14 -1
  54. package/src/cli/update-cli.ts +53 -3
  55. package/src/cli.ts +1 -0
  56. package/src/commands/deep-interview.ts +2 -2
  57. package/src/commands/launch.ts +1 -1
  58. package/src/commands/migrate.ts +46 -0
  59. package/src/commands/state.ts +2 -1
  60. package/src/commands/team.ts +7 -3
  61. package/src/config/model-registry.ts +9 -2
  62. package/src/config/model-resolver.ts +13 -2
  63. package/src/config/models-config-schema.ts +1 -0
  64. package/src/config/settings-schema.ts +17 -0
  65. package/src/coordinator-mcp/policy.ts +10 -2
  66. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +0 -1
  67. package/src/defaults/skc/skills/deep-interview/SKILL.md +30 -24
  68. package/src/defaults/skc/skills/ralplan/SKILL.md +10 -4
  69. package/src/defaults/skc/skills/team/SKILL.md +51 -47
  70. package/src/defaults/skc/skills/ultragoal/SKILL.md +17 -13
  71. package/src/exec/bash-executor.ts +3 -1
  72. package/src/extensibility/custom-commands/loader.ts +0 -7
  73. package/src/extensibility/skc-plugins/injection.ts +23 -4
  74. package/src/extensibility/skc-plugins/state.ts +16 -1
  75. package/src/harness-control-plane/storage.ts +14 -4
  76. package/src/hooks/native-skill-hook.ts +38 -12
  77. package/src/hooks/skill-state.ts +178 -83
  78. package/src/i18n/messages/de.ts +14 -0
  79. package/src/i18n/messages/en.ts +14 -0
  80. package/src/i18n/messages/es.ts +14 -0
  81. package/src/i18n/messages/fr.ts +14 -0
  82. package/src/i18n/messages/ja.ts +14 -0
  83. package/src/i18n/messages/ko.ts +14 -0
  84. package/src/i18n/messages/zh.ts +14 -0
  85. package/src/internal-urls/docs-index.generated.ts +11 -9
  86. package/src/lsp/startup-events.ts +24 -0
  87. package/src/migrate/action-planner.ts +318 -0
  88. package/src/migrate/adapters/claude-code.ts +39 -0
  89. package/src/migrate/adapters/codex.ts +70 -0
  90. package/src/migrate/adapters/index.ts +277 -0
  91. package/src/migrate/adapters/opencode.ts +52 -0
  92. package/src/migrate/executor.ts +81 -0
  93. package/src/migrate/mcp-mapper.ts +152 -0
  94. package/src/migrate/report.ts +104 -0
  95. package/src/migrate/skill-normalizer.ts +80 -0
  96. package/src/migrate/types.ts +163 -0
  97. package/src/modes/bridge/bridge-mode.ts +2 -2
  98. package/src/modes/components/custom-editor.ts +30 -20
  99. package/src/modes/components/model-selector.ts +42 -18
  100. package/src/modes/components/welcome.ts +20 -9
  101. package/src/modes/controllers/input-controller.ts +21 -3
  102. package/src/modes/interactive-mode.ts +27 -19
  103. package/src/modes/prompt-action-autocomplete.ts +11 -1
  104. package/src/modes/rpc/rpc-mode.ts +2 -2
  105. package/src/modes/shared/agent-wire/unattended-audit.ts +3 -2
  106. package/src/prompts/agents/init.md +1 -1
  107. package/src/prompts/system/plan-mode-active.md +1 -1
  108. package/src/prompts/tools/ast-grep.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/prompts/tools/task.md +1 -2
  111. package/src/research-plan/index.ts +1 -0
  112. package/src/research-plan/ledger.ts +177 -0
  113. package/src/rlm/artifacts.ts +12 -3
  114. package/src/rlm/index.ts +7 -0
  115. package/src/runtime-mcp/config-writer.ts +46 -0
  116. package/src/session/agent-session.ts +43 -41
  117. package/src/session/session-manager.ts +19 -2
  118. package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
  119. package/src/setup/hermes-setup.ts +1 -1
  120. package/src/skc-runtime/deep-interview-recorder.ts +51 -18
  121. package/src/skc-runtime/deep-interview-runtime.ts +49 -23
  122. package/src/skc-runtime/goal-mode-request.ts +26 -11
  123. package/src/skc-runtime/launch-tmux.ts +68 -15
  124. package/src/skc-runtime/ralplan-runtime.ts +79 -50
  125. package/src/skc-runtime/session-layout.ts +180 -0
  126. package/src/skc-runtime/session-resolution.ts +217 -0
  127. package/src/skc-runtime/state-graph.ts +1 -2
  128. package/src/skc-runtime/state-migrations.ts +1 -0
  129. package/src/skc-runtime/state-runtime.ts +237 -114
  130. package/src/skc-runtime/state-schema.ts +2 -0
  131. package/src/skc-runtime/state-writer.ts +310 -42
  132. package/src/skc-runtime/team-runtime.ts +43 -19
  133. package/src/skc-runtime/tmux-sessions.ts +43 -2
  134. package/src/skc-runtime/ultragoal-guard.ts +45 -2
  135. package/src/skc-runtime/ultragoal-runtime.ts +121 -41
  136. package/src/skc-runtime/workflow-command-ref.ts +1 -2
  137. package/src/skc-runtime/workflow-manifest.ts +1 -2
  138. package/src/skill-state/active-state.ts +116 -129
  139. package/src/skill-state/canonical-skills.ts +4 -0
  140. package/src/skill-state/deep-interview-mutation-guard.ts +238 -111
  141. package/src/skill-state/workflow-hud.ts +4 -2
  142. package/src/skill-state/workflow-state-contract.ts +3 -3
  143. package/src/slash-commands/builtin-registry.ts +8 -4
  144. package/src/system-prompt.ts +11 -9
  145. package/src/task/agents.ts +1 -22
  146. package/src/task/index.ts +1 -41
  147. package/src/task/spawn-gate.ts +1 -38
  148. package/src/task/types.ts +1 -1
  149. package/src/tools/ask.ts +34 -12
  150. package/src/tools/ast-edit.ts +2 -2
  151. package/src/tools/computer.ts +58 -4
  152. package/src/utils/edit-mode.ts +1 -1
  153. package/dist/types/extensibility/custom-commands/bundled/review/index.d.ts +0 -10
  154. package/src/extensibility/custom-commands/bundled/review/index.ts +0 -456
  155. package/src/prompts/agents/explore.md +0 -58
  156. package/src/prompts/agents/plan.md +0 -49
  157. package/src/prompts/agents/reviewer.md +0 -141
  158. package/src/prompts/agents/task.md +0 -16
  159. package/src/prompts/review-request.md +0 -70
@@ -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,
@@ -217,6 +216,11 @@ import { MCPManager } from "../runtime-mcp/manager";
217
216
  import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator";
218
217
  import { formatNoCredentialOnboardingError, formatNoModelOnboardingError } from "../setup/model-onboarding-guidance";
219
218
  import { buildSkcRuntimeSessionEnv, consumePendingGoalModeRequest } from "../skc-runtime/goal-mode-request";
219
+ import {
220
+ assertNonEmptySkcSessionId,
221
+ modeStatePath as sessionModeStatePath,
222
+ sessionStateDir,
223
+ } from "../skc-runtime/session-layout";
220
224
  import { persistCoordinatorRuntimeStateFromEvent } from "../skc-runtime/session-state-sidecar";
221
225
  import { writeArtifact } from "../skc-runtime/state-writer";
222
226
  import { requestSkcWorkerIntegrationAttempt } from "../skc-runtime/team-runtime";
@@ -225,7 +229,7 @@ import {
225
229
  readVisibleSkillActiveState,
226
230
  syncSkillActiveState,
227
231
  } from "../skill-state/active-state";
228
- import { assertDeepInterviewMutationAllowed } from "../skill-state/deep-interview-mutation-guard";
232
+ import { assertWorkflowMutationAllowed } from "../skill-state/deep-interview-mutation-guard";
229
233
  import { invalidateHostMetadata } from "../ssh/connection-manager";
230
234
  import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
231
235
  import {
@@ -313,13 +317,6 @@ export type AgentSessionEvent =
313
317
  | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined }
314
318
  | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
315
319
 
316
- /**
317
- * Safe path component pattern used to validate session-id segments before
318
- * joining them into `.skc/state` paths. Mirrors the regex used by the
319
- * `skc state` runtime selector resolver.
320
- */
321
- const SAFE_PATH_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
322
-
323
320
  function isUnderProjectSkc(cwd: string, targetPath: string): boolean {
324
321
  const relative = path.relative(path.join(path.resolve(cwd), ".skc"), path.resolve(targetPath));
325
322
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -1371,21 +1368,17 @@ export class AgentSession {
1371
1368
  getActiveSkillPhase(): string | undefined {
1372
1369
  const active = this.#activeSkillState;
1373
1370
  if (!active) return undefined;
1374
- // Path safety: refuse to read mode-state files when the skill or
1375
- // session-id are not safe path components. The `skill` tool
1376
- // interprets undefined as a non-terminal phase, so chaining is
1377
- // refused — there is no risk of bypassing the guard via a custom
1378
- // skill name with `..` or a session-id with separators.
1379
1371
  if (!isCanonicalSkcWorkflowSkill(active.skill)) return undefined;
1380
- if (active.sessionId !== undefined && !SAFE_PATH_COMPONENT.test(active.sessionId)) {
1381
- return undefined;
1382
- }
1372
+ const sessionId = active.sessionId ?? this.sessionManager.getSessionId();
1383
1373
  try {
1384
- const stateDir = path.join(this.sessionManager.getCwd(), ".skc", "state");
1385
- const segments = active.sessionId
1386
- ? [stateDir, "sessions", encodeURIComponent(active.sessionId).replaceAll(".", "%2E")]
1387
- : [stateDir];
1388
- const filePath = path.join(...segments, `${active.skill}-state.json`);
1374
+ assertNonEmptySkcSessionId(sessionId, "AgentSession.getActiveSkillPhase");
1375
+ // Keep the session-state-dir construction explicit here so the chain guard
1376
+ // refuses to fall back to a legacy root `.skc/state` read.
1377
+ const stateDir = sessionStateDir(this.sessionManager.getCwd(), sessionId);
1378
+ const filePath = path.join(
1379
+ stateDir,
1380
+ path.basename(sessionModeStatePath(this.sessionManager.getCwd(), sessionId, active.skill)),
1381
+ );
1389
1382
  const raw = fs.readFileSync(filePath, "utf-8");
1390
1383
  const parsed = JSON.parse(raw) as { current_phase?: unknown };
1391
1384
  return typeof parsed.current_phase === "string" ? parsed.current_phase : undefined;
@@ -1475,7 +1468,7 @@ export class AgentSession {
1475
1468
  }
1476
1469
  const sanitized = sanitizeMessage(providerMessages[i]!);
1477
1470
  if (!sanitized) continue;
1478
- const messageTokens = estimateTokens(sanitized);
1471
+ const messageTokens = estimateMessageTokensHeuristic(sanitized);
1479
1472
  if (maxTokens > 0 && approximateTokens + messageTokens > maxTokens) {
1480
1473
  recordSkip("token-limit");
1481
1474
  continue;
@@ -3764,7 +3757,7 @@ export class AgentSession {
3764
3757
  * prompts or tool execution can run.
3765
3758
  */
3766
3759
  #wrapToolForDeepInterviewMutationGuard<T extends AgentTool>(tool: T): T {
3767
- if (!["edit", "write", "ast_edit", "bash"].includes(tool.name)) return tool;
3760
+ if (!["edit", "write", "ast_edit"].includes(tool.name)) return tool;
3768
3761
  return new Proxy(tool, {
3769
3762
  get: (target, prop) => {
3770
3763
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -3775,7 +3768,7 @@ export class AgentSession {
3775
3768
  onUpdate: never,
3776
3769
  ctx: never,
3777
3770
  ) => {
3778
- await assertDeepInterviewMutationAllowed({
3771
+ await assertWorkflowMutationAllowed({
3779
3772
  cwd: this.sessionManager.getCwd(),
3780
3773
  sessionId: this.sessionManager.getSessionId(),
3781
3774
  tool: target,
@@ -9901,9 +9894,9 @@ export class AgentSession {
9901
9894
  #estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
9902
9895
  tokens: number;
9903
9896
  } {
9904
- const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageNativeContextTokens(message));
9897
+ const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageCompactionDeltaTokens(message));
9905
9898
  return {
9906
- tokens: estimate.tokens + this.#estimateMessagesNativeContextTokens(pendingMessages),
9899
+ tokens: estimate.tokens + this.#estimateMessagesCompactionDeltaTokens(pendingMessages),
9907
9900
  };
9908
9901
  }
9909
9902
 
@@ -9949,10 +9942,10 @@ export class AgentSession {
9949
9942
  };
9950
9943
  }
9951
9944
 
9952
- #estimateMessagesNativeContextTokens(messages: readonly AgentMessage[]): number {
9945
+ #estimateMessagesCompactionDeltaTokens(messages: readonly AgentMessage[]): number {
9953
9946
  let tokens = 0;
9954
9947
  for (const message of messages) {
9955
- tokens += this.#estimateMessageNativeContextTokens(message);
9948
+ tokens += this.#estimateMessageCompactionDeltaTokens(message);
9956
9949
  }
9957
9950
  return tokens;
9958
9951
  }
@@ -9965,11 +9958,17 @@ export class AgentSession {
9965
9958
  return tokens;
9966
9959
  }
9967
9960
 
9968
- #nativeTokenCache = new WeakMap<AgentMessage, { len: number; tokens: number }>();
9961
+ /**
9962
+ * Conservative inflation applied to the native-free chars/4 estimate of the
9963
+ * UNSENT context delta. chars/4 undercounts dense code/CJK, so we bias high
9964
+ * to compact slightly early rather than overflow the model window before the
9965
+ * next provider response re-anchors the exact count.
9966
+ */
9967
+ #compactionDeltaInflation = 1.2;
9968
+ #compactionDeltaTokenCache = new WeakMap<AgentMessage, { len: number; tokens: number }>();
9969
9969
 
9970
- /** Cheap content-size signal to invalidate the native token cache on mutation (growth). */
9971
9970
  /**
9972
- * Cheap content-size signal to invalidate the native token cache on mutation. Recursively
9971
+ * Cheap content-size signal to invalidate the compaction-delta token cache on mutation. Recursively
9973
9972
  * sums string lengths across the whole message (depth-bounded), so it covers every
9974
9973
  * provider-visible shape (text/thinking/tool args, toolResult output, tool names, etc.)
9975
9974
  * without allocating a serialized copy. A size-preserving in-place edit yields only a
@@ -9992,19 +9991,22 @@ export class AgentSession {
9992
9991
  return 0;
9993
9992
  }
9994
9993
 
9995
- #estimateMessageNativeContextTokens(message: AgentMessage): number {
9996
- // F10/F22: cache the expensive native token count per message object, invalidated by a
9997
- // cheap content-size signal, so unchanged (stable-size) messages are not re-tokenized on
9998
- // every pre-prompt estimate. A rare size-preserving in-place edit yields only a benign
9999
- // token-estimate drift, never wrong output.
9994
+ #estimateMessageCompactionDeltaTokens(message: AgentMessage): number {
9995
+ // Provider usage anchors the already-sent context (see calculatePromptTokens); this
9996
+ // estimates only the UNSENT delta with the native-free chars/4 heuristic, inflated by
9997
+ // #compactionDeltaInflation so dense input cannot undercount us past the compaction
9998
+ // threshold before the next provider response re-anchors the exact count. Cached per
9999
+ // message object, invalidated by a cheap content-size signal; a rare size-preserving
10000
+ // in-place edit yields only a benign estimate drift, never wrong output.
10000
10001
  const len = this.#messageTokenSize(message);
10001
- const cached = this.#nativeTokenCache.get(message);
10002
+ const cached = this.#compactionDeltaTokenCache.get(message);
10002
10003
  if (cached && cached.len === len) return cached.tokens;
10003
- let tokens = 0;
10004
+ let heuristic = 0;
10004
10005
  for (const llmMessage of convertToLlm([message])) {
10005
- tokens += estimateTokens(llmMessage);
10006
+ heuristic += estimateMessageTokensHeuristic(llmMessage);
10006
10007
  }
10007
- this.#nativeTokenCache.set(message, { len, tokens });
10008
+ const tokens = Math.ceil(heuristic * this.#compactionDeltaInflation);
10009
+ this.#compactionDeltaTokenCache.set(message, { len, tokens });
10008
10010
  return tokens;
10009
10011
  }
10010
10012
 
@@ -1129,6 +1129,23 @@ function formatTimeAgo(date: Date): string {
1129
1129
  return date.toLocaleDateString();
1130
1130
  }
1131
1131
 
1132
+ async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
1133
+ try {
1134
+ await fs.promises.rename(source, destination);
1135
+ return;
1136
+ } catch (error) {
1137
+ if (!hasFsCode(error, "EXDEV")) throw error;
1138
+ }
1139
+ const stat = await fs.promises.stat(source);
1140
+ if (stat.isDirectory()) {
1141
+ await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
1142
+ await fs.promises.rm(source, { recursive: true, force: false });
1143
+ return;
1144
+ }
1145
+ await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
1146
+ await fs.promises.unlink(source);
1147
+ }
1148
+
1132
1149
  const MAX_PERSIST_CHARS = 500_000;
1133
1150
  const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
1134
1151
  /** Minimum base64 length to externalize to blob store (skip tiny inline images) */
@@ -2498,14 +2515,14 @@ export class SessionManager {
2498
2515
  try {
2499
2516
  // Guard: session file may not exist yet (no assistant messages persisted)
2500
2517
  if (hadSessionFile) {
2501
- await fs.promises.rename(oldSessionFile, newSessionFile);
2518
+ await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
2502
2519
  movedSessionFile = true;
2503
2520
  }
2504
2521
 
2505
2522
  try {
2506
2523
  const stat = await fs.promises.stat(oldArtifactDir);
2507
2524
  if (stat.isDirectory()) {
2508
- await fs.promises.rename(oldArtifactDir, newArtifactDir);
2525
+ await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
2509
2526
  movedArtifactDir = true;
2510
2527
  }
2511
2528
  } catch (err) {
@@ -29,6 +29,14 @@ The Hermes bridge does not choose a model/provider. Generated setup configures `
29
29
 
30
30
  Provider-specific commands are examples only, never product defaults.
31
31
 
32
+ ## Visible routed-session fallback
33
+
34
+ If a Hermes/OpenClaw/Clawhip-style operator needs a human-visible, channel-routed SKC pane instead of a pure Coordinator MCP session, use the visible session pattern in [`docs/skc-session-clawhip-routing.md`](../../../../../../docs/skc-session-clawhip-routing.md).
35
+
36
+ Use that pattern only when the router must watch tmux output, send stale-session alerts, or inject follow-up prompts into the same visible pane. The short version is: prepare a dedicated worktree, register a stable tmux session through the host router, start interactive `skc`, wait for TUI readiness, inject the task prompt separately, and verify actual tool/work activity before reporting acceptance.
37
+
38
+ Do not put private channel ids, mention targets, socket names, tokens, or local routing policy into portable setup output. Keep those in the host/operator deployment.
39
+
32
40
  ## Safety
33
41
 
34
42
  - Mutating tools require bridge startup mutation classes and per-call consent.
@@ -404,7 +404,7 @@ async function installConfig(spec: CoordinatorSetupSpec, force: boolean): Promis
404
404
 
405
405
  async function runSmoke(spec: CoordinatorSetupSpec): Promise<HermesSetupResult["smoke"]> {
406
406
  const requiredTools = [...COORDINATOR_MCP_TOOL_NAMES];
407
- const server = createCoordinatorMcpServer({ env: {} });
407
+ const server = createCoordinatorMcpServer({ env: renderHermesServerBlock(spec).env as NodeJS.ProcessEnv });
408
408
  const listed = await server.handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
409
409
  const listedResult = isRecord(listed.result) ? listed.result : {};
410
410
  const tools = Array.isArray(listedResult.tools) ? listedResult.tools : [];
@@ -11,7 +11,8 @@ import {
11
11
  normalizeDeepInterviewEnvelope,
12
12
  questionHash,
13
13
  } from "./deep-interview-state";
14
- import { readExistingStateForMutation, writeWorkflowEnvelopeAtomic } from "./state-writer";
14
+ import { writeSessionActivityMarker } from "./session-resolution";
15
+ import { readExistingStateForMutation, writeGuardedWorkflowEnvelopeAtomic } from "./state-writer";
15
16
 
16
17
  export * from "./deep-interview-state";
17
18
 
@@ -298,6 +299,12 @@ async function readEnvelope(statePath: string): Promise<DeepInterviewStateEnvelo
298
299
  return ensureDeepInterviewStateShape(undefined);
299
300
  }
300
301
 
302
+ function existingStateRevision(value: unknown): number | undefined {
303
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
304
+ const revision = (value as Record<string, unknown>).state_revision;
305
+ return typeof revision === "number" && Number.isFinite(revision) ? revision : 0;
306
+ }
307
+
301
308
  function interviewIdOf(envelope: DeepInterviewStateEnvelope): string | undefined {
302
309
  const inner = (envelope.state ?? {}) as Record<string, unknown>;
303
310
  return typeof inner.interview_id === "string" ? inner.interview_id : undefined;
@@ -310,6 +317,7 @@ async function persistEnvelope(
310
317
  sessionId: string | undefined,
311
318
  command: string,
312
319
  ): Promise<void> {
320
+ if (!sessionId) throw new Error("deep-interview recorder requires a session id");
313
321
  const now = new Date().toISOString();
314
322
  const payload: Record<string, unknown> = { ...normalizeDeepInterviewEnvelope(envelope), updated_at: now };
315
323
  // Guarantee RequiredOnWriteEnvelopeSchema fields for the fresh/absent fallback;
@@ -318,11 +326,22 @@ async function persistEnvelope(
318
326
  payload.version ??= WORKFLOW_STATE_VERSION;
319
327
  payload.active ??= true;
320
328
  payload.current_phase ??= "interviewing";
321
- await writeWorkflowEnvelopeAtomic(statePath, payload, {
329
+ const expectedRevision = existingStateRevision(envelope);
330
+ const writeResult = await writeGuardedWorkflowEnvelopeAtomic(statePath, payload, {
322
331
  cwd,
332
+ policy: "source",
333
+ expectedRevision,
323
334
  receipt: { cwd, skill: "deep-interview", owner: "skc-runtime", command, sessionId, nowIso: now },
324
- audit: { category: "state", verb: "write", owner: "skc-runtime", skill: "deep-interview" },
335
+ audit: { category: "state", verb: "write", owner: "skc-runtime", skill: "deep-interview", sessionId },
325
336
  });
337
+ // Reflect the freshly written revision back onto the in-memory envelope so a
338
+ // follow-up HUD sync derives its `sourceRevision` from the persisted revision
339
+ // (not the stale pre-write value), otherwise the active-state writer treats the
340
+ // newer HUD as stale and skips it (e.g. dropping the ambiguity chip after scoring).
341
+ if (writeResult.written && typeof expectedRevision === "number") {
342
+ (envelope as Record<string, unknown>).state_revision = expectedRevision + 1;
343
+ }
344
+ await writeSessionActivityMarker(cwd, sessionId, { writer: "deep-interview-recorder", path: statePath });
326
345
  }
327
346
 
328
347
  /**
@@ -335,20 +354,17 @@ async function syncRecorderHud(
335
354
  envelope: DeepInterviewStateEnvelope,
336
355
  sessionId: string | undefined,
337
356
  ): Promise<void> {
338
- try {
339
- const phase = typeof envelope.current_phase === "string" ? envelope.current_phase : "interviewing";
340
- await syncSkillActiveState({
341
- cwd,
342
- skill: "deep-interview",
343
- active: phase !== "complete",
344
- phase,
345
- sessionId,
346
- source: "skc-runtime-deep-interview-recorder",
347
- hud: deriveDeepInterviewHud(envelope as Record<string, unknown>, { phase }),
348
- });
349
- } catch {
350
- // HUD sync is best-effort cache maintenance and must not change record semantics.
351
- }
357
+ const phase = typeof envelope.current_phase === "string" ? envelope.current_phase : "interviewing";
358
+ await syncSkillActiveState({
359
+ cwd,
360
+ skill: "deep-interview",
361
+ active: phase !== "complete",
362
+ phase,
363
+ sessionId,
364
+ source: "skc-runtime-deep-interview-recorder",
365
+ hud: deriveDeepInterviewHud(envelope as Record<string, unknown>, { phase }),
366
+ sourceRevision: existingStateRevision(envelope),
367
+ });
352
368
  }
353
369
 
354
370
  /**
@@ -360,6 +376,19 @@ async function repairRecorderHudFromPersisted(
360
376
  cwd: string,
361
377
  statePath: string,
362
378
  sessionId: string | undefined,
379
+ ): Promise<void> {
380
+ try {
381
+ await syncDeepInterviewRecorderHud(cwd, statePath, sessionId);
382
+ } catch {
383
+ // HUD sync is best-effort cache maintenance and must not change record semantics.
384
+ }
385
+ }
386
+
387
+ /** Refresh the best-effort HUD cache from persisted deep-interview state. */
388
+ export async function syncDeepInterviewRecorderHud(
389
+ cwd: string,
390
+ statePath: string,
391
+ sessionId: string | undefined,
363
392
  ): Promise<void> {
364
393
  const read = await readExistingStateForMutation(statePath);
365
394
  if (read.kind !== "valid") return;
@@ -384,7 +413,11 @@ export async function appendOrMergeDeepInterviewRound(
384
413
  }
385
414
  (envelope.state as Record<string, unknown>).rounds = result.rounds;
386
415
  await persistEnvelope(cwd, statePath, envelope, options.sessionId, "skc deep-interview record-answer");
387
- await syncRecorderHud(cwd, envelope, options.sessionId);
416
+ try {
417
+ await syncRecorderHud(cwd, envelope, options.sessionId);
418
+ } catch {
419
+ // HUD sync is best-effort cache maintenance and must not change record semantics.
420
+ }
388
421
  return { action: result.action, record: result.record };
389
422
  }
390
423
 
@@ -7,6 +7,8 @@ import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
7
7
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
8
8
  import { normalizeDeepInterviewEnvelope } from "./deep-interview-state";
9
9
  import { runNativeRalplanCommand } from "./ralplan-runtime";
10
+ import { modeStatePath, sessionSpecsDir } from "./session-layout";
11
+ import { resolveSkcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
10
12
  import { runNativeStateCommand } from "./state-runtime";
11
13
  import { appendJsonl, readExistingStateForMutation, writeArtifact, writeWorkflowEnvelopeAtomic } from "./state-writer";
12
14
 
@@ -76,10 +78,6 @@ function assertSafePathComponent(value: string, label: string): void {
76
78
  }
77
79
  }
78
80
 
79
- function encodeSessionSegment(value: string): string {
80
- return encodeURIComponent(value).replaceAll(".", "%2E");
81
- }
82
-
83
81
  function defaultSpecSlug(now: Date = new Date()): string {
84
82
  const yyyy = now.getUTCFullYear().toString().padStart(4, "0");
85
83
  const mm = (now.getUTCMonth() + 1).toString().padStart(2, "0");
@@ -89,14 +87,10 @@ function defaultSpecSlug(now: Date = new Date()): string {
89
87
  return `${yyyy}-${mm}-${dd}-${hh}${min}-${randomBytes(2).toString("hex")}`;
90
88
  }
91
89
 
92
- function stateDirFor(cwd: string, sessionId: string | undefined): string {
93
- return sessionId
94
- ? path.join(cwd, ".skc", "state", "sessions", encodeSessionSegment(sessionId))
95
- : path.join(cwd, ".skc", "state");
96
- }
97
-
98
- export function deepInterviewStatePath(cwd: string, sessionId: string | undefined): string {
99
- return path.join(stateDirFor(cwd, sessionId), "deep-interview-state.json");
90
+ export function deepInterviewStatePath(cwd: string, sessionId?: string): string {
91
+ const resolvedSessionId = sessionId?.trim() || process.env.SKC_SESSION_ID?.trim();
92
+ if (!resolvedSessionId) throw new Error("deep-interview state path requires a session id");
93
+ return modeStatePath(cwd, resolvedSessionId, "deep-interview");
100
94
  }
101
95
 
102
96
  async function resolveSpecContent(rawSpec: string, cwd: string): Promise<string> {
@@ -117,7 +111,7 @@ interface ResolvedDeepInterviewArgs {
117
111
  resolution: DeepInterviewResolution;
118
112
  threshold: number;
119
113
  thresholdSource: string;
120
- sessionId?: string;
114
+ sessionId: string;
121
115
  idea: string;
122
116
  language?: DeepInterviewLanguagePreference;
123
117
  json: boolean;
@@ -134,7 +128,7 @@ export interface ResolvedDeepInterviewSpecWriteArgs {
134
128
  stage: "final";
135
129
  slug: string;
136
130
  spec: string;
137
- sessionId?: string;
131
+ sessionId: string;
138
132
  json: boolean;
139
133
  deliberate: boolean;
140
134
  handoff?: "ralplan";
@@ -277,8 +271,12 @@ async function resolveSpecWriteArgs(args: readonly string[], cwd: string): Promi
277
271
  throw new DeepInterviewCommandError(2, "--spec is required for deep-interview --write");
278
272
  }
279
273
 
280
- const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
281
- if (sessionId) assertSafePathComponent(sessionId, "session-id");
274
+ const session = resolveSkcSessionForWrite(cwd, {
275
+ flagValue: flagValue(args, "--session-id"),
276
+ envSessionId: process.env.SKC_SESSION_ID,
277
+ });
278
+ const sessionId = session.skcSessionId;
279
+ assertSafePathComponent(sessionId, "session-id");
282
280
 
283
281
  const rawHandoff = flagValue(args, "--handoff")?.trim() || undefined;
284
282
  if (rawHandoff && rawHandoff !== "ralplan") {
@@ -324,8 +322,12 @@ async function resolveSpecWriteArgs(args: readonly string[], cwd: string): Promi
324
322
  }
325
323
 
326
324
  async function resolveDeepInterviewArgs(args: readonly string[], cwd: string): Promise<ResolvedDeepInterviewArgs> {
327
- const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
328
- if (sessionId) assertSafePathComponent(sessionId, "session-id");
325
+ const session = resolveSkcSessionForWrite(cwd, {
326
+ flagValue: flagValue(args, "--session-id"),
327
+ envSessionId: process.env.SKC_SESSION_ID,
328
+ });
329
+ const sessionId = session.skcSessionId;
330
+ assertSafePathComponent(sessionId, "session-id");
329
331
 
330
332
  const explicitResolutions = (["quick", "standard", "deep"] as const).filter(name => hasFlag(args, `--${name}`));
331
333
  if (explicitResolutions.length > 1) {
@@ -402,19 +404,34 @@ export async function persistDeepInterviewSpec(
402
404
  }
403
405
  const existing = existingRead.kind === "valid" ? existingRead.value : {};
404
406
 
405
- const specPath = path.join(cwd, ".skc", "specs", `deep-interview-${resolved.slug}.md`);
407
+ const specPath = path.join(sessionSpecsDir(cwd, resolved.sessionId), `deep-interview-${resolved.slug}.md`);
406
408
  const content = resolved.spec.endsWith("\n") ? resolved.spec : `${resolved.spec}\n`;
407
409
  await writeArtifact(specPath, content, {
408
410
  cwd,
409
- audit: { category: "artifact", verb: "write", owner: "skc-runtime", skill: "deep-interview" },
411
+ audit: {
412
+ category: "artifact",
413
+ verb: "write",
414
+ owner: "skc-runtime",
415
+ skill: "deep-interview",
416
+ sessionId: resolved.sessionId,
417
+ },
410
418
  });
411
419
 
412
420
  const sha256 = createHash("sha256").update(content).digest("hex");
413
421
  const createdAt = new Date().toISOString();
414
422
  await appendJsonl(
415
- path.join(cwd, ".skc", "specs", "deep-interview-index.jsonl"),
423
+ path.join(sessionSpecsDir(cwd, resolved.sessionId), "deep-interview-index.jsonl"),
416
424
  { slug: resolved.slug, stage: resolved.stage, path: specPath, created_at: createdAt, sha256 },
417
- { cwd, audit: { category: "ledger", verb: "append", owner: "skc-runtime", skill: "deep-interview" } },
425
+ {
426
+ cwd,
427
+ audit: {
428
+ category: "ledger",
429
+ verb: "append",
430
+ owner: "skc-runtime",
431
+ skill: "deep-interview",
432
+ sessionId: resolved.sessionId,
433
+ },
434
+ },
418
435
  );
419
436
 
420
437
  const payload = normalizeDeepInterviewEnvelope({
@@ -446,9 +463,11 @@ export async function persistDeepInterviewSpec(
446
463
  verb: "write",
447
464
  owner: "skc-runtime",
448
465
  skill: "deep-interview",
466
+ sessionId: resolved.sessionId,
449
467
  forced: resolved.force,
450
468
  },
451
469
  });
470
+ await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "deep-interview-runtime", path: statePath });
452
471
  await syncDeepInterviewHud({
453
472
  cwd,
454
473
  sessionId: resolved.sessionId,
@@ -503,8 +522,15 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
503
522
  sessionId: resolved.sessionId,
504
523
  nowIso: now,
505
524
  },
506
- audit: { category: "state", verb: "write", owner: "skc-runtime", skill: "deep-interview" },
525
+ audit: {
526
+ category: "state",
527
+ verb: "write",
528
+ owner: "skc-runtime",
529
+ skill: "deep-interview",
530
+ sessionId: resolved.sessionId,
531
+ },
507
532
  });
533
+ await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "deep-interview-runtime", path: statePath });
508
534
  await syncDeepInterviewHud({ cwd, sessionId: resolved.sessionId, payload, phase: "interviewing" });
509
535
  return statePath;
510
536
  }
@@ -8,6 +8,8 @@ import {
8
8
  type ModeChangeEntry,
9
9
  type SessionEntry,
10
10
  } from "../session/session-manager";
11
+ import { sessionStateDir, sessionUltragoalDir } from "./session-layout";
12
+ import { resolveSkcSessionForRead, resolveSkcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
11
13
  import { removeFileAudited, writeJsonAtomic } from "./state-writer";
12
14
 
13
15
  export const SKC_SESSION_FILE_ENV = "SKC_SESSION_FILE";
@@ -48,12 +50,12 @@ function isEnoent(error: unknown): boolean {
48
50
  );
49
51
  }
50
52
 
51
- function requestPath(cwd: string): string {
52
- return path.join(cwd, ".skc", "state", "goal-mode-request.json");
53
+ function requestPath(cwd: string, sessionId: string): string {
54
+ return path.join(sessionStateDir(cwd, sessionId), "goal-mode-request.json");
53
55
  }
54
56
 
55
- function ultragoalGoalsPath(cwd: string): string {
56
- return path.join(cwd, ".skc", "ultragoal", "goals.json");
57
+ function ultragoalGoalsPath(cwd: string, sessionId: string): string {
58
+ return path.join(sessionUltragoalDir(cwd, sessionId), "goals.json");
57
59
  }
58
60
 
59
61
  function isCreateGoalsArg(value: string): boolean {
@@ -65,8 +67,14 @@ export function isUltragoalCreateGoalsInvocation(args: readonly string[]): boole
65
67
  return command !== undefined && isCreateGoalsArg(command);
66
68
  }
67
69
 
68
- export async function readUltragoalSkcObjective(cwd: string): Promise<{ objective: string; goalsPath: string }> {
69
- const goalsPath = ultragoalGoalsPath(cwd);
70
+ export async function readUltragoalSkcObjective(
71
+ cwd: string,
72
+ sessionId?: string | null,
73
+ ): Promise<{ objective: string; goalsPath: string }> {
74
+ const session = sessionId?.trim()
75
+ ? { skcSessionId: sessionId.trim() }
76
+ : await resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID });
77
+ const goalsPath = ultragoalGoalsPath(cwd, session.skcSessionId);
70
78
  try {
71
79
  const plan = (await Bun.file(goalsPath).json()) as UltragoalPlanShape;
72
80
  const objective = typeof plan.skcObjective === "string" ? plan.skcObjective.trim() : "";
@@ -87,7 +95,10 @@ export async function writePendingGoalModeRequest(input: {
87
95
  }): Promise<PendingGoalModeRequest> {
88
96
  const objective = input.objective.trim();
89
97
  if (!objective) throw new Error("goal objective is required");
90
- const sessionId = input.sessionId?.trim();
98
+ const resolvedSessionId =
99
+ input.sessionId?.trim() ||
100
+ resolveSkcSessionForWrite(input.cwd, { envSessionId: process.env.SKC_SESSION_ID }).skcSessionId;
101
+ const sessionId = resolvedSessionId;
91
102
  const request: PendingGoalModeRequest = {
92
103
  version: REQUEST_VERSION,
93
104
  kind: "goal_mode_request",
@@ -97,11 +108,12 @@ export async function writePendingGoalModeRequest(input: {
97
108
  goalsPath: input.goalsPath,
98
109
  ...(sessionId ? { sessionId } : {}),
99
110
  };
100
- const filePath = requestPath(input.cwd);
111
+ const filePath = requestPath(input.cwd, sessionId);
101
112
  await writeJsonAtomic(filePath, request, {
102
113
  cwd: input.cwd,
103
- audit: { category: "state", verb: "write", owner: "skc-runtime" },
114
+ audit: { category: "state", verb: "write", owner: "skc-runtime", sessionId },
104
115
  });
116
+ await writeSessionActivityMarker(input.cwd, sessionId, { writer: "goal-mode-request", path: filePath });
105
117
  return request;
106
118
  }
107
119
 
@@ -175,7 +187,10 @@ export async function consumePendingGoalModeRequest(
175
187
  cwd: string,
176
188
  currentSessionId?: string | null,
177
189
  ): Promise<PendingGoalModeRequest | null> {
178
- const filePath = requestPath(cwd);
190
+ const session = currentSessionId?.trim()
191
+ ? { skcSessionId: currentSessionId.trim() }
192
+ : await resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID });
193
+ const filePath = requestPath(cwd, session.skcSessionId);
179
194
  let raw: unknown;
180
195
  try {
181
196
  raw = await Bun.file(filePath).json();
@@ -203,7 +218,7 @@ export async function consumePendingGoalModeRequest(
203
218
  }
204
219
  await removeFileAudited(filePath, {
205
220
  cwd,
206
- audit: { category: "prune", verb: "remove", owner: "skc-runtime" },
221
+ audit: { category: "prune", verb: "remove", owner: "skc-runtime", sessionId: session.skcSessionId },
207
222
  }).catch(error => {
208
223
  if (!isEnoent(error)) throw error;
209
224
  });