triad-plus 1.4.2 → 1.5.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.5.0 — 2026-09-05
4
+
5
+ - Add cause-coded retry/recovery accounting with independent finite runtime and
6
+ candidate-remediation budgets, plus fail-closed retry-policy validation while
7
+ retaining backward-compatible legacy retry behavior.
8
+ - Add opt-in deterministic candidate scope contracts with card-level baselines,
9
+ changed-path evidence, and scope verification before expensive gates.
10
+ - Keep independent Reviewer approval mandatory after deterministic verification.
11
+
3
12
  ## 1.4.2 — 2026-09-01
4
13
 
5
14
  - Align package repository, homepage, and issue links with the renamed GitHub
@@ -30,3 +30,13 @@ 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.
@@ -44,6 +44,15 @@ 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
+ ## Retry e scope del candidato
48
+
49
+ Gli attempt sono record storici di esecuzione. I nuovi workspace separano e
50
+ limitano le transizioni di recovery runtime e di remediation del candidato: un
51
+ rework o uno scope cleanup non consuma il budget di recovery. Una card può
52
+ attivare un contratto deterministico sui path; il verifier confronta il delta
53
+ completo con il baseline originario della card prima dei gate costosi. Uno scope
54
+ PASS non sostituisce mai la review indipendente.
55
+
47
56
  ## Evaluator+
48
57
 
49
58
  Evaluator+ è opzionale e fuori dal loop produttivo. Se abilitato in `team.json`,
@@ -45,6 +45,16 @@ 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
+ ## Retry accounting and candidate scope
49
+
50
+ Attempts are historical execution records. New workspaces separately bound
51
+ runtime recovery and candidate remediation transitions; a rework or scope
52
+ cleanup does not consume recovery capacity. A card can opt in to a deterministic
53
+ path scope contract. The verifier checks the complete card delta against the
54
+ card's original baseline before expensive gates; a scope failure returns only a
55
+ bounded cleanup to Developer. A scope pass never replaces independent Reviewer
56
+ review.
57
+
48
58
  ## Evaluator+
49
59
 
50
60
  Evaluator+ is optional and outside the production loop. When enabled in
@@ -29,6 +29,27 @@ 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
+ ## Optional deterministic candidate scope
33
+
34
+ Cards may add a versioned JSON scope contract, bound by path and SHA-256 in the
35
+ Developer assignment. The contract names repository-relative `allowed_paths`,
36
+ optional `allowed_incidental_paths`, and `forbidden_paths` for each target
37
+ repository. It uses a small glob syntax: `*` does not cross a directory,
38
+ `**` may cross directories, and `?` matches one non-separator character.
39
+
40
+ For a scope-bound card, the Orchestrator captures a clean card baseline commit
41
+ before the first assignment and reuses it for every rework attempt. The verifier
42
+ compares the full cumulative candidate delta against that baseline before running
43
+ expensive gates. It records changed paths and offending paths in evidence. A
44
+ scope failure skips expensive gates and Reviewer dispatch; it is bounded
45
+ `scope_cleanup`, not approval. A card without a scope contract records
46
+ `scope_not_configured` and retains legacy behavior.
47
+
48
+ Package manifests and lockfiles require an explicit `allowed_incidental_paths`
49
+ entry. Snapshot patterns must be narrow; broad `snapshots/**` contracts are
50
+ rejected. A scope pass is only a deterministic path check: Reviewer remains
51
+ mandatory for semantic scope, correctness, design, and risk.
52
+
32
53
  ## Codex dispatch modes
33
54
 
34
55
  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.4.2",
3
+ "version": "1.5.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 head = (await gitLines(root, ["rev-parse", "HEAD"]))[0] ?? "NO_HEAD";
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
- for (const relativePath of [...changed].sort()) {
38
- if (SENSITIVE_PATH.test(relativePath) || IGNORED_PATH.test(relativePath)) continue;
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 = "DELETED";
42
- try {
43
- const metadata = await stat(absolutePath);
44
- if (metadata.isFile()) contentHash = digest(await readFile(absolutePath));
45
- } catch (error) {
46
- if (error.code !== "ENOENT") throw error;
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: head, files });
51
- return { algorithm: "sha256", value: digest(canonical), git_head: head, files };
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
  }
@@ -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
+ }
@@ -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";
7
+ import { calculateCandidateFingerprint, collectCandidateChanges, worktreeBranch } from "./lib/fingerprint.mjs";
8
8
  import { executeGates, loadTrustedGates } 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) => {
@@ -89,6 +90,51 @@ async function buildInvalidEvidence({ runId, trigger, assignment, reason, output
89
90
  return evidence;
90
91
  }
91
92
 
93
+ function withinRoot(root, candidate, label) {
94
+ const resolved = path.resolve(root, candidate);
95
+ if (!resolved.startsWith(`${root}${path.sep}`)) throw new Error(`${label} escapes project root`);
96
+ return resolved;
97
+ }
98
+
99
+ async function scopePreflight(projectRoot, worktree, assignment) {
100
+ const specification = assignment.scope_contract;
101
+ if (specification === undefined) {
102
+ const candidate = await collectCandidateChanges(worktree);
103
+ return {
104
+ configured: false,
105
+ status: "not_configured",
106
+ baseline: null,
107
+ changed_paths: candidate.changes,
108
+ ignored_paths: candidate.ignored_paths,
109
+ offending_paths: []
110
+ };
111
+ }
112
+ if (!specification || typeof specification !== "object") throw new Error("scope_contract must be an object when declared");
113
+ if (typeof specification.path !== "string" || typeof specification.sha256 !== "string" || typeof specification.repository_id !== "string") {
114
+ throw new Error("scope_contract requires path, sha256, and repository_id");
115
+ }
116
+ const baseline = specification.card_baseline;
117
+ if (!baseline || baseline.initial_state !== "clean" || typeof baseline.git_head !== "string" || typeof baseline.repository_id !== "string") {
118
+ throw new Error("scope_contract requires a clean card_baseline with repository_id and git_head");
119
+ }
120
+ if (baseline.repository_id !== specification.repository_id) throw new Error("scope contract repository does not match card baseline repository");
121
+ const contractPath = withinRoot(projectRoot, specification.path, "scope contract");
122
+ const source = await readFile(contractPath, "utf8");
123
+ const actualHash = sha256(source);
124
+ if (actualHash !== specification.sha256) throw new Error("scope contract hash mismatch");
125
+ const contract = parseScopeContract(source);
126
+ const candidate = await collectCandidateChanges(worktree, { baseCommit: baseline.git_head });
127
+ const changes = candidate.changes.map((change) => ({ ...change, repository: specification.repository_id }));
128
+ const result = evaluateScopeContract({ contract, repository: specification.repository_id, changes });
129
+ return {
130
+ ...result,
131
+ contract_ref: path.relative(projectRoot, contractPath),
132
+ contract_sha256: actualHash,
133
+ baseline: { repository_id: baseline.repository_id, git_head: baseline.git_head },
134
+ ignored_paths: candidate.ignored_paths
135
+ };
136
+ }
137
+
92
138
  async function main() {
93
139
  const payload = await readStdin();
94
140
  const trigger = triggerFrom(payload);
@@ -124,6 +170,38 @@ async function main() {
124
170
  const before = await calculateCandidateFingerprint(worktree);
125
171
  const branch = await worktreeBranch(worktree);
126
172
  if (assignment.expected_branch && assignment.expected_branch !== branch) throw new Error("worktree branch does not match assignment");
173
+ const scope = await scopePreflight(projectRoot, worktree, assignment);
174
+ if (scope.status === "fail") {
175
+ const evidence = {
176
+ schema_version: 1,
177
+ run_id: runId,
178
+ feature_id: assignment.feature_id,
179
+ attempt: assignment.attempt,
180
+ assignment_id: assignment.assignment_id,
181
+ assignment_sha256: assignmentHash,
182
+ trigger,
183
+ assignment_ref: path.relative(projectRoot, assignmentPath),
184
+ baseline: {
185
+ prd_sha256: assignment.expected_prd_sha256,
186
+ card_sha256: assignment.expected_card_sha256,
187
+ gates_sha256: null,
188
+ git_head: before.git_head,
189
+ candidate_fingerprint: before.value,
190
+ branch,
191
+ },
192
+ repository_skills: repositorySkills,
193
+ scope,
194
+ gates: [],
195
+ required_gates_passed: false,
196
+ status: "fail",
197
+ failure: { code: "candidate_scope_violation", reason: "candidate changed paths exceed the declared scope contract" },
198
+ created_at: new Date().toISOString(),
199
+ };
200
+ await writeAtomicJson(outputPath, evidence);
201
+ process.stdout.write(`${JSON.stringify({ run_id: runId, status: evidence.status, evidence: outputPath })}\n`);
202
+ process.exitCode = 2;
203
+ return;
204
+ }
127
205
  const gatesPath = path.resolve(projectRoot, assignment.gates_path ?? ".loop/quality-gates.yaml");
128
206
  const trusted = await loadTrustedGates(gatesPath, assignment.expected_gates_sha256);
129
207
  if (!trusted.valid) throw new Error("quality gates are missing or changed from their declared hash");
@@ -150,6 +228,7 @@ async function main() {
150
228
  branch,
151
229
  },
152
230
  repository_skills: repositorySkills,
231
+ scope,
153
232
  gates,
154
233
  required_gates_passed: requiredGatesPassed,
155
234
  status: candidateChanged ? "invalidated" : requiredGatesPassed ? "pass" : "fail",
@@ -13,6 +13,16 @@
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
+ },
16
26
  "gates": { "type": "array" },
17
27
  "required_gates_passed": { "type": "boolean" },
18
28
  "status": { "enum": ["pass", "fail", "invalid_context", "infrastructure_error", "invalidated"] },
@@ -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. Stop automatic retry at the declared limit and record the
19
- owner decision needed. Normal delivery requires all non-deferred cards approved,
20
- project gates passed, one local commit per approved card, and declared branches
21
- pushed. Pull requests, releases, publication, force pushes, and demo start/stop
22
- remain owner decisions.
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.
@@ -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,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
+ "scope_contract": null,
19
20
  "required_repository_skills": [
20
21
  {
21
22
  "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
+ }
@@ -1,6 +1,7 @@
1
1
  version: 2
2
2
  policy:
3
- max_rework_attempts_per_item: 2
3
+ max_runtime_recoveries_per_item: 2
4
+ max_candidate_remediations_per_item: 2
4
5
  require_reviewer_approval: true
5
6
  require_feature_plan_declaration: true
6
7
  require_clean_diff_check: true
@@ -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. Verify the declared worktree and branch before editing. Do not change
11
- scope, policy, assignments, queue, state, or evidence records.
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
@@ -35,7 +35,12 @@ presentation for this invocation, do not repeat it.
35
35
  a skill router, read it, select the router, routed skills, and completion
36
36
  skill required by the card, and bind their worktree-relative paths plus
37
37
  SHA-256 values in `required_repository_skills` on the Developer assignment.
38
- Do not assign the card if this binding cannot be made.
38
+ Do not assign the card if this binding cannot be made. If the card declares
39
+ an opt-in scope contract, capture its repository ID, file path, SHA-256, and
40
+ a clean card baseline commit before its first Developer assignment. Reuse
41
+ that exact card baseline for every rework attempt: preserved candidate changes
42
+ are normal during rework. Do not create the first scope-bound assignment from
43
+ unattributable dirty product changes; record `invalid_context` instead.
39
44
  2. Choose one dependency-approved `ready` card, mark it `in_progress`, append an
40
45
  attempt, and create an active assignment before delegating. Before each
41
46
  delegation, publish an owner-facing activation notice that attributes the
@@ -47,7 +52,12 @@ presentation for this invocation, do not repeat it.
47
52
  wait for a valid hook-produced file when one is configured, otherwise invoke
48
53
  the verifier explicitly. Accept only current evidence whose assignment ID,
49
54
  feature, attempt, PRD/card/gate hashes, expected branch, and candidate
50
- fingerprint match the active candidate.
55
+ fingerprint match the active candidate. When an opt-in scope contract is
56
+ configured, `triad-verify` first compares the complete candidate delta from
57
+ the card baseline with that contract. A scope fail records offending paths,
58
+ runs no expensive gates, and returns only bounded `scope_cleanup` work to the
59
+ Developer; it never dispatches the Reviewer. `scope_not_configured` preserves
60
+ legacy behavior.
51
61
  A Developer report is never a human-input wait condition: immediately wait
52
62
  for the configured hook evidence or invoke the verifier, then immediately
53
63
  dispatch the Reviewer on a verifier pass. Do not ask the owner to continue
@@ -60,10 +70,22 @@ presentation for this invocation, do not repeat it.
60
70
  - `approved`: verify scope/evidence, commit the card locally, promote every
61
71
  dependency-satisfied draft card to `ready`, then immediately select and
62
72
  assign the next ready card;
63
- - `rework`: preserve findings and return the card to `in_progress` for a new
64
- attempt and new verification;
73
+ - `rework`: preserve findings as `reviewer_rework` and return the card to
74
+ `in_progress` for a new attempt and new verification;
65
75
  - `blocked`: record the exact external condition or owner decision required.
66
- 7. Stop automatic retry at the declared limit and escalate the decision needed.
76
+ 7. Attempts are historical execution numbers, not a retry budget. Before an
77
+ automatic restart, record `resolution.kind`, `evidence_refs`, and whether
78
+ the transition is automatic. For a cause-coded policy, count only previous
79
+ automatic transitions in the relevant family: runtime
80
+ (`runtime_recovery`, `verifier_infrastructure_failure`) or candidate
81
+ remediation (`verifier_candidate_failure`, `reviewer_rework`,
82
+ `scope_cleanup`). A maximum of two permits transition #1 and #2, then
83
+ escalates transition #3. `blocked` never retries. Only verifier/log evidence
84
+ may classify infrastructure; an ambiguous verifier failure is candidate
85
+ remediation or an escalation. A legacy policy declaring only
86
+ `max_rework_attempts_per_item` retains its single-budget behavior; do not
87
+ silently reset its history. Stop automatic retry at the relevant declared
88
+ limit and escalate the decision needed.
67
89
  Do not ask the owner to continue, pause between cards, or finish the run
68
90
  while a dependency-satisfied card remains `ready`; stop only for a declared
69
91
  escalation, a blocked card, or when every required card is terminal.