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/CHANGELOG.md +14 -0
- package/README.md +7 -0
- package/adapters/antigravity/README.md +1 -1
- package/adapters/antigravity/install.sh +1 -1
- package/adapters/claude-code/README.md +1 -1
- package/adapters/claude-code/install.sh +1 -1
- package/adapters/codex/README.md +1 -1
- package/adapters/codex/install.sh +3 -3
- package/adapters/hermes/install.sh +1 -1
- package/adapters/opencode/.opencode/agents/triad-developer.md +14 -0
- package/adapters/opencode/.opencode/agents/triad-orchestrator.md +1 -0
- package/adapters/opencode/.opencode/agents/triad-reviewer.md +15 -0
- package/adapters/opencode/README.md +15 -6
- package/adapters/opencode/install.sh +1 -1
- package/adapters/registry.mjs +22 -2
- package/bin/triad-plus.js +207 -41
- package/docs/configuration.md +26 -6
- package/docs/npx-installation.md +16 -0
- package/docs/opencode-replication.md +8 -0
- package/docs/runtimes.md +1 -1
- package/package.json +2 -2
- package/runtime/lib/assignment-packet.mjs +38 -1
- package/runtime/lib/model-config.mjs +165 -0
- package/runtime/lib/repository-context.mjs +192 -0
- package/runtime/lib/terminal.mjs +86 -0
- package/runtime/triad-runtime-context.mjs +88 -0
- package/runtime/triad-verify.mjs +26 -18
- package/skills/triad-loop-developer/SKILL.md +14 -0
- package/skills/triad-loop-orchestrator/SKILL.md +13 -0
- package/skills/triad-loop-reviewer/SKILL.md +16 -0
- package/skills/triad-model-configuration/SKILL.md +98 -0
- package/skills/triad-model-configuration/agents/openai.yaml +4 -0
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
312
|
-
? (await prompt.question(`${role.label} reasoning effort [${role.defaultEffort}]: `)).trim()
|
|
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
|
|
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(
|
|
452
|
-
process.stdout.write(
|
|
453
|
-
process.stdout.write(
|
|
454
|
-
process.stdout.write(`
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
process.stdout.write(`
|
|
458
|
-
process.stdout.write(`
|
|
459
|
-
|
|
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
|
|
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
|
|
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
|
|
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 });
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
values are omitted so the host uses
|
|
40
|
-
expose different controls; Triad+ only
|
|
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
|
|
package/docs/npx-installation.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|