cadet-agent 0.56.0 → 0.60.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.
@@ -66,8 +66,8 @@ export const PHASE_SKILL = Object.freeze({
66
66
  closed: 'Resume.md',
67
67
  });
68
68
 
69
- /** The runtime contract every phase reads. */
70
- const HARNESS_CONTRACT = '.cadet/agent/core/Harness.md';
69
+ /** The lean runtime contract every phase reads. */
70
+ const HARNESS_CONTRACT = '.cadet/agent/core/HarnessRuntime.md';
71
71
  const KICKOFF = '.cadet/agent/core/cadet-agent.md';
72
72
 
73
73
  const normalise = (path) => String(path).replace(/\\/g, '/').replace(/^\.\//, '');
@@ -101,7 +101,7 @@ export function planEntries({ targetDir, policy, state = null }) {
101
101
  tier: 'tier0', reason: 'always-load: the directive that routes every other decision', authority: 'framework', required: true,
102
102
  }));
103
103
  required.push(entry(HARNESS_CONTRACT, {
104
- tier: 'tier0', reason: 'the runtime contract for gates, evidence and budgets', authority: 'framework', required: true,
104
+ tier: 'tier0', reason: 'the lean runtime contract for gates, evidence and budgets', authority: 'framework', required: true,
105
105
  }));
106
106
  for (const ref of ['.cadet/harness.json', '.cadet/state.json']) {
107
107
  required.push(entry(ref, {
@@ -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,27 @@ 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, which is why the route is a form they fill in and `verify-play` refuses
197
+ // to record a `required` declaration on their behalf.
198
+ automatedPath: null,
199
+ command: null,
200
+ projectCommand: false,
201
+ manual: true,
202
+ // `files`, where its sibling `reachabilityAddressed` binds `story`. The difference is
203
+ // the subject: reachability's evidence is a declaration about a work item, while this
204
+ // gate's evidence is an account of a build the person actually ran, so a later edit to
205
+ // the sources that produced it must invalidate the record. The deferral route
206
+ // (`Play: deferred to <work item>`) binds the story, which is where the declaration is.
207
+ binds: 'files',
208
+ attests: 'a named person played the delivered work and recorded what they did and what they saw',
209
+ },
189
210
  codeReviewCompleted: {
190
211
  owner: 'agent',
191
212
  contract: 1,
@@ -360,5 +381,11 @@ export function auditGateRegistry() {
360
381
  if (gateBuilder(REACHABILITY_GATE)?.projectCommand !== false) {
361
382
  problems.push(`gate "${REACHABILITY_GATE}" must not accept a project command`);
362
383
  }
384
+ if (gateBuilder(USER_PLAY_GATE)?.projectCommand !== false) {
385
+ problems.push(`gate "${USER_PLAY_GATE}" must not accept a project command`);
386
+ }
387
+ if (gateBuilder(USER_PLAY_GATE)?.owner !== 'human') {
388
+ problems.push(`gate "${USER_PLAY_GATE}" must stay human-owned: a person plays the work`);
389
+ }
363
390
  return problems;
364
391
  }
@@ -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,
@@ -136,7 +137,18 @@ export {
136
137
  } from './reachability.mjs';
137
138
 
138
139
  export {
139
- COMMANDS, mutatingCommands, readOnlyCommands, resolveCommand,
140
+ PLAY_KINDS,
141
+ parsePlayDeclaration, parsePlayDeclarationText,
142
+ validatePlayDeclaration, readSiblingPlayDeclarations, describePlayGaps,
143
+ } from './play.mjs';
144
+
145
+ export {
146
+ PLAY_FORM_GATE, PLAY_FORM_SUFFIX, PLAY_TEMPLATE_RELATIVE, PLAY_HUMAN_FIELDS,
147
+ playFormPath, buildPlayForm, parsePlayForm, writePlayForm, readPlayTemplate,
148
+ } from './play-form.mjs';
149
+
150
+ export {
151
+ COMMANDS, mutatingCommands, readOnlyCommands, resolveCommand, selfBoundFiles,
140
152
  describeCommand, describeAllCommands, checkUnattendedRequirements,
141
153
  } from './commands.mjs';
142
154
 
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The user-playthrough form — the only route by which `userPlaythroughConfirmed` is
3
+ * recorded for a story whose `Play:` declaration is `required`.
4
+ *
5
+ * Why a form rather than a flag. The gate asks whether a PERSON played the delivered
6
+ * work and what they saw. A flag route would let an agent answer that question with a
7
+ * sentence, which is exactly the failure the gate exists to close; the form leaves the
8
+ * three fields only a person can supply unfilled, and `harness confirm` refuses a form
9
+ * that still holds a placeholder. It mirrors the human-acceptance form deliberately —
10
+ * same shape, same refusal, one story instead of one epic.
11
+ *
12
+ * A `Play: deferred to <work item>` declaration never needs a form: the declaration is
13
+ * the answer, and `harness verify-play` records it. This module is for the other half.
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { execFileSync } from 'node:child_process';
18
+ import { dirname, join } from 'node:path';
19
+
20
+ import { isUnfilled } from './acceptance-form.mjs';
21
+
22
+ /** The gate this form records. */
23
+ export const PLAY_FORM_GATE = 'userPlaythroughConfirmed';
24
+
25
+ /** The form's file name, written beside the story it belongs to. */
26
+ export const PLAY_FORM_SUFFIX = '-UserPlaythrough.md';
27
+
28
+ /** The template, relative to the repository root. */
29
+ export const PLAY_TEMPLATE_RELATIVE = '.cadet/agent/core/templates/UserPlaythroughTemplate.md';
30
+
31
+ /**
32
+ * The fields only a person can supply. The generator leaves all three unfilled and the
33
+ * parser refuses a form where any of them still is one.
34
+ */
35
+ export const PLAY_HUMAN_FIELDS = Object.freeze(['player', 'witness', 'limitations']);
36
+
37
+ /** The form's path: beside the story, whose name it extends. */
38
+ export function playFormPath(targetDir, storyPath) {
39
+ const rel = storyPath.replace(/\\/g, '/');
40
+ const stem = rel.endsWith('.md') ? rel.slice(0, -3) : rel;
41
+ return join(targetDir, `${stem}${PLAY_FORM_SUFFIX}`);
42
+ }
43
+
44
+ /** Fill every `<slot id="…"/>` in the template from a value map. */
45
+ function fillSlots(text, values) {
46
+ return text.replace(/<slot\s+id="([^"]+)"((?:"[^"]*"|[^>"])*)\/>/g, (whole, id, attrs) => {
47
+ const value = values[id];
48
+ return value === undefined || value === null ? whole : String(value);
49
+ });
50
+ }
51
+
52
+ /** Strip the template's authoring attributes from a filled form. */
53
+ function stripAttributes(text) {
54
+ return text.replace(/<slot\s+id="([^"]+)"(?:"[^"]*"|[^>"])*\/>/g, (whole, id) => {
55
+ const m = whole.match(/^<slot\s+id="[^"]+"[^>]*?\/>$/);
56
+ return m ? `<slot id="${id}"/>` : whole;
57
+ });
58
+ }
59
+
60
+ /** The machine's best answer for "which revision is this", or `unknown`. */
61
+ function revisionOf(targetDir) {
62
+ try {
63
+ const out = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: targetDir, encoding: 'utf-8' });
64
+ return out.trim() || 'unknown';
65
+ } catch {
66
+ return 'unknown';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Build the form for one story.
72
+ *
73
+ * `storyPath` is repository-relative. The story itself is bound as the relevant file, so
74
+ * editing the story after the playthrough makes the record stale rather than leaving it
75
+ * looking current.
76
+ */
77
+ export function buildPlayForm({ template, state, storyPath, storyRel, epicId = null, targetDir, now = new Date(), declaration = null }) {
78
+ const storyName = storyRel.replace(/\\/g, '/').split('/').pop() || storyRel;
79
+ const instruction = declaration && declaration.kind === 'required' && declaration.instruction
80
+ ? declaration.instruction
81
+ : '(the story declares no instruction — see its Play: line)';
82
+
83
+ const candidates = [];
84
+ candidates.push(`- \`Play:\` declaration: ${declaration && declaration.declared ? declaration.kind : 'MISSING — the story declares no Play: line'}`);
85
+ if (state && state.gates && typeof state.gates === 'object') {
86
+ const unmet = Object.entries(state.gates).filter(([, value]) => value !== true).map(([gate]) => gate);
87
+ if (unmet.length > 0) candidates.push(`- gates not yet true in state.json: ${unmet.join(', ')}`);
88
+ }
89
+ const workItem = state && state.activeWorkItem && state.activeWorkItem.storyId
90
+ ? `${state.activeWorkItem.epicId}::${state.activeWorkItem.storyId}`
91
+ : '(no active work item)';
92
+
93
+ const values = {
94
+ story: `\`${storyName}\` (${workItem})`,
95
+ date: now.toISOString(),
96
+ revision: revisionOf(targetDir),
97
+ files: storyRel.replace(/\\/g, '/'),
98
+ environment: 'editor=<version>, scene=<path>',
99
+ instruction,
100
+ candidates: candidates.join('\n'),
101
+ recording: [
102
+ 'Fill in every field above, then record it — nothing is retyped:',
103
+ '',
104
+ '```',
105
+ `cadet-agent harness confirm --gate ${PLAY_FORM_GATE} \\`,
106
+ ` --artifact <this file> \\`,
107
+ ` --reason "<why this playthrough is the record>" --expires-at <ISO-8601>`,
108
+ '```',
109
+ '',
110
+ 'A form with an unfilled field is refused. The play is the person\'s; the agent only records it.',
111
+ ].join('\n'),
112
+ };
113
+
114
+ return stripAttributes(fillSlots(template, values));
115
+ }
116
+
117
+ /**
118
+ * Parse a filled form.
119
+ *
120
+ * Returns the values it found plus `incomplete`, the human fields still unanswered. It
121
+ * makes no judgement about the playthrough itself — that is the gate's question, and the
122
+ * point of the record is that the person answered it.
123
+ */
124
+ export function parsePlayForm(text) {
125
+ if (typeof text !== 'string' || text.trim() === '') {
126
+ return { incomplete: [...PLAY_HUMAN_FIELDS], error: 'the form is empty' };
127
+ }
128
+ const lines = text.split(/\r?\n/);
129
+
130
+ const scalar = (label) => {
131
+ const re = new RegExp(`^${label}:\\s*(.*)$`, 'i');
132
+ for (const line of lines) {
133
+ const m = line.match(re);
134
+ if (m) return m[1].trim();
135
+ }
136
+ return null;
137
+ };
138
+
139
+ const section = (heading) => {
140
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading}`.toLowerCase());
141
+ if (start < 0) return null;
142
+ const body = [];
143
+ for (let i = start + 1; i < lines.length; i += 1) {
144
+ if (/^##\s/.test(lines[i]) || /^---\s*$/.test(lines[i])) break;
145
+ body.push(lines[i]);
146
+ }
147
+ return body.join('\n').trim();
148
+ };
149
+
150
+ const form = {
151
+ story: (text.match(/^#\s*User Playthrough:\s*(.+)$/m) || [])[1]?.trim() || null,
152
+ player: scalar('Played by'),
153
+ date: scalar('Date'),
154
+ revision: scalar('Revision'),
155
+ files: scalar('Files'),
156
+ environment: scalar('Environment'),
157
+ witness: section('What I did'),
158
+ instruction: section('What the story claims'),
159
+ limitations: section('Accepted limitations'),
160
+ };
161
+
162
+ const incomplete = PLAY_HUMAN_FIELDS.filter((field) => isUnfilled(form[field]));
163
+ if (isUnfilled(form.story)) incomplete.push('story');
164
+
165
+ const fileList = (form.files || '')
166
+ .split(',')
167
+ .map((f) => f.trim().replace(/\\/g, '/'))
168
+ .filter((f) => f && !f.startsWith('('))
169
+ .filter((f) => !isUnfilled(f));
170
+
171
+ return { ...form, fileList, incomplete };
172
+ }
173
+
174
+ /** Write the form, create-only: a half-filled form is never overwritten. */
175
+ export function writePlayForm(targetDir, storyRel, text, { out = null } = {}) {
176
+ const path = out ? join(targetDir, out) : playFormPath(targetDir, storyRel);
177
+ if (existsSync(path)) {
178
+ return { written: false, path, reason: 'a form already exists; edit it and record it, or move it aside to regenerate' };
179
+ }
180
+ mkdirSync(dirname(path), { recursive: true });
181
+ writeFileSync(path, text);
182
+ return { written: true, path };
183
+ }
184
+
185
+ /** Read the template, or throw with a message a caller can print. */
186
+ export function readPlayTemplate(targetDir) {
187
+ const path = join(targetDir, PLAY_TEMPLATE_RELATIVE);
188
+ try {
189
+ return readFileSync(path, 'utf-8');
190
+ } catch (err) {
191
+ throw new Error(`the playthrough template could not be read at ${PLAY_TEMPLATE_RELATIVE}: ${err.message}`);
192
+ }
193
+ }
@@ -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 (the form route in
19
+ * `play-form.mjs`); `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,