triad-plus 1.5.0 → 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 CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## 1.5.0 — 2026-09-05
4
13
 
5
14
  - Add cause-coded retry/recovery accounting with independent finite runtime and
@@ -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
@@ -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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triad-plus",
3
- "version": "1.5.0",
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",
@@ -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) {
@@ -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: "verification_context_invalid", reason },
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(trusted.gates, worktree, logDirectory);
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({ runId, trigger, assignment: fallbackAssignment, reason: error.message, outputPath });
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
 
@@ -16,6 +16,7 @@
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": [],
19
20
  "scope_contract": null,
20
21
  "required_repository_skills": [
21
22
  {
@@ -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, gates,
49
- risks, and prior findings. Treat its command results and report as
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.