create-principles-disciple 1.145.6 → 1.145.8

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.
Files changed (102) hide show
  1. package/_release/product-identity.json +2 -2
  2. package/codex-adapter/dist/ingestion/explicit-owner-statement.d.ts +1 -17
  3. package/codex-adapter/dist/ingestion/explicit-owner-statement.js +1 -21
  4. package/console/dist/server/models/CodexGovernanceHealthModel.d.ts +39 -2
  5. package/console/dist/server/models/CodexGovernanceHealthModel.js +228 -29
  6. package/core/dist/runtime-v2/adapter/artificer-l2-adapter.d.ts.map +1 -1
  7. package/core/dist/runtime-v2/adapter/artificer-l2-adapter.js +28 -9
  8. package/core/dist/runtime-v2/adapter/artificer-l2-adapter.js.map +1 -1
  9. package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.d.ts.map +1 -1
  10. package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.js +29 -9
  11. package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.js.map +1 -1
  12. package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.d.ts +6 -0
  13. package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.d.ts.map +1 -1
  14. package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.js +105 -1
  15. package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.js.map +1 -1
  16. package/core/dist/runtime-v2/context-payload.d.ts +4 -4
  17. package/core/dist/runtime-v2/context-payload.d.ts.map +1 -1
  18. package/core/dist/runtime-v2/context-payload.js +1 -0
  19. package/core/dist/runtime-v2/context-payload.js.map +1 -1
  20. package/core/dist/runtime-v2/index.d.ts +7 -2
  21. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  22. package/core/dist/runtime-v2/index.js +4 -1
  23. package/core/dist/runtime-v2/index.js.map +1 -1
  24. package/core/dist/runtime-v2/internalization/artificer-intent-preflight.d.ts +154 -0
  25. package/core/dist/runtime-v2/internalization/artificer-intent-preflight.d.ts.map +1 -0
  26. package/core/dist/runtime-v2/internalization/artificer-intent-preflight.js +686 -0
  27. package/core/dist/runtime-v2/internalization/artificer-intent-preflight.js.map +1 -0
  28. package/core/dist/runtime-v2/internalization/artificer-prompt-builder.d.ts +21 -3
  29. package/core/dist/runtime-v2/internalization/artificer-prompt-builder.d.ts.map +1 -1
  30. package/core/dist/runtime-v2/internalization/artificer-prompt-builder.js +48 -5
  31. package/core/dist/runtime-v2/internalization/artificer-prompt-builder.js.map +1 -1
  32. package/core/dist/runtime-v2/internalization/artificer-runner.d.ts +17 -0
  33. package/core/dist/runtime-v2/internalization/artificer-runner.d.ts.map +1 -1
  34. package/core/dist/runtime-v2/internalization/artificer-runner.js +45 -2
  35. package/core/dist/runtime-v2/internalization/artificer-runner.js.map +1 -1
  36. package/core/dist/runtime-v2/internalization/behavior-example-pack.d.ts +0 -19
  37. package/core/dist/runtime-v2/internalization/behavior-example-pack.d.ts.map +1 -1
  38. package/core/dist/runtime-v2/internalization/behavior-example-pack.js +31 -0
  39. package/core/dist/runtime-v2/internalization/behavior-example-pack.js.map +1 -1
  40. package/core/dist/runtime-v2/internalization/intent-contract.d.ts +31 -0
  41. package/core/dist/runtime-v2/internalization/intent-contract.d.ts.map +1 -1
  42. package/core/dist/runtime-v2/internalization/intent-contract.js +42 -0
  43. package/core/dist/runtime-v2/internalization/intent-contract.js.map +1 -1
  44. package/core/dist/runtime-v2/internalization/peer-runner-contracts.d.ts +14 -1
  45. package/core/dist/runtime-v2/internalization/peer-runner-contracts.d.ts.map +1 -1
  46. package/core/dist/runtime-v2/internalization/peer-runner-contracts.js.map +1 -1
  47. package/core/dist/runtime-v2/internalization/pitask-metadata.d.ts +64 -0
  48. package/core/dist/runtime-v2/internalization/pitask-metadata.d.ts.map +1 -1
  49. package/core/dist/runtime-v2/internalization/pitask-metadata.js +84 -0
  50. package/core/dist/runtime-v2/internalization/pitask-metadata.js.map +1 -1
  51. package/core/dist/runtime-v2/internalization/scribe-output.d.ts +13 -1
  52. package/core/dist/runtime-v2/internalization/scribe-output.d.ts.map +1 -1
  53. package/core/dist/runtime-v2/internalization/scribe-output.js +13 -1
  54. package/core/dist/runtime-v2/internalization/scribe-output.js.map +1 -1
  55. package/core/dist/runtime-v2/internalization/scribe-prompt-builder.d.ts +15 -1
  56. package/core/dist/runtime-v2/internalization/scribe-prompt-builder.d.ts.map +1 -1
  57. package/core/dist/runtime-v2/internalization/scribe-prompt-builder.js +29 -1
  58. package/core/dist/runtime-v2/internalization/scribe-prompt-builder.js.map +1 -1
  59. package/core/dist/runtime-v2/task-status.d.ts +1 -1
  60. package/core/dist/runtime-v2/tools/agent-tool-contract.d.ts +2 -1
  61. package/core/dist/runtime-v2/tools/agent-tool-contract.d.ts.map +1 -1
  62. package/core/dist/runtime-v2/tools/agent-tool-contract.js +1 -1
  63. package/core/dist/runtime-v2/tools/agent-tool-contract.js.map +1 -1
  64. package/core/dist/runtime-v2/tools/artificer-l2-tool-contract.d.ts +1 -1
  65. package/core/dist/telemetry-event.d.ts +4 -2
  66. package/core/dist/telemetry-event.d.ts.map +1 -1
  67. package/core/dist/telemetry-event.js +12 -0
  68. package/core/dist/telemetry-event.js.map +1 -1
  69. package/core/package.json +3 -3
  70. package/dist/installers/workbuddy-host-installer.d.ts +10 -3
  71. package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
  72. package/dist/installers/workbuddy-host-installer.js +400 -39
  73. package/dist/installers/workbuddy-host-installer.js.map +1 -1
  74. package/host-runtime/dist/explicit-owner-statement-prefix.d.ts +17 -0
  75. package/host-runtime/dist/explicit-owner-statement-prefix.js +21 -0
  76. package/host-runtime/dist/governance-signal-admission.d.ts +2 -2
  77. package/host-runtime/dist/governance-signal-admission.js +15 -8
  78. package/host-runtime/dist/index.d.ts +1 -0
  79. package/host-runtime/dist/index.js +1 -0
  80. package/host-runtime/dist/internalization-consumer-cycle.d.ts +10 -1
  81. package/host-runtime/dist/internalization-consumer-cycle.js +85 -4
  82. package/host-runtime/dist/production-rulehost-gate.js +40 -3
  83. package/host-runtime/package.json +1 -1
  84. package/package.json +2 -2
  85. package/plugin/dist/bundle.js +655 -781
  86. package/plugin/dist/openclaw.plugin.json +1 -1
  87. package/plugin/dist/rulehost-evidence.js +27 -27
  88. package/plugin/openclaw.plugin.json +1 -1
  89. package/plugin/package.json +1 -1
  90. package/release-manager/dist/installers/workbuddy-host-installer.d.ts +10 -3
  91. package/release-manager/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
  92. package/release-manager/dist/installers/workbuddy-host-installer.js +400 -39
  93. package/release-manager/dist/installers/workbuddy-host-installer.js.map +1 -1
  94. package/release-manager/package.json +2 -2
  95. package/workbuddy-adapter/dist/explicit-owner-statement.d.ts +3 -0
  96. package/workbuddy-adapter/dist/explicit-owner-statement.js +52 -0
  97. package/workbuddy-adapter/dist/pd-hook.js +31 -7
  98. package/workbuddy-adapter/plugin/scripts/pd-control.cjs +45 -0
  99. package/workbuddy-adapter/plugin/skills/pd-disable/SKILL.md +18 -0
  100. package/workbuddy-adapter/plugin/skills/pd-record-pain/SKILL.md +21 -0
  101. package/workbuddy-adapter/plugin/skills/pd-review/SKILL.md +16 -0
  102. package/workbuddy-adapter/plugin/skills/pd-status/SKILL.md +17 -0
@@ -42,11 +42,12 @@
42
42
  * result reason; every result carries reason + nextAction.
43
43
  */
44
44
  import { spawnSync } from 'node:child_process';
45
- import { existsSync, mkdirSync, readFileSync, rmSync, cpSync, writeFileSync } from 'node:fs';
45
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, cpSync, writeFileSync } from 'node:fs';
46
46
  import * as path from 'node:path';
47
47
  import * as yaml from 'js-yaml';
48
+ import semver from 'semver';
48
49
  import { getInstallLayoutPaths, getWorkbuddyWorkspaceDir, installManifestHostsForPersist, mergeInstallManifestWorkspaces, parseInstallManifest, INSTALL_LAYOUT_VERSION, } from '@principles/install-layout';
49
- import { atomicReplaceTextFile } from '../utils/config-file-io.js';
50
+ import { atomicReplaceTextFile, errnoCode } from '../utils/config-file-io.js';
50
51
  import { getHomeDir, generateConfigYamlContent, getConfigYamlPath, resolveDefaultModelForProvider, isValidHttpBaseUrl } from '../mvp-config.js';
51
52
  // Type-only import from @principles/core — erased at compile time; this
52
53
  // package ships self-contained without @principles/core (PRI-625/PRI-669).
@@ -57,6 +58,19 @@ export const WORKBUDDY_PLUGIN_NAME = 'principles-disciple';
57
58
  export const WORKBUDDY_MARKETPLACE_NAME = 'principles-disciple-market';
58
59
  /** Registry/settings key: "principles-disciple@principles-disciple-market". */
59
60
  export const WORKBUDDY_PLUGIN_KEY = `${WORKBUDDY_PLUGIN_NAME}@${WORKBUDDY_MARKETPLACE_NAME}`;
61
+ const WORKBUDDY_REQUIRED_SKILLS = ['pd-record-pain', 'pd-status', 'pd-review', 'pd-disable'];
62
+ /**
63
+ * Files the host needs to load this plugin. One list serves both the
64
+ * "is the cached payload current?" comparison and the "did the staged copy
65
+ * finish?" proof — two copies of this set would drift apart silently.
66
+ */
67
+ const WORKBUDDY_PAYLOAD_FILES = [
68
+ path.join('.codebuddy-plugin', 'plugin.json'),
69
+ path.join('hooks', 'hooks.json'),
70
+ path.join('scripts', 'wrapper.cjs'),
71
+ path.join('scripts', 'pd-control.cjs'),
72
+ ...WORKBUDDY_REQUIRED_SKILLS.map((skill) => path.join('skills', skill, 'SKILL.md')),
73
+ ];
60
74
  /** Engine CLI shipped with WorkBuddy Desktop (probe P01 frozen path). */
61
75
  export const WORKBUDDY_ENGINE_RELATIVE = ['resources', 'app.asar.unpacked', 'cli', 'dist', 'codebuddy-lite-wb.mjs'];
62
76
  /** Engine command success markers (frozen by probe P01 regexes). */
@@ -70,6 +84,47 @@ const GOVERNANCE_INIT_TIMEOUT_MS = 180_000;
70
84
  function isRecord(value) {
71
85
  return typeof value === 'object' && value !== null && !Array.isArray(value);
72
86
  }
87
+ /** Native v2 stores scope records as an array; old PD installs used one object. */
88
+ function userInstalledPluginRecord(value) {
89
+ const records = Array.isArray(value) ? value : [value];
90
+ return records.find((record) => isRecord(record) && record.scope === 'user' && record.projectPath === undefined);
91
+ }
92
+ function isSafePluginVersion(version) {
93
+ return typeof version === 'string'
94
+ && /^[0-9][0-9A-Za-z.+-]*$/.test(version)
95
+ && semver.valid(version) !== null;
96
+ }
97
+ /** Deletion/writes may target only a strict child of our fixed cache root. */
98
+ function workbuddyCacheVersionDir(cacheRoot, version) {
99
+ if (!isSafePluginVersion(version))
100
+ return undefined;
101
+ const root = path.resolve(cacheRoot);
102
+ const target = path.resolve(root, version);
103
+ const relative = path.relative(root, target);
104
+ if (relative.length === 0 || path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`))
105
+ return undefined;
106
+ return target;
107
+ }
108
+ /** Every scope record PD may have written for its key (native array or legacy object). */
109
+ function installedPluginRecords(value) {
110
+ return (Array.isArray(value) ? value : [value]).filter(isRecord);
111
+ }
112
+ /**
113
+ * Proof that a *staged* cache payload is complete before it replaces the live
114
+ * one. Returns the first missing/empty required file, or undefined when the
115
+ * payload is loadable. Never trusts `cpSync` returning without throwing.
116
+ */
117
+ function missingWorkbuddyPayloadFile(root) {
118
+ for (const file of WORKBUDDY_PAYLOAD_FILES) {
119
+ try {
120
+ if (readFileSync(path.join(root, file)).length > 0)
121
+ continue;
122
+ }
123
+ catch { /* unreachable payload — reported below, never guessed */ }
124
+ return file;
125
+ }
126
+ return undefined;
127
+ }
73
128
  export function resolveWorkbuddyPaths(homeDir) {
74
129
  const layout = getInstallLayoutPaths(homeDir);
75
130
  // The frozen governance dir (~/.workbuddy/pd-workspace) defines the host
@@ -175,8 +230,14 @@ export function readHostJsonFile(filePath) {
175
230
  try {
176
231
  raw = readFileSync(filePath, 'utf-8');
177
232
  }
178
- catch {
179
- return { ok: true, value: undefined, existed: false };
233
+ catch (error) {
234
+ if (errnoCode(error) === 'ENOENT')
235
+ return { ok: true, value: undefined, existed: false };
236
+ return {
237
+ ok: false, path: filePath,
238
+ reason: `workbuddy_settings_unreadable: cannot read ${filePath} (${error instanceof Error ? error.message : String(error)}). No PD write was performed.`,
239
+ nextAction: 'Restore host file read access before installing or uninstalling; do not delete caches while registry references are unknown.',
240
+ };
180
241
  }
181
242
  let parsed;
182
243
  try {
@@ -273,6 +334,9 @@ export function findWorkbuddyPluginPayload(pluginDir, runtimeAdapterDir) {
273
334
  if (!isRecord(manifest) || manifest.name !== WORKBUDDY_PLUGIN_NAME) {
274
335
  return { reason: `workbuddy_plugin_manifest_invalid: ${manifestPath} must declare name "${WORKBUDDY_PLUGIN_NAME}" (got ${isRecord(manifest) ? JSON.stringify(manifest.name) : 'unparsable manifest'}).` };
275
336
  }
337
+ if (!isSafePluginVersion(manifest.version)) {
338
+ return { reason: `workbuddy_plugin_version_invalid: ${manifestPath} must declare a safe single-segment semantic version.` };
339
+ }
276
340
  let hooks;
277
341
  try {
278
342
  hooks = JSON.parse(readFileSync(hooksPath, 'utf-8'));
@@ -283,7 +347,21 @@ export function findWorkbuddyPluginPayload(pluginDir, runtimeAdapterDir) {
283
347
  if (!isRecord(hooks) || !isRecord(hooks.hooks)) {
284
348
  return { reason: `workbuddy_plugin_hooks_invalid: ${hooksPath} must contain a "hooks" object (frozen manifest-facts shape).` };
285
349
  }
286
- const version = typeof manifest.version === 'string' && manifest.version.length > 0 ? manifest.version : '0.0.0';
350
+ // Plugin-root discovery is the WorkBuddy skill delivery boundary. Do not
351
+ // register an old hook-only payload as a complete install.
352
+ for (const skill of WORKBUDDY_REQUIRED_SKILLS) {
353
+ const skillPath = path.join(root, 'skills', skill, 'SKILL.md');
354
+ try {
355
+ if (readFileSync(skillPath, 'utf-8').trim().length > 0)
356
+ continue;
357
+ }
358
+ catch { /* Missing/unreadable required skills fail before registration. */ }
359
+ return { reason: `workbuddy_plugin_skill_missing: required nonempty skill ${skillPath}. Reinstall with a complete WorkBuddy payload.` };
360
+ }
361
+ if (!existsSync(path.join(root, 'scripts', 'pd-control.cjs'))) {
362
+ return { reason: `workbuddy_plugin_control_missing: ${root} must ship scripts/pd-control.cjs for the pd-disable skill.` };
363
+ }
364
+ const { version } = manifest;
287
365
  return { payload: { pluginDir: root, version } };
288
366
  }
289
367
  return {
@@ -1139,6 +1217,12 @@ export class WorkBuddyHostInstaller {
1139
1217
  }
1140
1218
  registerPlugin(details, version) {
1141
1219
  const p = this.paths();
1220
+ // A registered old plugin can still be hook-only, even at the same
1221
+ // manifest version. Reuse the installer-owned registration/cache writer
1222
+ // rather than reporting a stale cache as an idempotent successful install.
1223
+ if (details.registered && !this.pluginCacheMatchesPayload(version)) {
1224
+ return this.directWriteRegistration(details, version, 'registered plugin cache differs from the current bundled payload; refreshed by installer');
1225
+ }
1142
1226
  // Idempotent fast path: already registered AND enabled AND cached.
1143
1227
  if (details.registered && details.pluginEnabled && existsSync(p.cachePluginRoot)) {
1144
1228
  return { ok: true, mode: 'already-registered', settingsAction: 'preserved' };
@@ -1202,6 +1286,28 @@ export class WorkBuddyHostInstaller {
1202
1286
  return direct;
1203
1287
  return { ok: false, reason: direct.reason, nextAction: direct.nextAction };
1204
1288
  }
1289
+ pluginCacheMatchesPayload(version) {
1290
+ const p = this.paths();
1291
+ const registry = readHostJsonFile(p.registryPath);
1292
+ const rawEntry = registry.ok && isRecord(registry.value?.plugins)
1293
+ ? registry.value.plugins[WORKBUDDY_PLUGIN_KEY] : undefined;
1294
+ const entry = userInstalledPluginRecord(rawEntry);
1295
+ const cacheDir = workbuddyCacheVersionDir(p.cachePluginRoot, version);
1296
+ if (cacheDir === undefined)
1297
+ return false;
1298
+ // The host may write the same install path in a different (but equivalent)
1299
+ // textual form; comparing raw strings would re-register on every run.
1300
+ const entryInstallPath = typeof entry?.installPath === 'string' ? path.resolve(entry.installPath) : undefined;
1301
+ if (!isRecord(entry) || entry.version !== version || entryInstallPath !== path.resolve(cacheDir))
1302
+ return false;
1303
+ const sourceDir = path.join(p.marketplaceRoot, WORKBUDDY_PLUGIN_NAME);
1304
+ try {
1305
+ return WORKBUDDY_PAYLOAD_FILES.every((file) => readFileSync(path.join(sourceDir, file)).equals(readFileSync(path.join(cacheDir, file))));
1306
+ }
1307
+ catch {
1308
+ return false;
1309
+ }
1310
+ }
1205
1311
  /**
1206
1312
  * Set enabledPlugins[OUR_KEY]=true in settings.json, preserving every other
1207
1313
  * key (same protected write discipline as the direct-write registration).
@@ -1225,55 +1331,187 @@ export class WorkBuddyHostInstaller {
1225
1331
  /**
1226
1332
  * Direct-write registration for engine-unreachable machines, aligned with the
1227
1333
  * frozen P01 formats: enabledPlugins[key]=true in settings.json (other keys
1228
- * and other plugins preserved), installed_plugins.json v2 entry
1229
- * {scope:'user', installPath, version, installedAt}, and the versioned cache
1334
+ * and other plugins preserved), installed_plugins.json v2 user registration
1335
+ * (native scope array or legacy object), and the versioned cache
1230
1336
  * copy under plugins/cache/<marketplace>/<plugin>/<version>/.
1231
1337
  */
1232
1338
  directWriteRegistration(details, version, engineNote) {
1233
1339
  const p = this.paths();
1340
+ const cacheVersionDir = workbuddyCacheVersionDir(p.cachePluginRoot, version);
1341
+ if (cacheVersionDir === undefined) {
1342
+ return { ok: false, reason: 'workbuddy_cache_path_invalid: version must resolve strictly inside the PD plugin cache root. No registration/cache write was performed.', nextAction: 'Reinstall with a valid WorkBuddy plugin payload.' };
1343
+ }
1234
1344
  const settings = readHostJsonFile(p.settingsPath);
1235
1345
  if (!settings.ok)
1236
1346
  return { ok: false, reason: settings.reason, nextAction: settings.nextAction };
1237
1347
  const registry = readHostJsonFile(p.registryPath);
1238
1348
  if (!registry.ok)
1239
1349
  return { ok: false, reason: registry.reason, nextAction: registry.nextAction };
1240
- // settings.json merge
1350
+ // Capture exact pre-install bytes so a later JSON commit failure can undo
1351
+ // *both* host settings and the cache swap, including original formatting.
1352
+ let originalSettingsBytes;
1353
+ let originalRegistryBytes;
1354
+ try {
1355
+ if (settings.existed)
1356
+ originalSettingsBytes = readFileSync(p.settingsPath, 'utf-8');
1357
+ if (registry.existed)
1358
+ originalRegistryBytes = readFileSync(p.registryPath, 'utf-8');
1359
+ }
1360
+ catch (error) {
1361
+ return { ok: false, reason: `workbuddy_registration_snapshot_failed: ${error instanceof Error ? error.message : String(error)}. No change was made.`, nextAction: 'Check host registry/settings permissions, then retry.' };
1362
+ }
1363
+ // settings.json merge — computed here, WRITTEN after the cache lands (see
1364
+ // below): a failed payload must leave no registration state behind.
1241
1365
  const nextSettings = { ...(settings.value ?? {}) };
1242
1366
  const enabledPlugins = isRecord(nextSettings.enabledPlugins) ? { ...nextSettings.enabledPlugins } : {};
1243
1367
  enabledPlugins[WORKBUDDY_PLUGIN_KEY] = true;
1244
1368
  nextSettings.enabledPlugins = enabledPlugins;
1245
- const settingsWrite = writeHostJsonFileProtected(p.settingsPath, JSON.stringify(nextSettings, null, 2) + '\n');
1246
- if (!settingsWrite.ok)
1247
- return { ok: false, reason: settingsWrite.reason, nextAction: settingsWrite.nextAction };
1248
1369
  const settingsAction = settings.existed ? 'updated' : 'created';
1249
1370
  // installed_plugins.json v2 merge (create with version 2 when absent)
1250
1371
  const nextRegistry = { ...(registry.value ?? {}) };
1251
1372
  if (typeof nextRegistry.version !== 'number')
1252
1373
  nextRegistry.version = 2;
1253
1374
  const plugins = isRecord(nextRegistry.plugins) ? { ...nextRegistry.plugins } : {};
1254
- const cacheVersionDir = path.join(p.cachePluginRoot, version);
1255
- plugins[WORKBUDDY_PLUGIN_KEY] = {
1375
+ const existingEntry = plugins[WORKBUDDY_PLUGIN_KEY];
1376
+ const previousUser = userInstalledPluginRecord(existingEntry);
1377
+ const now = new Date().toISOString();
1378
+ const userEntry = {
1379
+ ...previousUser,
1256
1380
  scope: 'user',
1257
1381
  installPath: cacheVersionDir,
1258
1382
  version,
1259
- installedAt: new Date().toISOString(),
1383
+ installedAt: typeof previousUser?.installedAt === 'string' ? previousUser.installedAt : now,
1384
+ lastUpdated: now,
1260
1385
  };
1386
+ // Update only the user registration. Project/local/managed records and
1387
+ // unknown fields remain owned by the host, even under the same plugin key.
1388
+ plugins[WORKBUDDY_PLUGIN_KEY] = Array.isArray(existingEntry)
1389
+ ? previousUser !== undefined
1390
+ ? existingEntry.map((record) => record === previousUser ? userEntry : record)
1391
+ : [...existingEntry, userEntry]
1392
+ : isRecord(existingEntry) && previousUser === undefined
1393
+ ? [existingEntry, userEntry]
1394
+ : userEntry;
1261
1395
  nextRegistry.plugins = plugins;
1262
- const registryWrite = writeHostJsonFileProtected(p.registryPath, JSON.stringify(nextRegistry, null, 2) + '\n');
1263
- if (!registryWrite.ok)
1264
- return { ok: false, reason: registryWrite.reason, nextAction: registryWrite.nextAction };
1396
+ // The registry write is DEFERRED until the cache payload is proven in place:
1397
+ // pointing the host at a version whose directory was never materialized
1398
+ // would turn a failed upgrade into an unloadable plugin.
1265
1399
  // Versioned cache copy from our marketplace source.
1400
+ //
1401
+ // A same-version refresh REPLACES the payload the host is currently loading,
1402
+ // so the copy never targets the live directory: build it in a staging
1403
+ // sibling, prove it complete, and only then swap. A failure (ENOSPC, EPERM
1404
+ // on a locked file, a partial cpSync) must leave the previous cache intact —
1405
+ // otherwise a failed upgrade trades a working old plugin for a broken one.
1406
+ const previousCacheExisted = existsSync(cacheVersionDir);
1407
+ const cacheParent = path.resolve(path.dirname(p.cachePluginRoot));
1408
+ const stagingDir = path.join(cacheParent, `.pd-staging-${process.pid}-${Date.now()}`);
1409
+ const backupDir = path.join(cacheParent, `.pd-backup-${process.pid}-${Date.now()}`);
1410
+ const swapFailedReason = (error) => `workbuddy_cache_write_failed: ${error instanceof Error ? error.message : String(error)}`;
1411
+ let restoreFailure;
1266
1412
  try {
1267
- rmSync(p.cachePluginRoot, { recursive: true, force: true });
1268
- cpSync(path.join(p.marketplaceRoot, WORKBUDDY_PLUGIN_NAME), cacheVersionDir, { recursive: true });
1413
+ if (workbuddyCacheVersionDir(p.cachePluginRoot, version) !== cacheVersionDir)
1414
+ throw new Error('workbuddy_cache_path_invalid');
1415
+ // Both temp paths are derived locally, but a future refactor must not be
1416
+ // able to point them outside the marketplace cache parent.
1417
+ if (path.dirname(path.resolve(stagingDir)) !== cacheParent || path.dirname(path.resolve(backupDir)) !== cacheParent) {
1418
+ throw new Error('workbuddy_cache_path_invalid');
1419
+ }
1420
+ rmSync(stagingDir, { recursive: true, force: true });
1421
+ rmSync(backupDir, { recursive: true, force: true });
1422
+ cpSync(path.join(p.marketplaceRoot, WORKBUDDY_PLUGIN_NAME), stagingDir, { recursive: true });
1423
+ const missing = missingWorkbuddyPayloadFile(stagingDir);
1424
+ if (missing !== undefined)
1425
+ throw new Error(`staged payload is incomplete (missing or empty ${missing})`);
1426
+ // Swap: move the live dir aside, move the verified payload in, drop the backup.
1427
+ if (existsSync(cacheVersionDir))
1428
+ renameSync(cacheVersionDir, backupDir);
1429
+ try {
1430
+ mkdirSync(path.dirname(cacheVersionDir), { recursive: true });
1431
+ renameSync(stagingDir, cacheVersionDir);
1432
+ }
1433
+ catch (swapError) {
1434
+ // Never leave the host without a payload: put the previous one back.
1435
+ try {
1436
+ if (existsSync(backupDir))
1437
+ renameSync(backupDir, cacheVersionDir);
1438
+ }
1439
+ catch (restoreError) {
1440
+ restoreFailure = restoreError instanceof Error ? restoreError.message : String(restoreError);
1441
+ throw new Error(`workbuddy_cache_swap_failed: ${swapError instanceof Error ? swapError.message : String(swapError)}; restoring the previous cache also failed`, { cause: restoreError });
1442
+ }
1443
+ throw swapError;
1444
+ }
1445
+ // Do not discard the old cache until *both* JSON files are committed.
1446
+ // A registry/settings write can still fail after a successful swap.
1269
1447
  }
1270
1448
  catch (error) {
1449
+ // The live cache is untouched (or restored); only our temp dir may remain.
1450
+ try {
1451
+ rmSync(stagingDir, { recursive: true, force: true });
1452
+ }
1453
+ catch { /* reported below */ }
1454
+ const cacheState = restoreFailure === undefined
1455
+ ? 'the previous cache payload was left in place'
1456
+ : `the previous cache payload is preserved at ${backupDir} but could NOT be restored in place (${restoreFailure})`;
1271
1457
  return {
1272
1458
  ok: false,
1273
- reason: `workbuddy_cache_write_failed: ${error instanceof Error ? error.message : String(error)}. Settings and registry were updated; the cache copy is incomplete.`,
1459
+ reason: `${swapFailedReason(error)}. No registration state was changed; ${cacheState}.`,
1274
1460
  nextAction: `Re-run the installer (idempotent), or run the engine command manually: node <engine> plugin install ${WORKBUDDY_PLUGIN_KEY}.`,
1275
1461
  };
1276
1462
  }
1463
+ // Cache is verified and in place. Commit the two host files, retaining
1464
+ // the old cache until they both succeed; failures restore all three.
1465
+ const rollbackCommit = (failedWrite) => {
1466
+ const rollbackErrors = [];
1467
+ try {
1468
+ if (previousCacheExisted) {
1469
+ if (!existsSync(backupDir))
1470
+ throw new Error('previous cache backup missing');
1471
+ rmSync(cacheVersionDir, { recursive: true, force: true });
1472
+ renameSync(backupDir, cacheVersionDir);
1473
+ }
1474
+ else {
1475
+ rmSync(cacheVersionDir, { recursive: true, force: true });
1476
+ }
1477
+ }
1478
+ catch (error) {
1479
+ rollbackErrors.push(`cache: ${error instanceof Error ? error.message : String(error)} (backup: ${backupDir})`);
1480
+ }
1481
+ const restoreJson = (file, original) => {
1482
+ try {
1483
+ if (original === undefined) {
1484
+ rmSync(file, { force: true });
1485
+ }
1486
+ else if (readFileSync(file, 'utf-8') !== original) {
1487
+ const restored = writeHostJsonFileProtected(file, original);
1488
+ if (!restored.ok)
1489
+ throw new Error(restored.reason);
1490
+ }
1491
+ }
1492
+ catch (error) {
1493
+ rollbackErrors.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
1494
+ }
1495
+ };
1496
+ restoreJson(p.settingsPath, originalSettingsBytes);
1497
+ restoreJson(p.registryPath, originalRegistryBytes);
1498
+ return {
1499
+ ok: false,
1500
+ reason: `workbuddy_registration_commit_failed: ${failedWrite.reason}. ${rollbackErrors.length === 0 ? 'Previous cache and registration state restored.' : `Rollback incomplete: ${rollbackErrors.join('; ')}`}`,
1501
+ nextAction: rollbackErrors.length === 0 ? failedWrite.nextAction : 'Preserve the reported backup and repair the host registration before retrying.',
1502
+ };
1503
+ };
1504
+ const settingsWrite = writeHostJsonFileProtected(p.settingsPath, JSON.stringify(nextSettings, null, 2) + '\n');
1505
+ if (!settingsWrite.ok)
1506
+ return rollbackCommit(settingsWrite);
1507
+ const registryWrite = writeHostJsonFileProtected(p.registryPath, JSON.stringify(nextRegistry, null, 2) + '\n');
1508
+ if (!registryWrite.ok)
1509
+ return rollbackCommit(registryWrite);
1510
+ // The new installation is now committed; cleanup must not invalidate it.
1511
+ try {
1512
+ rmSync(backupDir, { recursive: true, force: true });
1513
+ }
1514
+ catch { /* orphan backup is recoverable, not a failed registration */ }
1277
1515
  return { ok: true, mode: 'direct-write', settingsAction, engineNote };
1278
1516
  }
1279
1517
  // ─── enable / disable (explicit, user-visible — OD-03) ─────────────────────
@@ -1305,8 +1543,36 @@ export class WorkBuddyHostInstaller {
1305
1543
  const preservedPaths = [];
1306
1544
  const failedPaths = [];
1307
1545
  const notes = [];
1308
- // 1. Disable first so hooks stop even if later steps fail (belt).
1309
- if (details.pluginEnabled) {
1546
+ // Fail before ANY destructive operation when the native registry cannot be
1547
+ // read. Treating an unreadable registry as "no references" risks deleting
1548
+ // caches still owned by another scope.
1549
+ const registryPreflight = readHostJsonFile(p.registryPath);
1550
+ if (!registryPreflight.ok) {
1551
+ return {
1552
+ success: false, hostId: this.hostId, removedPaths,
1553
+ preservedPaths: [p.registryPath, p.cachePluginRoot, p.marketplaceRoot],
1554
+ reason: registryPreflight.reason, nextAction: registryPreflight.nextAction,
1555
+ };
1556
+ }
1557
+ const settingsPreflight = readHostJsonFile(p.settingsPath);
1558
+ if (!settingsPreflight.ok) {
1559
+ return {
1560
+ success: false, hostId: this.hostId, removedPaths,
1561
+ preservedPaths: [p.settingsPath, p.registryPath, p.cachePluginRoot],
1562
+ reason: settingsPreflight.reason, nextAction: settingsPreflight.nextAction,
1563
+ };
1564
+ }
1565
+ const registeredEntry = isRecord(registryPreflight.value?.plugins)
1566
+ ? registryPreflight.value.plugins[WORKBUDDY_PLUGIN_KEY] : undefined;
1567
+ const otherScopesPresent = installedPluginRecords(registeredEntry)
1568
+ .some((record) => !(record.scope === 'user' && record.projectPath === undefined));
1569
+ // The native engine's disable/uninstall commands affect the entire plugin
1570
+ // key (all scopes). They must not be used for a USER-only uninstall when
1571
+ // project/local/managed registrations coexist.
1572
+ if (otherScopesPresent)
1573
+ notes.push('other scopes remain: bypassed host-wide engine commands and preserved their registrations');
1574
+ // 1. Disable first only if this key has no other scope owners.
1575
+ if (details.pluginEnabled && !otherScopesPresent) {
1310
1576
  const disable = details.engineFound
1311
1577
  ? this.runEngine(['plugin', 'disable', WORKBUDDY_PLUGIN_KEY])
1312
1578
  : undefined;
@@ -1315,7 +1581,7 @@ export class WorkBuddyHostInstaller {
1315
1581
  }
1316
1582
  }
1317
1583
  // 2. Unregister via the engine when possible.
1318
- if (details.registered && details.engineFound) {
1584
+ if (details.registered && details.engineFound && !otherScopesPresent) {
1319
1585
  const uninstall = this.runEngine(['plugin', 'uninstall', WORKBUDDY_PLUGIN_KEY]);
1320
1586
  const engineUninstalled = uninstall !== undefined && uninstall.status === 0 && PLUGIN_UNINSTALL_OK.test(uninstall.stdout + uninstall.stderr);
1321
1587
  if (!engineUninstalled && uninstall !== undefined) {
@@ -1323,7 +1589,9 @@ export class WorkBuddyHostInstaller {
1323
1589
  }
1324
1590
  }
1325
1591
  // 3. Direct-write removal of the frozen-format entries when the engine did
1326
- // not (or could not) remove them. Only OUR key is touched.
1592
+ // not (or could not) remove them. Only OUR key is touched, and only the
1593
+ // USER scope: a project/local/managed record under the same key owns its
1594
+ // own installPath and survives this uninstall.
1327
1595
  const afterEngine = this.detectDetails();
1328
1596
  if (afterEngine.registered || afterEngine.pluginEnabled) {
1329
1597
  const removal = this.directWriteRemoval();
@@ -1339,22 +1607,76 @@ export class WorkBuddyHostInstaller {
1339
1607
  nextAction: removal.nextAction,
1340
1608
  };
1341
1609
  }
1342
- removedPaths.push(p.settingsPath, p.registryPath);
1610
+ if (removal.wroteSettings)
1611
+ removedPaths.push(p.settingsPath);
1612
+ if (removal.wroteRegistry)
1613
+ removedPaths.push(p.registryPath);
1614
+ if (removal.preservedScopes.length > 0) {
1615
+ preservedPaths.push(p.registryPath);
1616
+ notes.push(`preserved ${removal.preservedScopes.join('/')} scope registration(s) under ${WORKBUDDY_PLUGIN_KEY}; only the user scope was uninstalled.`);
1617
+ }
1343
1618
  }
1344
- // 4. Remove OUR cache subtree (provable: cache/<OUR_MARKETPLACE>/<OUR_PLUGIN>).
1619
+ // 4. Remove only cache versions that no surviving scope record references.
1620
+ // Another scope may still load an older version from this same root, so
1621
+ // the version dirs are swept individually instead of deleting the root.
1345
1622
  if (existsSync(p.cachePluginRoot)) {
1623
+ const references = this.referencedCacheDirs();
1624
+ if (!references.ok) {
1625
+ return {
1626
+ success: false, hostId: this.hostId, removedPaths,
1627
+ preservedPaths: [...preservedPaths, p.cachePluginRoot, p.marketplaceRoot],
1628
+ reason: references.reason, nextAction: references.nextAction,
1629
+ };
1630
+ }
1631
+ const referenced = references.directories;
1632
+ let entries = [];
1346
1633
  try {
1347
- rmSync(p.cachePluginRoot, { recursive: true, force: true });
1348
- removedPaths.push(p.cachePluginRoot);
1634
+ entries = readdirSync(p.cachePluginRoot);
1349
1635
  }
1350
1636
  catch (error) {
1351
1637
  failedPaths.push(p.cachePluginRoot);
1352
1638
  preservedPaths.push(p.cachePluginRoot);
1353
- notes.push(`cache removal failed: ${error instanceof Error ? error.message : String(error)}`);
1639
+ notes.push(`cache listing failed: ${error instanceof Error ? error.message : String(error)}`);
1640
+ }
1641
+ for (const name of entries) {
1642
+ const versionDir = workbuddyCacheVersionDir(p.cachePluginRoot, name);
1643
+ // Preserve unexpected/unknown cache children; only validated version
1644
+ // directories may be removed by a user-scope uninstall.
1645
+ if (versionDir === undefined) {
1646
+ preservedPaths.push(path.join(p.cachePluginRoot, name));
1647
+ continue;
1648
+ }
1649
+ if (referenced.has(path.resolve(versionDir))) {
1650
+ preservedPaths.push(versionDir);
1651
+ continue;
1652
+ }
1653
+ try {
1654
+ rmSync(versionDir, { recursive: true, force: true });
1655
+ removedPaths.push(versionDir);
1656
+ }
1657
+ catch (error) {
1658
+ failedPaths.push(versionDir);
1659
+ preservedPaths.push(versionDir);
1660
+ notes.push(`cache removal failed for ${versionDir}: ${error instanceof Error ? error.message : String(error)}`);
1661
+ }
1662
+ }
1663
+ // Drop the root itself only once nothing survives inside it.
1664
+ try {
1665
+ if (existsSync(p.cachePluginRoot) && readdirSync(p.cachePluginRoot).length === 0) {
1666
+ rmSync(p.cachePluginRoot, { recursive: true, force: true });
1667
+ removedPaths.push(p.cachePluginRoot);
1668
+ }
1669
+ }
1670
+ catch {
1671
+ notes.push(`cache root cleanup skipped: ${p.cachePluginRoot} is not removable yet.`);
1354
1672
  }
1355
1673
  }
1356
1674
  // 5. Remove the PD-owned marketplace dir.
1357
- if (existsSync(p.marketplaceRoot)) {
1675
+ if (otherScopesPresent) {
1676
+ preservedPaths.push(p.marketplaceRoot);
1677
+ notes.push('preserved marketplace and host manifest for remaining non-user scopes');
1678
+ }
1679
+ else if (existsSync(p.marketplaceRoot)) {
1358
1680
  try {
1359
1681
  rmSync(p.marketplaceRoot, { recursive: true, force: true });
1360
1682
  removedPaths.push(p.marketplaceRoot);
@@ -1367,7 +1689,8 @@ export class WorkBuddyHostInstaller {
1367
1689
  }
1368
1690
  // 6. Manifest bookkeeping: drop 'workbuddy' from hosts. The governance
1369
1691
  // workspace entry and ALL governance data are preserved (§18 default).
1370
- removeWorkbuddyFromInstallManifestHosts(p.installManifestPath);
1692
+ if (!otherScopesPresent)
1693
+ removeWorkbuddyFromInstallManifestHosts(p.installManifestPath);
1371
1694
  const governancePreservedByDesign = existsSync(p.governanceDir);
1372
1695
  if (governancePreservedByDesign) {
1373
1696
  preservedPaths.push(p.governanceDir);
@@ -1390,13 +1713,32 @@ export class WorkBuddyHostInstaller {
1390
1713
  ].filter(Boolean).join(' '),
1391
1714
  };
1392
1715
  }
1393
- /** Remove only OUR entries from settings.json + installed_plugins.json (frozen formats). */
1716
+ /**
1717
+ * Remove the USER-scope registration only (frozen formats). Other scopes of
1718
+ * the same key are host-owned: dropping the whole key would delete a
1719
+ * project/local/managed install PD never owned.
1720
+ */
1394
1721
  directWriteRemoval() {
1395
1722
  const p = this.paths();
1396
1723
  const settings = readHostJsonFile(p.settingsPath);
1397
1724
  if (!settings.ok)
1398
1725
  return { ok: false, reason: settings.reason, nextAction: settings.nextAction };
1399
- if (settings.existed && isRecord(settings.value?.enabledPlugins) && Object.hasOwn(settings.value.enabledPlugins, WORKBUDDY_PLUGIN_KEY)) {
1726
+ const registry = readHostJsonFile(p.registryPath);
1727
+ if (!registry.ok)
1728
+ return { ok: false, reason: registry.reason, nextAction: registry.nextAction };
1729
+ const registered = registry.existed && isRecord(registry.value?.plugins)
1730
+ ? registry.value.plugins[WORKBUDDY_PLUGIN_KEY] : undefined;
1731
+ const allRecords = installedPluginRecords(registered);
1732
+ const remaining = allRecords.filter((record) => !(record.scope === 'user' && record.projectPath === undefined));
1733
+ const preservedScopes = [...new Set(remaining
1734
+ .map((record) => (typeof record.scope === 'string' && record.scope.length > 0 ? record.scope : 'unknown')))];
1735
+ const removedUserRecord = remaining.length !== allRecords.length;
1736
+ // enabledPlugins is a single host-level value for the key: clear it only
1737
+ // when no scope remains installed.
1738
+ let wroteSettings = false;
1739
+ if (remaining.length === 0
1740
+ && settings.existed && isRecord(settings.value?.enabledPlugins)
1741
+ && Object.hasOwn(settings.value.enabledPlugins, WORKBUDDY_PLUGIN_KEY)) {
1400
1742
  const nextSettings = { ...settings.value };
1401
1743
  const enabledPlugins = { ...nextSettings.enabledPlugins };
1402
1744
  delete enabledPlugins[WORKBUDDY_PLUGIN_KEY];
@@ -1404,20 +1746,39 @@ export class WorkBuddyHostInstaller {
1404
1746
  const write = writeHostJsonFileProtected(p.settingsPath, JSON.stringify(nextSettings, null, 2) + '\n');
1405
1747
  if (!write.ok)
1406
1748
  return { ok: false, reason: write.reason, nextAction: write.nextAction };
1749
+ wroteSettings = true;
1407
1750
  }
1408
- const registry = readHostJsonFile(p.registryPath);
1409
- if (!registry.ok)
1410
- return { ok: false, reason: registry.reason, nextAction: registry.nextAction };
1411
- if (registry.existed && isRecord(registry.value?.plugins) && Object.hasOwn(registry.value.plugins, WORKBUDDY_PLUGIN_KEY)) {
1751
+ let wroteRegistry = false;
1752
+ if (removedUserRecord) {
1412
1753
  const nextRegistry = { ...registry.value };
1413
1754
  const plugins = { ...nextRegistry.plugins };
1414
- delete plugins[WORKBUDDY_PLUGIN_KEY];
1755
+ if (remaining.length === 0)
1756
+ delete plugins[WORKBUDDY_PLUGIN_KEY];
1757
+ else
1758
+ plugins[WORKBUDDY_PLUGIN_KEY] = Array.isArray(registered) ? remaining : remaining[0];
1415
1759
  nextRegistry.plugins = plugins;
1416
1760
  const write = writeHostJsonFileProtected(p.registryPath, JSON.stringify(nextRegistry, null, 2) + '\n');
1417
1761
  if (!write.ok)
1418
1762
  return { ok: false, reason: write.reason, nextAction: write.nextAction };
1763
+ wroteRegistry = true;
1419
1764
  }
1420
- return { ok: true };
1765
+ return { ok: true, wroteSettings, wroteRegistry, preservedScopes };
1766
+ }
1767
+ /** installPaths still referenced by ANY surviving scope record of our key. */
1768
+ referencedCacheDirs() {
1769
+ const p = this.paths();
1770
+ const registry = readHostJsonFile(p.registryPath);
1771
+ if (!registry.ok)
1772
+ return { ok: false, reason: registry.reason, nextAction: registry.nextAction };
1773
+ const referenced = new Set();
1774
+ if (isRecord(registry.value?.plugins)) {
1775
+ for (const record of installedPluginRecords(registry.value.plugins[WORKBUDDY_PLUGIN_KEY])) {
1776
+ if (typeof record.installPath === 'string' && record.installPath.length > 0) {
1777
+ referenced.add(path.resolve(record.installPath));
1778
+ }
1779
+ }
1780
+ }
1781
+ return { ok: true, directories: referenced };
1421
1782
  }
1422
1783
  // ─── detect ───────────────────────────────────────────────────────────────
1423
1784
  detect() {