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.
- package/README.md +48 -14
- package/package.json +1 -1
- package/src/cli.mjs +268 -21
- package/src/harness/commands.mjs +75 -0
- package/src/harness/context-protocol.mjs +3 -3
- package/src/harness/gates.mjs +28 -1
- package/src/harness/index.mjs +13 -1
- package/src/harness/play-form.mjs +193 -0
- package/src/harness/play.mjs +271 -0
- package/src/harness/policy.mjs +61 -1
- package/src/harness/state.mjs +53 -2
|
@@ -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/
|
|
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, {
|
package/src/harness/gates.mjs
CHANGED
|
@@ -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
|
}
|
package/src/harness/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/harness/policy.mjs
CHANGED
|
@@ -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,
|