cadet-agent 0.30.0 → 0.32.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.
@@ -1,359 +1,560 @@
1
- /**
2
- * Cadet-Agent harness policy.
3
- *
4
- * Loads hard-coded conservative defaults, optionally overlays a repository-local
5
- * `.cadet/harness.json`, validates the merged result, and exposes the resolved
6
- * budget/limit policy used by every other harness module.
7
- *
8
- * Contract: docs/core/HarnessContract.md §3. Phase names and gate names are frozen
9
- * compatibility invariants — this module may validate them but must not rename them.
10
- */
11
-
12
- import { readFileSync, existsSync } from 'node:fs';
13
- import { join } from 'node:path';
14
-
15
- /** Frozen phase names (compatibility invariant C1). */
16
- export const PHASES = Object.freeze([
17
- 'context-resolution',
18
- 'requirements',
19
- 'requirementsComplete',
20
- 'architecture',
21
- 'architectureComplete',
22
- 'spikes',
23
- 'story-breakdown',
24
- 'implementation',
25
- 'review',
26
- 'validation',
27
- 'closed',
28
- ]);
29
-
30
- /** Frozen gate names (compatibility invariant C3). */
31
- export const GATES = Object.freeze([
32
- 'codeReviewCompleted',
33
- 'testsPassed',
34
- 'storyTrackingUpdated',
35
- 'compileCheckConfirmed',
36
- 'unityAnalyzerClean',
37
- 'acceptanceCriteriaValidated',
38
- 'securityReviewPassed',
39
- 'designArtifactSyncConfirmed',
40
- ]);
41
-
42
- /** Legal phase transitions (compatibility invariant C4). */
43
- export const TRANSITIONS = Object.freeze({
44
- implementation: { to: 'review', gates: ['testsPassed', 'compileCheckConfirmed', 'unityAnalyzerClean', 'storyTrackingUpdated'] },
45
- review: { to: 'validation', gates: ['codeReviewCompleted', 'securityReviewPassed', 'acceptanceCriteriaValidated'] },
46
- validation: { to: 'closed', gates: ['designArtifactSyncConfirmed'] },
47
- });
48
-
49
- /** Evidence statuses. */
50
- export const EVIDENCE_STATUSES = Object.freeze(['passed', 'failed', 'blocked', 'manual-confirmation', 'superseded']);
51
-
52
- /** Retry classes. */
53
- export const RETRY_CLASSES = Object.freeze(['deterministic', 'transient', 'repair', 'unknown']);
54
-
55
- /** Context tiers. */
56
- export const CONTEXT_TIERS = Object.freeze(['tier0', 'tier1', 'tier2', 'tier3']);
57
-
58
- const MIB = 1024 * 1024;
59
-
60
- /**
61
- * Conservative default budgets. Every value is validated at load time.
62
- * `hard` is the hard-stop threshold; `warn` is the soft-warning threshold (fraction 0..1).
63
- */
64
- export const DEFAULT_BUDGETS = Object.freeze({
65
- maxContextTokens: { hard: 64000, warn: 0.80, unit: 'tokens' },
66
- maxOutputTokens: { hard: 8000, warn: 0.80, unit: 'tokens' },
67
- maxToolCalls: { hard: 80, warn: 0.75, unit: 'calls' },
68
- maxRetriesPerStep: { hard: 2, warn: null, unit: 'retries' },
69
- maxTotalRetries: { hard: 8, warn: 0.75, unit: 'retries' },
70
- maxWallClockMs: { hard: 30 * 60 * 1000, warn: 0.80, unit: 'ms' },
71
- maxEstimatedCostUsd: { hard: 2.00, warn: 0.80, unit: 'usd' },
72
- maxDownloadedBytes: { hard: 25 * MIB, warn: 0.80, unit: 'bytes' },
73
- maxDecompressedBytes: { hard: 100 * MIB, warn: 0.80, unit: 'bytes' },
74
- maxArchiveFiles: { hard: 2000, warn: 0.80, unit: 'files' },
75
- });
76
-
77
- /**
78
- * Hard safety ceilings. A repository override may raise a hard limit only when an
79
- * explicit compatibility flag is set; it may never lower one below the default
80
- * without the same explicit flag (contract §3, plan §5.3).
81
- */
82
- export const HARD_CEILINGS = Object.freeze({
83
- maxDownloadedBytes: 100 * MIB,
84
- maxDecompressedBytes: 500 * MIB,
85
- maxArchiveFiles: 10000,
86
- maxWallClockMs: 4 * 60 * 60 * 1000,
87
- });
88
-
89
- /** Default archive-safety limits (Phase 6 consumes these). */
90
- export const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
91
- maxCompressedBytes: 25 * MIB,
92
- maxDecompressedBytes: 100 * MIB,
93
- maxFiles: 2000,
94
- maxFilenameBytes: 240,
95
- maxCompressionRatio: 100,
96
- });
97
-
98
- /** Default tool-output and retention policy. */
99
- export const DEFAULT_OUTPUT_POLICY = Object.freeze({
100
- maxInlineBytes: 64 * 1024,
101
- previewBytes: 4 * 1024,
102
- });
103
-
104
- export const DEFAULT_RETENTION = Object.freeze({
105
- keepOnFailure: true,
106
- retainRunRecords: false,
107
- retainRawPrompt: false,
108
- });
109
-
110
- /** Default token/cost estimation policy. */
111
- export const DEFAULT_ESTIMATION = Object.freeze({
112
- bytesPerToken: 3,
113
- // Known rate cards keyed by model id. Absence of a card => no USD is reported.
114
- rateCards: Object.freeze({}),
115
- });
116
-
117
- /** Git-guard hook behavior. */
118
- export const DEFAULT_HOOK_POLICY = Object.freeze({
119
- mode: 'ask-on-recognized-write', // or 'fail-open' (opt-in, visible)
120
- });
121
-
122
- const BUDGET_KEYS = Object.keys(DEFAULT_BUDGETS);
123
-
124
- class PolicyError extends Error {
125
- constructor(message) {
126
- super(message);
127
- this.name = 'PolicyError';
128
- }
129
- }
130
-
131
- function isPlainObject(v) {
132
- return v !== null && typeof v === 'object' && !Array.isArray(v);
133
- }
134
-
135
- function isFiniteNumber(v) {
136
- return typeof v === 'number' && Number.isFinite(v);
137
- }
138
-
139
- function isNonNegativeInt(v) {
140
- return Number.isInteger(v) && v >= 0;
141
- }
142
-
143
- /**
144
- * Validate a single budget override. Returns the normalized entry or throws PolicyError.
145
- */
146
- function validateBudgetOverride(key, value, defaults) {
147
- const def = defaults[key];
148
- if (!def) {
149
- throw new PolicyError(`Unknown budget key "${key}".`);
150
- }
151
- if (isPlainObject(value)) {
152
- const out = { ...def };
153
- for (const field of Object.keys(value)) {
154
- if (field !== 'hard' && field !== 'warn') {
155
- throw new PolicyError(`Budget "${key}" has unknown field "${field}".`);
156
- }
157
- const fv = value[field];
158
- if (field === 'hard') {
159
- if (!isFiniteNumber(fv) || fv < 0) {
160
- throw new PolicyError(`Budget "${key}.hard" must be a non-negative finite number.`);
161
- }
162
- out.hard = fv;
163
- } else {
164
- if (fv !== null && (!isFiniteNumber(fv) || fv < 0 || fv > 1)) {
165
- throw new PolicyError(`Budget "${key}.warn" must be null or a fraction between 0 and 1.`);
166
- }
167
- out.warn = fv;
168
- }
169
- }
170
- return out;
171
- }
172
- if (!isFiniteNumber(value) || value < 0) {
173
- throw new PolicyError(`Budget "${key}" must be a non-negative finite number or an object.`);
174
- }
175
- return { ...def, hard: value };
176
- }
177
-
178
- /**
179
- * A project may never lower a hard safety ceiling, and may only exceed it with an
180
- * explicit compatibility flag.
181
- */
182
- function enforceCeilings(resolved, { allowCeilingOverride = false } = {}) {
183
- for (const [key, ceiling] of Object.entries(HARD_CEILINGS)) {
184
- const value = resolved.budgets[key]?.hard;
185
- if (value === undefined) continue;
186
- if (value > ceiling && !allowCeilingOverride) {
187
- throw new PolicyError(
188
- `Budget "${key}" (${value}) exceeds the hard safety ceiling (${ceiling}). ` +
189
- `Set allowBudgetCeilingOverride in .cadet/harness.json to opt in explicitly.`
190
- );
191
- }
192
- }
193
- }
194
-
195
- /**
196
- * Parse and validate a repository harness policy document.
197
- * Unknown top-level keys are rejected so misconfiguration fails loudly.
198
- */
199
- export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
200
- if (!isPlainObject(raw)) {
201
- throw new PolicyError('Harness policy must be a JSON object.');
202
- }
203
- const allowed = new Set([
204
- '$schema',
205
- 'budgets', 'archive', 'output', 'retention', 'estimation', 'hook',
206
- 'allowBudgetCeilingOverride', 'scopes', 'model', 'analyzerCommand',
207
- 'compileCommand', 'testCommand', 'allowEmptyFreshness',
208
- ]);
209
- for (const key of Object.keys(raw)) {
210
- if (!allowed.has(key)) {
211
- throw new PolicyError(`Unknown harness policy key "${key}".`);
212
- }
213
- }
214
-
215
- const budgets = {};
216
- for (const [key, def] of Object.entries(defaults)) {
217
- budgets[key] = { ...def };
218
- }
219
- if (raw.budgets !== undefined) {
220
- if (!isPlainObject(raw.budgets)) {
221
- throw new PolicyError('"budgets" must be an object.');
222
- }
223
- for (const [key, value] of Object.entries(raw.budgets)) {
224
- budgets[key] = validateBudgetOverride(key, value, defaults);
225
- }
226
- }
227
-
228
- const archive = { ...DEFAULT_ARCHIVE_LIMITS, ...(raw.archive || {}) };
229
- for (const [key, value] of Object.entries(raw.archive || {})) {
230
- if (!(key in DEFAULT_ARCHIVE_LIMITS)) {
231
- throw new PolicyError(`Unknown archive limit "${key}".`);
232
- }
233
- if (!isNonNegativeInt(value)) {
234
- throw new PolicyError(`Archive limit "${key}" must be a non-negative integer.`);
235
- }
236
- }
237
-
238
- const output = { ...DEFAULT_OUTPUT_POLICY, ...(raw.output || {}) };
239
- for (const [key, value] of Object.entries(raw.output || {})) {
240
- if (!(key in DEFAULT_OUTPUT_POLICY)) {
241
- throw new PolicyError(`Unknown output policy key "${key}".`);
242
- }
243
- if (!isNonNegativeInt(value)) {
244
- throw new PolicyError(`Output policy "${key}" must be a non-negative integer.`);
245
- }
246
- }
247
-
248
- const retention = { ...DEFAULT_RETENTION, ...(raw.retention || {}) };
249
- for (const [key, value] of Object.entries(raw.retention || {})) {
250
- if (!(key in DEFAULT_RETENTION)) {
251
- throw new PolicyError(`Unknown retention key "${key}".`);
252
- }
253
- if (typeof value !== 'boolean') {
254
- throw new PolicyError(`Retention "${key}" must be a boolean.`);
255
- }
256
- }
257
-
258
- const estimation = {
259
- ...DEFAULT_ESTIMATION,
260
- ...(raw.estimation || {}),
261
- rateCards: { ...(raw.estimation?.rateCards || {}) },
262
- };
263
- if (!isNonNegativeInt(estimation.bytesPerToken) || estimation.bytesPerToken < 1) {
264
- throw new PolicyError('"estimation.bytesPerToken" must be a positive integer.');
265
- }
266
-
267
- const hook = { ...DEFAULT_HOOK_POLICY, ...(raw.hook || {}) };
268
- if (!['ask-on-recognized-write', 'fail-open'].includes(hook.mode)) {
269
- throw new PolicyError('"hook.mode" must be "ask-on-recognized-write" or "fail-open".');
270
- }
271
-
272
- const allowCeilingOverride = raw.allowBudgetCeilingOverride === true;
273
-
274
- const resolved = {
275
- budgets,
276
- archive,
277
- output,
278
- retention,
279
- estimation,
280
- hook,
281
- allowBudgetCeilingOverride: allowCeilingOverride,
282
- allowEmptyFreshness: raw.allowEmptyFreshness === true,
283
- scopes: raw.scopes || { perRun: {}, perStory: {} },
284
- model: raw.model || null,
285
- analyzerCommand: raw.analyzerCommand || null,
286
- compileCommand: raw.compileCommand || null,
287
- testCommand: raw.testCommand || null,
288
- };
289
-
290
- if (raw.scopes !== undefined) {
291
- if (!isPlainObject(raw.scopes)) throw new PolicyError('"scopes" must be an object.');
292
- for (const scope of ['perRun', 'perStory']) {
293
- const s = raw.scopes[scope];
294
- if (s === undefined) continue;
295
- if (!isPlainObject(s)) throw new PolicyError(`"scopes.${scope}" must be an object.`);
296
- for (const [key, value] of Object.entries(s)) {
297
- const v = validateBudgetOverride(key, isPlainObject(value) ? value.hard ?? value : value, defaults);
298
- if (value !== null && isPlainObject(value) && value.warn !== undefined) {
299
- if (value.warn !== null && (!isFiniteNumber(value.warn) || value.warn < 0 || value.warn > 1)) {
300
- throw new PolicyError(`"scopes.${scope}.${key}.warn" must be null or a fraction between 0 and 1.`);
301
- }
302
- }
303
- s[key] = v.hard;
304
- }
305
- }
306
- }
307
-
308
- enforceCeilings(resolved, { allowCeilingOverride });
309
-
310
- return resolved;
311
- }
312
-
313
- /** Returns the built-in default policy, fully validated. */
314
- export function defaultPolicy() {
315
- return validatePolicy({});
316
- }
317
-
318
- export function policyPath(targetDir) {
319
- return join(targetDir, '.cadet', 'harness.json');
320
- }
321
-
322
- /**
323
- * Load the resolved policy for a repository. Missing file => defaults.
324
- * Malformed/unknown-typed policy throws PolicyError rather than silently degrading.
325
- */
326
- export function loadPolicy(targetDir, { defaults = DEFAULT_BUDGETS } = {}) {
327
- const path = policyPath(targetDir);
328
- if (!existsSync(path)) {
329
- return { ...validatePolicy({}, defaults), sourcePath: null };
330
- }
331
- let raw;
332
- try {
333
- raw = JSON.parse(readFileSync(path, 'utf-8'));
334
- } catch (err) {
335
- throw new PolicyError(`Failed to parse ${path}: ${err.message}`);
336
- }
337
- return { ...validatePolicy(raw, defaults), sourcePath: path };
338
- }
339
-
340
- /** Resolve the effective budget for a scope, applying per-run/per-story overrides. */
341
- export function budgetForScope(policy, scope = 'perRun') {
342
- const budgets = {};
343
- for (const [key, def] of Object.entries(policy.budgets)) {
344
- budgets[key] = { ...def };
345
- }
346
- const overrides = policy.scopes?.[scope] || {};
347
- for (const [key, value] of Object.entries(overrides)) {
348
- if (budgets[key]) budgets[key] = { ...budgets[key], hard: value };
349
- }
350
- return budgets;
351
- }
352
-
353
- /** Warning threshold for a budget, or null when the budget has no warning. */
354
- export function warnThreshold(def) {
355
- if (def.warn === null || def.warn === undefined) return null;
356
- return def.hard * def.warn;
357
- }
358
-
359
- export { PolicyError, BUDGET_KEYS, MIB };
1
+ /**
2
+ * Cadet-Agent harness policy.
3
+ *
4
+ * Loads hard-coded conservative defaults, optionally overlays a repository-local
5
+ * `.cadet/harness.json`, validates the merged result, and exposes the resolved
6
+ * budget/limit policy used by every other harness module.
7
+ *
8
+ * Contract: docs/core/HarnessContract.md §3. Phase names and gate names are frozen
9
+ * compatibility invariants — this module may validate them but must not rename them.
10
+ */
11
+
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ /** Frozen phase names (compatibility invariant C1). */
16
+ export const PHASES = Object.freeze([
17
+ 'context-resolution',
18
+ 'requirements',
19
+ 'requirementsComplete',
20
+ 'architecture',
21
+ 'architectureComplete',
22
+ 'spikes',
23
+ 'story-breakdown',
24
+ 'implementation',
25
+ 'review',
26
+ 'validation',
27
+ 'closed',
28
+ ]);
29
+
30
+ /** Frozen gate names (compatibility invariant C3). */
31
+ export const GATES = Object.freeze([
32
+ 'codeReviewCompleted',
33
+ 'testsPassed',
34
+ 'storyTrackingUpdated',
35
+ 'compileCheckConfirmed',
36
+ 'unityAnalyzerClean',
37
+ 'acceptanceCriteriaValidated',
38
+ 'securityReviewPassed',
39
+ 'designArtifactSyncConfirmed',
40
+ ]);
41
+
42
+ /**
43
+ * Legal phase transitions (compatibility invariant C4, revised in contract v3).
44
+ *
45
+ * `gates` is unchanged: the v2 frozen list each transition must satisfy.
46
+ * `revalidate` is new in v3 — gates that must be satisfied *again* as a
47
+ * precondition of this transition. It is applied only when
48
+ * `strictClosure.enabled` is true, so with the flag off this table behaves
49
+ * exactly as it did in v2.
50
+ *
51
+ * Why revalidation exists: v2 closure required only
52
+ * `designArtifactSyncConfirmed`, so a gate satisfied early in `implementation`
53
+ * could go stale during a long `review`/`validation` and closure would still
54
+ * succeed. The sets below pin the gates at the point they are cheapest to fix.
55
+ */
56
+ export const TRANSITIONS = Object.freeze({
57
+ implementation: {
58
+ to: 'review',
59
+ gates: ['testsPassed', 'compileCheckConfirmed', 'unityAnalyzerClean', 'storyTrackingUpdated'],
60
+ revalidate: [],
61
+ },
62
+ review: {
63
+ to: 'validation',
64
+ gates: ['codeReviewCompleted', 'securityReviewPassed', 'acceptanceCriteriaValidated'],
65
+ revalidate: ['testsPassed', 'compileCheckConfirmed', 'unityAnalyzerClean', 'storyTrackingUpdated'],
66
+ },
67
+ validation: {
68
+ to: 'closed',
69
+ gates: ['designArtifactSyncConfirmed'],
70
+ revalidate: [
71
+ 'codeReviewCompleted', 'securityReviewPassed', 'acceptanceCriteriaValidated',
72
+ 'testsPassed', 'compileCheckConfirmed', 'unityAnalyzerClean', 'storyTrackingUpdated',
73
+ ],
74
+ },
75
+ });
76
+
77
+ /** Evidence statuses. */
78
+ export const EVIDENCE_STATUSES = Object.freeze(['passed', 'failed', 'blocked', 'manual-confirmation', 'superseded']);
79
+
80
+ /** Retry classes. */
81
+ export const RETRY_CLASSES = Object.freeze(['deterministic', 'transient', 'repair', 'unknown']);
82
+
83
+ /** Context tiers. */
84
+ export const CONTEXT_TIERS = Object.freeze(['tier0', 'tier1', 'tier2', 'tier3']);
85
+
86
+ const MIB = 1024 * 1024;
87
+
88
+ /**
89
+ * Conservative default budgets. Every value is validated at load time.
90
+ * `hard` is the hard-stop threshold; `warn` is the soft-warning threshold (fraction 0..1).
91
+ */
92
+ export const DEFAULT_BUDGETS = Object.freeze({
93
+ maxContextTokens: { hard: 64000, warn: 0.80, unit: 'tokens' },
94
+ maxOutputTokens: { hard: 8000, warn: 0.80, unit: 'tokens' },
95
+ maxToolCalls: { hard: 80, warn: 0.75, unit: 'calls' },
96
+ maxRetriesPerStep: { hard: 2, warn: null, unit: 'retries' },
97
+ maxTotalRetries: { hard: 8, warn: 0.75, unit: 'retries' },
98
+ maxWallClockMs: { hard: 30 * 60 * 1000, warn: 0.80, unit: 'ms' },
99
+ maxEstimatedCostUsd: { hard: 2.00, warn: 0.80, unit: 'usd' },
100
+ maxDownloadedBytes: { hard: 25 * MIB, warn: 0.80, unit: 'bytes' },
101
+ maxDecompressedBytes: { hard: 100 * MIB, warn: 0.80, unit: 'bytes' },
102
+ maxArchiveFiles: { hard: 2000, warn: 0.80, unit: 'files' },
103
+ });
104
+
105
+ /**
106
+ * Hard safety ceilings. A repository override may raise a hard limit only when an
107
+ * explicit compatibility flag is set; it may never lower one below the default
108
+ * without the same explicit flag (contract §3, plan §5.3).
109
+ */
110
+ export const HARD_CEILINGS = Object.freeze({
111
+ maxDownloadedBytes: 100 * MIB,
112
+ maxDecompressedBytes: 500 * MIB,
113
+ maxArchiveFiles: 10000,
114
+ maxWallClockMs: 4 * 60 * 60 * 1000,
115
+ });
116
+
117
+ /** Default archive-safety limits (Phase 6 consumes these). */
118
+ export const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
119
+ maxCompressedBytes: 25 * MIB,
120
+ maxDecompressedBytes: 100 * MIB,
121
+ maxFiles: 2000,
122
+ maxFilenameBytes: 240,
123
+ maxCompressionRatio: 100,
124
+ });
125
+
126
+ /** Default tool-output and retention policy. */
127
+ export const DEFAULT_OUTPUT_POLICY = Object.freeze({
128
+ maxInlineBytes: 64 * 1024,
129
+ previewBytes: 4 * 1024,
130
+ });
131
+
132
+ export const DEFAULT_RETENTION = Object.freeze({
133
+ keepOnFailure: true,
134
+ retainRunRecords: false,
135
+ retainRawPrompt: false,
136
+ });
137
+
138
+ /** Default token/cost estimation policy. */
139
+ export const DEFAULT_ESTIMATION = Object.freeze({
140
+ bytesPerToken: 3,
141
+ // Known rate cards keyed by model id. Absence of a card => no USD is reported.
142
+ rateCards: Object.freeze({}),
143
+ });
144
+
145
+ /** Git-guard hook behavior. */
146
+ export const DEFAULT_HOOK_POLICY = Object.freeze({
147
+ mode: 'ask-on-recognized-write', // or 'fail-open' (opt-in, visible)
148
+ });
149
+
150
+ /**
151
+ * Gate-exception taxonomy (contract v3 §4).
152
+ *
153
+ * Two situations that v2 could not distinguish — "this gate cannot be automated
154
+ * here" versus "this work item is a documentation-only change" — need different
155
+ * expiry policies and different levels of review. A category makes that explicit
156
+ * and lets a typo fail loudly instead of classifying as an untyped exception.
157
+ */
158
+ export const EXCEPTION_CATEGORIES = Object.freeze([
159
+ 'manual-compile',
160
+ 'budget-override',
161
+ 'analyzer-fallback',
162
+ 'unscoped-freshness',
163
+ 'documentation-only',
164
+ 'tooling-gap',
165
+ ]);
166
+
167
+ /** Default expiry (in days) per category. `null` means "no default bound". */
168
+ export const EXCEPTION_EXPIRY_DAYS = Object.freeze({
169
+ 'manual-compile': 7,
170
+ 'budget-override': null, // scoped to the run that overrode it
171
+ 'analyzer-fallback': 30,
172
+ 'unscoped-freshness': 1,
173
+ 'documentation-only': null, // scoped to the work item
174
+ 'tooling-gap': 14,
175
+ });
176
+
177
+ /** Categories whose exception must carry a closure review note. */
178
+ export const EXCEPTION_REQUIRES_REVIEW_NOTE = Object.freeze([
179
+ 'manual-compile',
180
+ 'budget-override',
181
+ 'analyzer-fallback',
182
+ 'unscoped-freshness',
183
+ 'tooling-gap',
184
+ ]);
185
+
186
+ /**
187
+ * Gates that are agent-owned and can never be satisfied by a human assertion,
188
+ * so listing them in `disallowManualFor` would be meaningless. Rejecting them
189
+ * keeps the flag's intent legible (contract v3 §2).
190
+ */
191
+ export const AGENT_OWNED_GATES = Object.freeze(['codeReviewCompleted']);
192
+
193
+ /**
194
+ * Strict-closure policy (contract v3 §2). Absent or `enabled: false` means
195
+ * byte-identical v2 behaviour — the property that makes this opt-in.
196
+ */
197
+ export const DEFAULT_STRICT_CLOSURE = Object.freeze({
198
+ enabled: false,
199
+ revalidateOnClosure: true,
200
+ requireFreshRevalidation: true,
201
+ manualConfirmation: Object.freeze({
202
+ requireReason: true,
203
+ requireExpiresAt: true,
204
+ requireEnvironment: true,
205
+ requireScope: true,
206
+ maxValidityMs: 24 * 60 * 60 * 1000,
207
+ // Tolerance for clock skew between the writer and the validator. Without a
208
+ // tolerance, a record created on a machine a few seconds fast would be
209
+ // rejected as future-dated.
210
+ clockSkewToleranceMs: 60 * 1000,
211
+ }),
212
+ disallowManualFor: Object.freeze(['testsPassed']),
213
+ });
214
+
215
+ const STRICT_CLOSURE_KEYS = new Set([
216
+ 'enabled', 'revalidateOnClosure', 'requireFreshRevalidation', 'manualConfirmation', 'disallowManualFor',
217
+ ]);
218
+ const MANUAL_CONFIRMATION_KEYS = new Set([
219
+ 'requireReason', 'requireExpiresAt', 'requireEnvironment', 'requireScope', 'maxValidityMs',
220
+ 'clockSkewToleranceMs',
221
+ ]);
222
+
223
+ const BUDGET_KEYS = Object.keys(DEFAULT_BUDGETS);
224
+
225
+ class PolicyError extends Error {
226
+ constructor(message) {
227
+ super(message);
228
+ this.name = 'PolicyError';
229
+ }
230
+ }
231
+
232
+ function isPlainObject(v) {
233
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
234
+ }
235
+
236
+ function isFiniteNumber(v) {
237
+ return typeof v === 'number' && Number.isFinite(v);
238
+ }
239
+
240
+ function isNonNegativeInt(v) {
241
+ return Number.isInteger(v) && v >= 0;
242
+ }
243
+
244
+ /**
245
+ * Validate a single budget override. Returns the normalized entry or throws PolicyError.
246
+ */
247
+ function validateBudgetOverride(key, value, defaults) {
248
+ const def = defaults[key];
249
+ if (!def) {
250
+ throw new PolicyError(`Unknown budget key "${key}".`);
251
+ }
252
+ if (isPlainObject(value)) {
253
+ const out = { ...def };
254
+ for (const field of Object.keys(value)) {
255
+ if (field !== 'hard' && field !== 'warn') {
256
+ throw new PolicyError(`Budget "${key}" has unknown field "${field}".`);
257
+ }
258
+ const fv = value[field];
259
+ if (field === 'hard') {
260
+ if (!isFiniteNumber(fv) || fv < 0) {
261
+ throw new PolicyError(`Budget "${key}.hard" must be a non-negative finite number.`);
262
+ }
263
+ out.hard = fv;
264
+ } else {
265
+ if (fv !== null && (!isFiniteNumber(fv) || fv < 0 || fv > 1)) {
266
+ throw new PolicyError(`Budget "${key}.warn" must be null or a fraction between 0 and 1.`);
267
+ }
268
+ out.warn = fv;
269
+ }
270
+ }
271
+ return out;
272
+ }
273
+ if (!isFiniteNumber(value) || value < 0) {
274
+ throw new PolicyError(`Budget "${key}" must be a non-negative finite number or an object.`);
275
+ }
276
+ return { ...def, hard: value };
277
+ }
278
+
279
+ /**
280
+ * A project may never lower a hard safety ceiling, and may only exceed it with an
281
+ * explicit compatibility flag.
282
+ */
283
+ function enforceCeilings(resolved, { allowCeilingOverride = false } = {}) {
284
+ for (const [key, ceiling] of Object.entries(HARD_CEILINGS)) {
285
+ const value = resolved.budgets[key]?.hard;
286
+ if (value === undefined) continue;
287
+ if (value > ceiling && !allowCeilingOverride) {
288
+ throw new PolicyError(
289
+ `Budget "${key}" (${value}) exceeds the hard safety ceiling (${ceiling}). ` +
290
+ `Set allowBudgetCeilingOverride in .cadet/harness.json to opt in explicitly.`
291
+ );
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Resolve and validate the `strictClosure` policy block (contract v3 §2).
298
+ *
299
+ * Every rejection here is deliberate: a strictness knob that is accepted but
300
+ * never applied is worse than one that is absent, because it lets a reader
301
+ * believe a guarantee exists. `revalidateOnClosure: true` with `enabled: false`
302
+ * is rejected for exactly that reason.
303
+ */
304
+ function resolveStrictClosure(raw) {
305
+ if (raw === undefined) return { ...DEFAULT_STRICT_CLOSURE, manualConfirmation: { ...DEFAULT_STRICT_CLOSURE.manualConfirmation }, disallowManualFor: [...DEFAULT_STRICT_CLOSURE.disallowManualFor] };
306
+ if (!isPlainObject(raw)) throw new PolicyError('"strictClosure" must be an object.');
307
+
308
+ for (const key of Object.keys(raw)) {
309
+ if (!STRICT_CLOSURE_KEYS.has(key)) {
310
+ throw new PolicyError(`Unknown "strictClosure" key "${key}".`);
311
+ }
312
+ }
313
+
314
+ const out = {
315
+ enabled: raw.enabled === true,
316
+ revalidateOnClosure: raw.revalidateOnClosure === undefined ? DEFAULT_STRICT_CLOSURE.revalidateOnClosure : raw.revalidateOnClosure === true,
317
+ requireFreshRevalidation: raw.requireFreshRevalidation === undefined ? DEFAULT_STRICT_CLOSURE.requireFreshRevalidation : raw.requireFreshRevalidation === true,
318
+ manualConfirmation: { ...DEFAULT_STRICT_CLOSURE.manualConfirmation },
319
+ disallowManualFor: raw.disallowManualFor === undefined
320
+ ? [...DEFAULT_STRICT_CLOSURE.disallowManualFor]
321
+ : raw.disallowManualFor,
322
+ };
323
+
324
+ for (const key of ['revalidateOnClosure', 'requireFreshRevalidation']) {
325
+ if (raw[key] !== undefined && typeof raw[key] !== 'boolean') {
326
+ throw new PolicyError(`"strictClosure.${key}" must be a boolean.`);
327
+ }
328
+ }
329
+
330
+ // Contradiction guard: these only take effect when the master switch is on.
331
+ if (out.enabled !== true) {
332
+ for (const key of ['revalidateOnClosure', 'requireFreshRevalidation']) {
333
+ if (raw[key] === true) {
334
+ throw new PolicyError(`"strictClosure.${key}" is true but "strictClosure.enabled" is false; the setting would be inert. Enable strictClosure or remove the override.`);
335
+ }
336
+ }
337
+ if (raw.disallowManualFor !== undefined && raw.disallowManualFor.length > 0) {
338
+ throw new PolicyError('"strictClosure.disallowManualFor" is set but "strictClosure.enabled" is false; the setting would be inert.');
339
+ }
340
+ if (raw.manualConfirmation !== undefined) {
341
+ throw new PolicyError('"strictClosure.manualConfirmation" is set but "strictClosure.enabled" is false; the setting would be inert.');
342
+ }
343
+ }
344
+
345
+ if (raw.manualConfirmation !== undefined) {
346
+ if (!isPlainObject(raw.manualConfirmation)) {
347
+ throw new PolicyError('"strictClosure.manualConfirmation" must be an object.');
348
+ }
349
+ for (const key of Object.keys(raw.manualConfirmation)) {
350
+ if (!MANUAL_CONFIRMATION_KEYS.has(key)) {
351
+ throw new PolicyError(`Unknown "strictClosure.manualConfirmation" key "${key}".`);
352
+ }
353
+ }
354
+ for (const key of ['requireReason', 'requireExpiresAt', 'requireEnvironment', 'requireScope']) {
355
+ const v = raw.manualConfirmation[key];
356
+ if (v === undefined) continue;
357
+ if (typeof v !== 'boolean') throw new PolicyError(`"strictClosure.manualConfirmation.${key}" must be a boolean.`);
358
+ out.manualConfirmation[key] = v;
359
+ }
360
+ const mv = raw.manualConfirmation.maxValidityMs;
361
+ if (mv !== undefined) {
362
+ if (mv !== null && (!Number.isInteger(mv) || mv <= 0)) {
363
+ throw new PolicyError('"strictClosure.manualConfirmation.maxValidityMs" must be a positive integer or null.');
364
+ }
365
+ out.manualConfirmation.maxValidityMs = mv;
366
+ }
367
+ const skew = raw.manualConfirmation.clockSkewToleranceMs;
368
+ if (skew !== undefined) {
369
+ if (!Number.isInteger(skew) || skew < 0) {
370
+ throw new PolicyError('"strictClosure.manualConfirmation.clockSkewToleranceMs" must be a non-negative integer.');
371
+ }
372
+ out.manualConfirmation.clockSkewToleranceMs = skew;
373
+ }
374
+ }
375
+
376
+ if (!Array.isArray(out.disallowManualFor)) {
377
+ throw new PolicyError('"strictClosure.disallowManualFor" must be an array of gate names.');
378
+ }
379
+ for (const gate of out.disallowManualFor) {
380
+ if (!GATES.includes(gate)) {
381
+ throw new PolicyError(`"strictClosure.disallowManualFor" contains unknown gate "${gate}".`);
382
+ }
383
+ // A gate that is agent-owned can never be manual, so listing it is a no-op
384
+ // that would mislead a reader into thinking a restriction was added.
385
+ if (AGENT_OWNED_GATES.includes(gate)) {
386
+ throw new PolicyError(`"strictClosure.disallowManualFor" cannot contain agent-owned gate "${gate}"; it is never satisfied by manual confirmation.`);
387
+ }
388
+ }
389
+ out.disallowManualFor = [...out.disallowManualFor];
390
+
391
+ return out;
392
+ }
393
+
394
+ /**
395
+ * Parse and validate a repository harness policy document.
396
+ * Unknown top-level keys are rejected so misconfiguration fails loudly.
397
+ */
398
+ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
399
+ if (!isPlainObject(raw)) {
400
+ throw new PolicyError('Harness policy must be a JSON object.');
401
+ }
402
+ const allowed = new Set([
403
+ '$schema',
404
+ 'budgets', 'archive', 'output', 'retention', 'estimation', 'hook',
405
+ 'allowBudgetCeilingOverride', 'scopes', 'model', 'analyzerCommand',
406
+ 'compileCommand', 'testCommand', 'allowEmptyFreshness', 'strictClosure',
407
+ ]);
408
+ for (const key of Object.keys(raw)) {
409
+ if (!allowed.has(key)) {
410
+ throw new PolicyError(`Unknown harness policy key "${key}".`);
411
+ }
412
+ }
413
+
414
+ const budgets = {};
415
+ for (const [key, def] of Object.entries(defaults)) {
416
+ budgets[key] = { ...def };
417
+ }
418
+ if (raw.budgets !== undefined) {
419
+ if (!isPlainObject(raw.budgets)) {
420
+ throw new PolicyError('"budgets" must be an object.');
421
+ }
422
+ for (const [key, value] of Object.entries(raw.budgets)) {
423
+ budgets[key] = validateBudgetOverride(key, value, defaults);
424
+ }
425
+ }
426
+
427
+ const archive = { ...DEFAULT_ARCHIVE_LIMITS, ...(raw.archive || {}) };
428
+ for (const [key, value] of Object.entries(raw.archive || {})) {
429
+ if (!(key in DEFAULT_ARCHIVE_LIMITS)) {
430
+ throw new PolicyError(`Unknown archive limit "${key}".`);
431
+ }
432
+ if (!isNonNegativeInt(value)) {
433
+ throw new PolicyError(`Archive limit "${key}" must be a non-negative integer.`);
434
+ }
435
+ }
436
+
437
+ const output = { ...DEFAULT_OUTPUT_POLICY, ...(raw.output || {}) };
438
+ for (const [key, value] of Object.entries(raw.output || {})) {
439
+ if (!(key in DEFAULT_OUTPUT_POLICY)) {
440
+ throw new PolicyError(`Unknown output policy key "${key}".`);
441
+ }
442
+ if (!isNonNegativeInt(value)) {
443
+ throw new PolicyError(`Output policy "${key}" must be a non-negative integer.`);
444
+ }
445
+ }
446
+
447
+ const retention = { ...DEFAULT_RETENTION, ...(raw.retention || {}) };
448
+ for (const [key, value] of Object.entries(raw.retention || {})) {
449
+ if (!(key in DEFAULT_RETENTION)) {
450
+ throw new PolicyError(`Unknown retention key "${key}".`);
451
+ }
452
+ if (typeof value !== 'boolean') {
453
+ throw new PolicyError(`Retention "${key}" must be a boolean.`);
454
+ }
455
+ }
456
+
457
+ const estimation = {
458
+ ...DEFAULT_ESTIMATION,
459
+ ...(raw.estimation || {}),
460
+ rateCards: { ...(raw.estimation?.rateCards || {}) },
461
+ };
462
+ if (!isNonNegativeInt(estimation.bytesPerToken) || estimation.bytesPerToken < 1) {
463
+ throw new PolicyError('"estimation.bytesPerToken" must be a positive integer.');
464
+ }
465
+
466
+ const hook = { ...DEFAULT_HOOK_POLICY, ...(raw.hook || {}) };
467
+ if (!['ask-on-recognized-write', 'fail-open'].includes(hook.mode)) {
468
+ throw new PolicyError('"hook.mode" must be "ask-on-recognized-write" or "fail-open".');
469
+ }
470
+
471
+ const allowCeilingOverride = raw.allowBudgetCeilingOverride === true;
472
+ const strictClosure = resolveStrictClosure(raw.strictClosure);
473
+
474
+ const resolved = {
475
+ budgets,
476
+ archive,
477
+ output,
478
+ retention,
479
+ estimation,
480
+ hook,
481
+ allowBudgetCeilingOverride: allowCeilingOverride,
482
+ allowEmptyFreshness: raw.allowEmptyFreshness === true,
483
+ strictClosure,
484
+ scopes: raw.scopes || { perRun: {}, perStory: {} },
485
+ model: raw.model || null,
486
+ analyzerCommand: raw.analyzerCommand || null,
487
+ compileCommand: raw.compileCommand || null,
488
+ testCommand: raw.testCommand || null,
489
+ };
490
+
491
+ if (raw.scopes !== undefined) {
492
+ if (!isPlainObject(raw.scopes)) throw new PolicyError('"scopes" must be an object.');
493
+ for (const scope of ['perRun', 'perStory']) {
494
+ const s = raw.scopes[scope];
495
+ if (s === undefined) continue;
496
+ if (!isPlainObject(s)) throw new PolicyError(`"scopes.${scope}" must be an object.`);
497
+ for (const [key, value] of Object.entries(s)) {
498
+ const v = validateBudgetOverride(key, isPlainObject(value) ? value.hard ?? value : value, defaults);
499
+ if (value !== null && isPlainObject(value) && value.warn !== undefined) {
500
+ if (value.warn !== null && (!isFiniteNumber(value.warn) || value.warn < 0 || value.warn > 1)) {
501
+ throw new PolicyError(`"scopes.${scope}.${key}.warn" must be null or a fraction between 0 and 1.`);
502
+ }
503
+ }
504
+ s[key] = v.hard;
505
+ }
506
+ }
507
+ }
508
+
509
+ enforceCeilings(resolved, { allowCeilingOverride });
510
+
511
+ return resolved;
512
+ }
513
+
514
+ /** Returns the built-in default policy, fully validated. */
515
+ export function defaultPolicy() {
516
+ return validatePolicy({});
517
+ }
518
+
519
+ export function policyPath(targetDir) {
520
+ return join(targetDir, '.cadet', 'harness.json');
521
+ }
522
+
523
+ /**
524
+ * Load the resolved policy for a repository. Missing file => defaults.
525
+ * Malformed/unknown-typed policy throws PolicyError rather than silently degrading.
526
+ */
527
+ export function loadPolicy(targetDir, { defaults = DEFAULT_BUDGETS } = {}) {
528
+ const path = policyPath(targetDir);
529
+ if (!existsSync(path)) {
530
+ return { ...validatePolicy({}, defaults), sourcePath: null };
531
+ }
532
+ let raw;
533
+ try {
534
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
535
+ } catch (err) {
536
+ throw new PolicyError(`Failed to parse ${path}: ${err.message}`);
537
+ }
538
+ return { ...validatePolicy(raw, defaults), sourcePath: path };
539
+ }
540
+
541
+ /** Resolve the effective budget for a scope, applying per-run/per-story overrides. */
542
+ export function budgetForScope(policy, scope = 'perRun') {
543
+ const budgets = {};
544
+ for (const [key, def] of Object.entries(policy.budgets)) {
545
+ budgets[key] = { ...def };
546
+ }
547
+ const overrides = policy.scopes?.[scope] || {};
548
+ for (const [key, value] of Object.entries(overrides)) {
549
+ if (budgets[key]) budgets[key] = { ...budgets[key], hard: value };
550
+ }
551
+ return budgets;
552
+ }
553
+
554
+ /** Warning threshold for a budget, or null when the budget has no warning. */
555
+ export function warnThreshold(def) {
556
+ if (def.warn === null || def.warn === undefined) return null;
557
+ return def.hard * def.warn;
558
+ }
559
+
560
+ export { PolicyError, BUDGET_KEYS, MIB };