@kisev/skills-opencode 2.0.3 → 2.0.5

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [Русский](README.ru.md)
4
4
 
5
- `@kisev/skills-opencode@2.0.3` is the optional OpenCode-specific layer. Portable
5
+ `@kisev/skills-opencode@2.0.5` is the optional OpenCode-specific layer. Portable
6
6
  skills have a separate lifecycle and must be installed independently through the
7
7
  [root portable flow](../../README.md).
8
8
 
@@ -30,7 +30,7 @@ Install in the repository's npm project and run the CLI from that project root:
30
30
 
31
31
  ```shell
32
32
  cd /path/to/project
33
- npm install --save-exact @kisev/skills-opencode@2.0.3
33
+ npm install --save-exact @kisev/skills-opencode@2.0.5
34
34
  npm exec -- skills-opencode install --scope project --dry-run
35
35
  ```
36
36
 
@@ -45,7 +45,7 @@ Use `~/.config/opencode` as the persistent npm project:
45
45
  mkdir -p "$HOME/.config/opencode"
46
46
  cd "$HOME/.config/opencode"
47
47
  test -f package.json || npm init --yes
48
- npm install --save-exact @kisev/skills-opencode@2.0.3
48
+ npm install --save-exact @kisev/skills-opencode@2.0.5
49
49
  npm exec -- skills-opencode install --scope global --dry-run
50
50
  ```
51
51
 
@@ -100,8 +100,12 @@ under `~/.config/opencode`. Restart OpenCode after activation or asset changes.
100
100
  ## Preview and Confirm
101
101
 
102
102
  Every mutation begins with `--dry-run`. The preview reports operations,
103
- conflicts, restart requirements, receipt expiry, a SHA-256 digest, and the exact
104
- confirmation command.
103
+ conflicts, restart requirements, receipt expiry, separate plan and confirmation
104
+ digests, and the exact confirmation command. If reconcile reports modified
105
+ managed files or ownership conflicts, it is blocked: no receipt or Apply command
106
+ is issued. Install or upgrade the current package first, apply its exact
107
+ installer confirmation, then repeat reconcile; resolve ownership conflicts
108
+ manually.
105
109
 
106
110
  ```shell
107
111
  npm exec -- skills-opencode install --scope project --dry-run
@@ -110,8 +114,13 @@ npm exec -- skills-opencode install --scope project --dry-run
110
114
  The mandatory OpenCode flow is: persistent npm install, `install --dry-run`, the
111
115
  exact confirmation command printed by that preview, add the package to the
112
116
  user-owned `plugin` entry, and restart OpenCode. Use the persistent npm project
113
- at `~/.config/opencode` for global commands. Only after this flow is complete,
114
- update the package and apply its installer plan before running reconcile.
117
+ at `~/.config/opencode` for global commands. Install or upgrade the package and
118
+ apply its installer plan before every reconcile.
119
+
120
+ The preview has a deterministic `plan_digest` and a unique
121
+ `confirmation_digest`. A later dry-run in the same scope supersedes any older
122
+ unconsumed preview, including previews from another package or agent operation;
123
+ the older confirmation is rejected.
115
124
 
116
125
  Run the command printed by the preview, including all selection flags. Receipts
117
126
  are private, valid for 10 minutes, single-use, and bound to the action, scope,
@@ -140,7 +149,7 @@ version, preview and confirm `install` with the same scope and desired selection
140
149
  then restart OpenCode:
141
150
 
142
151
  ```shell
143
- npm install --save-exact @kisev/skills-opencode@2.0.3
152
+ npm install --save-exact @kisev/skills-opencode@2.0.5
144
153
  npm exec -- skills-opencode install --scope project --dry-run
145
154
  ```
146
155
 
package/README.ru.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [Русский](README.ru.md)
4
4
 
5
- `@kisev/skills-opencode@2.0.3` - optional OpenCode-specific слой. У portable
5
+ `@kisev/skills-opencode@2.0.5` - optional OpenCode-specific слой. У portable
6
6
  skills отдельный lifecycle: их нужно установить независимо через
7
7
  [корневую инструкцию](../../README.ru.md).
8
8
 
@@ -30,7 +30,7 @@ assets или portable skills и не меняют OpenCode configuration.
30
30
 
31
31
  ```shell
32
32
  cd /path/to/project
33
- npm install --save-exact @kisev/skills-opencode@2.0.3
33
+ npm install --save-exact @kisev/skills-opencode@2.0.5
34
34
  npm exec -- skills-opencode install --scope project --dry-run
35
35
  ```
36
36
 
@@ -45,7 +45,7 @@ Package остаётся в project `node_modules`, confirmed assets разме
45
45
  mkdir -p "$HOME/.config/opencode"
46
46
  cd "$HOME/.config/opencode"
47
47
  test -f package.json || npm init --yes
48
- npm install --save-exact @kisev/skills-opencode@2.0.3
48
+ npm install --save-exact @kisev/skills-opencode@2.0.5
49
49
  npm exec -- skills-opencode install --scope global --dry-run
50
50
  ```
51
51
 
@@ -101,8 +101,12 @@ OpenCode.
101
101
  ## Preview и confirm
102
102
 
103
103
  Каждая mutation начинается с `--dry-run`. Preview показывает operations,
104
- conflicts, необходимость restart, срок действия receipt, SHA-256 digest и точную
105
- confirmation command.
104
+ conflicts, необходимость restart, срок действия receipt, отдельные plan и
105
+ confirmation digests и точную confirmation command. Если reconcile показывает
106
+ modified managed files или ownership conflicts, он блокируется: receipt и Apply
107
+ command не создаются. Сначала установите или обновите текущий package,
108
+ примените его exact installer confirmation, затем повторите reconcile; ownership
109
+ conflicts нужно разрешить вручную.
106
110
 
107
111
  ```shell
108
112
  npm exec -- skills-opencode install --scope project --dry-run
@@ -111,8 +115,12 @@ npm exec -- skills-opencode install --scope project --dry-run
111
115
  Обязательный OpenCode flow: persistent npm install, `install --dry-run`, exact
112
116
  confirmation command из preview, добавление package в user-owned `plugin` и
113
117
  перезапуск OpenCode. Для global-команд используйте persistent npm project в
114
- `~/.config/opencode`. Только после этого обновите package, примените installer
115
- plan и запускайте reconcile.
118
+ `~/.config/opencode`. Перед каждым reconcile установите или обновите package и
119
+ примените его installer plan.
120
+
121
+ Preview имеет deterministic `plan_digest` и unique `confirmation_digest`. Новый
122
+ dry-run в том же scope supersede-ит любой старый unconsumed preview, включая
123
+ preview другой package или agent operation; старая confirmation отклоняется.
116
124
 
117
125
  Выполните команду из preview со всеми selection flags. Receipts приватны,
118
126
  действуют 10 минут, применяются один раз и связаны с action, scope, root и
@@ -141,7 +149,7 @@ incomplete probe failure.
141
149
  перезапустите OpenCode:
142
150
 
143
151
  ```shell
144
- npm install --save-exact @kisev/skills-opencode@2.0.3
152
+ npm install --save-exact @kisev/skills-opencode@2.0.5
145
153
  npm exec -- skills-opencode install --scope project --dry-run
146
154
  ```
147
155
 
@@ -1,4 +1,4 @@
1
- import { LifecycleError, type FileMutation, type Scope, type TransactionOptions } from "./lifecycle.js";
1
+ import { LifecycleError, type SupersededPlan, type FileMutation, type Scope, type TransactionOptions } from "./lifecycle.js";
2
2
  export { type Scope as AgentProfileScope } from "./lifecycle.js";
3
3
  export declare const FIXED_AGENT_ROLES: readonly ["manager", "architect", "mapper", "worker", "review", "critic"];
4
4
  export type FixedAgentRole = (typeof FIXED_AGENT_ROLES)[number];
@@ -70,6 +70,9 @@ export type AgentProfilePlan = {
70
70
  root: string;
71
71
  operations: AgentProfileOperation[];
72
72
  critic_pool: string[];
73
+ plan_digest: string;
74
+ confirmation_digest?: string;
75
+ superseded_plan?: SupersededPlan;
73
76
  digest: string;
74
77
  receipt_expires_at?: string;
75
78
  requires_restart: boolean;
@@ -520,9 +520,11 @@ export async function buildAgentProfilePlan(request, scope, cwd = process.cwd(),
520
520
  critic_pool: desired?.critic_pool ?? finalManifest?.critic_pool ?? [],
521
521
  requires_restart: mutations.some((item) => item.path.startsWith("agents/")),
522
522
  };
523
+ const planDigest = planDigestBase(base, inventoryDigest, request);
523
524
  const plan = {
524
525
  ...base,
525
- digest: planDigestBase(base, inventoryDigest, request),
526
+ plan_digest: planDigest,
527
+ digest: planDigest,
526
528
  };
527
529
  return {
528
530
  plan,
@@ -639,8 +641,15 @@ export async function previewAgentProfileChange(request, scope, cwd = process.cw
639
641
  if (await recoverTransaction(deploymentRoot(scope, cwd, home), stateRoot))
640
642
  throw new AgentProfileError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
641
643
  const built = await buildAgentProfilePlan(request, scope, cwd, home);
642
- const receipt = await saveReceipt(stateRoot, `agent:${request.action}`, scope, built.plan.root, { request, digest: built.plan.digest });
643
- return { ...built.plan, digest: receipt.digest, receipt_expires_at: receipt.expires_at };
644
+ const receipt = await saveReceipt(stateRoot, `agent:${request.action}`, scope, built.plan.root, { request, plan_digest: built.plan.digest }, Date.now(), built.plan.digest);
645
+ return {
646
+ ...built.plan,
647
+ plan_digest: built.plan.digest,
648
+ confirmation_digest: receipt.digest,
649
+ digest: receipt.digest,
650
+ receipt_expires_at: receipt.expires_at,
651
+ ...(receipt.superseded_plan ? { superseded_plan: receipt.superseded_plan } : {}),
652
+ };
644
653
  });
645
654
  }
646
655
  export async function applyAgentProfileChange(request, scope, confirmationDigest, cwd = process.cwd(), home = homedir(), options = {}) {
@@ -658,7 +667,7 @@ export async function applyAgentProfileChange(request, scope, confirmationDigest
658
667
  if (stable(receipt.request) !== stable(request))
659
668
  throw new AgentProfileError("confirmation_unknown", "Saved confirmation belongs to a different request");
660
669
  const built = await buildAgentProfilePlan(request, scope, cwd, home);
661
- if (built.plan.digest !== receipt.digest)
670
+ if (built.plan.digest !== (receipt.plan_digest ?? receipt.digest))
662
671
  throw new AgentProfileError("stale_plan", "Agent inventory changed after preview");
663
672
  if (built.plan.operations.some((item) => item.operation === "conflict" && item.reason.includes("collision")))
664
673
  throw new AgentProfileError("collision", "Exact-name user-owned collision blocks apply");
package/dist/catalog.d.ts CHANGED
@@ -4,5 +4,5 @@ export declare const CATALOG: {
4
4
  readonly agents: readonly ["manager", "architect", "mapper", "worker", "review", "critic"];
5
5
  readonly package_commands: readonly ["capabilities", "doctor", "reconcile", "agent-profiles"];
6
6
  readonly tools: readonly ["capabilities", "route", "doctor", "agent_profiles", "reconcile"];
7
- readonly version: "2.0.3";
7
+ readonly version: "2.0.5";
8
8
  };
package/dist/catalog.js CHANGED
@@ -34,5 +34,5 @@ export const CATALOG = {
34
34
  agents: ["manager", "architect", "mapper", "worker", "review", "critic"],
35
35
  package_commands: ["capabilities", "doctor", "reconcile", "agent-profiles"],
36
36
  tools: ["capabilities", "route", "doctor", "agent_profiles", "reconcile"],
37
- version: "2.0.3",
37
+ version: "2.0.5",
38
38
  };
@@ -119,9 +119,16 @@ export function renderPlan(plan, options) {
119
119
  ? `Restart required: ${plan.requires_restart ? "yes" : "no"}`
120
120
  : `Restart after apply: ${plan.requires_restart ? "yes" : "no"}`);
121
121
  if (!options.applied) {
122
+ if (plan.superseded_plan)
123
+ lines.push("", "Superseded plan:", ` kind: ${terminalSafe(plan.superseded_plan.kind)}`, ` confirmation: ${terminalSafe(plan.superseded_plan.confirmation_digest)}`, ` created: ${terminalSafe(plan.superseded_plan.created_at)}`, ` expires: ${terminalSafe(plan.superseded_plan.expires_at)}`);
122
124
  if (plan.receipt_expires_at)
123
125
  lines.push(`Confirmation expires: ${plan.receipt_expires_at}`);
124
- lines.push(`Digest: ${plan.digest}`);
126
+ if ("plan_digest" in plan)
127
+ lines.push(`Plan digest: ${plan.plan_digest}`);
128
+ if (plan.confirmation_digest ?? plan.digest) {
129
+ lines.push(`Confirmation digest: ${plan.confirmation_digest ?? plan.digest}`);
130
+ lines.push(`Digest: ${plan.confirmation_digest ?? plan.digest}`);
131
+ }
125
132
  if (options.confirmationCommand)
126
133
  lines.push("", "Apply:", ` ${options.confirmationCommand}`);
127
134
  }
@@ -183,9 +190,6 @@ export function renderReconcile(plan, options) {
183
190
  if (plan.modified_managed.length)
184
191
  lines.push("", "Modified managed:", ...plan.modified_managed.map((entry) => ` ${terminalSafe(entry.path)} (${terminalSafe(entry.reason)})`));
185
192
  if (!options.applied) {
186
- lines.push("", `Digest: ${plan.digest}`);
187
- if (plan.receipt_expires_at)
188
- lines.push(`Confirmation expires: ${plan.receipt_expires_at}`);
189
193
  const blocked = plan.modified_managed.length > 0 || plan.conflicts.length > 0;
190
194
  if (blocked) {
191
195
  lines.push("", "Blocked:");
@@ -195,6 +199,13 @@ export function renderReconcile(plan, options) {
195
199
  lines.push(" Manually resolve every ownership conflict listed above before reconciling.");
196
200
  }
197
201
  else if (options.confirmationCommand) {
202
+ if (plan.superseded_plan)
203
+ lines.push("", "Superseded plan:", ` kind: ${terminalSafe(plan.superseded_plan.kind)}`, ` confirmation: ${terminalSafe(plan.superseded_plan.confirmation_digest)}`, ` created: ${terminalSafe(plan.superseded_plan.created_at)}`, ` expires: ${terminalSafe(plan.superseded_plan.expires_at)}`);
204
+ lines.push("", `Plan digest: ${plan.plan_digest}`);
205
+ if (plan.receipt_expires_at)
206
+ lines.push(`Confirmation expires: ${plan.receipt_expires_at}`);
207
+ if (plan.confirmation_digest ?? plan.digest)
208
+ lines.push(`Confirmation digest: ${plan.confirmation_digest ?? plan.digest}`, `Digest: ${plan.confirmation_digest ?? plan.digest}`);
198
209
  lines.push("", "Apply:", ` ${options.confirmationCommand}`);
199
210
  }
200
211
  }
package/dist/cli.js CHANGED
@@ -353,7 +353,7 @@ async function run(arguments_) {
353
353
  if (options.json)
354
354
  process.stdout.write(`${JSON.stringify({ status: "ok", applied: false, plan }, null, 2)}\n`);
355
355
  else
356
- process.stdout.write(renderReconcile(plan, { applied: false, confirmationCommand: shellCommand(["reconcile", "--scope", options.scope, "--confirm", plan.digest]) }));
356
+ process.stdout.write(renderReconcile(plan, { applied: false, confirmationCommand: plan.confirmable ? shellCommand(["reconcile", "--scope", options.scope, "--confirm", plan.confirmation_digest ?? plan.digest]) : undefined }));
357
357
  }
358
358
  else {
359
359
  const applied = await applyReconcile(options.scope, options.confirm);
@@ -1,4 +1,4 @@
1
- import { LifecycleError, type FileMutation, type Scope, type TransactionOptions } from "./lifecycle.js";
1
+ import { LifecycleError, type SupersededPlan, type FileMutation, type Scope, type TransactionOptions } from "./lifecycle.js";
2
2
  import { CATALOG } from "./catalog.js";
3
3
  import { type FixedAgentRole } from "./agent-profiles.js";
4
4
  export type { Scope } from "./lifecycle.js";
@@ -25,6 +25,9 @@ export type Plan = {
25
25
  package_version: string;
26
26
  selection: InstallerSelection;
27
27
  operations: PlanItem[];
28
+ plan_digest: string;
29
+ confirmation_digest?: string;
30
+ superseded_plan?: SupersededPlan;
28
31
  digest: string;
29
32
  receipt_expires_at?: string;
30
33
  requires_restart: boolean;
package/dist/installer.js CHANGED
@@ -404,8 +404,9 @@ async function build(action, scope, cwd = process.cwd(), home = homedir(), reque
404
404
  }
405
405
  const sorted = operations.sort((left, right) => left.path.localeCompare(right.path) || left.operation.localeCompare(right.operation));
406
406
  const base = { schema_version: 2, action, scope, root, package_version: packageVersion(), selection, operations: sorted, requires_restart: (action === "install" && owned.manifest?.package_version !== packageVersion()) || profiles.plan.requires_restart || mutations.some((item) => item.path.startsWith("agents/") || item.path.startsWith("commands/") || item.path.startsWith("plugins/")) };
407
+ const planDigest = digest(base);
407
408
  return {
408
- plan: { ...base, digest: digest(base) },
409
+ plan: { ...base, plan_digest: planDigest, digest: planDigest },
409
410
  mutations,
410
411
  expectedManifest: manifestContent,
411
412
  profiles,
@@ -419,8 +420,15 @@ export async function preview(action, scope, cwd = process.cwd(), home = homedir
419
420
  if (await recoverTransaction(root, stateRoot))
420
421
  throw new InstallerError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
421
422
  const built = await build(action, scope, cwd, home, selection);
422
- const receipt = await saveReceipt(stateRoot, `installer:${action}`, scope, root, { digest: built.plan.digest });
423
- return { ...built.plan, digest: receipt.digest, receipt_expires_at: receipt.expires_at };
423
+ const receipt = await saveReceipt(stateRoot, `installer:${action}`, scope, root, { plan_digest: built.plan.digest }, Date.now(), built.plan.digest);
424
+ return {
425
+ ...built.plan,
426
+ plan_digest: built.plan.digest,
427
+ confirmation_digest: receipt.digest,
428
+ digest: receipt.digest,
429
+ receipt_expires_at: receipt.expires_at,
430
+ ...(receipt.superseded_plan ? { superseded_plan: receipt.superseded_plan } : {}),
431
+ };
424
432
  });
425
433
  }
426
434
  catch (error) {
@@ -440,7 +448,9 @@ export async function apply(action, scope, confirmationDigest, cwd = process.cwd
440
448
  throw new InstallerError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
441
449
  const receipt = (await consumeReceipt(stateRoot, { digest: confirmationDigest, kind: `installer:${action}`, scope, root }));
442
450
  const built = await build(action, scope, cwd, home, selection);
443
- if (built.plan.digest !== receipt.digest)
451
+ const savedPlanDigest = receipt.plan_digest ??
452
+ receipt.digest;
453
+ if (built.plan.digest !== savedPlanDigest)
444
454
  throw new InstallerError("stale_plan", "Installer plan changed after preview");
445
455
  if (built.plan.operations.some((item) => item.operation === "conflict" && (item.reason === "unmanaged_file" || item.reason === "v1.0.0_agent_ownership_mismatch" || item.reason?.includes("collision")))) {
446
456
  throw new InstallerError("conflict", "Installer plan contains an exact-name ownership conflict");
@@ -21,6 +21,12 @@ export type FileMutation = {
21
21
  operation: "remove";
22
22
  expected: FileExpectation;
23
23
  };
24
+ export type SupersededPlan = {
25
+ kind: string;
26
+ confirmation_digest: string;
27
+ created_at: string;
28
+ expires_at: string;
29
+ };
24
30
  export declare function stable(value: unknown): string;
25
31
  export declare function sha256(value: Buffer | string): string;
26
32
  export declare function digest(value: unknown): string;
@@ -36,9 +42,11 @@ export declare function deploymentRoot(scope: Scope, cwd?: string, home?: string
36
42
  export declare function lifecycleRoot(scope: Scope, cwd?: string, home?: string): string;
37
43
  export declare function archiveRoot(scope: Scope, cwd?: string, home?: string): string;
38
44
  export declare function withLifecycleLock<T>(stateRoot: string, callback: () => Promise<T>): Promise<T>;
39
- export declare function saveReceipt(stateRoot: string, kind: string, scope: Scope, root: string, payload: unknown, now?: number): Promise<{
45
+ export declare function saveReceipt(stateRoot: string, kind: string, scope: Scope, root: string, payload: unknown, now?: number, planDigest?: string): Promise<{
40
46
  digest: string;
47
+ plan_digest: string;
41
48
  expires_at: string;
49
+ superseded_plan?: SupersededPlan;
42
50
  }>;
43
51
  export declare function consumeReceipt(stateRoot: string, expected: {
44
52
  digest: string;
@@ -46,6 +54,8 @@ export declare function consumeReceipt(stateRoot: string, expected: {
46
54
  scope: Scope;
47
55
  root: string;
48
56
  }, now?: number): Promise<unknown>;
57
+ /** Invalidate an active receipt when a preview is blocked before a replacement exists. */
58
+ export declare function supersedeReceipt(stateRoot: string, now?: number): Promise<void>;
49
59
  export declare function recoverTransaction(root: string, stateRoot: string): Promise<boolean>;
50
60
  export type TransactionOptions = {
51
61
  beforePublish?: (index: number) => void;
package/dist/lifecycle.js CHANGED
@@ -240,37 +240,52 @@ export async function withLifecycleLock(stateRoot, callback) {
240
240
  function receiptPath(stateRoot) {
241
241
  return join(stateRoot, "receipt.json");
242
242
  }
243
- export async function saveReceipt(stateRoot, kind, scope, root, payload, now = Date.now()) {
244
- const confirmationDigest = digest({ schema_version: 1, kind, scope, root, payload });
243
+ export async function saveReceipt(stateRoot, kind, scope, root, payload, now = Date.now(), planDigest = digest(payload)) {
245
244
  const existingRaw = await readRegular(receiptPath(stateRoot));
245
+ let superseded;
246
246
  if (existingRaw) {
247
247
  const existing = parseReceipt(existingRaw);
248
- if (!existing.consumed &&
249
- Date.parse(existing.expires_at) >= now &&
250
- existing.digest !== confirmationDigest) {
251
- throw new LifecycleError("active_receipt", "An unconsumed agent or installer plan is still active");
252
- }
253
- if (!existing.consumed &&
254
- Date.parse(existing.expires_at) >= now &&
255
- existing.digest === confirmationDigest) {
256
- return { digest: existing.digest, expires_at: existing.expires_at };
248
+ if (!existing.consumed && Date.parse(existing.expires_at) >= now) {
249
+ superseded = existing;
257
250
  }
258
251
  }
252
+ const confirmationDigest = sha256(randomBytes(32));
259
253
  const receipt = {
260
254
  schema_version: 1,
261
255
  digest: confirmationDigest,
256
+ plan_digest: planDigest,
262
257
  nonce: randomBytes(32).toString("base64url"),
258
+ created_at: new Date(now).toISOString(),
263
259
  expires_at: new Date(now + RECEIPT_TTL_MS).toISOString(),
264
260
  consumed: false,
265
261
  kind,
266
262
  scope,
267
263
  root,
268
264
  payload,
265
+ ...(superseded
266
+ ? {
267
+ superseded_digests: [superseded.digest, ...(superseded.superseded_digests ?? [])],
268
+ }
269
+ : {}),
269
270
  integrity: "",
270
271
  };
271
272
  receipt.integrity = receiptIntegrity(receipt);
272
273
  await writeAtomic(receiptPath(stateRoot), Buffer.from(`${stable(receipt)}\n`), 0o600);
273
- return { digest: receipt.digest, expires_at: receipt.expires_at };
274
+ return {
275
+ digest: receipt.digest,
276
+ plan_digest: receipt.plan_digest,
277
+ expires_at: receipt.expires_at,
278
+ ...(superseded
279
+ ? {
280
+ superseded_plan: {
281
+ kind: superseded.kind,
282
+ confirmation_digest: superseded.digest.slice(0, 12),
283
+ created_at: superseded.created_at,
284
+ expires_at: superseded.expires_at,
285
+ },
286
+ }
287
+ : {}),
288
+ };
274
289
  }
275
290
  function parseReceipt(raw) {
276
291
  let value;
@@ -281,27 +296,92 @@ function parseReceipt(raw) {
281
296
  throw new LifecycleError("invalid_receipt", "Receipt is not valid JSON");
282
297
  }
283
298
  const receipt = value;
299
+ if (receipt &&
300
+ receipt.schema_version === 1 &&
301
+ typeof receipt.digest === "string" &&
302
+ /^[a-f0-9]{64}$/.test(receipt.digest) &&
303
+ typeof receipt.nonce === "string" &&
304
+ typeof receipt.expires_at === "string" &&
305
+ typeof receipt.consumed === "boolean" &&
306
+ typeof receipt.kind === "string" &&
307
+ (receipt.scope === "global" || receipt.scope === "project") &&
308
+ typeof receipt.root === "string" &&
309
+ typeof receipt.integrity === "string" &&
310
+ receipt.plan_digest === undefined) {
311
+ const legacyDocument = {
312
+ schema_version: 1,
313
+ digest: receipt.digest,
314
+ nonce: receipt.nonce,
315
+ expires_at: receipt.expires_at,
316
+ consumed: receipt.consumed,
317
+ kind: receipt.kind,
318
+ scope: receipt.scope,
319
+ root: receipt.root,
320
+ payload: receipt.payload,
321
+ integrity: receipt.integrity,
322
+ };
323
+ if (receipt.digest !==
324
+ digest({
325
+ schema_version: 1,
326
+ kind: receipt.kind,
327
+ scope: receipt.scope,
328
+ root: receipt.root,
329
+ payload: receipt.payload,
330
+ }) ||
331
+ receipt.integrity !==
332
+ digest({
333
+ schema_version: 1,
334
+ digest: receipt.digest,
335
+ nonce: receipt.nonce,
336
+ expires_at: receipt.expires_at,
337
+ consumed: receipt.consumed,
338
+ kind: receipt.kind,
339
+ scope: receipt.scope,
340
+ root: receipt.root,
341
+ payload: receipt.payload,
342
+ })) {
343
+ throw new LifecycleError("invalid_receipt", "Receipt integrity check failed");
344
+ }
345
+ const payloadRecord = receipt.payload;
346
+ const planDigest = payloadRecord &&
347
+ typeof payloadRecord === "object" &&
348
+ typeof payloadRecord.digest === "string" &&
349
+ /^[a-f0-9]{64}$/.test(payloadRecord.digest)
350
+ ? payloadRecord.digest
351
+ : digest(receipt.payload);
352
+ return {
353
+ ...legacyDocument,
354
+ plan_digest: planDigest,
355
+ created_at: new Date(Date.parse(receipt.expires_at) - RECEIPT_TTL_MS).toISOString(),
356
+ };
357
+ }
284
358
  if (!receipt ||
285
359
  receipt.schema_version !== 1 ||
286
360
  typeof receipt.digest !== "string" ||
287
361
  !/^[a-f0-9]{64}$/.test(receipt.digest) ||
362
+ typeof receipt.plan_digest !== "string" ||
363
+ !/^[a-f0-9]{64}$/.test(receipt.plan_digest) ||
288
364
  typeof receipt.nonce !== "string" ||
365
+ typeof receipt.created_at !== "string" ||
289
366
  typeof receipt.expires_at !== "string" ||
290
367
  typeof receipt.consumed !== "boolean" ||
368
+ (receipt.superseded !== undefined && typeof receipt.superseded !== "boolean") ||
291
369
  typeof receipt.kind !== "string" ||
292
370
  (receipt.scope !== "global" && receipt.scope !== "project") ||
293
371
  typeof receipt.root !== "string" ||
372
+ (receipt.superseded_digests !== undefined &&
373
+ (!Array.isArray(receipt.superseded_digests) ||
374
+ !receipt.superseded_digests.every((value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)))) ||
294
375
  typeof receipt.integrity !== "string") {
295
376
  throw new LifecycleError("invalid_receipt", "Receipt has an unsupported schema");
296
377
  }
297
- if (receipt.digest !==
298
- digest({
299
- schema_version: 1,
300
- kind: receipt.kind,
301
- scope: receipt.scope,
302
- root: receipt.root,
303
- payload: receipt.payload,
304
- })) {
378
+ const payloadRecord = receipt.payload;
379
+ const payloadPlanDigest = payloadRecord &&
380
+ typeof payloadRecord === "object" &&
381
+ typeof payloadRecord.plan_digest === "string"
382
+ ? payloadRecord.plan_digest
383
+ : digest(receipt.payload);
384
+ if (receipt.plan_digest !== payloadPlanDigest) {
305
385
  throw new LifecycleError("invalid_receipt", "Receipt digest does not match its payload");
306
386
  }
307
387
  if (receipt.integrity !== receiptIntegrity(receipt))
@@ -323,8 +403,12 @@ export async function consumeReceipt(stateRoot, expected, now = Date.now()) {
323
403
  receipt.kind !== expected.kind ||
324
404
  receipt.scope !== expected.scope ||
325
405
  receipt.root !== expected.root) {
406
+ if (receipt.superseded_digests?.includes(expected.digest))
407
+ throw new LifecycleError("superseded_plan", "Confirmation receipt was superseded by a newer plan");
326
408
  throw new LifecycleError("confirmation_unknown", "Confirmation does not match the saved plan");
327
409
  }
410
+ if (receipt.superseded)
411
+ throw new LifecycleError("superseded_plan", "Confirmation receipt was superseded by a newer plan");
328
412
  if (receipt.consumed)
329
413
  throw new LifecycleError("confirmation_consumed", "Confirmation receipt was already consumed");
330
414
  if (Date.parse(receipt.expires_at) < now)
@@ -334,6 +418,19 @@ export async function consumeReceipt(stateRoot, expected, now = Date.now()) {
334
418
  await writeAtomic(receiptPath(stateRoot), Buffer.from(`${stable(consumed)}\n`), 0o600);
335
419
  return receipt.payload;
336
420
  }
421
+ /** Invalidate an active receipt when a preview is blocked before a replacement exists. */
422
+ export async function supersedeReceipt(stateRoot, now = Date.now()) {
423
+ const path = receiptPath(stateRoot);
424
+ const raw = await readRegular(path);
425
+ if (!raw)
426
+ return;
427
+ const receipt = parseReceipt(raw);
428
+ if (receipt.consumed || receipt.superseded || Date.parse(receipt.expires_at) < now)
429
+ return;
430
+ const superseded = { ...receipt, consumed: true, superseded: true, integrity: "" };
431
+ superseded.integrity = receiptIntegrity(superseded);
432
+ await writeAtomic(path, Buffer.from(`${stable(superseded)}\n`), 0o600);
433
+ }
337
434
  function journalPath(stateRoot) {
338
435
  return join(stateRoot, "transaction-journal.json");
339
436
  }
@@ -1,4 +1,4 @@
1
- import { LifecycleError, type Scope, type TransactionOptions } from "./lifecycle.js";
1
+ import { LifecycleError, type SupersededPlan, type Scope, type TransactionOptions } from "./lifecycle.js";
2
2
  export type ReconcileStatus = "current" | "retired" | "archive-pending" | "renamed" | "modified-managed" | "user-owned" | "unknown" | "conflict" | "diagnostic-state-only";
3
3
  export type ReconcileItem = {
4
4
  path: string;
@@ -27,7 +27,11 @@ export type ReconcilePlan = {
27
27
  operation: "remove" | "write";
28
28
  sha256?: string;
29
29
  }>;
30
- digest: string;
30
+ plan_digest: string;
31
+ confirmation_digest?: string;
32
+ superseded_plan?: SupersededPlan;
33
+ confirmable: boolean;
34
+ digest?: string;
31
35
  receipt_expires_at?: string;
32
36
  };
33
37
  export type ReconcileResult = {
package/dist/reconcile.js CHANGED
@@ -3,7 +3,7 @@ import { lstat, readdir, readFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { applyTransaction, assertSafePath, consumeReceipt, destination, digest, LifecycleError, lifecycleRoot, recoverTransaction, saveReceipt, sha256, stable, withLifecycleLock, } from "./lifecycle.js";
6
+ import { applyTransaction, assertSafePath, consumeReceipt, destination, digest, LifecycleError, lifecycleRoot, recoverTransaction, saveReceipt, supersedeReceipt, sha256, stable, withLifecycleLock, } from "./lifecycle.js";
7
7
  import { archiveMutations } from "./installer.js";
8
8
  const PACKAGE_NAME = "@kisev/skills-opencode";
9
9
  const GENERIC_MANIFEST = ".skills-opencode-manifest.json";
@@ -437,7 +437,11 @@ async function build(scope, cwd = process.cwd(), home = homedir()) {
437
437
  diagnostic_state_only: diagnostic(scope, cwd, home),
438
438
  operations: operations.sort((left, right) => left.path.localeCompare(right.path)),
439
439
  };
440
- return { plan: { ...base, digest: digest(base) }, mutations };
440
+ const planDigest = digest(base);
441
+ return {
442
+ plan: { ...base, plan_digest: planDigest, confirmable: true, digest: planDigest },
443
+ mutations,
444
+ };
441
445
  }
442
446
  export async function previewReconcile(scope, cwd = process.cwd(), home = homedir()) {
443
447
  const stateRoot = lifecycleRoot(scope, cwd, home);
@@ -447,10 +451,24 @@ export async function previewReconcile(scope, cwd = process.cwd(), home = homedi
447
451
  if (await recoverTransaction(root, stateRoot))
448
452
  throw new ReconcileError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
449
453
  const built = await build(scope, cwd, home);
454
+ const blocked = built.plan.modified_managed.length > 0 || built.plan.conflicts.length > 0;
455
+ if (blocked) {
456
+ await supersedeReceipt(stateRoot);
457
+ const { digest: _digest, ...withoutReceipt } = built.plan;
458
+ return { ...withoutReceipt, confirmable: false };
459
+ }
450
460
  const receipt = await saveReceipt(stateRoot, "reconcile", scope, root, {
451
- digest: built.plan.digest,
452
- });
453
- return { ...built.plan, digest: receipt.digest, receipt_expires_at: receipt.expires_at };
461
+ plan_digest: built.plan.plan_digest,
462
+ }, Date.now(), built.plan.plan_digest);
463
+ return {
464
+ ...built.plan,
465
+ plan_digest: built.plan.plan_digest,
466
+ confirmation_digest: receipt.digest,
467
+ digest: receipt.digest,
468
+ confirmable: true,
469
+ receipt_expires_at: receipt.expires_at,
470
+ ...(receipt.superseded_plan ? { superseded_plan: receipt.superseded_plan } : {}),
471
+ };
454
472
  });
455
473
  }
456
474
  catch (error) {
@@ -479,7 +497,7 @@ export async function applyReconcile(scope, confirmationDigest, cwd = process.cw
479
497
  root,
480
498
  }));
481
499
  const built = await build(scope, cwd, home);
482
- if (built.plan.digest !== payload.digest)
500
+ if (built.plan.plan_digest !== (payload.plan_digest ?? payload.digest))
483
501
  throw new ReconcileError("stale_plan", "Reconcile inventory changed after preview");
484
502
  if (built.plan.conflicts.length || built.plan.modified_managed.length)
485
503
  throw new ReconcileError("conflict", "Reconcile contains unsafe ownership conflicts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kisev/skills-opencode",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "OpenCode integration and opt-in installer for portable Agent Skills.",
5
5
  "license": "MIT",
6
6
  "repository": {