@sayknow-cli/coding-agent 0.2.4 → 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 (149) hide show
  1. package/dist/types/cli/migrate-cli.d.ts +20 -0
  2. package/dist/types/cli/update-cli.d.ts +3 -0
  3. package/dist/types/commands/migrate.d.ts +33 -0
  4. package/dist/types/config/keybindings.d.ts +4 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/models-config-schema.d.ts +5 -0
  7. package/dist/types/config/settings-schema.d.ts +27 -0
  8. package/dist/types/harness-control-plane/storage.d.ts +2 -1
  9. package/dist/types/hooks/skill-state.d.ts +12 -4
  10. package/dist/types/lsp/startup-events.d.ts +1 -0
  11. package/dist/types/migrate/action-planner.d.ts +11 -0
  12. package/dist/types/migrate/adapters/claude-code.d.ts +2 -0
  13. package/dist/types/migrate/adapters/codex.d.ts +5 -0
  14. package/dist/types/migrate/adapters/index.d.ts +45 -0
  15. package/dist/types/migrate/adapters/opencode.d.ts +2 -0
  16. package/dist/types/migrate/executor.d.ts +2 -0
  17. package/dist/types/migrate/mcp-mapper.d.ts +20 -0
  18. package/dist/types/migrate/report.d.ts +18 -0
  19. package/dist/types/migrate/skill-normalizer.d.ts +27 -0
  20. package/dist/types/migrate/types.d.ts +126 -0
  21. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  22. package/dist/types/modes/components/welcome.d.ts +3 -1
  23. package/dist/types/modes/interactive-mode.d.ts +3 -0
  24. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  25. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +1 -1
  26. package/dist/types/research-plan/index.d.ts +1 -0
  27. package/dist/types/research-plan/ledger.d.ts +33 -0
  28. package/dist/types/rlm/artifacts.d.ts +1 -1
  29. package/dist/types/runtime-mcp/config-writer.d.ts +26 -0
  30. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +2 -0
  31. package/dist/types/skc-runtime/deep-interview-runtime.d.ts +2 -2
  32. package/dist/types/skc-runtime/goal-mode-request.d.ts +1 -1
  33. package/dist/types/skc-runtime/session-layout.d.ts +59 -0
  34. package/dist/types/skc-runtime/session-resolution.d.ts +47 -0
  35. package/dist/types/skc-runtime/state-graph.d.ts +1 -1
  36. package/dist/types/skc-runtime/state-runtime.d.ts +5 -4
  37. package/dist/types/skc-runtime/state-schema.d.ts +2 -0
  38. package/dist/types/skc-runtime/state-writer.d.ts +36 -7
  39. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  40. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +7 -4
  41. package/dist/types/skc-runtime/workflow-command-ref.d.ts +1 -1
  42. package/dist/types/skc-runtime/workflow-manifest.d.ts +1 -1
  43. package/dist/types/skill-state/active-state.d.ts +6 -11
  44. package/dist/types/skill-state/canonical-skills.d.ts +3 -0
  45. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +5 -0
  46. package/dist/types/skill-state/workflow-hud.d.ts +2 -0
  47. package/dist/types/task/spawn-gate.d.ts +1 -10
  48. package/package.json +7 -7
  49. package/scripts/build-binary.ts +0 -7
  50. package/src/cli/migrate-cli.ts +106 -0
  51. package/src/cli/setup-cli.ts +14 -1
  52. package/src/cli/update-cli.ts +53 -3
  53. package/src/cli.ts +1 -0
  54. package/src/commands/deep-interview.ts +2 -2
  55. package/src/commands/launch.ts +1 -1
  56. package/src/commands/migrate.ts +46 -0
  57. package/src/commands/state.ts +2 -1
  58. package/src/commands/team.ts +7 -3
  59. package/src/config/model-registry.ts +9 -2
  60. package/src/config/model-resolver.ts +13 -2
  61. package/src/config/models-config-schema.ts +1 -0
  62. package/src/config/settings-schema.ts +17 -0
  63. package/src/coordinator-mcp/policy.ts +10 -2
  64. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +0 -1
  65. package/src/defaults/skc/skills/deep-interview/SKILL.md +30 -24
  66. package/src/defaults/skc/skills/ralplan/SKILL.md +10 -4
  67. package/src/defaults/skc/skills/team/SKILL.md +51 -47
  68. package/src/defaults/skc/skills/ultragoal/SKILL.md +17 -13
  69. package/src/exec/bash-executor.ts +3 -1
  70. package/src/extensibility/custom-commands/loader.ts +0 -7
  71. package/src/extensibility/skc-plugins/injection.ts +23 -4
  72. package/src/extensibility/skc-plugins/state.ts +16 -1
  73. package/src/harness-control-plane/storage.ts +14 -4
  74. package/src/hooks/native-skill-hook.ts +38 -12
  75. package/src/hooks/skill-state.ts +178 -83
  76. package/src/internal-urls/docs-index.generated.ts +11 -9
  77. package/src/lsp/startup-events.ts +24 -0
  78. package/src/migrate/action-planner.ts +318 -0
  79. package/src/migrate/adapters/claude-code.ts +39 -0
  80. package/src/migrate/adapters/codex.ts +70 -0
  81. package/src/migrate/adapters/index.ts +277 -0
  82. package/src/migrate/adapters/opencode.ts +52 -0
  83. package/src/migrate/executor.ts +81 -0
  84. package/src/migrate/mcp-mapper.ts +152 -0
  85. package/src/migrate/report.ts +104 -0
  86. package/src/migrate/skill-normalizer.ts +80 -0
  87. package/src/migrate/types.ts +163 -0
  88. package/src/modes/bridge/bridge-mode.ts +2 -2
  89. package/src/modes/components/custom-editor.ts +30 -20
  90. package/src/modes/components/welcome.ts +20 -9
  91. package/src/modes/controllers/input-controller.ts +21 -3
  92. package/src/modes/interactive-mode.ts +27 -19
  93. package/src/modes/prompt-action-autocomplete.ts +11 -1
  94. package/src/modes/rpc/rpc-mode.ts +2 -2
  95. package/src/modes/shared/agent-wire/unattended-audit.ts +3 -2
  96. package/src/prompts/agents/init.md +1 -1
  97. package/src/prompts/system/plan-mode-active.md +1 -1
  98. package/src/prompts/tools/ast-grep.md +1 -1
  99. package/src/prompts/tools/search.md +1 -1
  100. package/src/prompts/tools/task.md +1 -2
  101. package/src/research-plan/index.ts +1 -0
  102. package/src/research-plan/ledger.ts +177 -0
  103. package/src/rlm/artifacts.ts +12 -3
  104. package/src/rlm/index.ts +7 -0
  105. package/src/runtime-mcp/config-writer.ts +46 -0
  106. package/src/session/agent-session.ts +43 -41
  107. package/src/session/session-manager.ts +19 -2
  108. package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
  109. package/src/setup/hermes-setup.ts +1 -1
  110. package/src/skc-runtime/deep-interview-recorder.ts +51 -18
  111. package/src/skc-runtime/deep-interview-runtime.ts +49 -23
  112. package/src/skc-runtime/goal-mode-request.ts +26 -11
  113. package/src/skc-runtime/launch-tmux.ts +68 -15
  114. package/src/skc-runtime/ralplan-runtime.ts +79 -50
  115. package/src/skc-runtime/session-layout.ts +180 -0
  116. package/src/skc-runtime/session-resolution.ts +217 -0
  117. package/src/skc-runtime/state-graph.ts +1 -2
  118. package/src/skc-runtime/state-migrations.ts +1 -0
  119. package/src/skc-runtime/state-runtime.ts +237 -114
  120. package/src/skc-runtime/state-schema.ts +2 -0
  121. package/src/skc-runtime/state-writer.ts +310 -42
  122. package/src/skc-runtime/team-runtime.ts +43 -19
  123. package/src/skc-runtime/tmux-sessions.ts +43 -2
  124. package/src/skc-runtime/ultragoal-guard.ts +45 -2
  125. package/src/skc-runtime/ultragoal-runtime.ts +121 -41
  126. package/src/skc-runtime/workflow-command-ref.ts +1 -2
  127. package/src/skc-runtime/workflow-manifest.ts +1 -2
  128. package/src/skill-state/active-state.ts +116 -129
  129. package/src/skill-state/canonical-skills.ts +4 -0
  130. package/src/skill-state/deep-interview-mutation-guard.ts +238 -111
  131. package/src/skill-state/workflow-hud.ts +4 -2
  132. package/src/skill-state/workflow-state-contract.ts +3 -3
  133. package/src/slash-commands/builtin-registry.ts +8 -4
  134. package/src/system-prompt.ts +11 -9
  135. package/src/task/agents.ts +1 -22
  136. package/src/task/index.ts +1 -41
  137. package/src/task/spawn-gate.ts +1 -38
  138. package/src/task/types.ts +1 -1
  139. package/src/tools/ask.ts +34 -12
  140. package/src/tools/ast-edit.ts +2 -2
  141. package/src/tools/computer.ts +58 -4
  142. package/src/utils/edit-mode.ts +1 -1
  143. package/dist/types/extensibility/custom-commands/bundled/review/index.d.ts +0 -10
  144. package/src/extensibility/custom-commands/bundled/review/index.ts +0 -456
  145. package/src/prompts/agents/explore.md +0 -58
  146. package/src/prompts/agents/plan.md +0 -49
  147. package/src/prompts/agents/reviewer.md +0 -141
  148. package/src/prompts/agents/task.md +0 -16
  149. package/src/prompts/review-request.md +0 -70
@@ -1,4 +1,6 @@
1
- import * as path from "node:path";
1
+ import * as logger from "@sayknow-cli/utils/logger";
2
+ import { activeSnapshotPath, assertNonEmptySkcSessionId, modeStatePath } from "../skc-runtime/session-layout";
3
+ import { resolveSkcSessionForRead, SessionResolutionError } from "../skc-runtime/session-resolution";
2
4
  import {
3
5
  type ActiveSessionScope,
4
6
  readActiveEntries,
@@ -6,13 +8,12 @@ import {
6
8
  removeActiveEntry,
7
9
  writeActiveEntry,
8
10
  } from "../skc-runtime/state-writer";
11
+ import { CANONICAL_SKC_WORKFLOW_SKILLS, type CanonicalSkcWorkflowSkill } from "./canonical-skills";
9
12
  import type { WorkflowStateReceipt } from "./workflow-state-contract";
10
13
 
11
14
  export const SKILL_ACTIVE_STATE_FILE = "skill-active-state.json";
12
15
 
13
- export const CANONICAL_SKC_WORKFLOW_SKILLS = ["deep-interview", "ralplan", "ultragoal", "team"] as const;
14
-
15
- export type CanonicalSkcWorkflowSkill = (typeof CANONICAL_SKC_WORKFLOW_SKILLS)[number];
16
+ export { CANONICAL_SKC_WORKFLOW_SKILLS, type CanonicalSkcWorkflowSkill };
16
17
  export type WorkflowHudSeverity = "info" | "warning" | "blocked" | "error" | "success";
17
18
 
18
19
  export interface WorkflowHudChip {
@@ -60,6 +61,7 @@ export interface SkillActiveEntry {
60
61
  handoff_to?: string;
61
62
  handoff_at?: string;
62
63
  active_subskills?: ActiveSubskillEntry[];
64
+ source_state_revision?: number;
63
65
  }
64
66
 
65
67
  export interface SkillActiveState {
@@ -83,7 +85,7 @@ export interface SkillActiveState {
83
85
 
84
86
  export interface SkillActiveStatePaths {
85
87
  rootPath: string;
86
- sessionPath?: string;
88
+ sessionPath: string;
87
89
  }
88
90
 
89
91
  export interface SyncSkillActiveStateOptions {
@@ -102,6 +104,7 @@ export interface SyncSkillActiveStateOptions {
102
104
  handoff_to?: string;
103
105
  handoff_at?: string;
104
106
  active_subskills?: ActiveSubskillEntry[];
107
+ sourceRevision?: number;
105
108
  }
106
109
 
107
110
  const HUD_TEXT_LIMIT = 80;
@@ -246,8 +249,12 @@ function unionActiveSubskillEntries(...entrySets: Array<ActiveSubskillEntry[] |
246
249
  return merged;
247
250
  }
248
251
 
249
- function encodePathSegment(value: string): string {
250
- return encodeURIComponent(value).replaceAll(".", "%2E");
252
+ function resolveBoundarySessionId(cwd: string, sessionId?: string): Promise<string> {
253
+ const normalizedSessionId = safeString(sessionId).trim();
254
+ if (normalizedSessionId) return Promise.resolve(normalizedSessionId);
255
+ return resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID }).then(
256
+ context => context.skcSessionId,
257
+ );
251
258
  }
252
259
 
253
260
  function entryKey(entry: Pick<SkillActiveEntry, "skill" | "session_id">): string {
@@ -343,14 +350,10 @@ export function normalizeSkillActiveState(raw: unknown): SkillActiveState | null
343
350
  }
344
351
 
345
352
  export function getSkillActiveStatePaths(cwd: string, sessionId?: string): SkillActiveStatePaths {
346
- const stateDir = path.join(cwd, ".skc", "state");
347
- const rootPath = path.join(stateDir, SKILL_ACTIVE_STATE_FILE);
348
353
  const normalizedSessionId = safeString(sessionId).trim();
349
- if (!normalizedSessionId) return { rootPath };
350
- return {
351
- rootPath,
352
- sessionPath: path.join(stateDir, "sessions", encodePathSegment(normalizedSessionId), SKILL_ACTIVE_STATE_FILE),
353
- };
354
+ assertNonEmptySkcSessionId(normalizedSessionId, "getSkillActiveStatePaths");
355
+ const sessionPath = activeSnapshotPath(cwd, normalizedSessionId);
356
+ return { rootPath: sessionPath, sessionPath };
354
357
  }
355
358
 
356
359
  /**
@@ -380,7 +383,12 @@ async function readRawActiveStateForHandoff(filePath: string, strict: boolean):
380
383
  if (!parsed || typeof parsed !== "object") return null;
381
384
  return parsed as SkillActiveState;
382
385
  } catch (err) {
383
- if (!strict) return null;
386
+ if (!strict) {
387
+ logger.warn(
388
+ `skc skill-state: invalid skill-active-state at ${filePath}: invalid JSON: ${(err as Error).message}`,
389
+ );
390
+ return null;
391
+ }
384
392
  throw err;
385
393
  }
386
394
  }
@@ -419,14 +427,10 @@ function rawActiveEntries(state: SkillActiveState | null): SkillActiveEntry[] {
419
427
 
420
428
  async function readModeStatePhase(
421
429
  cwd: string,
422
- sessionId: string | undefined,
430
+ sessionId: string,
423
431
  skill: CanonicalSkcWorkflowSkill,
424
432
  ): Promise<string | undefined> {
425
- const stateDir = path.join(cwd, ".skc", "state");
426
- const normalizedSessionId = safeString(sessionId).trim();
427
- const filePath = normalizedSessionId
428
- ? path.join(stateDir, "sessions", encodePathSegment(normalizedSessionId), `${skill}-state.json`)
429
- : path.join(stateDir, `${skill}-state.json`);
433
+ const filePath = modeStatePath(cwd, sessionId, skill);
430
434
  try {
431
435
  const parsed = JSON.parse(await Bun.file(filePath).text());
432
436
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
@@ -473,15 +477,6 @@ function withCanonicalRalplanPhase(entry: SkillActiveEntry, canonicalPhase: stri
473
477
  };
474
478
  }
475
479
 
476
- function filterRootEntriesForSession(entries: SkillActiveEntry[], sessionId?: string): SkillActiveEntry[] {
477
- const normalizedSessionId = safeString(sessionId).trim();
478
- if (!normalizedSessionId) return entries;
479
- return entries.filter(entry => {
480
- const entrySessionId = safeString(entry.session_id).trim();
481
- return entrySessionId.length === 0 || entrySessionId === normalizedSessionId;
482
- });
483
- }
484
-
485
480
  function entryRecency(entry: SkillActiveEntry): number {
486
481
  const stamp = entry.handoff_at || entry.updated_at || entry.activated_at;
487
482
  const ms = stamp ? Date.parse(stamp) : Number.NaN;
@@ -559,94 +554,94 @@ function dedupeVisibleBySkill(entries: SkillActiveEntry[], sessionId?: string):
559
554
 
560
555
  /**
561
556
  * The planning pipeline advances one stage at a time: `deep-interview →
562
- * ralplan → ultragoal`. Each stage is activated through its own command path
563
- * (`skc deep-interview`, `skc ralplan`, `skc ultragoal`), and those activations
564
- * do not demote the previous stage's row — only the explicit `handoff` verb
565
- * does. Without this collapse, activating ultragoal while ralplan is still
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.
557
+ * ralplan → ultragoal`. Activating a downstream stage supersedes upstream
558
+ * stages so stale rows cannot keep owning the HUD, gate, or primary active
559
+ * snapshot. `team` is intentionally excluded — it runs alongside ultragoal —
560
+ * and every non-pipeline skill is left untouched.
576
561
  */
577
562
  const PLANNING_PIPELINE_SKILLS = new Set<string>(["deep-interview", "ralplan", "ultragoal"]);
563
+ const PLANNING_PIPELINE_RANK = new Map<string, number>([
564
+ ["deep-interview", 0],
565
+ ["ralplan", 1],
566
+ ["ultragoal", 2],
567
+ ]);
568
+
569
+ function planningPipelineRank(skill: string): number | undefined {
570
+ return PLANNING_PIPELINE_RANK.get(skill);
571
+ }
572
+
573
+ function comparePipelineEntry(a: SkillActiveEntry, b: SkillActiveEntry): number {
574
+ const aRank = planningPipelineRank(a.skill);
575
+ const bRank = planningPipelineRank(b.skill);
576
+ if (aRank !== undefined || bRank !== undefined) return (bRank ?? -1) - (aRank ?? -1);
577
+ const aRecency = entryRecency(a);
578
+ const bRecency = entryRecency(b);
579
+ if (Number.isFinite(aRecency) || Number.isFinite(bRecency)) return (bRecency || 0) - (aRecency || 0);
580
+ return 0;
581
+ }
582
+
583
+ function upstreamPlanningPipelineSkills(skill: string): string[] {
584
+ const rank = planningPipelineRank(skill);
585
+ if (rank === undefined) return [];
586
+ return [...PLANNING_PIPELINE_RANK.entries()]
587
+ .filter(([, candidateRank]) => candidateRank < rank)
588
+ .map(([candidate]) => candidate);
589
+ }
578
590
 
579
591
  export function collapsePlanningPipeline(entries: readonly SkillActiveEntry[]): SkillActiveEntry[] {
580
592
  const pipeline = entries.filter(entry => PLANNING_PIPELINE_SKILLS.has(entry.skill));
581
593
  if (pipeline.length <= 1) return [...entries];
582
- let current = pipeline[0];
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
- }
594
+ const current = pipeline.toSorted(comparePipelineEntry)[0];
595
595
  return entries.filter(entry => !PLANNING_PIPELINE_SKILLS.has(entry.skill) || entry === current);
596
596
  }
597
597
 
598
598
  async function mergeVisibleEntries(
599
599
  cwd: string,
600
600
  sessionState: SkillActiveState | null,
601
- rootState: SkillActiveState | null,
602
- sessionId?: string,
601
+ sessionId: string,
603
602
  ): Promise<SkillActiveEntry[]> {
604
603
  // Use the raw (active + inactive) rows so a handoff demotion stays visible
605
604
  // long enough to supersede a stale same-skill row before the active filter.
606
605
  // Per-skill files in active/<skill>.json are authoritative and are merged
607
606
  // after the derived snapshot cache, so a stale skill-active-state.json row
608
607
  // cannot override the latest entry file.
609
- const rootEntries = filterRootEntriesForSession(
610
- [...rawActiveEntries(rootState), ...(await readActiveEntries(cwd))],
611
- sessionId,
612
- );
613
- const merged = new Map(rootEntries.map(entry => [entryKey(entry), entry]));
614
- const sessionEntries = sessionId
615
- ? [...rawActiveEntries(sessionState), ...(await readActiveEntries(cwd, { sessionId }))]
616
- : rawActiveEntries(sessionState);
617
- for (const entry of sessionEntries) {
618
- merged.set(entryKey(entry), entry);
619
- }
608
+ const entries = [...rawActiveEntries(sessionState), ...(await readActiveEntries(cwd, { sessionId }))];
609
+ const merged = new Map(entries.map(entry => [entryKey(entry), entry]));
620
610
  const canonicalRalplanPhase = await readModeStatePhase(cwd, sessionId, "ralplan");
621
- return dedupeVisibleBySkill([...merged.values()], sessionId)
611
+ const visibleEntries = dedupeVisibleBySkill([...merged.values()], sessionId)
622
612
  .filter(entry => entry.active !== false)
623
613
  .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase));
614
+ return collapsePlanningPipeline(visibleEntries).toSorted(comparePipelineEntry);
624
615
  }
625
616
 
626
617
  export async function readVisibleSkillActiveState(cwd: string, sessionId?: string): Promise<SkillActiveState | null> {
627
- const { rootPath, sessionPath } = getSkillActiveStatePaths(cwd, sessionId);
628
- const [rootState, sessionState] = await Promise.all([
629
- readRawActiveStateForHandoff(rootPath, false),
630
- sessionPath ? readRawActiveStateForHandoff(sessionPath, false) : Promise.resolve(null),
631
- ]);
632
- const activeSkills = await mergeVisibleEntries(cwd, sessionState, rootState, sessionId);
618
+ let resolvedSessionId: string;
619
+ try {
620
+ resolvedSessionId = await resolveBoundarySessionId(cwd, sessionId);
621
+ } catch (error) {
622
+ if (error instanceof SessionResolutionError && error.code === "no_session") return null;
623
+ throw error;
624
+ }
625
+ const { sessionPath } = getSkillActiveStatePaths(cwd, resolvedSessionId);
626
+ const sessionState = await readRawActiveStateForHandoff(sessionPath, false);
627
+ const activeSkills = await mergeVisibleEntries(cwd, sessionState, resolvedSessionId);
633
628
  if (activeSkills.length === 0) return null;
634
629
  const primary = activeSkills[0];
635
630
  return {
636
- ...(rootState ?? {}),
637
631
  ...(sessionState ?? {}),
638
632
  version: 1,
639
633
  active: true,
640
- skill: primary?.skill ?? "",
641
- phase: primary?.phase ?? "",
642
- session_id: safeString(sessionId).trim() || primary?.session_id,
634
+ skill: sessionState?.skill ?? primary?.skill ?? "",
635
+ phase: sessionState?.phase ?? primary?.phase ?? "",
636
+ session_id: resolvedSessionId,
643
637
  active_skills: activeSkills,
644
638
  active_subskills: activeSkills.flatMap(entry => entry.active_subskills ?? []),
645
639
  };
646
640
  }
647
641
 
648
- function activeStateWriterAudit(verb: string) {
649
- return { category: "state" as const, verb, owner: "skc-runtime" as const };
642
+ function activeStateWriterAudit(verb: string, sessionScope?: ActiveSessionScope | string) {
643
+ const sessionId = typeof sessionScope === "string" ? sessionScope : sessionScope?.sessionId;
644
+ return { category: "state" as const, verb, owner: "skc-runtime" as const, ...(sessionId ? { sessionId } : {}) };
650
645
  }
651
646
 
652
647
  async function persistActiveEntry(
@@ -657,12 +652,13 @@ async function persistActiveEntry(
657
652
  if (entry.active === false) {
658
653
  await removeActiveEntry(cwd, sessionScope, entry.skill, {
659
654
  cwd,
660
- audit: activeStateWriterAudit("remove-active-entry"),
655
+ audit: activeStateWriterAudit("remove-active-entry", sessionScope),
656
+ sourceRevision: entry.source_state_revision,
661
657
  });
662
658
  } else {
663
659
  await writeActiveEntry(cwd, sessionScope, entry.skill, entry, {
664
660
  cwd,
665
- audit: activeStateWriterAudit("write-active-entry"),
661
+ audit: activeStateWriterAudit("write-active-entry", sessionScope),
666
662
  });
667
663
  }
668
664
  }
@@ -674,12 +670,29 @@ async function writeHandoffEntry(
674
670
  ): Promise<void> {
675
671
  await writeActiveEntry(cwd, sessionScope, entry.skill, entry, {
676
672
  cwd,
677
- audit: activeStateWriterAudit("write-active-entry"),
673
+ audit: activeStateWriterAudit("write-active-entry", sessionScope),
678
674
  });
679
675
  }
680
676
 
681
677
  async function rebuildActiveState(cwd: string, sessionScope?: ActiveSessionScope): Promise<void> {
682
- await rebuildActiveSnapshot(cwd, sessionScope, { cwd, audit: activeStateWriterAudit("rebuild-active-snapshot") });
678
+ await rebuildActiveSnapshot(cwd, sessionScope, {
679
+ cwd,
680
+ audit: activeStateWriterAudit("rebuild-active-snapshot", sessionScope),
681
+ });
682
+ }
683
+
684
+ async function removeSupersededPlanningPipelineEntries(
685
+ cwd: string,
686
+ sessionScope: ActiveSessionScope | undefined,
687
+ entry: SkillActiveEntry,
688
+ ): Promise<void> {
689
+ if (entry.active === false) return;
690
+ for (const skill of upstreamPlanningPipelineSkills(entry.skill)) {
691
+ await removeActiveEntry(cwd, sessionScope, skill, {
692
+ cwd,
693
+ audit: activeStateWriterAudit("remove-superseded-pipeline-entry", sessionScope),
694
+ });
695
+ }
683
696
  }
684
697
 
685
698
  async function activeSubskillsForExistingEntry(
@@ -687,18 +700,17 @@ async function activeSubskillsForExistingEntry(
687
700
  sessionId: string | undefined,
688
701
  skill: string,
689
702
  ): Promise<ActiveSubskillEntry[] | undefined> {
690
- const { rootPath, sessionPath } = getSkillActiveStatePaths(cwd, sessionId);
691
- const [rootState, sessionState] = await Promise.all([
692
- readRawActiveStateForHandoff(rootPath, false),
693
- sessionPath ? readRawActiveStateForHandoff(sessionPath, false) : Promise.resolve(null),
694
- ]);
695
- const existing = (await mergeVisibleEntries(cwd, sessionState, rootState, sessionId)).find(
703
+ const resolvedSessionId = await resolveBoundarySessionId(cwd, sessionId);
704
+ const { sessionPath } = getSkillActiveStatePaths(cwd, resolvedSessionId);
705
+ const sessionState = await readRawActiveStateForHandoff(sessionPath, false);
706
+ const existing = (await mergeVisibleEntries(cwd, sessionState, resolvedSessionId)).find(
696
707
  entry => entry.skill === skill,
697
708
  );
698
709
  return existing?.active_subskills;
699
710
  }
700
711
 
701
712
  export async function syncSkillActiveState(options: SyncSkillActiveStateOptions): Promise<void> {
713
+ if (!options.sessionId) return;
702
714
  const preservedActiveSubskills =
703
715
  options.active_subskills === undefined
704
716
  ? await activeSubskillsForExistingEntry(options.cwd, options.sessionId, options.skill)
@@ -724,12 +736,10 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
724
736
  : preservedActiveSubskills
725
737
  ? { active_subskills: preservedActiveSubskills }
726
738
  : {}),
739
+ ...(typeof options.sourceRevision === "number" ? { source_state_revision: options.sourceRevision } : {}),
727
740
  };
728
- await persistActiveEntry(options.cwd, undefined, entry);
729
- await rebuildActiveState(options.cwd);
730
-
731
- if (!options.sessionId) return;
732
741
  const sessionScope = { sessionId: options.sessionId };
742
+ await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry);
733
743
  await persistActiveEntry(options.cwd, sessionScope, entry);
734
744
  await rebuildActiveState(options.cwd, sessionScope);
735
745
  }
@@ -745,36 +755,23 @@ export interface ApplyHandoffOptions {
745
755
  }
746
756
 
747
757
  /**
748
- * Atomically apply a workflow-skill handoff to both the session-scoped and
749
- * root `skill-active-state.json` files in a single write per file.
750
- *
751
- * Write order: **session first, root last**. The session file is the
752
- * source of truth for HUD; the root aggregate must never lead the session
753
- * during a handoff window. Each file is rewritten once with caller demoted
754
- * to `active:false` (preserving `handoff_to`/`handoff_at` lineage) and
755
- * callee promoted to `active:true` (with `handoff_from`/`handoff_at`).
758
+ * Atomically apply a workflow-skill handoff to the session-scoped active state.
756
759
  */
757
760
  export async function applyHandoffToActiveState(options: ApplyHandoffOptions): Promise<void> {
758
761
  const nowIso = options.nowIso ?? new Date().toISOString();
759
762
  const callerEntry = buildSyncEntry(options.caller, nowIso);
760
763
  const calleeEntry = buildSyncEntry(options.callee, nowIso);
761
764
  const sessionId = options.callee.sessionId ?? options.caller.sessionId;
762
- const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId);
765
+ assertNonEmptySkcSessionId(sessionId, "applyHandoffToActiveState");
766
+ const { sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId);
763
767
  const readState = (filePath: string) => readRawActiveStateForHandoff(filePath, options.strict === true);
764
- await Promise.all([readState(rootPath), ...(sessionPath ? [readState(sessionPath)] : [])]);
765
-
766
- // A skill can hold more than one visible row in this session's scope — e.g.
767
- // it was seeded without a session id (rendered globally) and is now handed
768
- // off under a concrete session id. Supersede every same-session-scope row of
769
- // the caller and callee skills, not just the exact `skill::session_id` key,
770
- // so a stale `active:true` row cannot survive the demotion and keep showing
771
- // in the HUD. Rows owned by other sessions are left untouched.
768
+ await readState(sessionPath);
769
+
772
770
  const handoffSession = safeString(sessionId).trim();
773
771
  const reassignedSkills = new Set([callerEntry.skill, calleeEntry.skill]);
774
772
  const supersedesVisible = (entry: SkillActiveEntry): boolean => {
775
773
  if (!reassignedSkills.has(entry.skill)) return false;
776
- const entrySession = safeString(entry.session_id).trim();
777
- return entrySession.length === 0 || entrySession === handoffSession;
774
+ return safeString(entry.session_id).trim() === handoffSession;
778
775
  };
779
776
  const applyEntries = (entries: SkillActiveEntry[]): SkillActiveEntry[] => {
780
777
  const callerKey = entryKey(callerEntry);
@@ -782,9 +779,6 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
782
779
  entries.find(e => entryKey(e) === callerKey) ??
783
780
  entries.find(e => e.skill === callerEntry.skill && supersedesVisible(e) && Boolean(e.handoff_from));
784
781
  const kept = entries.filter(e => !supersedesVisible(e));
785
- // Merge prior lineage into the demoted caller so multi-step handoff
786
- // chains preserve `handoff_from` from the previous transition while
787
- // the new `handoff_to`/`handoff_at` describe this one.
788
782
  const mergedCaller: SkillActiveEntry = priorCaller
789
783
  ? {
790
784
  ...callerEntry,
@@ -799,10 +793,7 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
799
793
  activeSubskills.length > 0 ? { ...calleeEntry, active_subskills: activeSubskills } : calleeEntry;
800
794
  return [...kept, mergedCaller, mergedCallee];
801
795
  };
802
- const writeEntries = async (
803
- sessionScope: ActiveSessionScope | undefined,
804
- prior: SkillActiveState | null,
805
- ): Promise<void> => {
796
+ const writeEntries = async (sessionScope: ActiveSessionScope, prior: SkillActiveState | null): Promise<void> => {
806
797
  const nextEntries = applyEntries(rawActiveEntries(prior));
807
798
  for (const entry of nextEntries) {
808
799
  await writeHandoffEntry(options.cwd, sessionScope, entry);
@@ -810,12 +801,8 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
810
801
  await rebuildActiveState(options.cwd, sessionScope);
811
802
  };
812
803
 
813
- if (sessionPath) {
814
- const prior = await readState(sessionPath);
815
- await writeEntries({ sessionId }, prior);
816
- }
817
- const priorRoot = await readState(rootPath);
818
- await writeEntries(undefined, priorRoot);
804
+ const prior = await readState(sessionPath);
805
+ await writeEntries({ sessionId }, prior);
819
806
  }
820
807
 
821
808
  function buildSyncEntry(options: SyncSkillActiveStateOptions, nowIso: string): SkillActiveEntry {
@@ -0,0 +1,4 @@
1
+ /** Native-free canonical SKC workflow skill identifiers. */
2
+ export const CANONICAL_SKC_WORKFLOW_SKILLS = ["deep-interview", "ralplan", "ultragoal", "team"] as const;
3
+
4
+ export type CanonicalSkcWorkflowSkill = (typeof CANONICAL_SKC_WORKFLOW_SKILLS)[number];