@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
|
@@ -0,0 +1,1667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit, audited cross-repository Ultragoal succession (#5353).
|
|
3
|
+
*
|
|
4
|
+
* An approved Ultragoal can be created in repository A while the implementation
|
|
5
|
+
* belongs in repository B. The repository-binding guard (#2901) correctly refuses
|
|
6
|
+
* to execute A's plan from B, and editing `repositoryBinding` or copying the
|
|
7
|
+
* runtime directory would defeat that boundary and launder verification
|
|
8
|
+
* provenance. This module adds the explicit alternative: a *successor* run in B
|
|
9
|
+
* that adopts selected unfinished goals from A, with immutable provenance and
|
|
10
|
+
* exactly one execution owner.
|
|
11
|
+
*
|
|
12
|
+
* Three durable records, all written through the sanctioned `state-writer`
|
|
13
|
+
* primitives (gate G1):
|
|
14
|
+
*
|
|
15
|
+
* - **offer** (`succession/offer-<operationId>.json`, source side) — provenance
|
|
16
|
+
* plus the complete carryover: the source brief verbatim, each selected goal's
|
|
17
|
+
* objective verbatim, and its unresolved obligations.
|
|
18
|
+
* - **outgoing fence** (`succession/outgoing-fence.json`, source side) — the
|
|
19
|
+
* durable ownership fence. Source admission (`startNextUltragoalGoal`,
|
|
20
|
+
* `checkpointUltragoalGoal`) refuses to schedule or checkpoint a fenced goal,
|
|
21
|
+
* so ownership leaves the source at the moment of the offer rather than when
|
|
22
|
+
* the target happens to adopt. Between the two, nobody owns the goal — which is
|
|
23
|
+
* the point: there is never an interval in which both runs may resume it.
|
|
24
|
+
* - **adoption** (`succession/adoption.json`, target side) — the target claim and
|
|
25
|
+
* the recorded operation a retry must reconcile against.
|
|
26
|
+
*
|
|
27
|
+
* Invariants worth stating explicitly, because they are what make this auditable
|
|
28
|
+
* rather than a rebind:
|
|
29
|
+
*
|
|
30
|
+
* - The source's `brief.md`, `goals.json` and `ledger.jsonl` are never written.
|
|
31
|
+
* Not rewritten, and not appended to either: the offer deliberately records no
|
|
32
|
+
* ledger event, because the recorded source digests must stay verifiable at
|
|
33
|
+
* adoption time. `succession` is therefore excluded from `RECONCILE_COMMANDS`,
|
|
34
|
+
* whose failure path appends a `reconcile_failed` row.
|
|
35
|
+
* - Adoption performs **no** write to the source repository at all. This is
|
|
36
|
+
* structural, not a convention: `resolveSkcTarget` confines every sanctioned
|
|
37
|
+
* write to `<cwd>/.skc/**`, and adoption runs with `cwd` at the target.
|
|
38
|
+
* - Integrity digests are evidence, never authorization. A valid digest set with
|
|
39
|
+
* no fence, no bounded authorization, or a target the offer does not name is
|
|
40
|
+
* rejected.
|
|
41
|
+
* - Nothing that carries authority is inherited: statuses, completion receipts,
|
|
42
|
+
* quality gates, validation batches, steering, reviews, approvals and the
|
|
43
|
+
* activated `skcObjective` all stay behind as provenance. The successor plan is
|
|
44
|
+
* fresh, pending, and bound to the target repository's own identity.
|
|
45
|
+
*
|
|
46
|
+
* Scope limits, stated so a reviewer does not have to infer them: adoption needs
|
|
47
|
+
* filesystem read access to the source worktree (no remote or offline adoption);
|
|
48
|
+
* one outgoing fence exists per source session, so a session hands off once;
|
|
49
|
+
* adoption is pinned to the exact target worktree the offer named, because linked
|
|
50
|
+
* worktrees keep separate `.skc` state; and there is deliberately no `revoke`
|
|
51
|
+
* verb. Taking ownership back is the highest-risk operation in this design and
|
|
52
|
+
* belongs in its own reviewed change: any read-then-delete of the fence races a
|
|
53
|
+
* concurrent adoption and can revive the source while the target is starting, so
|
|
54
|
+
* an unadopted offer keeps its goals fenced until such a verb exists.
|
|
55
|
+
*/
|
|
56
|
+
import { createHash } from "node:crypto";
|
|
57
|
+
import * as fs from "node:fs/promises";
|
|
58
|
+
import * as path from "node:path";
|
|
59
|
+
import { renderCliWriteReceipt } from "./cli-write-receipt";
|
|
60
|
+
import { DEFAULT_ULTRAGOAL_OBJECTIVE } from "./goal-mode-request";
|
|
61
|
+
import {
|
|
62
|
+
assertPathUnderRepositoryBinding,
|
|
63
|
+
captureRepositoryBinding,
|
|
64
|
+
parseRepositoryBinding,
|
|
65
|
+
publicRepositoryBinding,
|
|
66
|
+
type RepositoryBinding,
|
|
67
|
+
RepositoryBindingError,
|
|
68
|
+
repositoryBindingsMatch,
|
|
69
|
+
} from "./repository-binding";
|
|
70
|
+
import { sessionUltragoalDir, skcRoot } from "./session-layout";
|
|
71
|
+
import {
|
|
72
|
+
resolveSkcSessionForRead,
|
|
73
|
+
resolveSkcSessionForWrite,
|
|
74
|
+
SessionResolutionError,
|
|
75
|
+
writeSessionActivityMarker,
|
|
76
|
+
} from "./session-resolution";
|
|
77
|
+
import {
|
|
78
|
+
AlreadyExistsError,
|
|
79
|
+
appendJsonlIdempotent,
|
|
80
|
+
beginWorkflowTransactionJournal,
|
|
81
|
+
completeWorkflowTransactionJournal,
|
|
82
|
+
createJsonNoClobber,
|
|
83
|
+
writeJsonAtomic,
|
|
84
|
+
} from "./state-writer";
|
|
85
|
+
import {
|
|
86
|
+
getUltragoalPaths,
|
|
87
|
+
readUltragoalLedger,
|
|
88
|
+
readUltragoalPlan,
|
|
89
|
+
type UltragoalCommandResult,
|
|
90
|
+
type UltragoalGoal,
|
|
91
|
+
type UltragoalGoalStatus,
|
|
92
|
+
type UltragoalLedgerEvent,
|
|
93
|
+
type UltragoalPlan,
|
|
94
|
+
type UltragoalSkcGoalMode,
|
|
95
|
+
withUltragoalPlanOwnership,
|
|
96
|
+
writePlan,
|
|
97
|
+
} from "./ultragoal-runtime";
|
|
98
|
+
import { isWorkflowPlaceholderText, WORKFLOW_PLACEHOLDER_CORRECTION } from "./workflow-placeholder";
|
|
99
|
+
|
|
100
|
+
export const ULTRAGOAL_SUCCESSION_OPERATION_SCHEMA = "skc.ultragoal_succession_operation.v1" as const;
|
|
101
|
+
export const ULTRAGOAL_SUCCESSION_OFFER_SCHEMA = "skc.ultragoal_succession_offer.v1" as const;
|
|
102
|
+
export const ULTRAGOAL_SUCCESSION_FENCE_SCHEMA = "skc.ultragoal_succession_fence.v1" as const;
|
|
103
|
+
export const ULTRAGOAL_SUCCESSION_ADOPTION_SCHEMA = "skc.ultragoal_succession_adoption.v1" as const;
|
|
104
|
+
export const ULTRAGOAL_SUCCESSION_CLAIM_SCHEMA = "skc.ultragoal_succession_claim.v1" as const;
|
|
105
|
+
|
|
106
|
+
export const SUCCESSION_ADOPTED_EVENT = "succession_adopted" as const;
|
|
107
|
+
|
|
108
|
+
const SUCCESSION_DIR_NAME = "succession";
|
|
109
|
+
const SUCCESSION_FENCE_FILE = "outgoing-fence.json";
|
|
110
|
+
const SUCCESSION_ADOPTION_FILE = "adoption.json";
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Statuses a goal may be adopted from. `complete` and `superseded` are finished
|
|
114
|
+
* work — adopting them would manufacture completion authority in the target.
|
|
115
|
+
* `active` is live, and a live goal can never be handed off.
|
|
116
|
+
*/
|
|
117
|
+
const ADOPTABLE_SOURCE_STATUSES = new Set<UltragoalGoalStatus>(["pending", "failed", "blocked", "review_blocked"]);
|
|
118
|
+
|
|
119
|
+
const PROVENANCE_NOTICE =
|
|
120
|
+
"Source statuses, completion receipts, quality gates, reviews and approvals recorded here are provenance only. " +
|
|
121
|
+
"They are never target completion receipts and never authorize action in the target repository.";
|
|
122
|
+
|
|
123
|
+
export type UltragoalSuccessionErrorCode =
|
|
124
|
+
| "source_plan_missing"
|
|
125
|
+
| "source_not_quiescent"
|
|
126
|
+
| "invalid_selection"
|
|
127
|
+
| "unsafe_target"
|
|
128
|
+
| "authorization_required"
|
|
129
|
+
| "divergent_operation"
|
|
130
|
+
| "goal_handed_off"
|
|
131
|
+
| "adoption_unpublished"
|
|
132
|
+
| "publication_conflict"
|
|
133
|
+
| "published_plan_missing"
|
|
134
|
+
| "fence_missing"
|
|
135
|
+
| "fence_mismatch"
|
|
136
|
+
| "source_changed"
|
|
137
|
+
| "offer_untrusted"
|
|
138
|
+
| "offer_path_escape"
|
|
139
|
+
| "target_mismatch"
|
|
140
|
+
| "target_occupied"
|
|
141
|
+
| "duplicate_adoption";
|
|
142
|
+
|
|
143
|
+
export class UltragoalSuccessionError extends Error {
|
|
144
|
+
readonly code: UltragoalSuccessionErrorCode;
|
|
145
|
+
|
|
146
|
+
constructor(code: UltragoalSuccessionErrorCode, message: string) {
|
|
147
|
+
super(message);
|
|
148
|
+
this.name = "UltragoalSuccessionError";
|
|
149
|
+
this.code = code;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface UltragoalSuccessionArtifactDigests {
|
|
154
|
+
briefSha256: string;
|
|
155
|
+
goalsSha256: string;
|
|
156
|
+
ledgerSha256: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface UltragoalSuccessionCarriedGoal {
|
|
160
|
+
sourceGoalId: string;
|
|
161
|
+
title: string;
|
|
162
|
+
/** The source objective, verbatim. Never a summary. */
|
|
163
|
+
objective: string;
|
|
164
|
+
/** Historical provenance only; never a receipt. */
|
|
165
|
+
sourceStatusAtOffer: UltragoalGoalStatus;
|
|
166
|
+
unresolvedObligations: string[];
|
|
167
|
+
/**
|
|
168
|
+
* Source dependency groups this goal belonged to, carried as an obligation
|
|
169
|
+
* with explicit id mapping. The group's *metadata* (batch hashes, receipts)
|
|
170
|
+
* is deliberately not carried — that would be inherited validation authority.
|
|
171
|
+
* What is carried is the requirement that these goals be validated together.
|
|
172
|
+
*/
|
|
173
|
+
dependencyGroups: UltragoalSuccessionDependencyGroup[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type UltragoalSuccessionDependencyGroupKind = "validation-batch" | "review-blocker";
|
|
177
|
+
|
|
178
|
+
export interface UltragoalSuccessionDependencyGroup {
|
|
179
|
+
kind: UltragoalSuccessionDependencyGroupKind;
|
|
180
|
+
groupId: string;
|
|
181
|
+
memberSourceGoalIds: string[];
|
|
182
|
+
/** For validation batches: the source goal that closed the batch. */
|
|
183
|
+
finalSourceGoalId?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Per-selected-goal snapshot of exactly what was offered.
|
|
188
|
+
*
|
|
189
|
+
* Admission compares *this*, not whole-file equality. The fence deliberately
|
|
190
|
+
* leaves unselected goals schedulable, so ordinary source progress moves
|
|
191
|
+
* `goals.json`/`ledger.jsonl` bytes; requiring whole-file equality would
|
|
192
|
+
* permanently strand the selected work through drift that never touched it
|
|
193
|
+
* (issue #5353 review item 3). Whole-file digests remain recorded as provenance
|
|
194
|
+
* and any observed drift is written into the adoption record.
|
|
195
|
+
*/
|
|
196
|
+
export interface UltragoalSuccessionSelectionSnapshot {
|
|
197
|
+
/** The brief carries global constraints, so it must not change. */
|
|
198
|
+
briefSha256: string;
|
|
199
|
+
goals: Array<{ sourceGoalId: string; recordSha256: string; obligationsSha256: string }>;
|
|
200
|
+
snapshotSha256: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface UltragoalSuccessionOffer {
|
|
204
|
+
schema: typeof ULTRAGOAL_SUCCESSION_OFFER_SCHEMA;
|
|
205
|
+
operationId: string;
|
|
206
|
+
createdAt: string;
|
|
207
|
+
source: {
|
|
208
|
+
sessionId: string;
|
|
209
|
+
repository: RepositoryBinding;
|
|
210
|
+
/** Whole-file digests at offer time. Provenance; not the admission test. */
|
|
211
|
+
artifacts: UltragoalSuccessionArtifactDigests;
|
|
212
|
+
/** The authoritative admission test for "did the offered work change". */
|
|
213
|
+
selectionSnapshot: UltragoalSuccessionSelectionSnapshot;
|
|
214
|
+
skcGoalMode: UltragoalSkcGoalMode;
|
|
215
|
+
};
|
|
216
|
+
target: { repository: RepositoryBinding };
|
|
217
|
+
selection: { goalIds: string[] };
|
|
218
|
+
carryover: {
|
|
219
|
+
/** The source brief, verbatim. */
|
|
220
|
+
brief: string;
|
|
221
|
+
goals: UltragoalSuccessionCarriedGoal[];
|
|
222
|
+
};
|
|
223
|
+
authorization: { statement: string; authorizedBy: string };
|
|
224
|
+
provenanceNotice: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface UltragoalSuccessionFence {
|
|
228
|
+
schema: typeof ULTRAGOAL_SUCCESSION_FENCE_SCHEMA;
|
|
229
|
+
operationId: string;
|
|
230
|
+
createdAt: string;
|
|
231
|
+
sourceSessionId: string;
|
|
232
|
+
sourceRepository: RepositoryBinding;
|
|
233
|
+
targetRepository: RepositoryBinding;
|
|
234
|
+
selectedGoalIds: string[];
|
|
235
|
+
sourceArtifacts: UltragoalSuccessionArtifactDigests;
|
|
236
|
+
offerPath: string;
|
|
237
|
+
offerSha256: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface UltragoalSuccessionGoalMapping {
|
|
241
|
+
sourceGoalId: string;
|
|
242
|
+
targetGoalId: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface UltragoalSuccessionAdoption {
|
|
246
|
+
schema: typeof ULTRAGOAL_SUCCESSION_ADOPTION_SCHEMA;
|
|
247
|
+
operationId: string;
|
|
248
|
+
status: "pending" | "published";
|
|
249
|
+
claimedAt: string;
|
|
250
|
+
publishedAt?: string;
|
|
251
|
+
targetSessionId: string;
|
|
252
|
+
targetRepository: RepositoryBinding;
|
|
253
|
+
source: {
|
|
254
|
+
sessionId: string;
|
|
255
|
+
repository: RepositoryBinding;
|
|
256
|
+
artifacts: UltragoalSuccessionArtifactDigests;
|
|
257
|
+
/** Whole-file digests observed at adoption; may differ from the offer. */
|
|
258
|
+
artifactsAtAdoption: UltragoalSuccessionArtifactDigests;
|
|
259
|
+
/** Whole-file drift on goals the offer did not select, recorded honestly. */
|
|
260
|
+
unselectedDrift: string[];
|
|
261
|
+
verifiedAt: string;
|
|
262
|
+
};
|
|
263
|
+
goalMap: UltragoalSuccessionGoalMapping[];
|
|
264
|
+
/**
|
|
265
|
+
* Content digest of the plan this operation is entitled to publish, recorded
|
|
266
|
+
* before publication. A pending replay may only proceed when the target plan
|
|
267
|
+
* is absent or matches this exactly; anything else is someone else's work.
|
|
268
|
+
*/
|
|
269
|
+
expectedPlanDigest: string;
|
|
270
|
+
/** Pinned so the published plan is a deterministic function of the claim. */
|
|
271
|
+
plannedAt: string;
|
|
272
|
+
offerPath: string;
|
|
273
|
+
offerSha256: string;
|
|
274
|
+
fencePath: string;
|
|
275
|
+
claimPath: string;
|
|
276
|
+
authorization: { statement: string; authorizedBy: string };
|
|
277
|
+
provenanceNotice: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Repository-wide adoption claim.
|
|
282
|
+
*
|
|
283
|
+
* The session-scoped adoption record cannot be the exclusion primitive: two
|
|
284
|
+
* simultaneous sessions in one repository each create their own file and both
|
|
285
|
+
* win. This claim is a single O_EXCL file per operation for the whole
|
|
286
|
+
* repository, so exclusion is atomic rather than scan-then-write.
|
|
287
|
+
*/
|
|
288
|
+
export interface UltragoalSuccessionRepositoryClaim {
|
|
289
|
+
schema: typeof ULTRAGOAL_SUCCESSION_CLAIM_SCHEMA;
|
|
290
|
+
operationId: string;
|
|
291
|
+
targetSessionId: string;
|
|
292
|
+
targetWorktreeRoot: string;
|
|
293
|
+
targetCommonDir: string | null;
|
|
294
|
+
claimedAt: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---- paths -------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
export function ultragoalSuccessionDir(cwd: string, sessionId: string): string {
|
|
300
|
+
return path.join(sessionUltragoalDir(cwd, sessionId), SUCCESSION_DIR_NAME);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function ultragoalSuccessionFencePath(cwd: string, sessionId: string): string {
|
|
304
|
+
return path.join(ultragoalSuccessionDir(cwd, sessionId), SUCCESSION_FENCE_FILE);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function ultragoalSuccessionAdoptionPath(cwd: string, sessionId: string): string {
|
|
308
|
+
return path.join(ultragoalSuccessionDir(cwd, sessionId), SUCCESSION_ADOPTION_FILE);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function ultragoalSuccessionOfferPath(cwd: string, sessionId: string, operationId: string): string {
|
|
312
|
+
return path.join(ultragoalSuccessionDir(cwd, sessionId), `offer-${operationId}.json`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Repository-wide (deliberately NOT session-scoped) adoption claim.
|
|
317
|
+
*
|
|
318
|
+
* Session directories cannot express "this repository has already adopted this
|
|
319
|
+
* operation", so the exclusion primitive lives beside them under the shared
|
|
320
|
+
* ultragoal root that `getUltragoalPaths` already uses for session-less state.
|
|
321
|
+
*/
|
|
322
|
+
export function ultragoalSuccessionClaimPath(cwd: string, operationId: string): string {
|
|
323
|
+
return path.join(skcRoot(cwd), "ultragoal", SUCCESSION_DIR_NAME, "claims", `${operationId}.json`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- small helpers -----------------------------------------------------
|
|
327
|
+
|
|
328
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
329
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function nonEmpty(value: unknown): string | null {
|
|
333
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function canonical(value: unknown): unknown {
|
|
337
|
+
if (Array.isArray(value)) return value.map(item => canonical(item));
|
|
338
|
+
if (!isRecord(value)) return value;
|
|
339
|
+
const sorted: Record<string, unknown> = {};
|
|
340
|
+
for (const key of Object.keys(value).sort()) {
|
|
341
|
+
if (value[key] !== undefined) sorted[key] = canonical(value[key]);
|
|
342
|
+
}
|
|
343
|
+
return sorted;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function sha256(bytes: Uint8Array | string): string {
|
|
347
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function readBytesOrNull(filePath: string): Promise<Uint8Array | null> {
|
|
351
|
+
try {
|
|
352
|
+
return await fs.readFile(filePath);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function readJsonOrNull(filePath: string): Promise<unknown | null> {
|
|
360
|
+
const bytes = await readBytesOrNull(filePath);
|
|
361
|
+
if (!bytes) return null;
|
|
362
|
+
try {
|
|
363
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Authority-bearing repository identity, and nothing else.
|
|
371
|
+
*
|
|
372
|
+
* `captureRepositoryBinding` also records `head`, `branch` and `displayPath`,
|
|
373
|
+
* which move as ordinary work happens. Including them in the operation
|
|
374
|
+
* fingerprint would make an otherwise identical retry look divergent, so the
|
|
375
|
+
* fingerprint uses exactly the fields `repositoryBindingsMatch` consults.
|
|
376
|
+
*/
|
|
377
|
+
function repositoryIdentity(binding: RepositoryBinding): Record<string, unknown> {
|
|
378
|
+
return {
|
|
379
|
+
worktreeRoot: path.resolve(binding.worktreeRoot),
|
|
380
|
+
commonDir: binding.commonDir === null ? null : path.resolve(binding.commonDir),
|
|
381
|
+
...(binding.relativeSubdir ? { relativeSubdir: binding.relativeSubdir } : {}),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function successionOperationId(input: {
|
|
386
|
+
sourceRepository: RepositoryBinding;
|
|
387
|
+
sourceSessionId: string;
|
|
388
|
+
selectionSnapshotSha256: string;
|
|
389
|
+
targetRepository: RepositoryBinding;
|
|
390
|
+
goalIds: readonly string[];
|
|
391
|
+
}): string {
|
|
392
|
+
return sha256(
|
|
393
|
+
JSON.stringify(
|
|
394
|
+
canonical({
|
|
395
|
+
schema: ULTRAGOAL_SUCCESSION_OPERATION_SCHEMA,
|
|
396
|
+
source: {
|
|
397
|
+
repository: repositoryIdentity(input.sourceRepository),
|
|
398
|
+
sessionId: input.sourceSessionId,
|
|
399
|
+
selectionSnapshot: input.selectionSnapshotSha256,
|
|
400
|
+
},
|
|
401
|
+
target: { repository: repositoryIdentity(input.targetRepository) },
|
|
402
|
+
selection: { goalIds: [...input.goalIds].toSorted() },
|
|
403
|
+
}),
|
|
404
|
+
),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function readSourceArtifactDigests(
|
|
409
|
+
cwd: string,
|
|
410
|
+
sessionId: string,
|
|
411
|
+
): Promise<UltragoalSuccessionArtifactDigests | null> {
|
|
412
|
+
const paths = getUltragoalPaths(cwd, sessionId);
|
|
413
|
+
const [brief, goals, ledger] = await Promise.all([
|
|
414
|
+
readBytesOrNull(paths.briefPath),
|
|
415
|
+
readBytesOrNull(paths.goalsPath),
|
|
416
|
+
readBytesOrNull(paths.ledgerPath),
|
|
417
|
+
]);
|
|
418
|
+
if (!brief || !goals || !ledger) return null;
|
|
419
|
+
return { briefSha256: sha256(brief), goalsSha256: sha256(goals), ledgerSha256: sha256(ledger) };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---- fence read + source admission ------------------------------------
|
|
423
|
+
|
|
424
|
+
function parseFence(value: unknown): UltragoalSuccessionFence | null {
|
|
425
|
+
if (!isRecord(value) || value.schema !== ULTRAGOAL_SUCCESSION_FENCE_SCHEMA) return null;
|
|
426
|
+
const operationId = nonEmpty(value.operationId);
|
|
427
|
+
const sourceSessionId = nonEmpty(value.sourceSessionId);
|
|
428
|
+
const offerPath = nonEmpty(value.offerPath);
|
|
429
|
+
const offerSha256 = nonEmpty(value.offerSha256);
|
|
430
|
+
if (!operationId || !sourceSessionId || !offerPath || !offerSha256) return null;
|
|
431
|
+
if (!Array.isArray(value.selectedGoalIds) || !value.selectedGoalIds.every(item => nonEmpty(item))) return null;
|
|
432
|
+
if (!isRecord(value.sourceArtifacts)) return null;
|
|
433
|
+
let sourceRepository: RepositoryBinding;
|
|
434
|
+
let targetRepository: RepositoryBinding;
|
|
435
|
+
try {
|
|
436
|
+
sourceRepository = parseRepositoryBinding(value.sourceRepository);
|
|
437
|
+
targetRepository = parseRepositoryBinding(value.targetRepository);
|
|
438
|
+
} catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
schema: ULTRAGOAL_SUCCESSION_FENCE_SCHEMA,
|
|
443
|
+
operationId,
|
|
444
|
+
createdAt: nonEmpty(value.createdAt) ?? "",
|
|
445
|
+
sourceSessionId,
|
|
446
|
+
sourceRepository,
|
|
447
|
+
targetRepository,
|
|
448
|
+
selectedGoalIds: value.selectedGoalIds as string[],
|
|
449
|
+
sourceArtifacts: {
|
|
450
|
+
briefSha256: String(value.sourceArtifacts.briefSha256 ?? ""),
|
|
451
|
+
goalsSha256: String(value.sourceArtifacts.goalsSha256 ?? ""),
|
|
452
|
+
ledgerSha256: String(value.sourceArtifacts.ledgerSha256 ?? ""),
|
|
453
|
+
},
|
|
454
|
+
offerPath,
|
|
455
|
+
offerSha256,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Read the durable outgoing ownership fence for a source session, if any. */
|
|
460
|
+
export async function readUltragoalSuccessionFence(
|
|
461
|
+
cwd: string,
|
|
462
|
+
sessionId: string,
|
|
463
|
+
): Promise<UltragoalSuccessionFence | null> {
|
|
464
|
+
return parseFence(await readJsonOrNull(ultragoalSuccessionFencePath(cwd, sessionId)));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Read the durable adoption claim for a target session, if any. */
|
|
468
|
+
export async function readUltragoalSuccessionAdoption(
|
|
469
|
+
cwd: string,
|
|
470
|
+
sessionId: string,
|
|
471
|
+
): Promise<UltragoalSuccessionAdoption | null> {
|
|
472
|
+
const value = await readJsonOrNull(ultragoalSuccessionAdoptionPath(cwd, sessionId));
|
|
473
|
+
if (!isRecord(value) || value.schema !== ULTRAGOAL_SUCCESSION_ADOPTION_SCHEMA) return null;
|
|
474
|
+
return value as unknown as UltragoalSuccessionAdoption;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function resolveSessionForFenceLookup(cwd: string, sessionId?: string | null): Promise<string | null> {
|
|
478
|
+
const explicit = sessionId?.trim();
|
|
479
|
+
if (explicit) return explicit;
|
|
480
|
+
try {
|
|
481
|
+
return (await resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID })).skcSessionId;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
// No resolvable session means no discoverable fence. The caller's own
|
|
484
|
+
// session handling reports the resolution problem; this guard never
|
|
485
|
+
// converts it into a different error.
|
|
486
|
+
if (error instanceof SessionResolutionError) return null;
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Source admission guard: a goal that has been handed off to a successor run is
|
|
493
|
+
* no longer this run's to schedule or checkpoint.
|
|
494
|
+
*
|
|
495
|
+
* Goals outside the fenced selection stay fully schedulable — the fence defines
|
|
496
|
+
* one owner per goal, not a freeze of the whole source run.
|
|
497
|
+
*/
|
|
498
|
+
export async function assertUltragoalGoalNotFenced(
|
|
499
|
+
cwd: string,
|
|
500
|
+
sessionId: string | null | undefined,
|
|
501
|
+
goalId: string,
|
|
502
|
+
): Promise<void> {
|
|
503
|
+
const resolved = await resolveSessionForFenceLookup(cwd, sessionId);
|
|
504
|
+
if (!resolved) return;
|
|
505
|
+
const fence = await readUltragoalSuccessionFence(cwd, resolved);
|
|
506
|
+
if (!fence?.selectedGoalIds.includes(goalId)) return;
|
|
507
|
+
throw new UltragoalSuccessionError(
|
|
508
|
+
"goal_handed_off",
|
|
509
|
+
`Goal ${goalId} was handed off to a successor run in ${fence.targetRepository.worktreeRoot} by succession ${fence.operationId}; ` +
|
|
510
|
+
`this source run no longer owns it. The outgoing ownership fence is at ${ultragoalSuccessionFencePath(cwd, resolved)}. ` +
|
|
511
|
+
"Run `skc ultragoal succession status` for the recorded operation.",
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Target admission guard: an adoption that has not finished publishing does not
|
|
517
|
+
* yet own anything.
|
|
518
|
+
*
|
|
519
|
+
* Publication writes `goals.json` before it marks the claim published, so a
|
|
520
|
+
* crash leaves a *visible plan with an unpublished claim*. A visible plan is not
|
|
521
|
+
* admission evidence — the claim is. Without this, the target executes goals
|
|
522
|
+
* whose adoption was never completed, and the run's own completion receipts
|
|
523
|
+
* would rest on a transaction that never committed.
|
|
524
|
+
*
|
|
525
|
+
* Callers must invoke this before any mutation so a refusal leaves `goals.json`
|
|
526
|
+
* and `ledger.jsonl` untouched.
|
|
527
|
+
*/
|
|
528
|
+
export async function assertUltragoalAdoptionPublished(
|
|
529
|
+
cwd: string,
|
|
530
|
+
sessionId: string | null | undefined,
|
|
531
|
+
): Promise<void> {
|
|
532
|
+
const resolved = await resolveSessionForFenceLookup(cwd, sessionId);
|
|
533
|
+
if (!resolved) return;
|
|
534
|
+
const adoption = await readUltragoalSuccessionAdoption(cwd, resolved);
|
|
535
|
+
if (!adoption || adoption.status === "published") return;
|
|
536
|
+
throw new UltragoalSuccessionError(
|
|
537
|
+
"adoption_unpublished",
|
|
538
|
+
`This run was adopted from ${adoption.source.repository.worktreeRoot} by succession ${adoption.operationId}, but its ` +
|
|
539
|
+
`adoption is still pending publication, so it does not yet own any goal. A visible goals.json is not admission ` +
|
|
540
|
+
`evidence; the adoption record at ${ultragoalSuccessionAdoptionPath(cwd, resolved)} is. Re-run ` +
|
|
541
|
+
"`skc ultragoal succession adopt` with the same offer to reconcile the recorded operation.",
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ---- offer -------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
export interface UltragoalSuccessionOfferInput {
|
|
548
|
+
cwd: string;
|
|
549
|
+
sessionId?: string | null;
|
|
550
|
+
targetRepositoryPath: string;
|
|
551
|
+
goalIds: readonly string[];
|
|
552
|
+
authorization: string;
|
|
553
|
+
authorizedBy: string;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export interface UltragoalSuccessionOfferResult {
|
|
557
|
+
operationId: string;
|
|
558
|
+
offerPath: string;
|
|
559
|
+
fencePath: string;
|
|
560
|
+
offer: UltragoalSuccessionOffer;
|
|
561
|
+
/** True when an identical prior operation was reconciled instead of re-recorded. */
|
|
562
|
+
reconciled: boolean;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function requireBoundedAuthorization(statement: string, authorizedBy: string): void {
|
|
566
|
+
if (isWorkflowPlaceholderText(statement) || isWorkflowPlaceholderText(authorizedBy)) {
|
|
567
|
+
throw new UltragoalSuccessionError(
|
|
568
|
+
"authorization_required",
|
|
569
|
+
`Cross-repository succession requires an explicit --authorize statement and --authorized-by identity; ${WORKFLOW_PLACEHOLDER_CORRECTION}.`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function requireAdoptableSelection(plan: UltragoalPlan, goalIds: readonly string[]): UltragoalGoal[] {
|
|
575
|
+
if (goalIds.length === 0) {
|
|
576
|
+
throw new UltragoalSuccessionError(
|
|
577
|
+
"invalid_selection",
|
|
578
|
+
"Cross-repository succession requires at least one explicit --goal-id; there is no implicit or wildcard selection.",
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const seen = new Set<string>();
|
|
582
|
+
const selected: UltragoalGoal[] = [];
|
|
583
|
+
for (const goalId of goalIds) {
|
|
584
|
+
const trimmed = goalId.trim();
|
|
585
|
+
if (!trimmed) {
|
|
586
|
+
throw new UltragoalSuccessionError("invalid_selection", "--goal-id must be a non-empty durable goal id");
|
|
587
|
+
}
|
|
588
|
+
if (seen.has(trimmed)) {
|
|
589
|
+
throw new UltragoalSuccessionError("invalid_selection", `--goal-id ${trimmed} was selected more than once`);
|
|
590
|
+
}
|
|
591
|
+
seen.add(trimmed);
|
|
592
|
+
const goal = plan.goals.find(item => item.id === trimmed);
|
|
593
|
+
if (!goal) {
|
|
594
|
+
throw new UltragoalSuccessionError(
|
|
595
|
+
"invalid_selection",
|
|
596
|
+
`No ultragoal goal found for ${trimmed} in the source plan.`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (!ADOPTABLE_SOURCE_STATUSES.has(goal.status)) {
|
|
600
|
+
throw new UltragoalSuccessionError(
|
|
601
|
+
"invalid_selection",
|
|
602
|
+
`Goal ${trimmed} has source status ${goal.status} and cannot be adopted; only unfinished goals ` +
|
|
603
|
+
`(${[...ADOPTABLE_SOURCE_STATUSES].join(", ")}) may be handed to a successor run.`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
selected.push(goal);
|
|
607
|
+
}
|
|
608
|
+
// Preserve durable plan order so the successor's goal order is deterministic
|
|
609
|
+
// regardless of the order the operator typed the flags in.
|
|
610
|
+
const ordered = plan.goals.filter(goal => seen.has(goal.id));
|
|
611
|
+
requireCompleteDependencyGroups(plan, ordered, seen);
|
|
612
|
+
return ordered;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Refuse a selection that takes part of a group the source validated as a unit.
|
|
617
|
+
*
|
|
618
|
+
* Completion evidence is deliberately reset on adoption, but the *relationship*
|
|
619
|
+
* that made these goals inseparable is a constraint, not evidence. Splitting a
|
|
620
|
+
* validation batch, or separating a review blocker from the goal it blocks,
|
|
621
|
+
* would silently drop an unresolved obligation (issue #5353 review item 4).
|
|
622
|
+
*/
|
|
623
|
+
function requireCompleteDependencyGroups(
|
|
624
|
+
plan: UltragoalPlan,
|
|
625
|
+
selected: readonly UltragoalGoal[],
|
|
626
|
+
seen: ReadonlySet<string>,
|
|
627
|
+
): void {
|
|
628
|
+
for (const goal of selected) {
|
|
629
|
+
const batch = goal.validationBatch;
|
|
630
|
+
if (batch) {
|
|
631
|
+
const missing = batch.memberIds.filter(memberId => !seen.has(memberId));
|
|
632
|
+
if (missing.length > 0) {
|
|
633
|
+
throw new UltragoalSuccessionError(
|
|
634
|
+
"invalid_selection",
|
|
635
|
+
`Goal ${goal.id} belongs to validation batch ${batch.batchId}, whose members [${batch.memberIds.join(", ")}] ` +
|
|
636
|
+
`were validated as one unit. The selection omits [${missing.join(", ")}], which would drop that obligation. ` +
|
|
637
|
+
"Select the whole batch or none of it.",
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const blockedGoalId =
|
|
642
|
+
goal.steering && goal.steering.kind === "review_blocker" ? nonEmpty(goal.steering.blockedGoalId) : null;
|
|
643
|
+
if (blockedGoalId && !seen.has(blockedGoalId)) {
|
|
644
|
+
const blocked = plan.goals.find(item => item.id === blockedGoalId);
|
|
645
|
+
// A blocker whose blocked goal is already terminal carries no live
|
|
646
|
+
// obligation; only an unresolved pairing must travel together.
|
|
647
|
+
if (blocked && !["complete", "superseded"].includes(blocked.status)) {
|
|
648
|
+
throw new UltragoalSuccessionError(
|
|
649
|
+
"invalid_selection",
|
|
650
|
+
`Goal ${goal.id} is a review blocker for ${blockedGoalId}, which is still ${blocked.status} and is not selected. ` +
|
|
651
|
+
"Select both so the unresolved blocker relationship travels with the work, or neither.",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Source dependency groups a selected goal carries into the successor run. */
|
|
659
|
+
function dependencyGroupsFor(goal: UltragoalGoal): UltragoalSuccessionDependencyGroup[] {
|
|
660
|
+
const groups: UltragoalSuccessionDependencyGroup[] = [];
|
|
661
|
+
if (goal.validationBatch) {
|
|
662
|
+
groups.push({
|
|
663
|
+
kind: "validation-batch",
|
|
664
|
+
groupId: goal.validationBatch.batchId,
|
|
665
|
+
memberSourceGoalIds: [...goal.validationBatch.memberIds],
|
|
666
|
+
finalSourceGoalId: goal.validationBatch.finalGoalId,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
const blockedGoalId =
|
|
670
|
+
goal.steering && goal.steering.kind === "review_blocker" ? nonEmpty(goal.steering.blockedGoalId) : null;
|
|
671
|
+
if (blockedGoalId) {
|
|
672
|
+
groups.push({
|
|
673
|
+
kind: "review-blocker",
|
|
674
|
+
groupId: `${goal.id}->${blockedGoalId}`,
|
|
675
|
+
memberSourceGoalIds: [goal.id, blockedGoalId],
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return groups;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function requireQuiescentSource(plan: UltragoalPlan): void {
|
|
682
|
+
const active = plan.goals.filter(goal => goal.status === "active");
|
|
683
|
+
if (active.length === 0) return;
|
|
684
|
+
throw new UltragoalSuccessionError(
|
|
685
|
+
"source_not_quiescent",
|
|
686
|
+
`The source run is still live: ${active.map(goal => goal.id).join(", ")} ${active.length === 1 ? "is" : "are"} active. ` +
|
|
687
|
+
"Check the live goal to a durable non-active status before handing any goal to a successor run.",
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Unresolved obligations for one goal, in durable order and deduplicated.
|
|
693
|
+
*
|
|
694
|
+
* These are copied so the successor inherits the *requirements* that were never
|
|
695
|
+
* met, not a summary that quietly shrinks them.
|
|
696
|
+
*/
|
|
697
|
+
function unresolvedObligationsFor(goal: UltragoalGoal, ledger: readonly UltragoalLedgerEvent[]): string[] {
|
|
698
|
+
const obligations: string[] = [];
|
|
699
|
+
const push = (value: string): void => {
|
|
700
|
+
const trimmed = value.trim();
|
|
701
|
+
if (trimmed && !obligations.includes(trimmed)) obligations.push(trimmed);
|
|
702
|
+
};
|
|
703
|
+
const evidence = nonEmpty(goal.evidence);
|
|
704
|
+
if (evidence) push(`[source status ${goal.status}] ${evidence}`);
|
|
705
|
+
for (const event of ledger) {
|
|
706
|
+
if (event.goalId !== goal.id) continue;
|
|
707
|
+
if (event.event === "blocker_classified") {
|
|
708
|
+
const classification = nonEmpty(event.classification) ?? "unclassified";
|
|
709
|
+
const detail = nonEmpty(event.evidence);
|
|
710
|
+
if (detail) push(`[blocker ${classification}] ${detail}`);
|
|
711
|
+
}
|
|
712
|
+
if (event.event === "critic_verdict" && Array.isArray(event.blockers)) {
|
|
713
|
+
for (const blocker of event.blockers) {
|
|
714
|
+
if (typeof blocker === "string") push(`[critic blocker] ${blocker}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return obligations;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* The authoritative "did the offered work change" test.
|
|
723
|
+
*
|
|
724
|
+
* Hashes exactly what was offered — the brief plus, per selected goal, the
|
|
725
|
+
* durable record fields that become the successor's requirements and that
|
|
726
|
+
* goal's unresolved obligations. Deliberately excludes everything else in
|
|
727
|
+
* `goals.json` and `ledger.jsonl`: the fence leaves unselected goals
|
|
728
|
+
* schedulable, so whole-file equality would strand the selected work the first
|
|
729
|
+
* time the source made unrelated progress.
|
|
730
|
+
*/
|
|
731
|
+
function buildSelectionSnapshot(
|
|
732
|
+
plan: UltragoalPlan,
|
|
733
|
+
selected: readonly UltragoalGoal[],
|
|
734
|
+
ledger: readonly UltragoalLedgerEvent[],
|
|
735
|
+
): UltragoalSuccessionSelectionSnapshot {
|
|
736
|
+
const goals = selected.map(goal => ({
|
|
737
|
+
sourceGoalId: goal.id,
|
|
738
|
+
recordSha256: sha256(
|
|
739
|
+
JSON.stringify(
|
|
740
|
+
canonical({
|
|
741
|
+
id: goal.id,
|
|
742
|
+
title: goal.title,
|
|
743
|
+
objective: goal.objective,
|
|
744
|
+
status: goal.status,
|
|
745
|
+
dependencyGroups: dependencyGroupsFor(goal),
|
|
746
|
+
}),
|
|
747
|
+
),
|
|
748
|
+
),
|
|
749
|
+
obligationsSha256: sha256(JSON.stringify(unresolvedObligationsFor(goal, ledger))),
|
|
750
|
+
}));
|
|
751
|
+
const briefSha256 = sha256(plan.brief);
|
|
752
|
+
return {
|
|
753
|
+
briefSha256,
|
|
754
|
+
goals,
|
|
755
|
+
snapshotSha256: sha256(JSON.stringify(canonical({ briefSha256, goals }))),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function resolveTargetRepositoryBinding(
|
|
760
|
+
targetRepositoryPath: string,
|
|
761
|
+
sourceBinding: RepositoryBinding,
|
|
762
|
+
): Promise<RepositoryBinding> {
|
|
763
|
+
const raw = targetRepositoryPath.trim();
|
|
764
|
+
if (!raw) {
|
|
765
|
+
throw new UltragoalSuccessionError(
|
|
766
|
+
"unsafe_target",
|
|
767
|
+
"--target-repo is required and must be a real directory path",
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
let resolved: string;
|
|
771
|
+
try {
|
|
772
|
+
resolved = await fs.realpath(path.resolve(raw));
|
|
773
|
+
} catch {
|
|
774
|
+
throw new UltragoalSuccessionError(
|
|
775
|
+
"unsafe_target",
|
|
776
|
+
`--target-repo does not resolve to an existing directory: ${raw}`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
const stat = await fs.stat(resolved);
|
|
780
|
+
if (!stat.isDirectory()) {
|
|
781
|
+
throw new UltragoalSuccessionError("unsafe_target", `--target-repo is not a directory: ${raw}`);
|
|
782
|
+
}
|
|
783
|
+
const target = publicRepositoryBinding(await captureRepositoryBinding(resolved, { displayPath: resolved }));
|
|
784
|
+
if (repositoryBindingsMatch(target, sourceBinding)) {
|
|
785
|
+
throw new UltragoalSuccessionError(
|
|
786
|
+
"unsafe_target",
|
|
787
|
+
`--target-repo resolves to the source repository itself (${target.worktreeRoot}). ` +
|
|
788
|
+
"Succession moves work across repositories; it is not a rebind of the original run.",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
return target;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Record an explicit successor offer and fence the selected goals off from the
|
|
796
|
+
* source run. Writes nothing to the source brief, goals or ledger.
|
|
797
|
+
*/
|
|
798
|
+
export async function offerUltragoalSuccession(
|
|
799
|
+
input: UltragoalSuccessionOfferInput,
|
|
800
|
+
): Promise<UltragoalSuccessionOfferResult> {
|
|
801
|
+
const sessionId =
|
|
802
|
+
input.sessionId?.trim() ||
|
|
803
|
+
resolveSkcSessionForWrite(input.cwd, { envSessionId: process.env.SKC_SESSION_ID }).skcSessionId;
|
|
804
|
+
|
|
805
|
+
requireBoundedAuthorization(input.authorization, input.authorizedBy);
|
|
806
|
+
|
|
807
|
+
// Deciding that a source run is quiescent, and fencing it, is one indivisible
|
|
808
|
+
// step. Outside this exclusion an offer can read `pending` from a start that
|
|
809
|
+
// has already passed its own fence check and is about to commit `active`, and
|
|
810
|
+
// both runs then believe they own the goal (#5353).
|
|
811
|
+
return withUltragoalPlanOwnership(input.cwd, sessionId, () => offerUnderSourceOwnership(input, sessionId));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function offerUnderSourceOwnership(
|
|
815
|
+
input: UltragoalSuccessionOfferInput,
|
|
816
|
+
sessionId: string,
|
|
817
|
+
): Promise<UltragoalSuccessionOfferResult> {
|
|
818
|
+
// Read under the exclusion: any in-flight start has committed by now, so this
|
|
819
|
+
// snapshot is the run's settled state rather than a mid-write guess.
|
|
820
|
+
const plan = await readUltragoalPlan(input.cwd, sessionId);
|
|
821
|
+
if (!plan) {
|
|
822
|
+
throw new UltragoalSuccessionError(
|
|
823
|
+
"source_plan_missing",
|
|
824
|
+
"No ultragoal plan found in this repository/session; there is nothing to hand to a successor run.",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
if (!plan.repositoryBinding) {
|
|
828
|
+
throw new UltragoalSuccessionError(
|
|
829
|
+
"source_plan_missing",
|
|
830
|
+
"The source ultragoal plan has no repositoryBinding, so its repository identity cannot be recorded as provenance.",
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
requireQuiescentSource(plan);
|
|
834
|
+
const selected = requireAdoptableSelection(plan, input.goalIds);
|
|
835
|
+
|
|
836
|
+
const sourceRepository = publicRepositoryBinding(plan.repositoryBinding);
|
|
837
|
+
const targetRepository = await resolveTargetRepositoryBinding(input.targetRepositoryPath, sourceRepository);
|
|
838
|
+
|
|
839
|
+
const sourceArtifacts = await readSourceArtifactDigests(input.cwd, sessionId);
|
|
840
|
+
if (!sourceArtifacts) {
|
|
841
|
+
throw new UltragoalSuccessionError(
|
|
842
|
+
"source_plan_missing",
|
|
843
|
+
"The source brief, goals and ledger must all exist before their digests can be recorded as provenance.",
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
const ledger = await readUltragoalLedger(input.cwd, sessionId);
|
|
847
|
+
const selectionSnapshot = buildSelectionSnapshot(plan, selected, ledger);
|
|
848
|
+
|
|
849
|
+
// Keyed on the selection snapshot, not the whole-file digests: an identical
|
|
850
|
+
// retry after unrelated source progress must reconcile, not look divergent.
|
|
851
|
+
const operationId = successionOperationId({
|
|
852
|
+
sourceRepository,
|
|
853
|
+
sourceSessionId: sessionId,
|
|
854
|
+
selectionSnapshotSha256: selectionSnapshot.snapshotSha256,
|
|
855
|
+
targetRepository,
|
|
856
|
+
goalIds: selected.map(goal => goal.id),
|
|
857
|
+
});
|
|
858
|
+
const fencePath = ultragoalSuccessionFencePath(input.cwd, sessionId);
|
|
859
|
+
|
|
860
|
+
// A retry must reconcile the exact recorded operation or fail closed — never
|
|
861
|
+
// blindly re-record a fence over a different one.
|
|
862
|
+
const existingFence = await readUltragoalSuccessionFence(input.cwd, sessionId);
|
|
863
|
+
if (existingFence) {
|
|
864
|
+
if (existingFence.operationId !== operationId) {
|
|
865
|
+
throw new UltragoalSuccessionError(
|
|
866
|
+
"divergent_operation",
|
|
867
|
+
`This session already fenced goals [${existingFence.selectedGoalIds.join(", ")}] for succession ` +
|
|
868
|
+
`${existingFence.operationId} to ${existingFence.targetRepository.worktreeRoot}. The requested operation ` +
|
|
869
|
+
`${operationId} differs, so it is refused rather than resumed. Inspect ${fencePath}.`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
const recordedBytes = await readBytesOrNull(existingFence.offerPath);
|
|
873
|
+
if (!recordedBytes || sha256(recordedBytes) !== existingFence.offerSha256) {
|
|
874
|
+
throw new UltragoalSuccessionError(
|
|
875
|
+
"fence_mismatch",
|
|
876
|
+
`The recorded offer for succession ${operationId} is missing or no longer matches the digest in ${fencePath}.`,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
operationId,
|
|
881
|
+
offerPath: existingFence.offerPath,
|
|
882
|
+
fencePath,
|
|
883
|
+
offer: JSON.parse(new TextDecoder().decode(recordedBytes)) as UltragoalSuccessionOffer,
|
|
884
|
+
reconciled: true,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const offer: UltragoalSuccessionOffer = {
|
|
889
|
+
schema: ULTRAGOAL_SUCCESSION_OFFER_SCHEMA,
|
|
890
|
+
operationId,
|
|
891
|
+
createdAt: new Date().toISOString(),
|
|
892
|
+
source: {
|
|
893
|
+
sessionId,
|
|
894
|
+
repository: sourceRepository,
|
|
895
|
+
artifacts: sourceArtifacts,
|
|
896
|
+
selectionSnapshot,
|
|
897
|
+
skcGoalMode: plan.skcGoalMode,
|
|
898
|
+
},
|
|
899
|
+
target: { repository: targetRepository },
|
|
900
|
+
selection: { goalIds: selected.map(goal => goal.id) },
|
|
901
|
+
carryover: {
|
|
902
|
+
brief: plan.brief,
|
|
903
|
+
goals: selected.map(goal => ({
|
|
904
|
+
sourceGoalId: goal.id,
|
|
905
|
+
title: goal.title,
|
|
906
|
+
objective: goal.objective,
|
|
907
|
+
sourceStatusAtOffer: goal.status,
|
|
908
|
+
unresolvedObligations: unresolvedObligationsFor(goal, ledger),
|
|
909
|
+
dependencyGroups: dependencyGroupsFor(goal),
|
|
910
|
+
})),
|
|
911
|
+
},
|
|
912
|
+
authorization: { statement: input.authorization.trim(), authorizedBy: input.authorizedBy.trim() },
|
|
913
|
+
provenanceNotice: PROVENANCE_NOTICE,
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const offerPath = ultragoalSuccessionOfferPath(input.cwd, sessionId, operationId);
|
|
917
|
+
const auditContext = {
|
|
918
|
+
cwd: input.cwd,
|
|
919
|
+
audit: { category: "artifact" as const, verb: "write", owner: "skc-runtime" as const, sessionId },
|
|
920
|
+
};
|
|
921
|
+
try {
|
|
922
|
+
await createJsonNoClobber(offerPath, offer, auditContext);
|
|
923
|
+
} catch (error) {
|
|
924
|
+
// An offer file with this exact operation id already exists (a previous
|
|
925
|
+
// attempt crashed between writing the offer and creating the fence). Reuse
|
|
926
|
+
// its recorded bytes rather than overwriting evidence.
|
|
927
|
+
if (!(error instanceof AlreadyExistsError)) throw error;
|
|
928
|
+
}
|
|
929
|
+
const offerBytes = await readBytesOrNull(offerPath);
|
|
930
|
+
if (!offerBytes) {
|
|
931
|
+
throw new UltragoalSuccessionError("offer_untrusted", `Failed to persist the succession offer at ${offerPath}.`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const fence: UltragoalSuccessionFence = {
|
|
935
|
+
schema: ULTRAGOAL_SUCCESSION_FENCE_SCHEMA,
|
|
936
|
+
operationId,
|
|
937
|
+
createdAt: new Date().toISOString(),
|
|
938
|
+
sourceSessionId: sessionId,
|
|
939
|
+
sourceRepository,
|
|
940
|
+
targetRepository,
|
|
941
|
+
selectedGoalIds: selected.map(goal => goal.id),
|
|
942
|
+
sourceArtifacts,
|
|
943
|
+
offerPath,
|
|
944
|
+
offerSha256: sha256(offerBytes),
|
|
945
|
+
};
|
|
946
|
+
try {
|
|
947
|
+
await createJsonNoClobber(fencePath, fence, {
|
|
948
|
+
cwd: input.cwd,
|
|
949
|
+
audit: { category: "state", verb: "write", owner: "skc-runtime", sessionId },
|
|
950
|
+
});
|
|
951
|
+
} catch (error) {
|
|
952
|
+
if (!(error instanceof AlreadyExistsError)) throw error;
|
|
953
|
+
// Lost a race with a concurrent offer: re-read and apply the same
|
|
954
|
+
// reconcile-or-fail-closed rule.
|
|
955
|
+
const raced = await readUltragoalSuccessionFence(input.cwd, sessionId);
|
|
956
|
+
if (!raced || raced.operationId !== operationId) {
|
|
957
|
+
throw new UltragoalSuccessionError(
|
|
958
|
+
"divergent_operation",
|
|
959
|
+
`A concurrent succession offer fenced this session for a different operation; ${operationId} is refused.`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// The activity marker lives outside brief/goals/ledger, so recording that this
|
|
965
|
+
// session did something does not disturb the digests the offer just recorded.
|
|
966
|
+
await writeSessionActivityMarker(input.cwd, sessionId, { writer: "ultragoal-succession", path: fencePath });
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
operationId,
|
|
970
|
+
offerPath,
|
|
971
|
+
fencePath,
|
|
972
|
+
offer: JSON.parse(new TextDecoder().decode(offerBytes)) as UltragoalSuccessionOffer,
|
|
973
|
+
reconciled: false,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ---- adopt -------------------------------------------------------------
|
|
978
|
+
|
|
979
|
+
export interface UltragoalSuccessionAdoptInput {
|
|
980
|
+
cwd: string;
|
|
981
|
+
sessionId?: string | null;
|
|
982
|
+
offerPath: string;
|
|
983
|
+
skcGoalMode?: UltragoalSkcGoalMode;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export interface UltragoalSuccessionAdoptResult {
|
|
987
|
+
operationId: string;
|
|
988
|
+
adoptionPath: string;
|
|
989
|
+
plan: UltragoalPlan;
|
|
990
|
+
goalMap: UltragoalSuccessionGoalMapping[];
|
|
991
|
+
reconciled: boolean;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function parseOfferDocument(bytes: Uint8Array, offerPath: string): UltragoalSuccessionOffer {
|
|
995
|
+
let value: unknown;
|
|
996
|
+
try {
|
|
997
|
+
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
998
|
+
} catch {
|
|
999
|
+
throw new UltragoalSuccessionError("offer_untrusted", `Succession offer is not valid JSON: ${offerPath}`);
|
|
1000
|
+
}
|
|
1001
|
+
if (!isRecord(value) || value.schema !== ULTRAGOAL_SUCCESSION_OFFER_SCHEMA) {
|
|
1002
|
+
throw new UltragoalSuccessionError(
|
|
1003
|
+
"offer_untrusted",
|
|
1004
|
+
`Succession offer must declare schema ${ULTRAGOAL_SUCCESSION_OFFER_SCHEMA}: ${offerPath}`,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
const operationId = nonEmpty(value.operationId);
|
|
1008
|
+
const source = value.source;
|
|
1009
|
+
const target = value.target;
|
|
1010
|
+
const selection = value.selection;
|
|
1011
|
+
const carryover = value.carryover;
|
|
1012
|
+
const authorization = value.authorization;
|
|
1013
|
+
if (
|
|
1014
|
+
!operationId ||
|
|
1015
|
+
!isRecord(source) ||
|
|
1016
|
+
!isRecord(target) ||
|
|
1017
|
+
!isRecord(selection) ||
|
|
1018
|
+
!isRecord(carryover) ||
|
|
1019
|
+
!isRecord(authorization) ||
|
|
1020
|
+
!nonEmpty(source.sessionId) ||
|
|
1021
|
+
typeof carryover.brief !== "string" ||
|
|
1022
|
+
!Array.isArray(selection.goalIds) ||
|
|
1023
|
+
!Array.isArray(carryover.goals) ||
|
|
1024
|
+
!isRecord(source.artifacts)
|
|
1025
|
+
) {
|
|
1026
|
+
throw new UltragoalSuccessionError(
|
|
1027
|
+
"offer_untrusted",
|
|
1028
|
+
`Succession offer is structurally incomplete: ${offerPath}`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
if (carryover.goals.length !== selection.goalIds.length || carryover.goals.length === 0) {
|
|
1032
|
+
throw new UltragoalSuccessionError(
|
|
1033
|
+
"offer_untrusted",
|
|
1034
|
+
`Succession offer carryover does not cover its own selection: ${offerPath}`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
for (const goal of carryover.goals) {
|
|
1038
|
+
if (!isRecord(goal) || !nonEmpty(goal.sourceGoalId) || !nonEmpty(goal.title) || !nonEmpty(goal.objective)) {
|
|
1039
|
+
throw new UltragoalSuccessionError(
|
|
1040
|
+
"offer_untrusted",
|
|
1041
|
+
`Succession offer carries a goal without an id, title or objective: ${offerPath}`,
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
if (!Array.isArray(goal.unresolvedObligations)) {
|
|
1045
|
+
throw new UltragoalSuccessionError(
|
|
1046
|
+
"offer_untrusted",
|
|
1047
|
+
`Succession offer carries a goal without an unresolvedObligations list: ${offerPath}`,
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
try {
|
|
1052
|
+
parseRepositoryBinding(source.repository);
|
|
1053
|
+
parseRepositoryBinding(target.repository);
|
|
1054
|
+
} catch {
|
|
1055
|
+
throw new UltragoalSuccessionError(
|
|
1056
|
+
"offer_untrusted",
|
|
1057
|
+
`Succession offer carries an invalid repository binding: ${offerPath}`,
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
return value as unknown as UltragoalSuccessionOffer;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Content identity of a successor plan, independent of the revision stamping
|
|
1065
|
+
* `writeGuardedJsonAtomic` applies. Used to prove that a plan already on disk is
|
|
1066
|
+
* this operation's own partial write rather than unrelated target work.
|
|
1067
|
+
*/
|
|
1068
|
+
function successorPlanContentDigest(plan: UltragoalPlan): string {
|
|
1069
|
+
const { state_revision: _revision, source_state_revision: _source, receipt: _receipt, ...content } = plan;
|
|
1070
|
+
return sha256(JSON.stringify(canonical(content)));
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function composeSuccessorObjective(
|
|
1074
|
+
offer: UltragoalSuccessionOffer,
|
|
1075
|
+
carried: UltragoalSuccessionCarriedGoal,
|
|
1076
|
+
goalMap: readonly UltragoalSuccessionGoalMapping[],
|
|
1077
|
+
): string {
|
|
1078
|
+
const lines = [
|
|
1079
|
+
carried.objective,
|
|
1080
|
+
"",
|
|
1081
|
+
`## Carried from ${offer.source.repository.worktreeRoot} session ${offer.source.sessionId} goal ${carried.sourceGoalId}`,
|
|
1082
|
+
`Succession ${offer.operationId}.`,
|
|
1083
|
+
`Source status at handoff: ${carried.sourceStatusAtOffer}. ${PROVENANCE_NOTICE}`,
|
|
1084
|
+
];
|
|
1085
|
+
if (carried.unresolvedObligations.length > 0) {
|
|
1086
|
+
lines.push("", "Unresolved obligations carried forward — these are requirements, not history:");
|
|
1087
|
+
for (const obligation of carried.unresolvedObligations) lines.push(`- ${obligation}`);
|
|
1088
|
+
}
|
|
1089
|
+
// The source's validation metadata is deliberately not inherited, but the
|
|
1090
|
+
// relationship that made these goals inseparable is a live constraint.
|
|
1091
|
+
for (const group of carried.dependencyGroups ?? []) {
|
|
1092
|
+
const mapped = group.memberSourceGoalIds
|
|
1093
|
+
.map(memberId => {
|
|
1094
|
+
const row = goalMap.find(item => item.sourceGoalId === memberId);
|
|
1095
|
+
return row ? `${memberId} -> ${row.targetGoalId}` : `${memberId} -> (not adopted)`;
|
|
1096
|
+
})
|
|
1097
|
+
.join(", ");
|
|
1098
|
+
lines.push(
|
|
1099
|
+
"",
|
|
1100
|
+
group.kind === "validation-batch"
|
|
1101
|
+
? `Validation group ${group.groupId} carried from the source: these goals were validated as one unit and must be ` +
|
|
1102
|
+
`re-validated together here. Members ${mapped}.` +
|
|
1103
|
+
(group.finalSourceGoalId ? ` The source closed the group on ${group.finalSourceGoalId}.` : "")
|
|
1104
|
+
: `Unresolved review-blocker relationship carried from the source: ${mapped}. Resolving one without the other ` +
|
|
1105
|
+
"leaves the original obligation open.",
|
|
1106
|
+
"The source's own batch metadata and receipts are not carried; this run must establish its own validation.",
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
return lines.join("\n");
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function composeSuccessorBrief(
|
|
1113
|
+
offer: UltragoalSuccessionOffer,
|
|
1114
|
+
goalMap: readonly UltragoalSuccessionGoalMapping[],
|
|
1115
|
+
): string {
|
|
1116
|
+
return [
|
|
1117
|
+
`# Successor run adopted from ${offer.source.repository.worktreeRoot}`,
|
|
1118
|
+
"",
|
|
1119
|
+
`Succession: ${offer.operationId}`,
|
|
1120
|
+
`Source session: ${offer.source.sessionId}`,
|
|
1121
|
+
`Adopted goals: ${goalMap.map(row => `${row.sourceGoalId} -> ${row.targetGoalId}`).join(", ")}`,
|
|
1122
|
+
`Source digests: goals.json sha256:${offer.source.artifacts.goalsSha256}, ` +
|
|
1123
|
+
`ledger.jsonl sha256:${offer.source.artifacts.ledgerSha256}, brief.md sha256:${offer.source.artifacts.briefSha256}`,
|
|
1124
|
+
`Authorized by ${offer.authorization.authorizedBy}: ${offer.authorization.statement}`,
|
|
1125
|
+
"",
|
|
1126
|
+
PROVENANCE_NOTICE,
|
|
1127
|
+
"This run starts fresh validation under this repository's own binding.",
|
|
1128
|
+
"",
|
|
1129
|
+
`## Source brief (verbatim, sha256:${offer.source.artifacts.briefSha256})`,
|
|
1130
|
+
"",
|
|
1131
|
+
offer.carryover.brief,
|
|
1132
|
+
].join("\n");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Adopt an explicit successor offer into this repository/session.
|
|
1137
|
+
*
|
|
1138
|
+
* Fails closed on a forged or stale offer, a changed source, a missing source
|
|
1139
|
+
* fence, an unnamed target, an occupied target, and any duplicate or divergent
|
|
1140
|
+
* adoption anywhere in this repository. A retry reconciles the exact recorded
|
|
1141
|
+
* operation or fails closed; it never resumes a different one.
|
|
1142
|
+
*/
|
|
1143
|
+
export async function adoptUltragoalSuccession(
|
|
1144
|
+
input: UltragoalSuccessionAdoptInput,
|
|
1145
|
+
): Promise<UltragoalSuccessionAdoptResult> {
|
|
1146
|
+
const sessionId =
|
|
1147
|
+
input.sessionId?.trim() ||
|
|
1148
|
+
resolveSkcSessionForWrite(input.cwd, { envSessionId: process.env.SKC_SESSION_ID }).skcSessionId;
|
|
1149
|
+
|
|
1150
|
+
const declaredPath = path.resolve(input.offerPath.trim());
|
|
1151
|
+
const initialBytes = await readBytesOrNull(declaredPath);
|
|
1152
|
+
if (!initialBytes) {
|
|
1153
|
+
throw new UltragoalSuccessionError("offer_untrusted", `No succession offer found at ${declaredPath}`);
|
|
1154
|
+
}
|
|
1155
|
+
const declaredOffer = parseOfferDocument(initialBytes, declaredPath);
|
|
1156
|
+
const sourceRepository = parseRepositoryBinding(declaredOffer.source.repository);
|
|
1157
|
+
|
|
1158
|
+
// The offer must physically live inside the source worktree it claims. This
|
|
1159
|
+
// rejects a relocated copy and a symlink that escapes the bound root.
|
|
1160
|
+
let resolvedOfferPath: string;
|
|
1161
|
+
try {
|
|
1162
|
+
resolvedOfferPath = assertPathUnderRepositoryBinding(sourceRepository, declaredPath);
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
if (error instanceof RepositoryBindingError) {
|
|
1165
|
+
throw new UltragoalSuccessionError(
|
|
1166
|
+
"offer_path_escape",
|
|
1167
|
+
`The succession offer at ${declaredPath} resolves outside the source repository it names ` +
|
|
1168
|
+
`(${sourceRepository.worktreeRoot}): ${error.message}`,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
const offerBytes = await readBytesOrNull(resolvedOfferPath);
|
|
1174
|
+
if (!offerBytes) {
|
|
1175
|
+
throw new UltragoalSuccessionError("offer_untrusted", `No succession offer found at ${resolvedOfferPath}`);
|
|
1176
|
+
}
|
|
1177
|
+
const offer = parseOfferDocument(offerBytes, resolvedOfferPath);
|
|
1178
|
+
const offerSha256 = sha256(offerBytes);
|
|
1179
|
+
|
|
1180
|
+
// Structural seal: the recorded fingerprint must be derivable from the offer's
|
|
1181
|
+
// own declared identity, session, selection snapshot and selection.
|
|
1182
|
+
const recomputed = successionOperationId({
|
|
1183
|
+
sourceRepository,
|
|
1184
|
+
sourceSessionId: offer.source.sessionId,
|
|
1185
|
+
selectionSnapshotSha256: offer.source.selectionSnapshot.snapshotSha256,
|
|
1186
|
+
targetRepository: parseRepositoryBinding(offer.target.repository),
|
|
1187
|
+
goalIds: offer.selection.goalIds,
|
|
1188
|
+
});
|
|
1189
|
+
if (recomputed !== offer.operationId) {
|
|
1190
|
+
throw new UltragoalSuccessionError(
|
|
1191
|
+
"offer_untrusted",
|
|
1192
|
+
`Succession offer ${resolvedOfferPath} declares operation ${offer.operationId} but its own contents derive ${recomputed}.`,
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const fencePath = ultragoalSuccessionFencePath(sourceRepository.worktreeRoot, offer.source.sessionId);
|
|
1197
|
+
const fence = await readUltragoalSuccessionFence(sourceRepository.worktreeRoot, offer.source.sessionId);
|
|
1198
|
+
if (!fence) {
|
|
1199
|
+
throw new UltragoalSuccessionError(
|
|
1200
|
+
"fence_missing",
|
|
1201
|
+
`The source run has no outgoing ownership fence at ${fencePath}. An offer document alone never authorizes ` +
|
|
1202
|
+
"adoption: without the fence the source may still own the selected goals.",
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
if (fence.operationId !== offer.operationId) {
|
|
1206
|
+
throw new UltragoalSuccessionError(
|
|
1207
|
+
"fence_mismatch",
|
|
1208
|
+
`The source fence records succession ${fence.operationId}, not ${offer.operationId}.`,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
// Byte seal. The fingerprint above covers identity and selection; this covers
|
|
1212
|
+
// every byte, including the carried objectives and obligations.
|
|
1213
|
+
if (fence.offerSha256 !== offerSha256) {
|
|
1214
|
+
throw new UltragoalSuccessionError(
|
|
1215
|
+
"offer_untrusted",
|
|
1216
|
+
`Succession offer bytes at ${resolvedOfferPath} do not match the digest recorded in the source fence ` +
|
|
1217
|
+
`(${fence.offerSha256}). The offer has been altered since it was recorded.`,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Integrity evidence, scoped to what was actually offered.
|
|
1222
|
+
//
|
|
1223
|
+
// Whole-file equality is deliberately NOT the admission test: the fence leaves
|
|
1224
|
+
// unselected goals schedulable, so ordinary source progress moves goals.json
|
|
1225
|
+
// and ledger.jsonl and would permanently strand the selected work (issue #5353
|
|
1226
|
+
// review item 3). The authoritative test is the selection snapshot — the brief
|
|
1227
|
+
// plus each selected goal's record and obligations.
|
|
1228
|
+
const currentDigests = await readSourceArtifactDigests(sourceRepository.worktreeRoot, offer.source.sessionId);
|
|
1229
|
+
const sourcePlanNow = await readUltragoalPlan(sourceRepository.worktreeRoot, offer.source.sessionId);
|
|
1230
|
+
if (!currentDigests || !sourcePlanNow) {
|
|
1231
|
+
throw new UltragoalSuccessionError(
|
|
1232
|
+
"source_changed",
|
|
1233
|
+
`The source brief, goals or ledger is no longer readable under ${sourceRepository.worktreeRoot}.`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
const stillSelected = offer.selection.goalIds.map(goalId => sourcePlanNow.goals.find(item => item.id === goalId));
|
|
1237
|
+
if (stillSelected.some(goal => goal === undefined)) {
|
|
1238
|
+
throw new UltragoalSuccessionError(
|
|
1239
|
+
"source_changed",
|
|
1240
|
+
`The source plan no longer contains every offered goal [${offer.selection.goalIds.join(", ")}].`,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
const sourceLedgerNow = await readUltragoalLedger(sourceRepository.worktreeRoot, offer.source.sessionId);
|
|
1244
|
+
const snapshotNow = buildSelectionSnapshot(sourcePlanNow, stillSelected as UltragoalGoal[], sourceLedgerNow);
|
|
1245
|
+
if (snapshotNow.snapshotSha256 !== offer.source.selectionSnapshot.snapshotSha256) {
|
|
1246
|
+
throw new UltragoalSuccessionError(
|
|
1247
|
+
"source_changed",
|
|
1248
|
+
"The offered work itself changed after the succession offer was recorded (the source brief, or a selected " +
|
|
1249
|
+
"goal's record or unresolved obligations), so the carryover is no longer a truthful account of it. " +
|
|
1250
|
+
"Record a fresh offer from the current source state.",
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
// Unselected-goal drift is legitimate and is recorded rather than rejected.
|
|
1254
|
+
const unselectedDrift: string[] = [];
|
|
1255
|
+
if (currentDigests.goalsSha256 !== offer.source.artifacts.goalsSha256) unselectedDrift.push("goals.json");
|
|
1256
|
+
if (currentDigests.ledgerSha256 !== offer.source.artifacts.ledgerSha256) unselectedDrift.push("ledger.jsonl");
|
|
1257
|
+
|
|
1258
|
+
const targetActual = publicRepositoryBinding(await captureRepositoryBinding(input.cwd, { displayPath: input.cwd }));
|
|
1259
|
+
const targetDeclared = parseRepositoryBinding(offer.target.repository);
|
|
1260
|
+
if (!repositoryBindingsMatch(targetActual, targetDeclared)) {
|
|
1261
|
+
throw new UltragoalSuccessionError(
|
|
1262
|
+
"target_mismatch",
|
|
1263
|
+
`This repository (${targetActual.worktreeRoot}) is not the target the succession offer names ` +
|
|
1264
|
+
`(${targetDeclared.worktreeRoot}). Adopt from the named target repository.`,
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
// Stricter than the binding match on purpose. `repositoryBindingsMatch` accepts
|
|
1268
|
+
// any linked worktree of the named repository, but each worktree carries its own
|
|
1269
|
+
// `.skc`, so two admissible worktrees could each take a local claim for one
|
|
1270
|
+
// operation. Pinning the exact worktree the offer named keeps the claim below a
|
|
1271
|
+
// sound repository-wide exclusion. This narrows admission; it never widens it.
|
|
1272
|
+
if (path.resolve(targetActual.worktreeRoot) !== path.resolve(targetDeclared.worktreeRoot)) {
|
|
1273
|
+
throw new UltragoalSuccessionError(
|
|
1274
|
+
"target_mismatch",
|
|
1275
|
+
`This worktree (${targetActual.worktreeRoot}) shares a repository with the named target ` +
|
|
1276
|
+
`(${targetDeclared.worktreeRoot}) but is not that worktree. Linked worktrees keep separate .skc state, so ` +
|
|
1277
|
+
"adoption is pinned to the exact worktree the offer named.",
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
if (repositoryBindingsMatch(targetActual, sourceRepository)) {
|
|
1281
|
+
throw new UltragoalSuccessionError(
|
|
1282
|
+
"unsafe_target",
|
|
1283
|
+
"Refusing to adopt a succession offer into its own source repository.",
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Everything from here on inspects and republishes this session's plan, so it
|
|
1288
|
+
// runs inside the same ownership exclusion the target's own admission uses.
|
|
1289
|
+
// Without it, two identical retries — or a retry racing a successful first
|
|
1290
|
+
// caller that has already started work — can interleave claim inspection with
|
|
1291
|
+
// publication and reset committed target progress (#5353).
|
|
1292
|
+
return withUltragoalPlanOwnership(input.cwd, sessionId, () =>
|
|
1293
|
+
publishAdoptionUnderTargetOwnership({
|
|
1294
|
+
input,
|
|
1295
|
+
sessionId,
|
|
1296
|
+
offer,
|
|
1297
|
+
offerSha256,
|
|
1298
|
+
resolvedOfferPath,
|
|
1299
|
+
fencePath,
|
|
1300
|
+
sourceRepository,
|
|
1301
|
+
targetActual,
|
|
1302
|
+
currentDigests,
|
|
1303
|
+
unselectedDrift,
|
|
1304
|
+
}),
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
interface AdoptionPublicationContext {
|
|
1309
|
+
input: UltragoalSuccessionAdoptInput;
|
|
1310
|
+
sessionId: string;
|
|
1311
|
+
offer: UltragoalSuccessionOffer;
|
|
1312
|
+
offerSha256: string;
|
|
1313
|
+
resolvedOfferPath: string;
|
|
1314
|
+
fencePath: string;
|
|
1315
|
+
sourceRepository: RepositoryBinding;
|
|
1316
|
+
targetActual: RepositoryBinding;
|
|
1317
|
+
currentDigests: UltragoalSuccessionArtifactDigests;
|
|
1318
|
+
unselectedDrift: string[];
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function publishAdoptionUnderTargetOwnership(
|
|
1322
|
+
context: AdoptionPublicationContext,
|
|
1323
|
+
): Promise<UltragoalSuccessionAdoptResult> {
|
|
1324
|
+
const {
|
|
1325
|
+
input,
|
|
1326
|
+
sessionId,
|
|
1327
|
+
offer,
|
|
1328
|
+
offerSha256,
|
|
1329
|
+
resolvedOfferPath,
|
|
1330
|
+
fencePath,
|
|
1331
|
+
sourceRepository,
|
|
1332
|
+
targetActual,
|
|
1333
|
+
currentDigests,
|
|
1334
|
+
unselectedDrift,
|
|
1335
|
+
} = context;
|
|
1336
|
+
const adoptionPath = ultragoalSuccessionAdoptionPath(input.cwd, sessionId);
|
|
1337
|
+
const claimPath = ultragoalSuccessionClaimPath(input.cwd, offer.operationId);
|
|
1338
|
+
const ownClaim = await readUltragoalSuccessionAdoption(input.cwd, sessionId);
|
|
1339
|
+
if (ownClaim && ownClaim.operationId !== offer.operationId) {
|
|
1340
|
+
throw new UltragoalSuccessionError(
|
|
1341
|
+
"duplicate_adoption",
|
|
1342
|
+
`This session already recorded succession ${ownClaim.operationId}; adopting ${offer.operationId} on top of it ` +
|
|
1343
|
+
`is refused rather than resumed. Inspect ${adoptionPath}.`,
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const goalMap: UltragoalSuccessionGoalMapping[] = offer.carryover.goals.map((carried, index) => ({
|
|
1348
|
+
sourceGoalId: carried.sourceGoalId,
|
|
1349
|
+
targetGoalId: `G${String(index + 1).padStart(3, "0")}`,
|
|
1350
|
+
}));
|
|
1351
|
+
|
|
1352
|
+
// Repository-wide exclusion, atomic by O_EXCL rather than scan-then-write.
|
|
1353
|
+
// A per-session claim plus a directory scan is a TOCTOU: two simultaneous
|
|
1354
|
+
// sessions both scan empty and both create their own file, producing two
|
|
1355
|
+
// owners for one operation.
|
|
1356
|
+
const repositoryClaim: UltragoalSuccessionRepositoryClaim = {
|
|
1357
|
+
schema: ULTRAGOAL_SUCCESSION_CLAIM_SCHEMA,
|
|
1358
|
+
operationId: offer.operationId,
|
|
1359
|
+
targetSessionId: sessionId,
|
|
1360
|
+
targetWorktreeRoot: path.resolve(targetActual.worktreeRoot),
|
|
1361
|
+
targetCommonDir: targetActual.commonDir === null ? null : path.resolve(targetActual.commonDir),
|
|
1362
|
+
claimedAt: new Date().toISOString(),
|
|
1363
|
+
};
|
|
1364
|
+
try {
|
|
1365
|
+
await createJsonNoClobber(claimPath, repositoryClaim, {
|
|
1366
|
+
cwd: input.cwd,
|
|
1367
|
+
audit: { category: "state", verb: "write", owner: "skc-runtime", sessionId },
|
|
1368
|
+
});
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
if (!(error instanceof AlreadyExistsError)) throw error;
|
|
1371
|
+
const held = await readJsonOrNull(claimPath);
|
|
1372
|
+
const heldSession =
|
|
1373
|
+
isRecord(held) && held.schema === ULTRAGOAL_SUCCESSION_CLAIM_SCHEMA ? nonEmpty(held.targetSessionId) : null;
|
|
1374
|
+
if (heldSession !== sessionId) {
|
|
1375
|
+
throw new UltragoalSuccessionError(
|
|
1376
|
+
"duplicate_adoption",
|
|
1377
|
+
`Succession ${offer.operationId} is already claimed in this repository by session ${heldSession ?? "(unreadable)"} ` +
|
|
1378
|
+
`(${claimPath}). One operation has exactly one execution owner.`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const now = new Date().toISOString();
|
|
1384
|
+
// Pinned so the plan this operation may publish is a deterministic function of
|
|
1385
|
+
// the claim: a replay must be able to prove the bytes on disk are its own.
|
|
1386
|
+
const plannedAt = ownClaim?.plannedAt ?? now;
|
|
1387
|
+
const auditContext = {
|
|
1388
|
+
cwd: input.cwd,
|
|
1389
|
+
audit: { category: "state" as const, verb: "write", owner: "skc-runtime" as const, sessionId },
|
|
1390
|
+
};
|
|
1391
|
+
const plan: UltragoalPlan = {
|
|
1392
|
+
version: 1,
|
|
1393
|
+
brief: composeSuccessorBrief(offer, goalMap),
|
|
1394
|
+
// Fresh target-side validation policy. The source's mode is provenance in
|
|
1395
|
+
// the offer; it never silently selects the successor's validation posture.
|
|
1396
|
+
skcGoalMode: input.skcGoalMode ?? "aggregate",
|
|
1397
|
+
skcObjective: DEFAULT_ULTRAGOAL_OBJECTIVE,
|
|
1398
|
+
goals: offer.carryover.goals.map((carried, index) => ({
|
|
1399
|
+
id: goalMap[index]!.targetGoalId,
|
|
1400
|
+
title: carried.title,
|
|
1401
|
+
objective: composeSuccessorObjective(offer, carried, goalMap),
|
|
1402
|
+
status: "pending",
|
|
1403
|
+
createdAt: plannedAt,
|
|
1404
|
+
updatedAt: plannedAt,
|
|
1405
|
+
})),
|
|
1406
|
+
repositoryBinding: targetActual,
|
|
1407
|
+
createdAt: plannedAt,
|
|
1408
|
+
updatedAt: plannedAt,
|
|
1409
|
+
};
|
|
1410
|
+
const expectedPlanDigest = successorPlanContentDigest(plan);
|
|
1411
|
+
|
|
1412
|
+
if (ownClaim?.status === "published") {
|
|
1413
|
+
const published = await readUltragoalPlan(input.cwd, sessionId);
|
|
1414
|
+
if (!published) {
|
|
1415
|
+
// Do not reconstruct. A published plan that has vanished may have been
|
|
1416
|
+
// cleared deliberately; silently recreating it would resurrect a run the
|
|
1417
|
+
// operator retired and could contradict receipts that referenced it.
|
|
1418
|
+
throw new UltragoalSuccessionError(
|
|
1419
|
+
"published_plan_missing",
|
|
1420
|
+
`Succession ${offer.operationId} is recorded as published for this session, but its plan at ` +
|
|
1421
|
+
`${getUltragoalPaths(input.cwd, sessionId).goalsPath} is gone. Refusing to reconstruct it: adopt into a ` +
|
|
1422
|
+
"fresh session, or restore the plan, so an intentionally cleared run is never resurrected.",
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
return {
|
|
1426
|
+
operationId: offer.operationId,
|
|
1427
|
+
adoptionPath,
|
|
1428
|
+
plan: published,
|
|
1429
|
+
goalMap: ownClaim.goalMap,
|
|
1430
|
+
reconciled: true,
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// A matching pending operation id is NOT overwrite authority. Reconcile only
|
|
1435
|
+
// this operation's own partial bytes; anything else is someone's real work.
|
|
1436
|
+
const existingPlan = await readUltragoalPlan(input.cwd, sessionId);
|
|
1437
|
+
if (existingPlan) {
|
|
1438
|
+
if (!ownClaim) {
|
|
1439
|
+
throw new UltragoalSuccessionError(
|
|
1440
|
+
"target_occupied",
|
|
1441
|
+
`This repository/session already has an ultragoal plan at ${getUltragoalPaths(input.cwd, sessionId).goalsPath}. ` +
|
|
1442
|
+
"Adopt into a fresh session so the successor run cannot overwrite unrelated work.",
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
if (successorPlanContentDigest(existingPlan) !== (ownClaim.expectedPlanDigest ?? expectedPlanDigest)) {
|
|
1446
|
+
throw new UltragoalSuccessionError(
|
|
1447
|
+
"publication_conflict",
|
|
1448
|
+
`Succession ${offer.operationId} has an unpublished claim for this session, but the plan at ` +
|
|
1449
|
+
`${getUltragoalPaths(input.cwd, sessionId).goalsPath} is not the plan this operation was going to write. ` +
|
|
1450
|
+
"Refusing to overwrite it: a matching operation id is not authority over unrelated target work.",
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const claim: UltragoalSuccessionAdoption = {
|
|
1456
|
+
schema: ULTRAGOAL_SUCCESSION_ADOPTION_SCHEMA,
|
|
1457
|
+
operationId: offer.operationId,
|
|
1458
|
+
status: "pending",
|
|
1459
|
+
claimedAt: ownClaim?.claimedAt ?? now,
|
|
1460
|
+
targetSessionId: sessionId,
|
|
1461
|
+
targetRepository: targetActual,
|
|
1462
|
+
source: {
|
|
1463
|
+
sessionId: offer.source.sessionId,
|
|
1464
|
+
repository: sourceRepository,
|
|
1465
|
+
artifacts: offer.source.artifacts,
|
|
1466
|
+
artifactsAtAdoption: currentDigests,
|
|
1467
|
+
unselectedDrift,
|
|
1468
|
+
verifiedAt: now,
|
|
1469
|
+
},
|
|
1470
|
+
goalMap,
|
|
1471
|
+
expectedPlanDigest,
|
|
1472
|
+
plannedAt,
|
|
1473
|
+
offerPath: resolvedOfferPath,
|
|
1474
|
+
offerSha256,
|
|
1475
|
+
fencePath,
|
|
1476
|
+
claimPath,
|
|
1477
|
+
authorization: offer.authorization,
|
|
1478
|
+
provenanceNotice: PROVENANCE_NOTICE,
|
|
1479
|
+
};
|
|
1480
|
+
|
|
1481
|
+
await beginWorkflowTransactionJournal({
|
|
1482
|
+
cwd: input.cwd,
|
|
1483
|
+
sessionId,
|
|
1484
|
+
mutationId: offer.operationId,
|
|
1485
|
+
callee: "ultragoal",
|
|
1486
|
+
paths: [adoptionPath, getUltragoalPaths(input.cwd, sessionId).goalsPath],
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
if (!ownClaim) {
|
|
1490
|
+
try {
|
|
1491
|
+
await createJsonNoClobber(adoptionPath, claim, auditContext);
|
|
1492
|
+
} catch (error) {
|
|
1493
|
+
if (!(error instanceof AlreadyExistsError)) throw error;
|
|
1494
|
+
const raced = await readUltragoalSuccessionAdoption(input.cwd, sessionId);
|
|
1495
|
+
if (!raced || raced.operationId !== offer.operationId) {
|
|
1496
|
+
throw new UltragoalSuccessionError(
|
|
1497
|
+
"duplicate_adoption",
|
|
1498
|
+
`A concurrent adoption claimed this session for a different operation; ${offer.operationId} is refused.`,
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
await writePlan(input.cwd, plan, sessionId, { lockHeld: true });
|
|
1505
|
+
|
|
1506
|
+
await appendJsonlIdempotent(
|
|
1507
|
+
getUltragoalPaths(input.cwd, sessionId).ledgerPath,
|
|
1508
|
+
{
|
|
1509
|
+
eventId: `succession-${offer.operationId}`,
|
|
1510
|
+
event: SUCCESSION_ADOPTED_EVENT,
|
|
1511
|
+
operationId: offer.operationId,
|
|
1512
|
+
sourceRepository: sourceRepository.worktreeRoot,
|
|
1513
|
+
sourceSessionId: offer.source.sessionId,
|
|
1514
|
+
sourceArtifacts: offer.source.artifacts,
|
|
1515
|
+
goalMap,
|
|
1516
|
+
authorizedBy: offer.authorization.authorizedBy,
|
|
1517
|
+
timestamp: now,
|
|
1518
|
+
},
|
|
1519
|
+
{
|
|
1520
|
+
cwd: input.cwd,
|
|
1521
|
+
audit: { category: "ledger", verb: "append", owner: "skc-runtime", sessionId },
|
|
1522
|
+
key: entry =>
|
|
1523
|
+
isRecord(entry) && entry.event === SUCCESSION_ADOPTED_EVENT ? String(entry.operationId) : undefined,
|
|
1524
|
+
},
|
|
1525
|
+
);
|
|
1526
|
+
|
|
1527
|
+
await writeJsonAtomic(
|
|
1528
|
+
adoptionPath,
|
|
1529
|
+
{ ...claim, status: "published", publishedAt: new Date().toISOString() },
|
|
1530
|
+
auditContext,
|
|
1531
|
+
);
|
|
1532
|
+
await completeWorkflowTransactionJournal(input.cwd, sessionId, offer.operationId);
|
|
1533
|
+
|
|
1534
|
+
return { operationId: offer.operationId, adoptionPath, plan, goalMap, reconciled: Boolean(ownClaim) };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// ---- CLI ---------------------------------------------------------------
|
|
1538
|
+
|
|
1539
|
+
function flagValue(args: readonly string[], flag: string): string | undefined {
|
|
1540
|
+
const index = args.indexOf(flag);
|
|
1541
|
+
return index < 0 ? undefined : args[index + 1];
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function flagValues(args: readonly string[], flag: string): string[] {
|
|
1545
|
+
const values: string[] = [];
|
|
1546
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1547
|
+
if (args[index] !== flag) continue;
|
|
1548
|
+
const value = args[index + 1];
|
|
1549
|
+
if (value === undefined || value.startsWith("-")) continue;
|
|
1550
|
+
values.push(value);
|
|
1551
|
+
index += 1;
|
|
1552
|
+
}
|
|
1553
|
+
return values;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async function renderSuccessionStatus(cwd: string, json: boolean): Promise<UltragoalCommandResult> {
|
|
1557
|
+
const sessionId = (await resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID })).skcSessionId;
|
|
1558
|
+
const [fence, adoption] = await Promise.all([
|
|
1559
|
+
readUltragoalSuccessionFence(cwd, sessionId),
|
|
1560
|
+
readUltragoalSuccessionAdoption(cwd, sessionId),
|
|
1561
|
+
]);
|
|
1562
|
+
const payload = {
|
|
1563
|
+
session_id: sessionId,
|
|
1564
|
+
fenced: fence !== null,
|
|
1565
|
+
operation_id: fence?.operationId ?? adoption?.operationId,
|
|
1566
|
+
selected_goal_ids: fence?.selectedGoalIds,
|
|
1567
|
+
target_repository: fence?.targetRepository.worktreeRoot,
|
|
1568
|
+
fence_path: fence ? ultragoalSuccessionFencePath(cwd, sessionId) : undefined,
|
|
1569
|
+
offer_path: fence?.offerPath,
|
|
1570
|
+
adopted: adoption !== null,
|
|
1571
|
+
adoption_status: adoption?.status,
|
|
1572
|
+
adoption_source_repository: adoption?.source.repository.worktreeRoot,
|
|
1573
|
+
adoption_goal_map: adoption?.goalMap,
|
|
1574
|
+
};
|
|
1575
|
+
if (json) return { status: 0, stdout: `${JSON.stringify(payload, null, 2)}\n` };
|
|
1576
|
+
const lines: string[] = [];
|
|
1577
|
+
lines.push(
|
|
1578
|
+
fence
|
|
1579
|
+
? `Outgoing fence: succession ${fence.operationId} handed [${fence.selectedGoalIds.join(", ")}] to ${fence.targetRepository.worktreeRoot}.`
|
|
1580
|
+
: "Outgoing fence: none. This session owns every goal in its plan.",
|
|
1581
|
+
);
|
|
1582
|
+
lines.push(
|
|
1583
|
+
adoption
|
|
1584
|
+
? `Adoption: ${adoption.status} succession ${adoption.operationId} from ${adoption.source.repository.worktreeRoot} session ${adoption.source.sessionId}.`
|
|
1585
|
+
: "Adoption: none. This session's plan was not adopted from another repository.",
|
|
1586
|
+
);
|
|
1587
|
+
return { status: 0, stdout: `${lines.join("\n")}\n` };
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* `skc ultragoal succession <offer|adopt|status>`.
|
|
1592
|
+
*
|
|
1593
|
+
* Kept out of `RECONCILE_COMMANDS` on purpose: the reconcile pass appends a
|
|
1594
|
+
* `reconcile_failed` ledger row on failure, which would move the very source
|
|
1595
|
+
* digests this feature records. The target's workflow state and HUD reconcile on
|
|
1596
|
+
* the next ordinary `skc ultragoal status`.
|
|
1597
|
+
*/
|
|
1598
|
+
export async function runUltragoalSuccessionCommand(
|
|
1599
|
+
args: readonly string[],
|
|
1600
|
+
cwd: string,
|
|
1601
|
+
): Promise<UltragoalCommandResult> {
|
|
1602
|
+
const positional = args.filter(arg => !arg.startsWith("-"));
|
|
1603
|
+
const subcommand = positional[1];
|
|
1604
|
+
const json = args.includes("--json");
|
|
1605
|
+
try {
|
|
1606
|
+
if (subcommand === "status") return await renderSuccessionStatus(cwd, json);
|
|
1607
|
+
if (subcommand === "offer") {
|
|
1608
|
+
const result = await offerUltragoalSuccession({
|
|
1609
|
+
cwd,
|
|
1610
|
+
targetRepositoryPath: flagValue(args, "--target-repo") ?? "",
|
|
1611
|
+
goalIds: flagValues(args, "--goal-id"),
|
|
1612
|
+
authorization: flagValue(args, "--authorize") ?? "",
|
|
1613
|
+
authorizedBy: flagValue(args, "--authorized-by") ?? "",
|
|
1614
|
+
});
|
|
1615
|
+
return {
|
|
1616
|
+
status: 0,
|
|
1617
|
+
stdout: json
|
|
1618
|
+
? renderCliWriteReceipt({
|
|
1619
|
+
ok: true,
|
|
1620
|
+
operation_id: result.operationId,
|
|
1621
|
+
offer_path: result.offerPath,
|
|
1622
|
+
fence_path: result.fencePath,
|
|
1623
|
+
selected_goal_ids: result.offer.selection.goalIds,
|
|
1624
|
+
target_repository: result.offer.target.repository.worktreeRoot,
|
|
1625
|
+
reconciled: result.reconciled,
|
|
1626
|
+
})
|
|
1627
|
+
: `${result.reconciled ? "Reconciled existing" : "Recorded"} succession ${result.operationId}: ` +
|
|
1628
|
+
`[${result.offer.selection.goalIds.join(", ")}] fenced for ${result.offer.target.repository.worktreeRoot}.\n` +
|
|
1629
|
+
`Offer: ${result.offerPath}\nFence: ${result.fencePath}\n`,
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
if (subcommand === "adopt") {
|
|
1633
|
+
const mode = flagValue(args, "--skc-goal-mode");
|
|
1634
|
+
const result = await adoptUltragoalSuccession({
|
|
1635
|
+
cwd,
|
|
1636
|
+
offerPath: flagValue(args, "--offer") ?? "",
|
|
1637
|
+
skcGoalMode: mode === "per-story" || mode === "aggregate" ? mode : undefined,
|
|
1638
|
+
});
|
|
1639
|
+
return {
|
|
1640
|
+
status: 0,
|
|
1641
|
+
createdPlan: !result.reconciled,
|
|
1642
|
+
stdout: json
|
|
1643
|
+
? renderCliWriteReceipt({
|
|
1644
|
+
ok: true,
|
|
1645
|
+
operation_id: result.operationId,
|
|
1646
|
+
adoption_path: result.adoptionPath,
|
|
1647
|
+
goal_map: result.goalMap,
|
|
1648
|
+
goals_count: result.plan.goals.length,
|
|
1649
|
+
reconciled: result.reconciled,
|
|
1650
|
+
})
|
|
1651
|
+
: `${result.reconciled ? "Reconciled" : "Adopted"} succession ${result.operationId} with ` +
|
|
1652
|
+
`${result.plan.goals.length} goal${result.plan.goals.length === 1 ? "" : "s"}: ` +
|
|
1653
|
+
`${result.goalMap.map(row => `${row.sourceGoalId} -> ${row.targetGoalId}`).join(", ")}.\n` +
|
|
1654
|
+
`Adoption record: ${result.adoptionPath}\n`,
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
return {
|
|
1658
|
+
status: 1,
|
|
1659
|
+
stderr: `Unknown skc ultragoal succession subcommand: ${subcommand ?? "(missing)"}; supported: offer, adopt, status\n`,
|
|
1660
|
+
};
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
if (error instanceof UltragoalSuccessionError) {
|
|
1663
|
+
return { status: 1, stderr: `[${error.code}] ${error.message}\n` };
|
|
1664
|
+
}
|
|
1665
|
+
throw error;
|
|
1666
|
+
}
|
|
1667
|
+
}
|