cadet-agent 0.32.1 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -168,6 +168,10 @@ Hard gates are enforced at every phase transition. The agent reads `.cadet/state
168
168
  | review → validation | `codeReviewCompleted`, `securityReviewPassed`, `acceptanceCriteriaValidated` |
169
169
  | validation → closed | `designArtifactSyncConfirmed` |
170
170
 
171
+ **`closed` is end-of-epic, not per-story.** `validation → closed` is taken only when no stories remain (`NEXT_STORY → no → CLOSED` above). When an epic still has stories, the next story re-enters from `validation → implementation` (`NEXT_STORY → yes → IMPL`). Do not close a story individually: `closed` is terminal, and there is no transition out of it.
172
+
173
+ The full set of legal transitions is the three gated rows above **plus** the ungated forward edges (classification, planning progression, `story-breakdown → implementation`, and the `validation → implementation` next-story loop). Any transition outside that set is rejected with a named reason.
174
+
171
175
  ### Harness
172
176
 
173
177
  Gates are backed by **evidence**, not assertion. Each claimed gate must have a fresh, non-superseded evidence record bound to the current work item, input tree hash, and acceptance criteria. The harness also bounds context, tokens, tool calls, retries, wall-clock time, cost, and archive sizes — and those bounds are enforced, not advisory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadet-agent",
3
- "version": "0.32.1",
3
+ "version": "0.33.1",
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,55 @@ 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
+ // Next-story loop. The workflow is
722
+ // VALIDATE -->|"gate: designArtifactSyncConfirmed"| NEXT_STORY
723
+ // NEXT_STORY -->|"yes"| IMPL
724
+ // NEXT_STORY -->|"no"| CLOSED
725
+ // so the next story in an epic re-enters implementation from `validation`.
726
+ // `closed` stays terminal — it means the epic/plan is finished
727
+ // (Resume: "All work is complete for the current epic(s)") — and is
728
+ // deliberately NOT an escape hatch for starting the next story.
729
+ ['validation', 'implementation'],
730
+ ]);
731
+
732
+ /** Is `from → to` one of the declared ungated forward edges? */
733
+ export function isUngatedForwardEdge(fromPhase, toPhase) {
734
+ return UNGATED_FORWARD_EDGES.some(([from, to]) => from === fromPhase && to === toPhase);
735
+ }
736
+
688
737
  /**
689
738
  * Check one gate for a transition. Shared by the primary `gates` set and the
690
739
  * strict-closure `revalidate` set so the two can never drift apart.
@@ -770,8 +819,15 @@ export function evaluateTransition(state, toPhase, context = {}) {
770
819
 
771
820
  const spec = requiredGates(toPhase);
772
821
  if (!spec) {
773
- // Ungated transitions (e.g. context-resolution → requirements) are legal.
774
- return { allowed: true, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
822
+ // Ungated transitions are legal ONLY along the declared forward edges
823
+ // (bootstrap + planning progression). A target that is neither gated nor a
824
+ // declared forward edge is rejected — most importantly, this makes `closed`
825
+ // terminal instead of an any-to-any escape hatch.
826
+ if (isUngatedForwardEdge(fromPhase, toPhase)) {
827
+ return { allowed: true, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
828
+ }
829
+ errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (not a gated transition, and not a declared forward edge)`);
830
+ return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
775
831
  }
776
832
  if (spec.from !== fromPhase) {
777
833
  errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (expected from "${spec.from}")`);