cadet-agent 0.44.0 → 0.46.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/README.md +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +184 -6
- package/src/harness/commands.mjs +9 -0
- package/src/harness/index.mjs +9 -0
- package/src/harness/policy.mjs +96 -1
- package/src/harness/reachability.mjs +431 -0
- package/src/harness/routing.mjs +161 -153
- package/src/harness/state.mjs +101 -5
- package/src/harness/verification.mjs +219 -5
package/src/harness/routing.mjs
CHANGED
|
@@ -1,153 +1,161 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cadet-Agent tool routing.
|
|
3
|
-
*
|
|
4
|
-
* Applies the tool-selection decision tree (contract §8) and capability
|
|
5
|
-
* fallbacks. Routing never pretends a live capability exists: when MCP is
|
|
6
|
-
* unavailable the call falls back to static context and CLI verification.
|
|
7
|
-
* Live-editor mutation requires explicit user confirmation.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { existsSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
12
|
-
import { spawnSync } from 'node:child_process';
|
|
13
|
-
|
|
14
|
-
export const TOOL_KINDS = Object.freeze(['cli', 'read', 'search', 'mcp', 'write', 'manual']);
|
|
15
|
-
|
|
16
|
-
export const ROUTING_REASONS = Object.freeze({
|
|
17
|
-
verification: 'deterministic CLI for verification (exit-code driven)',
|
|
18
|
-
staticContext: 'repository read/search for static context',
|
|
19
|
-
liveInspection: 'MCP for live Unity inspection or mutation',
|
|
20
|
-
unavailable: 'capability unavailable — falling back to a safe alternative',
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
class RoutingError extends Error {
|
|
24
|
-
constructor(message, detail = {}) {
|
|
25
|
-
super(message);
|
|
26
|
-
this.name = 'RoutingError';
|
|
27
|
-
Object.assign(this, detail);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Detect the capability set. Nothing here contacts the network or the Editor;
|
|
33
|
-
* it only reports what the environment supports.
|
|
34
|
-
*/
|
|
35
|
-
export function detectCapabilities({ targetDir = process.cwd(), env = process.env, runner = defaultRunner } = {}) {
|
|
36
|
-
const unity = which('unity', { env, runner });
|
|
37
|
-
const mcpConfigured = existsSync(join(targetDir, '.vscode', 'mcp.json'))
|
|
38
|
-
|| existsSync(join(targetDir, '.cursor', 'mcp.json'))
|
|
39
|
-
|| existsSync(join(targetDir, '.mcp.json'));
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
cli: true,
|
|
43
|
-
unityCli: unity.available ? { available: true, path: unity.path, version: unity.version } : { available: false },
|
|
44
|
-
mcp: mcpConfigured ? { available: true, configured: true } : { available: false, configured: false },
|
|
45
|
-
hook: {
|
|
46
|
-
copilot: existsSync(join(targetDir, '.github', 'hooks', 'git-guard.json')),
|
|
47
|
-
note: 'Cursor, Continue, and Claude Code have no native PreToolUse hook',
|
|
48
|
-
},
|
|
49
|
-
tokenTelemetry: { provider: false, source: 'estimate' },
|
|
50
|
-
costTelemetry: { available: false, reason: 'no model rate card configured' },
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function defaultRunner(cmd, args) {
|
|
55
|
-
return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true, shell: false });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
case '
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Cadet-Agent tool routing.
|
|
3
|
+
*
|
|
4
|
+
* Applies the tool-selection decision tree (contract §8) and capability
|
|
5
|
+
* fallbacks. Routing never pretends a live capability exists: when MCP is
|
|
6
|
+
* unavailable the call falls back to static context and CLI verification.
|
|
7
|
+
* Live-editor mutation requires explicit user confirmation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
export const TOOL_KINDS = Object.freeze(['cli', 'read', 'search', 'mcp', 'write', 'manual']);
|
|
15
|
+
|
|
16
|
+
export const ROUTING_REASONS = Object.freeze({
|
|
17
|
+
verification: 'deterministic CLI for verification (exit-code driven)',
|
|
18
|
+
staticContext: 'repository read/search for static context',
|
|
19
|
+
liveInspection: 'MCP for live Unity inspection or mutation',
|
|
20
|
+
unavailable: 'capability unavailable — falling back to a safe alternative',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
class RoutingError extends Error {
|
|
24
|
+
constructor(message, detail = {}) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'RoutingError';
|
|
27
|
+
Object.assign(this, detail);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect the capability set. Nothing here contacts the network or the Editor;
|
|
33
|
+
* it only reports what the environment supports.
|
|
34
|
+
*/
|
|
35
|
+
export function detectCapabilities({ targetDir = process.cwd(), env = process.env, runner = defaultRunner } = {}) {
|
|
36
|
+
const unity = which('unity', { env, runner });
|
|
37
|
+
const mcpConfigured = existsSync(join(targetDir, '.vscode', 'mcp.json'))
|
|
38
|
+
|| existsSync(join(targetDir, '.cursor', 'mcp.json'))
|
|
39
|
+
|| existsSync(join(targetDir, '.mcp.json'));
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
cli: true,
|
|
43
|
+
unityCli: unity.available ? { available: true, path: unity.path, version: unity.version } : { available: false },
|
|
44
|
+
mcp: mcpConfigured ? { available: true, configured: true } : { available: false, configured: false },
|
|
45
|
+
hook: {
|
|
46
|
+
copilot: existsSync(join(targetDir, '.github', 'hooks', 'git-guard.json')),
|
|
47
|
+
note: 'Cursor, Continue, and Claude Code have no native PreToolUse hook',
|
|
48
|
+
},
|
|
49
|
+
tokenTelemetry: { provider: false, source: 'estimate' },
|
|
50
|
+
costTelemetry: { available: false, reason: 'no model rate card configured' },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function defaultRunner(cmd, args) {
|
|
55
|
+
return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true, shell: false });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every PATH match for `cmd`, in resolution order, or an empty array when it does
|
|
60
|
+
* not resolve. `where` is used on Windows and `which` elsewhere, with `shell: false`
|
|
61
|
+
* so a probe can never itself be reinterpreted by a shell.
|
|
62
|
+
*/
|
|
63
|
+
export function whichAll(cmd, { runner = defaultRunner } = {}) {
|
|
64
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
65
|
+
const res = runner(probe, [cmd]);
|
|
66
|
+
if (!res || res.status !== 0 || !res.stdout) return [];
|
|
67
|
+
return String(res.stdout).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function which(cmd, { env, runner }) {
|
|
71
|
+
const [path] = whichAll(cmd, { runner });
|
|
72
|
+
if (!path) return { available: false };
|
|
73
|
+
let version = null;
|
|
74
|
+
try {
|
|
75
|
+
const v = runner(cmd, ['--version']);
|
|
76
|
+
if (v && v.status === 0 && v.stdout) version = String(v.stdout).trim().split(/\r?\n/)[0];
|
|
77
|
+
} catch { /* version is optional */ }
|
|
78
|
+
return { available: true, path, version };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decide which tool to use for a task. Returns a routing decision with a reason
|
|
83
|
+
* and the fallback that was applied, if any.
|
|
84
|
+
*/
|
|
85
|
+
export function routeTask({ task, capabilities = {} } = {}) {
|
|
86
|
+
if (!task || !TOOL_KINDS.includes(task.kind)) {
|
|
87
|
+
throw new RoutingError(`unknown task kind "${task?.kind}"`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const decide = (tool, reason, extra = {}) => ({
|
|
91
|
+
tool,
|
|
92
|
+
kind: task.kind,
|
|
93
|
+
reason,
|
|
94
|
+
fallbackApplied: false,
|
|
95
|
+
...extra,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
switch (task.kind) {
|
|
99
|
+
case 'cli':
|
|
100
|
+
case 'manual':
|
|
101
|
+
return decide(task.kind, ROUTING_REASONS.verification);
|
|
102
|
+
|
|
103
|
+
case 'read':
|
|
104
|
+
case 'search':
|
|
105
|
+
return decide(task.kind, ROUTING_REASONS.staticContext);
|
|
106
|
+
|
|
107
|
+
case 'write': {
|
|
108
|
+
// A write to a live Editor is a mutation and needs confirmation.
|
|
109
|
+
if (task.target === 'live-editor') {
|
|
110
|
+
if (!task.userConfirmed) {
|
|
111
|
+
throw new RoutingError('live-editor mutation requires explicit user confirmation', { blocked: true, requiresConfirmation: true });
|
|
112
|
+
}
|
|
113
|
+
if (!capabilities.mcp?.available) {
|
|
114
|
+
return decide('manual', ROUTING_REASONS.unavailable, {
|
|
115
|
+
fallbackApplied: true,
|
|
116
|
+
fallback: 'live-editor mutation requested but MCP is unavailable; ask the user to perform it',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return decide('mcp', ROUTING_REASONS.liveInspection, { userConfirmed: true });
|
|
120
|
+
}
|
|
121
|
+
return decide('write', 'repository write within the workspace');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'mcp': {
|
|
125
|
+
if (capabilities.mcp?.available) {
|
|
126
|
+
return decide('mcp', ROUTING_REASONS.liveInspection);
|
|
127
|
+
}
|
|
128
|
+
// Never pretend live inspection occurred.
|
|
129
|
+
const fallback = task.fallback === 'cli' || task.verification
|
|
130
|
+
? decide('cli', ROUTING_REASONS.unavailable, { fallbackApplied: true, fallback: 'static context + CLI verification' })
|
|
131
|
+
: decide('read', ROUTING_REASONS.unavailable, { fallbackApplied: true, fallback: 'static context only' });
|
|
132
|
+
return { ...fallback, liveInspectionPerformed: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
default:
|
|
136
|
+
throw new RoutingError(`unhandled task kind "${task.kind}"`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Record a tool call. Arguments are sanitized by the caller (see redaction.mjs)
|
|
142
|
+
* before persistence; this function only shapes the span.
|
|
143
|
+
*/
|
|
144
|
+
export function toolCallSpan({ tool, args = {}, rationale, startedAt, finishedAt, durationMs, resultClass, outputBytes = 0, artifactPath = null, exitCode = null, retryNumber = 0 }) {
|
|
145
|
+
return {
|
|
146
|
+
kind: 'tool-call',
|
|
147
|
+
tool,
|
|
148
|
+
args,
|
|
149
|
+
reason: rationale,
|
|
150
|
+
startedAt,
|
|
151
|
+
finishedAt,
|
|
152
|
+
durationMs,
|
|
153
|
+
result: resultClass,
|
|
154
|
+
outputBytes,
|
|
155
|
+
artifactPath,
|
|
156
|
+
exitCode,
|
|
157
|
+
retryNumber,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export { RoutingError };
|
package/src/harness/state.mjs
CHANGED
|
@@ -8,13 +8,18 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync, writeFileSync, renameSync, copyFileSync, existsSync, rmSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
11
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
12
12
|
import {
|
|
13
13
|
PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, DEFAULT_STRICT_CLOSURE,
|
|
14
14
|
EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE,
|
|
15
|
+
REACHABILITY_GATE, REACHABILITY_TRANSITION_FROM,
|
|
15
16
|
} from './policy.mjs';
|
|
16
17
|
import { hashTree, hashFile, hashCriteria, timestamp, isUuid } from './util.mjs';
|
|
17
18
|
import { encodeEvidenceTrailers } from './gitmemo.mjs';
|
|
19
|
+
import {
|
|
20
|
+
parseReachabilityDeclaration, validateReachabilityDeclaration, collectWorkItems,
|
|
21
|
+
findDeferralCycles, readSiblingDeclarations,
|
|
22
|
+
} from './reachability.mjs';
|
|
18
23
|
|
|
19
24
|
export { PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES };
|
|
20
25
|
export { EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS };
|
|
@@ -1092,9 +1097,25 @@ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
|
|
|
1092
1097
|
// ── Transitions ─────────────────────────────────────────────────────────────
|
|
1093
1098
|
|
|
1094
1099
|
/** Required gates for a transition target, or null when the target is not gated. */
|
|
1095
|
-
export function requiredGates(toPhase) {
|
|
1100
|
+
export function requiredGates(toPhase, { reachability = false } = {}) {
|
|
1096
1101
|
for (const [from, spec] of Object.entries(TRANSITIONS)) {
|
|
1097
|
-
if (spec.to === toPhase)
|
|
1102
|
+
if (spec.to === toPhase) {
|
|
1103
|
+
const gates = [...spec.gates];
|
|
1104
|
+
// The reachability gate is appended ONLY to the review -> validation
|
|
1105
|
+
// transition, and only when the repository has opted in
|
|
1106
|
+
// (`reachability.enabled`). Two reasons for both halves of that:
|
|
1107
|
+
//
|
|
1108
|
+
// - PLACEMENT: reachability is a claim about a delivered, reviewed story,
|
|
1109
|
+
// so it is checked at the same point as the review gates rather than at
|
|
1110
|
+
// implementation, where the wiring may legitimately not exist yet.
|
|
1111
|
+
// - OPT-IN: an entering-`review` requirement would block every in-flight
|
|
1112
|
+
// story in every existing consumer on a framework update. With the
|
|
1113
|
+
// default OFF the list is exactly what the matrix declares, so a
|
|
1114
|
+
// single-argument call — and every existing caller and test — sees
|
|
1115
|
+
// unchanged behaviour. See REACHABILITY_GATE.
|
|
1116
|
+
if (reachability && from === REACHABILITY_TRANSITION_FROM) gates.push(REACHABILITY_GATE);
|
|
1117
|
+
return { from, gates, revalidate: spec.revalidate || [] };
|
|
1118
|
+
}
|
|
1098
1119
|
}
|
|
1099
1120
|
return null;
|
|
1100
1121
|
}
|
|
@@ -1205,6 +1226,60 @@ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase,
|
|
|
1205
1226
|
return { missingGates, staleEvidence };
|
|
1206
1227
|
}
|
|
1207
1228
|
|
|
1229
|
+
/**
|
|
1230
|
+
* Re-derive the opted-in reachability declaration against the CURRENT state at
|
|
1231
|
+
* `validation -> closed` (contract v6 §4, closure re-examination). A deferral
|
|
1232
|
+
* is a claim about the future, so closure re-reads the story's declaration and
|
|
1233
|
+
* re-validates it the same way `harness verify-reachability` does: a deferral
|
|
1234
|
+
* whose target is now `done`, one whose target has vanished, or a cycle that
|
|
1235
|
+
* has since formed all refuse closure. The evidence record is used only to
|
|
1236
|
+
* LOCATE the story file — its hashes and phase stamp are deliberately not
|
|
1237
|
+
* re-checked, because the record is legitimately created during `review` and
|
|
1238
|
+
* the phase/recency freshness machinery would wrongly reject it here.
|
|
1239
|
+
*/
|
|
1240
|
+
function recheckReachabilityAtClosure({ state, rootDir }) {
|
|
1241
|
+
const missingGates = [];
|
|
1242
|
+
const staleEvidence = [];
|
|
1243
|
+
const refuse = (reasons) => {
|
|
1244
|
+
missingGates.push(REACHABILITY_GATE);
|
|
1245
|
+
staleEvidence.push({ gate: REACHABILITY_GATE, reasons });
|
|
1246
|
+
return { missingGates, staleEvidence };
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
const record = latestEvidenceForGate(state, REACHABILITY_GATE);
|
|
1250
|
+
if (!record) {
|
|
1251
|
+
return refuse(['no reachability evidence record for the opted-in gate']);
|
|
1252
|
+
}
|
|
1253
|
+
const relevant = Array.isArray(record.relevantFiles) ? record.relevantFiles : [];
|
|
1254
|
+
const storyRef = relevant[0];
|
|
1255
|
+
if (!storyRef) {
|
|
1256
|
+
return refuse(['the reachability evidence record names no story file']);
|
|
1257
|
+
}
|
|
1258
|
+
// Records written since the path-binding fix hold a repo-relative path; older
|
|
1259
|
+
// ones may hold an absolute path, which is used as-is.
|
|
1260
|
+
const storyPath = isAbsolute(storyRef) ? storyRef : join(rootDir, storyRef);
|
|
1261
|
+
|
|
1262
|
+
let declaration;
|
|
1263
|
+
try {
|
|
1264
|
+
declaration = parseReachabilityDeclaration(storyPath);
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
return refuse([`cannot read the declared story "${storyRef}": ${err.message}`]);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const workItems = collectWorkItems(state);
|
|
1270
|
+
const selfName = basename(storyRef.replace(/\\/g, '/'));
|
|
1271
|
+
const validation = validateReachabilityDeclaration(declaration, { workItems, self: selfName });
|
|
1272
|
+
const cycles = findDeferralCycles(readSiblingDeclarations(storyPath, { workItems }));
|
|
1273
|
+
|
|
1274
|
+
const reasons = [];
|
|
1275
|
+
if (validation.ok !== true) reasons.push(validation.message);
|
|
1276
|
+
for (const cycle of cycles) {
|
|
1277
|
+
reasons.push(`reachability deferral cycle: ${cycle.join(' -> ')} — nothing in this loop can ever be witnessed`);
|
|
1278
|
+
}
|
|
1279
|
+
if (reasons.length > 0) return refuse(reasons);
|
|
1280
|
+
return { missingGates, staleEvidence };
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1208
1283
|
/**
|
|
1209
1284
|
* Evaluate whether a transition is legal and evidence-backed.
|
|
1210
1285
|
* Returns a machine-readable result:
|
|
@@ -1231,7 +1306,9 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1231
1306
|
return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
|
|
1232
1307
|
}
|
|
1233
1308
|
|
|
1234
|
-
|
|
1309
|
+
// The reachability gate joins the requirement only when the repository has
|
|
1310
|
+
// opted in, so a project that has not sees the pre-existing gate list exactly.
|
|
1311
|
+
const spec = requiredGates(toPhase, { reachability: context.policy?.reachability?.enabled === true });
|
|
1235
1312
|
if (!spec) {
|
|
1236
1313
|
// Ungated transitions are legal ONLY along the declared forward edges
|
|
1237
1314
|
// (bootstrap + planning progression). A target that is neither gated nor a
|
|
@@ -1280,6 +1357,21 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1280
1357
|
missingGates.push(...r.missingGates);
|
|
1281
1358
|
staleEvidence.push(...r.staleEvidence);
|
|
1282
1359
|
}
|
|
1360
|
+
|
|
1361
|
+
// Contract v6 §4: an opted-in reachability declaration is ALSO re-examined
|
|
1362
|
+
// at `validation -> closed`. This is deliberately NOT routed through the
|
|
1363
|
+
// freshness machinery the other revalidated gates use — the record is
|
|
1364
|
+
// legitimately created during `review`, so phase- and recency-staleness
|
|
1365
|
+
// would wrongly reject it — so the conditional append lives here rather
|
|
1366
|
+
// than in the TRANSITIONS table (which stays exactly as C4 declares it).
|
|
1367
|
+
if (toPhase === 'closed' && context.policy?.reachability?.enabled === true) {
|
|
1368
|
+
revalidated.push(REACHABILITY_GATE);
|
|
1369
|
+
if (!exceptions[REACHABILITY_GATE]) {
|
|
1370
|
+
const r = recheckReachabilityAtClosure({ state, rootDir });
|
|
1371
|
+
missingGates.push(...r.missingGates);
|
|
1372
|
+
staleEvidence.push(...r.staleEvidence);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1283
1375
|
}
|
|
1284
1376
|
|
|
1285
1377
|
return {
|
|
@@ -1299,12 +1391,16 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1299
1391
|
* Resets the target transition's gates is NOT done here — gates reset when a new
|
|
1300
1392
|
* work item starts (see `resetGatesForNewWorkItem`).
|
|
1301
1393
|
*/
|
|
1302
|
-
export function applyTransition(state, toPhase, { evidenceIds = [], at = new Date(), inputTreeHash = undefined, criteriaHash = undefined, rootDir = undefined, strictClosure = undefined } = {}) {
|
|
1394
|
+
export function applyTransition(state, toPhase, { evidenceIds = [], at = new Date(), inputTreeHash = undefined, criteriaHash = undefined, rootDir = undefined, strictClosure = undefined, policy = undefined } = {}) {
|
|
1303
1395
|
const context = { now: at };
|
|
1304
1396
|
if (inputTreeHash !== undefined) context.inputTreeHash = inputTreeHash;
|
|
1305
1397
|
if (criteriaHash !== undefined) context.criteriaHash = criteriaHash;
|
|
1306
1398
|
if (rootDir !== undefined) context.rootDir = rootDir;
|
|
1307
1399
|
if (strictClosure !== undefined) context.strictClosure = strictClosure;
|
|
1400
|
+
// The resolved policy, so conditional gates (contract v6 `reachability`) are
|
|
1401
|
+
// enforced by the internal re-evaluation too — not just by the caller's own
|
|
1402
|
+
// pre-check. A library caller that omits it gets the pre-v6 gate list.
|
|
1403
|
+
if (policy !== undefined) context.policy = policy;
|
|
1308
1404
|
const evaluation = evaluateTransition(state, toPhase, context);
|
|
1309
1405
|
if (!evaluation.allowed) {
|
|
1310
1406
|
throw new StateError(
|