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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { roleDefinitions } from '../../adapters/registry.mjs';
|
|
2
|
+
|
|
3
|
+
const roleIds = new Set(roleDefinitions.map((role) => role.id));
|
|
4
|
+
const modelFields = new Set(['model', 'reasoning_effort']);
|
|
5
|
+
|
|
6
|
+
function nullableString(value, field) {
|
|
7
|
+
if (value === undefined || value === null) return;
|
|
8
|
+
if (typeof value !== 'string') throw new Error(`${field} must be a string or null.`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function adapterModelRoles(adapter) {
|
|
12
|
+
return new Set(adapter?.modelRoles ?? roleDefinitions.map((role) => role.id));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function defaultSourceForHostField(field) {
|
|
16
|
+
return field === 'reasoningEffort' ? 'reasoning_effort' : field;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hostValue(value) {
|
|
20
|
+
return typeof value === 'string' && !value.trim() ? null : value ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Return whether the selected host can materialize a model for this role.
|
|
25
|
+
* A team-record adapter still records the requested value in team.json, but
|
|
26
|
+
* deliberately returns false because it has no native file to update.
|
|
27
|
+
*/
|
|
28
|
+
export function supportsNativeModel(adapter, roleId) {
|
|
29
|
+
if (adapter?.modelBinding === 'global-profiles') return roleIds.has(roleId);
|
|
30
|
+
if (adapter?.modelBinding === 'project-frontmatter') return adapterModelRoles(adapter).has(roleId);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Return whether the selected host exposes a native reasoning field. */
|
|
35
|
+
export function supportsNativeReasoning(adapter, roleId) {
|
|
36
|
+
if (!supportsNativeModel(adapter, roleId)) return false;
|
|
37
|
+
if (adapter?.modelBinding === 'global-profiles') return true;
|
|
38
|
+
return modelFieldMappings(adapter, roleId).some(({ source }) => source === 'reasoning_effort');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Describe the one-way mapping from canonical team.json fields to the
|
|
43
|
+
* host-native fields managed in a role asset. The adapter registry is the
|
|
44
|
+
* only place that chooses host names; callers never infer them by analogy.
|
|
45
|
+
*/
|
|
46
|
+
export function modelFieldMappings(adapter, roleId) {
|
|
47
|
+
if (!supportsNativeModel(adapter, roleId) || adapter?.modelBinding !== 'project-frontmatter') return [];
|
|
48
|
+
const fields = Array.isArray(adapter?.modelFields) && adapter.modelFields.length ? adapter.modelFields : ['model'];
|
|
49
|
+
const sources = adapter?.modelFieldSources ?? {};
|
|
50
|
+
return fields.map((field) => ({ field, source: sources[field] ?? defaultSourceForHostField(field) }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Return the host field used for a canonical semantic field, if any. */
|
|
54
|
+
export function hostModelField(adapter, roleId, source) {
|
|
55
|
+
return modelFieldMappings(adapter, roleId).find((mapping) => mapping.source === source)?.field ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Describe how a role's requested configuration is handled by the adapter.
|
|
60
|
+
* This is presentation metadata; team.json remains authoritative in every
|
|
61
|
+
* mode.
|
|
62
|
+
*/
|
|
63
|
+
export function modelBindingSummary(adapter, roleId) {
|
|
64
|
+
if (adapter?.modelBinding === 'team-record') return 'recorded in team.json (host-native binding unavailable)';
|
|
65
|
+
if (!supportsNativeModel(adapter, roleId)) return 'recorded in team.json (role field unsupported by host)';
|
|
66
|
+
if (supportsNativeReasoning(adapter, roleId)) {
|
|
67
|
+
// Global profile adapters expose their native effort field through the
|
|
68
|
+
// profile writer rather than project frontmatter metadata.
|
|
69
|
+
return `host-native model + ${hostModelField(adapter, roleId, 'reasoning_effort') ?? 'reasoning'}`;
|
|
70
|
+
}
|
|
71
|
+
return 'host-native model; reasoning is host-managed';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate a complete schema-version-1 team configuration without changing or
|
|
76
|
+
* stripping fields. Unknown fields remain forward-compatible and are
|
|
77
|
+
* preserved; malformed model values fail closed before installation/upgrade.
|
|
78
|
+
*/
|
|
79
|
+
export function validateTeamConfiguration(team) {
|
|
80
|
+
if (!team || typeof team !== 'object' || Array.isArray(team)) throw new Error('team config must be an object.');
|
|
81
|
+
if (team.schema_version !== 1) throw new Error('team config must contain schema_version 1.');
|
|
82
|
+
if (!team.interaction || typeof team.interaction !== 'object' || Array.isArray(team.interaction)) {
|
|
83
|
+
throw new Error('team config must contain interaction.');
|
|
84
|
+
}
|
|
85
|
+
if (!team.roles || typeof team.roles !== 'object' || Array.isArray(team.roles)) {
|
|
86
|
+
throw new Error('team config must contain roles.');
|
|
87
|
+
}
|
|
88
|
+
for (const role of roleDefinitions) {
|
|
89
|
+
const configuration = team.roles[role.id];
|
|
90
|
+
if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration)) {
|
|
91
|
+
throw new Error(`team config has an invalid ${role.id} role definition.`);
|
|
92
|
+
}
|
|
93
|
+
if (typeof configuration.displayName !== 'string' || !configuration.displayName.trim()) {
|
|
94
|
+
throw new Error(`team config has an invalid ${role.id} displayName.`);
|
|
95
|
+
}
|
|
96
|
+
nullableString(configuration.model, `${role.id}.model`);
|
|
97
|
+
nullableString(configuration.reasoning_effort, `${role.id}.reasoning_effort`);
|
|
98
|
+
if (configuration.enabled !== undefined && typeof configuration.enabled !== 'boolean') {
|
|
99
|
+
throw new Error(`team config has an invalid ${role.id}.enabled value.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return team;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate a user-requested native model patch. This intentionally rejects
|
|
107
|
+
* unknown fields and fields the selected host cannot materialize. Callers
|
|
108
|
+
* that only want to record intent should use team.json directly and report
|
|
109
|
+
* the adapter's modelBindingSummary instead of pretending native support.
|
|
110
|
+
*/
|
|
111
|
+
export function validateRequestedModelPatch(adapter, roleId, patch) {
|
|
112
|
+
if (!roleIds.has(roleId)) throw new Error(`Unknown Triad role: ${roleId}.`);
|
|
113
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('model patch must be an object.');
|
|
114
|
+
for (const field of Object.keys(patch)) {
|
|
115
|
+
if (!modelFields.has(field)) throw new Error(`Unsupported model configuration field: ${field}.`);
|
|
116
|
+
nullableString(patch[field], `${roleId}.${field}`);
|
|
117
|
+
if (field === 'model' && !supportsNativeModel(adapter, roleId)) {
|
|
118
|
+
throw new Error(`Model binding is not natively supported for ${adapter?.id ?? 'the selected host'} role ${roleId}.`);
|
|
119
|
+
}
|
|
120
|
+
if (field === 'reasoning_effort' && !supportsNativeReasoning(adapter, roleId)) {
|
|
121
|
+
throw new Error(`Reasoning effort is not natively supported for ${adapter?.id ?? 'the selected host'} role ${roleId}.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return patch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Apply an explicitly requested model/reasoning patch to an in-memory team
|
|
129
|
+
* document while preserving every unrelated field. Callers still decide when
|
|
130
|
+
* to persist the returned document and must use the adapter binding afterwards.
|
|
131
|
+
*/
|
|
132
|
+
export function mergeTeamModelConfiguration(team, roleId, patch) {
|
|
133
|
+
validateTeamConfiguration(team);
|
|
134
|
+
if (!roleIds.has(roleId)) throw new Error(`Unknown Triad role: ${roleId}.`);
|
|
135
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('model patch must be an object.');
|
|
136
|
+
for (const field of Object.keys(patch)) {
|
|
137
|
+
if (!modelFields.has(field)) throw new Error(`Unsupported model configuration field: ${field}.`);
|
|
138
|
+
nullableString(patch[field], `${roleId}.${field}`);
|
|
139
|
+
}
|
|
140
|
+
const updated = structuredClone(team);
|
|
141
|
+
Object.assign(updated.roles[roleId], patch);
|
|
142
|
+
return updated;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Return only fields that the adapter can materialize in host-managed assets. */
|
|
146
|
+
export function materializedRoleConfiguration(adapter, roleId, configuration = {}) {
|
|
147
|
+
const result = {};
|
|
148
|
+
if (supportsNativeModel(adapter, roleId)) result.model = hostValue(configuration.model);
|
|
149
|
+
if (supportsNativeReasoning(adapter, roleId)) result.reasoning_effort = hostValue(configuration.reasoning_effort);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Translate canonical semantic values into the exact host fields declared by
|
|
155
|
+
* the adapter. Null values are retained so the managed writer can remove a
|
|
156
|
+
* stale host binding and restore the host default.
|
|
157
|
+
*/
|
|
158
|
+
export function materializedHostRoleConfiguration(adapter, roleId, configuration = {}) {
|
|
159
|
+
const semantic = materializedRoleConfiguration(adapter, roleId, configuration);
|
|
160
|
+
const result = {};
|
|
161
|
+
for (const { field, source } of modelFieldMappings(adapter, roleId)) {
|
|
162
|
+
result[field] = semantic[source] ?? null;
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpath, stat, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runProcess } from "./process.mjs";
|
|
5
|
+
|
|
6
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
7
|
+
|
|
8
|
+
function contextError(message, code = "repository_context_invalid") {
|
|
9
|
+
const error = new Error(message);
|
|
10
|
+
error.code = code;
|
|
11
|
+
return error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function inside(root, candidate) {
|
|
15
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function digest(value) {
|
|
19
|
+
return createHash("sha256").update(value).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Required repository skills are deliberately worktree-relative. Reject
|
|
24
|
+
* absolute paths and parent traversal instead of allowing a caller to combine
|
|
25
|
+
* files from the control workspace or another repository root.
|
|
26
|
+
*/
|
|
27
|
+
export function repositoryRelativePath(value, label = "repository skill path") {
|
|
28
|
+
if (typeof value !== "string" || !value.trim()) throw contextError(`${label} must be a non-empty repository-relative path`);
|
|
29
|
+
const normalized = value.trim().replaceAll("\\", "/");
|
|
30
|
+
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
|
31
|
+
throw contextError(`${label} must be repository-relative`);
|
|
32
|
+
}
|
|
33
|
+
const segments = normalized.split("/");
|
|
34
|
+
if (segments.includes("..") || segments.includes("")) {
|
|
35
|
+
throw contextError(`${label} must not contain parent traversal or empty path segments`);
|
|
36
|
+
}
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function gitTopLevel(cwd) {
|
|
41
|
+
const result = await runProcess("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 15_000 });
|
|
42
|
+
if (result.exitCode !== 0) return null;
|
|
43
|
+
const value = result.stdout.trim();
|
|
44
|
+
if (!value) return null;
|
|
45
|
+
return realpath(value).catch(() => path.resolve(value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function shellPwd(cwd) {
|
|
49
|
+
const result = await runProcess("pwd", [], { cwd, timeoutMs: 15_000 });
|
|
50
|
+
return result.exitCode === 0 ? result.stdout.trim() || null : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function issue(code, message, details = {}) {
|
|
54
|
+
return { code, message, ...details };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Inspect the repository context bound to an assignment. This is intentionally
|
|
59
|
+
* read-only and host-neutral: it proves the assigned worktree, its Git root,
|
|
60
|
+
* and every required skill's real path/hash. `checkActualCwd` is used by a
|
|
61
|
+
* role activation; verifier/packet creation leave it false because they run
|
|
62
|
+
* from the control workspace and must not mistake that process cwd for the
|
|
63
|
+
* delegated role cwd.
|
|
64
|
+
*/
|
|
65
|
+
export async function inspectRepositoryContext({
|
|
66
|
+
worktree,
|
|
67
|
+
requiredSkills = [],
|
|
68
|
+
actualCwd = null,
|
|
69
|
+
checkActualCwd = false
|
|
70
|
+
} = {}) {
|
|
71
|
+
if (typeof worktree !== "string" || !worktree.trim()) throw contextError("assigned worktree is required");
|
|
72
|
+
if (!Array.isArray(requiredSkills)) throw contextError("required repository skills must be an array");
|
|
73
|
+
const assignedWorktree = await realpath(worktree).catch(() => {
|
|
74
|
+
throw contextError(`assigned worktree is missing: ${worktree}`);
|
|
75
|
+
});
|
|
76
|
+
const assignedGitTopLevel = await gitTopLevel(assignedWorktree);
|
|
77
|
+
const issues = [];
|
|
78
|
+
if (!assignedGitTopLevel) {
|
|
79
|
+
issues.push(issue("assigned_repository_not_git", "assigned worktree has no resolvable Git top-level", {
|
|
80
|
+
assigned_worktree: assignedWorktree
|
|
81
|
+
}));
|
|
82
|
+
} else if (assignedGitTopLevel !== assignedWorktree) {
|
|
83
|
+
issues.push(issue("assigned_worktree_not_repository_root", "assigned worktree is not the repository Git top-level", {
|
|
84
|
+
assigned_worktree: assignedWorktree,
|
|
85
|
+
assigned_git_top_level: assignedGitTopLevel
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const skills = [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const [index, item] of requiredSkills.entries()) {
|
|
92
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
93
|
+
issues.push(issue("invalid_repository_skill_binding", "repository skill binding must be an object", { index }));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
let relative;
|
|
97
|
+
try { relative = repositoryRelativePath(item.path, `repository skill [${index}] path`); }
|
|
98
|
+
catch (error) {
|
|
99
|
+
issues.push(issue("invalid_repository_skill_path", error.message, { index, path: item.path ?? null }));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (seen.has(relative)) {
|
|
103
|
+
issues.push(issue("duplicate_repository_skill", `repository skill is declared more than once: ${relative}`, { path: relative }));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
seen.add(relative);
|
|
107
|
+
if (typeof item.sha256 !== "string" || !SHA256.test(item.sha256)) {
|
|
108
|
+
issues.push(issue("invalid_repository_skill_hash", `repository skill requires a SHA-256 hash: ${relative}`, { path: relative }));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const lexical = path.resolve(assignedWorktree, relative);
|
|
112
|
+
if (!inside(assignedWorktree, lexical)) {
|
|
113
|
+
issues.push(issue("repository_skill_outside_worktree", `repository skill escapes assigned worktree: ${relative}`, { path: relative }));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let resolved;
|
|
117
|
+
try { resolved = await realpath(lexical); }
|
|
118
|
+
catch {
|
|
119
|
+
issues.push(issue("repository_skill_missing", `repository skill missing: ${relative}`, { path: relative, resolved_path: lexical }));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!inside(assignedWorktree, resolved)) {
|
|
123
|
+
issues.push(issue("repository_skill_symlink_escape", `repository skill resolves outside assigned worktree: ${relative}`, { path: relative, resolved_path: resolved }));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
if (!(await stat(resolved)).isFile()) throw new Error("not a regular file");
|
|
128
|
+
} catch {
|
|
129
|
+
issues.push(issue("repository_skill_not_file", `repository skill is not a regular file: ${relative}`, { path: relative, resolved_path: resolved }));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const actual = digest(await readFile(resolved));
|
|
133
|
+
const skillGitTopLevel = await gitTopLevel(path.dirname(resolved));
|
|
134
|
+
const entry = {
|
|
135
|
+
path: relative,
|
|
136
|
+
resolved_path: resolved,
|
|
137
|
+
sha256: actual,
|
|
138
|
+
repository_root: skillGitTopLevel
|
|
139
|
+
};
|
|
140
|
+
skills.push(entry);
|
|
141
|
+
if (actual.toLowerCase() !== item.sha256.toLowerCase()) {
|
|
142
|
+
issues.push(issue("repository_skill_hash_mismatch", `repository skill hash mismatch: ${relative}`, {
|
|
143
|
+
path: relative,
|
|
144
|
+
expected_sha256: item.sha256,
|
|
145
|
+
actual_sha256: actual
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
if (assignedGitTopLevel && skillGitTopLevel !== assignedGitTopLevel) {
|
|
149
|
+
issues.push(issue("repository_skill_root_mismatch", `repository skill belongs to a different Git root: ${relative}`, {
|
|
150
|
+
path: relative,
|
|
151
|
+
skill_repository_root: skillGitTopLevel,
|
|
152
|
+
assigned_git_top_level: assignedGitTopLevel
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let actual = null;
|
|
158
|
+
if (checkActualCwd) {
|
|
159
|
+
const requested = actualCwd || process.cwd();
|
|
160
|
+
const resolved = await realpath(requested).catch(() => path.resolve(requested));
|
|
161
|
+
const gitRoot = await gitTopLevel(resolved);
|
|
162
|
+
actual = {
|
|
163
|
+
process_cwd: process.cwd(),
|
|
164
|
+
shell_pwd: await shellPwd(process.cwd()),
|
|
165
|
+
resolved_cwd: resolved,
|
|
166
|
+
git_top_level: gitRoot
|
|
167
|
+
};
|
|
168
|
+
if (resolved !== assignedWorktree) {
|
|
169
|
+
issues.push(issue("runtime_cwd_mismatch", "role process cwd does not match assigned worktree", {
|
|
170
|
+
expected_worktree: assignedWorktree,
|
|
171
|
+
actual_cwd: resolved
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
if (assignedGitTopLevel && gitRoot !== assignedGitTopLevel) {
|
|
175
|
+
issues.push(issue("runtime_git_root_mismatch", "role process Git top-level does not match assigned repository", {
|
|
176
|
+
expected_git_top_level: assignedGitTopLevel,
|
|
177
|
+
actual_git_top_level: gitRoot
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
status: issues.length ? "blocked" : "pass",
|
|
184
|
+
assigned_worktree: assignedWorktree,
|
|
185
|
+
assigned_git_top_level: assignedGitTopLevel,
|
|
186
|
+
actual,
|
|
187
|
+
skills,
|
|
188
|
+
issues
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export { SHA256 };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { roleDefinitions } from '../../adapters/registry.mjs';
|
|
2
|
+
import { hostModelField, modelBindingSummary, supportsNativeReasoning } from './model-config.mjs';
|
|
3
|
+
|
|
4
|
+
const ANSI = {
|
|
5
|
+
reset: '\u001b[0m',
|
|
6
|
+
bold: '\u001b[1m',
|
|
7
|
+
dim: '\u001b[2m',
|
|
8
|
+
cyan: '\u001b[36m',
|
|
9
|
+
blue: '\u001b[34m',
|
|
10
|
+
green: '\u001b[32m',
|
|
11
|
+
yellow: '\u001b[33m',
|
|
12
|
+
red: '\u001b[31m'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const toneCodes = {
|
|
16
|
+
title: `${ANSI.bold}${ANSI.cyan}`,
|
|
17
|
+
section: `${ANSI.bold}${ANSI.blue}`,
|
|
18
|
+
value: ANSI.bold,
|
|
19
|
+
success: ANSI.green,
|
|
20
|
+
warning: ANSI.yellow,
|
|
21
|
+
error: ANSI.red,
|
|
22
|
+
hint: ANSI.dim
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function colorEnabled(stream = process.stdout) {
|
|
26
|
+
return Boolean(stream?.isTTY) && process.env.NO_COLOR === undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function paint(value, tone, { stream = process.stdout } = {}) {
|
|
30
|
+
const text = String(value);
|
|
31
|
+
if (!colorEnabled(stream) || !toneCodes[tone]) return text;
|
|
32
|
+
return `${toneCodes[tone]}${text}${ANSI.reset}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function statusText(status, { stream = process.stdout } = {}) {
|
|
36
|
+
const normalized = String(status).toLowerCase();
|
|
37
|
+
if (['ok', 'pass', 'passed', 'configured', 'installed', 'enabled'].includes(normalized) || /^ok\b/.test(normalized)) return paint(status, 'success', { stream });
|
|
38
|
+
if (['warning', 'warn', 'disabled', 'not configured', 'not installed', 'unavailable', 'host default'].includes(normalized) || normalized.startsWith('not installed') || normalized.startsWith('host rule detected')) return paint(status, 'warning', { stream });
|
|
39
|
+
if (['error', 'fail', 'failed', 'invalid', 'incomplete', 'missing'].includes(normalized) || normalized.startsWith('missing ') || normalized.startsWith('invalid ')) return paint(status, 'error', { stream });
|
|
40
|
+
return String(status);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function display(value, fallback = 'host default') {
|
|
44
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function roleModelLine(adapter, role, configuration, stream) {
|
|
48
|
+
const model = display(configuration?.model);
|
|
49
|
+
const parts = [`${role.label.padEnd(14)} ${display(configuration?.displayName, role.label)} — model: ${model}`];
|
|
50
|
+
if (supportsNativeReasoning(adapter, role.id)) {
|
|
51
|
+
const field = hostModelField(adapter, role.id, 'reasoning_effort');
|
|
52
|
+
parts.push(`${field === 'variant' ? 'variant' : 'reasoning'}: ${display(configuration?.reasoning_effort)}`);
|
|
53
|
+
}
|
|
54
|
+
parts.push(`binding: ${modelBindingSummary(adapter, role.id)}`);
|
|
55
|
+
return ` ${parts.join(' — ')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Render the compact confirmation shown before the interactive install. */
|
|
59
|
+
export function formatSetupSummary({ adapter, control, global = false, team, stream = process.stdout }) {
|
|
60
|
+
const interaction = team?.interaction ?? {};
|
|
61
|
+
const lines = [
|
|
62
|
+
paint('Triad+ setup summary', 'title', { stream }),
|
|
63
|
+
`${paint('Host', 'section', { stream })}: ${paint(`${adapter.label} (${adapter.id})`, 'value', { stream })}`,
|
|
64
|
+
`${paint('Control workspace', 'section', { stream })}: ${control}`,
|
|
65
|
+
`${paint('User-level assets', 'section', { stream })}: ${global ? 'yes' : 'no'}`,
|
|
66
|
+
`${paint('Interaction', 'section', { stream })}: ${display(interaction.language, 'English')} — owner ${display(interaction.owner_name, 'Owner')} — ${display(interaction.communication_style, 'professional and concise')}`,
|
|
67
|
+
`${paint('Evaluator+', 'section', { stream })}: ${team?.roles?.evaluator?.enabled === true ? statusText('enabled', { stream }) : statusText('disabled', { stream })}`,
|
|
68
|
+
paint('Roles', 'section', { stream }),
|
|
69
|
+
...roleDefinitions.map((role) => roleModelLine(adapter, role, team?.roles?.[role.id], stream)),
|
|
70
|
+
paint('Confirm installation', 'section', { stream })
|
|
71
|
+
];
|
|
72
|
+
return `${lines.join('\n')}\n`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Render a doctor row while preserving scanner-friendly plain text. */
|
|
76
|
+
export function formatDoctorLine(label, status, { stream = process.stdout } = {}) {
|
|
77
|
+
return `${label.padEnd(14)} ${statusText(status, { stream })}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatDoctorSection(title, { stream = process.stdout } = {}) {
|
|
81
|
+
return paint(title, 'section', { stream });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatModelValue(value) {
|
|
85
|
+
return display(value);
|
|
86
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { resolveAssignmentContext, validateAssignmentPacket } from "./lib/assignment-packet.mjs";
|
|
7
|
+
import { inspectRepositoryContext } from "./lib/repository-context.mjs";
|
|
8
|
+
|
|
9
|
+
function option(argv, name) {
|
|
10
|
+
const index = argv.indexOf(name);
|
|
11
|
+
return index >= 0 ? argv[index + 1] : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function usage() {
|
|
15
|
+
process.stderr.write("Usage: node .triad-runtime/triad-runtime-context.mjs --project <control-workspace> --assignment <assignment.json>\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function issue(code, message) {
|
|
19
|
+
return { code, message };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
25
|
+
usage();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const projectRoot = path.resolve(option(argv, "--project") ?? process.cwd());
|
|
29
|
+
const assignmentOption = option(argv, "--assignment");
|
|
30
|
+
if (!assignmentOption) throw Object.assign(new Error("--assignment is required"), { code: "runtime_context_invalid" });
|
|
31
|
+
const assignmentPath = path.resolve(projectRoot, assignmentOption);
|
|
32
|
+
const assignment = JSON.parse(await readFile(assignmentPath, "utf8"));
|
|
33
|
+
const output = {
|
|
34
|
+
status: "blocked",
|
|
35
|
+
assignment: {
|
|
36
|
+
id: assignment.assignment_id ?? null,
|
|
37
|
+
feature_id: assignment.feature_id ?? null,
|
|
38
|
+
attempt: assignment.attempt ?? null,
|
|
39
|
+
worktree: assignment.worktree ?? null,
|
|
40
|
+
packet_metadata_cwd: null
|
|
41
|
+
},
|
|
42
|
+
repository_mapping: null,
|
|
43
|
+
expected: null,
|
|
44
|
+
actual: null,
|
|
45
|
+
repository_skills: [],
|
|
46
|
+
issues: []
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
const context = await resolveAssignmentContext(assignment, { projectRoot });
|
|
50
|
+
output.assignment.worktree = context.worktree;
|
|
51
|
+
output.repository_mapping = context.repositoryMapping;
|
|
52
|
+
output.expected = {
|
|
53
|
+
worktree: context.worktree,
|
|
54
|
+
git_top_level: context.repositoryContext.assigned_git_top_level,
|
|
55
|
+
repository: context.repository
|
|
56
|
+
};
|
|
57
|
+
if (assignment.assignment_packet_path || assignment.assignment_packet_sha256) {
|
|
58
|
+
const packet = await validateAssignmentPacket(assignment, projectRoot);
|
|
59
|
+
output.assignment.packet_metadata_cwd = packet.metadata.cwd ?? null;
|
|
60
|
+
}
|
|
61
|
+
const inspected = await inspectRepositoryContext({
|
|
62
|
+
worktree: context.worktree,
|
|
63
|
+
requiredSkills: context.requiredSkills,
|
|
64
|
+
actualCwd: process.cwd(),
|
|
65
|
+
checkActualCwd: true
|
|
66
|
+
});
|
|
67
|
+
output.actual = inspected.actual;
|
|
68
|
+
output.repository_skills = inspected.skills;
|
|
69
|
+
output.issues = inspected.issues;
|
|
70
|
+
output.status = inspected.status;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.repositoryContext) {
|
|
73
|
+
output.actual = error.repositoryContext.actual ?? null;
|
|
74
|
+
output.repository_skills = error.repositoryContext.skills ?? [];
|
|
75
|
+
output.issues = error.repositoryContext.issues ?? [];
|
|
76
|
+
}
|
|
77
|
+
output.issues.push(issue(error.code ?? "runtime_context_invalid", error.message));
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
80
|
+
if (output.status !== "pass") process.exitCode = 2;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
84
|
+
main().catch((error) => {
|
|
85
|
+
process.stderr.write(`${error.code ? `${error.code}: ` : ""}${error.message}\n`);
|
|
86
|
+
process.exitCode = 2;
|
|
87
|
+
});
|
|
88
|
+
}
|
package/runtime/triad-verify.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { executeGates, gateSelectionEvidence, loadTrustedGates, resolveGateSelec
|
|
|
9
9
|
import { resolveQualityContract } from "./lib/quality-baseline.mjs";
|
|
10
10
|
import { evaluateScopeContract, parseScopeContract } from "./lib/scope-contract.mjs";
|
|
11
11
|
import { resolveAssignmentContext, validateAssignmentPacket } from "./lib/assignment-packet.mjs";
|
|
12
|
+
import { inspectRepositoryContext } from "./lib/repository-context.mjs";
|
|
12
13
|
|
|
13
14
|
const argv = process.argv.slice(2);
|
|
14
15
|
const option = (name) => {
|
|
@@ -35,23 +36,27 @@ async function sha256File(value) {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
async function validateRepositorySkills(required, worktree) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
39
|
+
const declared = required !== undefined;
|
|
40
|
+
const requiredSkills = declared ? required : [];
|
|
41
|
+
const context = await inspectRepositoryContext({ worktree, requiredSkills });
|
|
42
|
+
if (!declared) return { declared: false, skills: [], context };
|
|
43
|
+
if (!Array.isArray(required) || required.length === 0) {
|
|
44
|
+
const error = new Error("repository skill binding must declare at least one skill");
|
|
45
|
+
error.code = "repository_context_invalid";
|
|
46
|
+
error.repositoryContext = context;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
if (context.status !== "pass") {
|
|
50
|
+
const error = new Error(context.issues.map((item) => item.message).join("; ") || "repository skill binding is invalid");
|
|
51
|
+
error.code = "repository_context_invalid";
|
|
52
|
+
error.repositoryContext = context;
|
|
53
|
+
throw error;
|
|
53
54
|
}
|
|
54
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
declared: true,
|
|
57
|
+
skills: context.skills.map(({ path: relative, sha256 }) => ({ path: relative, sha256 })),
|
|
58
|
+
context
|
|
59
|
+
};
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
function triggerFrom(payload) {
|
|
@@ -71,7 +76,7 @@ async function resolveAssignment(projectRoot, trigger, explicitAssignment) {
|
|
|
71
76
|
return { assignmentPath, assignment: JSON.parse(source), assignmentHash: sha256(source) };
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
async function buildInvalidEvidence({ runId, trigger, assignment, reason, outputPath, failureCode = "verification_context_invalid", gateSelection = null, qualityBaselineFingerprint = null, assignmentPacket = null }) {
|
|
79
|
+
async function buildInvalidEvidence({ runId, trigger, assignment, reason, outputPath, failureCode = "verification_context_invalid", gateSelection = null, qualityBaselineFingerprint = null, assignmentPacket = null, repositorySkills = null }) {
|
|
75
80
|
const evidence = {
|
|
76
81
|
schema_version: 1,
|
|
77
82
|
run_id: runId,
|
|
@@ -90,6 +95,7 @@ async function buildInvalidEvidence({ runId, trigger, assignment, reason, output
|
|
|
90
95
|
assignment_packet: assignmentPacket ?? (assignment?.assignment_packet_path || assignment?.assignment_packet_sha256
|
|
91
96
|
? { path: assignment.assignment_packet_path ?? null, sha256: assignment.assignment_packet_sha256 ?? null }
|
|
92
97
|
: null),
|
|
98
|
+
...(repositorySkills ? { repository_skills: repositorySkills } : {}),
|
|
93
99
|
gates: [],
|
|
94
100
|
required_gates_passed: false,
|
|
95
101
|
status: "invalid_context",
|
|
@@ -159,6 +165,7 @@ async function main() {
|
|
|
159
165
|
let gateSelection = null;
|
|
160
166
|
let qualityBaseline = null;
|
|
161
167
|
let assignmentPacket = null;
|
|
168
|
+
let repositorySkills = null;
|
|
162
169
|
try {
|
|
163
170
|
let assignmentHash;
|
|
164
171
|
({ assignmentPath, assignment, assignmentHash } = await resolveAssignment(projectRoot, trigger, option("--assignment")));
|
|
@@ -188,7 +195,7 @@ async function main() {
|
|
|
188
195
|
qualityBaseline = await resolveQualityContract(assignment, projectRoot);
|
|
189
196
|
if ((await sha256File(prdPath)) !== assignment.expected_prd_sha256) throw new Error("PRD baseline hash mismatch");
|
|
190
197
|
if ((await sha256File(cardPath)) !== assignment.expected_card_sha256) throw new Error("feature card hash mismatch");
|
|
191
|
-
|
|
198
|
+
repositorySkills = await validateRepositorySkills(assignment.required_repository_skills, worktree);
|
|
192
199
|
const before = await calculateCandidateFingerprint(worktree);
|
|
193
200
|
const branch = await worktreeBranch(worktree);
|
|
194
201
|
if (assignment.expected_branch && assignment.expected_branch !== branch) throw new Error("worktree branch does not match assignment");
|
|
@@ -296,6 +303,7 @@ async function main() {
|
|
|
296
303
|
gateSelection,
|
|
297
304
|
qualityBaselineFingerprint: qualityBaseline?.fingerprint ?? null,
|
|
298
305
|
assignmentPacket,
|
|
306
|
+
repositorySkills: repositorySkills ?? error.repositoryContext ?? null,
|
|
299
307
|
});
|
|
300
308
|
process.stdout.write(`${JSON.stringify({ run_id: runId, status: evidence.status, evidence: outputPath ?? null })}\n`);
|
|
301
309
|
process.exitCode = 3;
|
|
@@ -37,6 +37,20 @@ SHA-256 values in the report as a **repository-skill attestation**. If a bound
|
|
|
37
37
|
skill is missing or its hash differs, stop and report the mismatch; do not
|
|
38
38
|
replace it with external context or a similarly named skill.
|
|
39
39
|
|
|
40
|
+
Before editing, prove the runtime context from the actual activation cwd with
|
|
41
|
+
the installed control-plane diagnostic:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
node /absolute/path/to/control-workspace/.triad-runtime/triad-runtime-context.mjs \
|
|
45
|
+
--project /absolute/path/to/control-workspace \
|
|
46
|
+
--assignment /absolute/path/to/control-workspace/.loop/runtime/assignments/<assignment-file>.json
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Record its `process_cwd`, `shell_pwd`, `git_top_level`, assigned worktree, and
|
|
50
|
+
resolved repository-skill paths/hashes. A non-zero result, a cwd/root mismatch,
|
|
51
|
+
or a missing/mutated skill is an invalid runtime context; stop before editing
|
|
52
|
+
and do not substitute a same-named control-workspace or global skill.
|
|
53
|
+
|
|
40
54
|
Implement the smallest complete change and focused tests. Run useful local
|
|
41
55
|
checks and measure declared criteria. Report changed files, tests, exact command
|
|
42
56
|
results, metrics, worktree/branch, risks, and blockers to the Orchestrator.
|
|
@@ -128,6 +128,19 @@ fail-closed escalation; do not dispatch a Developer or consume retry budget.
|
|
|
128
128
|
It atomically creates (or verifies) the immutable packet, binds its path and
|
|
129
129
|
SHA-256 to the assignment, and returns explicit `dispatch.cwd`, control
|
|
130
130
|
workspace, repository, branch, card, packet, and mandatory-skill paths.
|
|
131
|
+
Require each delegated Developer and Reviewer to run the installed runtime
|
|
132
|
+
context diagnostic from its actual activation cwd before reading repository
|
|
133
|
+
skills:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
node /absolute/path/to/control-workspace/.triad-runtime/triad-runtime-context.mjs \
|
|
137
|
+
--project /absolute/path/to/control-workspace \
|
|
138
|
+
--assignment /absolute/path/to/control-workspace/.loop/runtime/assignments/<assignment-file>.json
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Collect its process `cwd`, shell `pwd`, Git top-level, repository mapping,
|
|
142
|
+
and resolved skill paths/hashes. A mismatch or missing skill blocks that role;
|
|
143
|
+
do not substitute control-workspace/global copies or continue to review.
|
|
131
144
|
3. Dispatch the Developer with `cwd`/`workdir` exactly equal to the returned
|
|
132
145
|
assigned product worktree. Pass the card and Assignment Packet as the
|
|
133
146
|
primary contract, followed by the mandatory skill paths and any prior
|