@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.32

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 (67) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +136 -14
  3. package/bin/ldm.js +525 -75
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/lib/registry-migrations.mjs +296 -0
  9. package/package.json +24 -3
  10. package/scripts/test-boot-hook-registration.mjs +136 -0
  11. package/scripts/test-boot-payload-trim.mjs +200 -0
  12. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  13. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  14. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  15. package/scripts/test-crc-pair-login-flow.mjs +115 -0
  16. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  17. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  18. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  19. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  20. package/scripts/test-install-prompt-policy.mjs +84 -0
  21. package/scripts/test-installer-skill-directory.mjs +55 -0
  22. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  23. package/scripts/test-installer-target-self-update.mjs +131 -0
  24. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  25. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  26. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  27. package/scripts/test-ldm-status-concurrency.mjs +118 -0
  28. package/scripts/test-ldm-status-timeout.mjs +96 -0
  29. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  30. package/scripts/test-readme-install-prompt.mjs +66 -0
  31. package/shared/boot/boot-config.json +18 -8
  32. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  33. package/shared/rules/security.md +4 -0
  34. package/shared/templates/install-prompt.md +20 -2
  35. package/src/boot/README.md +24 -1
  36. package/src/boot/boot-config.json +18 -8
  37. package/src/boot/boot-hook.mjs +118 -28
  38. package/src/boot/installer.mjs +33 -10
  39. package/src/hosted-mcp/.env.example +4 -0
  40. package/src/hosted-mcp/README.md +37 -0
  41. package/src/hosted-mcp/app/footer.js +74 -0
  42. package/src/hosted-mcp/app/kaleidoscope-login.html +1290 -0
  43. package/src/hosted-mcp/app/pair.html +165 -57
  44. package/src/hosted-mcp/app/sprites.png +0 -0
  45. package/src/hosted-mcp/app/wip-logo.png +0 -0
  46. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  47. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  48. package/src/hosted-mcp/demo/footer.js +2 -2
  49. package/src/hosted-mcp/demo/index.html +169 -51
  50. package/src/hosted-mcp/demo/login.html +390 -28
  51. package/src/hosted-mcp/demo/privacy.html +4 -214
  52. package/src/hosted-mcp/demo/tos.html +4 -189
  53. package/src/hosted-mcp/deploy.sh +308 -56
  54. package/src/hosted-mcp/docs/self-host.md +268 -0
  55. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  56. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  57. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  58. package/src/hosted-mcp/legal/legal.css +166 -0
  59. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  60. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  61. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  62. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  63. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  64. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  65. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  66. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  67. package/src/hosted-mcp/server.mjs +1681 -166
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env node
2
+ // Fixture test for the Phase 1 source-types migration planner.
3
+ // See lib/registry-migrations.mjs and
4
+ // ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
5
+
6
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ import {
11
+ planLegacyNpmSourcesMigration,
12
+ summaryHasChanges,
13
+ emptyLegacyNpmSourcesSummary,
14
+ executeDirectoryMoves,
15
+ } from '../lib/registry-migrations.mjs';
16
+
17
+ function fail(msg) {
18
+ throw new Error(msg);
19
+ }
20
+
21
+ function assertEqual(actual, expected, label) {
22
+ if (actual !== expected) {
23
+ fail(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
24
+ }
25
+ }
26
+
27
+ function assertDeepEqual(actual, expected, label) {
28
+ const a = JSON.stringify(actual, Object.keys(actual || {}).sort());
29
+ const e = JSON.stringify(expected, Object.keys(expected || {}).sort());
30
+ if (a !== e) {
31
+ fail(`${label}: expected ${e}, got ${a}`);
32
+ }
33
+ }
34
+
35
+ // Mirrors the shape we see on Parker's machine in the 2026-05-13 dogfood.
36
+ function buildFixtureRegistry() {
37
+ return {
38
+ _format: 'v2',
39
+ extensions: {
40
+ // 404 npm + on-disk: should migrate to untracked.
41
+ 'cc-session-export': {
42
+ source: { type: 'github', npm: 'session-export', repo: 'wipcomputer/cc-session-export' },
43
+ installed: { version: '1.0.0' },
44
+ },
45
+ // Duplicate of cc-session-export: should be deduped (removed).
46
+ 'session-export': {
47
+ source: { type: 'github', npm: 'session-export' },
48
+ installed: { version: '1.0.0' },
49
+ },
50
+ 'compaction-indicator': {
51
+ source: { type: 'github', npm: 'compaction-indicator' },
52
+ installed: { version: '1.0.1' },
53
+ },
54
+ 'lesa-bridge': {
55
+ source: { type: 'github', npm: 'lesa-bridge' },
56
+ installed: { version: '0.3.0' },
57
+ },
58
+ // Duplicate of wip-branch-guard: should be deduped.
59
+ 'package': {
60
+ source: { type: 'github', npm: '@wipcomputer/wip-branch-guard' },
61
+ installed: { version: '1.0.0' },
62
+ },
63
+ 'wip-branch-guard': {
64
+ source: { type: 'github', npm: '@wipcomputer/wip-branch-guard' },
65
+ installed: { version: '1.0.0' },
66
+ },
67
+ // Real npm package: should be left alone.
68
+ 'memory-crystal': {
69
+ source: { type: 'github', npm: '@wipcomputer/memory-crystal' },
70
+ installed: { version: '2.0.0' },
71
+ },
72
+ // Phantom: no on-disk directory. Should be removed.
73
+ 'tavily': {
74
+ source: { type: 'github', npm: '@wipcomputer/openclaw-tavily' },
75
+ installed: { version: '0.1.0' },
76
+ },
77
+ // Mystery row: no source info at all but on-disk. Should be classified
78
+ // untracked (Step 4 path in the planner).
79
+ //
80
+ // Note: on Parker's real machine the `run` entry has no on-disk
81
+ // directory, so it hits Step 1 (phantom removal) instead. We exercise
82
+ // the Step 4 path here via this fixture; Step 1 is covered by the
83
+ // `tavily` fixture below.
84
+ 'run': {
85
+ installed: { version: 'unknown' },
86
+ },
87
+ // Already migrated: must be skipped (idempotency).
88
+ 'already-untracked': {
89
+ updateSource: { type: 'untracked' },
90
+ provenance: { 'legacy-npm-name': 'already-untracked', untrackedSince: '2026-05-13T00:00:00.000Z' },
91
+ installed: { version: '0.1.0' },
92
+ },
93
+ // Probe will fail (timeout). Should be left alone, listed in probeFailures.
94
+ 'flaky-network': {
95
+ source: { type: 'github', npm: 'flaky-network' },
96
+ installed: { version: '0.1.0' },
97
+ },
98
+ // Custom-path entry: declares `paths.ldm` pointing outside the default
99
+ // ~/.ldm/extensions/<name> location. The planner must NOT classify
100
+ // this as a phantom. Its source.npm is 404, so it should be migrated
101
+ // to untracked while the custom path is preserved.
102
+ 'custom-path-untracked': {
103
+ source: { type: 'github', npm: 'custom-path-untracked' },
104
+ paths: { ldm: '/custom/location/path' },
105
+ installed: { version: '1.0.0' },
106
+ },
107
+ // Legacy custom-path field (`ldmPath` flat). Same expectation.
108
+ 'legacy-custom-path': {
109
+ source: { type: 'github', npm: 'legacy-custom-path' },
110
+ ldmPath: '/legacy/custom/path',
111
+ installed: { version: '0.2.0' },
112
+ },
113
+ },
114
+ };
115
+ }
116
+
117
+ const NPM_404 = new Set([
118
+ 'session-export',
119
+ 'compaction-indicator',
120
+ 'lesa-bridge',
121
+ 'custom-path-untracked',
122
+ 'legacy-custom-path',
123
+ // @wipcomputer/wip-branch-guard simulated as 200 (exists). But the dedupe
124
+ // happens first, so `package` is gone before the probe runs.
125
+ // The remaining `wip-branch-guard` will see exists=true and be left alone.
126
+ ]);
127
+ const NPM_200 = new Set([
128
+ '@wipcomputer/memory-crystal',
129
+ '@wipcomputer/wip-branch-guard',
130
+ ]);
131
+ const NPM_FAIL = new Set([
132
+ 'flaky-network',
133
+ ]);
134
+
135
+ function fakeProbeNpm(name) {
136
+ if (NPM_FAIL.has(name)) return Promise.resolve(null);
137
+ if (NPM_200.has(name)) return Promise.resolve(true);
138
+ if (NPM_404.has(name)) return Promise.resolve(false);
139
+ // Fail loudly when the planner probes an npm name the test forgot to
140
+ // enumerate. Future planner changes that call probeNpm with new names
141
+ // must explicitly declare expected behavior here; silent 404 defaults
142
+ // would swallow regressions.
143
+ fail(`fakeProbeNpm called with un-enumerated name "${name}"`);
144
+ }
145
+
146
+ // On-disk extension simulator. `tavily` is a phantom (no directory at any
147
+ // location). All other names get a default directory unless they declare a
148
+ // custom path, in which case we honor the custom path. The planner must
149
+ // pass the entry to extensionExists for this to work.
150
+ const PHANTOM_NAMES = new Set(['tavily']);
151
+ const CUSTOM_PATH_EXISTS = new Set(['/custom/location/path', '/legacy/custom/path']);
152
+ function fakeExtensionExists(name, entry) {
153
+ if (entry?.paths?.ldm) return CUSTOM_PATH_EXISTS.has(entry.paths.ldm);
154
+ if (entry?.ldmPath) return CUSTOM_PATH_EXISTS.has(entry.ldmPath);
155
+ return !PHANTOM_NAMES.has(name);
156
+ }
157
+
158
+ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
159
+
160
+ // ── Test 1: full migration on fixture ──────────────────────────────────────
161
+ {
162
+ const input = buildFixtureRegistry();
163
+ const inputSnapshot = JSON.stringify(input);
164
+
165
+ const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
166
+ registry: input,
167
+ probeNpm: fakeProbeNpm,
168
+ extensionExists: fakeExtensionExists,
169
+ now: FIXED_NOW,
170
+ });
171
+
172
+ // Input must not be mutated.
173
+ assertEqual(JSON.stringify(input), inputSnapshot, 'input registry mutated');
174
+
175
+ // Phantoms removed.
176
+ assertEqual(summary.phantomsRemoved.length, 1, 'phantomsRemoved.length');
177
+ assertEqual(summary.phantomsRemoved[0].name, 'tavily', 'phantom name');
178
+ assertEqual(newRegistry.extensions.tavily, undefined, 'phantom still in registry');
179
+
180
+ // Duplicates removed.
181
+ assertEqual(summary.duplicatesRemoved.length, 2, 'duplicatesRemoved.length');
182
+ const removedDupes = summary.duplicatesRemoved.map(d => d.removed).sort();
183
+ assertDeepEqual(removedDupes, ['package', 'session-export'], 'dedupe targets');
184
+ assertEqual(newRegistry.extensions['session-export'], undefined, 'session-export still in registry');
185
+ assertEqual(newRegistry.extensions['package'], undefined, 'package still in registry');
186
+ if (!newRegistry.extensions['cc-session-export']) fail('canonical cc-session-export removed');
187
+ if (!newRegistry.extensions['wip-branch-guard']) fail('canonical wip-branch-guard removed');
188
+
189
+ // Each removed duplicate must also produce a directoryMoves entry. The
190
+ // wrapper in bin/ldm.js executes these moves after the registry write so
191
+ // autoDetectExtensions cannot re-register the duplicate on the same install
192
+ // run. Without this contract, the registry dedup reverts (see
193
+ // ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
194
+ assertEqual(summary.directoryMoves.length, 2, 'directoryMoves.length');
195
+ const moveNames = summary.directoryMoves.map(m => m.name).sort();
196
+ assertDeepEqual(moveNames, ['package', 'session-export'], 'directoryMoves names');
197
+ for (const m of summary.directoryMoves) {
198
+ assertEqual(m.reason, 'deduplicated', `directoryMoves[${m.name}].reason`);
199
+ if (!m.trashName.startsWith(`${m.name}-deduplicated-`)) {
200
+ fail(`directoryMoves[${m.name}].trashName should start with "${m.name}-deduplicated-" but was "${m.trashName}"`);
201
+ }
202
+ if (!m.trashName.includes('2026-05-13T18-00-00-000Z')) {
203
+ fail(`directoryMoves[${m.name}].trashName should embed the fixed-now timestamp but was "${m.trashName}"`);
204
+ }
205
+ }
206
+
207
+ // The planner is pure: it must NOT pre-populate directoryMovesPerformed or
208
+ // directoryMovesSkipped. Those are wrapper outputs from real filesystem I/O.
209
+ assertEqual(summary.directoryMovesPerformed.length, 0, 'directoryMovesPerformed should be empty from pure planner');
210
+ assertEqual(summary.directoryMovesSkipped.length, 0, 'directoryMovesSkipped should be empty from pure planner');
211
+
212
+ // Migrated entries: cc-session-export, compaction-indicator, lesa-bridge,
213
+ // run, plus the two custom-path entries (custom-path-untracked,
214
+ // legacy-custom-path). `session-export` and `package` were deduped before
215
+ // probe. `wip-branch-guard` exists on npm. `flaky-network` probe failed.
216
+ const migratedNames = summary.migrated.map(m => m.name).sort();
217
+ assertDeepEqual(migratedNames, [
218
+ 'cc-session-export', 'compaction-indicator', 'custom-path-untracked',
219
+ 'legacy-custom-path', 'lesa-bridge', 'run',
220
+ ], 'migrated names');
221
+
222
+ // Custom-path entries must NOT be removed as phantoms. The planner must
223
+ // pass the entry to extensionExists so the custom path is honored.
224
+ // Regression guard for the round-3 Codex blocker (data-loss path on
225
+ // entries with entry.paths.ldm or entry.ldmPath).
226
+ const cpu = newRegistry.extensions['custom-path-untracked'];
227
+ if (!cpu) fail('custom-path-untracked was removed as phantom (extensionExists ignored entry.paths.ldm)');
228
+ assertEqual(cpu.updateSource?.type, 'untracked', 'custom-path-untracked.updateSource.type');
229
+ assertEqual(cpu.paths?.ldm, '/custom/location/path', 'custom-path-untracked.paths.ldm preserved');
230
+ const lcp = newRegistry.extensions['legacy-custom-path'];
231
+ if (!lcp) fail('legacy-custom-path was removed as phantom (extensionExists ignored entry.ldmPath)');
232
+ assertEqual(lcp.updateSource?.type, 'untracked', 'legacy-custom-path.updateSource.type');
233
+ assertEqual(lcp.ldmPath, '/legacy/custom/path', 'legacy-custom-path.ldmPath preserved');
234
+
235
+ // Each migrated entry has updateSource.type=untracked.
236
+ for (const name of migratedNames) {
237
+ const e = newRegistry.extensions[name];
238
+ if (!e) fail(`migrated entry ${name} missing from newRegistry`);
239
+ assertEqual(e.updateSource?.type, 'untracked', `${name}.updateSource.type`);
240
+ if ('source' in e) fail(`${name}.source should be deleted`);
241
+ if (!e.provenance) fail(`${name}.provenance missing`);
242
+ assertEqual(e.provenance.untrackedSince, '2026-05-13T18:00:00.000Z', `${name}.provenance.untrackedSince`);
243
+ }
244
+
245
+ // cc-session-export specifically: legacy-npm-name + repo preserved.
246
+ const ccse = newRegistry.extensions['cc-session-export'];
247
+ assertEqual(ccse.provenance['legacy-npm-name'], 'session-export', 'cc-session-export legacy-npm-name');
248
+ assertEqual(ccse.provenance.repo, 'wipcomputer/cc-session-export', 'cc-session-export legacy repo');
249
+
250
+ // `run` had no source info: legacy-npm-name absent, no repo, but still classified.
251
+ const runEntry = newRegistry.extensions.run;
252
+ if ('legacy-npm-name' in runEntry.provenance) fail('run.provenance should not have legacy-npm-name');
253
+ if ('repo' in runEntry.provenance) fail('run.provenance should not have repo');
254
+
255
+ // Real npm package left alone.
256
+ const mc = newRegistry.extensions['memory-crystal'];
257
+ if (mc.updateSource) fail('memory-crystal should not be migrated (real npm pkg)');
258
+ assertEqual(mc.source?.npm, '@wipcomputer/memory-crystal', 'memory-crystal source preserved');
259
+
260
+ // wip-branch-guard left alone (real npm pkg).
261
+ const wbg = newRegistry.extensions['wip-branch-guard'];
262
+ if (wbg.updateSource) fail('wip-branch-guard should not be migrated (real npm pkg)');
263
+
264
+ // Already-untracked entry is unchanged.
265
+ const au = newRegistry.extensions['already-untracked'];
266
+ assertEqual(au.provenance.untrackedSince, '2026-05-13T00:00:00.000Z', 'already-untracked untracked since preserved');
267
+
268
+ // Probe failures recorded, entry untouched.
269
+ assertEqual(summary.probeFailures.length, 1, 'probeFailures.length');
270
+ assertEqual(summary.probeFailures[0].name, 'flaky-network', 'probe failure name');
271
+ const fn = newRegistry.extensions['flaky-network'];
272
+ if (fn.updateSource) fail('flaky-network should not be migrated (probe failed)');
273
+ assertEqual(fn.source?.npm, 'flaky-network', 'flaky-network source preserved');
274
+
275
+ // summaryHasChanges flips true when anything happens.
276
+ assertEqual(summaryHasChanges(summary), true, 'summaryHasChanges on populated summary');
277
+ }
278
+
279
+ // ── Test 2: idempotency on a fully-migrated registry ───────────────────────
280
+ {
281
+ const registry = {
282
+ extensions: {
283
+ 'a': {
284
+ updateSource: { type: 'untracked' },
285
+ provenance: { untrackedSince: '2026-05-13T00:00:00.000Z' },
286
+ installed: { version: '1.0.0' },
287
+ },
288
+ 'b': {
289
+ updateSource: { type: 'untracked' },
290
+ provenance: { 'legacy-npm-name': 'b', untrackedSince: '2026-05-13T00:00:00.000Z' },
291
+ installed: { version: '0.1.0' },
292
+ },
293
+ },
294
+ };
295
+ const before = JSON.stringify(registry);
296
+ const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
297
+ registry,
298
+ probeNpm: () => fail('probeNpm should not be called on fully-migrated registry'),
299
+ extensionExists: () => true,
300
+ now: FIXED_NOW,
301
+ });
302
+ assertEqual(JSON.stringify(registry), before, 'input mutated on idempotent run');
303
+ assertEqual(summary.migrated.length, 0, 'migrated.length on idempotent run');
304
+ assertEqual(summary.phantomsRemoved.length, 0, 'phantomsRemoved.length on idempotent run');
305
+ assertEqual(summary.duplicatesRemoved.length, 0, 'duplicatesRemoved.length on idempotent run');
306
+ assertEqual(summary.directoryMoves.length, 0, 'directoryMoves.length on idempotent run');
307
+ assertEqual(summaryHasChanges(summary), false, 'summaryHasChanges on empty summary');
308
+ assertDeepEqual(
309
+ Object.keys(newRegistry.extensions).sort(),
310
+ ['a', 'b'],
311
+ 'extensions preserved on idempotent run',
312
+ );
313
+ }
314
+
315
+ // ── Test 3: emptyLegacyNpmSourcesSummary returns the canonical shape ───────
316
+ {
317
+ const e = emptyLegacyNpmSourcesSummary();
318
+ assertDeepEqual(Object.keys(e).sort(), [
319
+ 'directoryMoves',
320
+ 'directoryMovesPerformed',
321
+ 'directoryMovesSkipped',
322
+ 'duplicatesRemoved',
323
+ 'migrated',
324
+ 'phantomsRemoved',
325
+ 'probeFailures',
326
+ 'probedCount',
327
+ 'timestamp',
328
+ ], 'empty summary keys');
329
+ // Wrapper-output fields start empty even though the planner doesn't
330
+ // populate them ... the wrapper is responsible for appending.
331
+ assertEqual(e.directoryMoves.length, 0, 'empty.directoryMoves');
332
+ assertEqual(e.directoryMovesPerformed.length, 0, 'empty.directoryMovesPerformed');
333
+ assertEqual(e.directoryMovesSkipped.length, 0, 'empty.directoryMovesSkipped');
334
+ }
335
+
336
+ // ── Test 4: executeDirectoryMoves against a real temp filesystem ──────────
337
+ // Regression guard for the bug fixed by this PR
338
+ // (ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
339
+ // The planner emits directoryMoves entries; the executor must actually move
340
+ // the on-disk directories into trash so autoDetectExtensions cannot
341
+ // re-register them on the next install scan.
342
+ {
343
+ const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-'));
344
+ try {
345
+ const extensionsRoot = join(tmpHome, 'extensions');
346
+ const trashRoot = join(tmpHome, '_trash');
347
+ mkdirSync(extensionsRoot, { recursive: true });
348
+
349
+ // Stand up the two duplicate directories the planner would dedup.
350
+ for (const name of ['session-export', 'package']) {
351
+ const dir = join(extensionsRoot, name);
352
+ mkdirSync(dir, { recursive: true });
353
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0' }) + '\n');
354
+ }
355
+ // And a non-duplicate that must be left alone (proxy for cc-session-export).
356
+ const ccsePath = join(extensionsRoot, 'cc-session-export');
357
+ mkdirSync(ccsePath, { recursive: true });
358
+ writeFileSync(join(ccsePath, 'package.json'), JSON.stringify({ name: 'cc-session-export', version: '1.0.0' }) + '\n');
359
+
360
+ // Run the planner with the fixture to get a real directoryMoves plan.
361
+ const registry = {
362
+ extensions: {
363
+ 'cc-session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
364
+ 'session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
365
+ 'wip-branch-guard': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
366
+ 'package': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
367
+ },
368
+ };
369
+ const { summary } = await planLegacyNpmSourcesMigration({
370
+ registry,
371
+ probeNpm: (name) => Promise.resolve(name === '@wipcomputer/wip-branch-guard'),
372
+ extensionExists: () => true,
373
+ now: FIXED_NOW,
374
+ });
375
+ assertEqual(summary.directoryMoves.length, 2, 'dedup plan produces 2 directoryMoves');
376
+
377
+ // Execute the moves against the temp filesystem.
378
+ const { performed, skipped } = executeDirectoryMoves({
379
+ directoryMoves: summary.directoryMoves,
380
+ extensionsRoot,
381
+ trashRoot,
382
+ });
383
+ assertEqual(performed.length, 2, 'executor performed 2 moves');
384
+ assertEqual(skipped.length, 0, 'executor did not skip any moves');
385
+
386
+ // Source directories are gone.
387
+ if (existsSync(join(extensionsRoot, 'session-export'))) {
388
+ fail('session-export directory should have been moved out of extensions/');
389
+ }
390
+ if (existsSync(join(extensionsRoot, 'package'))) {
391
+ fail('package directory should have been moved out of extensions/');
392
+ }
393
+
394
+ // Trash directory now has the moved entries with the deduplicated suffix.
395
+ const trashContents = readdirSync(trashRoot);
396
+ if (!trashContents.some(name => name.startsWith('session-export-deduplicated-'))) {
397
+ fail(`trash should contain session-export-deduplicated-* but had: ${trashContents.join(', ')}`);
398
+ }
399
+ if (!trashContents.some(name => name.startsWith('package-deduplicated-'))) {
400
+ fail(`trash should contain package-deduplicated-* but had: ${trashContents.join(', ')}`);
401
+ }
402
+
403
+ // Non-duplicate must NOT have been touched.
404
+ if (!existsSync(ccsePath)) fail('cc-session-export directory should be untouched');
405
+
406
+ // autoDetectExtensions simulation: a fresh scan of extensionsRoot must
407
+ // NOT see the moved duplicates. We replicate the production logic
408
+ // (bin/ldm.js autoDetectExtensions): scan top-level dirs in
409
+ // extensionsRoot, skip dirs named `_trash` or starting with `.` or
410
+ // `ldm-install-`, and treat any remaining dir with a package.json as a
411
+ // candidate for auto-registration.
412
+ const candidatesAfterMove = readdirSync(extensionsRoot, { withFileTypes: true })
413
+ .filter(d => d.isDirectory())
414
+ .map(d => d.name)
415
+ .filter(name => name !== '_trash' && !name.startsWith('.') && !name.startsWith('ldm-install-'))
416
+ .filter(name => existsSync(join(extensionsRoot, name, 'package.json')))
417
+ .sort();
418
+ assertDeepEqual(
419
+ candidatesAfterMove,
420
+ ['cc-session-export'],
421
+ 'autoDetect should see only the non-duplicate after the moves; duplicates must be gone',
422
+ );
423
+
424
+ // Idempotency: running executeDirectoryMoves again must skip all
425
+ // (source-missing) and not fail.
426
+ const second = executeDirectoryMoves({
427
+ directoryMoves: summary.directoryMoves,
428
+ extensionsRoot,
429
+ trashRoot,
430
+ });
431
+ assertEqual(second.performed.length, 0, 'second execute call performs nothing');
432
+ assertEqual(second.skipped.length, 2, 'second execute call skips both moves');
433
+ for (const s of second.skipped) {
434
+ assertEqual(s.reason, 'source-missing', `second-call skip reason for ${s.name}`);
435
+ }
436
+ } finally {
437
+ rmSync(tmpHome, { recursive: true, force: true });
438
+ }
439
+ }
440
+
441
+ // ── Test 5: executeDirectoryMoves with no moves is a no-op ───────────────
442
+ {
443
+ const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-empty-'));
444
+ try {
445
+ const result = executeDirectoryMoves({
446
+ directoryMoves: [],
447
+ extensionsRoot: join(tmpHome, 'extensions'),
448
+ trashRoot: join(tmpHome, '_trash'),
449
+ });
450
+ assertEqual(result.performed.length, 0, 'empty plan -> no performed');
451
+ assertEqual(result.skipped.length, 0, 'empty plan -> no skipped');
452
+ if (existsSync(join(tmpHome, '_trash'))) {
453
+ fail('executor should not pre-create trashRoot when there are no moves');
454
+ }
455
+ } finally {
456
+ rmSync(tmpHome, { recursive: true, force: true });
457
+ }
458
+ }
459
+
460
+ console.log('test-legacy-npm-sources-migration: all tests passed');
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
5
+ const promptTemplate = readFileSync(
6
+ new URL("../shared/templates/install-prompt.md", import.meta.url),
7
+ "utf8",
8
+ );
9
+ const skill = readFileSync(new URL("../SKILL.md", import.meta.url), "utf8");
10
+
11
+ const promptStart = readme.indexOf("Read https://wip.computer/install/wip-ldm-os.txt");
12
+ const promptEnd = readme.indexOf("```", promptStart);
13
+
14
+ assert(promptStart >= 0, "README install prompt must point at the public install document");
15
+ assert(promptEnd > promptStart, "README install prompt must be fenced");
16
+
17
+ const readmePrompt = readme.slice(promptStart, promptEnd);
18
+
19
+ for (const [label, prompt] of [
20
+ ["README install prompt", readmePrompt],
21
+ ["shared install prompt template", promptTemplate],
22
+ ]) {
23
+ assert(
24
+ prompt.includes("Read https://wip.computer/install/wip-ldm-os.txt"),
25
+ `${label} must delegate to the public install document`,
26
+ );
27
+
28
+ assert(
29
+ prompt.includes("Use the install document and live local checks as the source of truth."),
30
+ `${label} must name the install document as source of truth`,
31
+ );
32
+
33
+ assert(
34
+ prompt.includes("use the selected track's dry-run path from the install document"),
35
+ `${label} must delegate dry-run command mapping to SKILL.md`,
36
+ );
37
+
38
+ assert(
39
+ prompt.includes("use the selected track's install path from the install document"),
40
+ `${label} must delegate install command mapping to SKILL.md`,
41
+ );
42
+
43
+ for (const forbidden of [
44
+ "Track choices:",
45
+ "ldm install --alpha",
46
+ "ldm install --beta",
47
+ "ldm install --dry-run",
48
+ ]) {
49
+ assert(!prompt.includes(forbidden), `${label} must not include ${forbidden}`);
50
+ }
51
+ }
52
+
53
+ for (const required of [
54
+ "## Tracks",
55
+ "npm view @wipcomputer/wip-ldm-os dist-tags --json",
56
+ "stable/current/latest: `ldm install --dry-run`",
57
+ "beta/latest beta: `ldm install --beta --dry-run`",
58
+ "alpha/latest alpha: `ldm install --alpha --dry-run`",
59
+ "beta/latest beta: `npm install -g @wipcomputer/wip-ldm-os@beta`",
60
+ "alpha/latest alpha: `npm install -g @wipcomputer/wip-ldm-os@alpha`",
61
+ "The README prompt should stay short. This install document owns the detailed track rules.",
62
+ ]) {
63
+ assert(skill.includes(required), `SKILL.md must own track-selection logic: ${required}`);
64
+ }
65
+
66
+ console.log("readme-install-prompt: prompt stays short and delegates track rules to SKILL.md");
@@ -2,19 +2,23 @@
2
2
  "agentId": "cc-mini",
3
3
  "timezone": "America/Los_Angeles",
4
4
  "maxTotalLines": 2000,
5
+ "stalenessDays": 14,
6
+ "dailyLogStalenessDays": 2,
5
7
  "steps": {
6
8
  "sharedContext": {
7
9
  "path": "~/.openclaw/workspace/SHARED-CONTEXT.md",
8
10
  "label": "SHARED-CONTEXT.md",
9
11
  "stepNumber": 2,
10
- "critical": true
12
+ "critical": true,
13
+ "maxLines": 80
11
14
  },
12
15
  "journals": {
13
16
  "dir": "~/wipcomputerinc/team/cc-mini/documents/journals",
14
17
  "label": "Most Recent Journal (Parker)",
15
18
  "stepNumber": 3,
16
19
  "maxLines": 80,
17
- "strategy": "most-recent"
20
+ "strategy": "most-recent",
21
+ "stalenessDays": 14
18
22
  },
19
23
  "workspaceDailyLogs": {
20
24
  "dir": "~/.openclaw/workspace/memory",
@@ -22,7 +26,8 @@
22
26
  "stepNumber": 4,
23
27
  "maxLines": 40,
24
28
  "strategy": "daily-logs",
25
- "days": ["today", "yesterday"]
29
+ "days": ["today", "yesterday"],
30
+ "dailyLogStalenessDays": 2
26
31
  },
27
32
  "fullHistory": {
28
33
  "label": "Full History",
@@ -33,19 +38,22 @@
33
38
  "path": "~/.ldm/agents/cc-mini/CONTEXT.md",
34
39
  "label": "CC CONTEXT.md",
35
40
  "stepNumber": 6,
36
- "critical": true
41
+ "critical": true,
42
+ "maxLines": 60
37
43
  },
38
44
  "soul": {
39
45
  "path": "~/.ldm/agents/cc-mini/SOUL.md",
40
46
  "label": "CC SOUL.md",
41
- "stepNumber": 7
47
+ "stepNumber": 7,
48
+ "maxLines": 80
42
49
  },
43
50
  "ccJournals": {
44
51
  "dir": "~/.ldm/agents/cc-mini/memory/journals",
45
52
  "label": "Most Recent CC Journal",
46
53
  "stepNumber": 8,
47
54
  "maxLines": 80,
48
- "strategy": "most-recent"
55
+ "strategy": "most-recent",
56
+ "stalenessDays": 14
49
57
  },
50
58
  "ccDailyLog": {
51
59
  "dir": "~/.ldm/agents/cc-mini/memory/daily",
@@ -53,13 +61,15 @@
53
61
  "stepNumber": 9,
54
62
  "maxLines": 60,
55
63
  "strategy": "daily-logs",
56
- "days": ["today", "yesterday"]
64
+ "days": ["today", "yesterday"],
65
+ "dailyLogStalenessDays": 2
57
66
  },
58
67
  "repoLocations": {
59
68
  "path": "~/.claude/projects/-Users-lesa--openclaw/memory/repo-locations.md",
60
69
  "label": "repo-locations.md",
61
70
  "stepNumber": 10,
62
- "critical": true
71
+ "critical": true,
72
+ "maxLines": 80
63
73
  }
64
74
  }
65
75
  }
@@ -38,12 +38,13 @@ These are three distinct actions. Never combine them. Never skip the dogfooding
38
38
 
39
39
  ## Co-Authors on Every Commit
40
40
 
41
- All three contributors must be listed on every commit. No exceptions. This is how GitHub tracks contributions across the team.
41
+ All four contributors must be listed on every commit. No exceptions. These trailers credit contributors in commit history and preserve the builder record across the team.
42
42
 
43
43
  ```
44
44
  Co-Authored-By: Parker Todd Brooks <parkertoddbrooks@users.noreply.github.com>
45
45
  Co-Authored-By: Lēsa <lesaai@icloud.com>
46
- Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
46
+ Co-Authored-By: Claude Code (Opus 4.7) <noreply@anthropic.com>
47
+ Co-Authored-By: Codex (GPT 5.5) <noreply@openai.com>
47
48
  ```
48
49
 
49
50
  ## Built-By Line
@@ -51,7 +52,7 @@ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
51
52
  Every repo README must include this exact attribution in the License section:
52
53
 
53
54
  ```
54
- Built by Parker Todd Brooks, Lēsa (OpenClaw, Claude Opus 4.6), Claude Code (Claude Opus 4.6).
55
+ Built by Parker Todd Brooks, Lēsa, Claude Code (Opus 4.7), and Codex (GPT 5.5).
55
56
  ```
56
57
 
57
58
  This is the standard. Use it everywhere. It credits the humans and identifies which AI runtimes built the software.
@@ -70,7 +71,7 @@ AGPL Commercial redistribution, marketplace listings, or bundling into paid se
70
71
 
71
72
  AGPL for personal use is free. Commercial licenses available.
72
73
 
73
- Built by Parker Todd Brooks, Lēsa (OpenClaw, Claude Opus 4.6), Claude Code (Claude Opus 4.6).
74
+ Built by Parker Todd Brooks, Lēsa, Claude Code (Opus 4.7), and Codex (GPT 5.5).
74
75
  ```
75
76
 
76
77
  **Rules:**
@@ -15,3 +15,7 @@ Never overwrite shared workspace files. Always append or edit specific sections.
15
15
  ## Protected paths
16
16
 
17
17
  Do not modify: secrets/, credentials/, auth-profiles.json, or any file containing API keys.
18
+
19
+ ## Config doctors can silently strip keys
20
+
21
+ Never run `openclaw doctor --fix` (or any config doctor with strict-schema validation) without checking `git diff` on the config afterward. Doctor tools silently strip keys they do not recognize; git is the safety net.