cadet-agent 0.32.1 → 0.33.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.32.1",
3
+ "version": "0.33.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
@@ -39,6 +39,7 @@ function showHelp() {
39
39
  cadet-agent state validate Validate .cadet/state.json against the v2 schema
40
40
  cadet-agent state migrate Atomically migrate v1 state to v2 (backup on write)
41
41
  cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
42
+ cadet-agent state transition --to <phase> --dry-run Check only; writes nothing
42
43
 
43
44
  cadet-agent harness record Append a sanitized span/evidence/decision event
44
45
  cadet-agent harness confirm Record manual-confirmation evidence (writes ledger + state)
@@ -93,6 +94,7 @@ function parseArgs(argv) {
93
94
  case '--story': opts.story = argv[++i]; break;
94
95
  case '--report': opts.report = argv[++i]; break;
95
96
  case '--write-coverage': opts.writeCoverage = true; break;
97
+ case '--dry-run': opts.dryRun = true; break;
96
98
  case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
97
99
  case '--agents-md': opts.agentsMd = argv[++i]; break;
98
100
  case '--yes': case '-y': opts.yes = true; break;
@@ -204,16 +206,23 @@ async function cmdState(opts) {
204
206
  if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before transitioning.', () => 2);
205
207
  // Freshness is enforced against the current working tree: evaluateTransition
206
208
  // recomputes each gate's input-tree hash from the evidence's relevant files.
209
+ //
210
+ // `--dry-run` reports the SAME verdict and stops. It is the only safe way to
211
+ // ask "would this transition be allowed?" — running the command without the
212
+ // flag applies the transition. A check that is documented as a dry run must
213
+ // not have side effects, so the write below is gated on `!opts.dryRun`.
207
214
  const evaluation = evaluateTransition(state, opts.to, { rootDir: opts.targetDir });
208
215
  if (!evaluation.allowed) {
209
216
  const detail = {
210
217
  ok: false,
211
218
  allowed: false,
219
+ dryRun: opts.dryRun === true,
220
+ applied: false,
212
221
  missingGates: evaluation.missingGates,
213
222
  staleEvidence: evaluation.staleEvidence,
214
223
  errors: evaluation.errors,
215
224
  };
216
- const lines = ['❌ Transition rejected:'];
225
+ const lines = [`❌ Transition rejected${opts.dryRun ? ' (dry run — nothing was written)' : ''}:`];
217
226
  for (const e of evaluation.errors) lines.push(` ${e}`);
218
227
  if (evaluation.missingGates.length) lines.push(` missing gates/evidence: ${evaluation.missingGates.join(', ')}`);
219
228
  for (const s of evaluation.staleEvidence) lines.push(` stale: ${s.gate} — ${s.reason || (s.reasons || []).join('; ')}`);
@@ -221,9 +230,17 @@ async function cmdState(opts) {
221
230
  else console.error(lines.join('\n'));
222
231
  process.exit(1);
223
232
  }
233
+ if (opts.dryRun) {
234
+ emit(
235
+ opts,
236
+ `✅ Transition ${state.session?.currentPhase} → ${opts.to} would be allowed (dry run — nothing was written).`,
237
+ { ok: true, allowed: true, dryRun: true, applied: false, to: opts.to, from: state.session?.currentPhase },
238
+ );
239
+ return;
240
+ }
224
241
  const next = applyTransition(state, opts.to, { rootDir: opts.targetDir });
225
242
  writeState(opts.targetDir, next);
226
- emit(opts, `✅ Transitioned to ${opts.to}.`, { ok: true, allowed: true, to: opts.to });
243
+ emit(opts, `✅ Transitioned to ${opts.to}.`, { ok: true, allowed: true, dryRun: false, applied: true, to: opts.to });
227
244
  return;
228
245
  }
229
246
 
@@ -25,7 +25,7 @@ export {
25
25
  export {
26
26
  STATE_VERSION, READABLE_STATE_VERSIONS, validateState, migrateStateV1toV2, migrateStateFile,
27
27
  createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
28
- latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition, resolveStrict,
28
+ latestEvidenceForGate, activeExceptions, requiredGates, isUngatedForwardEdge, evaluateTransition, resolveStrict,
29
29
  applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
30
30
  } from './state.mjs';
31
31
 
@@ -685,6 +685,48 @@ export function requiredGates(toPhase) {
685
685
  return null;
686
686
  }
687
687
 
688
+ /**
689
+ * Ungated forward edges — transitions that carry no gate requirement but are
690
+ * still legal. These are the bootstrap and planning-progression edges from the
691
+ * workflow (README mermaid + Workflow.md): classification can drop straight to
692
+ * implementation, and the planning phases advance without gates.
693
+ *
694
+ * This list exists because `requiredGates` returns null for any phase that is
695
+ * never a *target* of a gated transition (implementation, requirements,
696
+ * architecture, …). Treating null as "ungated, therefore legal" allowed a
697
+ * transition into those phases from ANYWHERE — including out of the terminal
698
+ * `closed` phase. The set below is the closed enumeration of the intended
699
+ * forward edges; anything not in it (or in TRANSITIONS) is rejected.
700
+ */
701
+ const UNGATED_FORWARD_EDGES = Object.freeze([
702
+ // Classification (context-resolution) routes to planning or straight to work.
703
+ ['context-resolution', 'requirements'],
704
+ ['context-resolution', 'architecture'],
705
+ ['context-resolution', 'implementation'],
706
+ // Planning progression.
707
+ ['requirements', 'architecture'],
708
+ ['requirements', 'requirementsComplete'],
709
+ ['requirementsComplete', 'architecture'],
710
+ ['requirementsComplete', 'architectureComplete'],
711
+ ['architecture', 'architectureComplete'],
712
+ ['architecture', 'spikes'],
713
+ ['requirements', 'spikes'],
714
+ ['requirementsComplete', 'spikes'],
715
+ ['architectureComplete', 'spikes'],
716
+ ['spikes', 'architecture'],
717
+ ['spikes', 'architectureComplete'],
718
+ ['architectureComplete', 'story-breakdown'],
719
+ ['spikes', 'story-breakdown'],
720
+ ['story-breakdown', 'implementation'],
721
+ // Re-entering work for a new story/epic from a review/vallidation outcome.
722
+ ['story-breakdown', 'story-breakdown'],
723
+ ]);
724
+
725
+ /** Is `from → to` one of the declared ungated forward edges? */
726
+ export function isUngatedForwardEdge(fromPhase, toPhase) {
727
+ return UNGATED_FORWARD_EDGES.some(([from, to]) => from === fromPhase && to === toPhase);
728
+ }
729
+
688
730
  /**
689
731
  * Check one gate for a transition. Shared by the primary `gates` set and the
690
732
  * strict-closure `revalidate` set so the two can never drift apart.
@@ -770,8 +812,15 @@ export function evaluateTransition(state, toPhase, context = {}) {
770
812
 
771
813
  const spec = requiredGates(toPhase);
772
814
  if (!spec) {
773
- // Ungated transitions (e.g. context-resolution → requirements) are legal.
774
- return { allowed: true, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
815
+ // Ungated transitions are legal ONLY along the declared forward edges
816
+ // (bootstrap + planning progression). A target that is neither gated nor a
817
+ // declared forward edge is rejected — most importantly, this makes `closed`
818
+ // terminal instead of an any-to-any escape hatch.
819
+ if (isUngatedForwardEdge(fromPhase, toPhase)) {
820
+ return { allowed: true, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
821
+ }
822
+ errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (not a gated transition, and not a declared forward edge)`);
823
+ return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
775
824
  }
776
825
  if (spec.from !== fromPhase) {
777
826
  errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (expected from "${spec.from}")`);