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.
@@ -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
- function which(cmd, { env, runner }) {
59
- const probe = process.platform === 'win32' ? 'where' : 'which';
60
- const res = runner(probe, [cmd]);
61
- if (res && res.status === 0 && res.stdout) {
62
- const path = String(res.stdout).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
63
- let version = null;
64
- try {
65
- const v = runner(cmd, ['--version']);
66
- if (v && v.status === 0 && v.stdout) version = String(v.stdout).trim().split(/\r?\n/)[0];
67
- } catch { /* version is optional */ }
68
- return { available: true, path, version };
69
- }
70
- return { available: false };
71
- }
72
-
73
- /**
74
- * Decide which tool to use for a task. Returns a routing decision with a reason
75
- * and the fallback that was applied, if any.
76
- */
77
- export function routeTask({ task, capabilities = {} } = {}) {
78
- if (!task || !TOOL_KINDS.includes(task.kind)) {
79
- throw new RoutingError(`unknown task kind "${task?.kind}"`);
80
- }
81
-
82
- const decide = (tool, reason, extra = {}) => ({
83
- tool,
84
- kind: task.kind,
85
- reason,
86
- fallbackApplied: false,
87
- ...extra,
88
- });
89
-
90
- switch (task.kind) {
91
- case 'cli':
92
- case 'manual':
93
- return decide(task.kind, ROUTING_REASONS.verification);
94
-
95
- case 'read':
96
- case 'search':
97
- return decide(task.kind, ROUTING_REASONS.staticContext);
98
-
99
- case 'write': {
100
- // A write to a live Editor is a mutation and needs confirmation.
101
- if (task.target === 'live-editor') {
102
- if (!task.userConfirmed) {
103
- throw new RoutingError('live-editor mutation requires explicit user confirmation', { blocked: true, requiresConfirmation: true });
104
- }
105
- if (!capabilities.mcp?.available) {
106
- return decide('manual', ROUTING_REASONS.unavailable, {
107
- fallbackApplied: true,
108
- fallback: 'live-editor mutation requested but MCP is unavailable; ask the user to perform it',
109
- });
110
- }
111
- return decide('mcp', ROUTING_REASONS.liveInspection, { userConfirmed: true });
112
- }
113
- return decide('write', 'repository write within the workspace');
114
- }
115
-
116
- case 'mcp': {
117
- if (capabilities.mcp?.available) {
118
- return decide('mcp', ROUTING_REASONS.liveInspection);
119
- }
120
- // Never pretend live inspection occurred.
121
- const fallback = task.fallback === 'cli' || task.verification
122
- ? decide('cli', ROUTING_REASONS.unavailable, { fallbackApplied: true, fallback: 'static context + CLI verification' })
123
- : decide('read', ROUTING_REASONS.unavailable, { fallbackApplied: true, fallback: 'static context only' });
124
- return { ...fallback, liveInspectionPerformed: false };
125
- }
126
-
127
- default:
128
- throw new RoutingError(`unhandled task kind "${task.kind}"`);
129
- }
130
- }
131
-
132
- /**
133
- * Record a tool call. Arguments are sanitized by the caller (see redaction.mjs)
134
- * before persistence; this function only shapes the span.
135
- */
136
- export function toolCallSpan({ tool, args = {}, rationale, startedAt, finishedAt, durationMs, resultClass, outputBytes = 0, artifactPath = null, exitCode = null, retryNumber = 0 }) {
137
- return {
138
- kind: 'tool-call',
139
- tool,
140
- args,
141
- reason: rationale,
142
- startedAt,
143
- finishedAt,
144
- durationMs,
145
- result: resultClass,
146
- outputBytes,
147
- artifactPath,
148
- exitCode,
149
- retryNumber,
150
- };
151
- }
152
-
153
- export { RoutingError };
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 };
@@ -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) return { from, gates: spec.gates, revalidate: spec.revalidate || [] };
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
- const spec = requiredGates(toPhase);
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(