triad-plus 1.8.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +21 -12
  3. package/adapters/antigravity/.agents/agents/triad-developer/agent.md +7 -3
  4. package/adapters/antigravity/.agents/agents/triad-orchestrator/agent.md +5 -0
  5. package/adapters/antigravity/.agents/agents/triad-reviewer/agent.md +7 -2
  6. package/adapters/antigravity/.agents/skills/triad/SKILL.md +22 -0
  7. package/adapters/antigravity/README.md +1 -1
  8. package/adapters/antigravity/install.sh +1 -1
  9. package/adapters/claude-code/.claude/agents/triad-developer.md +9 -4
  10. package/adapters/claude-code/.claude/agents/triad-reviewer.md +10 -5
  11. package/adapters/claude-code/.claude/commands/triad.md +22 -0
  12. package/adapters/claude-code/README.md +12 -4
  13. package/adapters/claude-code/install.sh +1 -1
  14. package/adapters/codex/README.md +1 -1
  15. package/adapters/codex/install.sh +3 -3
  16. package/adapters/codex/prompts/triad.md +28 -0
  17. package/adapters/copilot/.github/agents/triad-developer.agent.md +12 -6
  18. package/adapters/copilot/.github/agents/triad-evaluator.agent.md +2 -1
  19. package/adapters/copilot/.github/agents/triad-orchestrator.agent.md +15 -1
  20. package/adapters/copilot/.github/agents/triad-reviewer.agent.md +11 -5
  21. package/adapters/copilot/.github/skills/triad/SKILL.md +23 -0
  22. package/adapters/hermes/install.sh +1 -1
  23. package/adapters/hermes/skills/triad/SKILL.md +18 -0
  24. package/adapters/opencode/.opencode/agents/triad-developer.md +23 -4
  25. package/adapters/opencode/.opencode/agents/triad-orchestrator.md +16 -0
  26. package/adapters/opencode/.opencode/agents/triad-reviewer.md +25 -3
  27. package/adapters/opencode/.opencode/commands/triad.md +21 -0
  28. package/adapters/opencode/README.md +29 -5
  29. package/adapters/opencode/install.sh +1 -1
  30. package/adapters/registry.mjs +23 -1
  31. package/bin/triad-plus.js +210 -38
  32. package/docs/architecture.md +8 -0
  33. package/docs/assignment-packets.md +35 -0
  34. package/docs/bmad-integration.md +187 -82
  35. package/docs/configuration.md +29 -0
  36. package/docs/npx-installation.md +20 -0
  37. package/docs/opencode-replication.md +8 -0
  38. package/docs/operating-guide.it.md +8 -0
  39. package/docs/operating-guide.md +7 -0
  40. package/docs/runtimes.md +1 -1
  41. package/docs/verification.md +20 -0
  42. package/integrations/bmad/README.md +17 -9
  43. package/integrations/bmad/epics-parser.mjs +697 -0
  44. package/integrations/bmad/story-importer.mjs +143 -5
  45. package/package.json +2 -2
  46. package/runtime/lib/assignment-packet.mjs +553 -0
  47. package/runtime/lib/model-config.mjs +165 -0
  48. package/runtime/lib/repository-context.mjs +192 -0
  49. package/runtime/lib/terminal.mjs +86 -0
  50. package/runtime/triad-assignment-packet.mjs +62 -0
  51. package/runtime/triad-bmad-intake.mjs +150 -0
  52. package/runtime/triad-runtime-context.mjs +88 -0
  53. package/runtime/triad-verify.mjs +41 -21
  54. package/schemas/verification-evidence.schema.json +8 -0
  55. package/skills/triad-loop-bootstrap/SKILL.md +39 -4
  56. package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +14 -1
  57. package/skills/triad-loop-developer/SKILL.md +33 -8
  58. package/skills/triad-loop-orchestrator/SKILL.md +96 -11
  59. package/skills/triad-loop-reviewer/SKILL.md +31 -5
  60. package/skills/triad-model-configuration/SKILL.md +98 -0
  61. package/skills/triad-model-configuration/agents/openai.yaml +4 -0
@@ -17,6 +17,7 @@ permission:
17
17
  triad-reviewer: allow
18
18
  skill:
19
19
  "triad-loop-*": allow
20
+ "triad-model-configuration": allow
20
21
  ---
21
22
 
22
23
  You are the Triad+ Engineering Loop orchestrator. Govern the workflow and the
@@ -64,6 +65,21 @@ developer-reviewer disagreements and record the rationale; escalate only a
64
65
  decision that changes product intent, criteria, metrics, gates, architecture,
65
66
  security, budget, or accepted risk.
66
67
 
68
+ Before each delegation, create the immutable Assignment Packet and bind it to
69
+ the assignment by running:
70
+
71
+ ```bash
72
+ node .triad-runtime/triad-assignment-packet.mjs \
73
+ --project /absolute/path/to/control-workspace \
74
+ --assignment .loop/runtime/assignments/<assignment-file>.json
75
+ ```
76
+
77
+ Use its returned `dispatch.cwd` as the OpenCode task workdir; it MUST equal the
78
+ assigned product worktree. Give Developer and Reviewer the same packet plus
79
+ explicit control/repository/branch/card/skill paths. The packet is primary;
80
+ full PRD/ADR reads are only fallback for a missing or contradictory detail.
81
+ Do not use the packet to bypass real skill reads or verifier hash checks.
82
+
67
83
  Perform hands-on development or review only when delegation is genuinely
68
84
  unavailable or cannot proceed. Record the trigger, scope, alternatives, risk,
69
85
  validation, independent-review evidence, and the restoration of normal roles.
@@ -18,14 +18,33 @@ permission:
18
18
 
19
19
  You are the independent Triad+ Engineering Loop reviewer. Load
20
20
  `triad-loop-reviewer` at the start of every assignment and follow it exactly.
21
- Review the actual card, PRD excerpt, diff, worktree, developer evidence,
22
- repository instructions, prior attempts, gates, and metrics. Independently rerun
23
- enough required gates to verify claims.
21
+ The host SHOULD launch this activation with its workdir/cwd equal to the
22
+ candidate product worktree when direct inspection is needed. Read the same
23
+ immutable Assignment Packet used by the Developer, then the card, diff,
24
+ developer evidence, repository instructions, prior attempts, gates, and
25
+ metrics. Consult only a necessary PRD/ADR section when the packet is
26
+ insufficient or contradictory; do not reconstruct the full requirement by
27
+ default. Independently rerun enough required gates to verify claims.
24
28
 
25
29
  At activation, read `.triad-plus/team.json`. Your first report to the
26
30
  Orchestrator identifies the configured `roles.reviewer.displayName` as the
27
31
  Triad+ Reviewer and names the feature and attempt under review.
28
32
 
33
+ Before inspecting the candidate, run this exact runtime-context proof from the
34
+ actual Reviewer cwd:
35
+
36
+ ```bash
37
+ node /absolute/path/to/control-workspace/.triad-runtime/triad-runtime-context.mjs \
38
+ --project /absolute/path/to/control-workspace \
39
+ --assignment /absolute/path/to/control-workspace/.loop/runtime/assignments/<assignment-file>.json
40
+ ```
41
+
42
+ Record packet cwd, process cwd, shell `pwd`, Git top-level, and each resolved
43
+ repository-skill path/hash. On non-zero status, `NotFound`, hash mismatch,
44
+ mixed repository roots, or cwd inconsistency, return `blocked` / invalid
45
+ runtime context. Do not use a same-named skill from the control workspace or
46
+ global skills as a fallback.
47
+
29
48
  Return one evidence-based recommendation: `approved`, `rework`, or `blocked`.
30
49
  List severity-ranked findings before gate and metric evidence, residual risks,
31
50
  and the recommendation. A blocked recommendation must identify the exact owner
@@ -34,3 +53,6 @@ decision or external condition required.
34
53
  Do not modify code, implement routine fixes, commit, push, publish packages,
35
54
  create releases, or change workflow state. The orchestrator makes transitions;
36
55
  the developer implements rework.
56
+
57
+ Use OpenCode read/grep/glob/list tools for simple reads; reserve Bash for
58
+ builds, tests, git, scripts, and system commands.
@@ -7,6 +7,11 @@ Operate a Triad+ Engineering Loop for the following owner request:
7
7
 
8
8
  $ARGUMENTS
9
9
 
10
+ When the input is a native BMAD `epics.md` (or its `_bmad-output` directory),
11
+ use the deterministic native intake from `triad-loop-orchestrator`. Do not ask
12
+ the owner to create one Story file per BMAD Story or to run
13
+ `import-bmad-story` repeatedly; keep `epics.md` read-only.
14
+
10
15
  If `.triad-plus/team.json` exists, load it before replying. Use its interaction
11
16
  language, owner address, display names, personas, and model contract in communication;
12
17
  technical role identifiers and authority remain unchanged.
@@ -43,3 +48,19 @@ When true, automatically dispatch `triad-evaluator` in a fresh context with only
43
48
  the approved evaluation packet. When false or omitted, finish without evaluation.
44
49
  An Evaluator+ verdict never reopens Triad, assigns Developer work, or starts
45
50
  repair; `--evaluator` and `--no-evaluator` are per-run overrides when supplied.
51
+
52
+ Before delegating each card, create the immutable Assignment Packet with:
53
+
54
+ ```bash
55
+ node .triad-runtime/triad-assignment-packet.mjs \
56
+ --project /absolute/path/to/control-workspace \
57
+ --assignment .loop/runtime/assignments/<assignment-file>.json
58
+ ```
59
+
60
+ Dispatch the Developer with the returned `dispatch.cwd` as its OpenCode
61
+ workdir, which MUST be the assigned product worktree. Pass the packet and card
62
+ as the primary contract, followed by explicit control paths and mandatory skill
63
+ paths. Dispatch the Reviewer with the same packet and candidate worktree when
64
+ it needs direct inspection; do not reconstruct the full PRD/ADR by default.
65
+ Use native read/grep/glob/list tools for simple reads and Bash only for
66
+ build/test/git/script/system commands.
@@ -1,6 +1,6 @@
1
1
  # Triad+ Engineering Loop for OpenCode
2
2
 
3
- This adapter makes the repository's existing five standard Agent Skills
3
+ This adapter makes the repository's existing six standard Agent Skills
4
4
  operational in OpenCode. It installs four specialized agents and the `/triad`
5
5
  command; it does not duplicate the method or alter `opencode.json`.
6
6
 
@@ -26,6 +26,7 @@ skills/triad-loop-orchestrator/
26
26
  skills/triad-loop-developer/
27
27
  skills/triad-loop-evaluator/
28
28
  skills/triad-loop-reviewer/
29
+ skills/triad-model-configuration/
29
30
  .triad-runtime/
30
31
  triad-verify.mjs
31
32
  triad-runtime-capabilities.mjs
@@ -53,10 +54,26 @@ remain in a declared project workspace.
53
54
 
54
55
  ## Configure models deliberately
55
56
 
56
- The adapter intentionally does not set `model` in its agent files. OpenCode
57
- therefore uses the currently selected primary model and, by default, passes it
58
- to subagents. Set a `model: provider/model-id` field in each installed agent
59
- file when role-specific routing is wanted:
57
+ `.triad-plus/team.json` is the source of truth for role-level model
58
+ configuration. When a team file supplies values, Triad+ materializes the
59
+ OpenCode-native `model` and `variant` frontmatter fields in each project agent
60
+ definition during `init` and `upgrade --apply`:
61
+
62
+ ```text
63
+ team.json model → OpenCode model
64
+ team.json reasoning_effort → OpenCode variant
65
+ ```
66
+
67
+ Do not treat generated `.opencode/agents/*.md` files as an independent
68
+ configuration source; a later managed upgrade regenerates them from
69
+ `.triad-plus/team.json`. A `null` or blank value leaves the corresponding field
70
+ absent so OpenCode uses its host/session default.
71
+
72
+ The adapter only writes fields supported by this OpenCode materialization. Host
73
+ and provider capabilities may still limit which model or reasoning levels are
74
+ available. Inspect identifiers and variants with `opencode models` and keep
75
+ role-specific routing explicit in the team file. OpenCode's role `variant` is
76
+ not the same thing as the default model/session selected by the TUI:
60
77
 
61
78
  | Agent | Recommended profile |
62
79
  | --- | --- |
@@ -69,6 +86,13 @@ Use `opencode models` to inspect the provider/model identifiers available in
69
86
  the local installation. Keep the developer, evaluator, and reviewer separately
70
87
  configured when independent model routing is required.
71
88
 
89
+ The adapter targets the installed OpenCode agent contract: `variant` is the
90
+ host-native per-agent option, while `team.json` remains the canonical desired
91
+ configuration. A null or blank model/effort removes the managed field and lets
92
+ OpenCode use its host or session default. `upgrade --apply` refreshes managed
93
+ agents and then reapplies this same binding; it does not rewrite `team.json`.
94
+ OpenCode 1.18.x exposes this contract through the `AgentConfig.variant` field.
95
+
72
96
  ## Start the loop
73
97
 
74
98
  Launch OpenCode from the project-control repository, then enter:
@@ -31,7 +31,7 @@ adapter_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
31
31
  repository_root="$(cd -- "$adapter_root/../.." && pwd)"
32
32
 
33
33
  agent_names=(triad-orchestrator triad-developer triad-evaluator triad-reviewer)
34
- skill_names=(triad-loop-bootstrap triad-loop-orchestrator triad-loop-developer triad-loop-evaluator triad-loop-reviewer)
34
+ skill_names=(triad-loop-bootstrap triad-loop-orchestrator triad-loop-developer triad-loop-evaluator triad-loop-reviewer triad-model-configuration)
35
35
  command_name="triad"
36
36
  collisions=()
37
37
 
@@ -7,7 +7,8 @@ export const sharedSkillNames = [
7
7
  'triad-loop-orchestrator',
8
8
  'triad-loop-developer',
9
9
  'triad-loop-evaluator',
10
- 'triad-loop-reviewer'
10
+ 'triad-loop-reviewer',
11
+ 'triad-model-configuration'
11
12
  ];
12
13
 
13
14
  export const roleDefinitions = [
@@ -37,6 +38,7 @@ function projectRuntimeAssets(id) {
37
38
  return [
38
39
  { source: 'runtime', destination: '.triad-runtime' },
39
40
  { source: 'schemas', destination: '.triad-runtime/schemas' },
41
+ { source: 'integrations/bmad', destination: '.triad-runtime/integrations/bmad' },
40
42
  { source: `adapters/${id}/runtime.json`, destination: '.triad-runtime/adapter.json', file: true }
41
43
  ];
42
44
  }
@@ -86,6 +88,13 @@ const definitions = [
86
88
  entry: '/triad',
87
89
  lifecycle: null,
88
90
  modelBinding: 'project-frontmatter',
91
+ // OpenCode's agent frontmatter has a first-class `variant` field. Keep
92
+ // Triad's canonical `reasoning_effort` name in team.json and map it to
93
+ // that host-native field rather than emitting provider options such as
94
+ // `reasoningEffort` into the agent profile.
95
+ modelFields: ['model', 'variant'],
96
+ modelFieldSources: { model: 'model', variant: 'reasoning_effort' },
97
+ modelCleanupFields: ['reasoningEffort'],
89
98
  modelRoles: roleDefinitions.map((role) => role.id),
90
99
  projectAssets: [
91
100
  { source: 'adapters/opencode/.opencode/agents', destination: '.opencode/agents' },
@@ -112,6 +121,10 @@ const definitions = [
112
121
  },
113
122
  roleModelPaths(controlRoot) {
114
123
  return roleDefinitions.map((role) => join(controlRoot, '.opencode', 'agents', `triad-${role.id}.md`));
124
+ },
125
+ globalRoleModelPaths() {
126
+ const root = hostHome('.config', 'opencode');
127
+ return rolePaths(root, roleDefinitions.map((role) => `triad-${role.id}`));
115
128
  }
116
129
  },
117
130
  {
@@ -155,6 +168,10 @@ const definitions = [
155
168
  },
156
169
  roleModelPaths(controlRoot) {
157
170
  return ['developer', 'reviewer', 'evaluator'].map((role) => join(controlRoot, '.claude', 'agents', `triad-${role}.md`));
171
+ },
172
+ globalRoleModelPaths() {
173
+ const root = hostHome('.claude');
174
+ return ['developer', 'reviewer', 'evaluator'].map((role) => join(root, 'agents', `triad-${role}.md`));
158
175
  }
159
176
  },
160
177
  {
@@ -220,6 +237,7 @@ const definitions = [
220
237
  lifecycle: null,
221
238
  modelBinding: 'project-frontmatter',
222
239
  modelFields: ['model', 'reasoningEffort'],
240
+ modelFieldSources: { model: 'model', reasoningEffort: 'reasoning_effort' },
223
241
  modelRoles: roleDefinitions.map((role) => role.id),
224
242
  projectAssets: [
225
243
  { source: 'adapters/copilot/.github/agents', destination: '.github/agents' },
@@ -251,6 +269,10 @@ const definitions = [
251
269
  },
252
270
  roleModelPaths(controlRoot) {
253
271
  return roleDefinitions.map((role) => join(controlRoot, '.github', 'agents', `triad-${role.id}.agent.md`));
272
+ },
273
+ globalRoleModelPaths() {
274
+ const root = hostHome('.copilot');
275
+ return roleDefinitions.map((role) => join(root, 'agents', `triad-${role.id}.agent.md`));
254
276
  }
255
277
  }
256
278
  ];
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,20 +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
- await writeFile(target, `---\n${frontmatter}${additions}\n${source.slice(closing)}`, 'utf8');
222
+ const normalizedFrontmatter = frontmatter.replace(/\s*$/, '');
223
+ const next = `---\n${normalizedFrontmatter}${additions ? `\n${additions}` : ''}${source.slice(closing)}`;
224
+ if (next !== source) await writeFile(target, next, 'utf8');
218
225
  }
219
226
 
220
227
  async function writeRoleProfiles(paths, team) {
@@ -240,18 +247,29 @@ async function writeRoleProfiles(paths, team) {
240
247
  }
241
248
  }
242
249
 
243
- async function applyTeamBinding(adapter, controlRoot, team, installContext) {
250
+ async function applyTeamBinding(adapter, controlRoot, team, installContext, scope = 'project') {
244
251
  if (!team || adapter.modelBinding === 'team-record') return;
245
252
  if (adapter.modelBinding === 'project-frontmatter') {
246
- 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);
247
258
  const roles = (adapter.modelRoles ?? roleDefinitions.map((role) => role.id))
248
259
  .map((roleId) => roleDefinitions.find((role) => role.id === roleId));
249
260
  for (const [index, role] of roles.entries()) {
250
- 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
+ );
251
268
  }
252
269
  return;
253
270
  }
254
271
  if (adapter.modelBinding === 'global-profiles') {
272
+ if (scope !== 'global') return;
255
273
  await writeRoleProfiles(adapter.roleModelPaths(installContext), team);
256
274
  return;
257
275
  }
@@ -276,6 +294,100 @@ async function allExist(paths) {
276
294
  return true;
277
295
  }
278
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
+
279
391
  function commandVersion(binary) {
280
392
  const candidates = Array.isArray(binary) ? binary : [binary];
281
393
  for (const candidate of candidates) {
@@ -307,8 +419,9 @@ async function collectTeamConfiguration(prompt, adapter) {
307
419
  const displayName = (await prompt.question(`${role.label} display name [${role.label}]: `)).trim() || role.label;
308
420
  const persona = (await prompt.question(`${role.label} persona [professional and role-focused]: `)).trim() || 'professional and role-focused';
309
421
  const model = (await prompt.question(`${role.label} model ID [host default]: `)).trim();
310
- const reasoningEffort = model && adapter.modelBinding === 'global-profiles'
311
- ? (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)
312
425
  : null;
313
426
  roles[role.id] = { displayName, persona, model: model || null, reasoning_effort: reasoningEffort, enabled };
314
427
  }
@@ -330,15 +443,19 @@ async function init(options) {
330
443
  ...adapter.projectPaths(controlRoot, installContext),
331
444
  ...(team ? [teamConfigPath(controlRoot)] : []),
332
445
  ...(options.global ? adapter.globalPaths(installContext) : []),
333
- ...(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
+ : [])
334
450
  ];
335
451
  const existing = await collisions(planned);
336
452
  if (existing.length > 0) throw new Error(`Installation aborted; existing paths would be overwritten:\n${existing.map((target) => ` ${target}`).join('\n')}`);
337
453
  await installAssets(adapter.projectAssets, controlRoot, installContext);
338
454
  if (team) await writeTeamConfig(controlRoot, team);
339
455
  if (team) await applyOverlay(controlRoot, true, team);
340
- if (team) await applyTeamBinding(adapter, controlRoot, team, installContext);
341
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');
342
459
  process.stdout.write(`Triad+ installed for ${adapter.label} in ${controlRoot}\n`);
343
460
  if (adapter.globalEntry) {
344
461
  process.stdout.write(`Install user-level assets with --global to expose ${adapter.entry}.\n`);
@@ -349,7 +466,7 @@ async function init(options) {
349
466
  async function currentTeam(controlRoot) {
350
467
  const target = teamConfigPath(controlRoot);
351
468
  if (!(await exists(target))) return null;
352
- try { return JSON.parse(await readFile(target, 'utf8')); }
469
+ try { return validateTeamConfiguration(JSON.parse(await readFile(target, 'utf8'))); }
353
470
  catch { throw new Error(`Cannot safely upgrade an invalid team config: ${target}`); }
354
471
  }
355
472
 
@@ -394,12 +511,15 @@ async function upgrade(options) {
394
511
  const backupRoot = join(controlRoot, '.triad-plus', 'backups', stamp);
395
512
  process.stdout.write(`Triad+ upgrade ${options.apply ? 'applying' : 'plan'} for ${adapter.label}\n`);
396
513
  await refreshAssets(adapter.projectAssets, controlRoot, installContext, join(backupRoot, 'project'), options.apply);
514
+ if (team && options.apply && adapter.modelBinding === 'project-frontmatter') {
515
+ await applyTeamBinding(adapter, controlRoot, team, installContext, 'project');
516
+ }
397
517
  await upgradeRunStateDelivery(controlRoot, backupRoot, options.apply);
398
518
  if (team) await applyOverlay(controlRoot, options.apply, team);
399
519
  else process.stdout.write(' Instructions skipped: .triad-plus/team.json is not configured\n');
400
520
  if (options.global) {
401
521
  await refreshAssets(adapter.globalAssets, controlRoot, installContext, join(backupRoot, 'global'), options.apply);
402
- if (team) await applyTeamBinding(adapter, controlRoot, team, installContext);
522
+ if (team && options.apply) await applyTeamBinding(adapter, controlRoot, team, installContext, 'global');
403
523
  }
404
524
  if (!options.apply) process.stdout.write('Dry run only. Re-run with --apply to update managed assets.\n');
405
525
  }
@@ -426,13 +546,18 @@ async function doctor(options) {
426
546
  if (requested.some((adapter) => !adapter)) throw new Error(`Choose --host ${listAdapters().map((item) => item.id).join(', ')}.`);
427
547
  let team = null;
428
548
  if (await exists(teamConfigPath(controlRoot))) {
429
- try { team = JSON.parse(await readFile(teamConfigPath(controlRoot), 'utf8')); }
549
+ try { team = validateTeamConfiguration(JSON.parse(await readFile(teamConfigPath(controlRoot), 'utf8'))); }
430
550
  catch { team = 'invalid'; }
431
551
  }
432
552
  for (const adapter of requested) {
433
553
  const installContext = context(controlRoot);
434
554
  const absent = [];
435
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);
436
561
  const binary = commandVersion(adapter.binaryCandidates ?? adapter.binary);
437
562
  const node = commandVersion('node');
438
563
  const manifestPath = join(controlRoot, '.triad-runtime', 'adapter.json');
@@ -442,25 +567,71 @@ async function doctor(options) {
442
567
  const roleTargets = targets.filter((target) => /[/\\]agents[/\\]triad-/.test(target));
443
568
  const triadSkillTargets = targets.filter((target) => /[/\\]skills[/\\]triad$/.test(target));
444
569
  const capability = manifest ? capabilitySnapshot(controlRoot, manifestPath) : null;
445
- process.stdout.write(`${adapter.label.padEnd(14)} ${absent.length ? 'not installed' : 'OK'}\n`);
446
- process.stdout.write(` Host runtime ${binary ? `OK (${binary})` : 'not installed or version unavailable'}\n`);
447
- process.stdout.write(` Verifier ${node && await exists(join(controlRoot, '.triad-runtime', 'triad-verify.mjs')) ? 'OK' : 'incomplete'}\n`);
448
- process.stdout.write(` Adapter ${manifest ? 'OK' : 'missing or different adapter'}\n`);
449
- if (roleTargets.length) process.stdout.write(` Role agents ${await allExist(roleTargets) ? 'OK' : 'missing'}\n`);
450
- if (triadSkillTargets.length) process.stdout.write(` Triad skill ${await allExist(triadSkillTargets) ? 'OK' : 'missing'}\n`);
451
- process.stdout.write(` Verification ${capability?.verification?.selected_mode ?? 'unavailable'}${capability?.verification?.reason ? ` (${capability.verification.reason})` : ''}\n`);
452
- process.stdout.write(` Team config ${team === 'invalid' ? 'invalid' : team ? 'OK' : 'not configured'}\n`);
453
- 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
+ }
454
625
  const overlay = await overlayPlan(controlRoot, team).catch(() => null);
455
- 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`);
456
627
  const globalAgents = join(codexHome(), 'AGENTS.md');
457
628
  if (await exists(globalAgents)) {
458
629
  const globalText = await readFile(globalAgents, 'utf8');
459
630
  const fixedIdentity = /(?:identity|name)[\s\S]{0,100}(?:always|only|must)/i.test(globalText);
460
- 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`);
461
632
  }
462
633
  if (options.hookConfig && adapter.lifecycle) {
463
- 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`);
464
635
  }
465
636
  }
466
637
  }
@@ -480,6 +651,7 @@ async function interactiveInit() {
480
651
  const control = (await prompt.question(`Project-control workspace [${join(process.cwd(), 'triad-control')}]: `)).trim() || join(process.cwd(), 'triad-control');
481
652
  const global = ['y', 'yes'].includes((await prompt.question(`Also install user-level ${adapter.entry} assets? [y/N]: `)).trim().toLowerCase());
482
653
  const team = await collectTeamConfiguration(prompt, adapter);
654
+ process.stdout.write(`\n${formatSetupSummary({ adapter, control, global, team })}`);
483
655
  const confirm = (await prompt.question('Type install to continue: ')).trim().toLowerCase();
484
656
  if (confirm !== 'install') return process.stdout.write('Cancelled. No files were changed.\n');
485
657
  await init({ host: adapter.id, control, global, team });
@@ -14,6 +14,14 @@ The host agent is the Orchestrator. It owns operational context, delegation, and
14
14
  the next decision. The runtime does not schedule work or implement a general
15
15
  state machine.
16
16
 
17
+ At assignment time the Orchestrator may materialize a small immutable
18
+ Assignment Packet. It is a bounded projection of the card and relevant context,
19
+ not a second control plane or PRD. The host receives an explicit dispatch
20
+ context whose `cwd` is the declared product worktree; control-workspace paths
21
+ remain explicit for policy, state, and evidence. Developer and Reviewer share
22
+ the packet, while mandatory repository skills continue to be read and checked
23
+ from their real worktree paths.
24
+
17
25
  Adapters are registered descriptors in `adapters/registry.mjs`. They describe
18
26
  only real host differences: binary discovery, installation destinations, native
19
27
  entry point, optional hook lifecycle, and supported model binding. Runtime-