cadet-agent 0.27.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadet-agent",
3
- "version": "0.27.0",
3
+ "version": "0.30.0",
4
4
  "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -1,434 +1,447 @@
1
- import { readFileSync } from 'node:fs';
2
- import { fileURLToPath } from 'node:url';
3
- import { dirname, join } from 'node:path';
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';
10
-
11
- const __filename = fileURLToPath(import.meta.url);
12
- const __dirname = dirname(__filename);
13
-
14
- function getVersion() {
15
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
16
- return pkg.version;
17
- }
18
-
19
- function showHelp() {
20
- console.log(`
21
- ██████╗ █████╗ ██████╗ ███████╗████████╗
22
- ██╔════╝██╔══██╗██╔══██╗██╔════╝╚══██╔══╝
23
- ██║ ███████║██║ ██║█████╗ ██║
24
- ██║ ██╔══██║██║ ██║██╔══╝ ██║
25
- ╚██████╗██║ ██║██████╔╝███████╗ ██║
26
- ╚═════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═╝
27
-
28
- Cross-IDE agent framework for Unity/C# game-development
29
-
30
- Usage:
31
- npx cadet-agent@latest init Install Cadet-Agent into the current directory
32
- npx cadet-agent@latest init --target <dir> Install into a specific directory
33
- npx cadet-agent@latest sync Update framework, preserving local policies/plans
34
- npx cadet-agent@latest sync --target <dir> Sync a specific directory
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
-
46
- Options:
47
- --target, -t Target directory (default: current working directory)
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)
54
- --agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
55
- --yes, -y Never prompt; keep existing files (non-interactive installs)
56
- --help, -h Show this help
57
- --version, -v Show version number
58
- `);
59
- }
60
-
61
- function parseArgs(argv) {
62
- const opts = { format: 'human', targetDir: process.cwd(), sourceUrl: null, rest: [] };
63
- // argv[2] is the top-level command (`state`/`harness`/`init`/...); argv[3] begins
64
- // the subcommand and its options.
65
- for (let i = 3; i < argv.length; i++) {
66
- const a = argv[i];
67
- switch (a) {
68
- case '--target': case '-t': opts.targetDir = argv[++i]; break;
69
- case '--source': opts.sourceUrl = argv[++i]; break;
70
- case '--format': opts.format = argv[++i] || 'human'; break;
71
- case '--to': opts.to = argv[++i]; break;
72
- case '--gate': opts.gate = argv[++i]; break;
73
- case '--command': opts.command = argv[++i]; break;
74
- case '--work-item': opts.workItemId = argv[++i]; break;
75
- case '--phase': opts.phase = argv[++i]; break;
76
- case '--run': opts.runId = argv[++i]; break;
77
- case '--type': opts.type = argv[++i]; break;
78
- case '--reason': opts.reason = argv[++i]; break;
79
- case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
80
- case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
81
- case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
82
- case '--agents-md': opts.agentsMd = argv[++i]; break;
83
- case '--yes': case '-y': opts.yes = true; break;
84
- default: opts.rest.push(a);
85
- }
86
- }
87
- return opts;
88
- }
89
-
90
- function emit(opts, human, json) {
91
- if (opts.format === 'json') {
92
- console.log(JSON.stringify(json, null, 2));
93
- } else {
94
- console.log(human);
95
- }
96
- }
97
-
98
- function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
99
- const exitCode = code(json);
100
- if (opts.format === 'json') {
101
- console.error(JSON.stringify({ ok: false, error: message, ...json }, null, 2));
102
- } else {
103
- console.error(`\n❌ ${message}`);
104
- }
105
- process.exit(exitCode);
106
- }
107
-
108
- // ── state commands ──────────────────────────────────────────────────────────
109
-
110
- async function cmdState(opts) {
111
- const sub = opts.rest[0];
112
- const statePath = join(opts.targetDir, '.cadet', 'state.json');
113
-
114
- if (sub === 'validate') {
115
- const { exists, state } = readState(opts.targetDir);
116
- if (!exists) {
117
- emit(opts, 'No .cadet/state.json found (nothing to validate).', { ok: true, valid: true, exists: false });
118
- return;
119
- }
120
- // Pass rootDir so stale/foreign evidence is caught at validation time.
121
- const result = validateState(state, { rootDir: opts.targetDir });
122
- if (opts.format === 'json') {
123
- emit(opts, '', { ok: result.valid, valid: result.valid, errors: result.errors, warnings: result.warnings });
124
- } else {
125
- if (result.valid) console.log(`✅ state.json is valid (v${state.version}).`);
126
- else {
127
- console.error('❌ state.json is invalid:');
128
- for (const e of result.errors) console.error(` ${e.path}: ${e.message}`);
129
- }
130
- for (const w of result.warnings) console.log(` ⚠️ ${w.path}: ${w.message}`);
131
- }
132
- if (!result.valid) process.exit(1);
133
- return;
134
- }
135
-
136
- if (sub === 'migrate') {
137
- const result = migrateStateFile(statePath, { backup: true });
138
- if (opts.format === 'json') {
139
- emit(opts, '', { ok: true, migrated: result.migrated, statePath: result.statePath });
140
- } else if (result.migrated) {
141
- console.log(`✅ Migrated ${statePath} to v2 (backup: ${statePath}.v1.bak).`);
142
- } else {
143
- console.log('✅ state.json is already v2 nothing to migrate.');
144
- }
145
- return;
146
- }
147
-
148
- if (sub === 'transition') {
149
- if (!opts.to) fail(opts, 'state transition requires --to <phase>');
150
- const { exists, state } = readState(opts.targetDir);
151
- if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before transitioning.', () => 2);
152
- // Freshness is enforced against the current working tree: evaluateTransition
153
- // recomputes each gate's input-tree hash from the evidence's relevant files.
154
- const evaluation = evaluateTransition(state, opts.to, { rootDir: opts.targetDir });
155
- if (!evaluation.allowed) {
156
- const detail = {
157
- ok: false,
158
- allowed: false,
159
- missingGates: evaluation.missingGates,
160
- staleEvidence: evaluation.staleEvidence,
161
- errors: evaluation.errors,
162
- };
163
- const lines = ['❌ Transition rejected:'];
164
- for (const e of evaluation.errors) lines.push(` ${e}`);
165
- if (evaluation.missingGates.length) lines.push(` missing gates/evidence: ${evaluation.missingGates.join(', ')}`);
166
- for (const s of evaluation.staleEvidence) lines.push(` stale: ${s.gate} — ${s.reason || (s.reasons || []).join('; ')}`);
167
- if (opts.format === 'json') emit(opts, '', detail);
168
- else console.error(lines.join('\n'));
169
- process.exit(1);
170
- }
171
- const next = applyTransition(state, opts.to, { rootDir: opts.targetDir });
172
- writeState(opts.targetDir, next);
173
- emit(opts, `✅ Transitioned to ${opts.to}.`, { ok: true, allowed: true, to: opts.to });
174
- return;
175
- }
176
-
177
- fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|transition.`);
178
- }
179
-
180
- // ── harness commands ────────────────────────────────────────────────────────
181
-
182
- async function cmdHarness(opts) {
183
- const sub = opts.rest[0];
184
- const policy = loadPolicy(opts.targetDir);
185
-
186
- if (sub === 'capabilities') {
187
- const caps = detectCapabilities({ targetDir: opts.targetDir });
188
- if (opts.format === 'json') emit(opts, '', { ok: true, capabilities: caps });
189
- else {
190
- console.log('Cadet-Agent capability report');
191
- console.log(` CLI: ${caps.cli ? 'available' : 'unavailable'}`);
192
- console.log(` Unity CLI: ${caps.unityCli.available ? `available (${caps.unityCli.version || 'version unknown'})` : 'unavailable — compile/analyzer gates fall back to manual confirmation'}`);
193
- console.log(` MCP: ${caps.mcp.available ? 'configured' : 'unavailable — live inspection not available'}`);
194
- console.log(` Copilot hook: ${caps.hook.copilot ? 'installed' : 'not installed'}`);
195
- console.log(` Token telemetry:${caps.tokenTelemetry.provider ? ' provider' : ' estimate/unknown'}`);
196
- console.log(` Cost telemetry: ${caps.costTelemetry.available ? 'available' : `unavailable (${caps.costTelemetry.reason})`}`);
197
- console.log(` Note: ${caps.hook.note}`);
198
- }
199
- return;
200
- }
201
-
202
- if (sub === 'record') {
203
- const { state } = readState(opts.targetDir);
204
- const ledger = new RunLedger({
205
- targetDir: opts.targetDir,
206
- policy,
207
- runId: opts.runId || state?.activeRunId || null,
208
- workItemId: opts.workItemId || (state ? workItemIdOf(state) : null),
209
- phase: opts.phase || state?.session?.currentPhase || null,
210
- });
211
- const type = opts.type || 'tool-call';
212
- const reason = opts.reason || opts.rest[1] || 'recorded event';
213
- if (type === 'decision') {
214
- ledger.addDecision({ kind: 'stop', reason });
215
- } else if (type === 'verification') {
216
- ledger.addSpan({ kind: 'verification', name: opts.gate || 'manual', status: opts.evidenceStatus || 'ok', reason });
217
- } else {
218
- ledger.addSpan({ kind: type, name: opts.gate || type, status: 'ok', reason, tool: opts.tool || null });
219
- }
220
- ledger.finalize();
221
- const path = ledger.persist();
222
- emit(opts, `✅ Recorded ${type} event in ${path}.`, { ok: true, runId: ledger.runId, path });
223
- return;
224
- }
225
-
226
- if (sub === 'verify') {
227
- const gate = opts.gate;
228
- if (!gate) fail(opts, 'harness verify requires --gate <gate>');
229
- const { state } = readState(opts.targetDir);
230
- const caps = detectCapabilities({ targetDir: opts.targetDir });
231
- const descriptor = opts.command
232
- ? { command: opts.command, tool: 'custom', automated: true }
233
- : commandForGate(gate, { policy, projectPath: opts.targetDir, unityAvailable: caps.unityCli.available });
234
-
235
- if (!descriptor.automated || !descriptor.command) {
236
- const detail = { ok: false, gate, blocked: true, reason: descriptor.reason || 'no automated command available' };
237
- if (opts.format === 'json') emit(opts, '', detail);
238
- else console.error(`❌ Cannot automate gate "${gate}": ${detail.reason}. Record a manual confirmation instead.`);
239
- process.exit(1);
240
- }
241
-
242
- const ledger = new RunLedger({
243
- targetDir: opts.targetDir,
244
- policy,
245
- runId: state?.activeRunId || null,
246
- workItemId: state ? workItemIdOf(state) : null,
247
- phase: state?.session?.currentPhase || null,
248
- capabilities: caps,
249
- });
250
-
251
- // Relevant files bind the evidence to a concrete input tree so later edits
252
- // invalidate it. Prefer an explicit --files list; otherwise use the working
253
- // tree's changed files. If git cannot be queried and no files were supplied,
254
- // freshness coverage cannot be established — fail safe rather than record a
255
- // passing gate against an empty input tree. `allowEmptyFreshness` is the
256
- // explicit, visible opt-out.
257
- const allowEmpty = policy?.allowEmptyFreshness === true;
258
- let relevantFiles;
259
- let filesSource;
260
- if (opts.files && opts.files.length) {
261
- relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
262
- filesSource = 'explicit';
263
- } else {
264
- const changed = gitChangedFiles(opts.targetDir);
265
- if (!changed.available) {
266
- if (!allowEmpty) {
267
- const detail = {
268
- ok: false,
269
- gate,
270
- blocked: true,
271
- code: 'freshness-unavailable',
272
- 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.`,
273
- };
274
- if (opts.format === 'json') emit(opts, '', detail);
275
- else console.error(`❌ ${detail.reason}`);
276
- process.exit(1);
277
- }
278
- relevantFiles = [];
279
- filesSource = 'unscoped (allowEmptyFreshness)';
280
- } else {
281
- relevantFiles = changed.files;
282
- filesSource = 'working-tree';
283
- }
284
- }
285
-
286
- if (relevantFiles.length === 0 && !allowEmpty && filesSource !== 'explicit') {
287
- const detail = {
288
- ok: false,
289
- gate,
290
- blocked: true,
291
- code: 'freshness-unavailable',
292
- 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.',
293
- };
294
- if (opts.format === 'json') emit(opts, '', detail);
295
- else console.error(`❌ ${detail.reason}`);
296
- process.exit(1);
297
- }
298
-
299
- // Red-before-green applies to testable work items; a `no_test_required`
300
- // change is exempt (contract §5).
301
- const noTestRequired = state?.session?.workflowPath === 'no_test_required';
302
-
303
- // Record how the relevant files were chosen (provenance) in the ledger.
304
- ledger.addDecision({
305
- kind: 'stop',
306
- reason: `freshness-bound via ${filesSource}`,
307
- scope: relevantFiles.join(',') || '(none)',
308
- });
309
-
310
- const result = await runVerificationLoop({
311
- gate,
312
- command: descriptor.command,
313
- tool: descriptor.tool,
314
- workItemId: ledger.workItemId || 'unscoped',
315
- phase: ledger.phase || 'implementation',
316
- relevantFiles,
317
- rootDir: opts.targetDir,
318
- policy,
319
- budgets: ledger.tracker,
320
- artifactDir: join(runsDir(opts.targetDir), 'artifacts'),
321
- priorEvidence: Array.isArray(state?.gateEvidence) ? state.gateEvidence : [],
322
- requireRedFirst: noTestRequired ? false : null,
323
- });
324
-
325
- for (const a of result.attempts) ledger.addEvidence(a.evidence);
326
- ledger.finalize({ status: result.ok ? 'ok' : 'failed' });
327
- const path = ledger.persist();
328
-
329
- // Close the loop: record the produced evidence in state.json so
330
- // `state transition` can see it. A passing verification flips the gate
331
- // only when it is evidence-backed; a failing one records the attempt.
332
- let stateUpdated = false;
333
- if (state) {
334
- const next = { ...state };
335
- next.gateEvidence = [...(Array.isArray(state.gateEvidence) ? state.gateEvidence : []), ...result.attempts.map((a) => a.evidence)];
336
- if (Array.isArray(next.gateEvidence)) {
337
- // Mark prior evidence for this gate as superseded by the new record.
338
- const newest = result.finalEvidence?.evidenceId;
339
- next.gateEvidence = next.gateEvidence.map((e) =>
340
- e.gate === gate && e.evidenceId !== newest && e.status === 'passed' && result.ok
341
- ? { ...e, status: 'superseded', supersededBy: newest }
342
- : e);
343
- }
344
- if (result.ok) {
345
- next.gates = { ...(state.gates || {}), [gate]: true };
346
- }
347
- writeState(opts.targetDir, next);
348
- stateUpdated = true;
349
- }
350
-
351
- if (opts.format === 'json') {
352
- emit(opts, '', { ok: result.ok, status: result.status, gate, attempts: result.attempts.length, stopReason: result.stopReason, runId: ledger.runId, path, stateUpdated });
353
- } else if (result.ok) {
354
- console.log(`✅ Gate "${gate}" verified (${result.attempts.length} attempt(s)). Ledger: ${path}`);
355
- } else {
356
- console.error(`❌ Gate "${gate}" failed (${result.status}, ${result.stopReason || 'no reason'}). Ledger: ${path}`);
357
- }
358
- if (!result.ok) process.exit(1);
359
- return;
360
- }
361
-
362
- if (sub === 'report') {
363
- const runs = listRuns(opts.targetDir);
364
- const target = opts.runId || runs[0]?.runId;
365
- if (!target) fail(opts, 'No run records found in .cadet/runs/.', () => 2);
366
- const run = loadRun(opts.targetDir, target);
367
- if (!run) fail(opts, `Run ${target} not found.`, () => 2);
368
- if (opts.format === 'json') emit(opts, '', { ok: true, report: buildReport(run) });
369
- else console.log(formatReport(run));
370
- return;
371
- }
372
-
373
- if (sub === 'cleanup') {
374
- const { deleted, kept } = cleanupRuns(opts.targetDir, policy, {
375
- olderThanMs: Number.isFinite(opts.olderThanMs) ? opts.olderThanMs : null,
376
- });
377
- emit(opts, `✅ Cleanup: deleted ${deleted.length} run(s), kept ${kept.length}.`, { ok: true, deleted, kept });
378
- return;
379
- }
380
-
381
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|verify|report|cleanup|capabilities.`);
382
- }
383
-
384
- export async function run(argv) {
385
- const command = argv[2];
386
- const opts = parseArgs(argv);
387
-
388
- // Validate the create-only policy flag early so a typo fails loudly.
389
- const AGENTS_MD_MODES = ['keep', 'overwrite', 'merge'];
390
- if (opts.agentsMd !== undefined && !AGENTS_MD_MODES.includes(opts.agentsMd)) {
391
- console.error(`Invalid --agents-md value "${opts.agentsMd}" (expected: ${AGENTS_MD_MODES.join('|')})`);
392
- process.exit(1);
393
- }
394
- const installOpts = {
395
- sourceUrl: opts.sourceUrl,
396
- yes: opts.yes === true,
397
- createOnlyPolicy: opts.agentsMd ? { 'AGENTS.md': opts.agentsMd } : undefined,
398
- };
399
-
400
- try {
401
- switch (command) {
402
- case 'init':
403
- await install(opts.targetDir, installOpts);
404
- break;
405
- case 'sync':
406
- await sync(opts.targetDir, installOpts);
407
- break;
408
- case 'state':
409
- await cmdState(opts);
410
- break;
411
- case 'harness':
412
- await cmdHarness(opts);
413
- break;
414
- case '--version':
415
- case '-v':
416
- console.log(`cadet-agent v${getVersion()}`);
417
- break;
418
- case '--help':
419
- case '-h':
420
- case undefined:
421
- showHelp();
422
- break;
423
- default:
424
- console.error(`Unknown command: ${command}`);
425
- console.error('Run cadet-agent --help for usage.');
426
- process.exit(1);
427
- }
428
- } catch (err) {
429
- if (err instanceof PolicyError || err instanceof StateError) {
430
- fail(opts, err.message);
431
- }
432
- throw err;
433
- }
434
- }
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
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
+ detectRepoRole, describeRepoRole,
10
+ } from './harness/index.mjs';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ function getVersion() {
16
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
17
+ return pkg.version;
18
+ }
19
+
20
+ function showHelp() {
21
+ console.log(`
22
+ ██████╗ █████╗ ██████╗ ███████╗████████╗
23
+ ██╔════╝██╔══██╗██╔══██╗██╔════╝╚══██╔══╝
24
+ ██║ ███████║██║ ██║█████╗ ██║
25
+ ██║ ██╔══██║██║ ██║██╔══╝ ██║
26
+ ╚██████╗██║ ██║██████╔╝███████╗ ██║
27
+ ╚═════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═╝
28
+
29
+ Cross-IDE agent framework for Unity/C# game-development
30
+
31
+ Usage:
32
+ npx cadet-agent@latest init Install Cadet-Agent into the current directory
33
+ npx cadet-agent@latest init --target <dir> Install into a specific directory
34
+ npx cadet-agent@latest sync Update framework, preserving local policies/plans
35
+ npx cadet-agent@latest sync --target <dir> Sync a specific directory
36
+
37
+ cadet-agent state validate Validate .cadet/state.json against the v2 schema
38
+ cadet-agent state migrate Atomically migrate v1 state to v2 (backup on write)
39
+ cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
40
+
41
+ cadet-agent harness record Append a sanitized span/evidence/decision event
42
+ cadet-agent harness verify Run a bounded, classified verification loop
43
+ cadet-agent harness report Summarize budget consumption and failures
44
+ cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
45
+ cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
46
+
47
+ Options:
48
+ --target, -t Target directory (default: current working directory)
49
+ --source Release API URL override (for forked deployments)
50
+ --format human|json (default: human)
51
+ --to Target phase (state transition)
52
+ --gate Gate name (harness verify)
53
+ --command Command override (harness verify)
54
+ --files Comma-separated relevant files to bind evidence to (harness verify)
55
+ --agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
56
+ --yes, -y Never prompt; keep existing files (non-interactive installs)
57
+ --help, -h Show this help
58
+ --version, -v Show version number
59
+ `);
60
+ }
61
+
62
+ function parseArgs(argv) {
63
+ const opts = { format: 'human', targetDir: process.cwd(), sourceUrl: null, rest: [] };
64
+ // argv[2] is the top-level command (`state`/`harness`/`init`/...); argv[3] begins
65
+ // the subcommand and its options.
66
+ for (let i = 3; i < argv.length; i++) {
67
+ const a = argv[i];
68
+ switch (a) {
69
+ case '--target': case '-t': opts.targetDir = argv[++i]; break;
70
+ case '--source': opts.sourceUrl = argv[++i]; break;
71
+ case '--format': opts.format = argv[++i] || 'human'; break;
72
+ case '--to': opts.to = argv[++i]; break;
73
+ case '--gate': opts.gate = argv[++i]; break;
74
+ case '--command': opts.command = argv[++i]; break;
75
+ case '--work-item': opts.workItemId = argv[++i]; break;
76
+ case '--phase': opts.phase = argv[++i]; break;
77
+ case '--run': opts.runId = argv[++i]; break;
78
+ case '--type': opts.type = argv[++i]; break;
79
+ case '--reason': opts.reason = argv[++i]; break;
80
+ case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
81
+ case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
82
+ case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
83
+ case '--agents-md': opts.agentsMd = argv[++i]; break;
84
+ case '--yes': case '-y': opts.yes = true; break;
85
+ default: opts.rest.push(a);
86
+ }
87
+ }
88
+ return opts;
89
+ }
90
+
91
+ function emit(opts, human, json) {
92
+ if (opts.format === 'json') {
93
+ console.log(JSON.stringify(json, null, 2));
94
+ } else {
95
+ console.log(human);
96
+ }
97
+ }
98
+
99
+ function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
100
+ const exitCode = code(json);
101
+ if (opts.format === 'json') {
102
+ console.error(JSON.stringify({ ok: false, error: message, ...json }, null, 2));
103
+ } else {
104
+ console.error(`\n❌ ${message}`);
105
+ }
106
+ process.exit(exitCode);
107
+ }
108
+
109
+ // ── state commands ──────────────────────────────────────────────────────────
110
+
111
+ async function cmdState(opts) {
112
+ const sub = opts.rest[0];
113
+ const statePath = join(opts.targetDir, '.cadet', 'state.json');
114
+
115
+ if (sub === 'validate') {
116
+ const { exists, state } = readState(opts.targetDir);
117
+ if (!exists) {
118
+ // Report the detected repo role instead of a bare ok. In the framework
119
+ // source repository a missing state file is expected, not a silent pass —
120
+ // saying so prevents story/gate reasoning against a repo that has no story.
121
+ const role = detectRepoRole(opts.targetDir);
122
+ const repoRoleDetail = describeRepoRole(role);
123
+ emit(
124
+ opts,
125
+ `No .cadet/state.json found (nothing to validate).\n Repo role: ${role.role} (${role.source}, ${role.confidence} confidence) ${repoRoleDetail}`,
126
+ { ok: true, valid: true, exists: false, repoRole: role.role, repoRoleDetail, repoRoleSource: role.source },
127
+ );
128
+ return;
129
+ }
130
+ // Pass rootDir so stale/foreign evidence is caught at validation time.
131
+ const result = validateState(state, { rootDir: opts.targetDir });
132
+ const role = detectRepoRole(opts.targetDir);
133
+ const repoRoleDetail = describeRepoRole(role);
134
+ if (opts.format === 'json') {
135
+ emit(opts, '', { ok: result.valid, valid: result.valid, errors: result.errors, warnings: result.warnings, repoRole: role.role, repoRoleDetail });
136
+ } else {
137
+ if (result.valid) console.log(`✅ state.json is valid (v${state.version}).`);
138
+ else {
139
+ console.error(' state.json is invalid:');
140
+ for (const e of result.errors) console.error(` ${e.path}: ${e.message}`);
141
+ }
142
+ for (const w of result.warnings) console.log(` ⚠️ ${w.path}: ${w.message}`);
143
+ console.log(` Repo role: ${role.role}${repoRoleDetail}`);
144
+ }
145
+ if (!result.valid) process.exit(1);
146
+ return;
147
+ }
148
+
149
+ if (sub === 'migrate') {
150
+ const result = migrateStateFile(statePath, { backup: true });
151
+ if (opts.format === 'json') {
152
+ emit(opts, '', { ok: true, migrated: result.migrated, statePath: result.statePath });
153
+ } else if (result.migrated) {
154
+ console.log(`✅ Migrated ${statePath} to v2 (backup: ${statePath}.v1.bak).`);
155
+ } else {
156
+ console.log('✅ state.json is already v2 — nothing to migrate.');
157
+ }
158
+ return;
159
+ }
160
+
161
+ if (sub === 'transition') {
162
+ if (!opts.to) fail(opts, 'state transition requires --to <phase>');
163
+ const { exists, state } = readState(opts.targetDir);
164
+ if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before transitioning.', () => 2);
165
+ // Freshness is enforced against the current working tree: evaluateTransition
166
+ // recomputes each gate's input-tree hash from the evidence's relevant files.
167
+ const evaluation = evaluateTransition(state, opts.to, { rootDir: opts.targetDir });
168
+ if (!evaluation.allowed) {
169
+ const detail = {
170
+ ok: false,
171
+ allowed: false,
172
+ missingGates: evaluation.missingGates,
173
+ staleEvidence: evaluation.staleEvidence,
174
+ errors: evaluation.errors,
175
+ };
176
+ const lines = ['❌ Transition rejected:'];
177
+ for (const e of evaluation.errors) lines.push(` ${e}`);
178
+ if (evaluation.missingGates.length) lines.push(` missing gates/evidence: ${evaluation.missingGates.join(', ')}`);
179
+ for (const s of evaluation.staleEvidence) lines.push(` stale: ${s.gate} — ${s.reason || (s.reasons || []).join('; ')}`);
180
+ if (opts.format === 'json') emit(opts, '', detail);
181
+ else console.error(lines.join('\n'));
182
+ process.exit(1);
183
+ }
184
+ const next = applyTransition(state, opts.to, { rootDir: opts.targetDir });
185
+ writeState(opts.targetDir, next);
186
+ emit(opts, `✅ Transitioned to ${opts.to}.`, { ok: true, allowed: true, to: opts.to });
187
+ return;
188
+ }
189
+
190
+ fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|transition.`);
191
+ }
192
+
193
+ // ── harness commands ────────────────────────────────────────────────────────
194
+
195
+ async function cmdHarness(opts) {
196
+ const sub = opts.rest[0];
197
+ const policy = loadPolicy(opts.targetDir);
198
+
199
+ if (sub === 'capabilities') {
200
+ const caps = detectCapabilities({ targetDir: opts.targetDir });
201
+ if (opts.format === 'json') emit(opts, '', { ok: true, capabilities: caps });
202
+ else {
203
+ console.log('Cadet-Agent capability report');
204
+ console.log(` CLI: ${caps.cli ? 'available' : 'unavailable'}`);
205
+ console.log(` Unity CLI: ${caps.unityCli.available ? `available (${caps.unityCli.version || 'version unknown'})` : 'unavailable — compile/analyzer gates fall back to manual confirmation'}`);
206
+ console.log(` MCP: ${caps.mcp.available ? 'configured' : 'unavailable — live inspection not available'}`);
207
+ console.log(` Copilot hook: ${caps.hook.copilot ? 'installed' : 'not installed'}`);
208
+ console.log(` Token telemetry:${caps.tokenTelemetry.provider ? ' provider' : ' estimate/unknown'}`);
209
+ console.log(` Cost telemetry: ${caps.costTelemetry.available ? 'available' : `unavailable (${caps.costTelemetry.reason})`}`);
210
+ console.log(` Note: ${caps.hook.note}`);
211
+ }
212
+ return;
213
+ }
214
+
215
+ if (sub === 'record') {
216
+ const { state } = readState(opts.targetDir);
217
+ const ledger = new RunLedger({
218
+ targetDir: opts.targetDir,
219
+ policy,
220
+ runId: opts.runId || state?.activeRunId || null,
221
+ workItemId: opts.workItemId || (state ? workItemIdOf(state) : null),
222
+ phase: opts.phase || state?.session?.currentPhase || null,
223
+ });
224
+ const type = opts.type || 'tool-call';
225
+ const reason = opts.reason || opts.rest[1] || 'recorded event';
226
+ if (type === 'decision') {
227
+ ledger.addDecision({ kind: 'stop', reason });
228
+ } else if (type === 'verification') {
229
+ ledger.addSpan({ kind: 'verification', name: opts.gate || 'manual', status: opts.evidenceStatus || 'ok', reason });
230
+ } else {
231
+ ledger.addSpan({ kind: type, name: opts.gate || type, status: 'ok', reason, tool: opts.tool || null });
232
+ }
233
+ ledger.finalize();
234
+ const path = ledger.persist();
235
+ emit(opts, `✅ Recorded ${type} event in ${path}.`, { ok: true, runId: ledger.runId, path });
236
+ return;
237
+ }
238
+
239
+ if (sub === 'verify') {
240
+ const gate = opts.gate;
241
+ if (!gate) fail(opts, 'harness verify requires --gate <gate>');
242
+ const { state } = readState(opts.targetDir);
243
+ const caps = detectCapabilities({ targetDir: opts.targetDir });
244
+ const descriptor = opts.command
245
+ ? { command: opts.command, tool: 'custom', automated: true }
246
+ : commandForGate(gate, { policy, projectPath: opts.targetDir, unityAvailable: caps.unityCli.available });
247
+
248
+ if (!descriptor.automated || !descriptor.command) {
249
+ const detail = { ok: false, gate, blocked: true, reason: descriptor.reason || 'no automated command available' };
250
+ if (opts.format === 'json') emit(opts, '', detail);
251
+ else console.error(`❌ Cannot automate gate "${gate}": ${detail.reason}. Record a manual confirmation instead.`);
252
+ process.exit(1);
253
+ }
254
+
255
+ const ledger = new RunLedger({
256
+ targetDir: opts.targetDir,
257
+ policy,
258
+ runId: state?.activeRunId || null,
259
+ workItemId: state ? workItemIdOf(state) : null,
260
+ phase: state?.session?.currentPhase || null,
261
+ capabilities: caps,
262
+ });
263
+
264
+ // Relevant files bind the evidence to a concrete input tree so later edits
265
+ // invalidate it. Prefer an explicit --files list; otherwise use the working
266
+ // tree's changed files. If git cannot be queried and no files were supplied,
267
+ // freshness coverage cannot be established — fail safe rather than record a
268
+ // passing gate against an empty input tree. `allowEmptyFreshness` is the
269
+ // explicit, visible opt-out.
270
+ const allowEmpty = policy?.allowEmptyFreshness === true;
271
+ let relevantFiles;
272
+ let filesSource;
273
+ if (opts.files && opts.files.length) {
274
+ relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
275
+ filesSource = 'explicit';
276
+ } else {
277
+ const changed = gitChangedFiles(opts.targetDir);
278
+ if (!changed.available) {
279
+ if (!allowEmpty) {
280
+ const detail = {
281
+ ok: false,
282
+ gate,
283
+ blocked: true,
284
+ code: 'freshness-unavailable',
285
+ 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.`,
286
+ };
287
+ if (opts.format === 'json') emit(opts, '', detail);
288
+ else console.error(`❌ ${detail.reason}`);
289
+ process.exit(1);
290
+ }
291
+ relevantFiles = [];
292
+ filesSource = 'unscoped (allowEmptyFreshness)';
293
+ } else {
294
+ relevantFiles = changed.files;
295
+ filesSource = 'working-tree';
296
+ }
297
+ }
298
+
299
+ if (relevantFiles.length === 0 && !allowEmpty && filesSource !== 'explicit') {
300
+ const detail = {
301
+ ok: false,
302
+ gate,
303
+ blocked: true,
304
+ code: 'freshness-unavailable',
305
+ 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.',
306
+ };
307
+ if (opts.format === 'json') emit(opts, '', detail);
308
+ else console.error(`❌ ${detail.reason}`);
309
+ process.exit(1);
310
+ }
311
+
312
+ // Red-before-green applies to testable work items; a `no_test_required`
313
+ // change is exempt (contract §5).
314
+ const noTestRequired = state?.session?.workflowPath === 'no_test_required';
315
+
316
+ // Record how the relevant files were chosen (provenance) in the ledger.
317
+ ledger.addDecision({
318
+ kind: 'stop',
319
+ reason: `freshness-bound via ${filesSource}`,
320
+ scope: relevantFiles.join(',') || '(none)',
321
+ });
322
+
323
+ const result = await runVerificationLoop({
324
+ gate,
325
+ command: descriptor.command,
326
+ tool: descriptor.tool,
327
+ workItemId: ledger.workItemId || 'unscoped',
328
+ phase: ledger.phase || 'implementation',
329
+ relevantFiles,
330
+ rootDir: opts.targetDir,
331
+ policy,
332
+ budgets: ledger.tracker,
333
+ artifactDir: join(runsDir(opts.targetDir), 'artifacts'),
334
+ priorEvidence: Array.isArray(state?.gateEvidence) ? state.gateEvidence : [],
335
+ requireRedFirst: noTestRequired ? false : null,
336
+ });
337
+
338
+ for (const a of result.attempts) ledger.addEvidence(a.evidence);
339
+ ledger.finalize({ status: result.ok ? 'ok' : 'failed' });
340
+ const path = ledger.persist();
341
+
342
+ // Close the loop: record the produced evidence in state.json so
343
+ // `state transition` can see it. A passing verification flips the gate
344
+ // only when it is evidence-backed; a failing one records the attempt.
345
+ let stateUpdated = false;
346
+ if (state) {
347
+ const next = { ...state };
348
+ next.gateEvidence = [...(Array.isArray(state.gateEvidence) ? state.gateEvidence : []), ...result.attempts.map((a) => a.evidence)];
349
+ if (Array.isArray(next.gateEvidence)) {
350
+ // Mark prior evidence for this gate as superseded by the new record.
351
+ const newest = result.finalEvidence?.evidenceId;
352
+ next.gateEvidence = next.gateEvidence.map((e) =>
353
+ e.gate === gate && e.evidenceId !== newest && e.status === 'passed' && result.ok
354
+ ? { ...e, status: 'superseded', supersededBy: newest }
355
+ : e);
356
+ }
357
+ if (result.ok) {
358
+ next.gates = { ...(state.gates || {}), [gate]: true };
359
+ }
360
+ writeState(opts.targetDir, next);
361
+ stateUpdated = true;
362
+ }
363
+
364
+ if (opts.format === 'json') {
365
+ emit(opts, '', { ok: result.ok, status: result.status, gate, attempts: result.attempts.length, stopReason: result.stopReason, runId: ledger.runId, path, stateUpdated, repoRole: detectRepoRole(opts.targetDir).role });
366
+ } else if (result.ok) {
367
+ console.log(`✅ Gate "${gate}" verified (${result.attempts.length} attempt(s)). Ledger: ${path}`);
368
+ } else {
369
+ console.error(`❌ Gate "${gate}" failed (${result.status}, ${result.stopReason || 'no reason'}). Ledger: ${path}`);
370
+ }
371
+ if (!result.ok) process.exit(1);
372
+ return;
373
+ }
374
+
375
+ if (sub === 'report') {
376
+ const runs = listRuns(opts.targetDir);
377
+ const target = opts.runId || runs[0]?.runId;
378
+ if (!target) fail(opts, 'No run records found in .cadet/runs/.', () => 2);
379
+ const run = loadRun(opts.targetDir, target);
380
+ if (!run) fail(opts, `Run ${target} not found.`, () => 2);
381
+ if (opts.format === 'json') emit(opts, '', { ok: true, report: buildReport(run) });
382
+ else console.log(formatReport(run));
383
+ return;
384
+ }
385
+
386
+ if (sub === 'cleanup') {
387
+ const { deleted, kept } = cleanupRuns(opts.targetDir, policy, {
388
+ olderThanMs: Number.isFinite(opts.olderThanMs) ? opts.olderThanMs : null,
389
+ });
390
+ emit(opts, `✅ Cleanup: deleted ${deleted.length} run(s), kept ${kept.length}.`, { ok: true, deleted, kept });
391
+ return;
392
+ }
393
+
394
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|verify|report|cleanup|capabilities.`);
395
+ }
396
+
397
+ export async function run(argv) {
398
+ const command = argv[2];
399
+ const opts = parseArgs(argv);
400
+
401
+ // Validate the create-only policy flag early so a typo fails loudly.
402
+ const AGENTS_MD_MODES = ['keep', 'overwrite', 'merge'];
403
+ if (opts.agentsMd !== undefined && !AGENTS_MD_MODES.includes(opts.agentsMd)) {
404
+ console.error(`Invalid --agents-md value "${opts.agentsMd}" (expected: ${AGENTS_MD_MODES.join('|')})`);
405
+ process.exit(1);
406
+ }
407
+ const installOpts = {
408
+ sourceUrl: opts.sourceUrl,
409
+ yes: opts.yes === true,
410
+ createOnlyPolicy: opts.agentsMd ? { 'AGENTS.md': opts.agentsMd } : undefined,
411
+ };
412
+
413
+ try {
414
+ switch (command) {
415
+ case 'init':
416
+ await install(opts.targetDir, installOpts);
417
+ break;
418
+ case 'sync':
419
+ await sync(opts.targetDir, installOpts);
420
+ break;
421
+ case 'state':
422
+ await cmdState(opts);
423
+ break;
424
+ case 'harness':
425
+ await cmdHarness(opts);
426
+ break;
427
+ case '--version':
428
+ case '-v':
429
+ console.log(`cadet-agent v${getVersion()}`);
430
+ break;
431
+ case '--help':
432
+ case '-h':
433
+ case undefined:
434
+ showHelp();
435
+ break;
436
+ default:
437
+ console.error(`Unknown command: ${command}`);
438
+ console.error('Run cadet-agent --help for usage.');
439
+ process.exit(1);
440
+ }
441
+ } catch (err) {
442
+ if (err instanceof PolicyError || err instanceof StateError) {
443
+ fail(opts, err.message);
444
+ }
445
+ throw err;
446
+ }
447
+ }
@@ -1,60 +1,64 @@
1
- /**
2
- * Cadet-Agent harness — stable internal entry point.
3
- *
4
- * The CLI and tests import from here so they never depend on the file layout of
5
- * the individual harness modules.
6
- */
7
-
8
- export {
9
- PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, RETRY_CLASSES, CONTEXT_TIERS,
10
- DEFAULT_BUDGETS, HARD_CEILINGS, DEFAULT_ARCHIVE_LIMITS, DEFAULT_OUTPUT_POLICY,
11
- DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY,
12
- validatePolicy, defaultPolicy, loadPolicy, budgetForScope, policyPath, PolicyError,
13
- } from './policy.mjs';
14
-
15
- export {
16
- BudgetTracker, budgetExhaustedResult, budgetReport, evaluateHardStop,
17
- estimateTokens, estimateCost, normalizeUsage, BUDGET_RESULTS,
18
- } from './budget.mjs';
19
-
20
- export {
21
- newId, isUuid, sha256, sha256Bytes, hashFile, hashTree, hashCriteria, timestamp, canonicalJson, changedFiles, gitChangedFiles,
22
- } from './util.mjs';
23
-
24
- export {
25
- STATE_VERSION, validateState, migrateStateV1toV2, migrateStateFile,
26
- createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
27
- latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition,
28
- applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
29
- } from './state.mjs';
30
-
31
- export {
32
- RETRY_CLASSES as VERIFICATION_RETRY_CLASSES, RESULT_STATUSES, TRANSIENT_BACKOFF_MS,
33
- DEFAULT_FLAKY_SIGNATURES, classifyResult, classifyRepair, runCommand,
34
- commandForGate, analyzerClean, runVerificationLoop, manualConfirmation, isBudgetExhaustion,
35
- } from './verification.mjs';
36
-
37
- export {
38
- ContextManifest, buildBaseManifest, tier0References, TIER_REASONS_REQUIRED,
39
- DEFAULT_MAX_EXPANSION_PER_STEP, ContextError,
40
- } from './context.mjs';
41
-
42
- export {
43
- TOOL_KINDS, ROUTING_REASONS, detectCapabilities, routeTask, toolCallSpan, RoutingError,
44
- } from './routing.mjs';
45
-
46
- export { redact, redactString, containsSecret, REDACTED, REDACTION_CATEGORIES } from './redaction.mjs';
47
-
48
- export {
49
- RUN_SCHEMA_VERSION, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
50
- runsDir, LedgerError,
51
- } from './ledger.mjs';
52
-
53
- export {
54
- extractArchive, readArchiveEntry, readEntries, assertContained, crc32, findEocd,
55
- ArchiveError, DEFAULT_ARCHIVE_LIMITS as ARCHIVE_LIMITS,
56
- } from './archive.mjs';
57
-
58
- export {
59
- RELEVANT_TOOLS, WRITE_PATTERNS, detectWrite, evaluateHook, hookOutput,
60
- } from './hook.mjs';
1
+ /**
2
+ * Cadet-Agent harness — stable internal entry point.
3
+ *
4
+ * The CLI and tests import from here so they never depend on the file layout of
5
+ * the individual harness modules.
6
+ */
7
+
8
+ export {
9
+ PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, RETRY_CLASSES, CONTEXT_TIERS,
10
+ DEFAULT_BUDGETS, HARD_CEILINGS, DEFAULT_ARCHIVE_LIMITS, DEFAULT_OUTPUT_POLICY,
11
+ DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY,
12
+ validatePolicy, defaultPolicy, loadPolicy, budgetForScope, policyPath, PolicyError,
13
+ } from './policy.mjs';
14
+
15
+ export {
16
+ BudgetTracker, budgetExhaustedResult, budgetReport, evaluateHardStop,
17
+ estimateTokens, estimateCost, normalizeUsage, BUDGET_RESULTS,
18
+ } from './budget.mjs';
19
+
20
+ export {
21
+ newId, isUuid, sha256, sha256Bytes, hashFile, hashTree, hashCriteria, timestamp, canonicalJson, changedFiles, gitChangedFiles,
22
+ } from './util.mjs';
23
+
24
+ export {
25
+ STATE_VERSION, validateState, migrateStateV1toV2, migrateStateFile,
26
+ createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
27
+ latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition,
28
+ applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
29
+ } from './state.mjs';
30
+
31
+ export {
32
+ RETRY_CLASSES as VERIFICATION_RETRY_CLASSES, RESULT_STATUSES, TRANSIENT_BACKOFF_MS,
33
+ DEFAULT_FLAKY_SIGNATURES, classifyResult, classifyRepair, runCommand,
34
+ commandForGate, analyzerClean, runVerificationLoop, manualConfirmation, isBudgetExhaustion,
35
+ } from './verification.mjs';
36
+
37
+ export {
38
+ ContextManifest, buildBaseManifest, tier0References, TIER_REASONS_REQUIRED,
39
+ DEFAULT_MAX_EXPANSION_PER_STEP, ContextError,
40
+ } from './context.mjs';
41
+
42
+ export {
43
+ TOOL_KINDS, ROUTING_REASONS, detectCapabilities, routeTask, toolCallSpan, RoutingError,
44
+ } from './routing.mjs';
45
+
46
+ export { redact, redactString, containsSecret, REDACTED, REDACTION_CATEGORIES } from './redaction.mjs';
47
+
48
+ export {
49
+ RUN_SCHEMA_VERSION, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
50
+ runsDir, LedgerError,
51
+ } from './ledger.mjs';
52
+
53
+ export {
54
+ extractArchive, readArchiveEntry, readEntries, assertContained, crc32, findEocd,
55
+ ArchiveError, DEFAULT_ARCHIVE_LIMITS as ARCHIVE_LIMITS,
56
+ } from './archive.mjs';
57
+
58
+ export {
59
+ RELEVANT_TOOLS, WRITE_PATTERNS, detectWrite, evaluateHook, hookOutput,
60
+ } from './hook.mjs';
61
+
62
+ export {
63
+ REPO_ROLES, REPO_ROLE_MARKER, detectRepoRole, isFrameworkSourceWithoutWorkItem, describeRepoRole,
64
+ } from './repo-role.mjs';
@@ -0,0 +1,118 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Repository role detection.
6
+ *
7
+ * Cadet is consumed two different ways:
8
+ *
9
+ * - `consumer-project` — the normal case: a Unity/game repository that installs
10
+ * Cadet and runs the workflow. It has `.cadet/state.json` (or will, once the
11
+ * first story starts) and planning artifacts under `.cadet/agent/project-plans/`.
12
+ * - `framework-source` — the canonical Cadet-Agent repository itself (and any
13
+ * fork of it). It ships the *rules about* stories and gates but deliberately
14
+ * holds no `.cadet/state.json` and no `.cadet/agent/project-plans/`; those are
15
+ * listed in CONTRIBUTING.md as "not in this repo".
16
+ *
17
+ * Why this exists: an agent dropped into the framework-source repo sees a tree
18
+ * full of gate vocabulary (`codeReviewCompleted`, Given/When/Then, story
19
+ * templates) with no active work item, and can be led to reason about stories
20
+ * that were never there. Detecting the role lets the CLI and the skills say so
21
+ * explicitly instead of reporting a bare success for a missing state file.
22
+ */
23
+
24
+ export const REPO_ROLES = Object.freeze({
25
+ CONSUMER: 'consumer-project',
26
+ FRAMEWORK: 'framework-source',
27
+ });
28
+
29
+ /** Relative path of the marker file written by install/sync. */
30
+ export const REPO_ROLE_MARKER = '.cadet/.repo-role';
31
+
32
+ /** Higher confidence wins when both a marker and structural signals are present. */
33
+ const CONFIDENCE = Object.freeze({ marker: 'high', structural: 'medium', unknown: 'low' });
34
+
35
+ function readMarker(targetDir) {
36
+ const path = join(targetDir, '.cadet', '.repo-role');
37
+ if (!existsSync(path)) return null;
38
+ try {
39
+ const value = readFileSync(path, 'utf-8').trim();
40
+ return value || null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Detect the role of `targetDir`.
48
+ *
49
+ * Resolution order:
50
+ * 1. Explicit `.cadet/.repo-role` marker (written by install/sync) — high confidence.
51
+ * 2. Structural signals: an existing `.cadet/state.json` or a
52
+ * `.cadet/agent/project-plans/` directory ⇒ consumer-project; a
53
+ * FrameworkManifest.json with no state and no project-plans ⇒ framework-source.
54
+ * 3. Otherwise `unknown`.
55
+ *
56
+ * Never throws: a malformed or unreadable marker degrades to structural signals.
57
+ */
58
+ export function detectRepoRole(targetDir, { marker = undefined } = {}) {
59
+ const explicit = marker === undefined ? readMarker(targetDir) : marker;
60
+
61
+ if (explicit === REPO_ROLES.CONSUMER || explicit === REPO_ROLES.FRAMEWORK) {
62
+ return {
63
+ role: explicit,
64
+ source: 'marker',
65
+ confidence: CONFIDENCE.marker,
66
+ marker: REPO_ROLE_MARKER,
67
+ };
68
+ }
69
+
70
+ const hasState = existsSync(join(targetDir, '.cadet', 'state.json'));
71
+ const hasPlans = existsSync(join(targetDir, '.cadet', 'agent', 'project-plans'));
72
+ const hasManifest = existsSync(join(targetDir, '.cadet', 'agent', 'core', 'FrameworkManifest.json'));
73
+
74
+ if (hasState || hasPlans) {
75
+ return {
76
+ role: REPO_ROLES.CONSUMER,
77
+ source: 'structural',
78
+ confidence: CONFIDENCE.structural,
79
+ signals: { hasState, hasPlans, hasManifest },
80
+ };
81
+ }
82
+
83
+ if (hasManifest) {
84
+ // Framework files are present but there is no story state and no plans tree:
85
+ // this is the framework source (or a fork of it), not a mid-story project.
86
+ return {
87
+ role: REPO_ROLES.FRAMEWORK,
88
+ source: 'structural',
89
+ confidence: CONFIDENCE.structural,
90
+ signals: { hasState, hasPlans, hasManifest },
91
+ };
92
+ }
93
+
94
+ return {
95
+ role: 'unknown',
96
+ source: 'structural',
97
+ confidence: CONFIDENCE.unknown,
98
+ signals: { hasState, hasPlans, hasManifest },
99
+ };
100
+ }
101
+
102
+ /** True when `targetDir` is the framework source and has no active work item. */
103
+ export function isFrameworkSourceWithoutWorkItem(targetDir) {
104
+ const info = detectRepoRole(targetDir);
105
+ if (info.role !== REPO_ROLES.FRAMEWORK) return false;
106
+ return !existsSync(join(targetDir, '.cadet', 'state.json'));
107
+ }
108
+
109
+ /** Human-readable one-liner explaining the detected role and its consequence. */
110
+ export function describeRepoRole(info) {
111
+ if (info.role === REPO_ROLES.FRAMEWORK) {
112
+ return 'framework-source repository — story/gate work is not applicable here; use the contribution workflow (CONTRIBUTING.md).';
113
+ }
114
+ if (info.role === REPO_ROLES.CONSUMER) {
115
+ return 'consumer-project repository — story/gate workflow applies.';
116
+ }
117
+ return 'unknown repository role — no Cadet install detected (run `cadet-agent init`).';
118
+ }
@@ -129,8 +129,23 @@ export function validateState(state, context = {}) {
129
129
  const workItemId = activeWorkItem
130
130
  ? `${activeWorkItem.epicId || 'none'}::${activeWorkItem.storyId || 'none'}`
131
131
  : null;
132
+
133
+ // Gate exceptions are honoured here for the same reasons `evaluateTransition`
134
+ // honours them. Before this, a scoped exception could make a transition legal
135
+ // while `state validate` still reported the identical document as invalid, so
136
+ // the two official commands contradicted each other and a reader could not tell
137
+ // "correctly excepted" from "evidence broken". Exceptions are keyed on the
138
+ // ACTIVE work item, so they cannot excuse a different story's gates.
139
+ const exceptions = activeExceptions(state, { workItemId: workItemId || undefined });
140
+
132
141
  for (const gate of GATES) {
133
142
  if (state.gates[gate] !== true) continue;
143
+
144
+ // An excepted gate is intentionally not held to freshness or work-item
145
+ // ownership: that is precisely what the exception is for. It still must have
146
+ // been claimed true, which the loop condition above already guarantees.
147
+ if (exceptions[gate]) continue;
148
+
134
149
  const evidence = latestEvidenceForGate(state, gate);
135
150
  if (!evidence || (evidence.status !== 'passed' && evidence.status !== 'manual-confirmation')) {
136
151
  errors.push({
package/src/install.mjs CHANGED
@@ -1,11 +1,12 @@
1
- import { readFileSync, unlinkSync, existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
- import { join, relative } from 'node:path';
1
+ import { readFileSync, unlinkSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join, relative, dirname } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { runUpgrades } from './upgrades.mjs';
5
5
  import {
6
6
  extractArchive, readArchiveEntry, findEocd,
7
7
  DEFAULT_ARCHIVE_LIMITS, ArchiveError,
8
8
  } from './harness/archive.mjs';
9
+ import { REPO_ROLES, REPO_ROLE_MARKER } from './harness/repo-role.mjs';
9
10
 
10
11
  // ── Constants ────────────────────────────────────────────────────────────────
11
12
 
@@ -247,6 +248,44 @@ async function downloadZip(url) {
247
248
  return Buffer.concat(chunks);
248
249
  }
249
250
 
251
+ // ── Repo-role marker ─────────────────────────────────────────────────────────
252
+ //
253
+ // Cadet is consumed either as a framework source checkout (this repository and
254
+ // its forks) or as a consumer project that installs the framework. The two need
255
+ // different behaviour — story/gate work does not apply to the framework source.
256
+ // Writing a tiny `.cadet/.repo-role` marker makes that boundary machine-checkable
257
+ // instead of relying on prose, and it is neither a managed nor a preserved path,
258
+ // so sync can never delete or overwrite it by accident.
259
+
260
+ const VALID_ROLES = [REPO_ROLES.CONSUMER, REPO_ROLES.FRAMEWORK];
261
+
262
+ /** Read the `.cadet/.repo-role` marker. Returns the role string, or null. */
263
+ export function readRepoRoleMarker(targetDir) {
264
+ const path = join(targetDir, REPO_ROLE_MARKER);
265
+ if (!existsSync(path)) return null;
266
+ try {
267
+ const value = readFileSync(path, 'utf-8').trim();
268
+ return value || null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Write the `.cadet/.repo-role` marker, creating `.cadet/` if necessary.
276
+ * Defaults to `consumer-project` — the role of anything that runs `init`/`sync`.
277
+ * Throws on an unrecognized role so a typo fails loudly rather than writing junk.
278
+ */
279
+ export function writeRepoRoleMarker(targetDir, role = REPO_ROLES.CONSUMER) {
280
+ if (!VALID_ROLES.includes(role)) {
281
+ throw new Error(`unknown repo role "${role}" (expected: ${VALID_ROLES.join('|')})`);
282
+ }
283
+ const path = join(targetDir, REPO_ROLE_MARKER);
284
+ mkdirSync(dirname(path), { recursive: true });
285
+ writeFileSync(path, `${role}\n`, 'utf-8');
286
+ return path;
287
+ }
288
+
250
289
  // ── Public install entry ────────────────────────────────────────────────────
251
290
 
252
291
  export async function install(targetDir, opts = {}) {
@@ -275,6 +314,11 @@ export async function install(targetDir, opts = {}) {
275
314
  });
276
315
  reportCreateOnlySkips(createOnly, targetDir, releaseVersion, opts);
277
316
 
317
+ // 4b. Record the repository role. This install targets a consumer project, and
318
+ // the marker lets later CLI/skill invocations say so instead of guessing.
319
+ const roleMarker = writeRepoRoleMarker(targetDir, REPO_ROLES.CONSUMER);
320
+ console.log(` Repo role: consumer-project (${roleMarker})`);
321
+
278
322
  // 5. Report
279
323
  console.log(`\n✅ Cadet-Agent v${releaseVersion} installed! Extracted ${extracted.length} files.\n`);
280
324
 
@@ -596,6 +640,12 @@ export async function sync(targetDir, opts = {}) {
596
640
  const oldVersionNorm = normalizeVersion(oldVersion);
597
641
  console.log(` Latest: v${newVersion} (published ${release.published_at})\n`);
598
642
 
643
+ // 2b. Ensure the repo-role marker exists even when no files change, so an
644
+ // existing install synced by an older CLI still gets the boundary recorded.
645
+ if (!readRepoRoleMarker(targetDir)) {
646
+ writeRepoRoleMarker(targetDir, REPO_ROLES.CONSUMER);
647
+ }
648
+
599
649
  if (oldVersionNorm === newVersion) {
600
650
  console.log(`✅ Already up to date (v${oldVersionNorm}). Nothing to sync.\n`);
601
651
  return;