triad-plus 1.8.0 → 1.9.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 +22 -0
- package/README.md +14 -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/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 +11 -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/skills/triad/SKILL.md +18 -0
- package/adapters/opencode/.opencode/agents/triad-developer.md +9 -4
- package/adapters/opencode/.opencode/agents/triad-orchestrator.md +15 -0
- package/adapters/opencode/.opencode/agents/triad-reviewer.md +10 -3
- package/adapters/opencode/.opencode/commands/triad.md +21 -0
- package/adapters/opencode/README.md +19 -4
- package/adapters/registry.mjs +2 -0
- package/bin/triad-plus.js +8 -2
- package/docs/architecture.md +8 -0
- package/docs/assignment-packets.md +35 -0
- package/docs/bmad-integration.md +187 -82
- package/docs/configuration.md +9 -0
- package/docs/npx-installation.md +4 -0
- package/docs/operating-guide.it.md +8 -0
- package/docs/operating-guide.md +7 -0
- 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 +516 -0
- package/runtime/triad-assignment-packet.mjs +62 -0
- package/runtime/triad-bmad-intake.mjs +150 -0
- package/runtime/triad-verify.mjs +16 -4
- 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 +19 -8
- package/skills/triad-loop-orchestrator/SKILL.md +83 -11
- package/skills/triad-loop-reviewer/SKILL.md +15 -5
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { writeAtomicJson } from "./evidence.mjs";
|
|
5
|
+
import { worktreeBranch } from "./fingerprint.mjs";
|
|
6
|
+
|
|
7
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
8
|
+
const PACKET_START = "<!-- triad-plus-assignment-packet:start -->";
|
|
9
|
+
const PACKET_END = "<!-- triad-plus-assignment-packet:end -->";
|
|
10
|
+
|
|
11
|
+
function packetError(code, message) {
|
|
12
|
+
const error = new Error(message);
|
|
13
|
+
error.code = code;
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function objectLike(value) {
|
|
18
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function digest(value) {
|
|
22
|
+
return createHash("sha256").update(value).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function canonicalize(value) {
|
|
26
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
27
|
+
if (!objectLike(value)) return value;
|
|
28
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeText(value) {
|
|
32
|
+
return String(value ?? "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nonEmpty(value) {
|
|
36
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeArray(value) {
|
|
40
|
+
if (!Array.isArray(value)) return [];
|
|
41
|
+
return value
|
|
42
|
+
.map((item) => {
|
|
43
|
+
if (typeof item === "string") return item.trim();
|
|
44
|
+
if (objectLike(item)) {
|
|
45
|
+
const pathValue = nonEmpty(item.path);
|
|
46
|
+
const text = nonEmpty(item.text ?? item.excerpt ?? item.value);
|
|
47
|
+
if (pathValue && text) return { path: pathValue, text };
|
|
48
|
+
if (text) return text;
|
|
49
|
+
return JSON.stringify(canonicalize(item));
|
|
50
|
+
}
|
|
51
|
+
return String(item);
|
|
52
|
+
})
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function firstDefined(...values) {
|
|
57
|
+
return values.find((value) => value !== undefined && value !== null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function contextObject(assignment) {
|
|
61
|
+
const context = objectLike(assignment.context) ? assignment.context : {};
|
|
62
|
+
const prompt = objectLike(assignment.assignment_prompt) ? assignment.assignment_prompt : {};
|
|
63
|
+
return { context, prompt };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeSlug(value, fallback = "card") {
|
|
67
|
+
const slug = String(value ?? "")
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
70
|
+
.replace(/^-+|-+$/g, "");
|
|
71
|
+
return slug || fallback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function relativeProjectPath(root, value, label, { allowAbsolute = true } = {}) {
|
|
75
|
+
if (typeof value !== "string" || !value.trim()) throw packetError("assignment_packet_invalid", `${label} must be a non-empty path`);
|
|
76
|
+
if (!allowAbsolute && path.isAbsolute(value)) throw packetError("assignment_packet_invalid", `${label} must be project-relative`);
|
|
77
|
+
const resolved = path.resolve(root, value);
|
|
78
|
+
if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) {
|
|
79
|
+
throw packetError("assignment_packet_invalid", `${label} escapes the control workspace`);
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function declaredPath(root, value) {
|
|
85
|
+
return path.isAbsolute(value) ? value : path.resolve(root, value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assignmentRepositoryId(assignment) {
|
|
89
|
+
const explicit = firstDefined(assignment.repository_id, assignment.repository, assignment.target_repository, assignment.assigned_repository);
|
|
90
|
+
return typeof explicit === "string" && explicit.trim() ? explicit.trim() : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scalarValue(value) {
|
|
94
|
+
return String(value ?? "").trim().replace(/^['"]|['"]$/g, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeRepositoryMappings(value) {
|
|
98
|
+
if (!Array.isArray(value)) return [];
|
|
99
|
+
return value
|
|
100
|
+
.filter((entry) => objectLike(entry))
|
|
101
|
+
.map((entry) => ({
|
|
102
|
+
id: scalarValue(entry.id),
|
|
103
|
+
path: typeof entry.path === "string" ? scalarValue(entry.path) : null,
|
|
104
|
+
worktree: typeof entry.worktree === "string" ? scalarValue(entry.worktree) : null,
|
|
105
|
+
}))
|
|
106
|
+
.filter((entry) => entry.id && (entry.path || entry.worktree));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read only the repository mapping subset needed to authorize a declared
|
|
111
|
+
* external worktree. This accepts project.yaml's stable JSON/YAML shapes and
|
|
112
|
+
* is deliberately not a second general-purpose project parser.
|
|
113
|
+
*/
|
|
114
|
+
function parseRepositoryMappings(source) {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(source);
|
|
117
|
+
const root = objectLike(parsed) ? parsed : {};
|
|
118
|
+
const project = objectLike(root.project) ? root.project : {};
|
|
119
|
+
const repositories = Array.isArray(root.repositories)
|
|
120
|
+
? root.repositories
|
|
121
|
+
: Array.isArray(project.repositories) ? project.repositories : [];
|
|
122
|
+
return normalizeRepositoryMappings(repositories);
|
|
123
|
+
} catch {}
|
|
124
|
+
|
|
125
|
+
const repositories = [];
|
|
126
|
+
let inRepositories = false;
|
|
127
|
+
let repositoryIndent = null;
|
|
128
|
+
let current = null;
|
|
129
|
+
let repositoryEntryIndent = null;
|
|
130
|
+
let repositoryPropertyIndent = null;
|
|
131
|
+
const mappingStack = [];
|
|
132
|
+
const finishRepositories = () => {
|
|
133
|
+
if (current) repositories.push(current);
|
|
134
|
+
current = null;
|
|
135
|
+
inRepositories = false;
|
|
136
|
+
repositoryIndent = null;
|
|
137
|
+
repositoryEntryIndent = null;
|
|
138
|
+
repositoryPropertyIndent = null;
|
|
139
|
+
};
|
|
140
|
+
const updateMappingStack = (key, indentation) => {
|
|
141
|
+
while (mappingStack.length && mappingStack[mappingStack.length - 1].indent >= indentation) mappingStack.pop();
|
|
142
|
+
mappingStack.push({ key: key.toLowerCase(), indent: indentation });
|
|
143
|
+
};
|
|
144
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
145
|
+
const line = rawLine.replace(/\s+#.*$/, "");
|
|
146
|
+
const indentation = line.search(/\S/);
|
|
147
|
+
const trimmed = line.trim();
|
|
148
|
+
if (!trimmed || indentation < 0) continue;
|
|
149
|
+
const itemMatch = line.match(/^(\s*)-\s*(?:(\w[\w-]*):\s*(.*?)\s*)?$/);
|
|
150
|
+
if (inRepositories && !(itemMatch && itemMatch[1].length > repositoryIndent) && indentation <= repositoryIndent) {
|
|
151
|
+
finishRepositories();
|
|
152
|
+
}
|
|
153
|
+
const propertyMatch = line.match(/^(\s*)([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/);
|
|
154
|
+
if (propertyMatch && propertyMatch[2].toLowerCase() === "repositories" && !propertyMatch[3]) {
|
|
155
|
+
while (mappingStack.length && mappingStack[mappingStack.length - 1].indent >= indentation) mappingStack.pop();
|
|
156
|
+
const parent = mappingStack[mappingStack.length - 1] ?? null;
|
|
157
|
+
const repositoriesIndent = propertyMatch[1].length;
|
|
158
|
+
const rootRepositories = repositoriesIndent === 0;
|
|
159
|
+
const projectRepositories = parent?.key === "project" && parent.indent === 0;
|
|
160
|
+
updateMappingStack(propertyMatch[2], repositoriesIndent);
|
|
161
|
+
if (rootRepositories || projectRepositories) {
|
|
162
|
+
if (inRepositories) finishRepositories();
|
|
163
|
+
inRepositories = true;
|
|
164
|
+
repositoryIndent = repositoriesIndent;
|
|
165
|
+
repositoryEntryIndent = null;
|
|
166
|
+
repositoryPropertyIndent = null;
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (itemMatch && inRepositories && itemMatch[1].length > repositoryIndent) {
|
|
171
|
+
const itemIndent = itemMatch[1].length;
|
|
172
|
+
if (repositoryEntryIndent !== null && itemIndent !== repositoryEntryIndent) continue;
|
|
173
|
+
if (current) repositories.push(current);
|
|
174
|
+
current = {};
|
|
175
|
+
repositoryEntryIndent = itemIndent;
|
|
176
|
+
repositoryPropertyIndent = itemMatch[2] ? itemIndent + 2 : null;
|
|
177
|
+
if (itemMatch[2]) current[itemMatch[2]] = scalarValue(itemMatch[3]);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (inRepositories && current && propertyMatch && repositoryEntryIndent !== null) {
|
|
181
|
+
const propertyIndent = propertyMatch[1].length;
|
|
182
|
+
if (repositoryPropertyIndent === null) repositoryPropertyIndent = propertyIndent;
|
|
183
|
+
if (propertyIndent === repositoryPropertyIndent) current[propertyMatch[2]] = scalarValue(propertyMatch[3]);
|
|
184
|
+
}
|
|
185
|
+
if (propertyMatch) updateMappingStack(propertyMatch[2], propertyMatch[1].length);
|
|
186
|
+
}
|
|
187
|
+
if (inRepositories) finishRepositories();
|
|
188
|
+
return normalizeRepositoryMappings(repositories);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function projectRepositoryMappings(root) {
|
|
192
|
+
try {
|
|
193
|
+
return parseRepositoryMappings(await readFile(path.join(root, "project.yaml"), "utf8"));
|
|
194
|
+
} catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function matchingRepositoryMappings(root, worktree) {
|
|
200
|
+
const mappings = await projectRepositoryMappings(root);
|
|
201
|
+
const matches = [];
|
|
202
|
+
for (const mapping of mappings) {
|
|
203
|
+
const declared = mapping.worktree || mapping.path;
|
|
204
|
+
if (!declared) continue;
|
|
205
|
+
const candidate = await realpath(declaredPath(root, declared)).catch(() => null);
|
|
206
|
+
if (candidate === worktree) matches.push(mapping);
|
|
207
|
+
}
|
|
208
|
+
return matches;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function resolveProjectRepository(root, assignment, worktree, external) {
|
|
212
|
+
const explicit = assignmentRepositoryId(assignment);
|
|
213
|
+
const matches = await matchingRepositoryMappings(root, worktree);
|
|
214
|
+
if (external) {
|
|
215
|
+
if (matches.length === 0) {
|
|
216
|
+
throw packetError("assignment_packet_invalid", "assigned external worktree is not declared by a project.yaml repository mapping");
|
|
217
|
+
}
|
|
218
|
+
if (matches.length > 1) {
|
|
219
|
+
throw packetError("assignment_packet_invalid", "assigned external worktree matches multiple project.yaml repository mappings");
|
|
220
|
+
}
|
|
221
|
+
if (explicit && explicit !== matches[0].id) {
|
|
222
|
+
throw packetError("assignment_packet_invalid", "assigned repository does not match the project.yaml worktree mapping: " + explicit);
|
|
223
|
+
}
|
|
224
|
+
return matches[0].id;
|
|
225
|
+
}
|
|
226
|
+
if (explicit) return explicit;
|
|
227
|
+
return matches.length === 1 ? matches[0].id : "declared-worktree";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function section(source, names) {
|
|
231
|
+
const wanted = new Set(names.map((name) => name.toLowerCase()));
|
|
232
|
+
const lines = source.split("\n");
|
|
233
|
+
const output = [];
|
|
234
|
+
let active = false;
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
237
|
+
if (heading) {
|
|
238
|
+
const title = heading[1].replace(/[#`]/g, "").trim().toLowerCase();
|
|
239
|
+
active = wanted.has(title);
|
|
240
|
+
if (active) output.push(line.trimEnd());
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (active) output.push(line.trimEnd());
|
|
244
|
+
}
|
|
245
|
+
return normalizeText(output.join("\n"));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function compactSection(source, names, fallback) {
|
|
249
|
+
const value = section(source, names);
|
|
250
|
+
return value || fallback;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function asLines(value, fallback = "Not supplied in the assignment packet context.") {
|
|
254
|
+
const entries = normalizeArray(value);
|
|
255
|
+
return entries.length ? entries : [fallback];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderList(title, values) {
|
|
259
|
+
const lines = [`## ${title}`];
|
|
260
|
+
for (const value of values) {
|
|
261
|
+
if (objectLike(value)) {
|
|
262
|
+
lines.push(`- ${value.path}:`);
|
|
263
|
+
lines.push(` ${normalizeText(value.text).replaceAll("\n", "\n ")}`);
|
|
264
|
+
} else {
|
|
265
|
+
lines.push(`- ${normalizeText(value).replaceAll("\n", "\n ")}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return lines.join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function renderBlock(title, value, fallback = "Not supplied in the assignment packet context.") {
|
|
272
|
+
const text = normalizeText(value) || fallback;
|
|
273
|
+
return `## ${title}\n\n${text}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderContextValue(value, fallback = "Not supplied in the assignment packet context.") {
|
|
277
|
+
if (typeof value === "string") return normalizeText(value) || fallback;
|
|
278
|
+
const entries = asLines(value, fallback);
|
|
279
|
+
return entries.map((entry) => objectLike(entry) ? `${entry.path}:\n${normalizeText(entry.text)}` : normalizeText(entry)).join("\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function packetRelativePath(root, target) {
|
|
283
|
+
const relative = path.relative(root, target).replaceAll(path.sep, "/");
|
|
284
|
+
return relative || ".";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function defaultAssignmentPacketPath(assignment) {
|
|
288
|
+
const feature = safeSlug(assignment?.feature_id);
|
|
289
|
+
const attempt = Number.isInteger(assignment?.attempt) && assignment.attempt > 0
|
|
290
|
+
? String(assignment.attempt).padStart(3, "0")
|
|
291
|
+
: "001";
|
|
292
|
+
return `.loop/runtime/assignments/${feature}-attempt-${attempt}.md`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolve the operational context once, keeping control paths explicit while
|
|
297
|
+
* making the product worktree the delegated agent's cwd.
|
|
298
|
+
*/
|
|
299
|
+
export async function resolveAssignmentContext(assignment, { projectRoot = process.cwd() } = {}) {
|
|
300
|
+
if (!objectLike(assignment)) throw packetError("assignment_packet_invalid", "assignment must be a JSON object");
|
|
301
|
+
const root = await realpath(projectRoot);
|
|
302
|
+
if (assignment.project_root) {
|
|
303
|
+
const declaredRoot = await realpath(declaredPath(root, assignment.project_root)).catch(() => null);
|
|
304
|
+
if (declaredRoot !== root) throw packetError("assignment_packet_invalid", "assignment project root does not match control workspace");
|
|
305
|
+
}
|
|
306
|
+
if (!assignment.worktree || typeof assignment.worktree !== "string") {
|
|
307
|
+
throw packetError("assignment_packet_invalid", "assignment worktree is required");
|
|
308
|
+
}
|
|
309
|
+
const declaredWorktree = declaredPath(root, assignment.worktree);
|
|
310
|
+
let worktree;
|
|
311
|
+
try { worktree = await realpath(declaredWorktree); }
|
|
312
|
+
catch { throw packetError("assignment_packet_invalid", `assigned worktree is missing: ${assignment.worktree}`); }
|
|
313
|
+
const external = !worktree.startsWith(`${root}${path.sep}`);
|
|
314
|
+
const branch = await worktreeBranch(worktree);
|
|
315
|
+
if (assignment.expected_branch && assignment.expected_branch !== branch) {
|
|
316
|
+
throw packetError("assignment_packet_invalid", `worktree branch does not match assignment: ${branch}`);
|
|
317
|
+
}
|
|
318
|
+
const repository = await resolveProjectRepository(root, assignment, worktree, external);
|
|
319
|
+
return {
|
|
320
|
+
projectRoot: root,
|
|
321
|
+
controlWorkspace: root,
|
|
322
|
+
worktree,
|
|
323
|
+
cwd: worktree,
|
|
324
|
+
external,
|
|
325
|
+
branch,
|
|
326
|
+
repository,
|
|
327
|
+
declaredWorktree: assignment.worktree,
|
|
328
|
+
cardPath: assignment.card_path ? relativeProjectPath(root, assignment.card_path, "card path") : null,
|
|
329
|
+
prdPath: assignment.prd_path ? relativeProjectPath(root, assignment.prd_path, "PRD path") : null,
|
|
330
|
+
gatesPath: assignment.gates_path ? relativeProjectPath(root, assignment.gates_path, "gates path") : null,
|
|
331
|
+
requiredSkills: Array.isArray(assignment.required_repository_skills) ? assignment.required_repository_skills : [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function packetMetadata(assignment, context, packetPath) {
|
|
336
|
+
const packetRelative = packetRelativePath(context.projectRoot, packetPath);
|
|
337
|
+
return canonicalize({
|
|
338
|
+
schema_version: 1,
|
|
339
|
+
packet_type: "triad-assignment",
|
|
340
|
+
assignment_id: assignment.assignment_id,
|
|
341
|
+
feature_id: assignment.feature_id,
|
|
342
|
+
attempt: assignment.attempt,
|
|
343
|
+
repository: context.repository,
|
|
344
|
+
branch: context.branch,
|
|
345
|
+
worktree: context.declaredWorktree,
|
|
346
|
+
control_workspace: ".",
|
|
347
|
+
cwd: context.declaredWorktree,
|
|
348
|
+
card_path: assignment.card_path ?? null,
|
|
349
|
+
packet_path: packetRelative,
|
|
350
|
+
prd_path: assignment.prd_path ?? null,
|
|
351
|
+
gates_path: assignment.gates_path ?? null,
|
|
352
|
+
verification_run_id: assignment.verification_run_id ?? null,
|
|
353
|
+
expected_hashes: {
|
|
354
|
+
prd_sha256: assignment.expected_prd_sha256 ?? null,
|
|
355
|
+
card_sha256: assignment.expected_card_sha256 ?? null,
|
|
356
|
+
gates_sha256: assignment.expected_gates_sha256 ?? null,
|
|
357
|
+
},
|
|
358
|
+
required_gate_ids: Array.isArray(assignment.required_gate_ids) ? assignment.required_gate_ids : [],
|
|
359
|
+
mandatory_skills: context.requiredSkills.map((skill) => ({ path: skill.path, sha256: skill.sha256 })),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Build a bounded, deterministic Markdown packet from an active assignment. */
|
|
364
|
+
export async function buildAssignmentPacket(assignment, { projectRoot = process.cwd(), packetPath = null } = {}) {
|
|
365
|
+
if (assignment?.status !== "active") {
|
|
366
|
+
throw packetError("assignment_packet_invalid", "assignment packet can only be created for an active assignment");
|
|
367
|
+
}
|
|
368
|
+
if (!Number.isInteger(assignment?.attempt) || assignment.attempt < 1) {
|
|
369
|
+
throw packetError("assignment_packet_invalid", "assignment attempt must be a positive integer");
|
|
370
|
+
}
|
|
371
|
+
const context = await resolveAssignmentContext(assignment, { projectRoot });
|
|
372
|
+
if (!assignment.assignment_id || !assignment.feature_id) {
|
|
373
|
+
throw packetError("assignment_packet_invalid", "assignment ID and feature ID are required");
|
|
374
|
+
}
|
|
375
|
+
if (!context.cardPath) throw packetError("assignment_packet_invalid", "assignment card path is required");
|
|
376
|
+
let card;
|
|
377
|
+
try { card = await readFile(context.cardPath, "utf8"); }
|
|
378
|
+
catch { throw packetError("assignment_packet_invalid", `feature card is missing: ${assignment.card_path}`); }
|
|
379
|
+
const root = context.projectRoot;
|
|
380
|
+
const target = packetPath
|
|
381
|
+
? relativeProjectPath(root, packetPath, "assignment packet path")
|
|
382
|
+
: relativeProjectPath(root, assignment.assignment_packet_path ?? defaultAssignmentPacketPath(assignment), "assignment packet path");
|
|
383
|
+
if (assignment.assignment_packet_path && path.resolve(root, assignment.assignment_packet_path) !== target) {
|
|
384
|
+
throw packetError("assignment_packet_invalid", "requested packet path does not match the assignment binding");
|
|
385
|
+
}
|
|
386
|
+
const { context: supplied, prompt } = contextObject(assignment);
|
|
387
|
+
const prdExcerpts = firstDefined(supplied.relevant_prd_excerpts, supplied.relevant_prd_excerpt, supplied.prd_excerpts, supplied.prd_excerpt, prompt.relevant_prd_excerpts, prompt.relevant_prd_excerpt, prompt.prd_excerpts, prompt.prd_excerpt, []);
|
|
388
|
+
const adrExcerpts = firstDefined(supplied.relevant_adr_excerpts, supplied.relevant_adr_excerpt, supplied.adr_excerpts, supplied.adr_excerpt, prompt.relevant_adr_excerpts, prompt.relevant_adr_excerpt, prompt.adr_excerpts, prompt.adr_excerpt, []);
|
|
389
|
+
const acceptance = firstDefined(supplied.acceptance_criteria, prompt.acceptance_criteria, prompt.requirements, []);
|
|
390
|
+
const verification = firstDefined(supplied.verification_mapping, supplied.ac_mapping, supplied.verification_expectations, prompt.verification_mapping, prompt.ac_verification_mapping, prompt.verification_expectations, []);
|
|
391
|
+
const expectedPaths = firstDefined(supplied.expected_paths, supplied.expected_files, supplied.touch_points, prompt.expected_paths, prompt.expected_files, prompt.touch_points, prompt.allowed_surface, prompt.scope, []);
|
|
392
|
+
const constraints = firstDefined(supplied.constraints, prompt.constraints, prompt.non_goals, []);
|
|
393
|
+
const risks = firstDefined(supplied.risks, prompt.risks, []);
|
|
394
|
+
const previous = firstDefined(supplied.previous_evidence, supplied.prior_evidence, supplied.rework_evidence, supplied.prior_findings, supplied.previous_attempts, prompt.previous_evidence, prompt.prior_evidence, prompt.prior_findings, prompt.previous_attempts, []);
|
|
395
|
+
const metadata = packetMetadata(assignment, context, target);
|
|
396
|
+
const cardOutcome = compactSection(card, ["Outcome and scope"], "Card outcome/scope is available at the declared card path.");
|
|
397
|
+
const cardAcceptance = compactSection(card, ["Acceptance criteria"], "Acceptance criteria are available at the declared card path.");
|
|
398
|
+
const cardMetrics = compactSection(card, ["Metrics and gates"], "Metrics and gates are available at the declared card path.");
|
|
399
|
+
const cardIntegration = compactSection(card, ["Integration, practical test, and risk"], "Integration details are available at the declared card path.");
|
|
400
|
+
const packet = [
|
|
401
|
+
PACKET_START,
|
|
402
|
+
"# Triad+ Assignment Packet",
|
|
403
|
+
"",
|
|
404
|
+
"This immutable packet is the primary operational contract for this assignment. Read it before broad repository discovery. Consult the full PRD or ADR only for a missing detail, contradiction, or explicitly required fallback.",
|
|
405
|
+
"",
|
|
406
|
+
"## Packet metadata",
|
|
407
|
+
"",
|
|
408
|
+
"```json",
|
|
409
|
+
JSON.stringify(metadata, null, 2),
|
|
410
|
+
"```",
|
|
411
|
+
"",
|
|
412
|
+
renderBlock("Card outcome and scope", cardOutcome),
|
|
413
|
+
"",
|
|
414
|
+
renderBlock("Acceptance criteria", renderContextValue(acceptance, cardAcceptance)),
|
|
415
|
+
"",
|
|
416
|
+
renderBlock("Relevant PRD excerpts", renderContextValue(prdExcerpts)),
|
|
417
|
+
"",
|
|
418
|
+
renderBlock("Relevant ADR / architecture excerpts", renderContextValue(adrExcerpts)),
|
|
419
|
+
"",
|
|
420
|
+
renderBlock("AC → verification / metric mapping", renderContextValue(verification)),
|
|
421
|
+
"",
|
|
422
|
+
renderList("Expected files / directories / touch points", asLines(expectedPaths)),
|
|
423
|
+
"",
|
|
424
|
+
renderList("Known constraints", asLines(constraints)),
|
|
425
|
+
"",
|
|
426
|
+
renderList("Known risks", asLines(risks)),
|
|
427
|
+
"",
|
|
428
|
+
renderList("Mandatory repository skills", context.requiredSkills.length ? context.requiredSkills.map((skill) => `${skill.path} (sha256: ${skill.sha256})`) : ["None declared by this assignment."]),
|
|
429
|
+
"",
|
|
430
|
+
renderList("Previous attempt / review / rework evidence", asLines(previous, "No previous evidence is attached to this assignment.")),
|
|
431
|
+
"",
|
|
432
|
+
renderBlock("Card metrics and gates", cardMetrics),
|
|
433
|
+
"",
|
|
434
|
+
renderBlock("Integration and practical test", cardIntegration),
|
|
435
|
+
"",
|
|
436
|
+
"## Dispatch contract",
|
|
437
|
+
"",
|
|
438
|
+
`The host MUST launch this role with cwd/workdir equal to the assigned product worktree (${metadata.cwd}). Control-workspace paths remain explicit in the metadata and are not discovered by walking from the product repository. Developer and Reviewer use this same packet; Reviewer additionally receives the candidate and verifier evidence.`,
|
|
439
|
+
"",
|
|
440
|
+
"## Tool discipline",
|
|
441
|
+
"",
|
|
442
|
+
"Use native read, grep, glob, and list primitives for simple file discovery when the host provides them. Use shell/Bash for build, test, git, scripts, and system commands; do not use shell as the default filesystem API.",
|
|
443
|
+
PACKET_END,
|
|
444
|
+
"",
|
|
445
|
+
].join("\n");
|
|
446
|
+
return { packet, packetPath: target, metadata, context, sha256: digest(packet) };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Write an immutable packet and bind its path/hash in the assignment. */
|
|
450
|
+
export async function writeAssignmentPacket(assignment, { projectRoot = process.cwd(), assignmentPath = null, packetPath = null } = {}) {
|
|
451
|
+
const built = await buildAssignmentPacket(assignment, { projectRoot, packetPath });
|
|
452
|
+
const existingPath = built.packetPath;
|
|
453
|
+
let existing = null;
|
|
454
|
+
try { existing = await readFile(existingPath, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
455
|
+
if (existing !== null && existing !== built.packet) {
|
|
456
|
+
throw packetError("assignment_packet_conflict", `assignment packet already exists with different content: ${packetRelativePath(built.context.projectRoot, existingPath)}`);
|
|
457
|
+
}
|
|
458
|
+
if (existing === null) {
|
|
459
|
+
await mkdir(path.dirname(existingPath), { recursive: true });
|
|
460
|
+
try { await writeFile(existingPath, built.packet, { encoding: "utf8", flag: "wx" }); }
|
|
461
|
+
catch (error) {
|
|
462
|
+
if (error.code !== "EEXIST") throw error;
|
|
463
|
+
const raced = await readFile(existingPath, "utf8");
|
|
464
|
+
if (raced !== built.packet) throw packetError("assignment_packet_conflict", `assignment packet changed during creation: ${packetRelativePath(built.context.projectRoot, existingPath)}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (assignment.assignment_packet_path && path.resolve(built.context.projectRoot, assignment.assignment_packet_path) !== existingPath) {
|
|
468
|
+
throw packetError("assignment_packet_invalid", "assignment packet path does not match the generated packet");
|
|
469
|
+
}
|
|
470
|
+
if (assignment.assignment_packet_sha256 && assignment.assignment_packet_sha256.toLowerCase() !== built.sha256) {
|
|
471
|
+
throw packetError("assignment_packet_invalid", "assignment packet hash does not match generated content");
|
|
472
|
+
}
|
|
473
|
+
if (assignmentPath && (!assignment.assignment_packet_path || !assignment.assignment_packet_sha256)) {
|
|
474
|
+
const relative = packetRelativePath(built.context.projectRoot, existingPath);
|
|
475
|
+
const updated = { ...assignment, assignment_packet_path: relative, assignment_packet_sha256: built.sha256 };
|
|
476
|
+
await writeAtomicJson(assignmentPath, updated);
|
|
477
|
+
Object.assign(assignment, updated);
|
|
478
|
+
}
|
|
479
|
+
return { ...built, assignment };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function parseMetadata(source) {
|
|
483
|
+
const start = source.indexOf(`${PACKET_START}\n`);
|
|
484
|
+
const marker = "```json\n";
|
|
485
|
+
const jsonStart = start < 0 ? -1 : source.indexOf(marker, start) + marker.length;
|
|
486
|
+
const jsonEnd = jsonStart > marker.length ? source.indexOf("\n```", jsonStart) : -1;
|
|
487
|
+
if (start < 0 || jsonStart < marker.length || jsonEnd < 0) throw packetError("assignment_packet_invalid", "assignment packet metadata is missing or malformed");
|
|
488
|
+
try { return JSON.parse(source.slice(jsonStart, jsonEnd)); }
|
|
489
|
+
catch { throw packetError("assignment_packet_invalid", "assignment packet metadata is not valid JSON"); }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Validate the immutable packet binding before a verifier executes gates. */
|
|
493
|
+
export async function validateAssignmentPacket(assignment, projectRoot = process.cwd()) {
|
|
494
|
+
const pathValue = nonEmpty(assignment?.assignment_packet_path);
|
|
495
|
+
const hashValue = nonEmpty(assignment?.assignment_packet_sha256);
|
|
496
|
+
if (!pathValue && !hashValue) return null;
|
|
497
|
+
if (!pathValue || !hashValue || !SHA256.test(hashValue)) {
|
|
498
|
+
throw packetError("assignment_packet_invalid", "assignment packet requires a path and SHA-256 binding");
|
|
499
|
+
}
|
|
500
|
+
const root = await realpath(projectRoot);
|
|
501
|
+
const target = relativeProjectPath(root, pathValue, "assignment packet path");
|
|
502
|
+
let source;
|
|
503
|
+
try { source = await readFile(target, "utf8"); }
|
|
504
|
+
catch { throw packetError("assignment_packet_invalid", `assignment packet is missing: ${pathValue}`); }
|
|
505
|
+
const actual = digest(source);
|
|
506
|
+
if (actual !== hashValue.toLowerCase()) throw packetError("assignment_packet_invalid", "assignment packet hash mismatch");
|
|
507
|
+
const metadata = parseMetadata(source);
|
|
508
|
+
for (const [key, expected] of [["assignment_id", assignment.assignment_id], ["feature_id", assignment.feature_id], ["attempt", assignment.attempt]]) {
|
|
509
|
+
if (metadata[key] !== expected) throw packetError("assignment_packet_invalid", `assignment packet ${key} does not match assignment`);
|
|
510
|
+
}
|
|
511
|
+
if (assignment.expected_branch && metadata.branch !== assignment.expected_branch) throw packetError("assignment_packet_invalid", "assignment packet branch does not match assignment");
|
|
512
|
+
if (metadata.packet_path !== packetRelativePath(root, target)) throw packetError("assignment_packet_invalid", "assignment packet metadata path does not match its location");
|
|
513
|
+
return { path: packetRelativePath(root, target), sha256: actual, metadata };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export { PACKET_END, PACKET_START };
|
|
@@ -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
|
+
}
|