cadet-agent 0.56.0 → 0.63.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.
@@ -43,7 +43,7 @@
43
43
  * Contract: docs/core/HarnessContract.md C14.
44
44
  */
45
45
 
46
- import { GATES, REACHABILITY_GATE, MANUAL_ONLY_GATES } from './policy.mjs';
46
+ import { GATES, REACHABILITY_GATE, MANUAL_ONLY_GATES, USER_PLAY_GATE } from './policy.mjs';
47
47
 
48
48
  /**
49
49
  * Who owns the evidence for a gate.
@@ -186,6 +186,28 @@ export const GATE_BUILDERS = Object.freeze({
186
186
  binds: 'files',
187
187
  attests: 'a named person accepted the delivered work against a stated witness, with the limitations they accepted',
188
188
  },
189
+ userPlaythroughConfirmed: {
190
+ owner: 'human',
191
+ contract: 1,
192
+ // No automated path and no project override, for the same reason
193
+ // humanAcceptanceConfirmed has neither: the gate asks whether a PERSON played the
194
+ // delivered work. A command cannot answer it, and neither can a reviewer's record —
195
+ // the record's substance is the person's own account of what they played and what
196
+ // they saw, so the route is to ask them for it and record their answer with
197
+ // `harness confirm --gate userPlaythroughConfirmed --reason "<their answer>"`, and
198
+ // `verify-play` refuses to record a `required` declaration on their behalf.
199
+ automatedPath: null,
200
+ command: null,
201
+ projectCommand: false,
202
+ manual: true,
203
+ // `files`, where its sibling `reachabilityAddressed` binds `story`. The difference is
204
+ // the subject: reachability's evidence is a declaration about a work item, while this
205
+ // gate's evidence is an account of a build the person actually ran, so a later edit to
206
+ // the sources that produced it must invalidate the record. The deferral route
207
+ // (`Play: deferred to <work item>`) binds the story, which is where the declaration is.
208
+ binds: 'files',
209
+ attests: 'a named person played the delivered work and recorded what they did and what they saw',
210
+ },
189
211
  codeReviewCompleted: {
190
212
  owner: 'agent',
191
213
  contract: 1,
@@ -360,5 +382,11 @@ export function auditGateRegistry() {
360
382
  if (gateBuilder(REACHABILITY_GATE)?.projectCommand !== false) {
361
383
  problems.push(`gate "${REACHABILITY_GATE}" must not accept a project command`);
362
384
  }
385
+ if (gateBuilder(USER_PLAY_GATE)?.projectCommand !== false) {
386
+ problems.push(`gate "${USER_PLAY_GATE}" must not accept a project command`);
387
+ }
388
+ if (gateBuilder(USER_PLAY_GATE)?.owner !== 'human') {
389
+ problems.push(`gate "${USER_PLAY_GATE}" must stay human-owned: a person plays the work`);
390
+ }
363
391
  return problems;
364
392
  }
@@ -13,6 +13,7 @@ export {
13
13
  DEFAULT_REACHABILITY, REACHABILITY_GATE,
14
14
  DEFAULT_DESIGN_REVIEW, DESIGN_REVIEW_GATE, DESIGN_REVIEW_TRANSITION_FROM,
15
15
  DEFAULT_HUMAN_ACCEPTANCE, HUMAN_ACCEPTANCE_GATE, HUMAN_ACCEPTANCE_TRANSITION_FROM,
16
+ DEFAULT_USER_PLAY, USER_PLAY_GATE, USER_PLAY_TRANSITION_FROM,
16
17
  DEFAULT_ARCHITECTURE_FITNESS, ARCHITECTURE_GATE, ARCHITECTURE_TRANSITION_FROM,
17
18
  ARCHITECTURE_TRANSITION_TO, CHECK_SEVERITIES, architectureFitnessActive,
18
19
  validatePolicy, defaultPolicy, loadPolicy, budgetForScope, policyPath, PolicyError,
@@ -46,6 +47,7 @@ export {
46
47
  HISTORY_ENTRIES_KEPT, MAX_HISTORY_ENTRY_CHARS, historyEntryLength, compactHistory, DEFAULT_MAX_LIVE_EVIDENCE, retainLiveRecords,
47
48
  createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
48
49
  latestEvidenceForGate, activeExceptions, requiredGates, conditionalEdgeGates, isUngatedForwardEdge,
50
+ buildStateBrief, renderStateBrief, legalEdgesFrom,
49
51
  evaluateTransition, resolveStrict,
50
52
  applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
51
53
  } from './state.mjs';
@@ -71,7 +73,8 @@ export {
71
73
  } from './hosts.mjs';
72
74
 
73
75
  export {
74
- CONTEXT_LEVELS, CONTEXT_DIR, CONTEXT_PLAN_FILE, CONTEXT_RECORD_FILE, PHASE_SKILL,
76
+ CONTEXT_LEVELS, CONTEXT_DIR, CONTEXT_PLAN_FILE, CONTEXT_RECORD_FILE, CONTEXT_BRIEF_FILE, PHASE_SKILL,
77
+ writeStateBrief, resolveWorkItemDocs,
75
78
  ContextProtocolError, planEntries, buildContextPlan, writeContextPlan, readContextPlan,
76
79
  buildContextRecord, writeContextRecord, readContextRecord, parseTranscript,
77
80
  validateContextRecord, describeContextState, declaresContextEnforcement,
@@ -82,12 +85,6 @@ export {
82
85
  runArchitectureChecks, summariseChecks, describeCheckResults,
83
86
  } from './architecture.mjs';
84
87
 
85
- export {
86
- ACCEPTANCE_FORM_GATE, ACCEPTANCE_FORM_FILENAME, ACCEPTANCE_TEMPLATE_RELATIVE,
87
- ACCEPTANCE_HUMAN_FIELDS, acceptanceFormPath, acceptanceCandidates, buildAcceptanceForm,
88
- parseAcceptanceForm, writeAcceptanceForm, isUnfilled,
89
- } from './acceptance-form.mjs';
90
-
91
88
  export {
92
89
  ContextManifest, buildBaseManifest, tier0References, TIER_REASONS_REQUIRED,
93
90
  DEFAULT_MAX_EXPANSION_PER_STEP, ContextError,
@@ -136,8 +133,28 @@ export {
136
133
  } from './reachability.mjs';
137
134
 
138
135
  export {
139
- COMMANDS, mutatingCommands, readOnlyCommands, resolveCommand,
136
+ PLAY_KINDS,
137
+ parsePlayDeclaration, parsePlayDeclarationText,
138
+ validatePlayDeclaration, readSiblingPlayDeclarations, describePlayGaps,
139
+ } from './play.mjs';
140
+
141
+ export {
142
+ COMMANDS, mutatingCommands, readOnlyCommands, resolveCommand, selfBoundFiles,
140
143
  describeCommand, describeAllCommands, checkUnattendedRequirements,
141
144
  } from './commands.mjs';
142
145
 
143
146
  export { computeStatus, STATUS_OK_LINE, STATUS_PREFIX } from './status.mjs';
147
+
148
+ export {
149
+ DEFAULT_EXCLUDED_DIRS, scanPackages, scanComponents, componentEdges,
150
+ } from './csharp.mjs';
151
+
152
+ export {
153
+ DIAGRAM_OUT_DIR, DIAGRAM_MANIFEST, STATUS_COLORS, STATUS_CLASS,
154
+ collectWorkItemFiles, indexComponents, componentWorkMap, statusesFromWork, componentStatusMap,
155
+ componentSlugs, buildDiagramModel, buildStructureModel,
156
+ renderNoml, renderIndexHtml, renderPackageHtml, renderComponentHtml,
157
+ writeDiagramFiles, syncDiagramTree, generateDiagrams, diagramDrift,
158
+ } from './diagram.mjs';
159
+
160
+ export { renderSvg as renderNomlSvg, nomnomlVersion } from './noml.mjs';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * nomnoml renderer wrapper — the only place the framework's UML dependency is
3
+ * touched.
4
+ *
5
+ * nomnoml is CommonJS; this package is ESM. `createRequire` is the interop that
6
+ * works regardless of how Node's named-export detection reads the bundle, and it
7
+ * keeps the load lazy so a run that never renders a diagram never pays for it.
8
+ *
9
+ * The dependency is deliberate and is recorded in the plan and the architecture
10
+ * decision: it breaks the framework's long-standing "dependency-free" property in
11
+ * exchange for a real UML renderer instead of a hand-drawn one.
12
+ */
13
+
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ let cached = null;
18
+
19
+ function loadRenderer() {
20
+ if (cached) return cached;
21
+ try {
22
+ cached = require('nomnoml');
23
+ } catch (err) {
24
+ throw new Error(
25
+ `the UML renderer "nomnoml" is not installed (${err.message}). Run "npm install" in the cadet-agent package.`,
26
+ );
27
+ }
28
+ return cached;
29
+ }
30
+
31
+ /** Render nomnoml source to an SVG string. */
32
+ export function renderSvg(source) {
33
+ const nomnoml = loadRenderer();
34
+ if (typeof nomnoml.renderSvg !== 'function') {
35
+ throw new Error('nomnoml did not expose renderSvg; the installed version is not the expected one.');
36
+ }
37
+ return nomnoml.renderSvg(String(source));
38
+ }
39
+
40
+ /** The installed nomnoml version, or null when it cannot be read. */
41
+ export function nomnomlVersion() {
42
+ try {
43
+ return loadRenderer().version || null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The `Play:` declaration — can a person play this story's deliverable, and if not,
3
+ * which work item will make it playable (contract v7 §1).
4
+ *
5
+ * Why this exists. Every gate in the framework can be satisfied by a command or by a
6
+ * reviewer's judgement, so a project can go fully green for many stories in a row with
7
+ * nothing ever on screen. The reachability gate names that risk and does not close it:
8
+ * a `witnessed` declaration is checked for shape, and with no project probe configured
9
+ * it passes on a sentence. The one gate that does ask a person — `humanAcceptanceConfirmed`
10
+ * — fires at epic closure, which is the point by which every story has already been
11
+ * marked done.
12
+ *
13
+ * So a story states, in one line, whether the user can play it:
14
+ *
15
+ * Play: required — <what the user does, and what they should see>
16
+ * Play: deferred to <work item> — <why it cannot be played yet>
17
+ *
18
+ * A `required` declaration is satisfied only by a person's own record, asked for and recorded
19
+ * in the conversation; `harness verify-play` refuses to record it. A `deferred` declaration
20
+ * is satisfied by the declaration itself, exactly as a reachability deferral is, and it
21
+ * expires the moment its target is done — a gap with an owner and a term, never a parked
22
+ * excuse. There is deliberately no third form: a story with no playable surface of its
23
+ * own is still reached through the running game, so "not applicable" would be an escape
24
+ * hatch an agent could write for itself, which is the class of check this framework keeps
25
+ * having to delete.
26
+ *
27
+ * The mechanics are deliberately the same as reachability's, and the deferral-graph
28
+ * helpers are IMPORTED rather than re-implemented: a deferral cycle spanning a `Play:`
29
+ * edge and a `Reachability:` edge is one graph, and two copies of the walk could disagree
30
+ * about it.
31
+ */
32
+
33
+ import { readFileSync, readdirSync } from 'node:fs';
34
+ import { basename, dirname, join } from 'node:path';
35
+
36
+ import {
37
+ DEFAULT_MAX_STORY_BYTES,
38
+ DEFAULT_MAX_SIBLING_STORIES,
39
+ normalizeWorkItemRef,
40
+ collectWorkItems,
41
+ findDeferralCycles,
42
+ } from './reachability.mjs';
43
+
44
+ /** The two forms a `Play:` declaration may take. */
45
+ export const PLAY_KINDS = Object.freeze(['required', 'deferred']);
46
+
47
+ /**
48
+ * Parse a story's `Play:` declaration out of its text.
49
+ *
50
+ * Returns `{ declared, kind, instruction, deferTo, reason, line, errors }`. The first
51
+ * `Play:` line outside a fenced block wins, and a malformed line returns `declared:
52
+ * false` WITH errors — a declaration that is wrong in a new way must not read as a
53
+ * story that is fine.
54
+ */
55
+ export function parsePlayDeclarationText(text, { maxBytes = DEFAULT_MAX_STORY_BYTES } = {}) {
56
+ const errors = [];
57
+ const raw = String(text ?? '');
58
+ const scanned = raw.length > maxBytes ? raw.slice(0, maxBytes) : raw;
59
+ const lines = scanned.split(/\r?\n/);
60
+
61
+ let inFence = false;
62
+
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const trimmed = lines[i].trim();
65
+ if (/^```/.test(trimmed)) {
66
+ inFence = !inFence;
67
+ continue;
68
+ }
69
+ if (inFence) continue;
70
+
71
+ const m = /^Play\s*:\s*(.*)$/i.exec(trimmed);
72
+ if (!m) continue;
73
+
74
+ const rest = m[1].trim();
75
+ const line = i + 1;
76
+
77
+ if (rest === '') {
78
+ errors.push(`line ${line}: "Play:" declares nothing — state either "required — <what the user does and sees>" or "deferred to <work item> — <why>".`);
79
+ return { declared: false, kind: null, instruction: '', deferTo: null, reason: '', line, errors };
80
+ }
81
+
82
+ const deferred = /^deferred\s+to\s+(\S+)\s*(?:[—-]\s*(.*))?$/i.exec(rest);
83
+ if (deferred) {
84
+ const target = deferred[1].replace(/[.,;]$/, '');
85
+ const reason = (deferred[2] || '').trim();
86
+ if (!reason) {
87
+ errors.push(`line ${line}: a deferral must say WHY the work cannot be played yet ("deferred to ${target} — <reason>"); an unexplained deferral is how a gap becomes permanent.`);
88
+ }
89
+ return { declared: true, kind: 'deferred', instruction: '', deferTo: target, reason, line, errors };
90
+ }
91
+
92
+ const required = /^required\s*(?:[—-]\s*(.*))?$/i.exec(rest);
93
+ if (required) {
94
+ const instruction = (required[1] || '').trim();
95
+ if (instruction === '') {
96
+ errors.push(`line ${line}: "required" must say what the user does and what they should see ("required — <how to reach it and what to look for>"); a play nobody can follow is a play that will not happen.`);
97
+ }
98
+ return { declared: true, kind: 'required', instruction, deferTo: null, reason: '', line, errors };
99
+ }
100
+
101
+ errors.push(`line ${line}: unrecognised play declaration "${rest}" — expected "required — <what the user does and sees>" or "deferred to <work item> — <why>".`);
102
+ return { declared: false, kind: null, instruction: '', deferTo: null, reason: '', line, errors };
103
+ }
104
+
105
+ return { declared: false, kind: null, instruction: '', deferTo: null, reason: '', line: null, errors };
106
+ }
107
+
108
+ /** File variant of parsePlayDeclarationText. */
109
+ export function parsePlayDeclaration(storyPath) {
110
+ const text = readFileSync(storyPath, 'utf-8');
111
+ return parsePlayDeclarationText(text);
112
+ }
113
+
114
+ /**
115
+ * Validate one declaration against the work items that exist.
116
+ *
117
+ * Returns `{ ok, code, message }`. Codes are stable, so a caller branches and a test
118
+ * asserts the FINDING rather than the prose:
119
+ * malformed — the parser rejected the line. Checked first: a reasonless
120
+ * deferral parses far enough to be typed and must still be
121
+ * refused, because the parser's no is the rule.
122
+ * not-declared — the story declares nothing at all. Silence is not coverage.
123
+ * deferral-self — a deferral names the story itself.
124
+ * unknown-target — a deferral names a work item that does not exist.
125
+ * deferral-target-done — a deferral names a work item that is already done, so the
126
+ * playthrough it promised can never arrive.
127
+ *
128
+ * A `required` declaration with a non-empty instruction is valid: the play itself is the
129
+ * person's, and the gate is what records whether they made it.
130
+ */
131
+ export function validatePlayDeclaration(declaration, { workItems = null, self = null } = {}) {
132
+ const d = declaration || {};
133
+
134
+ if (Array.isArray(d.errors) && d.errors.length > 0) {
135
+ return { ok: false, code: 'malformed', message: d.errors.join(' ') };
136
+ }
137
+ if (!d.declared) {
138
+ return {
139
+ ok: false,
140
+ code: 'not-declared',
141
+ message: 'the story declares no "Play:" line, so nothing says whether a person can play it. A story that cannot be played yet says so: "Play: deferred to <work item> — <why>".',
142
+ };
143
+ }
144
+
145
+ if (d.kind === 'required') {
146
+ // The code matters: `harness verify-play` branches on it to REFUSE recording a playable
147
+ // story's gate. Returning null here would let this command set the gate for a story only a
148
+ // person can settle, which is the hole the gate exists to close.
149
+ return { ok: true, code: 'required', message: `play required: ${d.instruction}` };
150
+ }
151
+
152
+ const target = d.deferTo;
153
+ const selfRefs = new Set(
154
+ (Array.isArray(self) ? self : [self])
155
+ .filter((value) => typeof value === 'string' && value.trim() !== '')
156
+ .map((value) => normalizeWorkItemRef(value)),
157
+ );
158
+
159
+ if (selfRefs.has(normalizeWorkItemRef(target))) {
160
+ return {
161
+ ok: false,
162
+ code: 'deferral-self',
163
+ message: `the deferral names "${target}", which is this story itself. A story cannot become playable by deferring to itself: declare "required", or name the work item that will make it playable.`,
164
+ };
165
+ }
166
+
167
+ if (!workItems) {
168
+ // No state to check against — the target cannot be verified, and "cannot verify"
169
+ // is reported as such rather than assumed fine.
170
+ return { ok: true, code: 'deferred-unchecked', message: `deferred to ${target} (no state document to check the target against)` };
171
+ }
172
+
173
+ const key = normalizeWorkItemRef(target);
174
+ if (!workItems.refs.has(key)) {
175
+ const known = [...workItems.refs].filter((r) => r.includes('::')).slice(0, 8);
176
+ return {
177
+ ok: false,
178
+ code: 'unknown-target',
179
+ message: `the deferral names "${target}", which is not a work item in state.json. A deferral to something that does not exist never expires and never lands. Known work items include: ${known.join(', ') || '(none)'}.`,
180
+ };
181
+ }
182
+
183
+ const status = workItems.status.get(key);
184
+ if (status === 'done' || status === 'complete') {
185
+ return {
186
+ ok: false,
187
+ code: 'deferral-target-done',
188
+ message: `the deferral names "${target}", which is already ${status}. The work item that was going to make this story playable has landed, so the deferral has expired: either the work can be played now (declare "required") or the playable surface was missed when "${target}" closed.`,
189
+ };
190
+ }
191
+
192
+ return { ok: true, code: 'deferred', message: `play deferred to ${target} (${status || 'unknown status'})` };
193
+ }
194
+
195
+ /**
196
+ * The `Play:` declarations of a story's own epic directory, for the deferral graph.
197
+ *
198
+ * The same shape as `readSiblingDeclarations` in `reachability.mjs`, parsing `Play:`
199
+ * lines instead: a cycle can only be found if every sibling's edge is read.
200
+ */
201
+ export function readSiblingPlayDeclarations(storyPath, { max = DEFAULT_MAX_SIBLING_STORIES, workItems = null } = {}) {
202
+ const dir = dirname(storyPath);
203
+ const dirKey = dir.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '';
204
+
205
+ const aliasesFor = (name) => {
206
+ const key = normalizeWorkItemRef(name);
207
+ const aliases = new Set();
208
+ if (dirKey) aliases.add(normalizeWorkItemRef(`${dirKey}::${name}`));
209
+ if (workItems && workItems.refs) {
210
+ for (const ref of workItems.refs) {
211
+ if (ref.endsWith(`::${key}`)) aliases.add(ref);
212
+ }
213
+ }
214
+ aliases.delete(key);
215
+ return [...aliases];
216
+ };
217
+
218
+ const out = [];
219
+ const seen = new Set();
220
+
221
+ const add = (name, path) => {
222
+ const id = normalizeWorkItemRef(name);
223
+ if (seen.has(id) || out.length >= max) return;
224
+ seen.add(id);
225
+ try {
226
+ out.push({ id: name, aliases: aliasesFor(name), path, declaration: parsePlayDeclaration(path) });
227
+ } catch {
228
+ // A file that cannot be read is not this verdict's business.
229
+ }
230
+ };
231
+
232
+ add(basename(storyPath), storyPath);
233
+
234
+ let entries = null;
235
+ try {
236
+ entries = readdirSync(dir);
237
+ } catch {
238
+ entries = null;
239
+ }
240
+
241
+ for (const entry of entries || []) {
242
+ if (out.length >= max) break;
243
+ if (!entry.endsWith('.md')) continue;
244
+ if (entry === basename(storyPath)) continue;
245
+ add(entry, join(dir, entry));
246
+ }
247
+
248
+ return out;
249
+ }
250
+
251
+ /**
252
+ * One line per play gap, for a human-readable failure.
253
+ *
254
+ * AN ARRAY, NOT A JOINED STRING, and the difference is not style: the CLI iterates this
255
+ * (`for (const line of gaps) console.error(line)`), which is the contract its sibling
256
+ * `describeReachabilityGaps` already has. A joined string iterates its CHARACTERS, so the
257
+ * first release of this command printed the refusal one letter per line — a useless message
258
+ * from the one command whose whole job is to tell a caller what to do next. Found by running
259
+ * the released CLI against a real story, which is why the test now asserts the shape.
260
+ */
261
+ export function describePlayGaps({ validation, cycles = [], story } = {}) {
262
+ const lines = [];
263
+ if (validation && !validation.ok) lines.push(`- ${story || 'the story'}: ${validation.message}`);
264
+ for (const cycle of cycles) {
265
+ lines.push(`- the play deferrals form a cycle, so none of these can ever be played: ${cycle.join(' → ')}`);
266
+ }
267
+ return lines;
268
+ }
269
+
270
+ /** Re-exported so the CLI and its tests need one import for both halves of the walk. */
271
+ export { collectWorkItems, findDeferralCycles };
@@ -51,6 +51,11 @@ export const GATES = Object.freeze([
51
51
  // APPENDED by the architecture-fitness change. OPT-IN, and required only when the
52
52
  // project has declared checks — see ARCHITECTURE_GATE.
53
53
  'architectureFitnessPassed',
54
+ // APPENDED by the user-play change. HUMAN-OWNED, for the same reason
55
+ // humanAcceptanceConfirmed is: only a person can answer whether they played the
56
+ // delivered work and what they saw. OPT-IN, and required on the story boundary —
57
+ // see USER_PLAY_GATE.
58
+ 'userPlaythroughConfirmed',
54
59
  ]);
55
60
 
56
61
  /**
@@ -141,6 +146,46 @@ export const DEFAULT_HUMAN_ACCEPTANCE = Object.freeze({
141
146
  enabled: false,
142
147
  });
143
148
 
149
+ /**
150
+ * The user-playthrough gate.
151
+ *
152
+ * It answers the one question the workflow otherwise never asks: did a PERSON play the
153
+ * delivered work. Every other gate can be satisfied by a command or by a reviewer, so a
154
+ * project can go green for many stories in a row with nothing ever on screen — the
155
+ * condition `humanAcceptanceConfirmed` only reaches at epic closure, and the condition a
156
+ * `Reachability: witnessed` line states without proving.
157
+ *
158
+ * Required on `review -> validation`, which IS the story boundary: `validation ->
159
+ * implementation` is the next-story loop and stays unblocked, so "before moving on to
160
+ * the next story" is this edge. It sits beside `reachabilityAddressed` because the two
161
+ * ask the same question at different strengths — reachability asks whether a person CAN
162
+ * reach the deliverable, and this asks whether one DID.
163
+ *
164
+ * A story that cannot be played yet declares `Play: deferred to <work item>`, and that
165
+ * declaration satisfies the gate exactly as a reachability deferral does: it names an
166
+ * owner and expires when the owner is done, so "we will see it later" is a plan with a
167
+ * term rather than a gap. There is deliberately NO "not applicable" form — a story with
168
+ * no playable surface of its own still has a reachable one (the game still runs), and an
169
+ * escape hatch an agent can write for itself is the class of check this framework keeps
170
+ * having to delete.
171
+ */
172
+ export const USER_PLAY_GATE = 'userPlaythroughConfirmed';
173
+
174
+ /** The `from` phase the user-playthrough gate attaches to (`-> validation`). */
175
+ export const USER_PLAY_TRANSITION_FROM = 'review';
176
+
177
+ /**
178
+ * Default user-play policy.
179
+ *
180
+ * Opt-in, on the same reasoning as every other switch in this file: adopting a framework
181
+ * version must not add a requirement to a repository that did not ask for it. A consumer
182
+ * turns it on in its own `.cadet/harness.json`, and a project that has never been played
183
+ * is the condition this gate exists for.
184
+ */
185
+ export const DEFAULT_USER_PLAY = Object.freeze({
186
+ enabled: false,
187
+ });
188
+
144
189
  /**
145
190
  * The architecture-fitness gate.
146
191
  *
@@ -459,6 +504,7 @@ export const MANUAL_ONLY_GATES = Object.freeze([
459
504
  'securityReviewPassed',
460
505
  'designArtifactSyncConfirmed',
461
506
  'humanAcceptanceConfirmed',
507
+ 'userPlaythroughConfirmed',
462
508
  ]);
463
509
 
464
510
  /**
@@ -744,6 +790,18 @@ function resolveHumanAcceptance(raw) {
744
790
  return { enabled: raw.enabled === true };
745
791
  }
746
792
 
793
+ function resolveUserPlay(raw) {
794
+ if (raw === undefined) return { ...DEFAULT_USER_PLAY };
795
+ if (!isPlainObject(raw)) throw new PolicyError('"userPlay" must be an object.');
796
+ for (const key of Object.keys(raw)) {
797
+ if (key !== 'enabled') throw new PolicyError(`Unknown "userPlay" key "${key}".`);
798
+ }
799
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
800
+ throw new PolicyError('"userPlay.enabled" must be a boolean.');
801
+ }
802
+ return { enabled: raw.enabled === true };
803
+ }
804
+
747
805
  function resolveDesignReview(raw) {
748
806
  if (raw === undefined) return { ...DEFAULT_DESIGN_REVIEW };
749
807
  if (!isPlainObject(raw)) throw new PolicyError('"designReview" must be an object.');
@@ -769,7 +827,7 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
769
827
  'budgets', 'archive', 'output', 'retention', 'estimation', 'hook',
770
828
  'allowBudgetCeilingOverride', 'scopes', 'model', 'analyzerCommand',
771
829
  'compileCommand', 'testCommand', 'allowEmptyFreshness', 'strictClosure',
772
- 'reachability', 'designReview', 'humanAcceptance', 'architectureFitness',
830
+ 'reachability', 'designReview', 'humanAcceptance', 'architectureFitness', 'userPlay',
773
831
  ]);
774
832
  for (const key of Object.keys(raw)) {
775
833
  if (!allowed.has(key)) {
@@ -840,6 +898,7 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
840
898
  const designReview = resolveDesignReview(raw.designReview);
841
899
  const humanAcceptance = resolveHumanAcceptance(raw.humanAcceptance);
842
900
  const architectureFitness = resolveArchitectureFitness(raw.architectureFitness);
901
+ const userPlay = resolveUserPlay(raw.userPlay);
843
902
 
844
903
  const resolved = {
845
904
  budgets,
@@ -855,6 +914,7 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
855
914
  designReview,
856
915
  humanAcceptance,
857
916
  architectureFitness,
917
+ userPlay,
858
918
  scopes: raw.scopes || { perRun: {}, perStory: {} },
859
919
  model: raw.model || null,
860
920
  analyzerCommand: raw.analyzerCommand || null,
@@ -56,6 +56,7 @@ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
56
56
 
57
57
  import { PHASES } from './policy.mjs';
58
58
  import { activeExceptionEntries } from './state.mjs';
59
+ import { diagramDrift, DIAGRAM_OUT_DIR } from './diagram.mjs';
59
60
  import {
60
61
  collectWorkItems,
61
62
  parseReachabilityDeclaration,
@@ -247,8 +248,13 @@ export function parseEpicHeader(text) {
247
248
  /**
248
249
  * Every directory under `root`, breadth-first and depth-bounded. Skipping the
249
250
  * usual game-repo ballast keeps a walk of a real project cheap.
251
+ *
252
+ * EXPORTED, because the context plan needs the same discovery this module worked out for the
253
+ * reconciler. A real project nests its plans (`<project>/epics/<epic-N>/`) and names its own files,
254
+ * so a plan built from fixed paths reports a correct project's own story as missing — the lesson
255
+ * REQUIRED_ARTIFACTS above already records. One walk, two consumers.
250
256
  */
251
- function walkDirs(root, maxDepth = MAX_SCAN_DEPTH) {
257
+ export function walkDirs(root, maxDepth = MAX_SCAN_DEPTH) {
252
258
  const found = [];
253
259
  let level = [root];
254
260
  for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
@@ -659,7 +665,33 @@ export function reconcileArtifacts(targetDir, {
659
665
  }
660
666
  }
661
667
 
662
- // ── 4. Sort, number, and summarise ────────────────────────────────────────
668
+ // ── 4. The generated diagram tree ─────────────────────────────────────────
669
+ //
670
+ // The diagrams are DERIVED — from the code and from `state.json` — so a tree
671
+ // that no longer matches is a projection of the record that has stopped
672
+ // agreeing with the record: exactly the class of inconsistency this module
673
+ // exists to find, one step further down the chain.
674
+ //
675
+ // BLOCKING, not a warning. `designArtifactSyncConfirmed` is supported only when
676
+ // the run reports no unexcused blocking finding, so a warning here would report
677
+ // the drift and gate nothing.
678
+ //
679
+ // Opt-in by PRESENCE: a repository that has never generated the tree has no
680
+ // manifest and is not checked, so adopting this release changes nothing until
681
+ // diagrams are actually produced.
682
+ const drift = diagramDrift({ rootDir: targetDir, state });
683
+ if (drift.available && !drift.ok) {
684
+ const counts = [];
685
+ if (drift.missing.length) counts.push(`${drift.missing.length} missing`);
686
+ if (drift.changed.length) counts.push(`${drift.changed.length} changed`);
687
+ if (drift.extra.length) counts.push(`${drift.extra.length} extra`);
688
+ add('diagram-drift', 'blocking', 'diagrams', `${toPosix(DIAGRAM_OUT_DIR)}/`,
689
+ `the committed diagram tree no longer matches the code and the work record (${counts.join(', ')} file(s)). `
690
+ + 'Run `cadet-agent harness diagram` and commit the result, so the diagrams travel with the change that moved them.',
691
+ [...drift.missing, ...drift.changed, ...drift.extra].slice(0, 3).join(', ') || null);
692
+ }
693
+
694
+ // ── 5. Sort, number, and summarise ────────────────────────────────────────
663
695
  findings.sort((a, b) => {
664
696
  const s = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
665
697
  if (s !== 0) return s;