@sayknow-cli/coding-agent 0.5.11 → 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.
@@ -29,6 +29,7 @@ import {
29
29
  UNK_MAX_TOKENS,
30
30
  unregisterCustomApis,
31
31
  } from "@sayknow-cli/ai";
32
+ import { detectDiscoveredApiFamily } from "@sayknow-cli/ai/utils/discovery/openai-compatible";
32
33
 
33
34
  // Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
34
35
  // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
@@ -212,6 +213,36 @@ type ProviderValidationMode = "models-config" | "runtime-register";
212
213
 
213
214
  const OPENAI_REQUEST_TRANSFORM_APIS = new Set<Api>(["openai-completions", "openai-responses"]);
214
215
 
216
+ const OPENAI_FAMILY_APIS = new Set<Api>([
217
+ "openai-completions",
218
+ "openai-responses",
219
+ "openai-codex-responses",
220
+ "azure-openai-responses",
221
+ ]);
222
+
223
+ function isOpenAIFamilyApi(api: Api): boolean {
224
+ return OPENAI_FAMILY_APIS.has(api);
225
+ }
226
+
227
+ function dominantOpenAIFamilyModelApi(models: readonly { api?: string }[] | undefined): Api | undefined {
228
+ if (!models || models.length === 0) return undefined;
229
+ const counts = new Map<Api, number>();
230
+ for (const model of models) {
231
+ const api = model.api as Api | undefined;
232
+ if (api === undefined || !isOpenAIFamilyApi(api)) continue;
233
+ counts.set(api, (counts.get(api) ?? 0) + 1);
234
+ }
235
+ let best: Api | undefined;
236
+ let bestCount = 0;
237
+ for (const [api, count] of counts) {
238
+ if (count > bestCount) {
239
+ best = api;
240
+ bestCount = count;
241
+ }
242
+ }
243
+ return best;
244
+ }
245
+
215
246
  function getKnownProviderApis(providerName: string): Set<Api> {
216
247
  const apis = new Set<Api>();
217
248
  for (const model of getBundledModels(providerName as Parameters<typeof getBundledModels>[0])) {
@@ -1563,17 +1594,30 @@ export class ModelRegistry {
1563
1594
  keylessProviders.add(providerName);
1564
1595
  }
1565
1596
 
1566
- if (providerConfig.discovery && providerConfig.api) {
1597
+ const effectiveDiscoveryBaseUrl = providerConfig.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName);
1598
+ const providerApi =
1599
+ (providerConfig.api as Api | undefined) ??
1600
+ dominantOpenAIFamilyModelApi(providerConfig.models as { api?: string }[] | undefined);
1601
+ const autoDiscovery: ProviderDiscovery | undefined =
1602
+ !providerConfig.discovery &&
1603
+ !localOpenAICompat &&
1604
+ providerApi !== undefined &&
1605
+ isOpenAIFamilyApi(providerApi) &&
1606
+ effectiveDiscoveryBaseUrl !== undefined
1607
+ ? { type: "openai-models-list" }
1608
+ : undefined;
1609
+ const effectiveDiscovery = providerConfig.discovery ?? autoDiscovery;
1610
+ if (effectiveDiscovery && providerApi) {
1567
1611
  discoverableProviders.push({
1568
1612
  provider: providerName,
1569
- api: providerConfig.api as Api,
1570
- baseUrl: providerConfig.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName),
1613
+ api: providerApi,
1614
+ baseUrl: effectiveDiscoveryBaseUrl,
1571
1615
  headers: providerConfig.headers,
1572
1616
  compat: providerConfig.compat,
1573
1617
  requestTransform: providerConfig.requestTransform,
1574
1618
  cacheRetention: providerConfig.cacheRetention,
1575
- discovery: providerConfig.discovery,
1576
- optional: false,
1619
+ discovery: effectiveDiscovery,
1620
+ optional: !providerConfig.discovery,
1577
1621
  });
1578
1622
  }
1579
1623
 
@@ -2280,50 +2324,82 @@ export class ModelRegistry {
2280
2324
  }
2281
2325
  return new Error(`HTTP ${response.status} from ${modelsUrl}`);
2282
2326
  },
2283
- mapModel: (item, defaults) => ({
2284
- ...defaults,
2285
- reasoning: isOmlx,
2286
- ...(isOmlx
2287
- ? {
2288
- thinking: {
2289
- mode: "effort" as const,
2290
- minLevel: Effort.Low,
2291
- maxLevel: Effort.High,
2292
- defaultLevel: Effort.Medium,
2293
- levels: [Effort.Low, Effort.Medium, Effort.High],
2294
- },
2295
- }
2296
- : {}),
2297
- contextWindow:
2298
- parseDiscoveryLimit(item.max_model_len) ??
2299
- parseDiscoveryLimit(item.context_length) ??
2300
- parseDiscoveryLimit(item.context_window) ??
2301
- parseDiscoveryLimit(item.max_context_length) ??
2302
- UNK_CONTEXT_WINDOW,
2303
- maxTokens:
2304
- parseDiscoveryLimit(item.max_completion_tokens) ??
2305
- parseDiscoveryLimit(item.max_tokens) ??
2306
- parseDiscoveryLimit(item.max_output_tokens) ??
2307
- UNK_MAX_TOKENS,
2308
- headers,
2309
- compat: {
2310
- ...(providerConfig.compat ?? {}),
2311
- supportsStore: false,
2312
- supportsDeveloperRole: false,
2313
- supportsReasoningEffort: isOmlx,
2327
+ mapModel: (item, defaults) => {
2328
+ const api = this.#resolveDiscoveredModelApi(
2329
+ providerConfig,
2330
+ typeof item.id === "string" ? item.id : "",
2331
+ item,
2332
+ );
2333
+ return {
2334
+ ...defaults,
2335
+ api,
2336
+ reasoning: isOmlx,
2314
2337
  ...(isOmlx
2315
2338
  ? {
2316
- thinkingFormat: "qwen-chat-template" as const,
2317
- reasoningContentField: "reasoning_content" as const,
2339
+ thinking: {
2340
+ mode: "effort" as const,
2341
+ minLevel: Effort.Low,
2342
+ maxLevel: Effort.High,
2343
+ defaultLevel: Effort.Medium,
2344
+ levels: [Effort.Low, Effort.Medium, Effort.High],
2345
+ },
2318
2346
  }
2319
2347
  : {}),
2320
- },
2321
- }),
2348
+ contextWindow:
2349
+ parseDiscoveryLimit(item.max_model_len) ??
2350
+ parseDiscoveryLimit(item.context_length) ??
2351
+ parseDiscoveryLimit(item.context_window) ??
2352
+ parseDiscoveryLimit(item.max_context_length) ??
2353
+ UNK_CONTEXT_WINDOW,
2354
+ maxTokens:
2355
+ parseDiscoveryLimit(item.max_completion_tokens) ??
2356
+ parseDiscoveryLimit(item.max_tokens) ??
2357
+ parseDiscoveryLimit(item.max_output_tokens) ??
2358
+ UNK_MAX_TOKENS,
2359
+ headers,
2360
+ compat: {
2361
+ ...(providerConfig.compat ?? {}),
2362
+ supportsStore: false,
2363
+ supportsDeveloperRole: false,
2364
+ supportsReasoningEffort: isOmlx,
2365
+ ...(isOmlx
2366
+ ? {
2367
+ thinkingFormat: "qwen-chat-template" as const,
2368
+ reasoningContentField: "reasoning_content" as const,
2369
+ }
2370
+ : {}),
2371
+ },
2372
+ };
2373
+ },
2322
2374
  });
2323
2375
  if (discovered === null) throw new Error(`Invalid OpenAI-compatible model catalog from ${baseUrl}`);
2324
2376
  return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
2325
2377
  }
2326
2378
 
2379
+ #resolveDiscoveredModelApi(
2380
+ providerConfig: DiscoveryProviderConfig,
2381
+ modelId: string,
2382
+ entry?: { id?: unknown; owned_by?: unknown },
2383
+ ): Api {
2384
+ let matchedPrefixLength = -1;
2385
+ let prefixApi: Api | undefined;
2386
+ for (const [prefix, routedApi] of Object.entries(providerConfig.discovery.apiByModelPrefix ?? {})) {
2387
+ if (modelId.startsWith(prefix) && prefix.length > matchedPrefixLength) {
2388
+ prefixApi = routedApi as Api;
2389
+ matchedPrefixLength = prefix.length;
2390
+ }
2391
+ }
2392
+ if (prefixApi !== undefined) return prefixApi;
2393
+ if (providerConfig.discovery.type === "openai-models-list") {
2394
+ const detected = detectDiscoveredApiFamily(entry ?? { id: modelId });
2395
+ if (detected === "anthropic-messages") return "anthropic-messages";
2396
+ if (detected === "openai-completions") {
2397
+ return isOpenAIFamilyApi(providerConfig.api) ? providerConfig.api : "openai-completions";
2398
+ }
2399
+ }
2400
+ return providerConfig.api;
2401
+ }
2402
+
2327
2403
  #normalizeLlamaCppBaseUrl(baseUrl?: string): string {
2328
2404
  const defaultBaseUrl = "http://127.0.0.1:8080";
2329
2405
  const raw = baseUrl || defaultBaseUrl;
@@ -185,6 +185,7 @@ export type ModelOverride = z.infer<typeof ModelOverrideSchema>;
185
185
 
186
186
  export const ProviderDiscoverySchema = z.object({
187
187
  type: z.enum(["ollama", "llama.cpp", "lm-studio", "omlx", "sglang", "openai-models-list"]),
188
+ apiByModelPrefix: z.record(z.string().min(1), z.string().min(1)).optional(),
188
189
  });
189
190
 
190
191
  const LocalOpenAICompatSchema = z
@@ -37,6 +37,9 @@ skc ultragoal complete-goals --retry-failed
37
37
  skc ultragoal checkpoint --goal-id <id> --status complete --evidence "<evidence>" --quality-gate-json <quality-gate-json-or-path>
38
38
  skc ultragoal checkpoint --goal-id <id> --status failed --evidence "<blocker/evidence>"
39
39
  skc ultragoal record-review-blockers --goal-id <id> --title "Resolve final review blockers" --objective "<blocker-resolution objective>" --evidence "<review findings>"
40
+ skc ultragoal succession offer --target-repo <path> --goal-id <id> --authorize "<statement>" --authorized-by <identity>
41
+ skc ultragoal succession adopt --offer <path>
42
+ skc ultragoal succession status --json
40
43
  ```
41
44
 
42
45
  Use these exact goal-tool calls for the inline goal state:
@@ -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
  }