cadet-agent 0.53.0 → 0.56.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 +18 -4
- package/package.json +1 -1
- package/src/cli.mjs +698 -17
- package/src/harness/acceptance-form.mjs +281 -0
- package/src/harness/architecture.mjs +201 -0
- package/src/harness/commands.mjs +60 -0
- package/src/harness/context-protocol.mjs +470 -0
- package/src/harness/design-review.mjs +162 -0
- package/src/harness/gates.mjs +364 -0
- package/src/harness/hosts.mjs +349 -0
- package/src/harness/index.mjs +42 -3
- package/src/harness/policy.mjs +283 -10
- package/src/harness/routing.mjs +14 -2
- package/src/harness/state.mjs +185 -15
- package/src/harness/status.mjs +132 -0
- package/src/harness/verification.mjs +51 -28
- package/src/install.mjs +129 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime context protocol: plan, record, validate.
|
|
3
|
+
*
|
|
4
|
+
* Cadet does not inject context into a model — hosts own model context. What the framework can
|
|
5
|
+
* do is state what a phase REQUIRES, let a host report what it actually loaded, and compare the
|
|
6
|
+
* two. That comparison is the whole point: "the agent read the skill file" is otherwise an
|
|
7
|
+
* assertion nobody can check, and an assertion nobody can check is how a workflow becomes
|
|
8
|
+
* ceremony.
|
|
9
|
+
*
|
|
10
|
+
* Four levels, and the difference between them is who can vouch for the loads:
|
|
11
|
+
*
|
|
12
|
+
* - `enforced` — a hook blocked substantive action until the plan was loaded, and the record
|
|
13
|
+
* names the hook. The claim is refused unless that file exists: a host without
|
|
14
|
+
* a hook cannot claim enforcement, and saying so is more useful than pretending
|
|
15
|
+
* every host is equal.
|
|
16
|
+
* - `recorded` — the host reported what it loaded. Observed, but not prevented.
|
|
17
|
+
* - `estimated` — nobody reported; the plan is used to infer what was probably loaded. Useful
|
|
18
|
+
* for a post-hoc reading, and never a pass.
|
|
19
|
+
* - `unavailable` — there is no information at all.
|
|
20
|
+
*
|
|
21
|
+
* `ContextManifest` does the work — hashes, sizes, dedup, budget — so a plan and a record are the
|
|
22
|
+
* same kind of object as every other manifest this framework keeps. This module adds only the
|
|
23
|
+
* rules: what a phase requires, what a record may claim, and when the two disagree.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { ContextManifest } from './context.mjs';
|
|
30
|
+
import { sha256, timestamp } from './util.mjs';
|
|
31
|
+
|
|
32
|
+
/** The levels a record may carry, strongest first. */
|
|
33
|
+
export const CONTEXT_LEVELS = Object.freeze(['enforced', 'recorded', 'estimated', 'unavailable']);
|
|
34
|
+
|
|
35
|
+
/** Where the active plan and record live. Preserved: `sync` never touches them. */
|
|
36
|
+
export const CONTEXT_DIR = '.cadet/context';
|
|
37
|
+
export const CONTEXT_PLAN_FILE = 'plan.json';
|
|
38
|
+
export const CONTEXT_RECORD_FILE = 'record.json';
|
|
39
|
+
|
|
40
|
+
export class ContextProtocolError extends Error {
|
|
41
|
+
constructor(message, code = 'context-protocol') {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'ContextProtocolError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which skill governs each phase.
|
|
50
|
+
*
|
|
51
|
+
* This mirrors the Skill Inventory in `.cadet/agent/core/cadet-agent.md`, and a test asserts that
|
|
52
|
+
* every phase has an entry and every named file exists. It is a table rather than a convention so
|
|
53
|
+
* that a phase with no skill is a visible omission instead of a silent gap.
|
|
54
|
+
*/
|
|
55
|
+
export const PHASE_SKILL = Object.freeze({
|
|
56
|
+
'context-resolution': 'Resume.md',
|
|
57
|
+
requirements: 'Requirements.md',
|
|
58
|
+
requirementsComplete: 'Requirements.md',
|
|
59
|
+
architecture: 'Architecture.md',
|
|
60
|
+
architectureComplete: 'DesignReview.md',
|
|
61
|
+
spikes: 'Spike.md',
|
|
62
|
+
'story-breakdown': 'StoryBreakdown.md',
|
|
63
|
+
implementation: 'TDD.md',
|
|
64
|
+
review: 'CodeReview.md',
|
|
65
|
+
validation: 'Resume.md',
|
|
66
|
+
closed: 'Resume.md',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** The runtime contract every phase reads. */
|
|
70
|
+
const HARNESS_CONTRACT = '.cadet/agent/core/Harness.md';
|
|
71
|
+
const KICKOFF = '.cadet/agent/core/cadet-agent.md';
|
|
72
|
+
|
|
73
|
+
const normalise = (path) => String(path).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
74
|
+
|
|
75
|
+
/** Read a JSON file, or null when it is absent or unreadable. */
|
|
76
|
+
function readJson(path) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function entry(reference, { tier, reason, authority, required }) {
|
|
85
|
+
return { reference: normalise(reference), tier, reason, authority, required };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What the current phase requires, and what merely helps.
|
|
90
|
+
*
|
|
91
|
+
* Required means: a context-complete checkpoint cannot be claimed without it. The list is derived
|
|
92
|
+
* from state and the framework's own layout rather than from a new policy key, because a policy
|
|
93
|
+
* that restates the framework's folder conventions is another thing to keep in step.
|
|
94
|
+
*/
|
|
95
|
+
export function planEntries({ targetDir, policy, state = null }) {
|
|
96
|
+
const phase = state?.session?.currentPhase || 'context-resolution';
|
|
97
|
+
const required = [];
|
|
98
|
+
const advisory = [];
|
|
99
|
+
|
|
100
|
+
required.push(entry(KICKOFF, {
|
|
101
|
+
tier: 'tier0', reason: 'always-load: the directive that routes every other decision', authority: 'framework', required: true,
|
|
102
|
+
}));
|
|
103
|
+
required.push(entry(HARNESS_CONTRACT, {
|
|
104
|
+
tier: 'tier0', reason: 'the runtime contract for gates, evidence and budgets', authority: 'framework', required: true,
|
|
105
|
+
}));
|
|
106
|
+
for (const ref of ['.cadet/harness.json', '.cadet/state.json']) {
|
|
107
|
+
required.push(entry(ref, {
|
|
108
|
+
tier: 'tier0', reason: 'the active policy and the session state the phase reads', authority: 'session', required: true,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The phase's own instruction: the skill the dispatch table names.
|
|
113
|
+
const skillFile = phase === 'architectureComplete' && policy?.designReview?.enabled !== true
|
|
114
|
+
? 'StoryBreakdown.md'
|
|
115
|
+
: PHASE_SKILL[phase];
|
|
116
|
+
if (skillFile) {
|
|
117
|
+
required.push(entry(join('.cadet/agent/core/skills', skillFile), {
|
|
118
|
+
tier: 'tier1', reason: `the skill this phase dispatches (${phase})`, authority: 'framework', required: true,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The work item in flight, and the epic it belongs to.
|
|
123
|
+
const epicId = state?.activeWorkItem?.epicId || null;
|
|
124
|
+
const storyId = state?.activeWorkItem?.storyId || null;
|
|
125
|
+
if (epicId && storyId) {
|
|
126
|
+
required.push(entry(join('.cadet/agent/project-plans', epicId, storyId), {
|
|
127
|
+
tier: 'tier1', reason: 'the active story: its status, its acceptance criteria, its reachability', authority: 'session', required: true,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
if (epicId) {
|
|
131
|
+
advisory.push(entry(join('.cadet/agent/project-plans', epicId, 'Epic.md'), {
|
|
132
|
+
tier: 'tier1', reason: 'the epic this story belongs to', authority: 'repository', required: false,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Everything that helps and nothing that blocks: the artifacts a reader wants nearby.
|
|
137
|
+
if (epicId && storyId) {
|
|
138
|
+
advisory.push(entry(join('.cadet/agent/project-plans', epicId, 'reviews', storyId.replace(/\.md$/, '-review.md')), {
|
|
139
|
+
tier: 'tier2', reason: 'a review of this story, when one exists', authority: 'repository', required: false,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
for (const ref of ['requirements.md', 'technical-design.md']) {
|
|
143
|
+
advisory.push(entry(join('.cadet/agent/project-plans', ref), {
|
|
144
|
+
tier: 'tier2', reason: 'planning context the phase may reason about', authority: 'repository', required: false,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { phase, required, advisory };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build the plan: the entries above, resolved through `ContextManifest` so each carries its hash,
|
|
153
|
+
* size, estimated tokens and budget effect.
|
|
154
|
+
*/
|
|
155
|
+
export function buildContextPlan({ targetDir, policy, state = null } = {}) {
|
|
156
|
+
const { phase, required, advisory } = planEntries({ targetDir, policy, state });
|
|
157
|
+
|
|
158
|
+
// A plan is a READING, not a load, and the load bounds must not bound it.
|
|
159
|
+
//
|
|
160
|
+
// Two bounds apply to a real load — the per-step expansion limit and the context-token hard
|
|
161
|
+
// limit — and both throw when exceeded, because a step that would blow the budget must not
|
|
162
|
+
// proceed. A plan exists to answer whether the budget is blown, so it has to be able to look
|
|
163
|
+
// past the limit and report it. Hence: measure with the limit lifted, compare with the real
|
|
164
|
+
// limit, and put the answer in `budget.fits`.
|
|
165
|
+
//
|
|
166
|
+
// `policy` must be a resolved policy (as `loadPolicy`/`validatePolicy` return it): the budget
|
|
167
|
+
// tracker reads `scopes`, and a partially built object is not a policy.
|
|
168
|
+
const planningPolicy = {
|
|
169
|
+
...policy,
|
|
170
|
+
budgets: policy?.budgets
|
|
171
|
+
? { ...policy.budgets, maxContextTokens: { ...(policy.budgets.maxContextTokens || {}), hard: null, warn: null } }
|
|
172
|
+
: policy?.budgets,
|
|
173
|
+
};
|
|
174
|
+
const manifest = new ContextManifest({ policy: planningPolicy, rootDir: targetDir, maxExpansionPerStep: 128 });
|
|
175
|
+
const resolved = { required: [], advisory: [] };
|
|
176
|
+
const absent = [];
|
|
177
|
+
|
|
178
|
+
for (const [group, list] of Object.entries({ required, advisory })) {
|
|
179
|
+
for (const item of list) {
|
|
180
|
+
const present = existsSync(join(targetDir, item.reference));
|
|
181
|
+
if (!present) {
|
|
182
|
+
absent.push({ reference: item.reference, required: item.required });
|
|
183
|
+
resolved[group].push({ ...item, present: false, hash: null, bytes: 0, estimatedTokens: 0 });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const { item: loaded } = manifest.load({
|
|
187
|
+
reference: item.reference,
|
|
188
|
+
tier: item.tier,
|
|
189
|
+
reason: item.reason,
|
|
190
|
+
authority: item.authority,
|
|
191
|
+
step: 'plan',
|
|
192
|
+
});
|
|
193
|
+
resolved[group].push({ ...item, present: true, hash: loaded.hash, bytes: loaded.bytes, estimatedTokens: loaded.estimatedTokens });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const requiredTokens = resolved.required.reduce((a, i) => a + i.estimatedTokens, 0);
|
|
198
|
+
const advisoryTokens = resolved.advisory.reduce((a, i) => a + i.estimatedTokens, 0);
|
|
199
|
+
const hard = policy?.budgets?.maxContextTokens?.hard ?? null;
|
|
200
|
+
const requiredBytes = resolved.required.reduce((a, i) => a + i.bytes, 0);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
workItemId: state?.activeWorkItem ? `${state.activeWorkItem.epicId}::${state.activeWorkItem.storyId}` : null,
|
|
204
|
+
phase,
|
|
205
|
+
plannedAt: timestamp(),
|
|
206
|
+
required: resolved.required,
|
|
207
|
+
advisory: resolved.advisory,
|
|
208
|
+
absent,
|
|
209
|
+
denied: manifest.manifest().denied,
|
|
210
|
+
budget: {
|
|
211
|
+
requiredBytes,
|
|
212
|
+
requiredTokens,
|
|
213
|
+
advisoryTokens,
|
|
214
|
+
totalTokens: requiredTokens + advisoryTokens,
|
|
215
|
+
hardContextTokens: hard,
|
|
216
|
+
// The honest number: a plan that cannot be loaded inside the budget is a plan the host
|
|
217
|
+
// will silently truncate, so it is reported rather than left to be discovered.
|
|
218
|
+
fits: hard === null ? null : requiredTokens <= hard,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Write the plan, and say where it went. */
|
|
224
|
+
export function writeContextPlan(targetDir, plan) {
|
|
225
|
+
const path = join(targetDir, CONTEXT_DIR, CONTEXT_PLAN_FILE);
|
|
226
|
+
mkdirSync(join(targetDir, CONTEXT_DIR), { recursive: true });
|
|
227
|
+
writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`, 'utf-8');
|
|
228
|
+
return path;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Read the plan file, or null. */
|
|
232
|
+
export function readContextPlan(targetDir) {
|
|
233
|
+
return readJson(join(targetDir, CONTEXT_DIR, CONTEXT_PLAN_FILE));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Read the record file, or null. */
|
|
237
|
+
export function readContextRecord(targetDir) {
|
|
238
|
+
return readJson(join(targetDir, CONTEXT_DIR, CONTEXT_RECORD_FILE));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build a record of what the host loaded.
|
|
243
|
+
*
|
|
244
|
+
* `loaded` is a list of references. Each is hashed AT RECORD TIME, which is what makes a later
|
|
245
|
+
* edit detectable: validate compares that hash with the file's, so "the agent read the skill file
|
|
246
|
+
* and then the file changed" is a stale read rather than an invisible one.
|
|
247
|
+
*/
|
|
248
|
+
export function buildContextRecord({
|
|
249
|
+
targetDir, policy, state = null, plan = null, level = 'recorded',
|
|
250
|
+
loaded = [], enforcedBy = null, host = null, notes = [], now = new Date(),
|
|
251
|
+
} = {}) {
|
|
252
|
+
if (!CONTEXT_LEVELS.includes(level)) {
|
|
253
|
+
throw new ContextProtocolError(`unknown context level "${level}" (expected one of ${CONTEXT_LEVELS.join(', ')})`, 'unknown-level');
|
|
254
|
+
}
|
|
255
|
+
// A host without a hook cannot claim enforcement. Rather than downgrade the claim silently —
|
|
256
|
+
// which would let a false statement into the record and read as a pass — the claim is refused
|
|
257
|
+
// and the remedy named.
|
|
258
|
+
if (level === 'enforced') {
|
|
259
|
+
if (!enforcedBy) {
|
|
260
|
+
throw new ContextProtocolError(
|
|
261
|
+
'a context record may claim "enforced" only by naming the hook that enforced it: pass --enforced-by <path to the hook>. '
|
|
262
|
+
+ 'A host without a hook records the plan as "recorded" instead, which says what was observed without claiming it was prevented.',
|
|
263
|
+
'enforcement-unverifiable',
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const hookPath = join(targetDir, normalise(enforcedBy));
|
|
267
|
+
if (!existsSync(hookPath)) {
|
|
268
|
+
throw new ContextProtocolError(
|
|
269
|
+
`the record names "${enforcedBy}" as the enforcing hook, and that file does not exist in this repository. `
|
|
270
|
+
+ 'An enforcement claim has to name a mechanism that is really there.',
|
|
271
|
+
'enforcement-unverifiable',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
// Existing is not enough, and this is the difference between a claim the framework checks and
|
|
275
|
+
// one it takes on trust: a repository with a git guard has a hook file, and naming it to claim
|
|
276
|
+
// context enforcement would be false. The hook must DECLARE that it enforces context, so
|
|
277
|
+
// "hosts without hooks cannot claim enforced context" is enforced rather than asserted.
|
|
278
|
+
if (!declaresContextEnforcement(hookPath)) {
|
|
279
|
+
throw new ContextProtocolError(
|
|
280
|
+
`"${enforcedBy}" exists but does not declare that it enforces context. A hook that guards something else `
|
|
281
|
+
+ 'is not a context mechanism: declare "{\"enforces\": [\"context\"]}" in the hook file to make the claim, '
|
|
282
|
+
+ 'or record the run as "recorded", which says what was observed without claiming it was prevented.',
|
|
283
|
+
'enforcement-unverifiable',
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const manifest = new ContextManifest({ policy, rootDir: targetDir, maxExpansionPerStep: 128 });
|
|
289
|
+
const resolved = [];
|
|
290
|
+
for (const reference of loaded.map(normalise)) {
|
|
291
|
+
const present = existsSync(join(targetDir, reference));
|
|
292
|
+
if (!present) {
|
|
293
|
+
resolved.push({ reference, present: false, hash: null, bytes: 0, estimatedTokens: 0 });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const { item } = manifest.load({ reference, tier: 'tier1', reason: 'reported as loaded by the host', authority: 'host', step: 'record' });
|
|
297
|
+
resolved.push({ reference, present: true, hash: item.hash, bytes: item.bytes, estimatedTokens: item.estimatedTokens });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const itemId = state?.activeWorkItem ? `${state.activeWorkItem.epicId}::${state.activeWorkItem.storyId}` : null;
|
|
301
|
+
return {
|
|
302
|
+
recordedAt: now.toISOString(),
|
|
303
|
+
workItemId: itemId,
|
|
304
|
+
phase: state?.session?.currentPhase || null,
|
|
305
|
+
level,
|
|
306
|
+
enforcedBy: enforcedBy ? normalise(enforcedBy) : null,
|
|
307
|
+
host: host || null,
|
|
308
|
+
loaded: resolved,
|
|
309
|
+
plannedRequired: (plan?.required || []).map((i) => i.reference),
|
|
310
|
+
notes: [...notes],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Write the record. */
|
|
315
|
+
export function writeContextRecord(targetDir, record) {
|
|
316
|
+
const path = join(targetDir, CONTEXT_DIR, CONTEXT_RECORD_FILE);
|
|
317
|
+
mkdirSync(join(targetDir, CONTEXT_DIR), { recursive: true });
|
|
318
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, 'utf-8');
|
|
319
|
+
return path;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Parse a host transcript: one JSON object per line, each naming a `reference`. */
|
|
323
|
+
export function parseTranscript(text) {
|
|
324
|
+
const loaded = [];
|
|
325
|
+
const problems = [];
|
|
326
|
+
for (const [i, line] of String(text).split(/\r?\n/).entries()) {
|
|
327
|
+
const trimmed = line.trim();
|
|
328
|
+
if (trimmed === '') continue;
|
|
329
|
+
let parsed;
|
|
330
|
+
try {
|
|
331
|
+
parsed = JSON.parse(trimmed);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
problems.push(`line ${i + 1} is not JSON (${err.message})`);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const reference = parsed.reference || parsed.path || parsed.file;
|
|
337
|
+
if (typeof reference !== 'string' || reference.trim() === '') {
|
|
338
|
+
problems.push(`line ${i + 1} names no reference`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
loaded.push(normalise(reference));
|
|
342
|
+
}
|
|
343
|
+
return { loaded, problems };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Compare the plan, the record and the files as they are now.
|
|
348
|
+
*
|
|
349
|
+
* The verdict answers one question: could a context-complete checkpoint be claimed? Only when
|
|
350
|
+
* every required reference was loaded, nothing required has changed since, the record belongs to
|
|
351
|
+
* the work item in flight, and the level is one that observes rather than infers.
|
|
352
|
+
*/
|
|
353
|
+
export function validateContextRecord({ targetDir, plan, record }) {
|
|
354
|
+
const required = plan?.required || [];
|
|
355
|
+
const advisory = plan?.advisory || [];
|
|
356
|
+
const reasons = [];
|
|
357
|
+
|
|
358
|
+
if (!record) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false, level: null, code: 'no-record',
|
|
361
|
+
missingRequired: required.filter((i) => i.present).map((i) => i.reference),
|
|
362
|
+
staleRequired: [], advisoryMissing: advisory.filter((i) => i.present).map((i) => i.reference),
|
|
363
|
+
reasons: ['no context record exists: run "cadet-agent harness context record" after loading the plan, or the checkpoint cannot be claimed'],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Work-item invalidation: a record from a previous story says nothing about this one.
|
|
368
|
+
if (record.workItemId && plan?.workItemId && record.workItemId !== plan.workItemId) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false, level: record.level, code: 'work-item-changed',
|
|
371
|
+
missingRequired: required.filter((i) => i.present).map((i) => i.reference),
|
|
372
|
+
staleRequired: [], advisoryMissing: [],
|
|
373
|
+
reasons: [`the record belongs to ${record.workItemId}, and the active work item is ${plan.workItemId}: it was taken before the story boundary, so it describes different context`],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// An enforcement claim whose mechanism has gone is no longer an enforcement claim.
|
|
378
|
+
if (record.level === 'enforced' && (!record.enforcedBy || !existsSync(join(targetDir, record.enforcedBy)))) {
|
|
379
|
+
reasons.push(`the record claims enforcement by "${record.enforcedBy || '(nothing)'}", which is not in the repository: the claim cannot stand`);
|
|
380
|
+
return { ok: false, level: record.level, code: 'enforcement-unverifiable', missingRequired: [], staleRequired: [], advisoryMissing: [], reasons };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const loadedByRef = new Map((record.loaded || []).map((i) => [i.reference, i]));
|
|
384
|
+
const missingRequired = [];
|
|
385
|
+
const staleRequired = [];
|
|
386
|
+
for (const item of required) {
|
|
387
|
+
if (!item.present) continue; // an absent file is a plan problem, reported separately
|
|
388
|
+
const loaded = loadedByRef.get(item.reference);
|
|
389
|
+
if (!loaded) {
|
|
390
|
+
missingRequired.push(item.reference);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
// Hash comparison: the file changed after the host says it read it.
|
|
394
|
+
const current = existsSync(join(targetDir, item.reference)) ? loadedNowHash(targetDir, item.reference) : null;
|
|
395
|
+
if (current !== null && loaded.hash !== current) staleRequired.push(item.reference);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const advisoryMissing = advisory.filter((i) => i.present && !loadedByRef.has(i.reference)).map((i) => i.reference);
|
|
399
|
+
const absentRequired = required.filter((i) => !i.present).map((i) => i.reference);
|
|
400
|
+
|
|
401
|
+
// An inferred level cannot certify a checkpoint. "Not verified" and "verified clean" must not
|
|
402
|
+
// look the same, which is the same rule `state validate --verify-sealed` follows.
|
|
403
|
+
if (record.level === 'estimated' || record.level === 'unavailable') {
|
|
404
|
+
reasons.push(`the record's level is "${record.level}": nobody observed what was loaded, so a context-complete checkpoint cannot be claimed from it`);
|
|
405
|
+
return { ok: false, level: record.level, code: 'context-unverified', missingRequired, staleRequired, advisoryMissing, reasons };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (missingRequired.length > 0) reasons.push(`required context was never loaded: ${missingRequired.join(', ')}`);
|
|
409
|
+
if (staleRequired.length > 0) reasons.push(`required context changed after it was recorded: ${staleRequired.join(', ')}`);
|
|
410
|
+
if (absentRequired.length > 0) reasons.push(`the plan requires references that do not exist in this repository: ${absentRequired.join(', ')} — the plan is wrong, not the host`);
|
|
411
|
+
if (advisoryMissing.length > 0) {
|
|
412
|
+
reasons.push(`advisory context was not loaded (reported, never blocking): ${advisoryMissing.join(', ')}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const ok = missingRequired.length === 0 && staleRequired.length === 0 && absentRequired.length === 0;
|
|
416
|
+
return {
|
|
417
|
+
ok,
|
|
418
|
+
level: record.level,
|
|
419
|
+
code: ok ? 'context-complete' : 'context-incomplete',
|
|
420
|
+
missingRequired, staleRequired, advisoryMissing, absentRequired,
|
|
421
|
+
reasons,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Does this hook file declare context enforcement?
|
|
427
|
+
*
|
|
428
|
+
* The declaration is a JSON field — `"enforces": ["context"]` — because a claim needs a mechanism
|
|
429
|
+
* that says what it does. Left as prose it would be unverifiable, and an unverifiable enforcement
|
|
430
|
+
* claim is exactly the kind of assertion this framework exists to refuse.
|
|
431
|
+
*/
|
|
432
|
+
export function declaresContextEnforcement(path) {
|
|
433
|
+
try {
|
|
434
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
435
|
+
const declared = parsed?.enforces ?? parsed?.hooks?.enforces;
|
|
436
|
+
return Array.isArray(declared) && declared.map((d) => String(d).toLowerCase()).includes('context');
|
|
437
|
+
} catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The current hash of a file, for the staleness comparison. */
|
|
443
|
+
function loadedNowHash(targetDir, reference) {
|
|
444
|
+
try {
|
|
445
|
+
return sha256(readFileSync(join(targetDir, reference)));
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* A short reading for a report: the level, and what is missing or stale.
|
|
453
|
+
*
|
|
454
|
+
* It never upgrades a level. A report that calls advisory loading "enforced" is worse than no
|
|
455
|
+
* report, because the reader stops looking.
|
|
456
|
+
*/
|
|
457
|
+
export function describeContextState({ plan, record, verdict = null }) {
|
|
458
|
+
if (!record) {
|
|
459
|
+
return { level: 'unavailable', line: 'Context: unavailable — no record; the host has not said what it loaded.' };
|
|
460
|
+
}
|
|
461
|
+
const required = (plan?.required || []).filter((i) => i.present).length;
|
|
462
|
+
const loaded = (record.loaded || []).filter((i) => i.present).length;
|
|
463
|
+
const bits = [`Context: ${record.level}`, `${loaded}/${required} required loaded`];
|
|
464
|
+
if (verdict) {
|
|
465
|
+
if (verdict.missingRequired?.length) bits.push(`missing: ${verdict.missingRequired.join(', ')}`);
|
|
466
|
+
if (verdict.staleRequired?.length) bits.push(`stale: ${verdict.staleRequired.join(', ')}`);
|
|
467
|
+
if (verdict.advisoryMissing?.length) bits.push(`advisory not loaded: ${verdict.advisoryMissing.length}`);
|
|
468
|
+
}
|
|
469
|
+
return { level: record.level, line: `${bits.join(' — ')}.` };
|
|
470
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design-review artifact — the record a formal review leaves behind, and the
|
|
3
|
+
* mechanical contract the gate checks.
|
|
4
|
+
*
|
|
5
|
+
* What this enforces, and what it does not:
|
|
6
|
+
*
|
|
7
|
+
* - It enforces STRUCTURE and the absence of an unresolved blocker: the artifact
|
|
8
|
+
* names its reviewer and inputs, lists findings with a disposition each, gives a
|
|
9
|
+
* reference for the dispositions that claim something exists elsewhere, and
|
|
10
|
+
* names a resolver for every contested finding.
|
|
11
|
+
* - It does NOT judge the review's quality, the design's merit, or whether the
|
|
12
|
+
* reviewer really read anything. Those are judgements, and a check that claimed
|
|
13
|
+
* to make them would be a prose assertion dressed as a test — the failure mode
|
|
14
|
+
* `HarnessContract.md` §0.1 names. The framework's answer is that a named
|
|
15
|
+
* reviewer, bound inputs and a recorded disposition make a bad review visible,
|
|
16
|
+
* not impossible.
|
|
17
|
+
*
|
|
18
|
+
* Why the shape is fixed at all: a review whose findings have no disposition is a
|
|
19
|
+
* list of worries, and a contested design decision with no resolver is the thing the
|
|
20
|
+
* gate exists to stop. Both are cheap to require and impossible to require later.
|
|
21
|
+
*
|
|
22
|
+
* Contract: docs/core/HarnessContract.md C15.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The dispositions a finding may carry. */
|
|
26
|
+
export const FINDING_DISPOSITIONS = Object.freeze(['accepted', 'rejected', 'deferred', 'contested']);
|
|
27
|
+
|
|
28
|
+
/** Dispositions whose claim points at something else, so they must say where. */
|
|
29
|
+
const DISPOSITIONS_NEEDING_REFERENCE = Object.freeze(['accepted', 'deferred']);
|
|
30
|
+
|
|
31
|
+
const FINDING_ID = /^DR-\d+$/;
|
|
32
|
+
|
|
33
|
+
function cellsOf(line) {
|
|
34
|
+
// A markdown table row: leading and trailing pipes are optional in the wild, so
|
|
35
|
+
// split on the pipes and drop the empty ends rather than requiring both.
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed.startsWith('|')) return null;
|
|
38
|
+
return trimmed.split('|').slice(1, -1).map((c) => c.trim());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isSeparator(cells) {
|
|
42
|
+
return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse a design-review artifact.
|
|
47
|
+
*
|
|
48
|
+
* Returns `{ reviewer, inputs, findings, contested, resolved, errors }`. `errors` is
|
|
49
|
+
* the list of structural problems, each with a stable `code` so a caller can branch
|
|
50
|
+
* and a test can assert the finding rather than the prose.
|
|
51
|
+
*/
|
|
52
|
+
export function parseDesignReviewArtifact(text) {
|
|
53
|
+
const raw = typeof text === 'string' ? text : String(text ?? '');
|
|
54
|
+
const lines = raw.split(/\r?\n/);
|
|
55
|
+
|
|
56
|
+
let reviewer = null;
|
|
57
|
+
let inputs = null;
|
|
58
|
+
let sectionPresent = false;
|
|
59
|
+
let inFindings = false;
|
|
60
|
+
let inResolution = false;
|
|
61
|
+
const findings = [];
|
|
62
|
+
const resolutions = new Map();
|
|
63
|
+
const errors = [];
|
|
64
|
+
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
|
|
68
|
+
const rev = /^\*{0,2}Reviewer\*{0,2}\s*:\s*(.*)$/i.exec(trimmed);
|
|
69
|
+
if (rev && reviewer === null) { reviewer = rev[1].trim(); continue; }
|
|
70
|
+
const inp = /^\*{0,2}Inputs\*{0,2}\s*:\s*(.*)$/i.exec(trimmed);
|
|
71
|
+
if (inp && inputs === null) { inputs = inp[1].trim(); continue; }
|
|
72
|
+
|
|
73
|
+
if (/^##\s+Findings\b/i.test(trimmed)) { sectionPresent = true; inFindings = true; inResolution = false; continue; }
|
|
74
|
+
if (/^##\s+Resolution\b/i.test(trimmed)) { inResolution = true; inFindings = false; continue; }
|
|
75
|
+
if (/^##\s+/.test(trimmed)) { inFindings = false; inResolution = false; continue; }
|
|
76
|
+
|
|
77
|
+
if (inResolution) {
|
|
78
|
+
// `- DR-2: the owner keeps the event bus — resolved by Dana`
|
|
79
|
+
const m = /^[-*]\s*(DR-\d+)\s*:\s*(.*)$/.exec(trimmed);
|
|
80
|
+
if (m) resolutions.set(m[1], m[2].trim());
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (inFindings) {
|
|
85
|
+
const cells = cellsOf(trimmed);
|
|
86
|
+
if (!cells) continue;
|
|
87
|
+
if (isSeparator(cells)) continue;
|
|
88
|
+
if (cells[0].toLowerCase() === 'id') continue; // the header row of the table
|
|
89
|
+
findings.push({
|
|
90
|
+
id: cells[0],
|
|
91
|
+
finding: cells[1] || '',
|
|
92
|
+
severity: cells[2] || '',
|
|
93
|
+
disposition: (cells[3] || '').toLowerCase(),
|
|
94
|
+
reference: cells[4] || '',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!sectionPresent) {
|
|
100
|
+
errors.push({ code: 'no-findings-section', message: 'the artifact has no "## Findings" section, so it cannot show that a review took place' });
|
|
101
|
+
}
|
|
102
|
+
if (!reviewer) {
|
|
103
|
+
errors.push({ code: 'no-reviewer', message: 'the artifact does not name its reviewer (add a "Reviewer: <name>" line)' });
|
|
104
|
+
}
|
|
105
|
+
if (!inputs) {
|
|
106
|
+
errors.push({ code: 'no-inputs', message: 'the artifact does not list its inputs (add an "Inputs: <requirements, design, ADRs>" line)' });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const f of findings) {
|
|
111
|
+
if (!FINDING_ID.test(f.id)) {
|
|
112
|
+
errors.push({ code: 'bad-finding-id', message: `finding id "${f.id}" is not of the form DR-<number>` });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (seen.has(f.id)) {
|
|
116
|
+
errors.push({ code: 'duplicate-finding', message: `finding ${f.id} appears more than once` });
|
|
117
|
+
}
|
|
118
|
+
seen.add(f.id);
|
|
119
|
+
if (f.finding === '') {
|
|
120
|
+
errors.push({ code: 'empty-finding', message: `finding ${f.id} states nothing` });
|
|
121
|
+
}
|
|
122
|
+
if (!FINDING_DISPOSITIONS.includes(f.disposition)) {
|
|
123
|
+
errors.push({
|
|
124
|
+
code: 'bad-disposition',
|
|
125
|
+
message: `finding ${f.id} has disposition "${f.disposition || '(none)'}"; expected one of ${FINDING_DISPOSITIONS.join(', ')}`,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (DISPOSITIONS_NEEDING_REFERENCE.includes(f.disposition) && f.reference === '') {
|
|
130
|
+
errors.push({
|
|
131
|
+
code: 'no-reference',
|
|
132
|
+
message: `finding ${f.id} is "${f.disposition}", which claims something exists elsewhere, so it must name where (a reference cell)`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (f.disposition === 'contested') {
|
|
136
|
+
const resolution = resolutions.get(f.id) || '';
|
|
137
|
+
const named = /resolved\s+by\s+\S/i.test(resolution);
|
|
138
|
+
if (!resolution || !named) {
|
|
139
|
+
errors.push({
|
|
140
|
+
code: 'contested-unresolved',
|
|
141
|
+
message: `finding ${f.id} is contested and has no resolution naming who decided it (add "- ${f.id}: <decision> — resolved by <name>" under "## Resolution")`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const contested = findings.filter((f) => f.disposition === 'contested').map((f) => f.id);
|
|
148
|
+
return {
|
|
149
|
+
reviewer,
|
|
150
|
+
inputs,
|
|
151
|
+
findings,
|
|
152
|
+
contested,
|
|
153
|
+
resolved: contested.filter((id) => resolutions.has(id)),
|
|
154
|
+
resolutions,
|
|
155
|
+
errors,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Human-readable failure lines, one per structural problem. */
|
|
160
|
+
export function describeDesignReviewGaps(parsed) {
|
|
161
|
+
return parsed.errors.map((e) => ` ${e.message}`);
|
|
162
|
+
}
|