cadet-agent 0.20.2 → 0.24.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 CHANGED
@@ -7,8 +7,13 @@ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished g
7
7
  ## Repository Layout
8
8
  - `.cadet/agent/core/` contains the shared Cadet-Agent framework documents.
9
9
  - `cadet-agent.md` is the thin global directive: identity, non-negotiable rules, workflow routing, hard-gate protocol, and skill dispatch.
10
- - `skills/` contains scoped workflow-phase skills (Requirements, Architecture, Spike, StoryBreakdown, TDD, Debugging, CodeReview).
10
+ - `Harness.md` is the canonical harness contract: budgets, evidence-backed gates, retries, context tiers, tool routing, privacy, and escalation.
11
+ - `harness.schema.json` and `state.schema.json` are the machine-readable schemas for harness records and session state.
12
+ - `skills/` contains scoped workflow-phase skills (Requirements, Architecture, Spike, StoryBreakdown, TDD, Debugging, CodeReview, Resume, MCPSetup, AgentReviewer).
11
13
  - `templates/` contains runtime templates for planning artifacts.
14
+ - `.cadet/harness.json` holds repository-local budget/policy overrides (preserved by sync).
15
+ - `.cadet/runs/` holds sanitized run ledgers (preserved by sync; no secrets or raw prompts by default).
16
+ - `src/harness/` contains the dependency-free harness implementation (policy, budget, state, verification, context, routing, redaction, ledger, archive, hook).
12
17
  - `.cadet/agent/docs/` contains setup guides for each supported IDE.
13
18
  - `.github/agents/` contains the Copilot custom agent definitions (Cadet Agent + Cadet Agent Reviewer).
14
19
  - `.github/prompts/` contains Copilot slash-command skill prompts (`/cadet-review`, `/cadet-tdd`, etc.).
@@ -21,7 +26,7 @@ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished g
21
26
 
22
27
  ## Cross-IDE Support
23
28
 
24
- Cadet-Agent provides full workflow parity across four IDEs. The same 7 skills + resume + reviewer are available in each:
29
+ Cadet-Agent provides full workflow parity across four IDEs. The same 9 skills + reviewer are available in each:
25
30
 
26
31
  | Feature | GitHub Copilot | Cursor | Continue | Claude Code |
27
32
  |---|---|---|---|---|
@@ -35,6 +40,7 @@ Cadet-Agent provides full workflow parity across four IDEs. The same 7 skills +
35
40
  | Debugging | ✅ | ✅ | ✅ | ✅ |
36
41
  | Code Review | ✅ | ✅ | ✅ | ✅ |
37
42
  | Resume | ✅ | ✅ | ✅ | ✅ |
43
+ | MCP Setup | ✅ | ✅ | ✅ | ✅ |
38
44
  | Reviewer mode | Agent picker | Rule toggle | `/cadet-agent-reviewer` | `/cadet-agent-reviewer` |
39
45
  | Git guard | PreToolUse hook | Manual | Manual | Manual |
40
46
 
@@ -58,7 +64,7 @@ npx cadet-agent@latest init --target ./my-unity-project
58
64
  npx cadet-agent@latest sync
59
65
  ```
60
66
 
61
- When a new release is available, `sync` downloads the updated framework and replaces managed files (`.cadet/agent/core/`, IDE integration shims, agent definitions). Your local policies (`.cadet/agent/policies/`) and project plans (`.cadet/agent/project-plans/`) are automatically preserved. After syncing, start a fresh chat for the changes to take effect.
67
+ When a new release is available, `sync` downloads the updated framework and replaces managed files (`.cadet/agent/core/`, IDE integration shims, agent definitions). Your local policies (`.cadet/agent/policies/`), project plans (`.cadet/agent/project-plans/`), harness overrides (`.cadet/harness.json`), and run ledgers (`.cadet/runs/`) are automatically preserved. After syncing, start a fresh chat for the changes to take effect.
62
68
 
63
69
  To sync a specific directory:
64
70
 
@@ -128,7 +134,7 @@ flowchart TD
128
134
 
129
135
  ### Resuming a Session
130
136
 
131
- Use the `/cadet-resume` slash command to pick up where you left off. It reads `.cadet/state.json` and reports the current phase, epic/story progress, and outstanding gates — then dispatches the right skill for the next step. If no state file exists, it initializes a fresh session from `context-resolution`.
137
+ Use the `/cadet-resume` slash command to pick up where you left off. It reads `.cadet/state.json` and reports the current phase, epic/story progress, and outstanding gates — then dispatches the right skill for the next step. It also checks the current branch and working tree, so leftover changes from a previous task are resolved (commit, stash, push, or move to a new branch) before a new task begins. If no state file exists, it initializes a fresh session from `context-resolution`.
132
138
 
133
139
  ### Phase Gating
134
140
 
@@ -140,6 +146,36 @@ Hard gates are enforced at every phase transition. The agent reads `.cadet/state
140
146
  | review → validation | `codeReviewCompleted`, `securityReviewPassed`, `acceptanceCriteriaValidated` |
141
147
  | validation → closed | `designArtifactSyncConfirmed` |
142
148
 
149
+ ### Harness
150
+
151
+ Gates are backed by **evidence**, not assertion. Each claimed gate must have a fresh, non-superseded evidence record bound to the current work item, input tree hash, and acceptance criteria. The harness also bounds context, tokens, tool calls, retries, wall-clock time, cost, and archive sizes — and those bounds are enforced, not advisory.
152
+
153
+ - Rules: `.cadet/agent/core/Harness.md`. Data contract: `docs/core/HarnessContract.md`.
154
+ - Overrides: `.cadet/harness.json` (preserved by sync; conservative defaults in `src/harness/policy.mjs`).
155
+ - Ledgers: `.cadet/runs/<runId>.json` (sanitized; artifacts are redacted before they are written; no secrets or raw prompts by default).
156
+ - Transitions recompute the input tree hash from the evidence's relevant files, so editing a relevant file invalidates the evidence.
157
+ - `harness verify` binds evidence to `--files` (or the working tree's changed files), and a `testsPassed` green result requires a prior red record.
158
+ - When Git is unavailable and no `--files` are given, verification blocks (`freshness-unavailable`) rather than recording unscoped evidence.
159
+ - `state validate` rejects a `true` gate whose evidence is missing, stale, expired, superseded, or bound to another work item; evidence records are schema-validated in full (`command`, `result`, `criteriaHash`, and a freshness bound).
160
+ - Evidence must include a UUID, work item, phase, gate, status, command/result, input-tree hash, criteria hash, relevant files, timestamp, and either `expiresAt` or `freshnessPolicy`.
161
+ - Command output counts against the output budget; a configured cost budget cannot be satisfied by unmeasurable cost (the run is blocked, `budget-blocked`).
162
+ - State and run ledgers are written atomically, so an interrupted write cannot truncate a record; persisted artifacts are redacted before hashing or writing.
163
+ - Empty freshness coverage is an explicit policy decision: set `allowEmptyFreshness: true` in `.cadet/harness.json` only when unscoped evidence is acceptable.
164
+
165
+ ```bash
166
+ cadet-agent state validate # validate state against the schema
167
+ cadet-agent state migrate # atomically upgrade v1 → v2
168
+ cadet-agent state transition --to review # enforce the matrix + evidence
169
+ cadet-agent harness verify --gate testsPassed --files src/a.cs # bounded, classified loop
170
+ cadet-agent harness report # budget consumption and failures (no secrets)
171
+ cadet-agent harness cleanup # apply the retention policy
172
+ cadet-agent harness capabilities # available CLI/Unity/MCP/hook/token/cost telemetry
173
+ ```
174
+
175
+ Every command supports `--format human|json` and exits nonzero for invalid state, failed verification, budget exhaustion, stale evidence, or safety rejection.
176
+
177
+ See `docs/guidance/HarnessTroubleshooting.md` for stale evidence, budget exhaustion, unavailable Unity CLI, and live MCP connection failures.
178
+
143
179
  ## Examples
144
180
 
145
181
  ### GitHub Copilot
@@ -199,7 +235,7 @@ If a specific game repository needs local conventions, add a policy file under `
199
235
 
200
236
  ## Package Output
201
237
  Running `./package-agent.ps1` produces `cadet-agent.zip` with this layout:
202
- - `.cadet/agent/core/`
238
+ - `.cadet/agent/core/` (including `Harness.md`, `harness.schema.json`, and `state.schema.json`)
203
239
  - `.cadet/agent/core/skills/`
204
240
  - `.cadet/agent/core/templates/`
205
241
  - `.github/agents/cadet.agent.md`
@@ -207,8 +243,12 @@ Running `./package-agent.ps1` produces `cadet-agent.zip` with this layout:
207
243
  - `.github/prompts/cadet-*.prompt.md`
208
244
  - `.github/hooks/`
209
245
  - `.cursor/rules/cadet-agent.md`
246
+ - `.cursor/rules/cadet-agent-reviewer.md`
210
247
  - `.continue/rules/cadet-agent.md`
211
- - `.claude/skills/cadet-agent.md`
248
+ - `.continue/rules/cadet-agent-reviewer.md`
249
+ - `.continue/config.yaml`
250
+ - `.claude/skills/cadet-agent/SKILL.md`
251
+ - `.claude/skills/cadet-*/SKILL.md`
212
252
 
213
253
  ## Notes
214
254
  - `.cadet/agent/core/FrameworkManifest.json` defines the managed and preserved paths for packaged installs.
package/package.json CHANGED
@@ -1,35 +1,35 @@
1
- {
2
- "name": "cadet-agent",
3
- "version": "0.20.2",
4
- "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
- "type": "module",
6
- "bin": {
7
- "cadet-agent": "bin/cli.mjs"
8
- },
9
- "scripts": {
10
- "test": "node --test test/*.test.mjs"
11
- },
12
- "files": [
13
- "bin/",
14
- "src/"
15
- ],
16
- "keywords": [
17
- "cadet",
18
- "cadet-agent",
19
- "unity",
20
- "game-development",
21
- "ai-agent",
22
- "copilot",
23
- "cursor",
24
- "claude-code"
25
- ],
26
- "license": "CC-BY-4.0",
27
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/naishtech/cadet-agent.git"
30
- },
31
- "homepage": "https://github.com/naishtech/cadet-agent#readme",
32
- "engines": {
33
- "node": ">=18.0.0"
34
- }
35
- }
1
+ {
2
+ "name": "cadet-agent",
3
+ "version": "0.24.0",
4
+ "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
+ "type": "module",
6
+ "bin": {
7
+ "cadet-agent": "bin/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/"
15
+ ],
16
+ "keywords": [
17
+ "cadet",
18
+ "cadet-agent",
19
+ "unity",
20
+ "game-development",
21
+ "ai-agent",
22
+ "copilot",
23
+ "cursor",
24
+ "claude-code"
25
+ ],
26
+ "license": "CC-BY-4.0",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/naishtech/cadet-agent.git"
30
+ },
31
+ "homepage": "https://github.com/naishtech/cadet-agent#readme",
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ }
35
+ }
package/src/cli.mjs CHANGED
@@ -2,6 +2,11 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { install, sync } from './install.mjs';
5
+ import {
6
+ validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
7
+ workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
8
+ runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
9
+ } from './harness/index.mjs';
5
10
 
6
11
  const __filename = fileURLToPath(import.meta.url);
7
12
  const __dirname = dirname(__filename);
@@ -28,51 +33,386 @@ function showHelp() {
28
33
  npx cadet-agent@latest sync Update framework, preserving local policies/plans
29
34
  npx cadet-agent@latest sync --target <dir> Sync a specific directory
30
35
 
36
+ cadet-agent state validate Validate .cadet/state.json against the v2 schema
37
+ cadet-agent state migrate Atomically migrate v1 state to v2 (backup on write)
38
+ cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
39
+
40
+ cadet-agent harness record Append a sanitized span/evidence/decision event
41
+ cadet-agent harness verify Run a bounded, classified verification loop
42
+ cadet-agent harness report Summarize budget consumption and failures
43
+ cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
44
+ cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
45
+
31
46
  Options:
32
47
  --target, -t Target directory (default: current working directory)
33
48
  --source Release API URL override (for forked deployments)
49
+ --format human|json (default: human)
50
+ --to Target phase (state transition)
51
+ --gate Gate name (harness verify)
52
+ --command Command override (harness verify)
53
+ --files Comma-separated relevant files to bind evidence to (harness verify)
34
54
  --help, -h Show this help
35
55
  --version, -v Show version number
36
56
  `);
37
57
  }
38
58
 
39
- export async function run(argv) {
40
- const command = argv[2];
59
+ function parseArgs(argv) {
60
+ const opts = { format: 'human', targetDir: process.cwd(), sourceUrl: null, rest: [] };
61
+ // argv[2] is the top-level command (`state`/`harness`/`init`/...); argv[3] begins
62
+ // the subcommand and its options.
63
+ for (let i = 3; i < argv.length; i++) {
64
+ const a = argv[i];
65
+ switch (a) {
66
+ case '--target': case '-t': opts.targetDir = argv[++i]; break;
67
+ case '--source': opts.sourceUrl = argv[++i]; break;
68
+ case '--format': opts.format = argv[++i] || 'human'; break;
69
+ case '--to': opts.to = argv[++i]; break;
70
+ case '--gate': opts.gate = argv[++i]; break;
71
+ case '--command': opts.command = argv[++i]; break;
72
+ case '--work-item': opts.workItemId = argv[++i]; break;
73
+ case '--phase': opts.phase = argv[++i]; break;
74
+ case '--run': opts.runId = argv[++i]; break;
75
+ case '--type': opts.type = argv[++i]; break;
76
+ case '--reason': opts.reason = argv[++i]; break;
77
+ case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
78
+ case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
79
+ case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
80
+ default: opts.rest.push(a);
81
+ }
82
+ }
83
+ return opts;
84
+ }
85
+
86
+ function emit(opts, human, json) {
87
+ if (opts.format === 'json') {
88
+ console.log(JSON.stringify(json, null, 2));
89
+ } else {
90
+ console.log(human);
91
+ }
92
+ }
93
+
94
+ function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
95
+ const exitCode = code(json);
96
+ if (opts.format === 'json') {
97
+ console.error(JSON.stringify({ ok: false, error: message, ...json }, null, 2));
98
+ } else {
99
+ console.error(`\n❌ ${message}`);
100
+ }
101
+ process.exit(exitCode);
102
+ }
103
+
104
+ // ── state commands ──────────────────────────────────────────────────────────
105
+
106
+ async function cmdState(opts) {
107
+ const sub = opts.rest[0];
108
+ const statePath = join(opts.targetDir, '.cadet', 'state.json');
109
+
110
+ if (sub === 'validate') {
111
+ const { exists, state } = readState(opts.targetDir);
112
+ if (!exists) {
113
+ emit(opts, 'No .cadet/state.json found (nothing to validate).', { ok: true, valid: true, exists: false });
114
+ return;
115
+ }
116
+ // Pass rootDir so stale/foreign evidence is caught at validation time.
117
+ const result = validateState(state, { rootDir: opts.targetDir });
118
+ if (opts.format === 'json') {
119
+ emit(opts, '', { ok: result.valid, valid: result.valid, errors: result.errors, warnings: result.warnings });
120
+ } else {
121
+ if (result.valid) console.log(`✅ state.json is valid (v${state.version}).`);
122
+ else {
123
+ console.error('❌ state.json is invalid:');
124
+ for (const e of result.errors) console.error(` ${e.path}: ${e.message}`);
125
+ }
126
+ for (const w of result.warnings) console.log(` ⚠️ ${w.path}: ${w.message}`);
127
+ }
128
+ if (!result.valid) process.exit(1);
129
+ return;
130
+ }
131
+
132
+ if (sub === 'migrate') {
133
+ const result = migrateStateFile(statePath, { backup: true });
134
+ if (opts.format === 'json') {
135
+ emit(opts, '', { ok: true, migrated: result.migrated, statePath: result.statePath });
136
+ } else if (result.migrated) {
137
+ console.log(`✅ Migrated ${statePath} to v2 (backup: ${statePath}.v1.bak).`);
138
+ } else {
139
+ console.log('✅ state.json is already v2 — nothing to migrate.');
140
+ }
141
+ return;
142
+ }
143
+
144
+ if (sub === 'transition') {
145
+ if (!opts.to) fail(opts, 'state transition requires --to <phase>');
146
+ const { exists, state } = readState(opts.targetDir);
147
+ if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before transitioning.', () => 2);
148
+ // Freshness is enforced against the current working tree: evaluateTransition
149
+ // recomputes each gate's input-tree hash from the evidence's relevant files.
150
+ const evaluation = evaluateTransition(state, opts.to, { rootDir: opts.targetDir });
151
+ if (!evaluation.allowed) {
152
+ const detail = {
153
+ ok: false,
154
+ allowed: false,
155
+ missingGates: evaluation.missingGates,
156
+ staleEvidence: evaluation.staleEvidence,
157
+ errors: evaluation.errors,
158
+ };
159
+ const lines = ['❌ Transition rejected:'];
160
+ for (const e of evaluation.errors) lines.push(` ${e}`);
161
+ if (evaluation.missingGates.length) lines.push(` missing gates/evidence: ${evaluation.missingGates.join(', ')}`);
162
+ for (const s of evaluation.staleEvidence) lines.push(` stale: ${s.gate} — ${s.reason || (s.reasons || []).join('; ')}`);
163
+ if (opts.format === 'json') emit(opts, '', detail);
164
+ else console.error(lines.join('\n'));
165
+ process.exit(1);
166
+ }
167
+ const next = applyTransition(state, opts.to, { rootDir: opts.targetDir });
168
+ writeState(opts.targetDir, next);
169
+ emit(opts, `✅ Transitioned to ${opts.to}.`, { ok: true, allowed: true, to: opts.to });
170
+ return;
171
+ }
172
+
173
+ fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|transition.`);
174
+ }
175
+
176
+ // ── harness commands ────────────────────────────────────────────────────────
41
177
 
42
- // Parse --target <dir> or -t <dir>
43
- let targetDir = process.cwd();
44
- let sourceUrl = null;
45
- const targetIdx = argv.indexOf('--target');
46
- const tIdx = argv.indexOf('-t');
47
- const sourceIdx = argv.indexOf('--source');
48
- if (targetIdx !== -1 && argv[targetIdx + 1]) {
49
- targetDir = argv[targetIdx + 1];
50
- } else if (tIdx !== -1 && argv[tIdx + 1]) {
51
- targetDir = argv[tIdx + 1];
178
+ async function cmdHarness(opts) {
179
+ const sub = opts.rest[0];
180
+ const policy = loadPolicy(opts.targetDir);
181
+
182
+ if (sub === 'capabilities') {
183
+ const caps = detectCapabilities({ targetDir: opts.targetDir });
184
+ if (opts.format === 'json') emit(opts, '', { ok: true, capabilities: caps });
185
+ else {
186
+ console.log('Cadet-Agent capability report');
187
+ console.log(` CLI: ${caps.cli ? 'available' : 'unavailable'}`);
188
+ console.log(` Unity CLI: ${caps.unityCli.available ? `available (${caps.unityCli.version || 'version unknown'})` : 'unavailable — compile/analyzer gates fall back to manual confirmation'}`);
189
+ console.log(` MCP: ${caps.mcp.available ? 'configured' : 'unavailable — live inspection not available'}`);
190
+ console.log(` Copilot hook: ${caps.hook.copilot ? 'installed' : 'not installed'}`);
191
+ console.log(` Token telemetry:${caps.tokenTelemetry.provider ? ' provider' : ' estimate/unknown'}`);
192
+ console.log(` Cost telemetry: ${caps.costTelemetry.available ? 'available' : `unavailable (${caps.costTelemetry.reason})`}`);
193
+ console.log(` Note: ${caps.hook.note}`);
194
+ }
195
+ return;
52
196
  }
53
- if (sourceIdx !== -1 && argv[sourceIdx + 1]) {
54
- sourceUrl = argv[sourceIdx + 1];
197
+
198
+ if (sub === 'record') {
199
+ const { state } = readState(opts.targetDir);
200
+ const ledger = new RunLedger({
201
+ targetDir: opts.targetDir,
202
+ policy,
203
+ runId: opts.runId || state?.activeRunId || null,
204
+ workItemId: opts.workItemId || (state ? workItemIdOf(state) : null),
205
+ phase: opts.phase || state?.session?.currentPhase || null,
206
+ });
207
+ const type = opts.type || 'tool-call';
208
+ const reason = opts.reason || opts.rest[1] || 'recorded event';
209
+ if (type === 'decision') {
210
+ ledger.addDecision({ kind: 'stop', reason });
211
+ } else if (type === 'verification') {
212
+ ledger.addSpan({ kind: 'verification', name: opts.gate || 'manual', status: opts.evidenceStatus || 'ok', reason });
213
+ } else {
214
+ ledger.addSpan({ kind: type, name: opts.gate || type, status: 'ok', reason, tool: opts.tool || null });
215
+ }
216
+ ledger.finalize();
217
+ const path = ledger.persist();
218
+ emit(opts, `✅ Recorded ${type} event in ${path}.`, { ok: true, runId: ledger.runId, path });
219
+ return;
55
220
  }
56
221
 
57
- switch (command) {
58
- case 'init':
59
- await install(targetDir, { sourceUrl });
60
- break;
61
- case 'sync':
62
- await sync(targetDir, { sourceUrl });
63
- break;
64
- case '--version':
65
- case '-v':
66
- console.log(`cadet-agent v${getVersion()}`);
67
- break;
68
- case '--help':
69
- case '-h':
70
- case undefined:
71
- showHelp();
72
- break;
73
- default:
74
- console.error(`Unknown command: ${command}`);
75
- console.error('Run cadet-agent --help for usage.');
222
+ if (sub === 'verify') {
223
+ const gate = opts.gate;
224
+ if (!gate) fail(opts, 'harness verify requires --gate <gate>');
225
+ const { state } = readState(opts.targetDir);
226
+ const caps = detectCapabilities({ targetDir: opts.targetDir });
227
+ const descriptor = opts.command
228
+ ? { command: opts.command, tool: 'custom', automated: true }
229
+ : commandForGate(gate, { policy, projectPath: opts.targetDir, unityAvailable: caps.unityCli.available });
230
+
231
+ if (!descriptor.automated || !descriptor.command) {
232
+ const detail = { ok: false, gate, blocked: true, reason: descriptor.reason || 'no automated command available' };
233
+ if (opts.format === 'json') emit(opts, '', detail);
234
+ else console.error(`❌ Cannot automate gate "${gate}": ${detail.reason}. Record a manual confirmation instead.`);
235
+ process.exit(1);
236
+ }
237
+
238
+ const ledger = new RunLedger({
239
+ targetDir: opts.targetDir,
240
+ policy,
241
+ runId: state?.activeRunId || null,
242
+ workItemId: state ? workItemIdOf(state) : null,
243
+ phase: state?.session?.currentPhase || null,
244
+ capabilities: caps,
245
+ });
246
+
247
+ // Relevant files bind the evidence to a concrete input tree so later edits
248
+ // invalidate it. Prefer an explicit --files list; otherwise use the working
249
+ // tree's changed files. If git cannot be queried and no files were supplied,
250
+ // freshness coverage cannot be established — fail safe rather than record a
251
+ // passing gate against an empty input tree. `allowEmptyFreshness` is the
252
+ // explicit, visible opt-out.
253
+ const allowEmpty = policy?.allowEmptyFreshness === true;
254
+ let relevantFiles;
255
+ let filesSource;
256
+ if (opts.files && opts.files.length) {
257
+ relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
258
+ filesSource = 'explicit';
259
+ } else {
260
+ const changed = gitChangedFiles(opts.targetDir);
261
+ if (!changed.available) {
262
+ if (!allowEmpty) {
263
+ const detail = {
264
+ ok: false,
265
+ gate,
266
+ blocked: true,
267
+ code: 'freshness-unavailable',
268
+ reason: `cannot establish freshness coverage: ${changed.reason}. Pass --files <paths> to bind evidence to the relevant files, or enable allowEmptyFreshness in .cadet/harness.json to opt into unscoped evidence.`,
269
+ };
270
+ if (opts.format === 'json') emit(opts, '', detail);
271
+ else console.error(`❌ ${detail.reason}`);
272
+ process.exit(1);
273
+ }
274
+ relevantFiles = [];
275
+ filesSource = 'unscoped (allowEmptyFreshness)';
276
+ } else {
277
+ relevantFiles = changed.files;
278
+ filesSource = 'working-tree';
279
+ }
280
+ }
281
+
282
+ if (relevantFiles.length === 0 && !allowEmpty && filesSource !== 'explicit') {
283
+ const detail = {
284
+ ok: false,
285
+ gate,
286
+ blocked: true,
287
+ code: 'freshness-unavailable',
288
+ reason: 'no relevant files were found to bind evidence to. Pass --files <paths>, or enable allowEmptyFreshness in .cadet/harness.json to opt into unscoped evidence.',
289
+ };
290
+ if (opts.format === 'json') emit(opts, '', detail);
291
+ else console.error(`❌ ${detail.reason}`);
76
292
  process.exit(1);
293
+ }
294
+
295
+ // Red-before-green applies to testable work items; a `no_test_required`
296
+ // change is exempt (contract §5).
297
+ const noTestRequired = state?.session?.workflowPath === 'no_test_required';
298
+
299
+ // Record how the relevant files were chosen (provenance) in the ledger.
300
+ ledger.addDecision({
301
+ kind: 'stop',
302
+ reason: `freshness-bound via ${filesSource}`,
303
+ scope: relevantFiles.join(',') || '(none)',
304
+ });
305
+
306
+ const result = await runVerificationLoop({
307
+ gate,
308
+ command: descriptor.command,
309
+ tool: descriptor.tool,
310
+ workItemId: ledger.workItemId || 'unscoped',
311
+ phase: ledger.phase || 'implementation',
312
+ relevantFiles,
313
+ rootDir: opts.targetDir,
314
+ policy,
315
+ budgets: ledger.tracker,
316
+ artifactDir: join(runsDir(opts.targetDir), 'artifacts'),
317
+ priorEvidence: Array.isArray(state?.gateEvidence) ? state.gateEvidence : [],
318
+ requireRedFirst: noTestRequired ? false : null,
319
+ });
320
+
321
+ for (const a of result.attempts) ledger.addEvidence(a.evidence);
322
+ ledger.finalize({ status: result.ok ? 'ok' : 'failed' });
323
+ const path = ledger.persist();
324
+
325
+ // Close the loop: record the produced evidence in state.json so
326
+ // `state transition` can see it. A passing verification flips the gate
327
+ // only when it is evidence-backed; a failing one records the attempt.
328
+ let stateUpdated = false;
329
+ if (state) {
330
+ const next = { ...state };
331
+ next.gateEvidence = [...(Array.isArray(state.gateEvidence) ? state.gateEvidence : []), ...result.attempts.map((a) => a.evidence)];
332
+ if (Array.isArray(next.gateEvidence)) {
333
+ // Mark prior evidence for this gate as superseded by the new record.
334
+ const newest = result.finalEvidence?.evidenceId;
335
+ next.gateEvidence = next.gateEvidence.map((e) =>
336
+ e.gate === gate && e.evidenceId !== newest && e.status === 'passed' && result.ok
337
+ ? { ...e, status: 'superseded', supersededBy: newest }
338
+ : e);
339
+ }
340
+ if (result.ok) {
341
+ next.gates = { ...(state.gates || {}), [gate]: true };
342
+ }
343
+ writeState(opts.targetDir, next);
344
+ stateUpdated = true;
345
+ }
346
+
347
+ if (opts.format === 'json') {
348
+ emit(opts, '', { ok: result.ok, status: result.status, gate, attempts: result.attempts.length, stopReason: result.stopReason, runId: ledger.runId, path, stateUpdated });
349
+ } else if (result.ok) {
350
+ console.log(`✅ Gate "${gate}" verified (${result.attempts.length} attempt(s)). Ledger: ${path}`);
351
+ } else {
352
+ console.error(`❌ Gate "${gate}" failed (${result.status}, ${result.stopReason || 'no reason'}). Ledger: ${path}`);
353
+ }
354
+ if (!result.ok) process.exit(1);
355
+ return;
356
+ }
357
+
358
+ if (sub === 'report') {
359
+ const runs = listRuns(opts.targetDir);
360
+ const target = opts.runId || runs[0]?.runId;
361
+ if (!target) fail(opts, 'No run records found in .cadet/runs/.', () => 2);
362
+ const run = loadRun(opts.targetDir, target);
363
+ if (!run) fail(opts, `Run ${target} not found.`, () => 2);
364
+ if (opts.format === 'json') emit(opts, '', { ok: true, report: buildReport(run) });
365
+ else console.log(formatReport(run));
366
+ return;
367
+ }
368
+
369
+ if (sub === 'cleanup') {
370
+ const { deleted, kept } = cleanupRuns(opts.targetDir, policy, {
371
+ olderThanMs: Number.isFinite(opts.olderThanMs) ? opts.olderThanMs : null,
372
+ });
373
+ emit(opts, `✅ Cleanup: deleted ${deleted.length} run(s), kept ${kept.length}.`, { ok: true, deleted, kept });
374
+ return;
375
+ }
376
+
377
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|verify|report|cleanup|capabilities.`);
378
+ }
379
+
380
+ export async function run(argv) {
381
+ const command = argv[2];
382
+ const opts = parseArgs(argv);
383
+
384
+ try {
385
+ switch (command) {
386
+ case 'init':
387
+ await install(opts.targetDir, { sourceUrl: opts.sourceUrl });
388
+ break;
389
+ case 'sync':
390
+ await sync(opts.targetDir, { sourceUrl: opts.sourceUrl });
391
+ break;
392
+ case 'state':
393
+ await cmdState(opts);
394
+ break;
395
+ case 'harness':
396
+ await cmdHarness(opts);
397
+ break;
398
+ case '--version':
399
+ case '-v':
400
+ console.log(`cadet-agent v${getVersion()}`);
401
+ break;
402
+ case '--help':
403
+ case '-h':
404
+ case undefined:
405
+ showHelp();
406
+ break;
407
+ default:
408
+ console.error(`Unknown command: ${command}`);
409
+ console.error('Run cadet-agent --help for usage.');
410
+ process.exit(1);
411
+ }
412
+ } catch (err) {
413
+ if (err instanceof PolicyError || err instanceof StateError) {
414
+ fail(opts, err.message);
415
+ }
416
+ throw err;
77
417
  }
78
418
  }