@sayknow-cli/coding-agent 0.5.10 → 0.5.13

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 (31) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/config/file-lock.d.ts +8 -0
  3. package/dist/types/config/model-profile-activation.d.ts +3 -1
  4. package/dist/types/config/model-registry.d.ts +3 -0
  5. package/dist/types/config/models-config-schema.d.ts +2 -0
  6. package/dist/types/session-import/redact.d.ts +1 -1
  7. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +13 -1
  8. package/dist/types/skc-runtime/ultragoal-succession.d.ts +251 -0
  9. package/dist/types/skc-runtime/workflow-placeholder.d.ts +6 -0
  10. package/dist/types/tools/ask.d.ts +460 -172
  11. package/package.json +7 -7
  12. package/src/cli/telegram-cli.ts +1 -1
  13. package/src/config/file-lock-gc.ts +39 -2
  14. package/src/config/file-lock.ts +22 -1
  15. package/src/config/model-profile-activation.ts +7 -11
  16. package/src/config/model-registry.ts +192 -44
  17. package/src/config/model-resolver.ts +4 -2
  18. package/src/config/models-config-schema.ts +1 -0
  19. package/src/defaults/skc/skills/deep-interview/SKILL.md +12 -0
  20. package/src/defaults/skc/skills/ultragoal/SKILL.md +3 -0
  21. package/src/modes/components/settings-selector.ts +39 -21
  22. package/src/modes/controllers/selector-controller.ts +35 -15
  23. package/src/modes/rpc/rpc-client.ts +20 -4
  24. package/src/sdk/bus/index.ts +1 -3
  25. package/src/session-import/redact.ts +6 -3
  26. package/src/skc-runtime/state-writer.ts +24 -27
  27. package/src/skc-runtime/ultragoal-runtime.ts +125 -45
  28. package/src/skc-runtime/ultragoal-succession.ts +1667 -0
  29. package/src/skc-runtime/workflow-placeholder.ts +33 -0
  30. package/src/tools/ask.ts +366 -108
  31. package/src/tools/bisect.ts +4 -1
@@ -204,6 +204,10 @@ const TIME_FORMAT_OPTIONS: SelectItem[] = [
204
204
  { value: "24h", label: "24h" },
205
205
  { value: "12h", label: "12h" },
206
206
  ];
207
+ const USAGE_MODE_OPTIONS: SelectItem[] = [
208
+ { value: "used", label: "Used" },
209
+ { value: "remaining", label: "Remaining" },
210
+ ];
207
211
  const USAGE_MODE_VALUES = ["used", "remaining"] as const;
208
212
  type UsageMode = (typeof USAGE_MODE_VALUES)[number];
209
213
 
@@ -220,6 +224,7 @@ function mergeSegmentOptions(
220
224
  path: base?.path || overrides?.path ? { ...(base?.path ?? {}), ...(overrides?.path ?? {}) } : undefined,
221
225
  git: base?.git || overrides?.git ? { ...(base?.git ?? {}), ...(overrides?.git ?? {}) } : undefined,
222
226
  time: base?.time || overrides?.time ? { ...(base?.time ?? {}), ...(overrides?.time ?? {}) } : undefined,
227
+ usage: base?.usage || overrides?.usage ? { ...(base?.usage ?? {}), ...(overrides?.usage ?? {}) } : undefined,
223
228
  };
224
229
  }
225
230
 
@@ -279,8 +284,6 @@ function segmentPlacement(
279
284
  class StatusLineCustomEditor extends Container {
280
285
  #list!: SettingsList;
281
286
  #draft: StatusLineDraft;
282
- #currentWidthPreviewText!: Text;
283
- #narrowWidthPreviewText!: Text;
284
287
  #previewHighlightSegment: StatusLineSegmentId | undefined;
285
288
 
286
289
  constructor(
@@ -312,13 +315,6 @@ class StatusLineCustomEditor extends Container {
312
315
  this.clear();
313
316
  this.addChild(new Text(theme.bold(theme.fg("accent", "Status Line Custom Editor")), 0, 0));
314
317
  this.addChild(new Spacer(1));
315
- this.addChild(new Text(theme.fg("muted", "Current width preview:"), 0, 0));
316
- this.#currentWidthPreviewText = new Text(this.#statusLinePreview(), 0, 0);
317
- this.addChild(this.#currentWidthPreviewText);
318
- this.addChild(new Text(theme.fg("muted", "Narrow width preview:"), 0, 0));
319
- this.#narrowWidthPreviewText = new Text(this.#statusLinePreview(40), 0, 0);
320
- this.addChild(this.#narrowWidthPreviewText);
321
- this.addChild(new Spacer(1));
322
318
  this.#list = new SettingsList(
323
319
  this.#items(),
324
320
  14,
@@ -326,27 +322,17 @@ class StatusLineCustomEditor extends Container {
326
322
  (id, value) => this.#handleChange(id, value),
327
323
  () => this.#cancel(),
328
324
  item => this.#setSelectedItem(item),
325
+ 2,
329
326
  );
330
327
  this.addChild(this.#list);
331
328
  }
332
329
 
333
330
  #refresh(): void {
334
- this.#refreshPreview();
335
331
  this.#list.setItems(this.#items());
336
332
  }
337
-
338
- #refreshPreview(): void {
339
- this.#currentWidthPreviewText.setText(this.#statusLinePreview());
340
- this.#narrowWidthPreviewText.setText(this.#statusLinePreview(40));
341
- }
342
-
343
- #statusLinePreview(width?: number): string {
344
- return this.callbacks.getStatusLinePreview?.(width) ?? theme.fg("dim", "(preview not available)");
345
- }
346
333
  #setSelectedItem(item: SettingItem | undefined): void {
347
334
  this.#previewHighlightSegment = this.#highlightSegmentForItem(item);
348
335
  this.#preview();
349
- this.#refreshPreview();
350
336
  }
351
337
 
352
338
  #highlightSegmentForItem(item: SettingItem | undefined): StatusLineSegmentId | undefined {
@@ -432,6 +418,22 @@ class StatusLineCustomEditor extends Container {
432
418
  },
433
419
  );
434
420
  }
421
+ if (id === "usage") {
422
+ items.push({
423
+ id: "option.usage.mode",
424
+ label: "Usage: mode",
425
+ currentValue: this.#draft.segmentOptions.usage?.mode ?? "used",
426
+ submenu: (currentValue, done) =>
427
+ new SelectSubmenu(
428
+ "Usage mode",
429
+ "Show used quota or remaining quota in the usage segment.",
430
+ USAGE_MODE_OPTIONS,
431
+ currentValue,
432
+ done,
433
+ () => done(),
434
+ ),
435
+ });
436
+ }
435
437
  }
436
438
 
437
439
  items.push(
@@ -596,6 +598,12 @@ class StatusLineCustomEditor extends Container {
596
598
  case "time.showSeconds":
597
599
  this.#draft.segmentOptions.time = { ...(this.#draft.segmentOptions.time ?? {}), showSeconds: bool };
598
600
  break;
601
+ case "usage.mode":
602
+ this.#draft.segmentOptions.usage = {
603
+ ...(this.#draft.segmentOptions.usage ?? {}),
604
+ mode: value === "remaining" ? "remaining" : "used",
605
+ };
606
+ break;
599
607
  }
600
608
  }
601
609
 
@@ -606,6 +614,8 @@ class StatusLineCustomEditor extends Container {
606
614
  rightSegments: [...this.#draft.rightSegments],
607
615
  separator: this.#draft.separator,
608
616
  segmentOptions: cloneSegmentOptions(this.#draft.segmentOptions),
617
+ sessionAccent: settings.get("statusLine.sessionAccent"),
618
+ maxRows: settings.get("statusLine.maxRows"),
609
619
  previewHighlightSegment: this.#previewHighlightSegment,
610
620
  });
611
621
  }
@@ -618,6 +628,7 @@ class StatusLineCustomEditor extends Container {
618
628
  separator: settings.get("statusLine.separator"),
619
629
  segmentOptions: cloneSegmentOptions(settings.get("statusLine.segmentOptions") as StatusLineSegmentOptions),
620
630
  sessionAccent: settings.get("statusLine.sessionAccent"),
631
+ maxRows: settings.get("statusLine.maxRows"),
621
632
  previewHighlightSegment: undefined,
622
633
  });
623
634
  }
@@ -1197,13 +1208,20 @@ export class SettingsSelectorComponent extends Container {
1197
1208
  #buildItemsForTab(defs: SettingDef[], tabId: SettingTab): SettingItem[] {
1198
1209
  const items = this.#buildItemsForDefs(defs);
1199
1210
  if (tabId === "appearance") {
1211
+ const customEditorCallbacks: SettingsCallbacks = {
1212
+ ...this.callbacks,
1213
+ onStatusLinePreview: previewSettings => {
1214
+ this.callbacks.onStatusLinePreview?.(previewSettings);
1215
+ this.#updateStatusPreview();
1216
+ },
1217
+ };
1200
1218
  const customEditorItem: SettingItem = {
1201
1219
  id: STATUS_LINE_CUSTOM_EDITOR_ID,
1202
1220
  label: "Status Line Custom Editor",
1203
1221
  description:
1204
1222
  "Edit custom status line segments, placement, separator, and typed segment options with live previews.",
1205
1223
  currentValue: "open",
1206
- submenu: (_currentValue, done) => new StatusLineCustomEditor(this.callbacks, done),
1224
+ submenu: (_currentValue, done) => new StatusLineCustomEditor(customEditorCallbacks, done),
1207
1225
  };
1208
1226
  const presetIndex = items.findIndex(item => item.id === "statusLine.preset");
1209
1227
  if (presetIndex >= 0) {
@@ -5,7 +5,7 @@ import type { OAuthProvider } from "@sayknow-cli/ai/utils/oauth/types";
5
5
  import type { Component, OverlayHandle, SlashCommand } from "@sayknow-cli/tui";
6
6
  import { Input, isPetMode, Loader, Spacer, Text } from "@sayknow-cli/tui";
7
7
  import { getAgentDbPath, getProjectDir, logger, VERSION } from "@sayknow-cli/utils";
8
- import { type AppKeybinding, formatKeyHints } from "../../config/keybindings";
8
+ import type { AppKeybinding } from "../../config/keybindings";
9
9
  import {
10
10
  activateModelProfile,
11
11
  materializeActiveModelProfileAssignment,
@@ -27,6 +27,7 @@ import {
27
27
  getPluginsCacheDir,
28
28
  MarketplaceManager,
29
29
  } from "../../extensibility/plugins/marketplace";
30
+ import { INTERACTIVE_SELECTOR_RESUME_ORIGIN } from "../../extensibility/shared-events";
30
31
  import {
31
32
  getAvailableThemes,
32
33
  getCurrentThemeName,
@@ -832,7 +833,7 @@ export class SelectorController {
832
833
  id: `action:${action.id}`,
833
834
  label: action.label,
834
835
  description: action.id,
835
- keybinding: formatKeyHints(this.ctx.keybindings.getKeys(action.id as AppKeybinding)) || undefined,
836
+ keybinding: this.ctx.keybindings.getDisplayString(action.id as AppKeybinding) || undefined,
836
837
  searchText: action.id,
837
838
  handler: action.handler,
838
839
  })),
@@ -1042,13 +1043,18 @@ export class SelectorController {
1042
1043
  const selector = new ThinkingSelectorComponent(
1043
1044
  this.ctx.session.thinkingLevel,
1044
1045
  availableLevels,
1045
- selection => {
1046
- done();
1047
-
1046
+ async selection => {
1048
1047
  const { level, persistDefault } = selection;
1049
1048
  const configuredDefault = this.ctx.settings.get("defaultThinkingLevel");
1050
1049
  const levelToApply = level === ThinkingLevel.Inherit ? configuredDefault : level;
1051
- this.ctx.session.setThinkingLevel(levelToApply, persistDefault);
1050
+ try {
1051
+ await this.ctx.session.setThinkingLevelForControl(level, persistDefault);
1052
+ } catch (error) {
1053
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1054
+ return;
1055
+ }
1056
+ done();
1057
+
1052
1058
  const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
1053
1059
  const requestedLabel =
1054
1060
  level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
@@ -1959,10 +1965,7 @@ export class SelectorController {
1959
1965
  }
1960
1966
 
1961
1967
  async showSessionSelector(): Promise<void> {
1962
- const sessions = await SessionManager.listForResumePickerReadOnly(
1963
- this.ctx.sessionManager.getCwd(),
1964
- this.ctx.sessionManager.getSessionDir(),
1965
- );
1968
+ const sessions = await this.ctx.sessionManager.listForResumePickerReadOnly();
1966
1969
  this.showSelector(done => {
1967
1970
  const selector = new SessionSelectorComponent(
1968
1971
  sessions,
@@ -2064,13 +2067,30 @@ export class SelectorController {
2064
2067
  const previousSessionId = this.ctx.sessionManager.getSessionId();
2065
2068
  const migrationPolicy =
2066
2069
  this.ctx.settings?.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain";
2067
- const writableSessionPath = await this.ctx.sessionManager.prepareManagedCandidateForWrite(
2068
- sessionPath,
2069
- migrationPolicy,
2070
- );
2070
+ let writableSessionPath = sessionPath;
2071
+ if (this.ctx.sessionManager.isManagedDestination()) {
2072
+ // Managed candidates are prepared against their inspected identity so a
2073
+ // replacement race fails closed before adoption. Explicit `--session-dir`
2074
+ // selections never enter the managed migration fence (preparation would
2075
+ // reject a non-managed destination authority outright).
2076
+ const inspection = await SessionManager.inspectSessionTailReadOnly(sessionPath);
2077
+ if (inspection.kind === "error")
2078
+ throw new Error(`Could not inspect selected session: ${inspection.reason}`);
2079
+ writableSessionPath = await this.ctx.sessionManager.prepareManagedCandidateForStrictAdoption(
2080
+ sessionPath,
2081
+ migrationPolicy,
2082
+ inspection.identity,
2083
+ );
2084
+ }
2071
2085
 
2072
2086
  // Switch session via AgentSession (emits hook and tool session events)
2073
- if (!(await this.ctx.session.switchSession(writableSessionPath, options))) return false;
2087
+ if (
2088
+ !(await this.ctx.session.switchSession(writableSessionPath, {
2089
+ ...options,
2090
+ transition: { origin: INTERACTIVE_SELECTOR_RESUME_ORIGIN },
2091
+ }))
2092
+ )
2093
+ return false;
2074
2094
  onSwitched?.();
2075
2095
  this.#clearTransientSessionUi();
2076
2096
  const switchingToDifferentSession = previousSessionId !== this.ctx.sessionManager.getSessionId();
@@ -288,11 +288,27 @@ class StdioRpcTransport implements RpcTransport {
288
288
  } else {
289
289
  onClose(new Error("RPC stdio transport closed"));
290
290
  }
291
- })().catch((err: Error) => {
292
- if (!readySettled) {
293
- readySettled = true;
291
+ })().catch(async (err: Error) => {
292
+ if (readySettled) {
293
+ onClose(err);
294
+ return;
295
+ }
296
+ // Non-JSONL stdout before `ready` usually means the CLI refused to start
297
+ // (for example a usage error such as the removed `--mode rpc`). When the
298
+ // child is already gone, report its exit code and stderr instead of the
299
+ // parse noise so callers see the actual refusal.
300
+ const proc = this.#process;
301
+ const exitCode = proc
302
+ ? await Promise.race([proc.exited.catch(() => proc.exitCode ?? -1), Bun.sleep(1_000).then(() => undefined)])
303
+ : undefined;
304
+ if (readySettled) return;
305
+ readySettled = true;
306
+ if (exitCode === undefined) {
294
307
  readyReject(err);
295
- } else onClose(err);
308
+ return;
309
+ }
310
+ const stderr = await getStartupStderr();
311
+ readyReject(new Error(`Agent process exited with code ${exitCode} before ready. Stderr: ${stderr}`));
296
312
  });
297
313
  void this.#process.exited.then(async (exitCode: number) => {
298
314
  if (!readySettled) {
@@ -3217,9 +3217,7 @@ export function createNotificationsExtension(
3217
3217
  let serverStopped = rt.serverStopped;
3218
3218
  if (!serverStopped) {
3219
3219
  try {
3220
- // fork's NotificationServer.stop() is synchronous; stopAndWait is upstream v0.11.x's
3221
- // async variant. TODO(port): add stop_and_wait to pi-natives/src/notifications.rs.
3222
- rt.server.stop();
3220
+ await rt.server.stopAndWait();
3223
3221
  serverStopped = true;
3224
3222
  rt.serverStopped = true;
3225
3223
  } catch (e) {
@@ -9,8 +9,7 @@
9
9
 
10
10
  export const IMPORT_REDACTED_PLACEHOLDER = "[REDACTED]";
11
11
  /** Bumped when patterns change; persisted in import provenance. */
12
- export const IMPORT_SANITIZER_VERSION = 3;
13
-
12
+ export const IMPORT_SANITIZER_VERSION = 4;
14
13
  interface RedactionRule {
15
14
  readonly id: string;
16
15
  readonly pattern: RegExp;
@@ -68,7 +67,11 @@ const REDACTION_RULES: readonly RedactionRule[] = [
68
67
  // Long hex secrets (64+ hex chars: webhook secrets, signing keys).
69
68
  { id: "hex-secret", pattern: new RegExp(`\\b${HEX}{64,}\\b`, "g") },
70
69
  // Basic-auth credentials embedded in URLs (https://user:pass@host).
71
- { id: "url-credential", pattern: /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]{1,256}:[^/\s@]{1,256}@/gi },
70
+ // The scheme repetition is bounded. Unbounded `[a-z0-9+.-]*` in front of the
71
+ // literal `://` re-tries every prefix of a long alphabetic run before failing,
72
+ // which is quadratic in the input length: 200 KB of ordinary prose costs ~10s.
73
+ // IANA's longest registered scheme is well under 16 characters.
74
+ { id: "url-credential", pattern: /([a-z][a-z0-9+.-]{0,15}:\/\/)[^/\s:@]{1,256}:[^/\s@]{1,256}@/gi },
72
75
  // Sensitive env/KEY assignments: OPENAI_API_KEY=..., token: ..., password = ...
73
76
  // The name prefix is optional so a bare sensitive name (`password: …`) also matches.
74
77
  {
@@ -515,38 +515,35 @@ async function writeGuardedResolvedJsonAtomic(
515
515
  value: unknown,
516
516
  options: GuardedStateWriterOptions,
517
517
  ): Promise<GuardedWriteResult> {
518
- return lockResolvedWorkflowTarget(
519
- filePath,
520
- async () => {
521
- const current = await readJsonIfPresentTolerant(filePath);
522
- const currentRevision = persistedStateRevision(current);
523
-
524
- if (options.policy === "source") {
525
- if (options.expectedRevision !== undefined && options.expectedRevision !== currentRevision) {
526
- throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
527
- }
528
- const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
529
- await atomicWrite(filePath, jsonText(next));
530
- await maybeAudit(filePath, options);
531
- return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
532
- }
518
+ const write = async (): Promise<GuardedWriteResult> => {
519
+ const current = await readJsonIfPresentTolerant(filePath);
520
+ const currentRevision = persistedStateRevision(current);
533
521
 
534
- const incomingSourceRevision =
535
- options.sourceRevision ?? (isPlainObject(value) ? persistedStateRevision(value) : 0);
536
- if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
537
- return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
522
+ if (options.policy === "source") {
523
+ if (options.expectedRevision !== undefined && options.expectedRevision !== currentRevision) {
524
+ throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
538
525
  }
539
- const next = stampStateRevision(
540
- withWorkflowReceipt(value, buildReceipt(options)),
541
- currentRevision + 1,
542
- incomingSourceRevision,
543
- );
526
+ const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
544
527
  await atomicWrite(filePath, jsonText(next));
545
528
  await maybeAudit(filePath, options);
546
529
  return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
547
- },
548
- options.lock,
549
- );
530
+ }
531
+
532
+ const incomingSourceRevision =
533
+ options.sourceRevision ?? (isPlainObject(value) ? persistedStateRevision(value) : 0);
534
+ if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
535
+ return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
536
+ }
537
+ const next = stampStateRevision(
538
+ withWorkflowReceipt(value, buildReceipt(options)),
539
+ currentRevision + 1,
540
+ incomingSourceRevision,
541
+ );
542
+ await atomicWrite(filePath, jsonText(next));
543
+ await maybeAudit(filePath, options);
544
+ return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
545
+ };
546
+ return options.lockHeld ? write() : lockResolvedWorkflowTarget(filePath, write, options.lock);
550
547
  }
551
548
 
552
549
  export async function writeGuardedJsonAtomic(
@@ -31,6 +31,11 @@ import {
31
31
  validateDeferredMemberReceiptFresh,
32
32
  validateReceiptFreshBase,
33
33
  } from "./ultragoal-receipt-freshness";
34
+ import {
35
+ assertUltragoalAdoptionPublished,
36
+ assertUltragoalGoalNotFenced,
37
+ runUltragoalSuccessionCommand,
38
+ } from "./ultragoal-succession";
34
39
 
35
40
  export {
36
41
  CRITIC_GATE_HARD_STOP_EVENT,
@@ -600,7 +605,12 @@ export async function recordUltragoalNudgeIfBudgetRemaining(input: {
600
605
  );
601
606
  }
602
607
 
603
- export async function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: string | null): Promise<void> {
608
+ export async function writePlan(
609
+ cwd: string,
610
+ plan: UltragoalPlan,
611
+ sessionId?: string | null,
612
+ options: { lockHeld?: boolean } = {},
613
+ ): Promise<void> {
604
614
  const resolvedSessionId =
605
615
  sessionId?.trim() || resolveSkcSessionForWrite(cwd, { envSessionId: process.env.SKC_SESSION_ID }).skcSessionId;
606
616
  const paths = getUltragoalPaths(cwd, resolvedSessionId);
@@ -611,12 +621,32 @@ export async function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: st
611
621
  await writeGuardedJsonAtomic(paths.goalsPath, plan, {
612
622
  cwd,
613
623
  policy: "source",
624
+ lockHeld: options.lockHeld === true,
614
625
  expectedRevision: typeof plan.state_revision === "number" ? persistedStateRevision(plan) : undefined,
615
626
  audit: { category: "state", verb: "write", owner: "skc-runtime", sessionId: resolvedSessionId },
616
627
  });
617
628
  await writeSessionActivityMarker(cwd, resolvedSessionId, { writer: "ultragoal-runtime", path: paths.goalsPath });
618
629
  }
619
630
 
631
+ /**
632
+ * The single exclusion that decides who owns an Ultragoal run.
633
+ *
634
+ * Establishing ownership is a read-decide-write sequence: a start reads the plan,
635
+ * checks the outgoing succession fence, then commits `active`. Every caller that
636
+ * decides ownership — source admission, succession offer, and successor
637
+ * publication — runs that decision inside this lock, keyed on the plan.
638
+ */
639
+ export async function withUltragoalPlanOwnership<T>(cwd: string, sessionId: string, fn: () => Promise<T>): Promise<T> {
640
+ return withWorkflowStateLock(getUltragoalPaths(cwd, sessionId).goalsPath, fn, { cwd });
641
+ }
642
+
643
+ export async function resolveUltragoalOwnershipSession(cwd: string, sessionId?: string | null): Promise<string> {
644
+ return (
645
+ sessionId?.trim() ||
646
+ (await resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID })).skcSessionId
647
+ );
648
+ }
649
+
620
650
  function chooseReceiptKind(
621
651
  plan: UltragoalPlan,
622
652
  ledger: readonly UltragoalLedgerEvent[],
@@ -1710,40 +1740,44 @@ export async function startNextUltragoalGoal(input: {
1710
1740
  allComplete: boolean;
1711
1741
  nextAction: UltragoalCompleteNextAction;
1712
1742
  }> {
1713
- const plan = await readUltragoalPlan(input.cwd, input.sessionId);
1714
- if (!plan) throw new Error("No ultragoal plan found. Run `skc ultragoal create-goals --brief ...` first.");
1715
- // Fail closed: delegated execution requires stamped repository authority (#2901).
1716
- if (!plan.repositoryBinding) {
1717
- throw new Error(
1718
- "Ultragoal plan is missing repositoryBinding; recreate goals so the plan is bound to an authoritative repository identity.",
1719
- );
1720
- }
1721
- await assertCwdMatchesRepositoryBinding(input.cwd, plan.repositoryBinding);
1722
- const retryFailed = input.retryFailed === true;
1723
- const goal = chooseNextGoal(plan, retryFailed);
1724
- if (!goal) {
1725
- const state = getUltragoalRunCompletionState(plan, { retryFailed });
1743
+ const sessionId = await resolveUltragoalOwnershipSession(input.cwd, input.sessionId);
1744
+ return withUltragoalPlanOwnership(input.cwd, sessionId, async () => {
1745
+ const plan = await readUltragoalPlan(input.cwd, sessionId);
1746
+ if (!plan) throw new Error("No ultragoal plan found. Run `skc ultragoal create-goals --brief ...` first.");
1747
+ if (!plan.repositoryBinding) {
1748
+ throw new Error(
1749
+ "Ultragoal plan is missing repositoryBinding; recreate goals so the plan is bound to an authoritative repository identity.",
1750
+ );
1751
+ }
1752
+ await assertCwdMatchesRepositoryBinding(input.cwd, plan.repositoryBinding);
1753
+ await assertUltragoalAdoptionPublished(input.cwd, sessionId);
1754
+ const retryFailed = input.retryFailed === true;
1755
+ const goal = chooseNextGoal(plan, retryFailed);
1756
+ if (goal) await assertUltragoalGoalNotFenced(input.cwd, sessionId, goal.id);
1757
+ if (!goal) {
1758
+ const state = getUltragoalRunCompletionState(plan, { retryFailed });
1759
+ return {
1760
+ plan,
1761
+ allComplete: state.allComplete,
1762
+ nextAction: resolveUltragoalCompleteNextAction(plan, { retryFailed }),
1763
+ };
1764
+ }
1765
+ if (goal.status !== "active") {
1766
+ const now = new Date().toISOString();
1767
+ goal.status = "active";
1768
+ goal.startedAt = goal.startedAt ?? now;
1769
+ goal.updatedAt = now;
1770
+ plan.updatedAt = now;
1771
+ await writePlan(input.cwd, plan, sessionId, { lockHeld: true });
1772
+ await appendLedger(input.cwd, { event: "goal_started", goalId: goal.id }, sessionId);
1773
+ }
1726
1774
  return {
1727
1775
  plan,
1728
- allComplete: state.allComplete,
1729
- nextAction: resolveUltragoalCompleteNextAction(plan, { retryFailed }),
1776
+ goal,
1777
+ allComplete: false,
1778
+ nextAction: { kind: "execute-goal", goal },
1730
1779
  };
1731
- }
1732
- if (goal.status !== "active") {
1733
- const now = new Date().toISOString();
1734
- goal.status = "active";
1735
- goal.startedAt = goal.startedAt ?? now;
1736
- goal.updatedAt = now;
1737
- plan.updatedAt = now;
1738
- await writePlan(input.cwd, plan, input.sessionId);
1739
- await appendLedger(input.cwd, { event: "goal_started", goalId: goal.id }, input.sessionId);
1740
- }
1741
- return {
1742
- plan,
1743
- goal,
1744
- allComplete: false,
1745
- nextAction: { kind: "execute-goal", goal },
1746
- };
1780
+ });
1747
1781
  }
1748
1782
 
1749
1783
  async function readStructuredValue(cwd: string, value: string): Promise<unknown> {
@@ -3046,10 +3080,13 @@ export async function checkpointUltragoalGoal(input: {
3046
3080
  evidence: string;
3047
3081
  qualityGateJson?: string;
3048
3082
  }): Promise<UltragoalPlan> {
3049
- const plan = await readUltragoalPlan(input.cwd);
3083
+ const sessionId = await resolveUltragoalOwnershipSession(input.cwd, null);
3084
+ const plan = await readUltragoalPlan(input.cwd, sessionId);
3050
3085
  if (!plan) throw new Error("No ultragoal plan found. Run `skc ultragoal create-goals --brief ...` first.");
3051
3086
  const goal = plan.goals.find(item => item.id === input.goalId);
3052
3087
  if (!goal) throw new Error(`No ultragoal goal found for ${input.goalId}.`);
3088
+ await assertUltragoalAdoptionPublished(input.cwd, sessionId);
3089
+ await assertUltragoalGoalNotFenced(input.cwd, sessionId, goal.id);
3053
3090
  const evidence = input.evidence.trim();
3054
3091
  if (!evidence) throw new Error("checkpoint evidence is required");
3055
3092
  const ledgerBefore = await readUltragoalLedger(input.cwd);
@@ -3209,17 +3246,21 @@ export async function checkpointUltragoalGoal(input: {
3209
3246
  goal.updatedAt = now;
3210
3247
  if (input.status === "complete") goal.completedAt = now;
3211
3248
  plan.updatedAt = now;
3212
- await writePlan(input.cwd, plan);
3213
- const persistedPlan = await readUltragoalPlan(input.cwd);
3214
- if (persistedPlan?.state_revision !== undefined) plan.state_revision = persistedPlan.state_revision;
3215
- await appendLedger(input.cwd, {
3216
- eventId: pendingCheckpointEventId,
3217
- event: "goal_checkpointed",
3218
- goalId: goal.id,
3219
- status: input.status,
3220
- evidence,
3221
- qualityGateJson,
3222
- completionVerification: goal.completionVerification,
3249
+ await withUltragoalPlanOwnership(input.cwd, sessionId, async () => {
3250
+ await assertUltragoalAdoptionPublished(input.cwd, sessionId);
3251
+ await assertUltragoalGoalNotFenced(input.cwd, sessionId, goal.id);
3252
+ await writePlan(input.cwd, plan, sessionId, { lockHeld: true });
3253
+ const persistedPlan = await readUltragoalPlan(input.cwd, sessionId);
3254
+ if (persistedPlan?.state_revision !== undefined) plan.state_revision = persistedPlan.state_revision;
3255
+ await appendLedger(input.cwd, {
3256
+ eventId: pendingCheckpointEventId,
3257
+ event: "goal_checkpointed",
3258
+ goalId: goal.id,
3259
+ status: input.status,
3260
+ evidence,
3261
+ qualityGateJson,
3262
+ completionVerification: goal.completionVerification,
3263
+ });
3223
3264
  });
3224
3265
  return plan;
3225
3266
  }
@@ -4155,6 +4196,10 @@ const FLAGS_WITH_VALUES = new Set([
4155
4196
  "--review-result-json",
4156
4197
  "--qa-result-json",
4157
4198
  "--target-state-json",
4199
+ "--target-repo",
4200
+ "--authorize",
4201
+ "--authorized-by",
4202
+ "--offer",
4158
4203
  ]);
4159
4204
 
4160
4205
  function isHelpArg(arg: string): boolean {
@@ -4283,6 +4328,36 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
4283
4328
  "",
4284
4329
  ].join("\n");
4285
4330
  }
4331
+ if (subject === "succession") {
4332
+ return [
4333
+ "Run native SKC Ultragoal workflow commands",
4334
+ "",
4335
+ "USAGE",
4336
+ " $ skc ultragoal succession offer --target-repo <path> --goal-id <id> [--goal-id <id> ...] --authorize <statement> --authorized-by <identity> [--json]",
4337
+ " $ skc ultragoal succession adopt --offer <path> [--skc-goal-mode aggregate|per-story] [--json]",
4338
+ " $ skc ultragoal succession status [--json]",
4339
+ "",
4340
+ "FLAGS",
4341
+ " --target-repo=<value> Destination repository for the successor run; never the source repository",
4342
+ " --goal-id=<value> Unfinished source goal to hand off (repeatable; no implicit or wildcard selection)",
4343
+ " --authorize=<value> Required. Explicit bounded authorization statement for this source/target/selection",
4344
+ " --authorized-by=<value> Required. Identity that authorized the handoff",
4345
+ " --offer=<value> Offer document recorded by `succession offer`, read from inside the source worktree",
4346
+ " --skc-goal-mode=<value> Successor validation mode (default aggregate); the source mode is provenance only",
4347
+ " --json Output a machine-readable receipt",
4348
+ "",
4349
+ "OWNERSHIP",
4350
+ " `offer` records a durable outgoing ownership fence, so the source run stops scheduling and checkpointing the",
4351
+ " selected goals immediately — not when the target happens to adopt. Between offer and adoption no run owns them.",
4352
+ " The source brief, goals and ledger are never written. Goals the offer did not select stay schedulable.",
4353
+ " There is deliberately no `revoke` verb: an unadopted offer keeps its goals fenced.",
4354
+ "",
4355
+ "EXAMPLES",
4356
+ ' $ skc ultragoal succession offer --target-repo ../payments-api --goal-id G002 --goal-id G003 --authorize "leader approved moving the refund stories" --authorized-by human:release-lead --json',
4357
+ " $ skc ultragoal succession adopt --offer ../plan-repo/.skc/_session-abc/ultragoal/succession/offer-<id>.json --json",
4358
+ "",
4359
+ ].join("\n");
4360
+ }
4286
4361
 
4287
4362
  return [
4288
4363
  "Run native SKC Ultragoal workflow commands",
@@ -4305,8 +4380,11 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
4305
4380
  " start-pipeline-overlap",
4306
4381
  " join-pipeline-overlap",
4307
4382
  " rebaseline-pipeline-overlap",
4383
+ " succession offer",
4384
+ " succession adopt",
4385
+ " succession status",
4308
4386
  "",
4309
- "Run `skc ultragoal checkpoint --help`, `skc ultragoal review --help`, `skc ultragoal classify-blocker --help`, `skc ultragoal record-critic-verdict --help`, or `skc ultragoal record-critic-gate-override --help` for command-specific requirements.",
4387
+ "Run `skc ultragoal checkpoint --help`, `skc ultragoal review --help`, `skc ultragoal classify-blocker --help`, `skc ultragoal record-critic-verdict --help`, `skc ultragoal record-critic-gate-override --help`, or `skc ultragoal succession --help` for command-specific requirements.",
4310
4388
  "",
4311
4389
  ].join("\n");
4312
4390
  }
@@ -4846,6 +4924,8 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
4846
4924
  stdout: json ? renderCliWriteReceipt(receipt) : `Rebaselined pipeline overlap ${receipt.overlap_id}.\n`,
4847
4925
  };
4848
4926
  }
4927
+ case "succession":
4928
+ return await runUltragoalSuccessionCommand(args, cwd);
4849
4929
  default:
4850
4930
  return { status: 1, stderr: `Unknown skc ultragoal command: ${command}\n` };
4851
4931
  }