@mc-and-his-agents/loom-installer 0.1.79 → 0.1.80

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
@@ -20,6 +20,13 @@ npx @mc-and-his-agents/loom-installer add skill <skill-id> --host codex
20
20
  npx @mc-and-his-agents/loom-installer add skill <skill-id> --host claude
21
21
  ```
22
22
 
23
+ Read-only upgrade rehearsal and verification:
24
+
25
+ ```bash
26
+ npx @mc-and-his-agents/loom-installer upgrade-plan plugin --host codex --json
27
+ npx @mc-and-his-agents/loom-installer verify-upgrade plugin --host codex --json
28
+ ```
29
+
23
30
  You can also pin the installer first:
24
31
 
25
32
  ```bash
@@ -47,6 +54,8 @@ Generated payload directories are not committed to git. The build step recreates
47
54
 
48
55
  Installer JSON output reports `distribution_layer`, `version_context`, and `failed_layer` so callers can distinguish host adapter plugin installs from generated single-skill installs.
49
56
 
57
+ Installer-managed layers also write `loom-installed-surface-status/v1` metadata. `upgrade-plan` and `verify-upgrade` read that metadata, compare it to the package payload, and report `upgrade_eligibility`, `changed_paths`, `drift`, `rollback_path`, and fail-closed reasons without mutating the target repository. See `docs/adoption/installed-loom-status.md` for the status contract.
58
+
50
59
  ## Release Notes
51
60
 
52
61
  Publishing only happens from `main`.
package/README.zh-CN.md CHANGED
@@ -20,6 +20,13 @@ npx @mc-and-his-agents/loom-installer add skill <skill-id> --host codex
20
20
  npx @mc-and-his-agents/loom-installer add skill <skill-id> --host claude
21
21
  ```
22
22
 
23
+ 只读升级演练与验证:
24
+
25
+ ```bash
26
+ npx @mc-and-his-agents/loom-installer upgrade-plan plugin --host codex --json
27
+ npx @mc-and-his-agents/loom-installer verify-upgrade plugin --host codex --json
28
+ ```
29
+
23
30
  也可以先固定 installer 版本:
24
31
 
25
32
  ```bash
@@ -47,6 +54,8 @@ Options:
47
54
 
48
55
  Installer JSON output 会报告 `distribution_layer`、`version_context` 和 `failed_layer`,让调用方区分 host adapter plugin install 与 generated single-skill install。
49
56
 
57
+ Installer 管理的 layer 也会写入 `loom-installed-surface-status/v1` metadata。`upgrade-plan` 和 `verify-upgrade` 只读取该 metadata,并与 package payload 比对,报告 `upgrade_eligibility`、`changed_paths`、`drift`、`rollback_path` 和 fail-closed reason;它们不会修改目标仓库。状态合同见 `docs/adoption/installed-loom-status.md`。
58
+
50
59
  ## Release Notes
51
60
 
52
61
  发布只会从 `main` 进行。
package/dist/src/cli.js CHANGED
@@ -3,10 +3,12 @@ import { formatResult, parseCli, runInstaller } from './index.js';
3
3
  import { InstallerError } from './utils.js';
4
4
  function errorResult(message) {
5
5
  return {
6
+ schema_version: 'loom-installer-result/v1',
7
+ operation: 'add',
6
8
  mode: 'plugin',
7
9
  host: 'codex',
8
10
  distribution_layer: 'host-adapter-plugin',
9
- status: 'installed',
11
+ status: 'blocked',
10
12
  installed_paths: [],
11
13
  verification: [],
12
14
  warnings: [],
package/dist/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { dirname, join, resolve } from 'node:path';
1
+ import { dirname, join, relative, resolve } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { InstallerError, assert, dirExists, ensureTargetWritable, runCommand } from './utils.js';
3
+ import { InstallerError, assert, dirExists, ensureTargetExists, ensureTargetWritable, fileExists, readJson, runCommand, sha256, writeJson } from './utils.js';
4
4
  import { loadPayloadManifest, resolveSkillRecord, verifyPayload } from './payload.js';
5
5
  import { installCodexPlugin, installCodexSkill } from './codex.js';
6
6
  import { installClaudePlugin, installClaudeSkill } from './claude.js';
@@ -52,8 +52,8 @@ export function parseCli(argv) {
52
52
  const subject = argv[1];
53
53
  const third = argv[2];
54
54
  const rest = subject === 'plugin' ? argv.slice(2) : argv.slice(3);
55
- if (command !== 'add') {
56
- throw new InstallerError('usage: loom-installer add plugin|skill <skill-id> [--host ...] [--target ...] [--force] [--json]');
55
+ if (command !== 'add' && command !== 'upgrade-plan' && command !== 'verify-upgrade') {
56
+ throw new InstallerError('usage: loom-installer add|upgrade-plan|verify-upgrade plugin|skill <skill-id> [--host ...] [--target ...] [--force] [--json]');
57
57
  }
58
58
  const options = { ...DEFAULT_OPTIONS };
59
59
  for (let index = 0; index < rest.length; index += 1) {
@@ -88,21 +88,23 @@ export function parseCli(argv) {
88
88
  }
89
89
  if (subject === 'plugin') {
90
90
  return {
91
+ operation: command,
91
92
  mode: 'plugin',
92
93
  options,
93
94
  };
94
95
  }
95
96
  if (subject === 'skill') {
96
97
  if (!third) {
97
- throw new InstallerError('usage: loom-installer add skill <skill-id>');
98
+ throw new InstallerError(`usage: loom-installer ${command} skill <skill-id>`);
98
99
  }
99
100
  return {
101
+ operation: command,
100
102
  mode: 'skill',
101
103
  skillId: third,
102
104
  options,
103
105
  };
104
106
  }
105
- throw new InstallerError(`unknown add target: ${String(subject)}`);
107
+ throw new InstallerError(`unknown ${command} target: ${String(subject)}`);
106
108
  }
107
109
  export function checkPython(env) {
108
110
  const result = runCommand(env.pythonBin, ['-c', 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}")'], process.env);
@@ -124,8 +126,8 @@ export function checkPython(env) {
124
126
  }
125
127
  return warnings;
126
128
  }
127
- export function ensureClaudeCliWhenNeeded(host, mode, env) {
128
- if (host !== 'claude' || mode !== 'plugin') {
129
+ export function ensureClaudeCliWhenNeeded(host, mode, operation, env) {
130
+ if (operation !== 'add' || host !== 'claude' || mode !== 'plugin') {
129
131
  return;
130
132
  }
131
133
  const result = runCommand(env.claudeBin, ['--version'], process.env);
@@ -136,8 +138,17 @@ export function ensureClaudeCliWhenNeeded(host, mode, env) {
136
138
  export function formatResult(result) {
137
139
  const version = result.version_context;
138
140
  return [
139
- `${result.host} ${result.mode}: ${result.status}`,
141
+ `${result.host} ${result.mode} ${result.operation ?? 'add'}: ${result.status}`,
140
142
  `layer: ${result.distribution_layer}`,
143
+ ...(result.installed_status
144
+ ? [
145
+ `runtime_state: ${result.installed_status.runtime_state}`,
146
+ `upgrade_eligibility: ${result.installed_status.upgrade_eligibility}`,
147
+ ]
148
+ : []),
149
+ ...(result.changed_paths?.length ? [`changed_paths: ${result.changed_paths.length}`] : []),
150
+ ...(result.drift?.length ? [`drift: ${result.drift.length}`] : []),
151
+ ...(result.rollback_path ? [`rollback_path: ${result.rollback_path}`] : []),
141
152
  ...(version
142
153
  ? [
143
154
  `versions: repo=${version.repo_version} installer=${version.installer_package_version} plugin=${version.plugin_surface_version} registry=${version.skills_registry_version}`,
@@ -150,13 +161,33 @@ export function formatResult(result) {
150
161
  export function runInstaller(parsed, envSource = process.env, packageRoot) {
151
162
  const resolvedEnv = resolveEnvironment(envSource);
152
163
  const host = selectHost(parsed.options.host, resolvedEnv);
164
+ const operation = parsed.operation ?? 'add';
153
165
  const resolvedPackageRoot = packageRoot ?? packageRootFromUrl(import.meta.url);
154
166
  const targetRoot = resolve(parsed.options.target);
155
- ensureTargetWritable(targetRoot);
167
+ if (operation === 'add') {
168
+ ensureTargetWritable(targetRoot);
169
+ }
170
+ else {
171
+ ensureTargetExists(targetRoot);
172
+ }
156
173
  const manifest = loadPayloadManifest(resolvedPackageRoot);
157
174
  verifyPayload(resolvedPackageRoot, manifest);
158
175
  const warnings = checkPython(resolvedEnv);
159
- ensureClaudeCliWhenNeeded(host, parsed.mode, resolvedEnv);
176
+ ensureClaudeCliWhenNeeded(host, parsed.mode, operation, resolvedEnv);
177
+ const skill = parsed.mode === 'skill' ? resolveSkillRecord(manifest, parsed.skillId ?? '') : undefined;
178
+ if (operation !== 'add') {
179
+ const result = inspectInstalledSurface({
180
+ operation,
181
+ host,
182
+ parsed,
183
+ packageRoot: resolvedPackageRoot,
184
+ targetRoot,
185
+ manifest,
186
+ skill,
187
+ });
188
+ result.warnings.unshift(...warnings);
189
+ return result;
190
+ }
160
191
  const result = installForHost({
161
192
  host,
162
193
  parsed,
@@ -166,7 +197,15 @@ export function runInstaller(parsed, envSource = process.env, packageRoot) {
166
197
  manifest,
167
198
  });
168
199
  result.warnings.unshift(...warnings);
169
- return withVersionContext(result, manifest, parsed.mode === 'skill' ? resolveSkillRecord(manifest, parsed.skillId ?? '') : undefined);
200
+ const withContext = withVersionContext(result, manifest, skill);
201
+ writeInstalledSurfaceStatus({
202
+ result: withContext,
203
+ host,
204
+ mode: parsed.mode,
205
+ skill,
206
+ targetRoot,
207
+ });
208
+ return withContext;
170
209
  }
171
210
  function payloadVersionContext(manifest, skill) {
172
211
  return {
@@ -186,10 +225,248 @@ function payloadVersionContext(manifest, skill) {
186
225
  }
187
226
  function withVersionContext(result, manifest, skill) {
188
227
  return {
228
+ schema_version: 'loom-installer-result/v1',
229
+ operation: 'add',
189
230
  ...result,
190
231
  version_context: payloadVersionContext(manifest, skill),
191
232
  };
192
233
  }
234
+ function distributionLayer(mode) {
235
+ return mode === 'plugin' ? 'host-adapter-plugin' : 'generated-single-skill';
236
+ }
237
+ function skillDirName(host, skillId) {
238
+ if (host === 'codex') {
239
+ return skillId.startsWith('loom-') ? skillId : `loom-${skillId}`;
240
+ }
241
+ return skillId;
242
+ }
243
+ function installedRoot(targetRoot, host, mode, skill) {
244
+ if (mode === 'plugin') {
245
+ return host === 'codex'
246
+ ? join(targetRoot, 'plugins', 'loom')
247
+ : join(targetRoot, '.claude', 'marketplaces', 'loom-local', 'plugins', 'loom');
248
+ }
249
+ assert(skill, 'skill install requires a skill record');
250
+ return host === 'codex'
251
+ ? join(targetRoot, '.agents', 'skills', skillDirName(host, skill.id))
252
+ : join(targetRoot, '.claude', 'skills', skillDirName(host, skill.id));
253
+ }
254
+ function installedStatusPath(root) {
255
+ return join(root, '.loom-install-status.json');
256
+ }
257
+ function writeInstalledSurfaceStatus(input) {
258
+ const root = installedRoot(input.targetRoot, input.host, input.mode, input.skill);
259
+ const statusPath = installedStatusPath(root);
260
+ const status = {
261
+ schema_version: 'loom-installed-surface-status/v1',
262
+ installed_layer: input.result.distribution_layer,
263
+ host_adapter: input.host,
264
+ mode: input.mode,
265
+ ...(input.skill ? { skill_id: input.skill.id } : {}),
266
+ version_context: input.result.version_context,
267
+ runtime_state: 'ready',
268
+ upgrade_eligibility: 'current',
269
+ evidence: [`installed surface metadata at ${statusPath}`],
270
+ failed_layer: null,
271
+ fail_closed_reason: null,
272
+ };
273
+ writeJson(statusPath, status);
274
+ input.result.installed_paths.push(statusPath);
275
+ input.result.installed_status = status;
276
+ }
277
+ function sourcePrefix(mode, manifest, skill) {
278
+ if (mode === 'plugin') {
279
+ return manifest.plugin.relative_path.replace(/\/$/, '');
280
+ }
281
+ assert(skill, 'skill install requires a skill record');
282
+ return skill.relative_path.replace(/\/$/, '');
283
+ }
284
+ function targetRelativePath(mode, host, sourcePath, manifest, skill) {
285
+ const prefix = sourcePrefix(mode, manifest, skill);
286
+ const suffix = sourcePath.slice(prefix.length).replace(/^\//, '');
287
+ if (mode === 'plugin') {
288
+ return host === 'codex'
289
+ ? join('plugins', 'loom', suffix)
290
+ : join('.claude', 'marketplaces', 'loom-local', 'plugins', 'loom', suffix);
291
+ }
292
+ assert(skill, 'skill install requires a skill record');
293
+ return host === 'codex'
294
+ ? join('.agents', 'skills', skillDirName(host, skill.id), suffix)
295
+ : join('.claude', 'skills', skillDirName(host, skill.id), suffix);
296
+ }
297
+ function compareInstalledPayload(input) {
298
+ const prefix = `${sourcePrefix(input.mode, input.manifest, input.skill)}/`;
299
+ const changed = [];
300
+ for (const file of input.manifest.files) {
301
+ if (!file.path.startsWith(prefix)) {
302
+ continue;
303
+ }
304
+ const source = join(input.packageRoot, 'payload', file.path);
305
+ const targetRelative = targetRelativePath(input.mode, input.host, file.path, input.manifest, input.skill);
306
+ const target = join(input.targetRoot, targetRelative);
307
+ if (!fileExists(target) || sha256(source) !== sha256(target)) {
308
+ changed.push(targetRelative);
309
+ }
310
+ }
311
+ return changed.sort();
312
+ }
313
+ function statusFailureResult(input) {
314
+ const available = payloadVersionContext(input.manifest, input.skill);
315
+ const rollbackPath = input.rollbackPath ?? null;
316
+ const installedStatus = {
317
+ schema_version: 'loom-installed-surface-status/v1',
318
+ installed_layer: distributionLayer(input.mode),
319
+ host_adapter: input.host,
320
+ mode: input.mode,
321
+ ...(input.skill ? { skill_id: input.skill.id } : {}),
322
+ version_context: null,
323
+ runtime_state: 'blocked',
324
+ upgrade_eligibility: 'incompatible',
325
+ evidence: input.evidence,
326
+ failed_layer: 'installed-surface',
327
+ fail_closed_reason: input.reason,
328
+ };
329
+ return {
330
+ schema_version: 'loom-installer-result/v1',
331
+ operation: input.operation,
332
+ mode: input.mode,
333
+ host: input.host,
334
+ distribution_layer: distributionLayer(input.mode),
335
+ status: 'blocked',
336
+ installed_paths: rollbackPath ? [rollbackPath] : [],
337
+ verification: input.evidence,
338
+ warnings: [],
339
+ version_context: null,
340
+ installed_status: installedStatus,
341
+ available_version_context: available,
342
+ changed_paths: [],
343
+ drift: [],
344
+ rollback_path: rollbackPath,
345
+ rehearsal: {
346
+ schema_version: 'loom-upgrade-rehearsal/v1',
347
+ mutates_target: false,
348
+ changed_paths: [],
349
+ drift: [],
350
+ rollback_path: rollbackPath,
351
+ },
352
+ failed_layer: 'installed-surface',
353
+ fail_closed_reason: input.reason,
354
+ };
355
+ }
356
+ function sameVersionContext(left, right) {
357
+ if (!left) {
358
+ return false;
359
+ }
360
+ const keys = [
361
+ 'repo_version',
362
+ 'installer_package_version',
363
+ 'plugin_surface_version',
364
+ 'host_adapter_version',
365
+ 'skills_registry_version',
366
+ 'runtime_core_version',
367
+ 'skill_package_version',
368
+ 'skill_contract_version',
369
+ 'skill_package_id',
370
+ ];
371
+ return keys.every((key) => left[key] === right[key]);
372
+ }
373
+ function inspectInstalledSurface(input) {
374
+ const root = installedRoot(input.targetRoot, input.host, input.parsed.mode, input.skill);
375
+ const statusPath = installedStatusPath(root);
376
+ const available = payloadVersionContext(input.manifest, input.skill);
377
+ if (!fileExists(statusPath)) {
378
+ return statusFailureResult({
379
+ operation: input.operation,
380
+ host: input.host,
381
+ mode: input.parsed.mode,
382
+ skill: input.skill,
383
+ manifest: input.manifest,
384
+ rollbackPath: root,
385
+ reason: `installed Loom status metadata is missing: ${relative(input.targetRoot, statusPath)}`,
386
+ evidence: [`missing installed status metadata at ${statusPath}`],
387
+ });
388
+ }
389
+ let installedStatus = readJson(statusPath);
390
+ if (installedStatus.schema_version !== 'loom-installed-surface-status/v1' ||
391
+ installedStatus.host_adapter !== input.host ||
392
+ installedStatus.mode !== input.parsed.mode ||
393
+ installedStatus.installed_layer !== distributionLayer(input.parsed.mode) ||
394
+ !installedStatus.version_context) {
395
+ return statusFailureResult({
396
+ operation: input.operation,
397
+ host: input.host,
398
+ mode: input.parsed.mode,
399
+ skill: input.skill,
400
+ manifest: input.manifest,
401
+ rollbackPath: root,
402
+ reason: `installed Loom status metadata is inconsistent: ${relative(input.targetRoot, statusPath)}`,
403
+ evidence: [`inconsistent installed status metadata at ${statusPath}`],
404
+ });
405
+ }
406
+ if (input.skill && installedStatus.skill_id !== input.skill.id) {
407
+ return statusFailureResult({
408
+ operation: input.operation,
409
+ host: input.host,
410
+ mode: input.parsed.mode,
411
+ skill: input.skill,
412
+ manifest: input.manifest,
413
+ rollbackPath: root,
414
+ reason: `installed Loom skill metadata does not match ${input.skill.id}`,
415
+ evidence: [`skill metadata mismatch at ${statusPath}`],
416
+ });
417
+ }
418
+ const changedPaths = compareInstalledPayload({
419
+ host: input.host,
420
+ mode: input.parsed.mode,
421
+ packageRoot: input.packageRoot,
422
+ targetRoot: input.targetRoot,
423
+ manifest: input.manifest,
424
+ skill: input.skill,
425
+ });
426
+ const versionMatches = sameVersionContext(installedStatus.version_context, available);
427
+ const eligibility = changedPaths.length === 0 && versionMatches ? 'current' : versionMatches ? 'drift' : 'upgrade-available';
428
+ const drift = eligibility === 'drift' ? changedPaths : [];
429
+ const runtimeState = eligibility === 'drift' ? 'blocked' : 'ready';
430
+ const rollbackPath = changedPaths.length > 0 ? root : null;
431
+ const failClosedReason = eligibility === 'drift' ? 'installed Loom payload drifted from its recorded version context' : null;
432
+ installedStatus = {
433
+ ...installedStatus,
434
+ runtime_state: runtimeState,
435
+ upgrade_eligibility: eligibility,
436
+ evidence: [`read installed status metadata at ${statusPath}`, `compared installed payload under ${root}`],
437
+ failed_layer: failClosedReason ? 'installed-surface' : null,
438
+ fail_closed_reason: failClosedReason,
439
+ };
440
+ return {
441
+ schema_version: 'loom-installer-result/v1',
442
+ operation: input.operation,
443
+ mode: input.parsed.mode,
444
+ host: input.host,
445
+ distribution_layer: distributionLayer(input.parsed.mode),
446
+ status: input.operation === 'verify-upgrade' ? (failClosedReason ? 'blocked' : 'verified') : failClosedReason ? 'blocked' : 'planned',
447
+ installed_paths: [root, statusPath],
448
+ verification: [
449
+ `read installed status metadata at ${statusPath}`,
450
+ `compared ${changedPaths.length} changed path(s) without mutating target`,
451
+ ],
452
+ warnings: [],
453
+ version_context: installedStatus.version_context,
454
+ installed_status: installedStatus,
455
+ available_version_context: available,
456
+ changed_paths: changedPaths,
457
+ drift,
458
+ rollback_path: rollbackPath,
459
+ rehearsal: {
460
+ schema_version: 'loom-upgrade-rehearsal/v1',
461
+ mutates_target: false,
462
+ changed_paths: changedPaths,
463
+ drift,
464
+ rollback_path: rollbackPath,
465
+ },
466
+ failed_layer: failClosedReason ? 'installed-surface' : null,
467
+ fail_closed_reason: failClosedReason,
468
+ };
469
+ }
193
470
  function installForHost(input) {
194
471
  const { host, parsed, env, packageRoot, targetRoot, manifest } = input;
195
472
  if (parsed.mode === 'plugin') {
package/dist/src/utils.js CHANGED
@@ -37,6 +37,11 @@ export function ensureTargetWritable(path) {
37
37
  throw new InstallerError(`target path is not writable: ${path}`);
38
38
  }
39
39
  }
40
+ export function ensureTargetExists(path) {
41
+ if (!existsSync(path)) {
42
+ throw new InstallerError(`target path does not exist: ${path}`);
43
+ }
44
+ }
40
45
  export function copyTree(source, target, force) {
41
46
  if (existsSync(target)) {
42
47
  if (!force) {
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { execFileSync, spawnSync } from 'node:child_process';
@@ -56,13 +56,22 @@ test('detectHosts and selectHost fail closed on conflicts', () => {
56
56
  });
57
57
  test('parseCli supports plugin and skill flows', () => {
58
58
  const plugin = parseCli(['add', 'plugin', '--host', 'codex', '--json']);
59
+ assert.equal(plugin.operation, 'add');
59
60
  assert.equal(plugin.mode, 'plugin');
60
61
  assert.equal(plugin.options.host, 'codex');
61
62
  assert.equal(plugin.options.json, true);
62
63
  const skill = parseCli(['add', 'skill', 'loom-review', '--target', '/tmp/repo']);
64
+ assert.equal(skill.operation, 'add');
63
65
  assert.equal(skill.mode, 'skill');
64
66
  assert.equal(skill.skillId, 'loom-review');
65
67
  assert.equal(skill.options.target, '/tmp/repo');
68
+ const plan = parseCli(['upgrade-plan', 'plugin', '--host', 'codex', '--target', '/tmp/repo', '--json']);
69
+ assert.equal(plan.operation, 'upgrade-plan');
70
+ assert.equal(plan.mode, 'plugin');
71
+ const verify = parseCli(['verify-upgrade', 'skill', 'loom-init', '--host', 'claude']);
72
+ assert.equal(verify.operation, 'verify-upgrade');
73
+ assert.equal(verify.mode, 'skill');
74
+ assert.equal(verify.skillId, 'loom-init');
66
75
  });
67
76
  test('payload manifest excludes python cache artifacts', () => {
68
77
  const manifest = JSON.parse(readFileSync(join(packageRoot(), 'payload', 'manifest.json'), 'utf8'));
@@ -119,9 +128,145 @@ test('codex plugin install writes marketplace entry', () => {
119
128
  assert.equal(result.host, 'codex');
120
129
  assert.equal(result.distribution_layer, 'host-adapter-plugin');
121
130
  assert.equal(result.version_context?.plugin_surface_version, '0.4.0');
131
+ assert.equal(result.installed_status?.schema_version, 'loom-installed-surface-status/v1');
132
+ assert.equal(result.installed_status?.runtime_state, 'ready');
122
133
  assert.equal(marketplace.plugins[0].name, 'loom');
123
134
  assert.equal(marketplace.plugins[0].source.path, './plugins/loom');
124
135
  });
136
+ test('upgrade-plan reports current installed plugin without mutating target', () => {
137
+ const base = fixtureRoot();
138
+ const envSource = prepareEnv(base);
139
+ mkdirSync(envSource.CODEX_HOME, { recursive: true });
140
+ const repoRoot = join(base, 'repo');
141
+ mkdirSync(repoRoot, { recursive: true });
142
+ runInstaller({
143
+ mode: 'plugin',
144
+ options: {
145
+ host: 'codex',
146
+ target: repoRoot,
147
+ force: false,
148
+ json: false,
149
+ },
150
+ }, envSource, packageRoot());
151
+ const plan = runInstaller({
152
+ operation: 'upgrade-plan',
153
+ mode: 'plugin',
154
+ options: {
155
+ host: 'codex',
156
+ target: repoRoot,
157
+ force: false,
158
+ json: false,
159
+ },
160
+ }, envSource, packageRoot());
161
+ assert.equal(plan.operation, 'upgrade-plan');
162
+ assert.equal(plan.status, 'planned');
163
+ assert.equal(plan.installed_status?.upgrade_eligibility, 'current');
164
+ assert.deepEqual(plan.changed_paths, []);
165
+ assert.deepEqual(plan.drift, []);
166
+ assert.equal(plan.rehearsal?.mutates_target, false);
167
+ assert.equal(plan.rollback_path, null);
168
+ });
169
+ test('upgrade-plan reports available payload and changed paths', () => {
170
+ const base = fixtureRoot();
171
+ const envSource = prepareEnv(base);
172
+ mkdirSync(envSource.CODEX_HOME, { recursive: true });
173
+ const repoRoot = join(base, 'repo');
174
+ mkdirSync(repoRoot, { recursive: true });
175
+ runInstaller({
176
+ mode: 'plugin',
177
+ options: {
178
+ host: 'codex',
179
+ target: repoRoot,
180
+ force: false,
181
+ json: false,
182
+ },
183
+ }, envSource, packageRoot());
184
+ const statusPath = join(repoRoot, 'plugins', 'loom', '.loom-install-status.json');
185
+ const status = JSON.parse(readFileSync(statusPath, 'utf8'));
186
+ status.version_context.installer_package_version = '0.0.0';
187
+ writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
188
+ writeFileSync(join(repoRoot, 'plugins', 'loom', 'skills', 'registry.json'), '{"registry_version":"0.0.0"}\n', 'utf8');
189
+ const plan = runInstaller({
190
+ operation: 'upgrade-plan',
191
+ mode: 'plugin',
192
+ options: {
193
+ host: 'codex',
194
+ target: repoRoot,
195
+ force: false,
196
+ json: false,
197
+ },
198
+ }, envSource, packageRoot());
199
+ assert.equal(plan.status, 'planned');
200
+ assert.equal(plan.installed_status?.upgrade_eligibility, 'upgrade-available');
201
+ assert.equal(plan.changed_paths?.includes(join('plugins', 'loom', 'skills', 'registry.json')), true);
202
+ assert.equal(plan.rollback_path, join(repoRoot, 'plugins', 'loom'));
203
+ });
204
+ test('verify-upgrade fails closed when installed payload drifts from recorded metadata', () => {
205
+ const base = fixtureRoot();
206
+ const envSource = prepareEnv(base);
207
+ mkdirSync(envSource.CODEX_HOME, { recursive: true });
208
+ const repoRoot = join(base, 'repo');
209
+ mkdirSync(repoRoot, { recursive: true });
210
+ runInstaller({
211
+ mode: 'plugin',
212
+ options: {
213
+ host: 'codex',
214
+ target: repoRoot,
215
+ force: false,
216
+ json: false,
217
+ },
218
+ }, envSource, packageRoot());
219
+ writeFileSync(join(repoRoot, 'plugins', 'loom', 'skills', 'registry.json'), '{"registry_version":"drift"}\n', 'utf8');
220
+ const verify = runInstaller({
221
+ operation: 'verify-upgrade',
222
+ mode: 'plugin',
223
+ options: {
224
+ host: 'codex',
225
+ target: repoRoot,
226
+ force: false,
227
+ json: false,
228
+ },
229
+ }, envSource, packageRoot());
230
+ assert.equal(verify.status, 'blocked');
231
+ assert.equal(verify.installed_status?.runtime_state, 'blocked');
232
+ assert.equal(verify.installed_status?.upgrade_eligibility, 'drift');
233
+ assert.equal(verify.drift?.includes(join('plugins', 'loom', 'skills', 'registry.json')), true);
234
+ assert.equal(verify.failed_layer, 'installed-surface');
235
+ assert.match(verify.fail_closed_reason ?? '', /drifted/);
236
+ assert.equal(verify.rollback_path, join(repoRoot, 'plugins', 'loom'));
237
+ });
238
+ test('verify-upgrade fails closed when installed version metadata is missing', () => {
239
+ const base = fixtureRoot();
240
+ const envSource = prepareEnv(base);
241
+ mkdirSync(envSource.CODEX_HOME, { recursive: true });
242
+ const repoRoot = join(base, 'repo');
243
+ mkdirSync(repoRoot, { recursive: true });
244
+ runInstaller({
245
+ mode: 'plugin',
246
+ options: {
247
+ host: 'codex',
248
+ target: repoRoot,
249
+ force: false,
250
+ json: false,
251
+ },
252
+ }, envSource, packageRoot());
253
+ rmSync(join(repoRoot, 'plugins', 'loom', '.loom-install-status.json'));
254
+ const verify = runInstaller({
255
+ operation: 'verify-upgrade',
256
+ mode: 'plugin',
257
+ options: {
258
+ host: 'codex',
259
+ target: repoRoot,
260
+ force: false,
261
+ json: false,
262
+ },
263
+ }, envSource, packageRoot());
264
+ assert.equal(verify.status, 'blocked');
265
+ assert.equal(verify.installed_status?.upgrade_eligibility, 'incompatible');
266
+ assert.equal(verify.failed_layer, 'installed-surface');
267
+ assert.match(verify.fail_closed_reason ?? '', /metadata is missing/);
268
+ assert.equal(verify.rollback_path, join(repoRoot, 'plugins', 'loom'));
269
+ });
125
270
  test('codex plugin install fails closed on marketplace conflicts without force', () => {
126
271
  const base = fixtureRoot();
127
272
  const envSource = prepareEnv(base);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mc-and-his-agents/loom-installer",
3
- "version": "0.1.79",
3
+ "version": "0.1.80",
4
4
  "description": "Node installer for Loom plugin and single-skill installation surfaces.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,12 +2,12 @@
2
2
  "schema_version": "loom-installer-payload/v1",
3
3
  "loom_version": "v0.6.0",
4
4
  "source_repository": "https://github.com/MC-and-his-Agents/Loom",
5
- "source_commit": "6ec1e597c9b2295cbf205af7d2deebb3efb0f308",
5
+ "source_commit": "0b80a513948efe0fa2fbfa37c20f151fa6ed66e6",
6
6
  "source_ref": "main",
7
- "built_at": "2026-05-09T03:16:44+08:00",
7
+ "built_at": "2026-05-09T04:16:59+08:00",
8
8
  "version_context": {
9
9
  "repo_version": "v0.6.0",
10
- "installer_package_version": "0.1.79",
10
+ "installer_package_version": "0.1.80",
11
11
  "plugin_surface_version": "0.4.0",
12
12
  "host_adapter_version": "1.0.0",
13
13
  "skills_registry_version": "1.4.0",