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 +46 -6
- package/package.json +35 -35
- package/src/cli.mjs +373 -33
- package/src/harness/archive.mjs +242 -0
- package/src/harness/budget.mjs +298 -0
- package/src/harness/context.mjs +229 -0
- package/src/harness/hook.mjs +147 -0
- package/src/harness/index.mjs +60 -0
- package/src/harness/ledger.mjs +309 -0
- package/src/harness/policy.mjs +359 -0
- package/src/harness/redaction.mjs +133 -0
- package/src/harness/routing.mjs +153 -0
- package/src/harness/state.mjs +646 -0
- package/src/harness/util.mjs +149 -0
- package/src/harness/verification.mjs +490 -0
- package/src/install.mjs +92 -154
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
|
-
- `
|
|
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
|
|
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/`)
|
|
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
|
-
- `.
|
|
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.
|
|
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
|
-
|
|
40
|
-
const
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
}
|