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.
- package/CHANGELOG.md +36 -0
- package/README.md +21 -12
- package/adapters/antigravity/.agents/agents/triad-developer/agent.md +7 -3
- package/adapters/antigravity/.agents/agents/triad-orchestrator/agent.md +5 -0
- package/adapters/antigravity/.agents/agents/triad-reviewer/agent.md +7 -2
- package/adapters/antigravity/.agents/skills/triad/SKILL.md +22 -0
- package/adapters/antigravity/README.md +1 -1
- package/adapters/antigravity/install.sh +1 -1
- package/adapters/claude-code/.claude/agents/triad-developer.md +9 -4
- package/adapters/claude-code/.claude/agents/triad-reviewer.md +10 -5
- package/adapters/claude-code/.claude/commands/triad.md +22 -0
- package/adapters/claude-code/README.md +12 -4
- package/adapters/claude-code/install.sh +1 -1
- package/adapters/codex/README.md +1 -1
- package/adapters/codex/install.sh +3 -3
- package/adapters/codex/prompts/triad.md +28 -0
- package/adapters/copilot/.github/agents/triad-developer.agent.md +12 -6
- package/adapters/copilot/.github/agents/triad-evaluator.agent.md +2 -1
- package/adapters/copilot/.github/agents/triad-orchestrator.agent.md +15 -1
- package/adapters/copilot/.github/agents/triad-reviewer.agent.md +11 -5
- package/adapters/copilot/.github/skills/triad/SKILL.md +23 -0
- package/adapters/hermes/install.sh +1 -1
- package/adapters/hermes/skills/triad/SKILL.md +18 -0
- package/adapters/opencode/.opencode/agents/triad-developer.md +23 -4
- package/adapters/opencode/.opencode/agents/triad-orchestrator.md +16 -0
- package/adapters/opencode/.opencode/agents/triad-reviewer.md +25 -3
- package/adapters/opencode/.opencode/commands/triad.md +21 -0
- package/adapters/opencode/README.md +29 -5
- package/adapters/opencode/install.sh +1 -1
- package/adapters/registry.mjs +23 -1
- package/bin/triad-plus.js +210 -38
- package/docs/architecture.md +8 -0
- package/docs/assignment-packets.md +35 -0
- package/docs/bmad-integration.md +187 -82
- package/docs/configuration.md +29 -0
- package/docs/npx-installation.md +20 -0
- package/docs/opencode-replication.md +8 -0
- package/docs/operating-guide.it.md +8 -0
- package/docs/operating-guide.md +7 -0
- package/docs/runtimes.md +1 -1
- package/docs/verification.md +20 -0
- package/integrations/bmad/README.md +17 -9
- package/integrations/bmad/epics-parser.mjs +697 -0
- package/integrations/bmad/story-importer.mjs +143 -5
- package/package.json +2 -2
- package/runtime/lib/assignment-packet.mjs +553 -0
- 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-assignment-packet.mjs +62 -0
- package/runtime/triad-bmad-intake.mjs +150 -0
- package/runtime/triad-runtime-context.mjs +88 -0
- package/runtime/triad-verify.mjs +41 -21
- package/schemas/verification-evidence.schema.json +8 -0
- package/skills/triad-loop-bootstrap/SKILL.md +39 -4
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +14 -1
- package/skills/triad-loop-developer/SKILL.md +33 -8
- package/skills/triad-loop-orchestrator/SKILL.md +96 -11
- package/skills/triad-loop-reviewer/SKILL.md +31 -5
- 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,62 @@
|
|
|
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 { writeAssignmentPacket } from "./lib/assignment-packet.mjs";
|
|
7
|
+
|
|
8
|
+
function option(argv, name) {
|
|
9
|
+
const index = argv.indexOf(name);
|
|
10
|
+
return index >= 0 ? argv[index + 1] : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function usage() {
|
|
14
|
+
process.stderr.write(`Usage: node .triad-runtime/triad-assignment-packet.mjs --project <control-workspace> --assignment <assignment.json> [--output <packet.md>]\n`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
20
|
+
usage();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const project = option(argv, "--project") ?? process.cwd();
|
|
24
|
+
const assignmentOption = option(argv, "--assignment");
|
|
25
|
+
if (!assignmentOption) throw Object.assign(new Error("--assignment is required"), { code: "assignment_packet_invalid" });
|
|
26
|
+
const projectRoot = path.resolve(project);
|
|
27
|
+
const assignmentPath = path.resolve(projectRoot, assignmentOption);
|
|
28
|
+
const assignment = JSON.parse(await readFile(assignmentPath, "utf8"));
|
|
29
|
+
const output = option(argv, "--output");
|
|
30
|
+
const result = await writeAssignmentPacket(assignment, {
|
|
31
|
+
projectRoot,
|
|
32
|
+
assignmentPath,
|
|
33
|
+
packetPath: output ? path.resolve(projectRoot, output) : null,
|
|
34
|
+
});
|
|
35
|
+
process.stdout.write(`${JSON.stringify({
|
|
36
|
+
status: "ready",
|
|
37
|
+
assignment: {
|
|
38
|
+
id: result.assignment.assignment_id,
|
|
39
|
+
feature_id: result.assignment.feature_id,
|
|
40
|
+
attempt: result.assignment.attempt,
|
|
41
|
+
},
|
|
42
|
+
packet_path: result.assignment.assignment_packet_path,
|
|
43
|
+
packet_absolute_path: result.packetPath,
|
|
44
|
+
packet_sha256: result.sha256,
|
|
45
|
+
dispatch: {
|
|
46
|
+
cwd: result.context.cwd,
|
|
47
|
+
control_workspace: result.context.controlWorkspace,
|
|
48
|
+
repository: result.context.repository,
|
|
49
|
+
branch: result.context.branch,
|
|
50
|
+
card_path: result.assignment.card_path,
|
|
51
|
+
packet_path: result.assignment.assignment_packet_path,
|
|
52
|
+
mandatory_skill_paths: result.context.requiredSkills.map((skill) => skill.path),
|
|
53
|
+
},
|
|
54
|
+
})}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
58
|
+
main().catch((error) => {
|
|
59
|
+
process.stderr.write(`${error.code ? `${error.code}: ` : ""}${error.message}\n`);
|
|
60
|
+
process.exitCode = 2;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { access } from 'node:fs/promises';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const runtimeDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
async function loadIntegration() {
|
|
11
|
+
const candidates = [
|
|
12
|
+
path.join(runtimeDirectory, 'integrations', 'bmad', 'epics-parser.mjs'),
|
|
13
|
+
path.resolve(runtimeDirectory, '..', 'integrations', 'bmad', 'epics-parser.mjs')
|
|
14
|
+
];
|
|
15
|
+
for (const candidate of candidates) {
|
|
16
|
+
try {
|
|
17
|
+
await access(candidate);
|
|
18
|
+
return import(pathToFileURL(candidate).href);
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
throw new Error('BMAD native intake assets are not installed');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function option(argv, name) {
|
|
25
|
+
const index = argv.indexOf(name);
|
|
26
|
+
return index >= 0 ? argv[index + 1] : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function repeated(argv, name) {
|
|
30
|
+
const values = [];
|
|
31
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
32
|
+
if (argv[index] !== name) continue;
|
|
33
|
+
const value = argv[index + 1];
|
|
34
|
+
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`);
|
|
35
|
+
values.push(value);
|
|
36
|
+
index += 1;
|
|
37
|
+
}
|
|
38
|
+
return values;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function usage() {
|
|
42
|
+
process.stderr.write(`Native BMAD epics.md intake (integration/automation API)
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
node .triad-runtime/triad-bmad-intake.mjs --source <epics.md>
|
|
46
|
+
node .triad-runtime/triad-bmad-intake.mjs --source <epics.md> --project <control> --output <features-dir> [--repository <id>] [--required-gate <story-id>=<gate-id>] [--depends-on <story-id>=<card-id>]
|
|
47
|
+
|
|
48
|
+
Without --output the command performs read-only ingestion and prints canonical
|
|
49
|
+
Stories. With --output it resolves Triad execution readiness and writes normal
|
|
50
|
+
Cards plus integration-side provenance. This command is an internal
|
|
51
|
+
deterministic primitive; the normal user entry point is the host's /triad
|
|
52
|
+
workflow with the epics.md path.
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function keyed(values, label) {
|
|
57
|
+
const result = {};
|
|
58
|
+
for (const value of values) {
|
|
59
|
+
const separator = value.indexOf('=');
|
|
60
|
+
if (separator <= 0 || separator === value.length - 1) throw new Error(`${label} must use STORY-ID=VALUE`);
|
|
61
|
+
const storyId = value.slice(0, separator).trim();
|
|
62
|
+
const item = value.slice(separator + 1).trim();
|
|
63
|
+
if (!storyId || !item) throw new Error(`${label} must use STORY-ID=VALUE`);
|
|
64
|
+
(result[storyId] ??= []).push(item);
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
const argv = process.argv.slice(2);
|
|
71
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
72
|
+
usage();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const source = option(argv, '--source');
|
|
76
|
+
if (!source) throw new Error('--source is required');
|
|
77
|
+
const output = option(argv, '--output');
|
|
78
|
+
const project = option(argv, '--project');
|
|
79
|
+
const repository = option(argv, '--repository');
|
|
80
|
+
const requiredGatesByStory = keyed(repeated(argv, '--required-gate'), '--required-gate');
|
|
81
|
+
const dependsOnByStory = keyed(repeated(argv, '--depends-on'), '--depends-on');
|
|
82
|
+
const { ingestBmadEpics, writeImportedEpics } = await loadIntegration();
|
|
83
|
+
if (output && !project) throw new Error('--project is required when --output is supplied');
|
|
84
|
+
const options = {
|
|
85
|
+
sourcePath: source,
|
|
86
|
+
projectRoot: project,
|
|
87
|
+
targetRepository: repository,
|
|
88
|
+
requiredGatesByStory,
|
|
89
|
+
dependsOnByStory
|
|
90
|
+
};
|
|
91
|
+
if (output) {
|
|
92
|
+
const result = await writeImportedEpics({ ...options, outputDirectory: output });
|
|
93
|
+
process.stdout.write(`${JSON.stringify({
|
|
94
|
+
status: 'ready',
|
|
95
|
+
source_kind: result.source_kind,
|
|
96
|
+
source_path: result.source_path,
|
|
97
|
+
source_sha256: result.source_sha256,
|
|
98
|
+
epic_count: result.epics.length,
|
|
99
|
+
story_count: result.stories.length,
|
|
100
|
+
card_count: result.outputs.length,
|
|
101
|
+
cards: result.outputs.map(({ story, cardPath, provenancePath, provenance }) => ({
|
|
102
|
+
story_id: story.id,
|
|
103
|
+
epic_id: story.epicId,
|
|
104
|
+
card_path: cardPath,
|
|
105
|
+
provenance_path: provenancePath,
|
|
106
|
+
card_sha256: provenance.card_sha256
|
|
107
|
+
}))
|
|
108
|
+
})}\n`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const result = await ingestBmadEpics(options);
|
|
112
|
+
process.stdout.write(`${JSON.stringify({
|
|
113
|
+
status: 'ingested',
|
|
114
|
+
source_kind: result.source_kind,
|
|
115
|
+
source_path: result.source_path,
|
|
116
|
+
source_sha256: result.source_sha256,
|
|
117
|
+
epic_count: result.epics.length,
|
|
118
|
+
story_count: result.stories.length,
|
|
119
|
+
stories: result.stories.map((story) => ({
|
|
120
|
+
epic_id: story.epicId,
|
|
121
|
+
epic_title: story.epicTitle,
|
|
122
|
+
story_id: story.id,
|
|
123
|
+
title: story.title,
|
|
124
|
+
status: story.status,
|
|
125
|
+
ingestion_ready: story.ingestionReady,
|
|
126
|
+
execution_ready: story.executionReady,
|
|
127
|
+
source_heading: story.sourceHeading,
|
|
128
|
+
source_range: story.sourceRange
|
|
129
|
+
}))
|
|
130
|
+
})}\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// `mktemp`/system temporary roots on macOS are commonly reached through the
|
|
134
|
+
// `/var` -> `/private/var` symlink. Compare canonical paths so the installed
|
|
135
|
+
// runtime CLI still runs when invoked from such a control workspace.
|
|
136
|
+
function isMainModule() {
|
|
137
|
+
if (!process.argv[1]) return false;
|
|
138
|
+
try {
|
|
139
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isMainModule()) {
|
|
146
|
+
main().catch((error) => {
|
|
147
|
+
process.stderr.write(`${error.code ? `${error.code}: ` : ''}${error.message}\n`);
|
|
148
|
+
process.exitCode = 2;
|
|
149
|
+
});
|
|
150
|
+
}
|