triad-plus 1.9.0 → 1.10.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
@@ -9,6 +9,17 @@ import process from 'node:process';
9
9
  import { createInterface } from 'node:readline/promises';
10
10
  import { getAdapter, listAdapters, roleDefinitions, sharedSkillNames } from '../adapters/registry.mjs';
11
11
  import { writeImportedCard } from '../integrations/bmad/story-importer.mjs';
12
+ import {
13
+ hostModelField,
14
+ materializedHostRoleConfiguration,
15
+ supportsNativeReasoning,
16
+ validateTeamConfiguration
17
+ } from '../runtime/lib/model-config.mjs';
18
+ import {
19
+ formatDoctorLine,
20
+ formatDoctorSection,
21
+ formatSetupSummary
22
+ } from '../runtime/lib/terminal.mjs';
12
23
 
13
24
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
14
25
 
@@ -19,7 +30,7 @@ function usage(exitCode = 0) {
19
30
  Usage:
20
31
  npx triad-plus
21
32
  npx triad-plus init --host <adapter-id> --control <path> [--global] [--team-config <path>] [--allow-product-repo]
22
- npx triad-plus doctor --host <adapter-id> --control <path> [--hook-config <path>]
33
+ npx triad-plus doctor --host <adapter-id> --control <path> [--global] [--hook-config <path>]
23
34
  npx triad-plus upgrade --host <adapter-id> --control <path> [--global] [--apply]
24
35
  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>]
25
36
 
@@ -180,19 +191,13 @@ async function applyOverlay(controlRoot, apply, team) {
180
191
  }
181
192
 
182
193
  async function loadTeamConfig(options) {
183
- if (options.team) return options.team;
194
+ if (options.team) return validateTeamConfiguration(options.team);
184
195
  if (!options.teamConfig) return null;
185
196
  let team;
186
197
  try { team = JSON.parse(await readFile(resolve(options.teamConfig), 'utf8')); }
187
198
  catch (error) { throw new Error(`Cannot read --team-config: ${error.message}`); }
188
- if (team?.schema_version !== 1 || !team.interaction || !team.roles) throw new Error('--team-config must contain schema_version 1, interaction, and roles.');
189
- for (const role of roleDefinitions) {
190
- const value = team.roles[role.id];
191
- if (!value || typeof value.displayName !== 'string' || ![null, undefined].includes(value.model) && typeof value.model !== 'string') {
192
- throw new Error(`--team-config has an invalid ${role.id} role definition.`);
193
- }
194
- }
195
- return team;
199
+ try { return validateTeamConfiguration(team); }
200
+ catch (error) { throw new Error(`--team-config is invalid: ${error.message}`); }
196
201
  }
197
202
 
198
203
  async function writeTeamConfig(controlRoot, team) {
@@ -201,21 +206,22 @@ async function writeTeamConfig(controlRoot, team) {
201
206
  await writeFile(target, `${JSON.stringify(team, null, 2)}\n`, 'utf8');
202
207
  }
203
208
 
204
- async function applyMarkdownModel(target, configuration, fields = ['model']) {
205
- if (!fields.some((field) => configuration?.[field === 'reasoningEffort' ? 'reasoning_effort' : field])) return;
209
+ async function applyMarkdownModel(target, configuration, fields = ['model'], cleanupFields = []) {
210
+ const managedFields = [...new Set([...fields, ...cleanupFields])];
211
+ if (managedFields.length === 0) return;
206
212
  const source = await readFile(target, 'utf8');
207
213
  if (!source.startsWith('---\n')) throw new Error(`Agent definition has no YAML frontmatter: ${target}`);
208
214
  const closing = source.indexOf('\n---\n', 4);
209
215
  if (closing === -1) throw new Error(`Agent definition has invalid YAML frontmatter: ${target}`);
210
216
  let frontmatter = source.slice(4, closing);
211
- for (const field of fields) frontmatter = frontmatter.replace(new RegExp(`^${field}:\\s*.*\\n?`, 'm'), '');
217
+ for (const field of managedFields) frontmatter = frontmatter.replace(new RegExp(`^${field}:\\s*.*\\n?`, 'm'), '');
212
218
  const values = fields
213
- .map((field) => ({ field, value: field === 'reasoningEffort' ? configuration.reasoning_effort : configuration[field] }))
219
+ .map((field) => ({ field, value: configuration?.[field] }))
214
220
  .filter(({ value }) => value !== null && value !== undefined && value !== '');
215
- if (values.length === 0) return;
216
221
  const additions = values.map(({ field, value }) => `${field}: ${JSON.stringify(value)}`).join('\n');
217
222
  const normalizedFrontmatter = frontmatter.replace(/\s*$/, '');
218
- await writeFile(target, `---\n${normalizedFrontmatter}\n${additions}${source.slice(closing)}`, 'utf8');
223
+ const next = `---\n${normalizedFrontmatter}${additions ? `\n${additions}` : ''}${source.slice(closing)}`;
224
+ if (next !== source) await writeFile(target, next, 'utf8');
219
225
  }
220
226
 
221
227
  async function writeRoleProfiles(paths, team) {
@@ -241,18 +247,29 @@ async function writeRoleProfiles(paths, team) {
241
247
  }
242
248
  }
243
249
 
244
- async function applyTeamBinding(adapter, controlRoot, team, installContext) {
250
+ async function applyTeamBinding(adapter, controlRoot, team, installContext, scope = 'project') {
245
251
  if (!team || adapter.modelBinding === 'team-record') return;
246
252
  if (adapter.modelBinding === 'project-frontmatter') {
247
- const paths = adapter.roleModelPaths(controlRoot, installContext);
253
+ const pathFactory = scope === 'global' ? adapter.globalRoleModelPaths : adapter.roleModelPaths;
254
+ if (typeof pathFactory !== 'function') return;
255
+ const paths = scope === 'global'
256
+ ? pathFactory(installContext)
257
+ : pathFactory(controlRoot, installContext);
248
258
  const roles = (adapter.modelRoles ?? roleDefinitions.map((role) => role.id))
249
259
  .map((roleId) => roleDefinitions.find((role) => role.id === roleId));
250
260
  for (const [index, role] of roles.entries()) {
251
- await applyMarkdownModel(paths[index], team.roles[role.id], adapter.modelFields ?? ['model']);
261
+ const fields = adapter.modelFields ?? ['model'];
262
+ await applyMarkdownModel(
263
+ paths[index],
264
+ materializedHostRoleConfiguration(adapter, role.id, team.roles[role.id]),
265
+ fields,
266
+ adapter.modelCleanupFields ?? []
267
+ );
252
268
  }
253
269
  return;
254
270
  }
255
271
  if (adapter.modelBinding === 'global-profiles') {
272
+ if (scope !== 'global') return;
256
273
  await writeRoleProfiles(adapter.roleModelPaths(installContext), team);
257
274
  return;
258
275
  }
@@ -277,6 +294,100 @@ async function allExist(paths) {
277
294
  return true;
278
295
  }
279
296
 
297
+ function sharedSkillRoots(adapter, root, installContext, scope) {
298
+ const assets = scope === 'global' ? adapter.globalAssets : adapter.projectAssets;
299
+ return assets
300
+ .filter((asset) => asset.source === 'shared-skills')
301
+ .map((asset) => resolveDestination(asset.destination, root, installContext));
302
+ }
303
+
304
+ async function anyExist(paths) {
305
+ for (const target of paths) if (await exists(target)) return true;
306
+ return false;
307
+ }
308
+
309
+ /** Return one status per shared skill so a partial install cannot look healthy. */
310
+ async function sharedSkillStatuses(adapter, root, installContext, scope) {
311
+ const roots = sharedSkillRoots(adapter, root, installContext, scope);
312
+ if (roots.length === 0) return [];
313
+ const configuredRoots = [];
314
+ for (const skillRoot of roots) if (await exists(skillRoot)) configuredRoots.push(skillRoot);
315
+ const statuses = [];
316
+ for (const name of sharedSkillNames) {
317
+ const paths = roots.map((skillRoot) => join(skillRoot, name));
318
+ if (configuredRoots.length === 0) {
319
+ statuses.push({ name, status: 'not configured', paths });
320
+ continue;
321
+ }
322
+ let present = 0;
323
+ for (const target of paths) if (await exists(target)) present += 1;
324
+ statuses.push({ name, status: present === paths.length ? 'OK' : 'MISSING', paths });
325
+ }
326
+ return statuses;
327
+ }
328
+
329
+ function roleModelEntries(adapter, controlRoot, installContext, scope) {
330
+ let paths;
331
+ if (scope === 'global') {
332
+ if (adapter.modelBinding === 'global-profiles') paths = adapter.roleModelPaths?.(installContext) ?? [];
333
+ else paths = adapter.globalRoleModelPaths?.(installContext) ?? [];
334
+ } else {
335
+ if (adapter.modelBinding !== 'project-frontmatter') return [];
336
+ paths = adapter.roleModelPaths?.(controlRoot, installContext) ?? [];
337
+ }
338
+ const roleIds = adapter.modelRoles ?? roleDefinitions.map((role) => role.id);
339
+ return roleIds.map((roleId, index) => ({
340
+ role: roleDefinitions.find((definition) => definition.id === roleId),
341
+ path: paths[index]
342
+ })).filter((entry) => entry.role && entry.path);
343
+ }
344
+
345
+ function parseFrontmatterFields(source, fields) {
346
+ const result = {};
347
+ if (!source.startsWith('---\n')) return result;
348
+ const closing = source.indexOf('\n---\n', 4);
349
+ if (closing < 0) return result;
350
+ const frontmatter = source.slice(4, closing);
351
+ for (const field of fields) {
352
+ const match = frontmatter.match(new RegExp(`^${field}:\\s*(.*?)\\s*$`, 'm'));
353
+ if (!match) {
354
+ result[field] = null;
355
+ continue;
356
+ }
357
+ try { result[field] = JSON.parse(match[1]); }
358
+ catch { result[field] = match[1].replace(/^['"]|['"]$/g, ''); }
359
+ }
360
+ return result;
361
+ }
362
+
363
+ async function modelBindingEvidence(adapter, controlRoot, installContext, team, scope) {
364
+ const entries = roleModelEntries(adapter, controlRoot, installContext, scope);
365
+ if (!entries.length) return [];
366
+ const evidence = [];
367
+ for (const { role, path: target } of entries) {
368
+ const configuration = team?.roles?.[role.id];
369
+ if (!configuration) {
370
+ evidence.push({ role, status: 'not configured', target });
371
+ continue;
372
+ }
373
+ const expected = materializedHostRoleConfiguration(adapter, role.id, configuration);
374
+ const fields = [...new Set([...(adapter.modelFields ?? Object.keys(expected)), ...(adapter.modelCleanupFields ?? [])])];
375
+ if (Object.keys(expected).length === 0) {
376
+ evidence.push({ role, status: 'UNSUPPORTED', target, expected, observed: null });
377
+ continue;
378
+ }
379
+ if (!(await exists(target))) {
380
+ evidence.push({ role, status: 'UNKNOWN / NOT OBSERVABLE', target, expected, observed: null });
381
+ continue;
382
+ }
383
+ const observed = parseFrontmatterFields(await readFile(target, 'utf8'), fields);
384
+ const matches = fields.every((field) => (expected[field] ?? null) === (observed[field] ?? null));
385
+ const allDefault = Object.values(expected).every((value) => value === null || value === undefined || value === '');
386
+ evidence.push({ role, status: matches && allDefault ? 'HOST DEFAULT' : matches ? 'VERIFIED' : 'MATERIALIZED MISMATCH', target, expected, observed });
387
+ }
388
+ return evidence;
389
+ }
390
+
280
391
  function commandVersion(binary) {
281
392
  const candidates = Array.isArray(binary) ? binary : [binary];
282
393
  for (const candidate of candidates) {
@@ -308,8 +419,9 @@ async function collectTeamConfiguration(prompt, adapter) {
308
419
  const displayName = (await prompt.question(`${role.label} display name [${role.label}]: `)).trim() || role.label;
309
420
  const persona = (await prompt.question(`${role.label} persona [professional and role-focused]: `)).trim() || 'professional and role-focused';
310
421
  const model = (await prompt.question(`${role.label} model ID [host default]: `)).trim();
311
- const reasoningEffort = model && adapter.modelBinding === 'global-profiles'
312
- ? (await prompt.question(`${role.label} reasoning effort [${role.defaultEffort}]: `)).trim() || role.defaultEffort
422
+ const reasoningEffort = supportsNativeReasoning(adapter, role.id)
423
+ ? (await prompt.question(`${role.label} reasoning effort [${adapter.modelBinding === 'global-profiles' && model ? role.defaultEffort : 'host default'}]: `)).trim()
424
+ || (adapter.modelBinding === 'global-profiles' && model ? role.defaultEffort : null)
313
425
  : null;
314
426
  roles[role.id] = { displayName, persona, model: model || null, reasoning_effort: reasoningEffort, enabled };
315
427
  }
@@ -331,15 +443,19 @@ async function init(options) {
331
443
  ...adapter.projectPaths(controlRoot, installContext),
332
444
  ...(team ? [teamConfigPath(controlRoot)] : []),
333
445
  ...(options.global ? adapter.globalPaths(installContext) : []),
334
- ...(team && options.global && adapter.modelBinding === 'global-profiles' ? adapter.roleModelPaths(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
+ : [])
335
450
  ];
336
451
  const existing = await collisions(planned);
337
452
  if (existing.length > 0) throw new Error(`Installation aborted; existing paths would be overwritten:\n${existing.map((target) => ` ${target}`).join('\n')}`);
338
453
  await installAssets(adapter.projectAssets, controlRoot, installContext);
339
454
  if (team) await writeTeamConfig(controlRoot, team);
340
455
  if (team) await applyOverlay(controlRoot, true, team);
341
- if (team) await applyTeamBinding(adapter, controlRoot, team, installContext);
342
456
  if (options.global) await installAssets(adapter.globalAssets, controlRoot, installContext);
457
+ if (team) await applyTeamBinding(adapter, controlRoot, team, installContext, 'project');
458
+ if (team && options.global) await applyTeamBinding(adapter, controlRoot, team, installContext, 'global');
343
459
  process.stdout.write(`Triad+ installed for ${adapter.label} in ${controlRoot}\n`);
344
460
  if (adapter.globalEntry) {
345
461
  process.stdout.write(`Install user-level assets with --global to expose ${adapter.entry}.\n`);
@@ -350,7 +466,7 @@ async function init(options) {
350
466
  async function currentTeam(controlRoot) {
351
467
  const target = teamConfigPath(controlRoot);
352
468
  if (!(await exists(target))) return null;
353
- try { return JSON.parse(await readFile(target, 'utf8')); }
469
+ try { return validateTeamConfiguration(JSON.parse(await readFile(target, 'utf8'))); }
354
470
  catch { throw new Error(`Cannot safely upgrade an invalid team config: ${target}`); }
355
471
  }
356
472
 
@@ -396,16 +512,14 @@ async function upgrade(options) {
396
512
  process.stdout.write(`Triad+ upgrade ${options.apply ? 'applying' : 'plan'} for ${adapter.label}\n`);
397
513
  await refreshAssets(adapter.projectAssets, controlRoot, installContext, join(backupRoot, 'project'), options.apply);
398
514
  if (team && options.apply && adapter.modelBinding === 'project-frontmatter') {
399
- await applyTeamBinding(adapter, controlRoot, team, installContext);
515
+ await applyTeamBinding(adapter, controlRoot, team, installContext, 'project');
400
516
  }
401
517
  await upgradeRunStateDelivery(controlRoot, backupRoot, options.apply);
402
518
  if (team) await applyOverlay(controlRoot, options.apply, team);
403
519
  else process.stdout.write(' Instructions skipped: .triad-plus/team.json is not configured\n');
404
520
  if (options.global) {
405
521
  await refreshAssets(adapter.globalAssets, controlRoot, installContext, join(backupRoot, 'global'), options.apply);
406
- if (team && options.apply && adapter.modelBinding === 'global-profiles') {
407
- await applyTeamBinding(adapter, controlRoot, team, installContext);
408
- }
522
+ if (team && options.apply) await applyTeamBinding(adapter, controlRoot, team, installContext, 'global');
409
523
  }
410
524
  if (!options.apply) process.stdout.write('Dry run only. Re-run with --apply to update managed assets.\n');
411
525
  }
@@ -432,13 +546,18 @@ async function doctor(options) {
432
546
  if (requested.some((adapter) => !adapter)) throw new Error(`Choose --host ${listAdapters().map((item) => item.id).join(', ')}.`);
433
547
  let team = null;
434
548
  if (await exists(teamConfigPath(controlRoot))) {
435
- try { team = JSON.parse(await readFile(teamConfigPath(controlRoot), 'utf8')); }
549
+ try { team = validateTeamConfiguration(JSON.parse(await readFile(teamConfigPath(controlRoot), 'utf8'))); }
436
550
  catch { team = 'invalid'; }
437
551
  }
438
552
  for (const adapter of requested) {
439
553
  const installContext = context(controlRoot);
440
554
  const absent = [];
441
555
  for (const target of adapter.projectPaths(controlRoot, installContext)) if (!(await exists(target))) absent.push(target);
556
+ const globalDetected = adapter.globalPaths ? await anyExist(adapter.globalPaths(installContext)) : false;
557
+ const inspectGlobal = Boolean(options.global || globalDetected);
558
+ const globalTargets = inspectGlobal && adapter.globalPaths ? adapter.globalPaths(installContext) : [];
559
+ const globalAbsent = [];
560
+ for (const target of globalTargets) if (!(await exists(target))) globalAbsent.push(target);
442
561
  const binary = commandVersion(adapter.binaryCandidates ?? adapter.binary);
443
562
  const node = commandVersion('node');
444
563
  const manifestPath = join(controlRoot, '.triad-runtime', 'adapter.json');
@@ -448,25 +567,71 @@ async function doctor(options) {
448
567
  const roleTargets = targets.filter((target) => /[/\\]agents[/\\]triad-/.test(target));
449
568
  const triadSkillTargets = targets.filter((target) => /[/\\]skills[/\\]triad$/.test(target));
450
569
  const capability = manifest ? capabilitySnapshot(controlRoot, manifestPath) : null;
451
- process.stdout.write(`${adapter.label.padEnd(14)} ${absent.length ? 'not installed' : 'OK'}\n`);
452
- process.stdout.write(` Host runtime ${binary ? `OK (${binary})` : 'not installed or version unavailable'}\n`);
453
- process.stdout.write(` Verifier ${node && await exists(join(controlRoot, '.triad-runtime', 'triad-verify.mjs')) ? 'OK' : 'incomplete'}\n`);
454
- process.stdout.write(` Adapter ${manifest ? 'OK' : 'missing or different adapter'}\n`);
455
- if (roleTargets.length) process.stdout.write(` Role agents ${await allExist(roleTargets) ? 'OK' : 'missing'}\n`);
456
- if (triadSkillTargets.length) process.stdout.write(` Triad skill ${await allExist(triadSkillTargets) ? 'OK' : 'missing'}\n`);
457
- process.stdout.write(` Verification ${capability?.verification?.selected_mode ?? 'unavailable'}${capability?.verification?.reason ? ` (${capability.verification.reason})` : ''}\n`);
458
- process.stdout.write(` Team config ${team === 'invalid' ? 'invalid' : team ? 'OK' : 'not configured'}\n`);
459
- process.stdout.write(` Evaluator+ ${team?.roles?.evaluator?.enabled === true ? 'configured' : 'not configured'}\n`);
570
+ process.stdout.write(`\n${formatDoctorSection(`Triad+ doctor — ${adapter.label}`)}\n`);
571
+ process.stdout.write(`${formatDoctorLine(adapter.label, absent.length || globalAbsent.length ? 'incomplete' : 'OK')}\n`);
572
+ process.stdout.write(`${formatDoctorSection('Runtime and installation')}\n`);
573
+ process.stdout.write(` ${formatDoctorLine('Host runtime', binary ? `OK (${binary})` : 'not installed or version unavailable')}\n`);
574
+ process.stdout.write(` ${formatDoctorLine('Verifier', node && await exists(join(controlRoot, '.triad-runtime', 'triad-verify.mjs')) ? 'OK' : 'incomplete')}\n`);
575
+ process.stdout.write(` ${formatDoctorLine('Adapter', manifest ? 'OK' : 'missing or different adapter')}\n`);
576
+ if (roleTargets.length) process.stdout.write(` ${formatDoctorLine('Role agents', await allExist(roleTargets) ? 'OK' : 'missing')}\n`);
577
+ if (triadSkillTargets.length) process.stdout.write(` ${formatDoctorLine('Triad skill', await allExist(triadSkillTargets) ? 'OK' : 'missing')}\n`);
578
+ const projectSharedSkills = await sharedSkillStatuses(adapter, controlRoot, installContext, 'project');
579
+ if (projectSharedSkills.length) {
580
+ process.stdout.write(`${formatDoctorSection('Shared skills')}\n`);
581
+ for (const skill of projectSharedSkills) process.stdout.write(` ${formatDoctorLine(skill.name, skill.status)}\n`);
582
+ }
583
+ if (inspectGlobal) {
584
+ const globalSharedSkills = await sharedSkillStatuses(adapter, controlRoot, installContext, 'global');
585
+ if (globalSharedSkills.length) {
586
+ process.stdout.write(`${formatDoctorSection('Global shared skills')}\n`);
587
+ for (const skill of globalSharedSkills) process.stdout.write(` ${formatDoctorLine(skill.name, skill.status)}\n`);
588
+ }
589
+ }
590
+ process.stdout.write(`${formatDoctorSection('Verification and capabilities')}\n`);
591
+ process.stdout.write(` ${formatDoctorLine('Verification', capability?.verification?.selected_mode ?? 'unavailable')}${capability?.verification?.reason ? ` (${capability.verification.reason})` : ''}\n`);
592
+ process.stdout.write(`${formatDoctorSection('Configuration')}\n`);
593
+ process.stdout.write(` ${formatDoctorLine('Team config', team === 'invalid' ? 'invalid' : team ? 'OK' : 'not configured')}\n`);
594
+ process.stdout.write(` ${formatDoctorLine('Evaluator+', team?.roles?.evaluator?.enabled === true ? 'configured' : 'not configured')}\n`);
595
+ const modelFields = adapter.modelBinding === 'global-profiles'
596
+ ? 'model, reasoning_effort'
597
+ : Array.isArray(adapter.modelFields) && adapter.modelFields.length ? adapter.modelFields.join(', ') : 'team.json record / host-managed';
598
+ process.stdout.write(` ${formatDoctorLine('Model binding', `${adapter.modelBinding ?? 'unavailable'} (${modelFields})`)}\n`);
599
+ const modelScopes = [];
600
+ const validTeam = team !== 'invalid' ? team : null;
601
+ if (adapter.modelBinding === 'project-frontmatter') {
602
+ modelScopes.push(['project', await modelBindingEvidence(adapter, controlRoot, installContext, validTeam, 'project')]);
603
+ }
604
+ if (inspectGlobal && (adapter.modelBinding === 'project-frontmatter' || adapter.modelBinding === 'global-profiles')) {
605
+ modelScopes.push(['global', await modelBindingEvidence(adapter, controlRoot, installContext, validTeam, 'global')]);
606
+ }
607
+ if (modelScopes.some(([, entries]) => entries.length)) {
608
+ process.stdout.write(`${formatDoctorSection('Model configuration evidence')}\n`);
609
+ for (const [scope, entries] of modelScopes) {
610
+ for (const entry of entries) {
611
+ const field = hostModelField(adapter, entry.role.id, 'reasoning_effort');
612
+ const desired = validTeam?.roles?.[entry.role.id];
613
+ const desiredText = desired
614
+ ? `desired model=${desired.model || 'host default'}${field ? ` ${field}=${desired.reasoning_effort || 'host default'}` : ''}`
615
+ : 'desired unavailable';
616
+ const observedText = entry.observed ? ` — materialized ${JSON.stringify(entry.observed)}` : '';
617
+ process.stdout.write(` ${formatDoctorLine(`${scope} ${entry.role.label}`, entry.status)} ${desiredText}${observedText}\n`);
618
+ }
619
+ }
620
+ if (adapter.modelBinding === 'project-frontmatter') {
621
+ process.stdout.write(` ${formatDoctorLine('Host default', 'UNKNOWN / NOT OBSERVABLE')}\n`);
622
+ process.stdout.write(` ${formatDoctorLine('Current session', 'UNKNOWN / NOT OBSERVABLE')}\n`);
623
+ }
624
+ }
460
625
  const overlay = await overlayPlan(controlRoot, team).catch(() => null);
461
- process.stdout.write(` Instructions ${overlay ? overlay.action === 'update' ? 'managed' : `needs ${overlay.action}` : 'invalid managed block'}\n`);
626
+ process.stdout.write(` ${formatDoctorLine('Instructions', overlay ? overlay.action === 'update' ? 'managed' : `needs ${overlay.action}` : 'invalid managed block')}\n`);
462
627
  const globalAgents = join(codexHome(), 'AGENTS.md');
463
628
  if (await exists(globalAgents)) {
464
629
  const globalText = await readFile(globalAgents, 'utf8');
465
630
  const fixedIdentity = /(?:identity|name)[\s\S]{0,100}(?:always|only|must)/i.test(globalText);
466
- process.stdout.write(` Identity policy ${fixedIdentity ? 'host rule detected; review for Triad role conflicts' : 'no fixed host rule detected'}\n`);
631
+ process.stdout.write(` ${formatDoctorLine('Identity policy', fixedIdentity ? 'host rule detected; review for Triad role conflicts' : 'no fixed host rule detected')}\n`);
467
632
  }
468
633
  if (options.hookConfig && adapter.lifecycle) {
469
- process.stdout.write(` Hook config declared; run runtime capability detection for detailed status\n`);
634
+ process.stdout.write(` ${formatDoctorLine('Hook config', 'declared; run runtime capability detection for detailed status')}\n`);
470
635
  }
471
636
  }
472
637
  }
@@ -486,6 +651,7 @@ async function interactiveInit() {
486
651
  const control = (await prompt.question(`Project-control workspace [${join(process.cwd(), 'triad-control')}]: `)).trim() || join(process.cwd(), 'triad-control');
487
652
  const global = ['y', 'yes'].includes((await prompt.question(`Also install user-level ${adapter.entry} assets? [y/N]: `)).trim().toLowerCase());
488
653
  const team = await collectTeamConfiguration(prompt, adapter);
654
+ process.stdout.write(`\n${formatSetupSummary({ adapter, control, global, team })}`);
489
655
  const confirm = (await prompt.question('Type install to continue: ')).trim().toLowerCase();
490
656
  if (confirm !== 'install') return process.stdout.write('Cancelled. No files were changed.\n');
491
657
  await init({ host: adapter.id, control, global, team });
@@ -31,14 +31,34 @@ adapter writes those into host-native profiles only where the selected host
31
31
  supports that facility. A blank model means the host default. Never put tokens,
32
32
  API keys, or private deployment data in this file.
33
33
 
34
+ ## Configure models through the agent
35
+
36
+ The installed `triad-model-configuration` skill lets an owner ask the selected
37
+ agent to show or change role models without editing host-specific files. The
38
+ agent reads `.triad-runtime/adapter.json`, validates `.triad-plus/team.json`,
39
+ changes only the explicitly requested `model` or `reasoning_effort` fields, and
40
+ uses the existing managed binding path to materialize supported values. It
41
+ reports whether each request was applied host-natively, recorded only in the
42
+ team file, left at the host default, or unsupported. It never invents model IDs
43
+ and never puts credentials in the configuration.
44
+
45
+ The skill follows the adapter metadata: `global-profiles` writes native
46
+ user-level profiles, `project-frontmatter` writes only the declared native
47
+ fields (OpenCode supports model plus its native `variant`, Copilot supports
48
+ model plus `reasoningEffort`, and Claude Code currently supports model only),
49
+ and `team-record` records intent without fabricating a host binding. A null or
50
+ blank value deliberately means host default. Reasoning levels are host-specific
51
+ and are never translated between providers.
52
+
34
53
  For project-frontmatter adapters, `.triad-plus/team.json` remains the canonical
35
54
  source of truth. Managed installation and `upgrade --apply` re-materialize each
36
- configured role's supported fields after refreshing agent assets. OpenCode and
37
- Copilot map `model` and `reasoning_effort` to the host-native `model` and
38
- `reasoningEffort` fields; Claude Code currently maps `model` only. Null or blank
39
- values are omitted so the host uses its session default. Other adapters may
40
- expose different controls; Triad+ only materializes fields supported by the
41
- selected host.
55
+ configured role's supported fields after refreshing agent assets. OpenCode maps
56
+ `model` to `model` and `reasoning_effort` to its native `variant` field; Copilot
57
+ maps the same canonical fields to `model` and `reasoningEffort`; Claude Code
58
+ currently maps `model` only. Null or blank values are omitted so the host uses
59
+ its session default. Other adapters may expose different controls; Triad+ only
60
+ materializes fields supported by the selected host. A host/session default is
61
+ distinct from a role-agent binding.
42
62
 
43
63
  ## Retry and scope policy
44
64
 
@@ -10,6 +10,10 @@ npx triad-plus
10
10
  The interactive setup selects a host, optional user-level command, language,
11
11
  owner address, role display names/personas, models, and whether the optional
12
12
  Evaluator+ is enabled. It changes files only after `install` is typed.
13
+ Before confirmation it prints a compact summary of the host, control workspace,
14
+ interaction settings, Evaluator+, and each role's model/binding. Colors are
15
+ used only for an interactive terminal and are disabled by `NO_COLOR` or when
16
+ output is not a TTY.
13
17
 
14
18
  For repeatable setup:
15
19
 
@@ -46,6 +50,18 @@ personas, models, and supported effort/options. Existing schema-version-1 team
46
50
  files remain valid. Core roles are always enabled; Evaluator+ is enabled only
47
51
  when `roles.evaluator.enabled` is `true`.
48
52
 
53
+ The shared `triad-model-configuration` skill is installed with the selected
54
+ adapter. Ask the host agent to inspect or change a role model; it keeps
55
+ `.triad-plus/team.json` as the source of truth and re-materializes only fields
56
+ the adapter declares as host-native. Unsupported fields are reported rather
57
+ than silently substituted.
58
+
59
+ For OpenCode, the canonical `reasoning_effort` value is materialized as the
60
+ native per-agent `variant` field. Copilot uses its own native
61
+ `reasoningEffort` field; these host contracts are intentionally not inferred
62
+ from one another. The active host/session default remains separate from a
63
+ materialized role profile.
64
+
49
65
  | Role | Responsibility |
50
66
  | --- | --- |
51
67
  | Orchestrator | Maintains goal/context and decides the next step. |
@@ -7,3 +7,11 @@ command assets carry the configured role models where OpenCode supports them.
7
7
  Verification uses explicit Orchestrator dispatch. A configured Evaluator+ is
8
8
  automatically invoked only after a Reviewer-approved result; it cannot reopen
9
9
  that run.
10
+
11
+ `.triad-plus/team.json` is the desired source of truth for OpenCode role
12
+ configuration. Triad+ materializes each supported role's `model` and native
13
+ `variant` (from `reasoning_effort`) in `.opencode/agents/*.md` during init and
14
+ after `upgrade --apply`. A null value leaves that field absent so OpenCode uses
15
+ its host/session default. The OpenCode session default is independent of these
16
+ per-agent bindings; use `opencode models` to inspect available IDs and
17
+ variants.
package/docs/runtimes.md CHANGED
@@ -49,6 +49,6 @@ validated independently as well.
49
49
  ## OpenCode
50
50
 
51
51
  Use the interactive OpenCode TUI for complete multi-step Triad runs. OpenCode
52
- 1.18.0 validated the full Orchestrator → Developer → verifier → Reviewer →
52
+ 1.18.x validated the full Orchestrator → Developer → verifier → Reviewer →
53
53
  configured Evaluator+ lifecycle in the TUI. `opencode run` is useful for
54
54
  one-shot work but does not retain that multi-step parent/subagent lifecycle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triad-plus",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "A lightweight, evidence-backed engineering loop for coding agents.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "node": ">=20"
29
29
  },
30
30
  "scripts": {
31
- "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs",
31
+ "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs && node tests/repository-context-test.mjs && node tests/tui-model-configuration-test.mjs",
32
32
  "pack:check": "npm pack --dry-run"
33
33
  },
34
34
  "repository": {
@@ -3,6 +3,7 @@ import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { writeAtomicJson } from "./evidence.mjs";
5
5
  import { worktreeBranch } from "./fingerprint.mjs";
6
+ import { inspectRepositoryContext } from "./repository-context.mjs";
6
7
 
7
8
  const SHA256 = /^[a-f0-9]{64}$/i;
8
9
  const PACKET_START = "<!-- triad-plus-assignment-packet:start -->";
@@ -227,6 +228,19 @@ async function resolveProjectRepository(root, assignment, worktree, external) {
227
228
  return matches.length === 1 ? matches[0].id : "declared-worktree";
228
229
  }
229
230
 
231
+ async function repositoryMappingDetails(root, repository) {
232
+ if (!repository) return null;
233
+ const mapping = (await projectRepositoryMappings(root)).find((entry) => entry.id === repository);
234
+ if (!mapping) return null;
235
+ const declared = mapping.worktree || mapping.path;
236
+ return {
237
+ id: mapping.id,
238
+ path: mapping.path,
239
+ worktree: mapping.worktree,
240
+ resolved_worktree: declared ? await realpath(declaredPath(root, declared)).catch(() => null) : null
241
+ };
242
+ }
243
+
230
244
  function section(source, names) {
231
245
  const wanted = new Set(names.map((name) => name.toLowerCase()));
232
246
  const lines = source.split("\n");
@@ -316,6 +330,21 @@ export async function resolveAssignmentContext(assignment, { projectRoot = proce
316
330
  throw packetError("assignment_packet_invalid", `worktree branch does not match assignment: ${branch}`);
317
331
  }
318
332
  const repository = await resolveProjectRepository(root, assignment, worktree, external);
333
+ const repositoryMapping = await repositoryMappingDetails(root, repository);
334
+ const requiredSkills = Array.isArray(assignment.required_repository_skills) ? assignment.required_repository_skills : [];
335
+ let repositoryContext;
336
+ try {
337
+ repositoryContext = await inspectRepositoryContext({ worktree, requiredSkills });
338
+ } catch (error) {
339
+ error.repositoryContext = error.repositoryContext ?? null;
340
+ throw error;
341
+ }
342
+ if (repositoryContext.status !== "pass") {
343
+ const details = repositoryContext.issues.map((item) => item.message).join("; ");
344
+ const error = packetError("repository_context_invalid", details || "assigned repository context is invalid");
345
+ error.repositoryContext = repositoryContext;
346
+ throw error;
347
+ }
319
348
  return {
320
349
  projectRoot: root,
321
350
  controlWorkspace: root,
@@ -324,11 +353,13 @@ export async function resolveAssignmentContext(assignment, { projectRoot = proce
324
353
  external,
325
354
  branch,
326
355
  repository,
356
+ repositoryMapping,
357
+ repositoryContext,
327
358
  declaredWorktree: assignment.worktree,
328
359
  cardPath: assignment.card_path ? relativeProjectPath(root, assignment.card_path, "card path") : null,
329
360
  prdPath: assignment.prd_path ? relativeProjectPath(root, assignment.prd_path, "PRD path") : null,
330
361
  gatesPath: assignment.gates_path ? relativeProjectPath(root, assignment.gates_path, "gates path") : null,
331
- requiredSkills: Array.isArray(assignment.required_repository_skills) ? assignment.required_repository_skills : [],
362
+ requiredSkills,
332
363
  };
333
364
  }
334
365
 
@@ -341,6 +372,7 @@ function packetMetadata(assignment, context, packetPath) {
341
372
  feature_id: assignment.feature_id,
342
373
  attempt: assignment.attempt,
343
374
  repository: context.repository,
375
+ repository_mapping: context.repositoryMapping,
344
376
  branch: context.branch,
345
377
  worktree: context.declaredWorktree,
346
378
  control_workspace: ".",
@@ -357,6 +389,11 @@ function packetMetadata(assignment, context, packetPath) {
357
389
  },
358
390
  required_gate_ids: Array.isArray(assignment.required_gate_ids) ? assignment.required_gate_ids : [],
359
391
  mandatory_skills: context.requiredSkills.map((skill) => ({ path: skill.path, sha256: skill.sha256 })),
392
+ repository_context: {
393
+ assigned_worktree: context.repositoryContext.assigned_worktree,
394
+ assigned_git_top_level: context.repositoryContext.assigned_git_top_level,
395
+ skills: context.repositoryContext.skills
396
+ },
360
397
  });
361
398
  }
362
399