@ours.network/install 0.18.0-nightly.4 → 0.18.0-nightly.6

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.
@@ -23,7 +23,7 @@
23
23
  // ask(prompt, default) -> boolean (never called when assumeYes)
24
24
  // now() -> number
25
25
 
26
- import { join } from 'node:path';
26
+ import { basename, dirname, join, resolve } from 'node:path';
27
27
  import { parseInstallArgs, resolveTarget, InstallUsageError } from './target.mjs';
28
28
  import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
29
29
  import {
@@ -37,12 +37,31 @@ import { configJournal, reportRollback } from './journal.mjs';
37
37
  import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
38
38
  import { detectPlatform, resolveChannel } from './logic.mjs';
39
39
  import { daemonEnv } from './effects.mjs';
40
+ import {
41
+ buildClaudeMarketplace, buildCodexMarketplace, marketplaceJson, marketplacePaths,
42
+ validateChannelVersion,
43
+ } from './marketplace.mjs';
40
44
  import { USAGE } from './usage.mjs';
41
45
  import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
42
46
 
43
47
  export const EXIT_OK = 0;
44
48
  export const EXIT_REFUSED = 2;
45
49
 
50
+ export async function resolveExactSuite(args, effects) {
51
+ const packages = {};
52
+ for (const key of ['mcp', 'claude-code', 'codex']) {
53
+ const version = await effects.resolvePackageVersion(`@ours.network/${key}`, args.channel);
54
+ const checked = validateChannelVersion(version, args.channel);
55
+ if (!checked.ok) return { ok: false, reason: `${key}: ${checked.reason}` };
56
+ packages[key] = checked.version;
57
+ }
58
+ const versions = new Set(Object.values(packages));
59
+ if (versions.size !== 1) {
60
+ return { ok: false, reason: `the ${args.channel} dist-tags are not lockstep (${Object.entries(packages).map(([k, v]) => `${k}=${v}`).join(', ')})` };
61
+ }
62
+ return { ok: true, channel: args.channel, version: versions.values().next().value, packages };
63
+ }
64
+
46
65
  /**
47
66
  * A dry run prints what it WOULD do and performs no mutation. The prefix is the
48
67
  * existing installer's, kept so the two flows read the same.
@@ -66,6 +85,104 @@ async function perform(effects, dryRun, label, thunk) {
66
85
 
67
86
  const reason = (error) => (error instanceof Error ? error.message : String(error));
68
87
 
88
+ function semverMajor(version) {
89
+ const match = /^(?:[~^<>= ]*)(\d+)\./.exec(String(version ?? '').trim());
90
+ return match ? Number(match[1]) : null;
91
+ }
92
+
93
+ function incompatibleUpgrade(target, cliDependencies) {
94
+ if (target.action !== 'update' || !target.daemonVersion) return null;
95
+ const runningMajor = semverMajor(target.daemonVersion);
96
+ const targetRange = cliDependencies?.['@ours.network/sdk'];
97
+ const targetMajor = semverMajor(targetRange);
98
+ if (runningMajor === null) {
99
+ return { unknown: true, runningMajor: null, runningVersion: target.daemonVersion };
100
+ }
101
+ if (targetMajor === null) {
102
+ return { unknown: true, runningMajor, runningVersion: target.daemonVersion };
103
+ }
104
+ return runningMajor !== targetMajor
105
+ ? { unknown: false, runningMajor, runningVersion: target.daemonVersion, targetMajor, targetRange }
106
+ : null;
107
+ }
108
+
109
+ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch) {
110
+ const dir = target.stateDir;
111
+ const configPath = join(dir, 'config.json');
112
+ if (mismatch.unknown) {
113
+ effects.out(warn(`ours: cannot verify whether ${cliPkg} can restore daemon v${mismatch.runningVersion}. Nothing was changed.`));
114
+ effects.out(info('Check npm registry access and re-run; compatibility checks fail closed.'));
115
+ return { refused: { reason: 'compatibility-unknown', exitCode: EXIT_REFUSED } };
116
+ }
117
+
118
+ effects.out(warn(
119
+ `ours: daemon v${mismatch.runningVersion} cannot be restored by the requested major v${mismatch.targetMajor}; major upgrades are intentionally incompatible.`,
120
+ ));
121
+ if (resolve(dir) === resolve(effects.home) || dirname(resolve(dir)) === resolve(dir)) {
122
+ effects.out(info(`Automatic purge is not available for the broad state path ${dir}. Back it up and remove it manually.`));
123
+ return { refused: { reason: 'incompatible-major-broad-path', exitCode: EXIT_REFUSED } };
124
+ }
125
+ // Backups live one directory below a non-daemon container. Putting a copied
126
+ // state beside ~/.ours under another `.ours*` name makes daemon discovery see
127
+ // the backup as a second live target on the next installer run.
128
+ const backupPath = join(
129
+ dirname(dir),
130
+ '.ours-backups',
131
+ `${basename(dir)}-before-v${mismatch.targetMajor}-${effects.now()}`,
132
+ );
133
+ if (args.dryRun) {
134
+ effects.out(info(`[dry-run] would ask to stop the old daemon, copy its complete state to ${backupPath}, remove its service/state, and initialize v${mismatch.targetMajor}.`));
135
+ return { refused: { reason: 'incompatible-major-dry-run', exitCode: EXIT_REFUSED } };
136
+ }
137
+ if (args.assumeYes) {
138
+ effects.out(info('This purge is never accepted through OURS_ASSUME_YES. Re-run interactively to confirm the backup and reset.'));
139
+ return { refused: { reason: 'incompatible-major-unattended', exitCode: EXIT_REFUSED } };
140
+ }
141
+ if (effects.readJson(join(dir, 'ours-cli-daemon.json')) === null) {
142
+ effects.out(info('The daemon is not CLI-managed. Stop its external launcher, back up and remove its state/service, then re-run the installer.'));
143
+ return { refused: { reason: 'incompatible-major-external', exitCode: EXIT_REFUSED } };
144
+ }
145
+ const confirmed = await effects.ask(
146
+ `Back up all daemon state to ${backupPath}, purge the incompatible daemon and service, and install v${mismatch.targetMajor}?`,
147
+ false,
148
+ );
149
+ if (!confirmed) {
150
+ effects.out(info(`Nothing was changed. Back up ${dir}, remove the old daemon service/state, and re-run when ready.`));
151
+ return { refused: { reason: 'incompatible-major-declined', exitCode: EXIT_REFUSED } };
152
+ }
153
+
154
+ await perform(effects, false, 'stop the incompatible daemon', () => effects.run(
155
+ 'ours', ['daemon', 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
156
+ ));
157
+ try {
158
+ await perform(effects, false, `back up complete daemon state to ${backupPath}`, () => effects.copyDir(dir, backupPath));
159
+ } catch (error) {
160
+ try {
161
+ await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
162
+ effects.out(ok('backup failed, but the old daemon was started again'));
163
+ } catch {
164
+ effects.out(warn(`backup failed and the old daemon did not restart; its state is still untouched at ${dir}`));
165
+ }
166
+ throw error;
167
+ }
168
+ try {
169
+ await perform(effects, false, 'remove the incompatible daemon boot service', () => effects.run(
170
+ 'ours', ['daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', configPath], { stream: true },
171
+ ));
172
+ } catch (error) {
173
+ try {
174
+ await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
175
+ effects.out(ok(`service removal failed, but the old daemon was started again; backup retained at ${backupPath}`));
176
+ } catch {
177
+ effects.out(warn(`service removal failed and the old daemon did not restart; state remains at ${dir} and the backup is at ${backupPath}`));
178
+ }
179
+ throw error;
180
+ }
181
+ await perform(effects, false, `remove incompatible state at ${dir}`, () => effects.removeDir(dir));
182
+ effects.out(ok(`backup retained at ${backupPath}`));
183
+ return { purged: true, backupPath };
184
+ }
185
+
69
186
  /**
70
187
  * A step that is allowed to fail without ending the run.
71
188
  *
@@ -102,7 +219,7 @@ function pairFor(plan, target) {
102
219
  /**
103
220
  * Which daemon is this run for?
104
221
  *
105
- * Never asks for a PATH — spec §2 stands — but when several daemons are DETECTED
222
+ * Never asks for a path, but when several daemons are detected
106
223
  * it shows them and lets the operator pick, because choosing from what was found
107
224
  * is not prompting for a state directory.
108
225
  *
@@ -144,7 +261,7 @@ export async function runSelectionPhase(args, effects) {
144
261
  const chosen = resolveSelection(answer, plan);
145
262
  if (chosen.action === 'invalid') {
146
263
  // Refused rather than guessed. Interpreting an unrecognised answer as a path
147
- // would be the "type a state directory" prompt spec §2 forbids, arriving
264
+ // would reintroduce the forbidden "type a state directory" prompt through the
148
265
  // through the back door.
149
266
  effects.out(warn(`ours: ${chosen.reason}. Nothing was changed.`));
150
267
  effects.out(info('Re-run and pick one of the numbers, or name a daemon directly with --state-dir.'));
@@ -158,10 +275,10 @@ export async function runSelectionPhase(args, effects) {
158
275
  }
159
276
 
160
277
  /**
161
- * The daemon half of a run: §§2-4. Returns the target decision plus the step
278
+ * Run the daemon half of an installation and return the target decision plus step
162
279
  * outcomes, or a refusal.
163
280
  */
164
- export async function runDaemonPhase(args, effects) {
281
+ export async function runDaemonPhase(args, effects, exactSuite = null) {
165
282
  const target = await resolveTarget({
166
283
  stateDir: args.stateDir,
167
284
  port: args.port,
@@ -184,7 +301,7 @@ export async function runDaemonPhase(args, effects) {
184
301
  }
185
302
 
186
303
  const dir = target.stateDir;
187
- const creating = target.action === 'create';
304
+ let creating = target.action === 'create';
188
305
  effects.out(heading(creating ? `target ${dir} — creating a daemon here` : `target ${dir} — daemon found on port ${target.port}`));
189
306
  if (target.stalePidRecord) {
190
307
  effects.out(info(`a PID record names port ${target.stalePidRecord} but nothing answers there; treating it as stale`));
@@ -203,8 +320,8 @@ export async function runDaemonPhase(args, effects) {
203
320
  // daemon's broker is its own record, and re-asking would invite an operator to
204
321
  // change it from a screen that is not about changing it. The broker question
205
322
  // stays in v3: it is orthogonal to --state-dir/--port, so it
206
- // does not violate spec §2's "nothing about a state directory or a port
207
- // appears in any prompt".
323
+ // does not violate the rule that state-directory and port values never appear
324
+ // in prompts.
208
325
  if (creating) args.brokerUrl = await askBroker(args, effects);
209
326
 
210
327
  const steps = [];
@@ -212,10 +329,26 @@ export async function runDaemonPhase(args, effects) {
212
329
  // The operator CLI owns the shared daemon; ours-mcp is the per-session stdio
213
330
  // adapter each harness spawns. Both are required, but only `ours daemon`
214
331
  // participates in lifecycle or service management.
215
- const mcpPkg = componentSpec(componentByKey('mcp'), args.channel);
332
+ const mcpPkg = exactSuite?.packages?.mcp
333
+ ? `@ours.network/mcp@${exactSuite.packages.mcp}`
334
+ : componentSpec(componentByKey('mcp'), args.channel);
335
+ // The CLI intentionally publishes only `latest`; unlike the lockstep MCP and
336
+ // connector packages it has no nightly dist-tag. Keep this untagged on every
337
+ // installer channel, and inspect that package's SDK dependency for the gate.
338
+ const cliPkg = '@ours.network/cli';
339
+ if (!creating && target.daemonVersion) {
340
+ const mismatch = incompatibleUpgrade(target, effects.packageDependencies(cliPkg));
341
+ if (mismatch) {
342
+ const prepared = await prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch);
343
+ if (prepared.refused) return { target, refused: prepared.refused, steps };
344
+ creating = prepared.purged === true;
345
+ target.backupPath = prepared.backupPath;
346
+ target.action = 'create';
347
+ }
348
+ }
216
349
  await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
217
350
  steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
218
- await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
351
+ await perform(effects, args.dryRun, `ours CLI installed (npm i -g ${cliPkg})`, () => effects.run('npm', ['i', '-g', cliPkg]));
219
352
  steps.push({ id: 'cli', changed: true, packageRefresh: true });
220
353
 
221
354
  // The config file — merged, never rewritten, and untouched when it already
@@ -249,8 +382,11 @@ export async function runDaemonPhase(args, effects) {
249
382
 
250
383
  try {
251
384
  if (creating) {
252
- await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
385
+ await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath], { stream: true }));
253
386
  steps.push({ id: 'start', changed: true });
387
+ } else {
388
+ await perform(effects, args.dryRun, `restart the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'restart', '--config', configPath], { stream: true }));
389
+ steps.push({ id: 'restart', changed: true });
254
390
  }
255
391
 
256
392
  const service = await runServicePhase(args, effects, dir, target.port);
@@ -343,7 +479,7 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
343
479
  }
344
480
 
345
481
  /**
346
- * The boot service: §4 step 4, including the legacy-unit case.
482
+ * Install the boot service, including the legacy-unit case.
347
483
  *
348
484
  * A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
349
485
  * the file. `--force` is passed ONLY here, only for a unit
@@ -430,11 +566,11 @@ export async function askComponents(args, effects) {
430
566
  }
431
567
 
432
568
  /**
433
- * Components: §5. A component that fails is reported with its retry command and
569
+ * A component that fails is reported with its retry command and
434
570
  * the run CONTINUES — a failed component is never a reason to undo a successful
435
571
  * one, or to undo the daemon.
436
572
  */
437
- export async function runComponentPhase(args, effects, target) {
573
+ export async function runComponentPhase(args, effects, target, exactSuite = null) {
438
574
  const dir = target.stateDir;
439
575
  const endpoint = `http://127.0.0.1:${target.port}`;
440
576
  const isDefaultStateDir = dir === join(effects.home, '.ours');
@@ -453,13 +589,15 @@ export async function runComponentPhase(args, effects, target) {
453
589
  continue;
454
590
  }
455
591
  try {
456
- results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }));
592
+ results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir, exactSuite }));
457
593
  } catch (error) {
458
594
  // Reported with its reason and the exact manual command; the run continues.
459
595
  // The retry carries the CHANNEL — a nightly run that hands the operator a
460
596
  // stable retry command sends them straight into the split-brain install
461
597
  // this phase exists to avoid.
462
- const retry = `npm i -g ${componentSpec(component, args.channel)}`;
598
+ const retrySpec = component.key === 'mcp' && exactSuite?.packages?.mcp
599
+ ? `@ours.network/mcp@${exactSuite.packages.mcp}` : componentSpec(component, args.channel);
600
+ const retry = `npm i -g ${retrySpec}`;
463
601
  effects.out(warn(`${component.label} failed: ${error instanceof Error ? error.message : String(error)}`));
464
602
  effects.out(info(`retry manually: ${retry}`));
465
603
  results.push({ key: component.key, state: 'failed', reason: String(error?.message ?? error), retry });
@@ -468,10 +606,11 @@ export async function runComponentPhase(args, effects, target) {
468
606
  return summarizeComponentRun(results);
469
607
  }
470
608
 
471
- async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }) {
609
+ async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir, exactSuite }) {
472
610
  if (component.key === 'mcp') {
473
611
  const plan = planMcpAttachment({ stateDir: dir, isDefaultStateDir, channel: args.channel });
474
- await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
612
+ const exact = exactSuite?.packages?.mcp ? `@ours.network/mcp@${exactSuite.packages.mcp}` : plan.install[3];
613
+ await perform(effects, args.dryRun, `install ${exact}`, () => effects.run('npm', ['i', '-g', exact]));
475
614
  if (Object.keys(plan.harnessEnv).length > 0) {
476
615
  effects.out(info(`harness registration carries OURS_CONFIG=${plan.harnessEnv.OURS_CONFIG}`));
477
616
  }
@@ -502,14 +641,21 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
502
641
  }
503
642
  }
504
643
  await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
644
+ const journal = configJournal(effects, { dryRun: args.dryRun });
505
645
  if (plan.changed) {
646
+ journal.snapshot(path);
506
647
  await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
507
648
  } else {
508
649
  effects.out(ok(`${path} already points here — not touched`));
509
650
  }
510
- effects.out(ok('Telegram connector installed and configured, but not started.'));
511
- effects.out(info('After adding a bot, start it explicitly with: ours-tg-connector install-service'));
512
- return { key: 'tg', state: 'installed', note: 'configured; stopped' };
651
+ try {
652
+ await perform(effects, args.dryRun, 'Telegram connector service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
653
+ } catch (error) {
654
+ rollBack(effects, journal, args, 'the Telegram connector service did not come up — putting its daemon selection back');
655
+ throw error;
656
+ }
657
+ effects.out(ok('Telegram connector installed as a durable service on the shared daemon.'));
658
+ return { key: 'tg', state: 'installed', note: 'configured; service running' };
513
659
  }
514
660
 
515
661
  const path = coworkConfigPath(effects.home, effects.env);
@@ -665,7 +811,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
665
811
  }
666
812
 
667
813
  /**
668
- * The harness plugins (spec §5's other half).
814
+ * Install harness plugins after the daemon and components are ready.
669
815
  *
670
816
  * Two things this phase must never do, both inherited rules rather than new
671
817
  * ones. It never DRIVES a command it could not identify — an alias or a wrapper
@@ -675,7 +821,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
675
821
  * get the exact export line and no promise. Hermes' writer is ours, so its
676
822
  * invocation carries the whole pair and the claim is true.
677
823
  */
678
- export async function runHarnessPhase(args, effects, { target, isDefaultStateDir }) {
824
+ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite = null }) {
679
825
  effects.out(heading('Harness plugins'));
680
826
  const detected = await effects.detectHarnesses();
681
827
  for (const h of detected) {
@@ -700,6 +846,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
700
846
  });
701
847
 
702
848
  const rows = [];
849
+ const markets = marketplacePaths(effects.home);
703
850
  for (const plan of plans) {
704
851
  const row = { key: plan.name, label: `${plan.label} plugin` };
705
852
  if (plan.action === 'skip') {
@@ -710,27 +857,65 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
710
857
  if (plan.action === 'manual') {
711
858
  // NEVER a dead end: the plugin is still installable, by hand, and the run
712
859
  // says so instead of pretending the harness does not exist.
860
+ let manual = plan.manual;
861
+ if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
862
+ const isClaude = plan.name === 'claude-code';
863
+ const root = isClaude ? markets.claudeRoot : markets.codexRoot;
864
+ const manifest = isClaude ? markets.claudeManifest : markets.codexManifest;
865
+ const value = isClaude
866
+ ? buildClaudeMarketplace(exactSuite.packages['claude-code'], exactSuite.channel)
867
+ : buildCodexMarketplace(exactSuite.packages.codex, exactSuite.channel);
868
+ await perform(effects, args.dryRun, `write exact ${plan.name} marketplace ${manifest}`, () => effects.writeJson(manifest, marketplaceJson(value)));
869
+ manual = isClaude
870
+ ? [`/plugin marketplace add ${root}`, '/plugin install ours']
871
+ : [`codex plugin marketplace add ${root}`, 'codex plugin add ours@ours-codex-marketplace', `npm i -g @ours.network/codex@${exactSuite.packages.codex}`];
872
+ }
713
873
  effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
714
- for (const step of plan.manual) effects.out(info(` ${step}`));
874
+ for (const step of manual) effects.out(info(` ${step}`));
715
875
  rows.push({ ...row, state: 'skipped', note: plan.reason });
716
876
  continue;
717
877
  }
718
878
 
879
+ let steps = plan.steps;
880
+ let manual = plan.manual;
881
+ if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
882
+ const isClaude = plan.name === 'claude-code';
883
+ const root = isClaude ? markets.claudeRoot : markets.codexRoot;
884
+ const manifest = isClaude ? markets.claudeManifest : markets.codexManifest;
885
+ const value = isClaude
886
+ ? buildClaudeMarketplace(exactSuite.packages['claude-code'], exactSuite.channel)
887
+ : buildCodexMarketplace(exactSuite.packages.codex, exactSuite.channel);
888
+ await perform(effects, args.dryRun, `write exact ${plan.name} marketplace ${manifest}`, () => effects.writeJson(manifest, marketplaceJson(value)));
889
+ if (!isClaude) {
890
+ const current = await effects.codexMarketplace();
891
+ const source = current?.marketplaceSource;
892
+ if (current && !(source?.sourceType === 'local' && source?.source === root)) {
893
+ await perform(effects, args.dryRun, 'remove moving Codex marketplace source', () => effects.run('codex', ['plugin', 'marketplace', 'remove', 'ours-codex-marketplace']));
894
+ }
895
+ }
896
+ steps = isClaude
897
+ ? [['claude', 'plugin', 'marketplace', 'add', root], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']]
898
+ : [['codex', 'plugin', 'marketplace', 'add', root], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'], ['npm', 'i', '-g', `@ours.network/codex@${exactSuite.packages.codex}`]];
899
+ manual = isClaude
900
+ ? [`/plugin marketplace add ${root}`, '/plugin install ours']
901
+ : [`codex plugin marketplace add ${root}`, 'codex plugin add ours@ours-codex-marketplace', `npm i -g @ours.network/codex@${exactSuite.packages.codex}`];
902
+ }
903
+
719
904
  const env = pairFor(plan, target);
720
905
  let failed = null;
721
- for (const step of plan.steps) {
906
+ for (const step of steps) {
722
907
  const outcome = await attempt(effects, args.dryRun, step.join(' '), () => effects.run(step[0], step.slice(1), env ? { env } : {}));
723
908
  if (!outcome.ok) { failed = outcome; break; }
724
909
  }
725
910
  if (failed) {
726
911
  effects.out(info(`${plan.label} can still be installed by hand:`));
727
- for (const step of plan.manual) effects.out(info(` ${step}`));
912
+ for (const step of manual) effects.out(info(` ${step}`));
728
913
  rows.push({ ...row, state: 'failed', note: 'install step failed' });
729
914
  continue;
730
915
  }
731
916
  if (plan.envLine) {
732
- // The honest line. §5's promise does NOT hold for this harness, and the
733
- // screen says exactly what is true and exactly what to do about it.
917
+ // This harness cannot persist the selected daemon pair. Say exactly what is
918
+ // true and how the operator can configure it explicitly.
734
919
  effects.out(warn(`${plan.label}'s registration cannot carry a value, so it will attach to the DEFAULT daemon.`));
735
920
  effects.out(info(`Add this to your shell profile so it uses this one instead: ${plan.envLine}`));
736
921
  }
@@ -823,13 +1008,9 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
823
1008
  }
824
1009
 
825
1010
  const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
826
- if (has('tg') || has('fleet')) {
1011
+ if (has('fleet')) {
827
1012
  effects.out('');
828
1013
  effects.out(` ${c.bold('Installed but intentionally stopped')}`);
829
- if (has('tg')) {
830
- effects.out(` ${c.gray('• Telegram: add a bot/route first, then run')}`);
831
- effects.out(` ${c.cyan('ours-tg-connector install-service')}`);
832
- }
833
1014
  if (has('fleet')) {
834
1015
  effects.out(` ${c.gray('• Fleet: review the generated coordinator/watchdog config, then run')}`);
835
1016
  effects.out(` ${c.cyan('ours-fleet doctor && ours-fleet config && ours-fleet up')}`);
@@ -917,6 +1098,13 @@ export async function runInstall(argv, effects) {
917
1098
  // that wrapped the old installer keeps its meaning.
918
1099
  if (!runPreflight(effects).ok) return EXIT_OK;
919
1100
 
1101
+ const exactSuite = await resolveExactSuite(args, effects);
1102
+ if (!exactSuite.ok) {
1103
+ effects.out(warn(`Release suite could not be resolved safely: ${exactSuite.reason}. Nothing was changed.`));
1104
+ return EXIT_REFUSED;
1105
+ }
1106
+ effects.out(ok(`Release channel: ${exactSuite.channel} → exact lockstep suite v${exactSuite.version}`));
1107
+
920
1108
  // Which daemon, before anything is decided about it. Only args.stateDir can
921
1109
  // change here; every refusal downstream is unaffected.
922
1110
  effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
@@ -924,7 +1112,7 @@ export async function runInstall(argv, effects) {
924
1112
  if (selection.action === 'refuse') return EXIT_REFUSED;
925
1113
 
926
1114
  effects.out(progress(3, 8, 'Prepare the shared daemon', 'Install the CLI, write config, start it, and enable boot persistence.'));
927
- const daemon = await runDaemonPhase(args, effects);
1115
+ const daemon = await runDaemonPhase(args, effects, exactSuite);
928
1116
  if (daemon.refused) return EXIT_REFUSED;
929
1117
  const target = daemon.target;
930
1118
  const isDefaultStateDir = target.stateDir === join(effects.home, '.ours');
@@ -946,8 +1134,8 @@ export async function runInstall(argv, effects) {
946
1134
  });
947
1135
  }
948
1136
 
949
- effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; Telegram stays stopped.'));
950
- const components = await runComponentPhase(args, effects, target);
1137
+ effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; run both shims as durable services.'));
1138
+ const components = await runComponentPhase(args, effects, target, exactSuite);
951
1139
  for (const component of COMPONENTS) {
952
1140
  const state = components.installed.includes(component.key) ? 'installed'
953
1141
  : components.failed.some((f) => f.key === component.key) ? 'failed' : 'skipped';
@@ -957,7 +1145,7 @@ export async function runInstall(argv, effects) {
957
1145
  state,
958
1146
  version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
959
1147
  note: components.failed.find((f) => f.key === component.key)?.reason
960
- ?? (state === 'installed' && component.key === 'tg' ? 'configured; stopped'
1148
+ ?? (state === 'installed' && component.key === 'tg' ? 'configured; service running'
961
1149
  : state === 'installed' && component.key === 'cowork' ? 'configured; service running' : undefined),
962
1150
  });
963
1151
  }
@@ -966,7 +1154,7 @@ export async function runInstall(argv, effects) {
966
1154
  effects.out(progress(5, 8, 'Create the Human identity', 'Create the daemon root identity once, or keep the existing one.'));
967
1155
  summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
968
1156
  effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
969
- summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
1157
+ summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite }));
970
1158
  effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
971
1159
  summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
972
1160
  summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
package/lib/plan.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // ours-install v3 — daemon creation and boot-service installation.
2
2
  //
3
- // Spec: installer-spec-v3 §§3-4. Pure, like lib/target.mjs: the orchestrator
3
+ // Pure planning code, like lib/target.mjs: the orchestrator
4
4
  // injects file reads, and every function returns a PLAN the caller renders and
5
5
  // executes. Nothing here writes, spawns, or runs systemctl.
6
6
 
@@ -11,7 +11,7 @@ export const SYSTEMD_USER_DIR = ['.config', 'systemd', 'user'];
11
11
  export const DEFAULT_SYSTEMD_UNIT = 'ours.service';
12
12
 
13
13
  // -----------------------------------------------------------------------------
14
- // §4 — which unit file does this state directory own?
14
+ // Which unit file does this state directory own?
15
15
  // -----------------------------------------------------------------------------
16
16
 
17
17
  // 1–32 chars, alphanumeric with interior hyphens/underscores, no dots.
@@ -59,7 +59,7 @@ export function unitPathForStateDir(stateDir, home) {
59
59
  * legacy — the unit published ours-mcp wrote: NO marker, ExecStart running
60
60
  * ours-mcp. This is the migration blocker. `ours daemon
61
61
  * install-service` refuses to overwrite an unmarked unit without
62
- * --force, so spec §4 step 4 fails for every existing Linux user.
62
+ * --force, so service installation fails for every existing Linux user.
63
63
  * foreign — unmarked and NOT recognisably ours-mcp's. Someone else's file.
64
64
  *
65
65
  * THE legacy/foreign SPLIT IS NOW THE ENTIRE SAFETY BOUNDARY. A `legacy` unit is
@@ -85,7 +85,7 @@ export function classifyUnit(text) {
85
85
  }
86
86
 
87
87
  /**
88
- * What this run should do about the boot service (spec §4 step 4).
88
+ * Decide what this run should do about the boot service.
89
89
  *
90
90
  * Returns one of:
91
91
  * { action: 'install' } — call the CLI; it does the rest
@@ -202,9 +202,9 @@ export function legacyReplacedNotice(unitPath, stateDir) {
202
202
 
203
203
  /**
204
204
  * The CLI invocation that installs the boot service. The unit NAME is not passed:
205
- * ours-sdk #20 made the CLI derive it from --state-dir itself, which is why spec
206
- * §4's "the installer must either pass a per-instance unit name or write the unit
207
- * itself" no longer applies — neither, it selects the daemon and the CLI names
205
+ * ours-sdk #20 made the CLI derive it from --state-dir itself, so the installer
206
+ * neither passes a per-instance unit name nor writes the unit itself: it selects
207
+ * the daemon and the CLI names
208
208
  * the unit. One derivation, in one place.
209
209
  */
210
210
  export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
@@ -221,7 +221,7 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
221
221
  }
222
222
 
223
223
  // -----------------------------------------------------------------------------
224
- // §3(a) step 2 / §4 step 2 — the daemon config file
224
+ // Daemon configuration file
225
225
  // -----------------------------------------------------------------------------
226
226
 
227
227
  /**
@@ -247,7 +247,7 @@ export function planDaemonConfig(existing, { port, stateDir, brokerUrl }) {
247
247
  }
248
248
 
249
249
  /**
250
- * The ordered, announced steps for the daemon half of a run (spec §4). Each is
250
+ * The ordered, announced steps for the daemon half of a run. Each is
251
251
  * idempotent, and an `update` skips creation entirely: it never moves a port and
252
252
  * never creates a second daemon.
253
253
  */
package/lib/rerun.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  // ours-install v3 — re-running, and a second daemon alongside the first.
2
2
  //
3
- // Spec: installer-spec-v3 §§6-7. Pure, like the earlier stages.
3
+ // Pure re-run and coexistence planning, like the earlier stages.
4
4
  //
5
5
  // Two properties this file exists to make checkable rather than hoped for:
6
6
  //
7
- // IDEMPOTENCE (§6). Running the installer again with the same answers changes
7
+ // IDEMPOTENCE. Running the installer again with the same answers changes
8
8
  // nothing except refreshed npm packages. Not "changes little" — nothing: no
9
9
  // config written, no unit rewritten, no systemctl run, no daemon restarted.
10
10
  //
11
- // COEXISTENCE (§7). Two daemons share no per-daemon artefact. Everything keyed
11
+ // COEXISTENCE. Two daemons share no per-daemon artefact. Everything keyed
12
12
  // to a daemon is derived from its state directory, so two state directories
13
13
  // produce two of everything.
14
14
 
@@ -16,8 +16,8 @@ import { join, resolve } from 'node:path';
16
16
  import { unitNameForStateDir } from './plan.mjs';
17
17
 
18
18
  /**
19
- * Everything that belongs to ONE daemon, derived from its state directory (spec
20
- * §7's table). Listing them in one place is what makes "these two daemons share
19
+ * Everything that belongs to one daemon is derived from its state directory.
20
+ * Listing the artifacts in one place makes "these two daemons share
21
21
  * nothing" a property a test can check instead of a claim in a document.
22
22
  *
23
23
  * `port` is included because it is per-daemon, but note it is NOT what identifies
@@ -55,7 +55,7 @@ export function daemonCollisions(a, b) {
55
55
  }
56
56
 
57
57
  /**
58
- * The per-component coexistence rule (spec §7), stated so the screen can never
58
+ * The per-component coexistence rule, stated so the screen can never
59
59
  * imply something the design does not do.
60
60
  *
61
61
  * mcp — coexists naturally. Each harness registration carries its own
@@ -107,7 +107,7 @@ export function summarizeRun(steps) {
107
107
  }
108
108
 
109
109
  /**
110
- * Did a re-run leave the daemon alone? Spec §3(a): update never deletes state,
110
+ * Did a re-run leave the daemon alone? An update never deletes state,
111
111
  * never moves a port, and never creates a second daemon.
112
112
  */
113
113
  export function assertUpdateLeftDaemonAlone({ before, after }) {