cadet-agent 0.30.0 → 0.31.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.30.0",
3
+ "version": "0.31.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
@@ -6,7 +6,7 @@ import {
6
6
  validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
7
7
  workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
8
8
  runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
9
- detectRepoRole, describeRepoRole,
9
+ detectRepoRole, describeRepoRole, GATES, manualConfirmation,
10
10
  } from './harness/index.mjs';
11
11
 
12
12
  const __filename = fileURLToPath(import.meta.url);
@@ -39,6 +39,7 @@ function showHelp() {
39
39
  cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
40
40
 
41
41
  cadet-agent harness record Append a sanitized span/evidence/decision event
42
+ cadet-agent harness confirm Record manual-confirmation evidence (writes ledger + state)
42
43
  cadet-agent harness verify Run a bounded, classified verification loop
43
44
  cadet-agent harness report Summarize budget consumption and failures
44
45
  cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
@@ -49,9 +50,13 @@ function showHelp() {
49
50
  --source Release API URL override (for forked deployments)
50
51
  --format human|json (default: human)
51
52
  --to Target phase (state transition)
52
- --gate Gate name (harness verify)
53
+ --gate Gate name (harness verify|confirm)
53
54
  --command Command override (harness verify)
54
- --files Comma-separated relevant files to bind evidence to (harness verify)
55
+ --files Comma-separated relevant files to bind evidence to (harness verify|confirm)
56
+ --reason Why automation was unavailable (harness confirm)
57
+ --expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
58
+ --environment key=value,... describing what was verified (harness confirm)
59
+ --scope Comma-separated scope of the confirmation (harness confirm)
55
60
  --agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
56
61
  --yes, -y Never prompt; keep existing files (non-interactive installs)
57
62
  --help, -h Show this help
@@ -77,6 +82,9 @@ function parseArgs(argv) {
77
82
  case '--run': opts.runId = argv[++i]; break;
78
83
  case '--type': opts.type = argv[++i]; break;
79
84
  case '--reason': opts.reason = argv[++i]; break;
85
+ case '--expires-at': opts.expiresAt = argv[++i]; break;
86
+ case '--environment': opts.environment = argv[++i]; break;
87
+ case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
80
88
  case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
81
89
  case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
82
90
  case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
@@ -96,6 +104,28 @@ function emit(opts, human, json) {
96
104
  }
97
105
  }
98
106
 
107
+ /**
108
+ * Parse `--environment "projectPath=...,editorVersion=...,tool=...,host=..."`
109
+ * into an object. Unknown keys are preserved: an unusual environment is still
110
+ * evidence, and silently dropping a field would misrepresent what was verified.
111
+ */
112
+ function parseEnvironment(raw) {
113
+ const env = {};
114
+ if (!raw) return env;
115
+ for (const part of String(raw).split(',')) {
116
+ const eq = part.indexOf('=');
117
+ if (eq === -1) {
118
+ const key = part.trim();
119
+ if (key) env[key] = true;
120
+ continue;
121
+ }
122
+ const key = part.slice(0, eq).trim();
123
+ const value = part.slice(eq + 1).trim();
124
+ if (key) env[key] = value;
125
+ }
126
+ return env;
127
+ }
128
+
99
129
  function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
100
130
  const exitCode = code(json);
101
131
  if (opts.format === 'json') {
@@ -127,8 +157,12 @@ async function cmdState(opts) {
127
157
  );
128
158
  return;
129
159
  }
130
- // Pass rootDir so stale/foreign evidence is caught at validation time.
131
- const result = validateState(state, { rootDir: opts.targetDir });
160
+ // Pass rootDir so stale/foreign evidence is caught at validation time, and
161
+ // the resolved policy so strict-closure rules are actually enforced. Without
162
+ // the policy, `strictClosure` was invisible here and every strict rule was
163
+ // silently skipped — the feature would "install cleanly and do nothing".
164
+ const policy = loadPolicy(opts.targetDir);
165
+ const result = validateState(state, { rootDir: opts.targetDir, strictClosure: policy.strictClosure });
132
166
  const role = detectRepoRole(opts.targetDir);
133
167
  const repoRoleDetail = describeRepoRole(role);
134
168
  if (opts.format === 'json') {
@@ -212,6 +246,137 @@ async function cmdHarness(opts) {
212
246
  return;
213
247
  }
214
248
 
249
+ if (sub === 'confirm') {
250
+ const gate = opts.gate;
251
+ if (!gate) fail(opts, 'harness confirm requires --gate <gate>');
252
+ if (!GATES.includes(gate)) fail(opts, `unknown gate "${gate}". Valid gates: ${GATES.join(', ')}`);
253
+
254
+ const { exists, state } = readState(opts.targetDir);
255
+ if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
256
+
257
+ const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
258
+ const mc = strict?.manualConfirmation || null;
259
+ // One reference instant for the whole command, captured before any work.
260
+ // Reading Date.now() at the check instead made the validity boundary
261
+ // non-deterministic: process latency absorbed a small overage, so the same
262
+ // input could pass or fail run to run.
263
+ const requestedAt = new Date();
264
+
265
+ // Collect EVERY missing field so the caller fixes the record in one pass,
266
+ // rather than discovering one omission per invocation.
267
+ const missing = [];
268
+ if (mc?.requireReason !== false && strict && (!opts.reason || String(opts.reason).trim() === '')) missing.push('--reason');
269
+ if (mc?.requireExpiresAt !== false && strict) {
270
+ if (!opts.expiresAt) missing.push('--expires-at');
271
+ else if (Number.isNaN(Date.parse(opts.expiresAt))) missing.push('--expires-at (not an ISO-8601 date-time)');
272
+ }
273
+ if (mc?.requireEnvironment !== false && strict && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
274
+ if (mc?.requireScope !== false && strict && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
275
+ if (missing.length) {
276
+ fail(opts, `strictClosure requires manual-confirmation metadata. Missing: ${missing.join(', ')}.`, () => 1, { ok: false, gate, code: 'strict-metadata-missing', missing });
277
+ }
278
+
279
+ // A gate listed in disallowManualFor may never be satisfied by a human
280
+ // assertion; point at the automated path instead of accepting the record.
281
+ if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
282
+ fail(opts, `manual-confirmation is not permitted for gate "${gate}" under strictClosure.disallowManualFor; run "cadet-agent harness verify --gate ${gate}" instead.`, () => 1, { ok: false, gate, code: 'manual-disallowed' });
283
+ }
284
+
285
+ // Bound the validity window: an expiry far in the future is how a manual
286
+ // assertion silently becomes permanent. Measured against `requestedAt`, the
287
+ // single instant captured at command start, so the boundary is deterministic
288
+ // and agrees with `validateState` (which anchors to the present too).
289
+ if (mc?.maxValidityMs !== null && mc?.maxValidityMs !== undefined && opts.expiresAt) {
290
+ const window = Date.parse(opts.expiresAt) - requestedAt.getTime();
291
+ if (Number.isFinite(window) && window > mc.maxValidityMs) {
292
+ fail(opts, `requested validity ${window}ms exceeds strictClosure.manualConfirmation.maxValidityMs (${mc.maxValidityMs}ms).`, () => 1, { ok: false, gate, code: 'validity-exceeded', requestedMs: window, maxValidityMs: mc.maxValidityMs });
293
+ }
294
+ }
295
+
296
+ // Freshness binding mirrors `harness verify`: never record a gate against an
297
+ // unknown input tree unless the repository explicitly opted out.
298
+ const allowEmpty = policy?.allowEmptyFreshness === true;
299
+ let relevantFiles;
300
+ if (opts.files && opts.files.length) {
301
+ relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
302
+ } else {
303
+ const changed = gitChangedFiles(opts.targetDir);
304
+ if (!changed.available) {
305
+ if (!allowEmpty) {
306
+ fail(opts, `cannot establish freshness coverage: ${changed.reason}. Pass --files <paths>, or enable allowEmptyFreshness in .cadet/harness.json.`, () => 1, { ok: false, gate, code: 'freshness-unavailable' });
307
+ }
308
+ relevantFiles = [];
309
+ } else {
310
+ relevantFiles = changed.files;
311
+ }
312
+ }
313
+
314
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
315
+ const phase = state?.session?.currentPhase || 'implementation';
316
+ // Reuse the command-start instant so the recorded createdAt and the validity
317
+ // check describe the same moment.
318
+ const at = requestedAt;
319
+ const environment = parseEnvironment(opts.environment);
320
+
321
+ const { evidence } = manualConfirmation({
322
+ gate,
323
+ workItemId,
324
+ phase,
325
+ projectPath: environment.projectPath || null,
326
+ editorVersion: environment.editorVersion || null,
327
+ scope: opts.scope || [],
328
+ reason: opts.reason || null,
329
+ expiresAt: opts.expiresAt || null,
330
+ environment,
331
+ relevantFiles,
332
+ rootDir: opts.targetDir,
333
+ approvedBy: opts.approvedBy || 'user',
334
+ at,
335
+ });
336
+
337
+ // Ledger first, then state. The ledger is append-only and merely references
338
+ // the evidence id; state.json carries the gate claim. Writing state first
339
+ // would let an interruption leave a gate claimed true with no ledger entry.
340
+ // This order fails toward "less proven", never "claimed but unbacked".
341
+ const ledger = new RunLedger({
342
+ targetDir: opts.targetDir,
343
+ policy,
344
+ runId: state?.activeRunId || null,
345
+ workItemId,
346
+ phase,
347
+ });
348
+ ledger.addEvidence(evidence);
349
+ ledger.addDecision({
350
+ kind: 'manual-confirmation',
351
+ reason: opts.reason || 'manual confirmation recorded',
352
+ gate,
353
+ evidenceId: evidence.evidenceId,
354
+ approvedBy: evidence.approvedBy || 'user',
355
+ });
356
+ ledger.finalize({ status: 'ok' });
357
+ const ledgerPath = ledger.persist();
358
+
359
+ const next = { ...state };
360
+ const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
361
+ next.gateEvidence = [
362
+ // Immutability: supersede prior passing evidence, never delete it.
363
+ ...prior.map((e) => (e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')
364
+ ? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
365
+ : e)),
366
+ evidence,
367
+ ];
368
+ next.gates = { ...(state.gates || {}), [gate]: true };
369
+ writeState(opts.targetDir, next);
370
+
371
+ const superseded = prior.filter((e) => e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')).length;
372
+ emit(
373
+ opts,
374
+ `✅ Recorded manual confirmation for gate "${gate}". Evidence: ${evidence.evidenceId}\n Ledger: ${ledgerPath}`,
375
+ { ok: true, gate, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath, stateUpdated: true, superseded },
376
+ );
377
+ return;
378
+ }
379
+
215
380
  if (sub === 'record') {
216
381
  const { state } = readState(opts.targetDir);
217
382
  const ledger = new RunLedger({
@@ -391,7 +556,7 @@ async function cmdHarness(opts) {
391
556
  return;
392
557
  }
393
558
 
394
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|verify|report|cleanup|capabilities.`);
559
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|report|cleanup|capabilities.`);
395
560
  }
396
561
 
397
562
  export async function run(argv) {
@@ -8,7 +8,8 @@
8
8
  export {
9
9
  PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, RETRY_CLASSES, CONTEXT_TIERS,
10
10
  DEFAULT_BUDGETS, HARD_CEILINGS, DEFAULT_ARCHIVE_LIMITS, DEFAULT_OUTPUT_POLICY,
11
- DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY,
11
+ DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY, DEFAULT_STRICT_CLOSURE,
12
+ EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE, AGENT_OWNED_GATES,
12
13
  validatePolicy, defaultPolicy, loadPolicy, budgetForScope, policyPath, PolicyError,
13
14
  } from './policy.mjs';
14
15
 
@@ -22,9 +23,9 @@ export {
22
23
  } from './util.mjs';
23
24
 
24
25
  export {
25
- STATE_VERSION, validateState, migrateStateV1toV2, migrateStateFile,
26
+ STATE_VERSION, READABLE_STATE_VERSIONS, validateState, migrateStateV1toV2, migrateStateFile,
26
27
  createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
27
- latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition,
28
+ latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition, resolveStrict,
28
29
  applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
29
30
  } from './state.mjs';
30
31