triad-plus 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +17 -0
- package/bin/triad-plus.js +28 -3
- package/docs/bmad-integration.md +110 -0
- package/docs/configuration.md +7 -0
- package/docs/operating-guide.it.md +7 -0
- package/docs/operating-guide.md +6 -0
- package/docs/verification.md +24 -0
- package/integrations/bmad/README.md +12 -0
- package/integrations/bmad/story-importer.mjs +475 -0
- package/package.json +2 -2
- package/runtime/lib/gates.mjs +114 -0
- package/runtime/triad-verify.mjs +34 -8
- package/schemas/verification-evidence.schema.json +20 -0
- package/skills/triad-loop-bootstrap/SKILL.md +4 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/feature-card.template.md +1 -1
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +1 -0
- package/skills/triad-loop-orchestrator/SKILL.md +21 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.0 — 2026-09-10
|
|
4
|
+
|
|
5
|
+
- Add an optional deterministic importer for one BMAD `ready-for-dev` Story at
|
|
6
|
+
a time, preserving the normal Triad Card contract and source provenance.
|
|
7
|
+
|
|
8
|
+
## 1.6.0 — 2026-09-05
|
|
9
|
+
|
|
10
|
+
- Add card-declared repository `required_gates` with additive per-card gate
|
|
11
|
+
selection; globally required gates are always preserved.
|
|
12
|
+
- Promote selected optional repository gates to mandatory for the card, with
|
|
13
|
+
trusted assignment binding, fail-closed unavailable-gate handling, and
|
|
14
|
+
generic gate-selection evidence.
|
|
15
|
+
- Preserve the existing legacy behavior when a card has no selected gates.
|
|
16
|
+
|
|
3
17
|
## 1.5.0 — 2026-09-05
|
|
4
18
|
|
|
5
19
|
- Add cause-coded retry/recovery accounting with independent finite runtime and
|
package/README.md
CHANGED
|
@@ -107,6 +107,23 @@ The Orchestrator first shows the feature cards, then delegates the bounded work.
|
|
|
107
107
|
If a tutorial step is unclear, see the [OpenCode guide](docs/runtimes.md#opencode)
|
|
108
108
|
and [troubleshooting](docs/troubleshooting.md).
|
|
109
109
|
|
|
110
|
+
## Optional BMAD Story import
|
|
111
|
+
|
|
112
|
+
When BMAD planning has already produced a Story with `status: ready-for-dev`,
|
|
113
|
+
you can convert one Story at a time into the normal Triad Card contract:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npx triad-plus import-bmad-story \
|
|
117
|
+
--source /absolute/path/to/story.md \
|
|
118
|
+
--output /absolute/path/to/control/features/STORY-001.md
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The importer is read-only and fail-closed. It preserves the Story's executable
|
|
122
|
+
intent, acceptance criteria, technical context, and references, while any
|
|
123
|
+
required gates or dependencies remain explicit caller options. It does not run
|
|
124
|
+
BMAD workflows or add BMAD semantics to the Core. See the [BMAD integration
|
|
125
|
+
guide](docs/bmad-integration.md).
|
|
126
|
+
|
|
110
127
|
## Quick start for every runtime
|
|
111
128
|
|
|
112
129
|
Requirements: Node.js 20+ and one supported coding-agent host.
|
package/bin/triad-plus.js
CHANGED
|
@@ -8,6 +8,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
8
8
|
import process from 'node:process';
|
|
9
9
|
import { createInterface } from 'node:readline/promises';
|
|
10
10
|
import { getAdapter, listAdapters, roleDefinitions, sharedSkillNames } from '../adapters/registry.mjs';
|
|
11
|
+
import { writeImportedCard } from '../integrations/bmad/story-importer.mjs';
|
|
11
12
|
|
|
12
13
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
14
|
|
|
@@ -20,6 +21,7 @@ Usage:
|
|
|
20
21
|
npx triad-plus init --host <adapter-id> --control <path> [--global] [--team-config <path>] [--allow-product-repo]
|
|
21
22
|
npx triad-plus doctor --host <adapter-id> --control <path> [--hook-config <path>]
|
|
22
23
|
npx triad-plus upgrade --host <adapter-id> --control <path> [--global] [--apply]
|
|
24
|
+
npx triad-plus import-bmad-story --source <story.md> --output <card.md> [--target-repository <id>] [--provenance <record.json>] [--required-gate <id>] [--depends-on <card-id>]
|
|
23
25
|
|
|
24
26
|
Adapters: ${listAdapters().map((adapter) => adapter.id).join(', ')}
|
|
25
27
|
|
|
@@ -31,13 +33,20 @@ Installation refuses every asset overwrite. Upgrade is a dry run unless --apply
|
|
|
31
33
|
|
|
32
34
|
function parseArgs(args) {
|
|
33
35
|
const [command, ...rest] = args;
|
|
34
|
-
const options = { command, global: false, allowProductRepo: false, apply: false };
|
|
36
|
+
const options = { command, global: false, allowProductRepo: false, apply: false, requiredGates: [], dependsOn: [] };
|
|
35
37
|
for (let index = 0; index < rest.length; index += 1) {
|
|
36
38
|
const argument = rest[index];
|
|
37
39
|
if (argument === '--global') options.global = true;
|
|
38
40
|
else if (argument === '--allow-product-repo') options.allowProductRepo = true;
|
|
39
41
|
else if (argument === '--apply') options.apply = true;
|
|
40
|
-
else if (
|
|
42
|
+
else if (argument === '--required-gate' || argument === '--depends-on') {
|
|
43
|
+
const value = rest[index + 1];
|
|
44
|
+
if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value.`);
|
|
45
|
+
const target = argument === '--required-gate' ? options.requiredGates : options.dependsOn;
|
|
46
|
+
target.push(value);
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
else if (['--host', '--control', '--team-config', '--hook-config', '--source', '--output', '--target-repository', '--provenance'].includes(argument)) {
|
|
41
50
|
const value = rest[index + 1];
|
|
42
51
|
if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value.`);
|
|
43
52
|
options[argument.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
|
|
@@ -395,6 +404,21 @@ async function upgrade(options) {
|
|
|
395
404
|
if (!options.apply) process.stdout.write('Dry run only. Re-run with --apply to update managed assets.\n');
|
|
396
405
|
}
|
|
397
406
|
|
|
407
|
+
async function importBmadStory(options) {
|
|
408
|
+
if (!options.source) throw new Error('Provide --source <bmad-story.md>.');
|
|
409
|
+
if (!options.output) throw new Error('Provide --output <triad-card.md>.');
|
|
410
|
+
const result = await writeImportedCard({
|
|
411
|
+
sourcePath: options.source,
|
|
412
|
+
outputPath: options.output,
|
|
413
|
+
targetRepository: options.targetRepository,
|
|
414
|
+
provenancePath: options.provenance,
|
|
415
|
+
requiredGates: options.requiredGates,
|
|
416
|
+
dependsOn: options.dependsOn
|
|
417
|
+
});
|
|
418
|
+
process.stdout.write(`Imported BMAD Story ${result.story.id} as Triad Card ${result.outputPath}\n`);
|
|
419
|
+
process.stdout.write(`Provenance ${result.provenancePath}\n`);
|
|
420
|
+
}
|
|
421
|
+
|
|
398
422
|
async function doctor(options) {
|
|
399
423
|
if (!options.control) throw new Error('Provide --control <project-control-path>.');
|
|
400
424
|
const controlRoot = resolve(options.control);
|
|
@@ -468,10 +492,11 @@ try {
|
|
|
468
492
|
if (options.command === 'init') await init(options);
|
|
469
493
|
else if (options.command === 'doctor') await doctor(options);
|
|
470
494
|
else if (options.command === 'upgrade') await upgrade(options);
|
|
495
|
+
else if (options.command === 'import-bmad-story') await importBmadStory(options);
|
|
471
496
|
else if (!options.command) await interactiveInit();
|
|
472
497
|
else if (options.command === '--help' || options.command === '-h') usage(0);
|
|
473
498
|
else throw new Error(`Unknown command: ${options.command}`);
|
|
474
499
|
} catch (error) {
|
|
475
|
-
process.stderr.write(`${error.message}\n`);
|
|
500
|
+
process.stderr.write(`${error.code ? `${error.code}: ` : ''}${error.message}\n`);
|
|
476
501
|
usage(2);
|
|
477
502
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Optional BMAD Story integration
|
|
2
|
+
|
|
3
|
+
Triad+ can consume one already-produced BMAD Story and turn it into a normal
|
|
4
|
+
Triad feature Card. This is an integration boundary, not a BMAD execution
|
|
5
|
+
adapter: BMAD remains the planning authority and Triad remains responsible for
|
|
6
|
+
implementation, verification, review, and delivery.
|
|
7
|
+
|
|
8
|
+
## Contract
|
|
9
|
+
|
|
10
|
+
The source is a read-only Markdown Story. It must contain:
|
|
11
|
+
|
|
12
|
+
- a unique Story `id` and `title` (frontmatter, metadata labels, or a Story
|
|
13
|
+
heading);
|
|
14
|
+
- `status: ready-for-dev` (frontmatter or a `Status` field);
|
|
15
|
+
- a target repository (or an explicit `--target-repository` importer option);
|
|
16
|
+
- an intent/outcome; and
|
|
17
|
+
- acceptance criteria.
|
|
18
|
+
|
|
19
|
+
The importer also carries through the Story's `Tasks & Acceptance`, Code Map,
|
|
20
|
+
Design Notes/constraints, verification expectations, and source references
|
|
21
|
+
when they are present. It does not interpret prose with an LLM, re-decompose a
|
|
22
|
+
Story, or invoke BMAD Build, Build Auto, or `bmad-loop`.
|
|
23
|
+
|
|
24
|
+
## CLI
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx triad-plus import-bmad-story \
|
|
28
|
+
--source /absolute/path/to/story.md \
|
|
29
|
+
--output /absolute/path/to/control/features/JFR-001.md \
|
|
30
|
+
--target-repository webup \
|
|
31
|
+
--required-gate cypress-jfr \
|
|
32
|
+
--depends-on JFR-000
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`--required-gate` and `--depends-on` may be repeated. They are explicit caller
|
|
36
|
+
options: gate IDs are additive to the repository's globally required gates,
|
|
37
|
+
and dependencies are never inferred from `stories.yaml` order. Omit both when
|
|
38
|
+
the Card should use the repository's normal/baseline behavior.
|
|
39
|
+
|
|
40
|
+
The command writes the Card and a sidecar provenance record (by default
|
|
41
|
+
`<card>.bmad-provenance.json`). The provenance records `source_kind:
|
|
42
|
+
bmad-story`, the resolved source path, source SHA-256, BMAD Story ID, target
|
|
43
|
+
repository, Card SHA-256, and the explicit options used for the import.
|
|
44
|
+
|
|
45
|
+
The same operation is available to Node consumers:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { importBmadStory, writeImportedCard } from
|
|
49
|
+
'triad-plus/integrations/bmad/story-importer.mjs';
|
|
50
|
+
|
|
51
|
+
const result = await importBmadStory({
|
|
52
|
+
sourcePath: '/absolute/path/to/story.md',
|
|
53
|
+
targetRepository: 'webup',
|
|
54
|
+
requiredGates: ['cypress-jfr']
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await writeImportedCard({
|
|
58
|
+
sourcePath: '/absolute/path/to/story.md',
|
|
59
|
+
outputPath: '/absolute/path/to/control/features/JFR-001.md',
|
|
60
|
+
targetRepository: 'webup',
|
|
61
|
+
requiredGates: ['cypress-jfr']
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Example mapping
|
|
66
|
+
|
|
67
|
+
Input (abridged):
|
|
68
|
+
|
|
69
|
+
```md
|
|
70
|
+
---
|
|
71
|
+
id: JFR-001
|
|
72
|
+
title: Route the provider document
|
|
73
|
+
status: ready-for-dev
|
|
74
|
+
target_repository: webup
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# Story JFR-001: Route the provider document
|
|
78
|
+
|
|
79
|
+
## Intent
|
|
80
|
+
|
|
81
|
+
Webup renders the provider-owned document at the existing boundary.
|
|
82
|
+
|
|
83
|
+
## Acceptance Criteria
|
|
84
|
+
|
|
85
|
+
- Given a valid document, when the route is requested, then it renders.
|
|
86
|
+
|
|
87
|
+
## Code Map
|
|
88
|
+
|
|
89
|
+
- `src/components/jfr/`
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The generated Card keeps that intent, acceptance criterion, Code Map, and the
|
|
93
|
+
target repository, then adds the normal Triad sections and the integration
|
|
94
|
+
boundary note. The caller-supplied `cypress-jfr` (if any) is recorded as an
|
|
95
|
+
additive required gate; it is not inferred from the word "render" or from a
|
|
96
|
+
file extension.
|
|
97
|
+
|
|
98
|
+
## Fail-closed behavior
|
|
99
|
+
|
|
100
|
+
No executable Card is produced for a missing source, missing/non-`ready-for-dev`
|
|
101
|
+
status, malformed or ambiguous ID/title, missing indispensable Card fields,
|
|
102
|
+
conflicting target repository, invalid caller options, or a source that changes
|
|
103
|
+
while it is being read. The API exposes integration-level error codes such as
|
|
104
|
+
`bmad_story_not_ready`, `bmad_story_ambiguous`, `bmad_story_unmappable`, and
|
|
105
|
+
`bmad_story_source_mutated` so a planning gap can return upstream rather than
|
|
106
|
+
become a Developer decision.
|
|
107
|
+
|
|
108
|
+
After import, the generated Card goes through the existing assignment and
|
|
109
|
+
`triad-verify` path. No Core schema, lifecycle, Reviewer, retry, or gate
|
|
110
|
+
execution semantics are changed by this integration.
|
package/docs/configuration.md
CHANGED
|
@@ -40,3 +40,10 @@ bind a versioned JSON scope contract at its first assignment; without one, the
|
|
|
40
40
|
deterministic scope preflight is not configured and independent review remains
|
|
41
41
|
the semantic scope check. See [verification.md](verification.md) for the
|
|
42
42
|
contract and matching rules.
|
|
43
|
+
|
|
44
|
+
Cards may also declare `required_gates` as an additive list of trusted
|
|
45
|
+
repository gate IDs. Globally required gates are never suppressed; a selected
|
|
46
|
+
optional gate becomes required for that card, and an absent or empty list keeps
|
|
47
|
+
the legacy gate behavior. Selected IDs are validated before Developer dispatch
|
|
48
|
+
and are bound to the assignment; Triad does not attach visual or other
|
|
49
|
+
domain-specific meaning to a gate ID.
|
|
@@ -44,6 +44,13 @@ registra push finale, eventuale valutazione, handoff, stato finale della run e
|
|
|
44
44
|
prova pratica prima di dichiarare il progetto consegnato. Avvio e stop della demo
|
|
45
45
|
restano del proprietario.
|
|
46
46
|
|
|
47
|
+
Una card può aggiungere una lista `required_gates` di gate ID del repository.
|
|
48
|
+
La lista è additiva: i gate globalmente obbligatori continuano a essere eseguiti,
|
|
49
|
+
quelli opzionali selezionati diventano obbligatori per la card e gli opzionali non
|
|
50
|
+
selezionati possono essere saltati. Lista assente o vuota mantiene il
|
|
51
|
+
comportamento legacy. Triad collega e registra gli ID senza attribuire loro un
|
|
52
|
+
significato visuale o di altro dominio.
|
|
53
|
+
|
|
47
54
|
## Retry e scope del candidato
|
|
48
55
|
|
|
49
56
|
Gli attempt sono record storici di esecuzione. I nuovi workspace separano e
|
package/docs/operating-guide.md
CHANGED
|
@@ -45,6 +45,12 @@ closure gate: it records the final push, optional evaluation, handoff, final run
|
|
|
45
45
|
record, and practical test before the project is called delivered. Demo start and
|
|
46
46
|
stop remain owner-controlled.
|
|
47
47
|
|
|
48
|
+
Cards may add a `required_gates` list containing trusted repository gate IDs.
|
|
49
|
+
The list is additive: globally required gates still run, selected optional gates
|
|
50
|
+
become required for that card, and unselected optional gates may be skipped. An
|
|
51
|
+
absent or empty list preserves the legacy behavior. Triad binds and records the
|
|
52
|
+
IDs but does not attach visual or other domain-specific meaning to them.
|
|
53
|
+
|
|
48
54
|
## Retry accounting and candidate scope
|
|
49
55
|
|
|
50
56
|
Attempts are historical execution records. New workspaces separately bound
|
package/docs/verification.md
CHANGED
|
@@ -29,6 +29,30 @@ it does not itself approve, rework, or transition a run.
|
|
|
29
29
|
Evidence files and logs are diagnostics. Users normally need only the
|
|
30
30
|
Orchestrator's summary and the Reviewer verdict.
|
|
31
31
|
|
|
32
|
+
## Card-declared required gates
|
|
33
|
+
|
|
34
|
+
The work queue may carry a machine-readable `required_gates` list for an
|
|
35
|
+
individual card. The values are repository-owned gate IDs; Triad does not infer
|
|
36
|
+
their purpose or know whether a gate checks UI, an API, a migration, or another
|
|
37
|
+
concern.
|
|
38
|
+
|
|
39
|
+
An absent or empty list preserves the v1.5 legacy execution behavior. When the
|
|
40
|
+
list is non-empty, the Orchestrator resolves one effective set by taking the
|
|
41
|
+
union of every trusted repository gate whose definition has `required: true`
|
|
42
|
+
and the card-selected IDs. The set is deduplicated. A selected optional gate is
|
|
43
|
+
promoted to required for that card, while unselected optional gates may be
|
|
44
|
+
skipped. The trusted catalog remains authoritative for command, timeout, and
|
|
45
|
+
executor details.
|
|
46
|
+
|
|
47
|
+
Every selected ID is validated before Developer dispatch. A missing ID,
|
|
48
|
+
placeholder command, or unsupported executor is an
|
|
49
|
+
`unavailable_required_gate` capability gap: the Developer is not dispatched and
|
|
50
|
+
no retry budget is consumed. The verifier repeats the binding check so a stale
|
|
51
|
+
assignment fails closed. Verification evidence records the mode, card-selected
|
|
52
|
+
IDs, baseline required IDs, effective IDs, and effective required IDs. A verifier
|
|
53
|
+
pass still always leads to an independent Reviewer; gate pass is not semantic
|
|
54
|
+
approval.
|
|
55
|
+
|
|
32
56
|
## Optional deterministic candidate scope
|
|
33
57
|
|
|
34
58
|
Cards may add a versioned JSON scope contract, bound by path and SHA-256 in the
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# BMAD Story importer
|
|
2
|
+
|
|
3
|
+
This optional integration converts one BMAD Markdown Story with
|
|
4
|
+
`status: ready-for-dev` into a normal Triad feature Card. It is intentionally
|
|
5
|
+
small and deterministic: the source is read-only, caller options are explicit,
|
|
6
|
+
and BMAD workflows are never invoked.
|
|
7
|
+
|
|
8
|
+
See [the public BMAD integration guide](../../docs/bmad-integration.md) for the
|
|
9
|
+
mapping contract, CLI/API examples, provenance sidecar, and fail-closed rules.
|
|
10
|
+
|
|
11
|
+
The implementation is in `story-importer.mjs`. It does not add BMAD-specific
|
|
12
|
+
branches to the Triad Core or infer gates/dependencies from planning order.
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const READY_STATUS = 'ready-for-dev';
|
|
6
|
+
const SOURCE_KIND = 'bmad-story';
|
|
7
|
+
|
|
8
|
+
export class BmadStoryImportError extends Error {
|
|
9
|
+
constructor(code, message, details = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'BmadStoryImportError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sha256(value) {
|
|
18
|
+
return createHash('sha256').update(value).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clean(value) {
|
|
22
|
+
return String(value ?? '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unquote(value) {
|
|
26
|
+
const text = clean(value);
|
|
27
|
+
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
28
|
+
return text.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\'", "'");
|
|
29
|
+
}
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scalar(value) {
|
|
34
|
+
const text = unquote(value);
|
|
35
|
+
if (text === 'null' || text === '~') return null;
|
|
36
|
+
if (text === 'true') return true;
|
|
37
|
+
if (text === 'false') return false;
|
|
38
|
+
if (text.startsWith('[') && text.endsWith(']')) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(text);
|
|
41
|
+
return parsed;
|
|
42
|
+
} catch {
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseFrontmatter(source) {
|
|
50
|
+
if (!source.startsWith('---\n') && !source.startsWith('---\r\n')) return { metadata: {}, body: source };
|
|
51
|
+
const openingEnd = source.indexOf('\n');
|
|
52
|
+
const closing = source.indexOf('\n---', openingEnd + 1);
|
|
53
|
+
if (closing < 0) throw new BmadStoryImportError('bmad_story_invalid', 'BMAD Story frontmatter is not closed.');
|
|
54
|
+
const closingEnd = source.indexOf('\n', closing + 1);
|
|
55
|
+
const frontmatter = source.slice(openingEnd + 1, closing);
|
|
56
|
+
const metadata = {};
|
|
57
|
+
const duplicateKeys = [];
|
|
58
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
59
|
+
if (!line.trim() || line.trimStart().startsWith('#')) continue;
|
|
60
|
+
const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
61
|
+
if (!match) {
|
|
62
|
+
// BMAD frontmatter may contain nested lists/maps. They are not part of
|
|
63
|
+
// this importer contract, but are preserved in the source and ignored.
|
|
64
|
+
if (/^\s+/.test(line) || /^\s*-\s+/.test(line)) continue;
|
|
65
|
+
throw new BmadStoryImportError('bmad_story_invalid', `Malformed BMAD Story frontmatter line: ${line}`);
|
|
66
|
+
}
|
|
67
|
+
const [, key, rawValue] = match;
|
|
68
|
+
if (Object.hasOwn(metadata, key)) duplicateKeys.push(key);
|
|
69
|
+
metadata[key] = scalar(rawValue);
|
|
70
|
+
}
|
|
71
|
+
if (duplicateKeys.length) {
|
|
72
|
+
throw new BmadStoryImportError('bmad_story_ambiguous', `BMAD Story frontmatter repeats: ${duplicateKeys.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
return { metadata, body: closingEnd < 0 ? '' : source.slice(closingEnd + 1) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeHeading(value) {
|
|
78
|
+
return clean(value)
|
|
79
|
+
.replaceAll('`', '')
|
|
80
|
+
.replace(/^\*+|\*+$/g, '')
|
|
81
|
+
.replace(/[::]+$/g, '')
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/&/g, 'and')
|
|
84
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
85
|
+
.trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseSections(body) {
|
|
89
|
+
const sections = [];
|
|
90
|
+
let current = null;
|
|
91
|
+
const flush = () => {
|
|
92
|
+
if (!current) return;
|
|
93
|
+
const content = current.lines.join('\n').trim();
|
|
94
|
+
sections.push({ heading: current.heading, level: current.level, content });
|
|
95
|
+
};
|
|
96
|
+
for (const line of body.split(/\r?\n/)) {
|
|
97
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
98
|
+
if (heading) {
|
|
99
|
+
flush();
|
|
100
|
+
current = { heading: heading[2], level: heading[1].length, lines: [] };
|
|
101
|
+
} else if (current) {
|
|
102
|
+
current.lines.push(line);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
flush();
|
|
106
|
+
return sections;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseLabelBlocks(body) {
|
|
110
|
+
const blocks = [];
|
|
111
|
+
let current = null;
|
|
112
|
+
const flush = () => {
|
|
113
|
+
if (!current) return;
|
|
114
|
+
const content = current.lines.join('\n').trim();
|
|
115
|
+
if (content) blocks.push({ heading: current.heading, content });
|
|
116
|
+
};
|
|
117
|
+
for (const line of body.split(/\r?\n/)) {
|
|
118
|
+
const label = line.match(/^\s*(?:[-*]\s*)?\*\*([^*]+)\*\*\s*:?\s*$/);
|
|
119
|
+
if (label) {
|
|
120
|
+
flush();
|
|
121
|
+
current = { heading: label[1], lines: [] };
|
|
122
|
+
} else if (/^#{1,6}\s+/.test(line)) {
|
|
123
|
+
flush();
|
|
124
|
+
current = null;
|
|
125
|
+
} else if (current) {
|
|
126
|
+
current.lines.push(line);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
flush();
|
|
130
|
+
return blocks;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function valuesForMetadata(metadata, names) {
|
|
134
|
+
const values = [];
|
|
135
|
+
for (const name of names) if (metadata[name] !== undefined && metadata[name] !== null) values.push(metadata[name]);
|
|
136
|
+
return values;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function metadataLines(body, names) {
|
|
140
|
+
const wanted = new Set(names.map((name) => normalizeHeading(name)));
|
|
141
|
+
const values = [];
|
|
142
|
+
for (const line of body.split(/\r?\n/)) {
|
|
143
|
+
const match = line.match(/^\s*(?:[-*]\s*)?(?:\*\*)?([^:*]+?)(?:\*\*)?\s*:\s*(.+?)\s*$/);
|
|
144
|
+
if (match && wanted.has(normalizeHeading(match[1]))) values.push(unquote(match[2]));
|
|
145
|
+
}
|
|
146
|
+
return values;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function contentsFor(sections, labels, labelBlocks = []) {
|
|
150
|
+
const wanted = new Set(labels.map((label) => normalizeHeading(label)));
|
|
151
|
+
const values = [
|
|
152
|
+
...sections.filter((section) => wanted.has(normalizeHeading(section.heading))).map((section) => section.content),
|
|
153
|
+
...labelBlocks.filter((block) => wanted.has(normalizeHeading(block.heading))).map((block) => block.content)
|
|
154
|
+
];
|
|
155
|
+
return [...new Set(values.map(clean).filter(Boolean))];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function firstContent(sections, labelBlocks, labels) {
|
|
159
|
+
const values = contentsFor(sections, labels, labelBlocks);
|
|
160
|
+
return values[0] ?? '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function firstLabelOrSectionContent(sections, labelBlocks, labels) {
|
|
164
|
+
const labeled = contentsFor([], labels, labelBlocks);
|
|
165
|
+
return labeled[0] ?? firstContent(sections, labelBlocks, labels);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function distinctStrings(values, field) {
|
|
169
|
+
const normalized = values.map((value) => clean(value)).filter(Boolean);
|
|
170
|
+
const distinct = [...new Set(normalized)];
|
|
171
|
+
if (distinct.length > 1) {
|
|
172
|
+
throw new BmadStoryImportError('bmad_story_ambiguous', `BMAD Story has conflicting ${field} values.`, { field, values: distinct });
|
|
173
|
+
}
|
|
174
|
+
return distinct[0] ?? '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseStoryHeading(sections) {
|
|
178
|
+
const headings = sections
|
|
179
|
+
.filter((section) => section.level === 1)
|
|
180
|
+
.map((section) => section.heading)
|
|
181
|
+
.map((heading) => {
|
|
182
|
+
const match = clean(heading).match(/^Story\s+([A-Za-z0-9][A-Za-z0-9._-]*)(?:\s*[:—-]\s*(.+))?$/i);
|
|
183
|
+
if (match) return { id: match[1], title: clean(match[2]) };
|
|
184
|
+
const plain = clean(heading).match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*[:—-]\s*(.+)$/);
|
|
185
|
+
return plain ? { id: plain[1], title: clean(plain[2]) } : null;
|
|
186
|
+
})
|
|
187
|
+
.filter(Boolean);
|
|
188
|
+
if (headings.length > 1) {
|
|
189
|
+
throw new BmadStoryImportError('bmad_story_ambiguous', 'BMAD Story source contains multiple story headings.', { headings });
|
|
190
|
+
}
|
|
191
|
+
return headings[0] ?? { id: '', title: '' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeList(value, field) {
|
|
195
|
+
if (value === undefined) return [];
|
|
196
|
+
if (!Array.isArray(value)) throw new BmadStoryImportError('bmad_story_unmappable', `${field} must be an array when supplied.`);
|
|
197
|
+
const result = [];
|
|
198
|
+
for (const item of value) {
|
|
199
|
+
if (typeof item !== 'string' || !item.trim()) throw new BmadStoryImportError('bmad_story_unmappable', `${field} must contain non-empty strings.`);
|
|
200
|
+
const normalized = item.trim();
|
|
201
|
+
if (!result.includes(normalized)) result.push(normalized);
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sourcePathLabel(sourcePath) {
|
|
207
|
+
return sourcePath ? resolve(sourcePath) : '<BMAD Story source>';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function parseBmadStory(source, { sourcePath = null, targetRepository = null } = {}) {
|
|
211
|
+
if (typeof source !== 'string' || !source.trim()) {
|
|
212
|
+
throw new BmadStoryImportError('bmad_story_invalid', 'BMAD Story source is empty.');
|
|
213
|
+
}
|
|
214
|
+
const { metadata, body } = parseFrontmatter(source);
|
|
215
|
+
const sections = parseSections(body);
|
|
216
|
+
const labelBlocks = parseLabelBlocks(body);
|
|
217
|
+
const heading = parseStoryHeading(sections);
|
|
218
|
+
|
|
219
|
+
const id = distinctStrings([
|
|
220
|
+
...valuesForMetadata(metadata, ['id', 'story_id', 'storyId']),
|
|
221
|
+
...metadataLines(body, ['Story ID', 'ID']),
|
|
222
|
+
heading.id
|
|
223
|
+
], 'story id');
|
|
224
|
+
if (!id) throw new BmadStoryImportError('bmad_story_not_found', `No BMAD Story id found in ${sourcePathLabel(sourcePath)}.`);
|
|
225
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
|
|
226
|
+
throw new BmadStoryImportError('bmad_story_invalid', `BMAD Story id is not filename-safe: ${id}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const title = distinctStrings([
|
|
230
|
+
...valuesForMetadata(metadata, ['title']),
|
|
231
|
+
...metadataLines(body, ['Title']),
|
|
232
|
+
heading.title
|
|
233
|
+
], 'title');
|
|
234
|
+
if (!title) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story title is required.');
|
|
235
|
+
|
|
236
|
+
const status = distinctStrings([
|
|
237
|
+
...valuesForMetadata(metadata, ['status']),
|
|
238
|
+
...metadataLines(body, ['Status']),
|
|
239
|
+
firstContent(sections, labelBlocks, ['Status'])
|
|
240
|
+
], 'status').toLowerCase();
|
|
241
|
+
if (!status) throw new BmadStoryImportError('bmad_story_not_ready', 'BMAD Story status is required and must be ready-for-dev.');
|
|
242
|
+
if (status !== READY_STATUS) {
|
|
243
|
+
throw new BmadStoryImportError('bmad_story_not_ready', `BMAD Story ${id} has status ${status}; only ready-for-dev can be imported.`, { status });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const declaredTargetRepository = distinctStrings([
|
|
247
|
+
...valuesForMetadata(metadata, ['target_repository', 'targetRepository', 'repository', 'repo']),
|
|
248
|
+
...metadataLines(body, ['Target repository', 'Target repo', 'Repository'])
|
|
249
|
+
], 'target repository');
|
|
250
|
+
const explicitTargetRepository = targetRepository === undefined || targetRepository === null ? '' : clean(targetRepository);
|
|
251
|
+
if (targetRepository !== undefined && targetRepository !== null && !explicitTargetRepository) {
|
|
252
|
+
throw new BmadStoryImportError('bmad_story_unmappable', 'targetRepository must be a non-empty string when supplied.');
|
|
253
|
+
}
|
|
254
|
+
if (declaredTargetRepository && explicitTargetRepository && declaredTargetRepository !== explicitTargetRepository) {
|
|
255
|
+
throw new BmadStoryImportError('bmad_story_ambiguous', 'BMAD Story target repository conflicts with the explicit importer option.', {
|
|
256
|
+
declared: declaredTargetRepository,
|
|
257
|
+
explicit: explicitTargetRepository
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const resolvedTargetRepository = declaredTargetRepository || explicitTargetRepository;
|
|
261
|
+
if (!resolvedTargetRepository) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story target repository is required.');
|
|
262
|
+
|
|
263
|
+
const outcome = firstContent(sections, labelBlocks, ['Intent', 'Outcome', 'User outcome', 'Objective', 'Story', 'Description']) || distinctStrings([
|
|
264
|
+
...valuesForMetadata(metadata, ['intent', 'outcome', 'objective', 'description']),
|
|
265
|
+
...metadataLines(body, ['Intent', 'Outcome', 'Objective', 'Description'])
|
|
266
|
+
], 'intent/outcome');
|
|
267
|
+
if (!outcome) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story intent/outcome is required.');
|
|
268
|
+
|
|
269
|
+
const acceptanceCriteria = firstContent(sections, labelBlocks, ['Acceptance Criteria', 'Acceptance']) || '';
|
|
270
|
+
if (!acceptanceCriteria) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story acceptance criteria are required.');
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
id,
|
|
274
|
+
title,
|
|
275
|
+
status,
|
|
276
|
+
targetRepository: resolvedTargetRepository,
|
|
277
|
+
branchWorktree: distinctStrings([
|
|
278
|
+
...valuesForMetadata(metadata, ['branch', 'worktree', 'branch_worktree', 'branchWorktree']),
|
|
279
|
+
...metadataLines(body, ['Branch', 'Worktree', 'Branch/worktree'])
|
|
280
|
+
], 'branch/worktree'),
|
|
281
|
+
outcome,
|
|
282
|
+
acceptanceCriteria,
|
|
283
|
+
tasksAcceptance: firstLabelOrSectionContent(sections, labelBlocks, ['Execution', 'Tasks / Subtasks', 'Tasks', 'Tasks & Acceptance']),
|
|
284
|
+
codeMap: firstContent(sections, labelBlocks, ['Code Map', 'Technical Context', 'Technical context / code map', 'Implementation Context']),
|
|
285
|
+
designNotes: firstContent(sections, labelBlocks, ['Design Notes', 'Boundaries & Constraints', 'Constraints', 'Technical Constraints']),
|
|
286
|
+
verification: firstContent(sections, labelBlocks, ['Verification', 'Verification Expectations', 'Metrics and Gates', 'Verification expectations']),
|
|
287
|
+
references: firstContent(sections, labelBlocks, ['Source References', 'References', 'References / Provenance']),
|
|
288
|
+
inScope: firstContent(sections, labelBlocks, ['In Scope', 'Scope']),
|
|
289
|
+
outOfScope: firstContent(sections, labelBlocks, ['Out of Scope', 'Non-goals', 'Non Goals']),
|
|
290
|
+
sourcePath: sourcePath ? resolve(sourcePath) : null
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function listText(values) {
|
|
295
|
+
return values.length ? values.map((value) => `\`${value}\``).join(', ') : 'none';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function sectionOrFallback(value, fallback) {
|
|
299
|
+
return value?.trim() || fallback;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function buildTriadCard(story, { requiredGates = [], dependsOn = [] } = {}) {
|
|
303
|
+
if (!story || typeof story !== 'object') {
|
|
304
|
+
throw new BmadStoryImportError('bmad_story_unmappable', 'A parsed BMAD Story object is required.');
|
|
305
|
+
}
|
|
306
|
+
for (const field of ['id', 'title', 'targetRepository', 'outcome', 'acceptanceCriteria']) {
|
|
307
|
+
if (typeof story[field] !== 'string' || !story[field].trim()) {
|
|
308
|
+
throw new BmadStoryImportError('bmad_story_unmappable', `BMAD Story ${field} is required before Card generation.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (story.status !== READY_STATUS) {
|
|
312
|
+
throw new BmadStoryImportError('bmad_story_not_ready', 'Only a ready-for-dev BMAD Story can become an executable Triad Card.', { status: story.status ?? null });
|
|
313
|
+
}
|
|
314
|
+
const gates = normalizeList(requiredGates, 'requiredGates');
|
|
315
|
+
const dependencies = normalizeList(dependsOn, 'dependsOn');
|
|
316
|
+
const branch = story.branchWorktree || 'not declared by BMAD Story';
|
|
317
|
+
const inScope = sectionOrFallback(story.inScope, 'Acceptance criteria, tasks, and technical context below define the bounded implementation; no additional scope is inferred.');
|
|
318
|
+
const outOfScope = sectionOrFallback(story.outOfScope, 'No additional out-of-scope constraints were declared by the BMAD Story.');
|
|
319
|
+
const tasks = sectionOrFallback(story.tasksAcceptance, 'No Tasks & Acceptance section was supplied by the BMAD Story.');
|
|
320
|
+
const codeMap = sectionOrFallback(story.codeMap, 'No Code Map or technical context section was supplied by the BMAD Story.');
|
|
321
|
+
const designNotes = sectionOrFallback(story.designNotes, 'No Design Notes or additional constraints were supplied by the BMAD Story.');
|
|
322
|
+
const verification = sectionOrFallback(story.verification, 'Use the repository-owned deterministic gates declared by the caller and the normal Triad verifier.');
|
|
323
|
+
const references = sectionOrFallback(story.references, 'No source references were declared by the BMAD Story.');
|
|
324
|
+
|
|
325
|
+
return [
|
|
326
|
+
`# ${story.id} — ${story.title}`,
|
|
327
|
+
'',
|
|
328
|
+
'## Outcome and scope',
|
|
329
|
+
'',
|
|
330
|
+
`- Target repository: \`${story.targetRepository}\``,
|
|
331
|
+
`- Branch/worktree: \`${branch}\``,
|
|
332
|
+
`- Outcome: ${story.outcome}`,
|
|
333
|
+
`- In scope: ${inScope}`,
|
|
334
|
+
`- Out of scope: ${outOfScope}`,
|
|
335
|
+
`- Dependencies: ${listText(dependencies)}`,
|
|
336
|
+
'',
|
|
337
|
+
'## Acceptance criteria',
|
|
338
|
+
'',
|
|
339
|
+
story.acceptanceCriteria,
|
|
340
|
+
'',
|
|
341
|
+
'## Tasks & Acceptance',
|
|
342
|
+
'',
|
|
343
|
+
tasks,
|
|
344
|
+
'',
|
|
345
|
+
'## Technical context / Code Map',
|
|
346
|
+
'',
|
|
347
|
+
codeMap,
|
|
348
|
+
'',
|
|
349
|
+
'## Design Notes / Constraints',
|
|
350
|
+
'',
|
|
351
|
+
designNotes,
|
|
352
|
+
'',
|
|
353
|
+
'## Verification expectations',
|
|
354
|
+
'',
|
|
355
|
+
verification,
|
|
356
|
+
'',
|
|
357
|
+
'## Source references',
|
|
358
|
+
'',
|
|
359
|
+
references,
|
|
360
|
+
'',
|
|
361
|
+
'## Metrics and gates',
|
|
362
|
+
'',
|
|
363
|
+
'| ID | Target | Evidence command or observation |',
|
|
364
|
+
'| --- | --- | --- |',
|
|
365
|
+
'| bmad-story-status | ready-for-dev source imported | source status and hash recorded in integration provenance |',
|
|
366
|
+
`- Required gates (\`required_gates\`): [${gates.join(', ')}]`,
|
|
367
|
+
'- Allowed dependencies: repository-owned dependencies only; no dependency was inferred from BMAD ordering.',
|
|
368
|
+
'',
|
|
369
|
+
'## Integration boundary',
|
|
370
|
+
'',
|
|
371
|
+
'- This card was generated from one BMAD Story; BMAD remains planning authority and Triad remains execution authority.',
|
|
372
|
+
'- The BMAD source is read-only. Provenance is recorded in the integration-side companion record.',
|
|
373
|
+
''
|
|
374
|
+
].join('\n');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function validateTriadCard(card) {
|
|
378
|
+
const required = [
|
|
379
|
+
/^#\s+[^\n]+/m,
|
|
380
|
+
/^## Outcome and scope$/m,
|
|
381
|
+
/^## Acceptance criteria$/m,
|
|
382
|
+
/^## Metrics and gates$/m,
|
|
383
|
+
/Required gates \(`required_gates`\):/,
|
|
384
|
+
/^## Integration boundary$/m
|
|
385
|
+
];
|
|
386
|
+
const missing = required.filter((pattern) => !pattern.test(card)).map((pattern) => pattern.toString());
|
|
387
|
+
return { valid: missing.length === 0, missing };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function readStableSource(sourcePath) {
|
|
391
|
+
const resolved = resolve(sourcePath);
|
|
392
|
+
let first;
|
|
393
|
+
try {
|
|
394
|
+
const sourceStat = await stat(resolved);
|
|
395
|
+
if (!sourceStat.isFile()) throw new Error('not a file');
|
|
396
|
+
first = await readFile(resolved);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw new BmadStoryImportError('bmad_story_not_found', `Cannot read BMAD Story source: ${resolved}`, { cause: error.code ?? 'not_a_file' });
|
|
399
|
+
}
|
|
400
|
+
const sourceText = first.toString('utf8');
|
|
401
|
+
let second;
|
|
402
|
+
try { second = await readFile(resolved); }
|
|
403
|
+
catch (error) { throw new BmadStoryImportError('bmad_story_source_mutated', `BMAD Story source changed during import: ${resolved}`, { cause: error.code ?? 'read_failed' }); }
|
|
404
|
+
if (!first.equals(second)) {
|
|
405
|
+
throw new BmadStoryImportError('bmad_story_source_mutated', `BMAD Story source changed during import: ${resolved}`);
|
|
406
|
+
}
|
|
407
|
+
return { resolved, source: sourceText, sourceSha256: sha256(first) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function importBmadStory({ sourcePath, targetRepository = null, requiredGates = [], dependsOn = [], capturedAt = null } = {}) {
|
|
411
|
+
if (!sourcePath || typeof sourcePath !== 'string') throw new BmadStoryImportError('bmad_story_not_found', 'Provide a BMAD Story source path.');
|
|
412
|
+
const loaded = await readStableSource(sourcePath);
|
|
413
|
+
const story = parseBmadStory(loaded.source, { sourcePath: loaded.resolved, targetRepository });
|
|
414
|
+
const card = buildTriadCard(story, { requiredGates, dependsOn });
|
|
415
|
+
const cardValidation = validateTriadCard(card);
|
|
416
|
+
if (!cardValidation.valid) throw new BmadStoryImportError('bmad_story_unmappable', 'Generated Triad Card failed integration validation.', { missing: cardValidation.missing });
|
|
417
|
+
const provenance = {
|
|
418
|
+
schema_version: 1,
|
|
419
|
+
source_kind: SOURCE_KIND,
|
|
420
|
+
source_path: loaded.resolved,
|
|
421
|
+
source_sha256: loaded.sourceSha256,
|
|
422
|
+
bmad_story_id: story.id,
|
|
423
|
+
target_repository: story.targetRepository,
|
|
424
|
+
card_sha256: sha256(card),
|
|
425
|
+
required_gate_ids: normalizeList(requiredGates, 'requiredGates'),
|
|
426
|
+
depends_on: normalizeList(dependsOn, 'dependsOn'),
|
|
427
|
+
imported_at: capturedAt ?? new Date().toISOString()
|
|
428
|
+
};
|
|
429
|
+
return { story, card, provenance };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function pathExists(target) {
|
|
433
|
+
try { await access(target); return true; } catch { return false; }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function writeImportedCard({ sourcePath, outputPath, provenancePath = null, targetRepository = null, requiredGates = [], dependsOn = [], capturedAt = null } = {}) {
|
|
437
|
+
if (!outputPath || typeof outputPath !== 'string') throw new BmadStoryImportError('bmad_story_unmappable', 'Provide an output path for the generated Triad Card.');
|
|
438
|
+
const output = resolve(outputPath);
|
|
439
|
+
const provenance = resolve(provenancePath || `${output}.bmad-provenance.json`);
|
|
440
|
+
if (output === provenance) throw new BmadStoryImportError('bmad_story_unmappable', 'Card output and provenance output must be different paths.');
|
|
441
|
+
if (await pathExists(output) || await pathExists(provenance)) {
|
|
442
|
+
throw new BmadStoryImportError('bmad_story_unmappable', 'Refusing to overwrite an existing Card or provenance record.', { output, provenance });
|
|
443
|
+
}
|
|
444
|
+
const result = await importBmadStory({ sourcePath, targetRepository, requiredGates, dependsOn, capturedAt });
|
|
445
|
+
await mkdir(dirname(output), { recursive: true });
|
|
446
|
+
await mkdir(dirname(provenance), { recursive: true });
|
|
447
|
+
const outputTemp = `${output}.tmp-${process.pid}`;
|
|
448
|
+
const provenanceTemp = `${provenance}.tmp-${process.pid}`;
|
|
449
|
+
let outputWritten = false;
|
|
450
|
+
let provenanceWritten = false;
|
|
451
|
+
try {
|
|
452
|
+
await writeFile(outputTemp, result.card, { encoding: 'utf8', flag: 'wx' });
|
|
453
|
+
await writeFile(provenanceTemp, `${JSON.stringify({ ...result.provenance, card_path: output }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
454
|
+
await rename(outputTemp, output);
|
|
455
|
+
outputWritten = true;
|
|
456
|
+
await rename(provenanceTemp, provenance);
|
|
457
|
+
provenanceWritten = true;
|
|
458
|
+
} catch (error) {
|
|
459
|
+
await rm(outputTemp, { force: true });
|
|
460
|
+
await rm(provenanceTemp, { force: true });
|
|
461
|
+
if (outputWritten) await rm(output, { force: true });
|
|
462
|
+
if (provenanceWritten) await rm(provenance, { force: true });
|
|
463
|
+
throw new BmadStoryImportError('bmad_story_unmappable', `Could not write imported Card: ${error.message}`, { cause: error.code ?? 'write_failed' });
|
|
464
|
+
}
|
|
465
|
+
return { ...result, outputPath: output, provenancePath: provenance };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export const bmadStoryImporter = {
|
|
469
|
+
sourceKind: SOURCE_KIND,
|
|
470
|
+
readyStatus: READY_STATUS,
|
|
471
|
+
parse: parseBmadStory,
|
|
472
|
+
buildCard: buildTriadCard,
|
|
473
|
+
import: importBmadStory,
|
|
474
|
+
write: writeImportedCard
|
|
475
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triad-plus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|
|
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",
|
|
32
32
|
"pack:check": "npm pack --dry-run"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
package/runtime/lib/gates.mjs
CHANGED
|
@@ -54,6 +54,120 @@ export async function loadTrustedGates(gatesPath, expectedHash) {
|
|
|
54
54
|
return { valid: true, actualHash, gates: parseQualityGates(source) };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function normalizeRequiredGateIds(requiredGateIds) {
|
|
58
|
+
if (requiredGateIds === undefined) return [];
|
|
59
|
+
if (!Array.isArray(requiredGateIds)) throw new Error("required_gate_ids must be an array");
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const normalized = [];
|
|
62
|
+
for (const value of requiredGateIds) {
|
|
63
|
+
if (typeof value !== "string" || !value.trim()) throw new Error("required_gate_ids must contain non-empty strings");
|
|
64
|
+
const id = value.trim();
|
|
65
|
+
if (!seen.has(id)) {
|
|
66
|
+
seen.add(id);
|
|
67
|
+
normalized.push(id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return normalized;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function selectedGateIssues(gate) {
|
|
74
|
+
if (!gate || typeof gate !== "object" || typeof gate.id !== "string" || !gate.id.trim()) return "invalid_definition";
|
|
75
|
+
if (typeof gate.command !== "string" || !gate.command.trim() || /^REPLACE_ME/.test(gate.command.trim())) return "missing_trusted_command";
|
|
76
|
+
if ((gate.executor ?? "control-plane") !== "control-plane") return "unsupported_executor";
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the repository gate catalog and an optional card-level additive
|
|
82
|
+
* selection. The returned gate objects are copies so a selected optional gate
|
|
83
|
+
* can be promoted to required without mutating the trusted catalog.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveGateSelection(gates, requiredGateIds = undefined) {
|
|
86
|
+
if (!Array.isArray(gates)) throw new Error("trusted gates must be an array");
|
|
87
|
+
const cardRequiredGateIds = normalizeRequiredGateIds(requiredGateIds);
|
|
88
|
+
const selected = new Set(cardRequiredGateIds);
|
|
89
|
+
const definitions = new Map();
|
|
90
|
+
const duplicateGateIds = new Set();
|
|
91
|
+
for (const gate of gates) {
|
|
92
|
+
if (typeof gate?.id !== "string" || !gate.id.trim()) continue;
|
|
93
|
+
const id = gate.id.trim();
|
|
94
|
+
if (definitions.has(id)) duplicateGateIds.add(id);
|
|
95
|
+
else definitions.set(id, gate);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const missingGateIds = cardRequiredGateIds.filter((id) => !definitions.has(id));
|
|
99
|
+
const invalidGateIds = cardRequiredGateIds
|
|
100
|
+
.filter((id) => definitions.has(id) && (duplicateGateIds.has(id) || selectedGateIssues(definitions.get(id))))
|
|
101
|
+
.map((id) => ({ id, reason: duplicateGateIds.has(id) ? "duplicate_definition" : selectedGateIssues(definitions.get(id)) }));
|
|
102
|
+
const baselineRequiredGateIds = [];
|
|
103
|
+
const baselineRequired = new Set();
|
|
104
|
+
for (const gate of gates) {
|
|
105
|
+
const id = typeof gate?.id === "string" ? gate.id.trim() : "";
|
|
106
|
+
if (id && gate.required !== false && !baselineRequired.has(id)) {
|
|
107
|
+
baselineRequired.add(id);
|
|
108
|
+
baselineRequiredGateIds.push(id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const mode = cardRequiredGateIds.length > 0 ? "selected" : "legacy";
|
|
113
|
+
if (mode === "legacy") {
|
|
114
|
+
return {
|
|
115
|
+
mode,
|
|
116
|
+
card_required_gate_ids: [],
|
|
117
|
+
baseline_required_gate_ids: baselineRequiredGateIds,
|
|
118
|
+
effective_gate_ids: gates.map((gate) => typeof gate?.id === "string" && gate.id.trim() ? gate.id.trim() : "unknown"),
|
|
119
|
+
effective_required_gate_ids: baselineRequiredGateIds,
|
|
120
|
+
missing_gate_ids: [],
|
|
121
|
+
invalid_gate_ids: [],
|
|
122
|
+
effective_gates: gates.map((gate) => ({ ...gate }))
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const effectiveGateIds = [];
|
|
127
|
+
const effectiveRequiredGateIds = [];
|
|
128
|
+
const effectiveGates = [];
|
|
129
|
+
const emitted = new Set();
|
|
130
|
+
for (const gate of gates) {
|
|
131
|
+
const id = typeof gate?.id === "string" ? gate.id.trim() : "";
|
|
132
|
+
if (!id || emitted.has(id)) continue;
|
|
133
|
+
const isSelected = selected.has(id);
|
|
134
|
+
const isGlobalRequired = gate.required !== false;
|
|
135
|
+
if (mode === "selected" && !isGlobalRequired && !isSelected) continue;
|
|
136
|
+
emitted.add(id);
|
|
137
|
+
effectiveGateIds.push(id);
|
|
138
|
+
if (isGlobalRequired || isSelected) effectiveRequiredGateIds.push(id);
|
|
139
|
+
effectiveGates.push(isSelected ? { ...gate, required: true } : { ...gate });
|
|
140
|
+
}
|
|
141
|
+
for (const id of cardRequiredGateIds) {
|
|
142
|
+
if (!effectiveGateIds.includes(id)) effectiveGateIds.push(id);
|
|
143
|
+
if (!effectiveRequiredGateIds.includes(id)) effectiveRequiredGateIds.push(id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
mode,
|
|
148
|
+
card_required_gate_ids: cardRequiredGateIds,
|
|
149
|
+
baseline_required_gate_ids: baselineRequiredGateIds,
|
|
150
|
+
effective_gate_ids: effectiveGateIds,
|
|
151
|
+
effective_required_gate_ids: effectiveRequiredGateIds,
|
|
152
|
+
missing_gate_ids: missingGateIds,
|
|
153
|
+
invalid_gate_ids: invalidGateIds,
|
|
154
|
+
effective_gates: effectiveGates
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function gateSelectionEvidence(selection) {
|
|
159
|
+
if (!selection) return null;
|
|
160
|
+
return {
|
|
161
|
+
mode: selection.mode,
|
|
162
|
+
card_required_gate_ids: selection.card_required_gate_ids,
|
|
163
|
+
baseline_required_gate_ids: selection.baseline_required_gate_ids,
|
|
164
|
+
effective_gate_ids: selection.effective_gate_ids,
|
|
165
|
+
effective_required_gate_ids: selection.effective_required_gate_ids,
|
|
166
|
+
missing_gate_ids: selection.missing_gate_ids,
|
|
167
|
+
invalid_gate_ids: selection.invalid_gate_ids
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
57
171
|
export async function executeGates(gates, worktree, logDirectory) {
|
|
58
172
|
const results = [];
|
|
59
173
|
for (const gate of gates) {
|
package/runtime/triad-verify.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { writeAtomicJson } from "./lib/evidence.mjs";
|
|
7
7
|
import { calculateCandidateFingerprint, collectCandidateChanges, worktreeBranch } from "./lib/fingerprint.mjs";
|
|
8
|
-
import { executeGates, loadTrustedGates } from "./lib/gates.mjs";
|
|
8
|
+
import { executeGates, gateSelectionEvidence, loadTrustedGates, resolveGateSelection } from "./lib/gates.mjs";
|
|
9
9
|
import { evaluateScopeContract, parseScopeContract } from "./lib/scope-contract.mjs";
|
|
10
10
|
|
|
11
11
|
const argv = process.argv.slice(2);
|
|
@@ -65,7 +65,7 @@ async function resolveAssignment(projectRoot, trigger, explicitAssignment) {
|
|
|
65
65
|
return { assignmentPath, assignment: JSON.parse(source), assignmentHash: sha256(source) };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
async function buildInvalidEvidence({ runId, trigger, assignment, reason, outputPath }) {
|
|
68
|
+
async function buildInvalidEvidence({ runId, trigger, assignment, reason, outputPath, failureCode = "verification_context_invalid", gateSelection = null }) {
|
|
69
69
|
const evidence = {
|
|
70
70
|
schema_version: 1,
|
|
71
71
|
run_id: runId,
|
|
@@ -83,9 +83,10 @@ async function buildInvalidEvidence({ runId, trigger, assignment, reason, output
|
|
|
83
83
|
gates: [],
|
|
84
84
|
required_gates_passed: false,
|
|
85
85
|
status: "invalid_context",
|
|
86
|
-
failure: { code:
|
|
86
|
+
failure: { code: failureCode, reason },
|
|
87
87
|
created_at: new Date().toISOString(),
|
|
88
88
|
};
|
|
89
|
+
if (gateSelection) evidence.gate_selection = gateSelectionEvidence(gateSelection);
|
|
89
90
|
if (outputPath) await writeAtomicJson(outputPath, evidence);
|
|
90
91
|
return evidence;
|
|
91
92
|
}
|
|
@@ -145,6 +146,7 @@ async function main() {
|
|
|
145
146
|
let assignment;
|
|
146
147
|
let assignmentPath;
|
|
147
148
|
let outputPath;
|
|
149
|
+
let gateSelection = null;
|
|
148
150
|
try {
|
|
149
151
|
let assignmentHash;
|
|
150
152
|
({ assignmentPath, assignment, assignmentHash } = await resolveAssignment(projectRoot, trigger, option("--assignment")));
|
|
@@ -170,6 +172,23 @@ async function main() {
|
|
|
170
172
|
const before = await calculateCandidateFingerprint(worktree);
|
|
171
173
|
const branch = await worktreeBranch(worktree);
|
|
172
174
|
if (assignment.expected_branch && assignment.expected_branch !== branch) throw new Error("worktree branch does not match assignment");
|
|
175
|
+
const gatesPath = path.resolve(projectRoot, assignment.gates_path ?? ".loop/quality-gates.yaml");
|
|
176
|
+
const trusted = await loadTrustedGates(gatesPath, assignment.expected_gates_sha256);
|
|
177
|
+
if (!trusted.valid) throw new Error("quality gates are missing or changed from their declared hash");
|
|
178
|
+
try {
|
|
179
|
+
gateSelection = resolveGateSelection(trusted.gates, assignment.required_gate_ids);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
error.code = "unavailable_required_gate";
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (gateSelection.missing_gate_ids.length > 0 || gateSelection.invalid_gate_ids.length > 0) {
|
|
185
|
+
const missing = gateSelection.missing_gate_ids.join(", ");
|
|
186
|
+
const invalid = gateSelection.invalid_gate_ids.map(({ id, reason }) => `${id} (${reason})`).join(", ");
|
|
187
|
+
const details = [missing && `missing: ${missing}`, invalid && `invalid: ${invalid}`].filter(Boolean).join("; ");
|
|
188
|
+
const error = new Error(`unavailable_required_gate: ${details}`);
|
|
189
|
+
error.code = "unavailable_required_gate";
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
173
192
|
const scope = await scopePreflight(projectRoot, worktree, assignment);
|
|
174
193
|
if (scope.status === "fail") {
|
|
175
194
|
const evidence = {
|
|
@@ -192,6 +211,7 @@ async function main() {
|
|
|
192
211
|
repository_skills: repositorySkills,
|
|
193
212
|
scope,
|
|
194
213
|
gates: [],
|
|
214
|
+
gate_selection: gateSelectionEvidence(gateSelection),
|
|
195
215
|
required_gates_passed: false,
|
|
196
216
|
status: "fail",
|
|
197
217
|
failure: { code: "candidate_scope_violation", reason: "candidate changed paths exceed the declared scope contract" },
|
|
@@ -202,11 +222,8 @@ async function main() {
|
|
|
202
222
|
process.exitCode = 2;
|
|
203
223
|
return;
|
|
204
224
|
}
|
|
205
|
-
const gatesPath = path.resolve(projectRoot, assignment.gates_path ?? ".loop/quality-gates.yaml");
|
|
206
|
-
const trusted = await loadTrustedGates(gatesPath, assignment.expected_gates_sha256);
|
|
207
|
-
if (!trusted.valid) throw new Error("quality gates are missing or changed from their declared hash");
|
|
208
225
|
const logDirectory = path.join(evidenceDirectory, "logs");
|
|
209
|
-
const gates = await executeGates(
|
|
226
|
+
const gates = await executeGates(gateSelection.effective_gates, worktree, logDirectory);
|
|
210
227
|
const after = await calculateCandidateFingerprint(worktree);
|
|
211
228
|
const candidateChanged = before.value !== after.value;
|
|
212
229
|
const requiredGatesPassed = !candidateChanged && gates.filter((gate) => gate.required).every((gate) => gate.status === "pass");
|
|
@@ -229,6 +246,7 @@ async function main() {
|
|
|
229
246
|
},
|
|
230
247
|
repository_skills: repositorySkills,
|
|
231
248
|
scope,
|
|
249
|
+
gate_selection: gateSelectionEvidence(gateSelection),
|
|
232
250
|
gates,
|
|
233
251
|
required_gates_passed: requiredGatesPassed,
|
|
234
252
|
status: candidateChanged ? "invalidated" : requiredGatesPassed ? "pass" : "fail",
|
|
@@ -244,7 +262,15 @@ async function main() {
|
|
|
244
262
|
const directory = path.join(projectRoot, ".loop", "evidence", fallbackAssignment.feature_id, `attempt-${String(fallbackAssignment.attempt).padStart(3, "0")}`);
|
|
245
263
|
outputPath = path.join(directory, "verification.json");
|
|
246
264
|
}
|
|
247
|
-
const evidence = await buildInvalidEvidence({
|
|
265
|
+
const evidence = await buildInvalidEvidence({
|
|
266
|
+
runId,
|
|
267
|
+
trigger,
|
|
268
|
+
assignment: fallbackAssignment,
|
|
269
|
+
reason: error.message,
|
|
270
|
+
outputPath,
|
|
271
|
+
failureCode: error.code ?? "verification_context_invalid",
|
|
272
|
+
gateSelection
|
|
273
|
+
});
|
|
248
274
|
process.stdout.write(`${JSON.stringify({ run_id: runId, status: evidence.status, evidence: outputPath ?? null })}\n`);
|
|
249
275
|
process.exitCode = 3;
|
|
250
276
|
}
|
|
@@ -23,6 +23,26 @@
|
|
|
23
23
|
"offending_paths": { "type": "array" }
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
|
+
"gate_selection": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["mode", "card_required_gate_ids", "baseline_required_gate_ids", "effective_gate_ids", "effective_required_gate_ids", "missing_gate_ids", "invalid_gate_ids"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"mode": { "enum": ["legacy", "selected"] },
|
|
31
|
+
"card_required_gate_ids": { "type": "array", "items": { "type": "string" } },
|
|
32
|
+
"baseline_required_gate_ids": { "type": "array", "items": { "type": "string" } },
|
|
33
|
+
"effective_gate_ids": { "type": "array", "items": { "type": "string" } },
|
|
34
|
+
"effective_required_gate_ids": { "type": "array", "items": { "type": "string" } },
|
|
35
|
+
"missing_gate_ids": { "type": "array", "items": { "type": "string" } },
|
|
36
|
+
"invalid_gate_ids": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"required": ["id", "reason"],
|
|
41
|
+
"properties": { "id": { "type": "string" }, "reason": { "type": "string" } }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
26
46
|
"gates": { "type": "array" },
|
|
27
47
|
"required_gates_passed": { "type": "boolean" },
|
|
28
48
|
"status": { "enum": ["pass", "fail", "invalid_context", "infrastructure_error", "invalidated"] },
|
|
@@ -23,6 +23,10 @@ conditions, runnable quality gates, practical-test need, and integration need.
|
|
|
23
23
|
5. Replace every gate placeholder. Gate executors in v1 are only
|
|
24
24
|
`control-plane`; remove a non-applicable gate with a recorded reason instead
|
|
25
25
|
of declaring manual or MCP execution.
|
|
26
|
+
Preserve each card's structured `required_gates` list in the work queue. An
|
|
27
|
+
absent or empty list keeps the legacy project-gate behavior; selected IDs are
|
|
28
|
+
additive and must be validated against the trusted gate catalog before a
|
|
29
|
+
Developer assignment is created.
|
|
26
30
|
6. Create `.loop/runtime/assignments/` and record the active adapter metadata in
|
|
27
31
|
`.loop/runtime/capabilities.json` by running
|
|
28
32
|
`.triad-runtime/triad-runtime-capabilities.mjs --adapter
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
| --- | --- | --- |
|
|
21
21
|
| `<metric>` | `<exact target>` | `<command/measurement>` |
|
|
22
22
|
|
|
23
|
-
- Required gates: `<gate IDs>`
|
|
23
|
+
- Required gates (`required_gates`): `<gate IDs; additive to globally required gates; empty/absent preserves existing project behavior>`
|
|
24
24
|
- Allowed dependencies: `<names or none>`
|
|
25
25
|
- Test fixtures/examples: `<paths>`
|
|
26
26
|
|
|
@@ -31,6 +31,17 @@ presentation for this invocation, do not repeat it.
|
|
|
31
31
|
1. Verify the PRD hash, declared worktree/branch, repository instructions,
|
|
32
32
|
runnable gates, and capability snapshot. The snapshot must reflect
|
|
33
33
|
`project.control_plane.dispatch_mode` as `requested_mode` (default `auto`).
|
|
34
|
+
Read each card's structured `required_gates` list from the queue; do not
|
|
35
|
+
infer gate requirements from prose, filenames, or repository type. An absent
|
|
36
|
+
or empty list is legacy mode. For a non-empty list, load the trusted gate
|
|
37
|
+
catalog and normalize/dedupe the IDs, then validate that every selected gate
|
|
38
|
+
exists, has a configured non-placeholder command, and uses a supported
|
|
39
|
+
executor. An unavailable or invalid selected gate is an owner-visible
|
|
40
|
+
capability gap (`unavailable_required_gate`): do not dispatch the Developer
|
|
41
|
+
and consume no retry budget. Bind the normalized IDs as
|
|
42
|
+
`required_gate_ids` in the assignment together with the existing gate path
|
|
43
|
+
and hash; the trusted catalog remains authoritative for command, timeout, and
|
|
44
|
+
executor details.
|
|
34
45
|
When repository instructions define
|
|
35
46
|
a skill router, read it, select the router, routed skills, and completion
|
|
36
47
|
skill required by the card, and bind their worktree-relative paths plus
|
|
@@ -45,8 +56,11 @@ presentation for this invocation, do not repeat it.
|
|
|
45
56
|
attempt, and create an active assignment before delegating. Before each
|
|
46
57
|
delegation, publish an owner-facing activation notice that attributes the
|
|
47
58
|
configured display name, technical role, and card/attempt to that role.
|
|
48
|
-
3. Give the Developer the card, relevant PRD excerpt, allowed surface,
|
|
49
|
-
|
|
59
|
+
3. Give the Developer the card, relevant PRD excerpt, allowed surface, the
|
|
60
|
+
effective gate IDs (all trusted `required: true` gates plus the card's
|
|
61
|
+
selected IDs), risks, and prior findings. Globally required gates are never
|
|
62
|
+
suppressed. In selected mode, a selected optional gate is required for that card and unselected optional gates may be
|
|
63
|
+
skipped; dedupe the effective set. Treat its command results and report as
|
|
50
64
|
**agent-reported claims**, never as control-plane gate truth.
|
|
51
65
|
4. After completion, move to `verifying`. Follow the recorded dispatch route:
|
|
52
66
|
wait for a valid hook-produced file when one is configured, otherwise invoke
|
|
@@ -61,7 +75,11 @@ presentation for this invocation, do not repeat it.
|
|
|
61
75
|
A Developer report is never a human-input wait condition: immediately wait
|
|
62
76
|
for the configured hook evidence or invoke the verifier, then immediately
|
|
63
77
|
dispatch the Reviewer on a verifier pass. Do not ask the owner to continue
|
|
64
|
-
between Developer completion, verification, and review.
|
|
78
|
+
between Developer completion, verification, and review. The verifier
|
|
79
|
+
resolves the assignment's `required_gate_ids` against the same trusted
|
|
80
|
+
catalog and fails closed if a stale assignment names a missing or invalid
|
|
81
|
+
gate. It records the selection mode, card IDs, effective IDs, and required
|
|
82
|
+
IDs in the verification evidence.
|
|
65
83
|
5. A passing verifier result is **environment-derived evidence**. Move only then
|
|
66
84
|
to `in_review`. Missing, stale, failed, timed-out, invalid-context, or
|
|
67
85
|
invalidated evidence never advances the card.
|