@sayknow-cli/coding-agent 0.2.7 → 0.3.0

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 (138) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/async/job-manager.d.ts +3 -1
  3. package/dist/types/cli/daemon-cli.d.ts +25 -0
  4. package/dist/types/cli/notify-cli.d.ts +23 -0
  5. package/dist/types/cli/setup-cli.d.ts +20 -1
  6. package/dist/types/commands/daemon.d.ts +41 -0
  7. package/dist/types/commands/notify.d.ts +41 -0
  8. package/dist/types/config/model-profile-activation.d.ts +12 -0
  9. package/dist/types/config/model-profiles.d.ts +2 -1
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +38 -0
  13. package/dist/types/coordinator/contract.d.ts +1 -1
  14. package/dist/types/daemon/builtin.d.ts +20 -0
  15. package/dist/types/daemon/control-types.d.ts +57 -0
  16. package/dist/types/daemon/runtime.d.ts +25 -0
  17. package/dist/types/defaults/skc-defaults.d.ts +13 -2
  18. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  19. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  20. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  21. package/dist/types/modes/interactive-mode.d.ts +1 -1
  22. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  23. package/dist/types/modes/types.d.ts +7 -1
  24. package/dist/types/notifications/config-commands.d.ts +26 -0
  25. package/dist/types/notifications/config.d.ts +61 -0
  26. package/dist/types/notifications/helpers.d.ts +55 -0
  27. package/dist/types/notifications/html-format.d.ts +62 -0
  28. package/dist/types/notifications/index.d.ts +28 -0
  29. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  30. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  31. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  32. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  33. package/dist/types/notifications/telegram-daemon.d.ts +276 -0
  34. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  35. package/dist/types/notifications/threaded-inbound.d.ts +58 -0
  36. package/dist/types/notifications/threaded-render.d.ts +66 -0
  37. package/dist/types/notifications/topic-registry.d.ts +67 -0
  38. package/dist/types/rlm/index.d.ts +12 -0
  39. package/dist/types/session/agent-session.d.ts +39 -2
  40. package/dist/types/session/auth-storage.d.ts +1 -1
  41. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  42. package/dist/types/setup/credential-import.d.ts +3 -0
  43. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  44. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  45. package/dist/types/skc-runtime/state-writer.d.ts +2 -0
  46. package/dist/types/skc-runtime/tmux-common.d.ts +3 -0
  47. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  48. package/dist/types/skc-runtime/ultragoal-guard.d.ts +15 -0
  49. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +14 -0
  50. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  51. package/dist/types/tools/index.d.ts +18 -0
  52. package/dist/types/tools/subagent.d.ts +3 -0
  53. package/package.json +7 -7
  54. package/scripts/build-binary.ts +3 -0
  55. package/src/async/job-manager.ts +5 -1
  56. package/src/cli/daemon-cli.ts +122 -0
  57. package/src/cli/notify-cli.ts +274 -0
  58. package/src/cli/setup-cli.ts +173 -84
  59. package/src/cli.ts +3 -3
  60. package/src/commands/daemon.ts +47 -0
  61. package/src/commands/notify.ts +61 -0
  62. package/src/commands/setup.ts +11 -1
  63. package/src/config/model-profile-activation.ts +74 -5
  64. package/src/config/model-profiles.ts +7 -4
  65. package/src/config/model-registry.ts +6 -3
  66. package/src/config/models-config-schema.ts +1 -1
  67. package/src/config/settings-schema.ts +29 -0
  68. package/src/coordinator/contract.ts +3 -0
  69. package/src/coordinator-mcp/server.ts +270 -1
  70. package/src/daemon/builtin.ts +46 -0
  71. package/src/daemon/control-types.ts +65 -0
  72. package/src/daemon/runtime.ts +51 -0
  73. package/src/defaults/skc/rules/ponytail.md +68 -0
  74. package/src/defaults/skc/skills/ultragoal/SKILL.md +16 -0
  75. package/src/defaults/skc-defaults.ts +27 -2
  76. package/src/edit/modes/replace.ts +1 -1
  77. package/src/extensibility/extensions/runner.ts +4 -0
  78. package/src/extensibility/extensions/types.ts +8 -0
  79. package/src/goals/tools/goal-tool.ts +11 -2
  80. package/src/hashline/hash.ts +1 -1
  81. package/src/internal-urls/docs-index.generated.ts +10 -8
  82. package/src/main.ts +30 -0
  83. package/src/modes/acp/acp-event-mapper.ts +1 -0
  84. package/src/modes/components/hook-editor.ts +7 -2
  85. package/src/modes/components/oauth-selector.ts +19 -0
  86. package/src/modes/controllers/event-controller.ts +20 -0
  87. package/src/modes/controllers/selector-controller.ts +80 -17
  88. package/src/modes/interactive-mode.ts +6 -2
  89. package/src/modes/runtime-init.ts +1 -0
  90. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  91. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  92. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  93. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  94. package/src/modes/types.ts +7 -1
  95. package/src/modes/utils/ui-helpers.ts +23 -0
  96. package/src/notifications/config-commands.ts +50 -0
  97. package/src/notifications/config.ts +107 -0
  98. package/src/notifications/helpers.ts +135 -0
  99. package/src/notifications/html-format.ts +389 -0
  100. package/src/notifications/index.ts +700 -0
  101. package/src/notifications/rate-limit-pool.ts +179 -0
  102. package/src/notifications/telegram-cli.ts +194 -0
  103. package/src/notifications/telegram-daemon-cli.ts +74 -0
  104. package/src/notifications/telegram-daemon-control.ts +370 -0
  105. package/src/notifications/telegram-daemon.ts +1370 -0
  106. package/src/notifications/telegram-reference.ts +335 -0
  107. package/src/notifications/threaded-inbound.ts +80 -0
  108. package/src/notifications/threaded-render.ts +155 -0
  109. package/src/notifications/topic-registry.ts +133 -0
  110. package/src/rlm/index.ts +19 -0
  111. package/src/sdk.ts +16 -0
  112. package/src/session/agent-session.ts +113 -3
  113. package/src/session/auth-storage.ts +3 -0
  114. package/src/session/session-dump-format.ts +43 -2
  115. package/src/session/session-manager.ts +39 -5
  116. package/src/setup/credential-auto-import.ts +258 -0
  117. package/src/setup/credential-import.ts +17 -0
  118. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  119. package/src/setup/host-plugin-setup.ts +142 -0
  120. package/src/skc-runtime/deep-interview-recorder.ts +2 -2
  121. package/src/skc-runtime/launch-tmux.ts +10 -2
  122. package/src/skc-runtime/state-runtime.ts +18 -10
  123. package/src/skc-runtime/state-writer.ts +8 -8
  124. package/src/skc-runtime/tmux-common.ts +8 -0
  125. package/src/skc-runtime/tmux-sessions.ts +8 -1
  126. package/src/skc-runtime/ultragoal-guard.ts +57 -2
  127. package/src/skc-runtime/ultragoal-runtime.ts +105 -19
  128. package/src/skc-runtime/workflow-manifest.generated.json +27 -2
  129. package/src/skc-runtime/workflow-manifest.ts +11 -1
  130. package/src/slash-commands/builtin-registry.ts +4 -1
  131. package/src/task/executor.ts +5 -1
  132. package/src/tools/ask-answer-registry.ts +25 -0
  133. package/src/tools/ask.ts +77 -6
  134. package/src/tools/image-gen.ts +5 -8
  135. package/src/tools/index.ts +19 -0
  136. package/src/tools/inspect-image.ts +16 -11
  137. package/src/tools/subagent-render.ts +7 -0
  138. package/src/tools/subagent.ts +38 -7
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Host plugin setup for `skc setup claude` and `skc setup codex`.
3
+ *
4
+ * Renders install guidance and a fail-closed coordinator MCP config preview for
5
+ * the canonical generated plugin bundle under `plugins/`. This is intentionally
6
+ * render-only and fail-closed: the workdir allowlist is scoped to the project
7
+ * root and no mutation class is enabled until the user opts in.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { getProjectDir } from "@sayknow-cli/utils";
13
+
14
+ export type HostPluginKind = "claude" | "codex";
15
+
16
+ export interface HostPluginSetupFlags {
17
+ json?: boolean;
18
+ check?: boolean;
19
+ root?: string[];
20
+ repo?: string;
21
+ }
22
+
23
+ export interface HostPluginSetupResult {
24
+ ok: true;
25
+ host: HostPluginKind;
26
+ mode: "render";
27
+ gated: boolean;
28
+ pluginPath: string;
29
+ manifestPath: string;
30
+ marketplacePath: string;
31
+ installGuidance: string[];
32
+ coordinatorConfigPreview: {
33
+ command: string;
34
+ args: string[];
35
+ env: Record<string, string>;
36
+ };
37
+ mutationPolicy: string;
38
+ notes: string[];
39
+ check?: { ok: boolean; checked: string[]; missing: string[] };
40
+ }
41
+
42
+ const NAMESPACE_LABEL = "sayknow-cli-plugin";
43
+
44
+ function resolveProjectRoot(flags: HostPluginSetupFlags): string {
45
+ const explicit = flags.root?.find(root => root.trim().length > 0);
46
+ return explicit ? path.resolve(explicit) : getProjectDir();
47
+ }
48
+
49
+ function verifyBundleFiles(files: string[]): { ok: boolean; checked: string[]; missing: string[] } {
50
+ const missing = files.filter(file => !fs.existsSync(file));
51
+ return { ok: missing.length === 0, checked: files, missing };
52
+ }
53
+
54
+ export function buildHostPluginSetup(host: HostPluginKind, flags: HostPluginSetupFlags = {}): HostPluginSetupResult {
55
+ const projectRoot = resolveProjectRoot(flags);
56
+ const marketplaceRoot = path.join(projectRoot, "plugins");
57
+ const pluginDir = path.join(marketplaceRoot, "sayknow-cli");
58
+ const repo = flags.repo && flags.repo.trim().length > 0 ? flags.repo.trim() : NAMESPACE_LABEL;
59
+
60
+ // Concrete, fail-closed env: workdir allowlist is the project root, no mutations.
61
+ const env: Record<string, string> = {
62
+ SKC_COORDINATOR_MCP_WORKDIR_ROOTS: projectRoot,
63
+ SKC_COORDINATOR_MCP_REPO: repo,
64
+ SKC_COORDINATOR_MCP_SESSION_COMMAND: "skc --worktree",
65
+ };
66
+
67
+ if (host === "claude") {
68
+ const manifestPath = path.join(pluginDir, ".claude-plugin", "plugin.json");
69
+ const marketplacePath = path.join(marketplaceRoot, ".claude-plugin", "marketplace.json");
70
+ return {
71
+ ok: true,
72
+ host,
73
+ mode: "render",
74
+ gated: false,
75
+ pluginPath: marketplaceRoot,
76
+ manifestPath,
77
+ marketplacePath,
78
+ installGuidance: [
79
+ `Add the local marketplace: /plugin marketplace add ${marketplaceRoot}`,
80
+ "Install the plugin: /plugin install sayknow-cli",
81
+ "Then call skc_delegate_plan / skc_delegate_execute / skc_delegate_team from Claude Code.",
82
+ ],
83
+ coordinatorConfigPreview: { command: "skc", args: ["mcp-serve", "coordinator"], env },
84
+ mutationPolicy:
85
+ "Fail-closed: delegation is read-only until you set SKC_COORDINATOR_MCP_MUTATIONS=sessions and pass allow_mutation:true per call.",
86
+ notes: [],
87
+ ...(flags.check
88
+ ? { check: verifyBundleFiles([manifestPath, marketplacePath, path.join(pluginDir, ".mcp.json")]) }
89
+ : {}),
90
+ };
91
+ }
92
+
93
+ // Codex: verified installable on Codex CLI 0.139.0 via the local marketplace smoke.
94
+ const manifestPath = path.join(pluginDir, ".codex-plugin", "plugin.json");
95
+ const marketplacePath = path.join(marketplaceRoot, ".agents", "plugins", "marketplace.json");
96
+ return {
97
+ ok: true,
98
+ host,
99
+ mode: "render",
100
+ gated: false,
101
+ pluginPath: marketplaceRoot,
102
+ manifestPath,
103
+ marketplacePath,
104
+ installGuidance: [
105
+ `Add the local marketplace: codex plugin marketplace add ${marketplaceRoot}`,
106
+ "Install the plugin: codex plugin add sayknow-cli@sayknow-cli-local",
107
+ "Then call skc_delegate_plan / skc_delegate_execute / skc_delegate_team from Codex.",
108
+ ],
109
+ coordinatorConfigPreview: { command: "skc", args: ["mcp-serve", "coordinator"], env },
110
+ mutationPolicy:
111
+ "Fail-closed: delegation is read-only until you set SKC_COORDINATOR_MCP_MUTATIONS=sessions and pass allow_mutation:true per call.",
112
+ notes: [
113
+ "Verified on Codex CLI 0.139.0: marketplace add + plugin add install the plugin (enabled) and `codex mcp list` registers skc-coordinator with the fail-closed env.",
114
+ "The bundled .codex.mcp.json workdir root is host-neutral; `skc setup codex` renders a concrete root, and operators should re-run the local marketplace smoke on their target Codex version.",
115
+ ],
116
+ ...(flags.check
117
+ ? {
118
+ check: verifyBundleFiles([
119
+ manifestPath,
120
+ marketplacePath,
121
+ path.join(pluginDir, ".codex.mcp.json"),
122
+ path.join(pluginDir, "skills", "skc-delegation", "SKILL.md"),
123
+ ]),
124
+ }
125
+ : {}),
126
+ };
127
+ }
128
+
129
+ export function formatHostPluginSetup(result: HostPluginSetupResult): string {
130
+ const lines: string[] = [];
131
+ lines.push(`host: ${result.host}${result.gated ? " (gated on versioned smoke)" : ""}`);
132
+ lines.push(`plugin: ${result.pluginPath}`);
133
+ lines.push("install:");
134
+ for (const step of result.installGuidance) lines.push(` - ${step}`);
135
+ lines.push(`mcp: ${result.coordinatorConfigPreview.command} ${result.coordinatorConfigPreview.args.join(" ")}`);
136
+ lines.push(
137
+ ` SKC_COORDINATOR_MCP_WORKDIR_ROOTS=${result.coordinatorConfigPreview.env.SKC_COORDINATOR_MCP_WORKDIR_ROOTS}`,
138
+ );
139
+ lines.push(result.mutationPolicy);
140
+ for (const note of result.notes) lines.push(`note: ${note}`);
141
+ return lines.join("\n");
142
+ }
@@ -362,8 +362,8 @@ async function syncRecorderHud(
362
362
  phase,
363
363
  sessionId,
364
364
  source: "skc-runtime-deep-interview-recorder",
365
- hud: deriveDeepInterviewHud(envelope as Record<string, unknown>, { phase }),
366
- sourceRevision: existingStateRevision(envelope),
365
+ hud: deriveDeepInterviewHud(normalizeDeepInterviewEnvelope(envelope) as Record<string, unknown>, { phase }),
366
+ sourceRevision: (existingStateRevision(envelope) ?? 0) + 1,
367
367
  });
368
368
  }
369
369
 
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import * as path from "node:path";
3
3
  import { safeStderrWrite } from "@sayknow-cli/utils";
4
+ import { VERSION } from "@sayknow-cli/utils/dirs";
4
5
  import type { Args } from "../cli/args";
5
6
  import { tmuxRuntimeSessionPath } from "./session-layout";
6
7
  import { SKC_COORDINATOR_SESSION_ID_ENV, SKC_COORDINATOR_SESSION_STATE_FILE_ENV } from "./session-state-sidecar";
@@ -16,7 +17,7 @@ import {
16
17
  SKC_TMUX_SESSION_PREFIX,
17
18
  type SkcTmuxProfileCommand,
18
19
  } from "./tmux-common";
19
- import { findSkcTmuxSessionByName, findSkcTmuxSessionByScope } from "./tmux-sessions";
20
+ import { findSkcTmuxSessionByName, findSkcTmuxSessionByScope, type SkcTmuxSessionStatus } from "./tmux-sessions";
20
21
 
21
22
  export {
22
23
  buildSkcTmuxProfileCommands,
@@ -88,6 +89,9 @@ export interface TmuxLaunchPlan {
88
89
  function explicitTmuxSessionName(env: NodeJS.ProcessEnv): string | undefined {
89
90
  return env.SKC_TMUX_SESSION?.trim() || undefined;
90
91
  }
92
+ function hasCurrentSkcVersion(session: SkcTmuxSessionStatus | undefined): boolean {
93
+ return session?.version === VERSION;
94
+ }
91
95
 
92
96
  function findExistingSessionForLaunch(context: {
93
97
  env: NodeJS.ProcessEnv;
@@ -96,7 +100,8 @@ function findExistingSessionForLaunch(context: {
96
100
  }): string | undefined {
97
101
  const explicit = explicitTmuxSessionName(context.env);
98
102
  if (explicit) return findSkcTmuxSessionByName(explicit, context.env)?.name;
99
- return findSkcTmuxSessionByScope(context.project, context.branch, context.env)?.name;
103
+ const scoped = findSkcTmuxSessionByScope(context.project, context.branch, context.env);
104
+ return hasCurrentSkcVersion(scoped) ? scoped?.name : undefined;
100
105
  }
101
106
 
102
107
  export interface SkcTmuxProfileResult {
@@ -116,6 +121,7 @@ export interface SkcTmuxProfileContext {
116
121
  project?: string | null;
117
122
  sessionId?: string | null;
118
123
  sessionStateFile?: string | null;
124
+ version?: string | null;
119
125
  }
120
126
 
121
127
  interface CommandResolutionContext {
@@ -195,6 +201,7 @@ export function applySkcTmuxProfile(context: SkcTmuxProfileContext): SkcTmuxProf
195
201
  project: context.project ?? null,
196
202
  sessionId: context.sessionId ?? env[SKC_COORDINATOR_SESSION_ID_ENV] ?? null,
197
203
  sessionStateFile: context.sessionStateFile ?? env[SKC_COORDINATOR_SESSION_STATE_FILE_ENV] ?? null,
204
+ version: context.version ?? null,
198
205
  });
199
206
  if (commands.length === 0) return { skipped: true, commands: [], failures: [] };
200
207
  const spawnSync = context.spawnSync ?? defaultSpawnSync;
@@ -457,6 +464,7 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
457
464
  project: plan.project,
458
465
  sessionId: plan.sessionId ?? null,
459
466
  sessionStateFile: plan.sessionStateFile ?? null,
467
+ version: VERSION,
460
468
  });
461
469
  const ownershipFailure = profile.failures.find(item => item.command.args.includes("@skc-profile"));
462
470
  if (ownershipFailure) {
@@ -818,7 +818,7 @@ async function writeJsonAtomic(
818
818
  toPhase?: string;
819
819
  owner?: WorkflowStateMutationOwner;
820
820
  },
821
- ): Promise<{ warning?: string; stamped: Record<string, unknown> }> {
821
+ ): Promise<{ warning?: string; stamped: Record<string, unknown>; revision: number }> {
822
822
  const warning = options?.skill
823
823
  ? await warnAndAuditOutOfBandIfNeeded(cwd, options.sessionId, filePath, options.skill, {
824
824
  mutationId: options.mutationId,
@@ -832,7 +832,7 @@ async function writeJsonAtomic(
832
832
  // writer lock; do not enforce an optimistic `expectedRevision` here (tamper
833
833
  // detection is handled by warnAndAuditOutOfBandIfNeeded above, and a forced
834
834
  // write must succeed over corrupt/missing prior state).
835
- await writeGuardedWorkflowEnvelopeAtomic(filePath, value, {
835
+ const writeResult = await writeGuardedWorkflowEnvelopeAtomic(filePath, value, {
836
836
  cwd,
837
837
  policy: "source",
838
838
  audit: {
@@ -847,7 +847,11 @@ async function writeJsonAtomic(
847
847
  forced: options?.force ?? false,
848
848
  },
849
849
  });
850
- return { warning, stamped: (await readJsonFile(filePath)) ?? {} };
850
+ // `writeResult.revision` is computed inside the writer lock, so it is the revision this
851
+ // write actually owns. Prefer it over a post-lock file re-read, which a concurrent writer
852
+ // could have advanced before the read — that race could otherwise let this payload be
853
+ // published with another writer's newer revision.
854
+ return { warning, stamped: (await readJsonFile(filePath)) ?? {}, revision: writeResult.revision };
851
855
  }
852
856
 
853
857
  function parseFieldsFlag(args: readonly string[]): StateProjectionField[] | undefined {
@@ -1399,7 +1403,11 @@ async function handleWrite(
1399
1403
  const validation = validateWorkflowStateEnvelope(mode, merged);
1400
1404
  if (!validation.valid) throw new StateCommandError(2, validation.error ?? `invalid ${mode} state envelope`);
1401
1405
 
1402
- const { warning: outOfBandWarning, stamped } = await writeJsonAtomic(cwd, filePath, merged, "write", {
1406
+ const {
1407
+ warning: outOfBandWarning,
1408
+ stamped,
1409
+ revision: stampedRevision,
1410
+ } = await writeJsonAtomic(cwd, filePath, merged, "write", {
1403
1411
  sessionId,
1404
1412
  skill: mode,
1405
1413
  mutationId,
@@ -1411,12 +1419,12 @@ async function handleWrite(
1411
1419
 
1412
1420
  const phase = typeof merged.current_phase === "string" ? merged.current_phase : undefined;
1413
1421
  const active = merged.active !== false;
1414
- // Reflect the freshly stamped mode-state revision onto the in-memory payload so the
1415
- // active-state/HUD sync derives a `sourceRevision` from the persisted revision, not the
1416
- // stale pre-write value; otherwise the active-state writer stale-skips the update and the
1417
- // mirror keeps the prior phase (e.g. staying "interviewing" after a "handoff" write).
1418
- const stampedRevision = existingStateRevision(stamped);
1419
- if (typeof stampedRevision === "number") merged.state_revision = stampedRevision;
1422
+ // Reflect the lock-owned mode-state revision onto the in-memory payload so the active-state/HUD
1423
+ // sync derives a `sourceRevision` from the revision this write actually owns (computed inside the
1424
+ // writer lock), not the stale pre-write value or a post-lock re-read a concurrent writer could
1425
+ // have advanced; otherwise the active-state writer stale-skips the update and the mirror keeps the
1426
+ // prior phase (e.g. staying "interviewing" after a "handoff" write).
1427
+ merged.state_revision = stampedRevision;
1420
1428
  await syncWorkflowSkillState({ cwd, mode, sessionId, threadId, turnId, active, phase, payload: merged, receipt });
1421
1429
  await touchStateActivityMarker(cwd, sessionId, filePath);
1422
1430
 
@@ -99,8 +99,8 @@ export interface GuardedStateWriterOptions extends StateWriterOptions {
99
99
  }
100
100
 
101
101
  export type GuardedWriteResult =
102
- | { path: string; written: true }
103
- | { path: string; written: false; reason: "stale-skip" };
102
+ | { path: string; written: true; revision: number }
103
+ | { path: string; written: false; reason: "stale-skip"; revision: number };
104
104
 
105
105
  export interface StateWriterOptions {
106
106
  cwd?: string;
@@ -511,13 +511,13 @@ async function writeGuardedResolvedJsonAtomic(
511
511
  const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
512
512
  await atomicWrite(filePath, jsonText(next));
513
513
  await maybeAudit(filePath, options);
514
- return { path: filePath, written: true };
514
+ return { path: filePath, written: true, revision: currentRevision + 1 };
515
515
  }
516
516
 
517
517
  const incomingSourceRevision =
518
518
  options.sourceRevision ?? (isPlainObject(value) ? persistedStateRevision(value) : 0);
519
519
  if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
520
- return { path: filePath, written: false, reason: "stale-skip" };
520
+ return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
521
521
  }
522
522
  const next = stampStateRevision(
523
523
  withWorkflowReceipt(value, buildReceipt(options)),
@@ -526,7 +526,7 @@ async function writeGuardedResolvedJsonAtomic(
526
526
  );
527
527
  await atomicWrite(filePath, jsonText(next));
528
528
  await maybeAudit(filePath, options);
529
- return { path: filePath, written: true };
529
+ return { path: filePath, written: true, revision: currentRevision + 1 };
530
530
  },
531
531
  options.lock,
532
532
  );
@@ -574,13 +574,13 @@ export async function writeGuardedWorkflowEnvelopeAtomic(
574
574
  }
575
575
  await atomicWrite(filePath, jsonText(next));
576
576
  await maybeAudit(filePath, options);
577
- return { path: filePath, written: true };
577
+ return { path: filePath, written: true, revision: currentRevision + 1 };
578
578
  }
579
579
 
580
580
  const incomingSourceRevision =
581
581
  options.sourceRevision ?? (isPlainObject(value) ? persistedStateRevision(value) : 0);
582
582
  if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
583
- return { path: filePath, written: false, reason: "stale-skip" };
583
+ return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
584
584
  }
585
585
  const next = stampWorkflowEnvelopeRevisionAndChecksum(
586
586
  value,
@@ -599,7 +599,7 @@ export async function writeGuardedWorkflowEnvelopeAtomic(
599
599
  }
600
600
  await atomicWrite(filePath, jsonText(next));
601
601
  await maybeAudit(filePath, options);
602
- return { path: filePath, written: true };
602
+ return { path: filePath, written: true, revision: currentRevision + 1 };
603
603
  },
604
604
  options.lock,
605
605
  );
@@ -10,6 +10,7 @@ export const SKC_TMUX_BRANCH_SLUG_OPTION = "@skc-branch-slug";
10
10
  export const SKC_TMUX_PROJECT_OPTION = "@skc-project";
11
11
  export const SKC_TMUX_SESSION_ID_OPTION = "@skc-session-id";
12
12
  export const SKC_TMUX_SESSION_STATE_FILE_OPTION = "@skc-session-state-file";
13
+ export const SKC_TMUX_VERSION_OPTION = "@skc-version";
13
14
 
14
15
  export interface SkcTmuxProfileCommand {
15
16
  description: string;
@@ -101,6 +102,7 @@ export function buildSkcTmuxRequiredProfileCommands(
101
102
  project?: string | null;
102
103
  sessionId?: string | null;
103
104
  sessionStateFile?: string | null;
105
+ version?: string | null;
104
106
  } = {},
105
107
  ): SkcTmuxProfileCommand[] {
106
108
  const commands: SkcTmuxProfileCommand[] = [
@@ -134,6 +136,11 @@ export function buildSkcTmuxRequiredProfileCommands(
134
136
  description: "record SKC session state marker",
135
137
  args: ["set-option", "-t", target, SKC_TMUX_SESSION_STATE_FILE_OPTION, metadata.sessionStateFile],
136
138
  });
139
+ if (metadata.version)
140
+ commands.push({
141
+ description: "record SKC version identity",
142
+ args: ["set-option", "-t", target, SKC_TMUX_VERSION_OPTION, metadata.version],
143
+ });
137
144
  return commands;
138
145
  }
139
146
 
@@ -146,6 +153,7 @@ export function buildSkcTmuxProfileCommands(
146
153
  project?: string | null;
147
154
  sessionId?: string | null;
148
155
  sessionStateFile?: string | null;
156
+ version?: string | null;
149
157
  } = {},
150
158
  ): SkcTmuxProfileCommand[] {
151
159
  const commands = buildSkcTmuxRequiredProfileCommands(target, metadata);
@@ -12,6 +12,7 @@ import {
12
12
  SKC_TMUX_PROJECT_OPTION,
13
13
  SKC_TMUX_SESSION_ID_OPTION,
14
14
  SKC_TMUX_SESSION_STATE_FILE_OPTION,
15
+ SKC_TMUX_VERSION_OPTION,
15
16
  } from "./tmux-common";
16
17
 
17
18
  export interface SkcTmuxSessionStatus {
@@ -26,6 +27,7 @@ export interface SkcTmuxSessionStatus {
26
27
  project?: string;
27
28
  sessionId?: string;
28
29
  sessionStateFile?: string;
30
+ version?: string;
29
31
  panePids: number[];
30
32
  profile?: string;
31
33
  }
@@ -37,6 +39,7 @@ export interface SkcTmuxSessionTagsForGc {
37
39
  branchSlug?: string;
38
40
  sessionId?: string;
39
41
  sessionStateFile?: string;
42
+ version?: string;
40
43
  createdAt?: string;
41
44
  attached?: boolean;
42
45
  panePids?: number[];
@@ -86,6 +89,7 @@ function parseSessionLine(line: string): SkcTmuxSessionStatus | null {
86
89
  project = "",
87
90
  sessionId = "",
88
91
  sessionStateFile = "",
92
+ version = "",
89
93
  ] = line.split("\t");
90
94
  if (!name) return null;
91
95
  return {
@@ -105,6 +109,7 @@ function parseSessionLine(line: string): SkcTmuxSessionStatus | null {
105
109
  profile: profile || undefined,
106
110
  sessionId: sessionId || undefined,
107
111
  sessionStateFile: sessionStateFile || undefined,
112
+ version: version || undefined,
108
113
  };
109
114
  }
110
115
 
@@ -131,7 +136,7 @@ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env):
131
136
 
132
137
  function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
133
138
  return runListSessions(
134
- `#{session_name}\t#{session_windows}\t#{session_attached}\t#{session_created}\t#{${SKC_TMUX_PROFILE_OPTION}}\t#{session_key_table}\t#{session_panes}\t#{pane_pid}\t#{${SKC_TMUX_BRANCH_OPTION}}\t#{${SKC_TMUX_BRANCH_SLUG_OPTION}}\t#{${SKC_TMUX_PROJECT_OPTION}}\t#{${SKC_TMUX_SESSION_ID_OPTION}}\t#{${SKC_TMUX_SESSION_STATE_FILE_OPTION}}`,
139
+ `#{session_name}\t#{session_windows}\t#{session_attached}\t#{session_created}\t#{${SKC_TMUX_PROFILE_OPTION}}\t#{session_key_table}\t#{session_panes}\t#{pane_pid}\t#{${SKC_TMUX_BRANCH_OPTION}}\t#{${SKC_TMUX_BRANCH_SLUG_OPTION}}\t#{${SKC_TMUX_PROJECT_OPTION}}\t#{${SKC_TMUX_SESSION_ID_OPTION}}\t#{${SKC_TMUX_SESSION_STATE_FILE_OPTION}}\t#{${SKC_TMUX_VERSION_OPTION}}`,
135
140
  env,
136
141
  );
137
142
  }
@@ -265,6 +270,7 @@ function hydrateSessionFromExactOptions(session: SkcTmuxSessionStatus, env: Node
265
270
  sessionId: session.sessionId ?? readExactOptionForGc(session.name, SKC_TMUX_SESSION_ID_OPTION, env),
266
271
  sessionStateFile:
267
272
  session.sessionStateFile ?? readExactOptionForGc(session.name, SKC_TMUX_SESSION_STATE_FILE_OPTION, env),
273
+ version: session.version ?? readExactOptionForGc(session.name, SKC_TMUX_VERSION_OPTION, env),
268
274
  };
269
275
  }
270
276
 
@@ -281,6 +287,7 @@ export function readTmuxSessionTagsForGc(
281
287
  branchSlug: readExactOptionForGc(sessionName, SKC_TMUX_BRANCH_SLUG_OPTION, env),
282
288
  sessionId: readExactOptionForGc(sessionName, SKC_TMUX_SESSION_ID_OPTION, env),
283
289
  sessionStateFile: readExactOptionForGc(sessionName, SKC_TMUX_SESSION_STATE_FILE_OPTION, env),
290
+ version: readExactOptionForGc(sessionName, SKC_TMUX_VERSION_OPTION, env),
284
291
  createdAt: session?.createdAt,
285
292
  attached: session?.attached,
286
293
  panePids: session?.panePids,
@@ -288,14 +288,15 @@ export function validateCompletionReceipt(input: {
288
288
  export async function readUltragoalVerificationState(input: {
289
289
  cwd: string;
290
290
  currentGoal?: CurrentGoalLike | null;
291
+ sessionId?: string | null;
291
292
  }): Promise<UltragoalGuardDiagnostic> {
292
293
  const currentObjective = input.currentGoal?.objective?.trim() ?? "";
293
294
  if (!currentObjective) return { state: "inactive", message: "No current goal objective is active." };
294
295
  let plan: UltragoalPlan | null;
295
296
  let ledger: UltragoalLedgerEvent[];
296
297
  try {
297
- plan = await readUltragoalPlan(input.cwd);
298
- ledger = await readUltragoalLedger(input.cwd);
298
+ plan = await readUltragoalPlan(input.cwd, input.sessionId ?? undefined);
299
+ ledger = await readUltragoalLedger(input.cwd, input.sessionId ?? undefined);
299
300
  } catch (error) {
300
301
  if (currentObjective === DEFAULT_ULTRAGOAL_OBJECTIVE) {
301
302
  return {
@@ -479,6 +480,7 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
479
480
  export async function assertCanCompleteCurrentGoal(input: {
480
481
  cwd: string;
481
482
  currentGoal?: CurrentGoalLike | null;
483
+ sessionId?: string | null;
482
484
  }): Promise<void> {
483
485
  if (!input.cwd) return;
484
486
  const diagnostic = await readUltragoalVerificationState(input);
@@ -496,3 +498,56 @@ export function isUltragoalBypassPrompt(prompt: string): boolean {
496
498
  ) || /goal[\s\S]{0,80}complete/i.test(normalized)
497
499
  );
498
500
  }
501
+ export interface UltragoalPauseBlockDiagnostic {
502
+ blocked: boolean;
503
+ reason: string;
504
+ }
505
+
506
+ /**
507
+ * While an Ultragoal run is active, `goal({"op":"pause"})` is only allowed when the
508
+ * current durable Ultragoal state is readable and the latest durable ledger event
509
+ * classifies the current blocker as `human_blocked`. Resolvable blockers must be
510
+ * worked, not parked. Reads fail closed so unreadable durable state or ledger data
511
+ * blocks pause rather than silently allowing a give-up.
512
+ */
513
+ export async function isUltragoalPauseBlocked(cwd: string): Promise<UltragoalPauseBlockDiagnostic> {
514
+ if (!cwd) return { blocked: false, reason: "No cwd to resolve durable Ultragoal state." };
515
+ const ask = await isUltragoalAskBlocked(cwd);
516
+ if (ask.source === "durable_state_unreadable") {
517
+ return {
518
+ blocked: true,
519
+ reason: `Unable to verify current durable Ultragoal state for pause: ${ask.reason}`,
520
+ };
521
+ }
522
+ if (!ask.active) return { blocked: false, reason: "No active Ultragoal run." };
523
+ let ledger: UltragoalLedgerEvent[];
524
+ try {
525
+ ledger = await readUltragoalLedger(cwd);
526
+ } catch (error) {
527
+ return {
528
+ blocked: true,
529
+ reason: `Unable to read durable Ultragoal ledger: ${error instanceof Error ? error.message : String(error)}`,
530
+ };
531
+ }
532
+ const latest = ledger.at(-1);
533
+ if (latest?.event === "blocker_classified" && latest.classification === "human_blocked") {
534
+ return { blocked: false, reason: "Latest Ultragoal ledger event classifies the blocker as human_blocked." };
535
+ }
536
+ return {
537
+ blocked: true,
538
+ reason:
539
+ "An Ultragoal run is active. Pausing requires the current blocker to be classified human_blocked as the latest ledger event.",
540
+ };
541
+ }
542
+
543
+ export async function assertUltragoalPauseAllowed(cwd: string): Promise<void> {
544
+ const diagnostic = await isUltragoalPauseBlocked(cwd);
545
+ if (!diagnostic.blocked) return;
546
+ throw new Error(
547
+ [
548
+ diagnostic.reason,
549
+ "Resolvable blockers must be worked, not paused: investigate, `skc ultragoal steer --kind add_subgoal`, delegate an executor, or `skc ultragoal record-review-blockers`.",
550
+ 'If the blocker is genuinely human-only, record `skc ultragoal classify-blocker --classification human_blocked --evidence "<human-only dependency>"` immediately before pausing.',
551
+ ].join("\n"),
552
+ );
553
+ }