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.
@@ -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
+ }
@@ -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
- if (required === undefined) return { declared: false, skills: [] };
39
- if (!Array.isArray(required) || required.length === 0) throw new Error("repository skill binding must declare at least one skill");
40
- const root = await realpath(worktree);
41
- const skills = [];
42
- for (const item of required) {
43
- if (!item || typeof item.path !== "string" || typeof item.sha256 !== "string") {
44
- throw new Error("repository skill binding entries require path and sha256");
45
- }
46
- const candidate = path.resolve(root, item.path);
47
- if (!candidate.startsWith(`${root}${path.sep}`)) throw new Error("repository skill path escapes worktree");
48
- try { await access(candidate); }
49
- catch { throw new Error(`repository skill missing: ${item.path}`); }
50
- const actual = await sha256File(candidate);
51
- if (actual !== item.sha256) throw new Error(`repository skill hash mismatch: ${item.path}`);
52
- skills.push({ path: item.path, sha256: actual });
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 { declared: true, skills };
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
- const repositorySkills = await validateRepositorySkills(assignment.required_repository_skills, worktree);
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