triad-plus 1.4.2 → 1.6.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 +18 -0
- package/docs/configuration.md +17 -0
- package/docs/operating-guide.it.md +16 -0
- package/docs/operating-guide.md +16 -0
- package/docs/verification.md +45 -0
- package/package.json +2 -2
- package/runtime/lib/fingerprint.mjs +86 -16
- package/runtime/lib/gates.mjs +114 -0
- package/runtime/lib/retry-accounting.mjs +112 -0
- package/runtime/lib/scope-contract.mjs +109 -0
- package/runtime/triad-verify.mjs +111 -6
- package/schemas/verification-evidence.schema.json +30 -0
- package/skills/triad-loop-bootstrap/SKILL.md +4 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/decision-policy.md +22 -5
- package/skills/triad-loop-bootstrap/assets/loop-template/feature-card.template.md +9 -1
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +2 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/scope-contract.template.json +17 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/work-queue.yaml +2 -1
- package/skills/triad-loop-developer/SKILL.md +6 -2
- package/skills/triad-loop-orchestrator/SKILL.md +48 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.0 — 2026-09-05
|
|
4
|
+
|
|
5
|
+
- Add card-declared repository `required_gates` with additive per-card gate
|
|
6
|
+
selection; globally required gates are always preserved.
|
|
7
|
+
- Promote selected optional repository gates to mandatory for the card, with
|
|
8
|
+
trusted assignment binding, fail-closed unavailable-gate handling, and
|
|
9
|
+
generic gate-selection evidence.
|
|
10
|
+
- Preserve the existing legacy behavior when a card has no selected gates.
|
|
11
|
+
|
|
12
|
+
## 1.5.0 — 2026-09-05
|
|
13
|
+
|
|
14
|
+
- Add cause-coded retry/recovery accounting with independent finite runtime and
|
|
15
|
+
candidate-remediation budgets, plus fail-closed retry-policy validation while
|
|
16
|
+
retaining backward-compatible legacy retry behavior.
|
|
17
|
+
- Add opt-in deterministic candidate scope contracts with card-level baselines,
|
|
18
|
+
changed-path evidence, and scope verification before expensive gates.
|
|
19
|
+
- Keep independent Reviewer approval mandatory after deterministic verification.
|
|
20
|
+
|
|
3
21
|
## 1.4.2 — 2026-09-01
|
|
4
22
|
|
|
5
23
|
- Align package repository, homepage, and issue links with the renamed GitHub
|
package/docs/configuration.md
CHANGED
|
@@ -30,3 +30,20 @@ different hosts. The team file records role-level models and effort, but an
|
|
|
30
30
|
adapter writes those into host-native profiles only where the selected host
|
|
31
31
|
supports that facility. A blank model means the host default. Never put tokens,
|
|
32
32
|
API keys, or private deployment data in this file.
|
|
33
|
+
|
|
34
|
+
## Retry and scope policy
|
|
35
|
+
|
|
36
|
+
New control workspaces use separate finite budgets for environment recovery and
|
|
37
|
+
candidate remediation. Existing workspaces that only declare
|
|
38
|
+
`max_rework_attempts_per_item` retain that legacy policy. A card may optionally
|
|
39
|
+
bind a versioned JSON scope contract at its first assignment; without one, the
|
|
40
|
+
deterministic scope preflight is not configured and independent review remains
|
|
41
|
+
the semantic scope check. See [verification.md](verification.md) for the
|
|
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,22 @@ 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
|
+
|
|
54
|
+
## Retry e scope del candidato
|
|
55
|
+
|
|
56
|
+
Gli attempt sono record storici di esecuzione. I nuovi workspace separano e
|
|
57
|
+
limitano le transizioni di recovery runtime e di remediation del candidato: un
|
|
58
|
+
rework o uno scope cleanup non consuma il budget di recovery. Una card può
|
|
59
|
+
attivare un contratto deterministico sui path; il verifier confronta il delta
|
|
60
|
+
completo con il baseline originario della card prima dei gate costosi. Uno scope
|
|
61
|
+
PASS non sostituisce mai la review indipendente.
|
|
62
|
+
|
|
47
63
|
## Evaluator+
|
|
48
64
|
|
|
49
65
|
Evaluator+ è opzionale e fuori dal loop produttivo. Se abilitato in `team.json`,
|
package/docs/operating-guide.md
CHANGED
|
@@ -45,6 +45,22 @@ 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
|
+
|
|
54
|
+
## Retry accounting and candidate scope
|
|
55
|
+
|
|
56
|
+
Attempts are historical execution records. New workspaces separately bound
|
|
57
|
+
runtime recovery and candidate remediation transitions; a rework or scope
|
|
58
|
+
cleanup does not consume recovery capacity. A card can opt in to a deterministic
|
|
59
|
+
path scope contract. The verifier checks the complete card delta against the
|
|
60
|
+
card's original baseline before expensive gates; a scope failure returns only a
|
|
61
|
+
bounded cleanup to Developer. A scope pass never replaces independent Reviewer
|
|
62
|
+
review.
|
|
63
|
+
|
|
48
64
|
## Evaluator+
|
|
49
65
|
|
|
50
66
|
Evaluator+ is optional and outside the production loop. When enabled in
|
package/docs/verification.md
CHANGED
|
@@ -29,6 +29,51 @@ 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
|
+
|
|
56
|
+
## Optional deterministic candidate scope
|
|
57
|
+
|
|
58
|
+
Cards may add a versioned JSON scope contract, bound by path and SHA-256 in the
|
|
59
|
+
Developer assignment. The contract names repository-relative `allowed_paths`,
|
|
60
|
+
optional `allowed_incidental_paths`, and `forbidden_paths` for each target
|
|
61
|
+
repository. It uses a small glob syntax: `*` does not cross a directory,
|
|
62
|
+
`**` may cross directories, and `?` matches one non-separator character.
|
|
63
|
+
|
|
64
|
+
For a scope-bound card, the Orchestrator captures a clean card baseline commit
|
|
65
|
+
before the first assignment and reuses it for every rework attempt. The verifier
|
|
66
|
+
compares the full cumulative candidate delta against that baseline before running
|
|
67
|
+
expensive gates. It records changed paths and offending paths in evidence. A
|
|
68
|
+
scope failure skips expensive gates and Reviewer dispatch; it is bounded
|
|
69
|
+
`scope_cleanup`, not approval. A card without a scope contract records
|
|
70
|
+
`scope_not_configured` and retains legacy behavior.
|
|
71
|
+
|
|
72
|
+
Package manifests and lockfiles require an explicit `allowed_incidental_paths`
|
|
73
|
+
entry. Snapshot patterns must be narrow; broad `snapshots/**` contracts are
|
|
74
|
+
rejected. A scope pass is only a deterministic path check: Reviewer remains
|
|
75
|
+
mandatory for semantic scope, correctness, design, and risk.
|
|
76
|
+
|
|
32
77
|
## Codex dispatch modes
|
|
33
78
|
|
|
34
79
|
Codex uses `explicit_dispatch` by default, including when a compatible async
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triad-plus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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/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",
|
|
32
32
|
"pack:check": "npm pack --dry-run"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
@@ -20,6 +20,70 @@ async function gitLines(worktree, args) {
|
|
|
20
20
|
return parseLines(result.stdout);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
async function gitOutput(worktree, args) {
|
|
24
|
+
const result = await runProcess("git", args, { cwd: worktree, timeoutMs: 15_000 });
|
|
25
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed`);
|
|
26
|
+
return result.stdout;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ignored(relativePath) {
|
|
30
|
+
return SENSITIVE_PATH.test(relativePath) || IGNORED_PATH.test(relativePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function statusFrom(code) {
|
|
34
|
+
if (code.startsWith("R")) return "renamed";
|
|
35
|
+
if (code.startsWith("A")) return "added";
|
|
36
|
+
if (code.startsWith("D")) return "deleted";
|
|
37
|
+
return "modified";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseNameStatus(source) {
|
|
41
|
+
const entries = [];
|
|
42
|
+
for (const line of source.split("\n")) {
|
|
43
|
+
if (!line) continue;
|
|
44
|
+
const [code, firstPath, secondPath] = line.split("\t");
|
|
45
|
+
if (!code || !firstPath) continue;
|
|
46
|
+
if (code.startsWith("R")) {
|
|
47
|
+
if (!secondPath) throw new Error("git rename entry is missing destination path");
|
|
48
|
+
entries.push({ status: "renamed", source: firstPath, destination: secondPath });
|
|
49
|
+
} else {
|
|
50
|
+
entries.push({ status: statusFrom(code), path: firstPath });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return entries;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pathsFor(entry) {
|
|
57
|
+
return entry.status === "renamed" ? [entry.source, entry.destination] : [entry.path];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function collectCandidateChanges(worktree, { baseCommit = null } = {}) {
|
|
61
|
+
const root = await realpath(worktree);
|
|
62
|
+
const head = (await gitLines(root, ["rev-parse", "HEAD"]))[0] ?? "NO_HEAD";
|
|
63
|
+
const base = baseCommit ?? head;
|
|
64
|
+
await gitLines(root, ["rev-parse", "--verify", `${base}^{commit}`]);
|
|
65
|
+
const tracked = parseNameStatus(await gitOutput(root, ["diff", "--name-status", "--find-renames", base]));
|
|
66
|
+
const known = new Set(tracked.flatMap(pathsFor));
|
|
67
|
+
for (const relativePath of await gitLines(root, ["ls-files", "--others", "--exclude-standard"])) {
|
|
68
|
+
if (!known.has(relativePath)) tracked.push({ status: "untracked", path: relativePath });
|
|
69
|
+
}
|
|
70
|
+
const changes = [];
|
|
71
|
+
const ignored_paths = [];
|
|
72
|
+
for (const entry of tracked) {
|
|
73
|
+
const entryPaths = pathsFor(entry);
|
|
74
|
+
if (entryPaths.some(ignored)) {
|
|
75
|
+
ignored_paths.push(...entryPaths.filter(ignored));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const relativePath of entryPaths) {
|
|
79
|
+
const absolutePath = path.resolve(root, relativePath);
|
|
80
|
+
if (!absolutePath.startsWith(`${root}${path.sep}`)) throw new Error(`unsafe changed path: ${relativePath}`);
|
|
81
|
+
}
|
|
82
|
+
changes.push(entry);
|
|
83
|
+
}
|
|
84
|
+
return { git_head: head, base_commit: base, changes, ignored_paths: [...new Set(ignored_paths)].sort() };
|
|
85
|
+
}
|
|
86
|
+
|
|
23
87
|
export async function worktreeBranch(worktree) {
|
|
24
88
|
const root = await realpath(worktree);
|
|
25
89
|
return (await gitLines(root, ["branch", "--show-current"]))[0] ?? "DETACHED";
|
|
@@ -27,26 +91,32 @@ export async function worktreeBranch(worktree) {
|
|
|
27
91
|
|
|
28
92
|
export async function calculateCandidateFingerprint(worktree) {
|
|
29
93
|
const root = await realpath(worktree);
|
|
30
|
-
const
|
|
31
|
-
const changed = new Set([
|
|
32
|
-
...(await gitLines(root, ["diff", "--name-only"])),
|
|
33
|
-
...(await gitLines(root, ["diff", "--cached", "--name-only"])),
|
|
34
|
-
...(await gitLines(root, ["ls-files", "--others", "--exclude-standard"])),
|
|
35
|
-
]);
|
|
94
|
+
const candidate = await collectCandidateChanges(root);
|
|
36
95
|
const files = [];
|
|
37
|
-
|
|
38
|
-
|
|
96
|
+
const changed = new Map();
|
|
97
|
+
for (const entry of candidate.changes) {
|
|
98
|
+
if (entry.status === "renamed") {
|
|
99
|
+
changed.set(entry.source, "DELETED");
|
|
100
|
+
changed.set(entry.destination, null);
|
|
101
|
+
} else {
|
|
102
|
+
changed.set(entry.path, entry.status === "deleted" ? "DELETED" : null);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const [relativePath, knownHash] of [...changed.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
39
106
|
const absolutePath = path.resolve(root, relativePath);
|
|
40
107
|
if (!absolutePath.startsWith(`${root}${path.sep}`)) throw new Error(`unsafe changed path: ${relativePath}`);
|
|
41
|
-
let contentHash =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
108
|
+
let contentHash = knownHash;
|
|
109
|
+
if (contentHash === null) {
|
|
110
|
+
contentHash = "DELETED";
|
|
111
|
+
try {
|
|
112
|
+
const metadata = await stat(absolutePath);
|
|
113
|
+
if (metadata.isFile()) contentHash = digest(await readFile(absolutePath));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code !== "ENOENT") throw error;
|
|
116
|
+
}
|
|
47
117
|
}
|
|
48
118
|
files.push({ path: relativePath, sha256: contentHash });
|
|
49
119
|
}
|
|
50
|
-
const canonical = JSON.stringify({ git_head:
|
|
51
|
-
return { algorithm: "sha256", value: digest(canonical), git_head:
|
|
120
|
+
const canonical = JSON.stringify({ git_head: candidate.git_head, files });
|
|
121
|
+
return { algorithm: "sha256", value: digest(canonical), git_head: candidate.git_head, files };
|
|
52
122
|
}
|
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) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export const resolutionKinds = new Set([
|
|
2
|
+
"runtime_recovery",
|
|
3
|
+
"verifier_infrastructure_failure",
|
|
4
|
+
"verifier_candidate_failure",
|
|
5
|
+
"reviewer_rework",
|
|
6
|
+
"scope_cleanup",
|
|
7
|
+
"blocked"
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const runtimeKinds = new Set(["runtime_recovery", "verifier_infrastructure_failure"]);
|
|
11
|
+
const candidateKinds = new Set(["verifier_candidate_failure", "reviewer_rework", "scope_cleanup"]);
|
|
12
|
+
|
|
13
|
+
export function budgetFamily(kind) {
|
|
14
|
+
if (runtimeKinds.has(kind)) return "runtime";
|
|
15
|
+
if (candidateKinds.has(kind)) return "candidate_remediation";
|
|
16
|
+
if (kind === "blocked") return null;
|
|
17
|
+
throw new Error(`Unknown retry resolution kind: ${kind}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function retryPolicyMode(policy = {}) {
|
|
21
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
22
|
+
throw new Error("retry policy must define either a valid legacy max_rework_attempts_per_item or both max_runtime_recoveries_per_item and max_candidate_remediations_per_item");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hasRuntime = Object.hasOwn(policy, "max_runtime_recoveries_per_item");
|
|
26
|
+
const hasCandidate = Object.hasOwn(policy, "max_candidate_remediations_per_item");
|
|
27
|
+
const runtime = policy.max_runtime_recoveries_per_item;
|
|
28
|
+
const candidate = policy.max_candidate_remediations_per_item;
|
|
29
|
+
|
|
30
|
+
if (hasRuntime || hasCandidate) {
|
|
31
|
+
if (hasRuntime && hasCandidate && Number.isInteger(runtime) && runtime >= 0 && Number.isInteger(candidate) && candidate >= 0) {
|
|
32
|
+
return "cause_coded";
|
|
33
|
+
}
|
|
34
|
+
throw new Error("retry policy must define either a valid legacy max_rework_attempts_per_item or both max_runtime_recoveries_per_item and max_candidate_remediations_per_item");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const legacy = policy.max_rework_attempts_per_item;
|
|
38
|
+
if (Number.isInteger(legacy) && legacy >= 0) return "legacy";
|
|
39
|
+
|
|
40
|
+
throw new Error("retry policy must define either a valid legacy max_rework_attempts_per_item or both max_runtime_recoveries_per_item and max_candidate_remediations_per_item");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function classifyVerifierResolution(evidence = {}) {
|
|
44
|
+
if (evidence.status === "infrastructure_error" || evidence?.failure?.code === "verified_infrastructure_failure") {
|
|
45
|
+
return "verifier_infrastructure_failure";
|
|
46
|
+
}
|
|
47
|
+
return "verifier_candidate_failure";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function automaticResolution(attempt) {
|
|
51
|
+
return attempt?.resolution?.automatic === true ? attempt.resolution : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function legacyReworkCount(attempts) {
|
|
55
|
+
return attempts.filter((attempt) => attempt?.state === "rework").length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function causeCount(attempts, family) {
|
|
59
|
+
return attempts
|
|
60
|
+
.map(automaticResolution)
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.filter((resolution) => budgetFamily(resolution.kind) === family)
|
|
63
|
+
.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function evaluateAutomaticRetry({ policy = {}, attempts = [], kind }) {
|
|
67
|
+
if (!resolutionKinds.has(kind)) throw new Error(`Unsupported retry resolution kind: ${kind}`);
|
|
68
|
+
const mode = retryPolicyMode(policy);
|
|
69
|
+
if (kind === "blocked") {
|
|
70
|
+
return {
|
|
71
|
+
mode,
|
|
72
|
+
allowed: false,
|
|
73
|
+
reason: "blocked_never_retries_automatically",
|
|
74
|
+
family: null,
|
|
75
|
+
previous_automatic_transitions: 0,
|
|
76
|
+
maximum: 0
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (mode === "legacy") {
|
|
81
|
+
const maximum = policy.max_rework_attempts_per_item;
|
|
82
|
+
if (!Number.isInteger(maximum) || maximum < 0) throw new Error("Legacy retry policy requires max_rework_attempts_per_item");
|
|
83
|
+
const previous = legacyReworkCount(attempts);
|
|
84
|
+
return {
|
|
85
|
+
mode,
|
|
86
|
+
allowed: previous < maximum,
|
|
87
|
+
reason: previous < maximum ? "legacy_rework_budget_available" : "legacy_rework_budget_exhausted",
|
|
88
|
+
family: "legacy_rework",
|
|
89
|
+
previous_automatic_transitions: previous,
|
|
90
|
+
maximum
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const family = budgetFamily(kind);
|
|
95
|
+
const policyKey = family === "runtime" ? "max_runtime_recoveries_per_item" : "max_candidate_remediations_per_item";
|
|
96
|
+
const maximum = policy[policyKey];
|
|
97
|
+
const previous = causeCount(attempts, family);
|
|
98
|
+
return {
|
|
99
|
+
mode,
|
|
100
|
+
allowed: previous < maximum,
|
|
101
|
+
reason: previous < maximum ? `${family}_budget_available` : `${family}_budget_exhausted`,
|
|
102
|
+
family,
|
|
103
|
+
previous_automatic_transitions: previous,
|
|
104
|
+
maximum
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolutionRecord(kind, evidenceRefs = [], automatic = true) {
|
|
109
|
+
if (!resolutionKinds.has(kind)) throw new Error(`Unsupported retry resolution kind: ${kind}`);
|
|
110
|
+
if (!Array.isArray(evidenceRefs)) throw new Error("resolution evidence_refs must be an array");
|
|
111
|
+
return { kind, evidence_refs: evidenceRefs, automatic };
|
|
112
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const packagePaths = /(?:^|\/)(?:package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock)$/;
|
|
2
|
+
|
|
3
|
+
function normalized(value) {
|
|
4
|
+
if (typeof value !== "string" || !value || value.startsWith("/") || value.split("/").includes("..")) {
|
|
5
|
+
throw new Error("scope paths and patterns must be non-empty repository-relative paths");
|
|
6
|
+
}
|
|
7
|
+
return value.replaceAll("\\", "/");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function patternExpression(pattern) {
|
|
11
|
+
let expression = "";
|
|
12
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
13
|
+
const character = pattern[index];
|
|
14
|
+
const next = pattern[index + 1];
|
|
15
|
+
if (character === "*" && next === "*") {
|
|
16
|
+
if (pattern[index + 2] === "/") {
|
|
17
|
+
expression += "(?:.*/)?";
|
|
18
|
+
index += 2;
|
|
19
|
+
} else {
|
|
20
|
+
expression += ".*";
|
|
21
|
+
index += 1;
|
|
22
|
+
}
|
|
23
|
+
} else if (character === "*") {
|
|
24
|
+
expression += "[^/]*";
|
|
25
|
+
} else if (character === "?") {
|
|
26
|
+
expression += "[^/]";
|
|
27
|
+
} else {
|
|
28
|
+
expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return new RegExp(`^${expression}$`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validatePatterns(patterns, label) {
|
|
35
|
+
if (patterns === undefined) return [];
|
|
36
|
+
if (!Array.isArray(patterns)) throw new Error(`${label} must be an array`);
|
|
37
|
+
return patterns.map((pattern) => {
|
|
38
|
+
const value = normalized(pattern);
|
|
39
|
+
if ((value.includes("/snapshots/") || value.startsWith("snapshots/")) && (value.startsWith("**/") || value.includes("/snapshots/**") || value.startsWith("snapshots/**") || value.endsWith("/**"))) {
|
|
40
|
+
throw new Error(`snapshot pattern is too broad: ${value}`);
|
|
41
|
+
}
|
|
42
|
+
patternExpression(value);
|
|
43
|
+
return value;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function validateScopeContract(contract) {
|
|
48
|
+
if (!contract || contract.schema_version !== 1 || contract.mode !== "enforce" || !contract.repositories || typeof contract.repositories !== "object" || Array.isArray(contract.repositories)) {
|
|
49
|
+
throw new Error("scope contract requires schema_version 1, mode enforce, and repositories");
|
|
50
|
+
}
|
|
51
|
+
const repositories = {};
|
|
52
|
+
for (const [repository, value] of Object.entries(contract.repositories)) {
|
|
53
|
+
if (!repository || !value || typeof value !== "object" || Array.isArray(value)) throw new Error("scope contract repository entries must be objects");
|
|
54
|
+
const allowed = validatePatterns(value.allowed_paths, `allowed_paths for ${repository}`);
|
|
55
|
+
if (allowed.length === 0) throw new Error(`allowed_paths for ${repository} must not be empty`);
|
|
56
|
+
repositories[repository] = {
|
|
57
|
+
allowed_paths: allowed,
|
|
58
|
+
allowed_incidental_paths: validatePatterns(value.allowed_incidental_paths, `allowed_incidental_paths for ${repository}`),
|
|
59
|
+
forbidden_paths: validatePatterns(value.forbidden_paths, `forbidden_paths for ${repository}`)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { schema_version: 1, mode: "enforce", repositories };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matches(pathname, patterns) {
|
|
66
|
+
return patterns.some((pattern) => patternExpression(pattern).test(pathname));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pathChecks(change) {
|
|
70
|
+
if (change.status === "renamed") {
|
|
71
|
+
return [
|
|
72
|
+
{ path: change.source, side: "source" },
|
|
73
|
+
{ path: change.destination, side: "destination" }
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
return [{ path: change.path, side: null }];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function evaluateScopeContract({ contract, repository, changes }) {
|
|
80
|
+
const validated = validateScopeContract(contract);
|
|
81
|
+
const surface = validated.repositories[repository];
|
|
82
|
+
if (!surface) throw new Error(`scope contract has no surface for repository: ${repository}`);
|
|
83
|
+
const offending_paths = [];
|
|
84
|
+
for (const change of changes) {
|
|
85
|
+
for (const check of pathChecks(change)) {
|
|
86
|
+
const pathname = normalized(check.path);
|
|
87
|
+
let reason = null;
|
|
88
|
+
if (matches(pathname, surface.forbidden_paths)) reason = "forbidden_path";
|
|
89
|
+
else if (packagePaths.test(pathname) && !matches(pathname, surface.allowed_incidental_paths)) reason = "package_or_lock_requires_explicit_incidental_allowance";
|
|
90
|
+
else if (!matches(pathname, surface.allowed_paths) && !matches(pathname, surface.allowed_incidental_paths)) reason = "outside_allowed_surface";
|
|
91
|
+
if (reason) offending_paths.push({ path: pathname, status: change.status, ...(check.side ? { side: check.side } : {}), reason });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
configured: true,
|
|
96
|
+
status: offending_paths.length === 0 ? "pass" : "fail",
|
|
97
|
+
repository,
|
|
98
|
+
changed_paths: changes,
|
|
99
|
+
offending_paths
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseScopeContract(source) {
|
|
104
|
+
try {
|
|
105
|
+
return validateScopeContract(JSON.parse(source));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new Error(`invalid scope contract: ${error.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/runtime/triad-verify.mjs
CHANGED
|
@@ -4,8 +4,9 @@ import { access, readFile, realpath } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { writeAtomicJson } from "./lib/evidence.mjs";
|
|
7
|
-
import { calculateCandidateFingerprint, worktreeBranch } from "./lib/fingerprint.mjs";
|
|
8
|
-
import { executeGates, loadTrustedGates } from "./lib/gates.mjs";
|
|
7
|
+
import { calculateCandidateFingerprint, collectCandidateChanges, worktreeBranch } from "./lib/fingerprint.mjs";
|
|
8
|
+
import { executeGates, gateSelectionEvidence, loadTrustedGates, resolveGateSelection } from "./lib/gates.mjs";
|
|
9
|
+
import { evaluateScopeContract, parseScopeContract } from "./lib/scope-contract.mjs";
|
|
9
10
|
|
|
10
11
|
const argv = process.argv.slice(2);
|
|
11
12
|
const option = (name) => {
|
|
@@ -64,7 +65,7 @@ async function resolveAssignment(projectRoot, trigger, explicitAssignment) {
|
|
|
64
65
|
return { assignmentPath, assignment: JSON.parse(source), assignmentHash: sha256(source) };
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
async function buildInvalidEvidence({ runId, trigger, assignment, reason, outputPath }) {
|
|
68
|
+
async function buildInvalidEvidence({ runId, trigger, assignment, reason, outputPath, failureCode = "verification_context_invalid", gateSelection = null }) {
|
|
68
69
|
const evidence = {
|
|
69
70
|
schema_version: 1,
|
|
70
71
|
run_id: runId,
|
|
@@ -82,13 +83,59 @@ async function buildInvalidEvidence({ runId, trigger, assignment, reason, output
|
|
|
82
83
|
gates: [],
|
|
83
84
|
required_gates_passed: false,
|
|
84
85
|
status: "invalid_context",
|
|
85
|
-
failure: { code:
|
|
86
|
+
failure: { code: failureCode, reason },
|
|
86
87
|
created_at: new Date().toISOString(),
|
|
87
88
|
};
|
|
89
|
+
if (gateSelection) evidence.gate_selection = gateSelectionEvidence(gateSelection);
|
|
88
90
|
if (outputPath) await writeAtomicJson(outputPath, evidence);
|
|
89
91
|
return evidence;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
function withinRoot(root, candidate, label) {
|
|
95
|
+
const resolved = path.resolve(root, candidate);
|
|
96
|
+
if (!resolved.startsWith(`${root}${path.sep}`)) throw new Error(`${label} escapes project root`);
|
|
97
|
+
return resolved;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function scopePreflight(projectRoot, worktree, assignment) {
|
|
101
|
+
const specification = assignment.scope_contract;
|
|
102
|
+
if (specification === undefined) {
|
|
103
|
+
const candidate = await collectCandidateChanges(worktree);
|
|
104
|
+
return {
|
|
105
|
+
configured: false,
|
|
106
|
+
status: "not_configured",
|
|
107
|
+
baseline: null,
|
|
108
|
+
changed_paths: candidate.changes,
|
|
109
|
+
ignored_paths: candidate.ignored_paths,
|
|
110
|
+
offending_paths: []
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (!specification || typeof specification !== "object") throw new Error("scope_contract must be an object when declared");
|
|
114
|
+
if (typeof specification.path !== "string" || typeof specification.sha256 !== "string" || typeof specification.repository_id !== "string") {
|
|
115
|
+
throw new Error("scope_contract requires path, sha256, and repository_id");
|
|
116
|
+
}
|
|
117
|
+
const baseline = specification.card_baseline;
|
|
118
|
+
if (!baseline || baseline.initial_state !== "clean" || typeof baseline.git_head !== "string" || typeof baseline.repository_id !== "string") {
|
|
119
|
+
throw new Error("scope_contract requires a clean card_baseline with repository_id and git_head");
|
|
120
|
+
}
|
|
121
|
+
if (baseline.repository_id !== specification.repository_id) throw new Error("scope contract repository does not match card baseline repository");
|
|
122
|
+
const contractPath = withinRoot(projectRoot, specification.path, "scope contract");
|
|
123
|
+
const source = await readFile(contractPath, "utf8");
|
|
124
|
+
const actualHash = sha256(source);
|
|
125
|
+
if (actualHash !== specification.sha256) throw new Error("scope contract hash mismatch");
|
|
126
|
+
const contract = parseScopeContract(source);
|
|
127
|
+
const candidate = await collectCandidateChanges(worktree, { baseCommit: baseline.git_head });
|
|
128
|
+
const changes = candidate.changes.map((change) => ({ ...change, repository: specification.repository_id }));
|
|
129
|
+
const result = evaluateScopeContract({ contract, repository: specification.repository_id, changes });
|
|
130
|
+
return {
|
|
131
|
+
...result,
|
|
132
|
+
contract_ref: path.relative(projectRoot, contractPath),
|
|
133
|
+
contract_sha256: actualHash,
|
|
134
|
+
baseline: { repository_id: baseline.repository_id, git_head: baseline.git_head },
|
|
135
|
+
ignored_paths: candidate.ignored_paths
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
92
139
|
async function main() {
|
|
93
140
|
const payload = await readStdin();
|
|
94
141
|
const trigger = triggerFrom(payload);
|
|
@@ -99,6 +146,7 @@ async function main() {
|
|
|
99
146
|
let assignment;
|
|
100
147
|
let assignmentPath;
|
|
101
148
|
let outputPath;
|
|
149
|
+
let gateSelection = null;
|
|
102
150
|
try {
|
|
103
151
|
let assignmentHash;
|
|
104
152
|
({ assignmentPath, assignment, assignmentHash } = await resolveAssignment(projectRoot, trigger, option("--assignment")));
|
|
@@ -127,8 +175,55 @@ async function main() {
|
|
|
127
175
|
const gatesPath = path.resolve(projectRoot, assignment.gates_path ?? ".loop/quality-gates.yaml");
|
|
128
176
|
const trusted = await loadTrustedGates(gatesPath, assignment.expected_gates_sha256);
|
|
129
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
|
+
}
|
|
192
|
+
const scope = await scopePreflight(projectRoot, worktree, assignment);
|
|
193
|
+
if (scope.status === "fail") {
|
|
194
|
+
const evidence = {
|
|
195
|
+
schema_version: 1,
|
|
196
|
+
run_id: runId,
|
|
197
|
+
feature_id: assignment.feature_id,
|
|
198
|
+
attempt: assignment.attempt,
|
|
199
|
+
assignment_id: assignment.assignment_id,
|
|
200
|
+
assignment_sha256: assignmentHash,
|
|
201
|
+
trigger,
|
|
202
|
+
assignment_ref: path.relative(projectRoot, assignmentPath),
|
|
203
|
+
baseline: {
|
|
204
|
+
prd_sha256: assignment.expected_prd_sha256,
|
|
205
|
+
card_sha256: assignment.expected_card_sha256,
|
|
206
|
+
gates_sha256: null,
|
|
207
|
+
git_head: before.git_head,
|
|
208
|
+
candidate_fingerprint: before.value,
|
|
209
|
+
branch,
|
|
210
|
+
},
|
|
211
|
+
repository_skills: repositorySkills,
|
|
212
|
+
scope,
|
|
213
|
+
gates: [],
|
|
214
|
+
gate_selection: gateSelectionEvidence(gateSelection),
|
|
215
|
+
required_gates_passed: false,
|
|
216
|
+
status: "fail",
|
|
217
|
+
failure: { code: "candidate_scope_violation", reason: "candidate changed paths exceed the declared scope contract" },
|
|
218
|
+
created_at: new Date().toISOString(),
|
|
219
|
+
};
|
|
220
|
+
await writeAtomicJson(outputPath, evidence);
|
|
221
|
+
process.stdout.write(`${JSON.stringify({ run_id: runId, status: evidence.status, evidence: outputPath })}\n`);
|
|
222
|
+
process.exitCode = 2;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
130
225
|
const logDirectory = path.join(evidenceDirectory, "logs");
|
|
131
|
-
const gates = await executeGates(
|
|
226
|
+
const gates = await executeGates(gateSelection.effective_gates, worktree, logDirectory);
|
|
132
227
|
const after = await calculateCandidateFingerprint(worktree);
|
|
133
228
|
const candidateChanged = before.value !== after.value;
|
|
134
229
|
const requiredGatesPassed = !candidateChanged && gates.filter((gate) => gate.required).every((gate) => gate.status === "pass");
|
|
@@ -150,6 +245,8 @@ async function main() {
|
|
|
150
245
|
branch,
|
|
151
246
|
},
|
|
152
247
|
repository_skills: repositorySkills,
|
|
248
|
+
scope,
|
|
249
|
+
gate_selection: gateSelectionEvidence(gateSelection),
|
|
153
250
|
gates,
|
|
154
251
|
required_gates_passed: requiredGatesPassed,
|
|
155
252
|
status: candidateChanged ? "invalidated" : requiredGatesPassed ? "pass" : "fail",
|
|
@@ -165,7 +262,15 @@ async function main() {
|
|
|
165
262
|
const directory = path.join(projectRoot, ".loop", "evidence", fallbackAssignment.feature_id, `attempt-${String(fallbackAssignment.attempt).padStart(3, "0")}`);
|
|
166
263
|
outputPath = path.join(directory, "verification.json");
|
|
167
264
|
}
|
|
168
|
-
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
|
+
});
|
|
169
274
|
process.stdout.write(`${JSON.stringify({ run_id: runId, status: evidence.status, evidence: outputPath ?? null })}\n`);
|
|
170
275
|
process.exitCode = 3;
|
|
171
276
|
}
|
|
@@ -13,6 +13,36 @@
|
|
|
13
13
|
"assignment_sha256": { "type": ["string", "null"] },
|
|
14
14
|
"trigger": { "type": "object", "required": ["event", "agent_id", "agent_type"], "properties": { "event": { "type": "string" }, "agent_id": { "type": ["string", "null"] }, "agent_type": { "type": ["string", "null"] } } },
|
|
15
15
|
"baseline": { "type": "object", "required": ["prd_sha256", "card_sha256", "git_head", "candidate_fingerprint"], "properties": { "prd_sha256": { "type": ["string", "null"] }, "card_sha256": { "type": ["string", "null"] }, "git_head": { "type": ["string", "null"] }, "candidate_fingerprint": { "type": ["string", "null"] }, "branch": { "type": ["string", "null"] } } },
|
|
16
|
+
"scope": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"configured": { "type": "boolean" },
|
|
20
|
+
"status": { "enum": ["not_configured", "pass", "fail"] },
|
|
21
|
+
"baseline": { "type": ["object", "null"] },
|
|
22
|
+
"changed_paths": { "type": "array" },
|
|
23
|
+
"offending_paths": { "type": "array" }
|
|
24
|
+
}
|
|
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
|
+
},
|
|
16
46
|
"gates": { "type": "array" },
|
|
17
47
|
"required_gates_passed": { "type": "boolean" },
|
|
18
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
|
|
@@ -15,8 +15,25 @@
|
|
|
15
15
|
Only the Orchestrator records transitions. The verifier writes evidence only.
|
|
16
16
|
Developer and Reviewer reports are agent-reported claims; a `control-plane` gate
|
|
17
17
|
is authoritative only through matching verifier evidence. Every new patch needs
|
|
18
|
-
a new verifier run.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
a new verifier run.
|
|
19
|
+
|
|
20
|
+
For a cause-coded policy, `max_runtime_recoveries_per_item` and
|
|
21
|
+
`max_candidate_remediations_per_item` each mean the number of automatic
|
|
22
|
+
transitions permitted for that family. Before starting a retry, count only prior
|
|
23
|
+
automatic resolutions in that family: if the count is lower than the declared
|
|
24
|
+
maximum, record the resolution and start the next attempt; otherwise record an
|
|
25
|
+
escalation and do not start it. Runtime family: `runtime_recovery`,
|
|
26
|
+
`verifier_infrastructure_failure`. Candidate-remediation family:
|
|
27
|
+
`verifier_candidate_failure`, `reviewer_rework`, `scope_cleanup`. `blocked`
|
|
28
|
+
never starts an automatic retry. An ambiguous verifier failure is candidate
|
|
29
|
+
remediation or an escalation; a Developer claim cannot classify it as
|
|
30
|
+
infrastructure. Existing policies that declare only
|
|
31
|
+
`max_rework_attempts_per_item` retain legacy single-budget accounting.
|
|
32
|
+
When both valid cause-coded limits are present, they take precedence even if the
|
|
33
|
+
legacy limit is also present. The cause-coded limits are an atomic pair: they
|
|
34
|
+
must both be non-negative integers, or both be absent. A partial or malformed
|
|
35
|
+
pair is invalid and must not fall back to legacy accounting.
|
|
36
|
+
|
|
37
|
+
Normal delivery requires all non-deferred cards approved, project gates passed,
|
|
38
|
+
one local commit per approved card, and declared branches pushed. Pull requests,
|
|
39
|
+
releases, publication, force pushes, and demo start/stop remain owner decisions.
|
|
@@ -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
|
|
|
@@ -30,6 +30,14 @@
|
|
|
30
30
|
- Required skills: `<router, routed skills, and completion skill>`
|
|
31
31
|
- Assignment evidence: `<relative SKILL.md paths and SHA-256 values>`
|
|
32
32
|
|
|
33
|
+
## Optional deterministic scope contract
|
|
34
|
+
|
|
35
|
+
- Scope contract: `<scope-contracts/<feature ID>.json or not configured>`
|
|
36
|
+
- Repository IDs: `<one or more declared repository IDs>`
|
|
37
|
+
- Card baseline: `<clean commit captured before the first Developer assignment>`
|
|
38
|
+
- The human-readable scope above remains authoritative for semantic review. A
|
|
39
|
+
configured scope contract only permits deterministic changed-path checks.
|
|
40
|
+
|
|
33
41
|
## Integration, practical test, and risk
|
|
34
42
|
|
|
35
43
|
- Local-worktree setup: `<not applicable or command>`
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"expected_prd_sha256": "REPLACE_ME_SHA256",
|
|
17
17
|
"expected_card_sha256": "REPLACE_ME_SHA256",
|
|
18
18
|
"expected_gates_sha256": "REPLACE_ME_SHA256",
|
|
19
|
+
"required_gate_ids": [],
|
|
20
|
+
"scope_contract": null,
|
|
19
21
|
"required_repository_skills": [
|
|
20
22
|
{
|
|
21
23
|
"path": ".agents/skills/router/SKILL.md",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"mode": "enforce",
|
|
4
|
+
"repositories": {
|
|
5
|
+
"product-repository": {
|
|
6
|
+
"allowed_paths": [
|
|
7
|
+
"src/example/**",
|
|
8
|
+
"test/example-*.test.js"
|
|
9
|
+
],
|
|
10
|
+
"allowed_incidental_paths": [],
|
|
11
|
+
"forbidden_paths": [
|
|
12
|
+
"package.json",
|
|
13
|
+
"package-lock.json"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -7,8 +7,12 @@ description: Implement one bounded Triad feature card, add focused tests, and re
|
|
|
7
7
|
|
|
8
8
|
Change the artifact to satisfy the assigned goal. Read the card, PRD excerpt,
|
|
9
9
|
project manifest, repository instructions, allowed surface, prior findings, and
|
|
10
|
-
gates.
|
|
11
|
-
|
|
10
|
+
gates. When the assignment contains a scope contract, read it as a deterministic
|
|
11
|
+
path boundary in addition to the human card scope: keep every cumulative change
|
|
12
|
+
from the card baseline within its allowed paths, and use the recorded offending
|
|
13
|
+
paths for `scope_cleanup`. Verify the declared worktree and branch before
|
|
14
|
+
editing. Do not change scope, policy, assignments, queue, state, or evidence
|
|
15
|
+
records.
|
|
12
16
|
|
|
13
17
|
At the beginning of every activation, read `.triad-plus/team.json` when it
|
|
14
18
|
exists. Your first report to the Orchestrator must identify you as its configured
|
|
@@ -31,27 +31,55 @@ 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
|
|
37
48
|
SHA-256 values in `required_repository_skills` on the Developer assignment.
|
|
38
|
-
Do not assign the card if this binding cannot be made.
|
|
49
|
+
Do not assign the card if this binding cannot be made. If the card declares
|
|
50
|
+
an opt-in scope contract, capture its repository ID, file path, SHA-256, and
|
|
51
|
+
a clean card baseline commit before its first Developer assignment. Reuse
|
|
52
|
+
that exact card baseline for every rework attempt: preserved candidate changes
|
|
53
|
+
are normal during rework. Do not create the first scope-bound assignment from
|
|
54
|
+
unattributable dirty product changes; record `invalid_context` instead.
|
|
39
55
|
2. Choose one dependency-approved `ready` card, mark it `in_progress`, append an
|
|
40
56
|
attempt, and create an active assignment before delegating. Before each
|
|
41
57
|
delegation, publish an owner-facing activation notice that attributes the
|
|
42
58
|
configured display name, technical role, and card/attempt to that role.
|
|
43
|
-
3. Give the Developer the card, relevant PRD excerpt, allowed surface,
|
|
44
|
-
|
|
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
|
|
45
64
|
**agent-reported claims**, never as control-plane gate truth.
|
|
46
65
|
4. After completion, move to `verifying`. Follow the recorded dispatch route:
|
|
47
66
|
wait for a valid hook-produced file when one is configured, otherwise invoke
|
|
48
67
|
the verifier explicitly. Accept only current evidence whose assignment ID,
|
|
49
68
|
feature, attempt, PRD/card/gate hashes, expected branch, and candidate
|
|
50
|
-
fingerprint match the active candidate.
|
|
69
|
+
fingerprint match the active candidate. When an opt-in scope contract is
|
|
70
|
+
configured, `triad-verify` first compares the complete candidate delta from
|
|
71
|
+
the card baseline with that contract. A scope fail records offending paths,
|
|
72
|
+
runs no expensive gates, and returns only bounded `scope_cleanup` work to the
|
|
73
|
+
Developer; it never dispatches the Reviewer. `scope_not_configured` preserves
|
|
74
|
+
legacy behavior.
|
|
51
75
|
A Developer report is never a human-input wait condition: immediately wait
|
|
52
76
|
for the configured hook evidence or invoke the verifier, then immediately
|
|
53
77
|
dispatch the Reviewer on a verifier pass. Do not ask the owner to continue
|
|
54
|
-
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.
|
|
55
83
|
5. A passing verifier result is **environment-derived evidence**. Move only then
|
|
56
84
|
to `in_review`. Missing, stale, failed, timed-out, invalid-context, or
|
|
57
85
|
invalidated evidence never advances the card.
|
|
@@ -60,10 +88,22 @@ presentation for this invocation, do not repeat it.
|
|
|
60
88
|
- `approved`: verify scope/evidence, commit the card locally, promote every
|
|
61
89
|
dependency-satisfied draft card to `ready`, then immediately select and
|
|
62
90
|
assign the next ready card;
|
|
63
|
-
- `rework`: preserve findings and return the card to
|
|
64
|
-
attempt and new verification;
|
|
91
|
+
- `rework`: preserve findings as `reviewer_rework` and return the card to
|
|
92
|
+
`in_progress` for a new attempt and new verification;
|
|
65
93
|
- `blocked`: record the exact external condition or owner decision required.
|
|
66
|
-
7.
|
|
94
|
+
7. Attempts are historical execution numbers, not a retry budget. Before an
|
|
95
|
+
automatic restart, record `resolution.kind`, `evidence_refs`, and whether
|
|
96
|
+
the transition is automatic. For a cause-coded policy, count only previous
|
|
97
|
+
automatic transitions in the relevant family: runtime
|
|
98
|
+
(`runtime_recovery`, `verifier_infrastructure_failure`) or candidate
|
|
99
|
+
remediation (`verifier_candidate_failure`, `reviewer_rework`,
|
|
100
|
+
`scope_cleanup`). A maximum of two permits transition #1 and #2, then
|
|
101
|
+
escalates transition #3. `blocked` never retries. Only verifier/log evidence
|
|
102
|
+
may classify infrastructure; an ambiguous verifier failure is candidate
|
|
103
|
+
remediation or an escalation. A legacy policy declaring only
|
|
104
|
+
`max_rework_attempts_per_item` retains its single-budget behavior; do not
|
|
105
|
+
silently reset its history. Stop automatic retry at the relevant declared
|
|
106
|
+
limit and escalate the decision needed.
|
|
67
107
|
Do not ask the owner to continue, pause between cards, or finish the run
|
|
68
108
|
while a dependency-satisfied card remains `ready`; stop only for a declared
|
|
69
109
|
escalation, a blocked card, or when every required card is terminal.
|