triad-plus 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/triad-plus.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { access, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { access, cp, mkdir, readFile, rm, stat, writeFile, lstat, readdir } from 'node:fs/promises';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { readFileSync } from 'node:fs';
5
6
  import { homedir } from 'node:os';
6
7
  import { fileURLToPath } from 'node:url';
7
- import { dirname, join, resolve } from 'node:path';
8
+ import { dirname, join, resolve, relative, sep } from 'node:path';
8
9
  import process from 'node:process';
9
10
  import { createInterface } from 'node:readline/promises';
10
11
  import { getAdapter, listAdapters, roleDefinitions, sharedSkillNames } from '../adapters/registry.mjs';
@@ -20,8 +21,21 @@ import {
20
21
  formatDoctorSection,
21
22
  formatSetupSummary
22
23
  } from '../runtime/lib/terminal.mjs';
24
+ import {
25
+ buildInstallationManifest,
26
+ collectManagedAssetRecords,
27
+ installationManifestFingerprint,
28
+ installationManifestPath,
29
+ loadInstallationManifest,
30
+ manifestIsUninstalled,
31
+ manifestScopeStatus,
32
+ sha256Text,
33
+ sha256File,
34
+ writeInstallationManifest
35
+ } from '../runtime/lib/installation-manifest.mjs';
23
36
 
24
37
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
38
+ const packageVersion = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
25
39
 
26
40
  function usage(exitCode = 0) {
27
41
  const stream = exitCode === 0 ? process.stdout : process.stderr;
@@ -29,15 +43,18 @@ function usage(exitCode = 0) {
29
43
 
30
44
  Usage:
31
45
  npx triad-plus
46
+ npx triad-plus --version
32
47
  npx triad-plus init --host <adapter-id> --control <path> [--global] [--team-config <path>] [--allow-product-repo]
33
48
  npx triad-plus doctor --host <adapter-id> --control <path> [--global] [--hook-config <path>]
49
+ npx triad-plus version --control <path>
34
50
  npx triad-plus upgrade --host <adapter-id> --control <path> [--global] [--apply]
51
+ npx triad-plus uninstall --host <adapter-id> --control <path> [--global] [--apply]
35
52
  npx triad-plus import-bmad-story --source <story.md> --output <card.md> [--target-repository <id>] [--provenance <record.json>] [--required-gate <id>] [--depends-on <card-id>]
36
53
 
37
54
  Adapters: ${listAdapters().map((adapter) => adapter.id).join(', ')}
38
55
 
39
56
  The control path is a project-control workspace, not a product repository.
40
- Installation refuses every asset overwrite. Upgrade is a dry run unless --apply is supplied.
57
+ Installation refuses conflicting asset overwrites; identical global assets may be reused. Upgrade is a dry run unless --apply is supplied.
41
58
  `);
42
59
  process.exit(exitCode);
43
60
  }
@@ -77,6 +94,16 @@ async function requireDirectory(target) {
77
94
  if (!(await stat(target)).isDirectory()) throw new Error(`Control path is not a directory: ${target}`);
78
95
  }
79
96
 
97
+ async function requireExistingDirectory(target) {
98
+ let info;
99
+ try { info = await stat(target); }
100
+ catch (error) {
101
+ if (error.code === 'ENOENT') throw new Error(`Control workspace does not exist: ${target}`);
102
+ throw error;
103
+ }
104
+ if (!info.isDirectory()) throw new Error(`Control path is not a directory: ${target}`);
105
+ }
106
+
80
107
  function codexHome() {
81
108
  return process.env.CODEX_HOME || join(homedir(), '.codex');
82
109
  }
@@ -122,6 +149,101 @@ async function installAssets(assets, root, installContext) {
122
149
  for (const asset of assets) await copyAsset(asset, root, installContext);
123
150
  }
124
151
 
152
+ function assetDestinations(assets, root, installContext) {
153
+ return assets.map((asset) => resolveDestination(asset.destination, root, installContext));
154
+ }
155
+
156
+ async function sourceFiles(source) {
157
+ const info = await lstat(source);
158
+ if (info.isFile()) return [source];
159
+ if (!info.isDirectory()) throw new Error(`Managed asset source is not a regular file or directory: ${source}`);
160
+ const files = [];
161
+ for (const name of (await readdir(source)).sort()) files.push(...await sourceFiles(join(source, name)));
162
+ return files;
163
+ }
164
+
165
+ /** Expand registry assets to exact source/destination file pairs, never to a host directory. */
166
+ async function materializedAssetPairs(assets, root, installContext) {
167
+ const pairs = [];
168
+ for (const asset of assets) {
169
+ const destination = resolveDestination(asset.destination, root, installContext);
170
+ if (asset.source === 'shared-skills') {
171
+ for (const name of sharedSkillNames) {
172
+ const source = join(packageRoot, 'skills', name);
173
+ const sourceRoot = resolve(source);
174
+ for (const file of await sourceFiles(sourceRoot)) {
175
+ pairs.push({ source: file, target: join(destination, name, relative(sourceRoot, file)) });
176
+ }
177
+ }
178
+ continue;
179
+ }
180
+ const source = sourcePath(asset.source);
181
+ const sourceInfo = await lstat(source);
182
+ if (asset.file || sourceInfo.isFile()) {
183
+ pairs.push({ source, target: destination });
184
+ continue;
185
+ }
186
+ const sourceRoot = resolve(source);
187
+ for (const file of await sourceFiles(sourceRoot)) pairs.push({ source: file, target: join(destination, relative(sourceRoot, file)) });
188
+ }
189
+ return pairs.map((pair) => ({ source: resolve(pair.source), target: resolve(pair.target) }));
190
+ }
191
+
192
+ /** Expand registry assets to the exact files copied, never to a host directory. */
193
+ async function materializedAssetPaths(assets, root, installContext) {
194
+ return (await materializedAssetPairs(assets, root, installContext)).map((pair) => pair.target);
195
+ }
196
+
197
+ function generatedRoleAssetPaths(adapter, installContext, scope, team) {
198
+ if (!team || scope !== 'global' || adapter.modelBinding !== 'global-profiles' || typeof adapter.roleModelPaths !== 'function') return [];
199
+ return adapter.roleModelPaths(installContext);
200
+ }
201
+
202
+ async function collectInstallationAssets(adapter, controlRoot, installContext, scope, team) {
203
+ const assets = scope === 'global' ? adapter.globalAssets : adapter.projectAssets;
204
+ const paths = [
205
+ ...await materializedAssetPaths(assets, controlRoot, installContext),
206
+ ...generatedRoleAssetPaths(adapter, installContext, scope, team)
207
+ ];
208
+ return collectManagedAssetRecords(paths, {
209
+ scope,
210
+ baseRoot: scope === 'project' ? controlRoot : null
211
+ });
212
+ }
213
+
214
+ function overallInstallationStatus(scopeStatus) {
215
+ const values = Object.values(scopeStatus);
216
+ if (values.every((value) => ['not_configured', 'uninstalled'].includes(value))) return 'uninstalled';
217
+ if (values.some((value) => ['partial', 'uninstalled'].includes(value))) return 'partial';
218
+ return 'installed';
219
+ }
220
+
221
+ async function installationManifestFor({ adapter, controlRoot, installContext, team, global = false, previous = null, installedAt = null }) {
222
+ const projectAssets = await collectInstallationAssets(adapter, controlRoot, installContext, 'project', team);
223
+ const previousManifest = previous?.manifest ?? previous;
224
+ const overlayAssets = await managedOverlayRecord(controlRoot, previousManifest, team);
225
+ const globalConfigured = Boolean(global || previousManifest?.scopes?.global);
226
+ const globalAssets = global
227
+ ? await collectInstallationAssets(adapter, controlRoot, installContext, 'global', team)
228
+ : (previousManifest?.managed_assets ?? []).filter((asset) => asset.scope === 'global');
229
+ const scopeStatus = {
230
+ project: 'installed',
231
+ global: globalConfigured
232
+ ? (global ? 'installed' : manifestScopeStatus(previousManifest, 'global'))
233
+ : 'not_configured'
234
+ };
235
+ return buildInstallationManifest({
236
+ triadVersion: packageVersion,
237
+ adapter: adapter.id,
238
+ installedAt: installedAt ?? previousManifest?.installed_at ?? new Date().toISOString(),
239
+ updatedAt: new Date().toISOString(),
240
+ scopes: { project: true, global: globalConfigured },
241
+ scopeStatus,
242
+ managedAssets: [...projectAssets, ...overlayAssets, ...globalAssets],
243
+ status: overallInstallationStatus(scopeStatus)
244
+ });
245
+ }
246
+
125
247
  async function replaceManagedPath(source, destination, backup, apply) {
126
248
  const present = await exists(destination);
127
249
  process.stdout.write(` ${apply ? 'Update' : 'Would update'} ${destination}${present ? ' (backup)' : ''}\n`);
@@ -172,16 +294,26 @@ outside Triad+.
172
294
  ${overlayEnd}`;
173
295
  }
174
296
 
297
+ function managedBlockIn(source, startMarker = overlayStart, endMarker = overlayEnd) {
298
+ const startCount = source.split(startMarker).length - 1;
299
+ const endCount = source.split(endMarker).length - 1;
300
+ if (startCount === 0 && endCount === 0) return null;
301
+ if (startCount !== 1 || endCount !== 1) throw new Error('Managed instruction block is ambiguous: duplicate or incomplete markers.');
302
+ const start = source.indexOf(startMarker);
303
+ const end = source.indexOf(endMarker, start + startMarker.length);
304
+ if (end < 0) throw new Error('Managed instruction block has invalid marker order.');
305
+ const blockEnd = end + endMarker.length;
306
+ return { start, end, blockEnd, content: source.slice(start, blockEnd) };
307
+ }
308
+
175
309
  async function overlayPlan(controlRoot, team) {
176
310
  const target = join(controlRoot, 'AGENTS.md');
177
311
  const overlay = instructionOverlay(team);
178
312
  if (!(await exists(target))) return { target, action: 'create', content: `# Project instructions\n\n${overlay}\n` };
179
313
  const source = await readFile(target, 'utf8');
180
- const start = source.indexOf(overlayStart);
181
- const end = source.indexOf(overlayEnd);
182
- if (start === -1 && end === -1) return { target, action: 'append', content: `${source.replace(/\s*$/, '')}\n\n${overlay}\n` };
183
- if (start < 0 || end < start) throw new Error(`Cannot safely update managed instruction block: ${target}`);
184
- return { target, action: 'update', content: `${source.slice(0, start)}${overlay}${source.slice(end + overlayEnd.length)}` };
314
+ const managed = managedBlockIn(source);
315
+ if (!managed) return { target, action: 'append', content: `${source}${source.endsWith('\n') ? '\n' : '\n\n'}${overlay}\n` };
316
+ return { target, action: 'update', content: `${source.slice(0, managed.start)}${overlay}${source.slice(managed.blockEnd)}` };
185
317
  }
186
318
 
187
319
  async function applyOverlay(controlRoot, apply, team) {
@@ -190,6 +322,23 @@ async function applyOverlay(controlRoot, apply, team) {
190
322
  if (apply) await writeFile(plan.target, plan.content, 'utf8');
191
323
  }
192
324
 
325
+ async function managedOverlayRecord(controlRoot, previousManifest, team) {
326
+ const previousBlocks = (previousManifest?.managed_assets ?? []).filter((asset) => asset.kind === 'managed_block' && asset.scope === 'project');
327
+ if (!team) return previousBlocks;
328
+ const target = join(controlRoot, 'AGENTS.md');
329
+ const source = await readFile(target, 'utf8');
330
+ const managed = managedBlockIn(source);
331
+ if (!managed) throw new Error(`Managed instruction block is missing after materialization: ${target}`);
332
+ return [{
333
+ scope: 'project',
334
+ path: 'AGENTS.md',
335
+ kind: 'managed_block',
336
+ start_marker: overlayStart,
337
+ end_marker: overlayEnd,
338
+ sha256: sha256Text(managed.content)
339
+ }];
340
+ }
341
+
193
342
  async function loadTeamConfig(options) {
194
343
  if (options.team) return validateTeamConfiguration(options.team);
195
344
  if (!options.teamConfig) return null;
@@ -306,6 +455,138 @@ async function anyExist(paths) {
306
455
  return false;
307
456
  }
308
457
 
458
+ async function globalPathReusable(target, pairs) {
459
+ let info;
460
+ try { info = await lstat(target); } catch (error) {
461
+ if (error.code === 'ENOENT') return true;
462
+ throw error;
463
+ }
464
+ if (info.isDirectory()) {
465
+ const descendants = pairs.filter((pair) => pathWithin(target, pair.target));
466
+ if (descendants.length === 0) return false;
467
+ for (const pair of descendants) {
468
+ let pairInfo;
469
+ try { pairInfo = await lstat(pair.target); } catch (error) {
470
+ if (error.code === 'ENOENT') continue;
471
+ return false;
472
+ }
473
+ if (!pairInfo.isFile() || await sha256File(pair.target) !== await sha256File(pair.source)) return false;
474
+ }
475
+ return true;
476
+ }
477
+ const pair = pairs.find((candidate) => candidate.target === resolve(target));
478
+ return Boolean(pair && info.isFile() && await sha256File(target) === await sha256File(pair.source));
479
+ }
480
+
481
+ function versionTuple(value) {
482
+ const match = String(value ?? '').match(/^(?:v)?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
483
+ return match ? match.slice(1).map(Number) : null;
484
+ }
485
+
486
+ function compareVersions(left, right) {
487
+ const a = versionTuple(left);
488
+ const b = versionTuple(right);
489
+ if (!a || !b) return null;
490
+ for (let index = 0; index < a.length; index += 1) {
491
+ if (a[index] > b[index]) return 1;
492
+ if (a[index] < b[index]) return -1;
493
+ }
494
+ return 0;
495
+ }
496
+
497
+ function pathWithin(root, target) {
498
+ const base = resolve(root);
499
+ const candidate = resolve(target);
500
+ return candidate === base || candidate.startsWith(`${base}${sep}`);
501
+ }
502
+
503
+ async function managedAssetPaths(adapter, controlRoot, installContext, scope) {
504
+ const assets = scope === 'global' ? adapter.globalAssets : adapter.projectAssets;
505
+ const paths = await materializedAssetPaths(assets, controlRoot, installContext);
506
+ if (scope === 'global' && adapter.modelBinding === 'global-profiles' && typeof adapter.roleModelPaths === 'function') {
507
+ paths.push(...adapter.roleModelPaths(installContext));
508
+ }
509
+ return new Set(paths.map((target) => resolve(target)));
510
+ }
511
+
512
+ function manifestAssetPath(controlRoot, asset) {
513
+ return asset.scope === 'project' ? resolve(controlRoot, asset.path) : resolve(asset.path);
514
+ }
515
+
516
+ function displayManagedAssetPath(asset) {
517
+ if (asset.scope !== 'global') return asset.path;
518
+ const home = resolve(homedir());
519
+ const target = resolve(asset.path);
520
+ return pathWithin(home, target) ? `~${relative(home, target) ? `/${relative(home, target)}` : ''}` : target;
521
+ }
522
+
523
+ function assetAllowedByRegistry(managedPaths, controlRoot, asset) {
524
+ const target = manifestAssetPath(controlRoot, asset);
525
+ if (asset.kind === 'managed_block') return asset.scope === 'project' && target === resolve(controlRoot, 'AGENTS.md');
526
+ return managedPaths.has(target);
527
+ }
528
+
529
+ async function installationAssetIssues(controlRoot, adapter, installContext, manifest) {
530
+ const issues = [];
531
+ for (const asset of manifest.managed_assets) {
532
+ const target = manifestAssetPath(controlRoot, asset);
533
+ const expectedState = manifestScopeStatus(manifest, asset.scope);
534
+ let info;
535
+ try { info = await lstat(target); } catch (error) {
536
+ if (error.code === 'ENOENT') {
537
+ if (expectedState !== 'uninstalled') issues.push({ asset, status: 'missing' });
538
+ continue;
539
+ }
540
+ issues.push({ asset, status: `unreadable:${error.code ?? 'error'}` });
541
+ continue;
542
+ }
543
+ if (asset.kind === 'managed_block') {
544
+ if (!info.isFile()) {
545
+ issues.push({ asset, status: 'modified' });
546
+ continue;
547
+ }
548
+ let source;
549
+ try { source = await readFile(target, 'utf8'); } catch { issues.push({ asset, status: 'unreadable' }); continue; }
550
+ let managed;
551
+ try { managed = managedBlockIn(source, asset.start_marker, asset.end_marker); } catch { managed = null; }
552
+ if (managed && sha256Text(managed.content) !== asset.sha256) issues.push({ asset, status: 'modified' });
553
+ else if (managed && expectedState === 'uninstalled') issues.push({ asset, status: 'present_after_uninstall' });
554
+ else if (!managed && expectedState !== 'uninstalled') issues.push({ asset, status: 'modified' });
555
+ continue;
556
+ }
557
+ if (!info.isFile()) {
558
+ issues.push({ asset, status: 'modified' });
559
+ continue;
560
+ }
561
+ let observed;
562
+ try { observed = await sha256File(target); } catch { issues.push({ asset, status: 'unreadable' }); continue; }
563
+ if (observed !== asset.sha256) issues.push({ asset, status: 'modified' });
564
+ else if (expectedState === 'uninstalled') issues.push({ asset, status: 'present_after_uninstall' });
565
+ }
566
+ return issues;
567
+ }
568
+
569
+ async function inspectInstallation(controlRoot, adapter, installContext) {
570
+ let loaded;
571
+ try { loaded = await loadInstallationManifest(controlRoot); }
572
+ catch (error) {
573
+ return { state: 'invalid', message: error.message, manifest: null, issues: [] };
574
+ }
575
+ if (!loaded) return { state: 'legacy', manifest: null, issues: [] };
576
+ const manifest = loaded.manifest;
577
+ if (manifest.adapter !== adapter.id) {
578
+ return {
579
+ state: 'invalid',
580
+ message: `manifest adapter ${manifest.adapter} does not match selected ${adapter.id}`,
581
+ manifest,
582
+ issues: []
583
+ };
584
+ }
585
+ const issues = await installationAssetIssues(controlRoot, adapter, installContext, manifest);
586
+ const versionRelation = compareVersions(packageVersion, manifest.triad_version);
587
+ return { state: manifestIsUninstalled(manifest) ? 'uninstalled' : 'valid', manifest, issues, versionRelation };
588
+ }
589
+
309
590
  /** Return one status per shared skill so a partial install cannot look healthy. */
310
591
  async function sharedSkillStatuses(adapter, root, installContext, scope) {
311
592
  const roots = sharedSkillRoots(adapter, root, installContext, scope);
@@ -438,24 +719,52 @@ async function init(options) {
438
719
  if (!options.allowProductRepo && productRepositoryAt(controlRoot)) {
439
720
  throw new Error('Control path appears to be a product repository. Use a separate project-control workspace, or pass --allow-product-repo after reviewing the risk.');
440
721
  }
722
+ let previousManifest = null;
723
+ if (await exists(installationManifestPath(controlRoot))) previousManifest = await loadInstallationManifest(controlRoot);
441
724
  const installContext = context(controlRoot);
442
- const planned = [
725
+ const projectPlanned = [
443
726
  ...adapter.projectPaths(controlRoot, installContext),
444
- ...(team ? [teamConfigPath(controlRoot)] : []),
445
- ...(options.global ? adapter.globalPaths(installContext) : []),
446
- ...(team && options.global && adapter.modelBinding === 'global-profiles' ? adapter.roleModelPaths(installContext) : []),
447
- ...(team && options.global && adapter.modelBinding === 'project-frontmatter' && adapter.globalRoleModelPaths
448
- ? adapter.globalRoleModelPaths(installContext)
449
- : [])
727
+ ...(team ? [teamConfigPath(controlRoot)] : [])
450
728
  ];
451
- const existing = await collisions(planned);
452
- if (existing.length > 0) throw new Error(`Installation aborted; existing paths would be overwritten:\n${existing.map((target) => ` ${target}`).join('\n')}`);
729
+ const existingProject = await collisions(projectPlanned);
730
+ if (existingProject.length > 0) throw new Error(`Installation aborted; existing paths would be overwritten:\n${existingProject.map((target) => ` ${target}`).join('\n')}`);
731
+ if (options.global) {
732
+ const globalPlanned = [
733
+ ...adapter.globalPaths(installContext),
734
+ ...(team && adapter.modelBinding === 'global-profiles' ? adapter.roleModelPaths(installContext) : []),
735
+ ...(team && adapter.modelBinding === 'project-frontmatter' && adapter.globalRoleModelPaths
736
+ ? adapter.globalRoleModelPaths(installContext)
737
+ : [])
738
+ ];
739
+ const globalPairs = await materializedAssetPairs(adapter.globalAssets, controlRoot, installContext);
740
+ const teamModelPaths = team
741
+ ? new Set([
742
+ ...(adapter.modelBinding === 'global-profiles' && typeof adapter.roleModelPaths === 'function' ? adapter.roleModelPaths(installContext) : []),
743
+ ...(adapter.modelBinding === 'project-frontmatter' && typeof adapter.globalRoleModelPaths === 'function' ? adapter.globalRoleModelPaths(installContext) : [])
744
+ ].map((target) => resolve(target)))
745
+ : new Set();
746
+ const existingGlobal = [];
747
+ for (const target of globalPlanned) {
748
+ if (!(await exists(target))) continue;
749
+ if (teamModelPaths.has(resolve(target)) || !(await globalPathReusable(target, globalPairs))) existingGlobal.push(target);
750
+ }
751
+ if (existingGlobal.length > 0) throw new Error(`Installation aborted; existing global paths would be overwritten:\n${existingGlobal.map((target) => ` ${target}`).join('\n')}`);
752
+ }
453
753
  await installAssets(adapter.projectAssets, controlRoot, installContext);
454
754
  if (team) await writeTeamConfig(controlRoot, team);
455
755
  if (team) await applyOverlay(controlRoot, true, team);
456
756
  if (options.global) await installAssets(adapter.globalAssets, controlRoot, installContext);
457
757
  if (team) await applyTeamBinding(adapter, controlRoot, team, installContext, 'project');
458
758
  if (team && options.global) await applyTeamBinding(adapter, controlRoot, team, installContext, 'global');
759
+ const manifest = await installationManifestFor({
760
+ adapter,
761
+ controlRoot,
762
+ installContext,
763
+ team,
764
+ global: options.global,
765
+ previous: previousManifest
766
+ });
767
+ await writeInstallationManifest(controlRoot, manifest);
459
768
  process.stdout.write(`Triad+ installed for ${adapter.label} in ${controlRoot}\n`);
460
769
  if (adapter.globalEntry) {
461
770
  process.stdout.write(`Install user-level assets with --global to expose ${adapter.entry}.\n`);
@@ -504,7 +813,12 @@ async function upgrade(options) {
504
813
  if (!adapter) throw new Error(`Choose --host ${listAdapters().map((item) => item.id).join(', ')}.`);
505
814
  if (!options.control) throw new Error('Provide --control <project-control-path>.');
506
815
  const controlRoot = resolve(options.control);
507
- await requireDirectory(controlRoot);
816
+ await requireExistingDirectory(controlRoot);
817
+ let previousManifest = null;
818
+ if (await exists(installationManifestPath(controlRoot))) previousManifest = await loadInstallationManifest(controlRoot);
819
+ if (previousManifest && previousManifest.manifest.adapter !== adapter.id) {
820
+ throw new Error(`Installation manifest belongs to ${previousManifest.manifest.adapter}, not ${adapter.id}.`);
821
+ }
508
822
  const team = await currentTeam(controlRoot);
509
823
  const installContext = context(controlRoot);
510
824
  const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-');
@@ -521,6 +835,20 @@ async function upgrade(options) {
521
835
  await refreshAssets(adapter.globalAssets, controlRoot, installContext, join(backupRoot, 'global'), options.apply);
522
836
  if (team && options.apply) await applyTeamBinding(adapter, controlRoot, team, installContext, 'global');
523
837
  }
838
+ if (options.apply) {
839
+ const manifest = await installationManifestFor({
840
+ adapter,
841
+ controlRoot,
842
+ installContext,
843
+ team,
844
+ global: options.global,
845
+ previous: previousManifest
846
+ });
847
+ await writeInstallationManifest(controlRoot, manifest);
848
+ process.stdout.write(` Installation manifest updated ${installationManifestPath(controlRoot)}\n`);
849
+ } else {
850
+ process.stdout.write(` Installation manifest would be ${previousManifest ? 'updated' : 'created'} ${installationManifestPath(controlRoot)}\n`);
851
+ }
524
852
  if (!options.apply) process.stdout.write('Dry run only. Re-run with --apply to update managed assets.\n');
525
853
  }
526
854
 
@@ -539,6 +867,181 @@ async function importBmadStory(options) {
539
867
  process.stdout.write(`Provenance ${result.provenancePath}\n`);
540
868
  }
541
869
 
870
+ function installationScopeText(manifest, scope) {
871
+ const status = manifestScopeStatus(manifest, scope);
872
+ if (status === 'installed') return 'yes';
873
+ if (status === 'not_configured') return 'no';
874
+ return status;
875
+ }
876
+
877
+ async function versionCommand(options) {
878
+ if (!options.control) throw new Error('Provide --control <project-control-path>.');
879
+ const controlRoot = resolve(options.control);
880
+ await requireExistingDirectory(controlRoot);
881
+ process.stdout.write(`Triad+ CLI ${packageVersion}\n`);
882
+ process.stdout.write(`Workspace ${controlRoot}\n`);
883
+ let loaded;
884
+ try { loaded = await loadInstallationManifest(controlRoot); }
885
+ catch (error) {
886
+ process.stdout.write('Installed Triad unknown (manifest invalid)\n');
887
+ process.stdout.write(`Manifest invalid — ${error.message}\n`);
888
+ throw error;
889
+ }
890
+ if (!loaded) {
891
+ process.stdout.write('Installed Triad unknown (legacy installation)\n');
892
+ process.stdout.write('Manifest legacy / manifest missing\n');
893
+ process.stdout.write('Adapter unknown\n');
894
+ process.stdout.write('Project install unknown\n');
895
+ process.stdout.write('Global install unknown\n');
896
+ return;
897
+ }
898
+ const manifest = loaded.manifest;
899
+ const adapter = getAdapter(manifest.adapter);
900
+ const uninstalled = manifestIsUninstalled(manifest);
901
+ process.stdout.write(`Installed Triad ${uninstalled ? 'unknown (uninstalled)' : manifest.triad_version}\n`);
902
+ process.stdout.write(`Manifest ${uninstalled ? 'OK (uninstalled)' : 'OK'}\n`);
903
+ process.stdout.write(`Adapter ${adapter?.label ?? manifest.adapter}\n`);
904
+ process.stdout.write(`Project install ${installationScopeText(manifest, 'project')}\n`);
905
+ process.stdout.write(`Global install ${installationScopeText(manifest, 'global')}\n`);
906
+ }
907
+
908
+ async function uninstall(options) {
909
+ const adapter = getAdapter(options.host);
910
+ if (!adapter) throw new Error(`Choose --host ${listAdapters().map((item) => item.id).join(', ')}.`);
911
+ if (!options.control) throw new Error('Provide --control <project-control-path>.');
912
+ const controlRoot = resolve(options.control);
913
+ await requireExistingDirectory(controlRoot);
914
+ const loaded = await loadInstallationManifest(controlRoot);
915
+ process.stdout.write(`Triad+ uninstall — ${adapter.label}\n`);
916
+ if (!loaded) {
917
+ process.stdout.write('Manifest legacy / manifest missing\n');
918
+ process.stdout.write('No files were changed. Refusing to delete assets without an installation manifest.\n');
919
+ return;
920
+ }
921
+ const manifest = loaded.manifest;
922
+ if (manifest.adapter !== adapter.id) throw new Error(`Installation manifest belongs to ${manifest.adapter}, not ${adapter.id}.`);
923
+ const installContext = context(controlRoot);
924
+ const selectedScopes = options.global ? ['project', 'global'] : ['project'];
925
+ const scopeStats = Object.fromEntries(selectedScopes.map((scope) => [scope, { modified: 0, removed: 0 }]));
926
+ if (options.global) process.stdout.write('Global assets are preserved because cross-workspace ownership cannot be proven.\n');
927
+ for (const scope of selectedScopes) {
928
+ const registryPaths = await managedAssetPaths(adapter, controlRoot, installContext, scope);
929
+ process.stdout.write(`\n${scope === 'project' ? 'Project' : 'Global'} managed assets\n`);
930
+ const entries = manifest.managed_assets.filter((asset) => asset.scope === scope);
931
+ if (entries.length === 0) process.stdout.write(' (none)\n');
932
+ for (const asset of entries) {
933
+ const target = manifestAssetPath(controlRoot, asset);
934
+ const display = displayManagedAssetPath(asset);
935
+ let info;
936
+ try { info = await lstat(target); } catch (error) {
937
+ if (error.code === 'ENOENT') {
938
+ process.stdout.write(` ABSENT ${display}\n`);
939
+ continue;
940
+ }
941
+ process.stdout.write(` PRESERVE ${display}\n`);
942
+ process.stdout.write(` cannot inspect asset: ${error.message}\n`);
943
+ scopeStats[scope].modified += 1;
944
+ continue;
945
+ }
946
+ if (scope === 'global') {
947
+ process.stdout.write(` PRESERVE ${display}\n`);
948
+ process.stdout.write(' global ownership may be shared by another control workspace\n');
949
+ scopeStats[scope].modified += 1;
950
+ continue;
951
+ }
952
+ if (!assetAllowedByRegistry(registryPaths, controlRoot, asset)) {
953
+ process.stdout.write(` PRESERVE ${display}\n`);
954
+ process.stdout.write(' path is not in the current adapter managed plan\n');
955
+ scopeStats[scope].modified += 1;
956
+ continue;
957
+ }
958
+ if (asset.kind === 'managed_block') {
959
+ if (!info.isFile()) {
960
+ process.stdout.write(` PRESERVE ${display}\n`);
961
+ process.stdout.write(' managed block host file is not a regular file\n');
962
+ scopeStats[scope].modified += 1;
963
+ continue;
964
+ }
965
+ let source;
966
+ let managed;
967
+ try {
968
+ source = await readFile(target, 'utf8');
969
+ managed = managedBlockIn(source, asset.start_marker, asset.end_marker);
970
+ } catch {
971
+ managed = null;
972
+ }
973
+ if (!managed || sha256Text(managed.content) !== asset.sha256) {
974
+ process.stdout.write(` PRESERVE ${display}\n`);
975
+ process.stdout.write(' managed block is modified, ambiguous, or unverifiable\n');
976
+ scopeStats[scope].modified += 1;
977
+ continue;
978
+ }
979
+ process.stdout.write(` ${options.apply ? 'REMOVE' : 'WOULD REMOVE'} block ${display}\n`);
980
+ if (options.apply) {
981
+ await writeFile(target, `${source.slice(0, managed.start)}${source.slice(managed.blockEnd)}`, 'utf8');
982
+ scopeStats[scope].removed += 1;
983
+ }
984
+ continue;
985
+ }
986
+ if (!info.isFile()) {
987
+ process.stdout.write(` PRESERVE ${display}\n`);
988
+ process.stdout.write(' asset is no longer a regular file\n');
989
+ scopeStats[scope].modified += 1;
990
+ continue;
991
+ }
992
+ const observed = await sha256File(target);
993
+ if (observed !== asset.sha256) {
994
+ process.stdout.write(` PRESERVE ${display}\n`);
995
+ process.stdout.write(' content differs from installation manifest\n');
996
+ scopeStats[scope].modified += 1;
997
+ continue;
998
+ }
999
+ process.stdout.write(` ${options.apply ? 'REMOVE' : 'WOULD REMOVE'} ${display}\n`);
1000
+ if (options.apply) {
1001
+ await rm(target, { force: true });
1002
+ scopeStats[scope].removed += 1;
1003
+ }
1004
+ }
1005
+ }
1006
+ process.stdout.write('\nPreserved state\n');
1007
+ for (const preserved of ['.triad-plus/team.json', '.loop/', 'project.yaml', 'features/', 'artifacts/', 'evidence/']) {
1008
+ process.stdout.write(` KEEP ${preserved}\n`);
1009
+ }
1010
+ if (!options.apply) {
1011
+ process.stdout.write('\nNo files were changed. Re-run with --apply to uninstall managed assets.\n');
1012
+ return;
1013
+ }
1014
+ const scopeStatus = { project: manifestScopeStatus(manifest, 'project'), global: manifestScopeStatus(manifest, 'global') };
1015
+ for (const scope of selectedScopes) scopeStatus[scope] = scopeStats[scope].modified > 0 ? 'partial' : 'uninstalled';
1016
+ const status = overallInstallationStatus(scopeStatus);
1017
+ const updated = buildInstallationManifest({
1018
+ triadVersion: manifest.triad_version,
1019
+ adapter: manifest.adapter,
1020
+ installedAt: manifest.installed_at,
1021
+ updatedAt: new Date().toISOString(),
1022
+ uninstalledAt: status === 'uninstalled' ? new Date().toISOString() : undefined,
1023
+ scopes: manifest.scopes,
1024
+ scopeStatus,
1025
+ managedAssets: manifest.managed_assets,
1026
+ status
1027
+ });
1028
+ await writeInstallationManifest(controlRoot, updated);
1029
+ process.stdout.write(`\nInstallation manifest updated ${installationManifestPath(controlRoot)}\n`);
1030
+ const projectPartial = scopeStatus.project === 'partial';
1031
+ const globalPreserved = options.global && scopeStatus.global === 'partial';
1032
+ if (status === 'uninstalled') {
1033
+ process.stdout.write('Complete project uninstall; user state was preserved.\n');
1034
+ } else if (projectPartial && globalPreserved) {
1035
+ process.stdout.write('Partial uninstall; modified or unverifiable project assets and shared global assets were preserved.\n');
1036
+ } else if (projectPartial) {
1037
+ process.stdout.write('Partial project uninstall; modified or unverifiable assets were preserved.\n');
1038
+ } else if (globalPreserved) {
1039
+ process.stdout.write('Project uninstall complete; global assets were intentionally preserved because shared ownership could not be proven.\n');
1040
+ } else {
1041
+ process.stdout.write('Uninstall completed conservatively; preserved assets remain in place.\n');
1042
+ }
1043
+ }
1044
+
542
1045
  async function doctor(options) {
543
1046
  if (!options.control) throw new Error('Provide --control <project-control-path>.');
544
1047
  const controlRoot = resolve(options.control);
@@ -551,6 +1054,7 @@ async function doctor(options) {
551
1054
  }
552
1055
  for (const adapter of requested) {
553
1056
  const installContext = context(controlRoot);
1057
+ const installation = await inspectInstallation(controlRoot, adapter, installContext);
554
1058
  const absent = [];
555
1059
  for (const target of adapter.projectPaths(controlRoot, installContext)) if (!(await exists(target))) absent.push(target);
556
1060
  const globalDetected = adapter.globalPaths ? await anyExist(adapter.globalPaths(installContext)) : false;
@@ -568,7 +1072,41 @@ async function doctor(options) {
568
1072
  const triadSkillTargets = targets.filter((target) => /[/\\]skills[/\\]triad$/.test(target));
569
1073
  const capability = manifest ? capabilitySnapshot(controlRoot, manifestPath) : null;
570
1074
  process.stdout.write(`\n${formatDoctorSection(`Triad+ doctor — ${adapter.label}`)}\n`);
571
- process.stdout.write(`${formatDoctorLine(adapter.label, absent.length || globalAbsent.length ? 'incomplete' : 'OK')}\n`);
1075
+ process.stdout.write(`${formatDoctorLine(adapter.label, absent.length || globalAbsent.length || installation.issues.length ? 'incomplete' : 'OK')}\n`);
1076
+ process.stdout.write(`${formatDoctorSection('Triad+ installation')}\n`);
1077
+ process.stdout.write(` ${formatDoctorLine('CLI version', packageVersion)}\n`);
1078
+ if (installation.state === 'legacy') {
1079
+ process.stdout.write(` ${formatDoctorLine('Installed version', 'unknown (legacy installation)')}\n`);
1080
+ process.stdout.write(` ${formatDoctorLine('Manifest', 'legacy / manifest missing')}\n`);
1081
+ process.stdout.write(` ${formatDoctorLine('Adapter', adapter.label)}\n`);
1082
+ process.stdout.write(` ${formatDoctorLine('Project scope', 'unknown (legacy)')}\n`);
1083
+ process.stdout.write(` ${formatDoctorLine('Global scope', 'unknown (legacy)')}\n`);
1084
+ } else if (installation.state === 'invalid') {
1085
+ process.stdout.write(` ${formatDoctorLine('Installed version', 'unknown (manifest invalid)')}\n`);
1086
+ process.stdout.write(` ${formatDoctorLine('Manifest', 'invalid')} — ${installation.message}\n`);
1087
+ process.stdout.write(` ${formatDoctorLine('Adapter', adapter.label)}\n`);
1088
+ process.stdout.write(` ${formatDoctorLine('Project scope', 'unknown')}\n`);
1089
+ process.stdout.write(` ${formatDoctorLine('Global scope', 'unknown')}\n`);
1090
+ } else {
1091
+ const versionStatus = installation.state === 'uninstalled'
1092
+ ? `unknown (uninstalled; last ${installation.manifest.triad_version})`
1093
+ : installation.manifest.triad_version;
1094
+ const versionWarning = installation.versionRelation === 1
1095
+ ? ' — CLI newer / upgrade available'
1096
+ : installation.versionRelation === -1
1097
+ ? ' — CLI older than installed version'
1098
+ : '';
1099
+ const issueStatus = installation.issues.some((issue) => issue.status === 'modified' || issue.status === 'present_after_uninstall')
1100
+ ? 'managed asset modified'
1101
+ : installation.issues.some((issue) => issue.status === 'missing')
1102
+ ? 'managed asset missing'
1103
+ : installation.state === 'uninstalled' ? 'OK (uninstalled)' : 'OK';
1104
+ process.stdout.write(` ${formatDoctorLine('Installed version', versionStatus)}${versionWarning}\n`);
1105
+ process.stdout.write(` ${formatDoctorLine('Manifest', issueStatus)}\n`);
1106
+ process.stdout.write(` ${formatDoctorLine('Adapter', adapter.label)}\n`);
1107
+ process.stdout.write(` ${formatDoctorLine('Project scope', installationScopeText(installation.manifest, 'project'))}\n`);
1108
+ process.stdout.write(` ${formatDoctorLine('Global scope', installationScopeText(installation.manifest, 'global'))}\n`);
1109
+ }
572
1110
  process.stdout.write(`${formatDoctorSection('Runtime and installation')}\n`);
573
1111
  process.stdout.write(` ${formatDoctorLine('Host runtime', binary ? `OK (${binary})` : 'not installed or version unavailable')}\n`);
574
1112
  process.stdout.write(` ${formatDoctorLine('Verifier', node && await exists(join(controlRoot, '.triad-runtime', 'triad-verify.mjs')) ? 'OK' : 'incomplete')}\n`);
@@ -660,10 +1198,17 @@ async function interactiveInit() {
660
1198
  }
661
1199
 
662
1200
  try {
663
- const options = parseArgs(process.argv.slice(2));
1201
+ const argv = process.argv.slice(2);
1202
+ if (argv.length === 1 && ['--version', '-v'].includes(argv[0])) {
1203
+ process.stdout.write(`${packageVersion}\n`);
1204
+ process.exit(0);
1205
+ }
1206
+ const options = parseArgs(argv);
664
1207
  if (options.command === 'init') await init(options);
665
1208
  else if (options.command === 'doctor') await doctor(options);
1209
+ else if (options.command === 'version') await versionCommand(options);
666
1210
  else if (options.command === 'upgrade') await upgrade(options);
1211
+ else if (options.command === 'uninstall') await uninstall(options);
667
1212
  else if (options.command === 'import-bmad-story') await importBmadStory(options);
668
1213
  else if (!options.command) await interactiveInit();
669
1214
  else if (options.command === '--help' || options.command === '-h') usage(0);