@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.
- package/CHANGELOG.md +42 -0
- package/dist/types/config/file-lock.d.ts +8 -0
- package/dist/types/config/model-profile-activation.d.ts +3 -1
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/session-import/redact.d.ts +1 -1
- package/dist/types/skc-runtime/ultragoal-runtime.d.ts +13 -1
- package/dist/types/skc-runtime/ultragoal-succession.d.ts +251 -0
- package/dist/types/skc-runtime/workflow-placeholder.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +460 -172
- package/package.json +7 -7
- package/src/cli/telegram-cli.ts +1 -1
- package/src/config/file-lock-gc.ts +39 -2
- package/src/config/file-lock.ts +22 -1
- package/src/config/model-profile-activation.ts +7 -11
- package/src/config/model-registry.ts +192 -44
- package/src/config/model-resolver.ts +4 -2
- package/src/config/models-config-schema.ts +1 -0
- package/src/defaults/skc/skills/deep-interview/SKILL.md +12 -0
- package/src/defaults/skc/skills/ultragoal/SKILL.md +3 -0
- package/src/modes/components/settings-selector.ts +39 -21
- package/src/modes/controllers/selector-controller.ts +35 -15
- package/src/modes/rpc/rpc-client.ts +20 -4
- package/src/sdk/bus/index.ts +1 -3
- package/src/session-import/redact.ts +6 -3
- package/src/skc-runtime/state-writer.ts +24 -27
- package/src/skc-runtime/ultragoal-runtime.ts +125 -45
- package/src/skc-runtime/ultragoal-succession.ts +1667 -0
- package/src/skc-runtime/workflow-placeholder.ts +33 -0
- package/src/tools/ask.ts +366 -108
- 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(
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
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 (
|
|
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 (
|
|
293
|
-
|
|
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
|
-
|
|
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) {
|
package/src/sdk/bus/index.ts
CHANGED
|
@@ -3217,9 +3217,7 @@ export function createNotificationsExtension(
|
|
|
3217
3217
|
let serverStopped = rt.serverStopped;
|
|
3218
3218
|
if (!serverStopped) {
|
|
3219
3219
|
try {
|
|
3220
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
filePath
|
|
520
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
const
|
|
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
|
-
|
|
1729
|
-
|
|
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
|
|
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
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
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`,
|
|
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
|
}
|