triad-plus 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +21 -12
  3. package/adapters/antigravity/.agents/agents/triad-developer/agent.md +7 -3
  4. package/adapters/antigravity/.agents/agents/triad-orchestrator/agent.md +5 -0
  5. package/adapters/antigravity/.agents/agents/triad-reviewer/agent.md +7 -2
  6. package/adapters/antigravity/.agents/skills/triad/SKILL.md +22 -0
  7. package/adapters/antigravity/README.md +1 -1
  8. package/adapters/antigravity/install.sh +1 -1
  9. package/adapters/claude-code/.claude/agents/triad-developer.md +9 -4
  10. package/adapters/claude-code/.claude/agents/triad-reviewer.md +10 -5
  11. package/adapters/claude-code/.claude/commands/triad.md +22 -0
  12. package/adapters/claude-code/README.md +12 -4
  13. package/adapters/claude-code/install.sh +1 -1
  14. package/adapters/codex/README.md +1 -1
  15. package/adapters/codex/install.sh +3 -3
  16. package/adapters/codex/prompts/triad.md +28 -0
  17. package/adapters/copilot/.github/agents/triad-developer.agent.md +12 -6
  18. package/adapters/copilot/.github/agents/triad-evaluator.agent.md +2 -1
  19. package/adapters/copilot/.github/agents/triad-orchestrator.agent.md +15 -1
  20. package/adapters/copilot/.github/agents/triad-reviewer.agent.md +11 -5
  21. package/adapters/copilot/.github/skills/triad/SKILL.md +23 -0
  22. package/adapters/hermes/install.sh +1 -1
  23. package/adapters/hermes/skills/triad/SKILL.md +18 -0
  24. package/adapters/opencode/.opencode/agents/triad-developer.md +23 -4
  25. package/adapters/opencode/.opencode/agents/triad-orchestrator.md +16 -0
  26. package/adapters/opencode/.opencode/agents/triad-reviewer.md +25 -3
  27. package/adapters/opencode/.opencode/commands/triad.md +21 -0
  28. package/adapters/opencode/README.md +29 -5
  29. package/adapters/opencode/install.sh +1 -1
  30. package/adapters/registry.mjs +23 -1
  31. package/bin/triad-plus.js +210 -38
  32. package/docs/architecture.md +8 -0
  33. package/docs/assignment-packets.md +35 -0
  34. package/docs/bmad-integration.md +187 -82
  35. package/docs/configuration.md +29 -0
  36. package/docs/npx-installation.md +20 -0
  37. package/docs/opencode-replication.md +8 -0
  38. package/docs/operating-guide.it.md +8 -0
  39. package/docs/operating-guide.md +7 -0
  40. package/docs/runtimes.md +1 -1
  41. package/docs/verification.md +20 -0
  42. package/integrations/bmad/README.md +17 -9
  43. package/integrations/bmad/epics-parser.mjs +697 -0
  44. package/integrations/bmad/story-importer.mjs +143 -5
  45. package/package.json +2 -2
  46. package/runtime/lib/assignment-packet.mjs +553 -0
  47. package/runtime/lib/model-config.mjs +165 -0
  48. package/runtime/lib/repository-context.mjs +192 -0
  49. package/runtime/lib/terminal.mjs +86 -0
  50. package/runtime/triad-assignment-packet.mjs +62 -0
  51. package/runtime/triad-bmad-intake.mjs +150 -0
  52. package/runtime/triad-runtime-context.mjs +88 -0
  53. package/runtime/triad-verify.mjs +41 -21
  54. package/schemas/verification-evidence.schema.json +8 -0
  55. package/skills/triad-loop-bootstrap/SKILL.md +39 -4
  56. package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +14 -1
  57. package/skills/triad-loop-developer/SKILL.md +33 -8
  58. package/skills/triad-loop-orchestrator/SKILL.md +96 -11
  59. package/skills/triad-loop-reviewer/SKILL.md +31 -5
  60. package/skills/triad-model-configuration/SKILL.md +98 -0
  61. package/skills/triad-model-configuration/agents/openai.yaml +4 -0
@@ -115,10 +115,18 @@ function parseLabelBlocks(body) {
115
115
  if (content) blocks.push({ heading: current.heading, content });
116
116
  };
117
117
  for (const line of body.split(/\r?\n/)) {
118
- const label = line.match(/^\s*(?:[-*]\s*)?\*\*([^*]+)\*\*\s*:?\s*$/);
118
+ // Native BMAD epics.md commonly puts the label and its first paragraph on
119
+ // one line (`**Intent / outcome:** ...`), while standalone Stories often
120
+ // put the label on its own line. Only treat a colon inside the bold label
121
+ // as an inline section marker so Given/When/Then lines remain content of
122
+ // the Acceptance Criteria block.
123
+ const inlineCandidate = line.match(/^\s*(?:[-*]\s*)?\*\*([^*]+:)\*\*\s*(.*)$/);
124
+ const criterionStep = inlineCandidate && /^(?:Given|When|Then|And|But)\s*:$/i.test(inlineCandidate[1].trim());
125
+ const inlineLabel = inlineCandidate && !criterionStep ? inlineCandidate : null;
126
+ const label = inlineLabel || line.match(/^\s*(?:[-*]\s*)?\*\*([^*]+)\*\*\s*:?\s*$/);
119
127
  if (label) {
120
128
  flush();
121
- current = { heading: label[1], lines: [] };
129
+ current = { heading: label[1], lines: inlineLabel && label[2].trim() ? [label[2].trim()] : [] };
122
130
  } else if (/^#{1,6}\s+/.test(line)) {
123
131
  flush();
124
132
  current = null;
@@ -141,7 +149,12 @@ function metadataLines(body, names) {
141
149
  const values = [];
142
150
  for (const line of body.split(/\r?\n/)) {
143
151
  const match = line.match(/^\s*(?:[-*]\s*)?(?:\*\*)?([^:*]+?)(?:\*\*)?\s*:\s*(.+?)\s*$/);
144
- if (match && wanted.has(normalizeHeading(match[1]))) values.push(unquote(match[2]));
152
+ if (match && wanted.has(normalizeHeading(match[1]))) {
153
+ // Support BMAD's inline bold labels (`**Status:** ready-for-dev`),
154
+ // whose closing emphasis appears before the colon and is otherwise
155
+ // captured as a leading `**` in the value.
156
+ values.push(unquote(match[2].replace(/^\*\*\s*/, '').replace(/\s*\*\*$/, '')));
157
+ }
145
158
  }
146
159
  return values;
147
160
  }
@@ -165,6 +178,105 @@ function firstLabelOrSectionContent(sections, labelBlocks, labels) {
165
178
  return labeled[0] ?? firstContent(sections, labelBlocks, labels);
166
179
  }
167
180
 
181
+ function singleLabelOrSectionContent(sections, labelBlocks, labels, field) {
182
+ const values = contentsFor(sections, labels, labelBlocks);
183
+ if (values.length > 1) {
184
+ throw new BmadStoryImportError('bmad_story_ambiguous', `BMAD Story has conflicting ${field} values.`, {
185
+ field,
186
+ values
187
+ });
188
+ }
189
+ return values[0] ?? '';
190
+ }
191
+
192
+ /**
193
+ * Parse the body contract shared by a standalone BMAD Story and a Story
194
+ * section inside epics.md. Unlike parseBmadStory(), this helper deliberately
195
+ * does not require status or a target repository: epics.md is a planning
196
+ * artifact and those execution fields may be absent there.
197
+ */
198
+ export function parseBmadStoryContent(body, {
199
+ sourcePath = null,
200
+ id = '',
201
+ title = '',
202
+ status = null,
203
+ targetRepository = null,
204
+ epicId = null,
205
+ epicTitle = null,
206
+ sourceHeading = null,
207
+ sourceHeadingMarkdown = null,
208
+ sourceRange = null
209
+ } = {}) {
210
+ const sections = parseSections(body);
211
+ const labelBlocks = parseLabelBlocks(body);
212
+ const bodyId = distinctStrings(metadataLines(body, ['Story ID', 'ID']), 'story id');
213
+ if (bodyId && id && bodyId !== id) {
214
+ throw new BmadStoryImportError('bmad_story_ambiguous', `BMAD Story id conflicts with its heading: ${id} vs ${bodyId}.`, { id, bodyId });
215
+ }
216
+ const resolvedId = id || bodyId;
217
+ if (!resolvedId) throw new BmadStoryImportError('bmad_story_not_found', `No BMAD Story id found in ${sourcePathLabel(sourcePath)}.`);
218
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(resolvedId)) {
219
+ throw new BmadStoryImportError('bmad_story_invalid', `BMAD Story id is not filename-safe: ${resolvedId}`);
220
+ }
221
+
222
+ const bodyTitle = distinctStrings(metadataLines(body, ['Title']), 'title');
223
+ if (bodyTitle && title && bodyTitle !== title) {
224
+ throw new BmadStoryImportError('bmad_story_ambiguous', `BMAD Story title conflicts with its heading: ${title} vs ${bodyTitle}.`, { title, bodyTitle });
225
+ }
226
+ const resolvedTitle = title || bodyTitle;
227
+ if (!resolvedTitle) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story title is required.');
228
+
229
+ const bodyStatus = distinctStrings([
230
+ ...contentsFor(sections, ['Status'], labelBlocks),
231
+ ...metadataLines(body, ['Status'])
232
+ ], 'status');
233
+ const resolvedStatus = clean(status || bodyStatus).toLowerCase() || null;
234
+ const bodyRepository = distinctStrings([
235
+ ...contentsFor(sections, ['Target repository', 'Target repo', 'Repository'], labelBlocks),
236
+ ...metadataLines(body, ['Target repository', 'Target repo', 'Repository'])
237
+ ], 'target repository');
238
+ if (bodyRepository && targetRepository && bodyRepository !== targetRepository) {
239
+ throw new BmadStoryImportError('bmad_story_ambiguous', 'BMAD Story target repository conflicts with its execution mapping.', {
240
+ declared: bodyRepository,
241
+ resolved: targetRepository
242
+ });
243
+ }
244
+ const resolvedTargetRepository = clean(targetRepository || bodyRepository) || null;
245
+ const outcome = distinctStrings([
246
+ ...contentsFor(sections, ['Intent', 'Intent / outcome', 'Outcome', 'User outcome', 'Objective', 'Story', 'Description'], labelBlocks),
247
+ ...metadataLines(body, ['Intent', 'Outcome', 'Objective', 'Description'])
248
+ ], 'intent/outcome');
249
+ if (!outcome) throw new BmadStoryImportError('bmad_story_unmappable', `BMAD Story ${resolvedId} intent/outcome is required.`);
250
+
251
+ const acceptanceCriteria = singleLabelOrSectionContent(sections, labelBlocks, ['Acceptance Criteria', 'Acceptance'], 'acceptance criteria') || '';
252
+ if (!acceptanceCriteria) throw new BmadStoryImportError('bmad_story_unmappable', `BMAD Story ${resolvedId} acceptance criteria are required.`);
253
+
254
+ return {
255
+ id: resolvedId,
256
+ title: resolvedTitle,
257
+ status: resolvedStatus,
258
+ targetRepository: resolvedTargetRepository,
259
+ epicId: epicId ? clean(epicId) : null,
260
+ epicTitle: epicTitle ? clean(epicTitle) : null,
261
+ sourceHeading: sourceHeading ? clean(sourceHeading) : null,
262
+ sourceHeadingMarkdown: sourceHeadingMarkdown ? clean(sourceHeadingMarkdown) : null,
263
+ sourceRange: sourceRange ?? null,
264
+ branchWorktree: distinctStrings([
265
+ ...metadataLines(body, ['Branch', 'Worktree', 'Branch/worktree'])
266
+ ], 'branch/worktree'),
267
+ outcome,
268
+ acceptanceCriteria,
269
+ tasksAcceptance: firstLabelOrSectionContent(sections, labelBlocks, ['Execution', 'Tasks / Subtasks', 'Tasks', 'Tasks & Acceptance', 'Implementation Tasks']),
270
+ codeMap: firstContent(sections, labelBlocks, ['Code Map', 'Technical Context', 'Technical context / code map', 'Implementation Context']),
271
+ designNotes: firstContent(sections, labelBlocks, ['Design Notes', 'Boundaries & Constraints', 'Constraints', 'Technical Constraints']),
272
+ verification: firstContent(sections, labelBlocks, ['Verification', 'Verification Expectations', 'Metrics and Gates', 'Verification expectations']),
273
+ references: firstContent(sections, labelBlocks, ['Source References', 'References', 'References / Provenance']),
274
+ inScope: firstContent(sections, labelBlocks, ['In Scope', 'Scope']),
275
+ outOfScope: firstContent(sections, labelBlocks, ['Out of Scope', 'Non-goals', 'Non Goals']),
276
+ sourcePath: sourcePath ? resolve(sourcePath) : null
277
+ };
278
+ }
279
+
168
280
  function distinctStrings(values, field) {
169
281
  const normalized = values.map((value) => clean(value)).filter(Boolean);
170
282
  const distinct = [...new Set(normalized)];
@@ -299,7 +411,7 @@ function sectionOrFallback(value, fallback) {
299
411
  return value?.trim() || fallback;
300
412
  }
301
413
 
302
- export function buildTriadCard(story, { requiredGates = [], dependsOn = [] } = {}) {
414
+ export function buildTriadCard(story, { requiredGates = [], dependsOn = [], sourceKind = SOURCE_KIND } = {}) {
303
415
  if (!story || typeof story !== 'object') {
304
416
  throw new BmadStoryImportError('bmad_story_unmappable', 'A parsed BMAD Story object is required.');
305
417
  }
@@ -321,6 +433,12 @@ export function buildTriadCard(story, { requiredGates = [], dependsOn = [] } = {
321
433
  const designNotes = sectionOrFallback(story.designNotes, 'No Design Notes or additional constraints were supplied by the BMAD Story.');
322
434
  const verification = sectionOrFallback(story.verification, 'Use the repository-owned deterministic gates declared by the caller and the normal Triad verifier.');
323
435
  const references = sectionOrFallback(story.references, 'No source references were declared by the BMAD Story.');
436
+ const epicContext = sourceKind === 'bmad-epics' && story.epicId
437
+ ? [
438
+ `- BMAD Epic: \`${story.epicId}${story.epicTitle ? ` — ${story.epicTitle}` : ''}\``,
439
+ story.sourceHeading ? `- BMAD source heading: \`${story.sourceHeading}\`` : null
440
+ ].filter(Boolean)
441
+ : [];
324
442
 
325
443
  return [
326
444
  `# ${story.id} — ${story.title}`,
@@ -368,12 +486,32 @@ export function buildTriadCard(story, { requiredGates = [], dependsOn = [] } = {
368
486
  '',
369
487
  '## Integration boundary',
370
488
  '',
371
- '- This card was generated from one BMAD Story; BMAD remains planning authority and Triad remains execution authority.',
489
+ ...epicContext,
490
+ `- This card was generated from one ${sourceKind === 'bmad-epics' ? 'BMAD epics.md Story' : 'BMAD Story'}; BMAD remains planning authority and Triad remains execution authority.`,
372
491
  '- The BMAD source is read-only. Provenance is recorded in the integration-side companion record.',
373
492
  ''
374
493
  ].join('\n');
375
494
  }
376
495
 
496
+ /**
497
+ * Build the existing Card contract after a canonical epics.md Story has passed
498
+ * Triad execution-readiness checks. The source may omit BMAD's optional
499
+ * ready-for-dev field; execution readiness is supplied by the downstream
500
+ * project/repository resolver rather than written back into BMAD.
501
+ */
502
+ export function buildTriadCardFromCanonicalStory(story, {
503
+ requiredGates = [],
504
+ dependsOn = [],
505
+ executionReady = false,
506
+ sourceKind = 'bmad-epics'
507
+ } = {}) {
508
+ if (!executionReady) {
509
+ throw new BmadStoryImportError('bmad_story_not_ready', `BMAD Story ${story?.id ?? '<unknown>'} is ingestible but not execution-ready.`);
510
+ }
511
+ const normalized = { ...story, status: READY_STATUS };
512
+ return buildTriadCard(normalized, { requiredGates, dependsOn, sourceKind });
513
+ }
514
+
377
515
  export function validateTriadCard(card) {
378
516
  const required = [
379
517
  /^#\s+[^\n]+/m,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triad-plus",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "A lightweight, evidence-backed engineering loop for coding agents.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "node": ">=20"
29
29
  },
30
30
  "scripts": {
31
- "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/immutable-quality-contract-test.mjs",
31
+ "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs && node tests/repository-context-test.mjs && node tests/tui-model-configuration-test.mjs",
32
32
  "pack:check": "npm pack --dry-run"
33
33
  },
34
34
  "repository": {