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 +1 -1
- package/src/cli.mjs +447 -434
- package/src/harness/index.mjs +64 -60
- package/src/harness/repo-role.mjs +118 -0
- package/src/harness/state.mjs +15 -0
- package/src/install.mjs +52 -2
package/package.json
CHANGED
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
██║
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
npx cadet-agent@latest init
|
|
33
|
-
npx cadet-agent@latest
|
|
34
|
-
npx cadet-agent@latest sync
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
cadet-agent state
|
|
38
|
-
cadet-agent state
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
cadet-agent harness
|
|
42
|
-
cadet-agent harness
|
|
43
|
-
cadet-agent harness
|
|
44
|
-
cadet-agent harness
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
--
|
|
49
|
-
--
|
|
50
|
-
--
|
|
51
|
-
--
|
|
52
|
-
--
|
|
53
|
-
--
|
|
54
|
-
--
|
|
55
|
-
--
|
|
56
|
-
--
|
|
57
|
-
--
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// the
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
case '--
|
|
70
|
-
case '--
|
|
71
|
-
case '--
|
|
72
|
-
case '--
|
|
73
|
-
case '--
|
|
74
|
-
case '--
|
|
75
|
-
case '--
|
|
76
|
-
case '--
|
|
77
|
-
case '--
|
|
78
|
-
case '--
|
|
79
|
-
case '--
|
|
80
|
-
case '--
|
|
81
|
-
case '--
|
|
82
|
-
case '--
|
|
83
|
-
case '--
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
emit(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
console.log(
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
case '
|
|
416
|
-
|
|
417
|
-
break;
|
|
418
|
-
case '
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
}
|
package/src/harness/index.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/harness/state.mjs
CHANGED
|
@@ -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;
|