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
package/src/harness/policy.mjs
CHANGED
|
@@ -41,6 +41,16 @@ export const GATES = Object.freeze([
|
|
|
41
41
|
// name must keep its meaning. This one is additionally OPT-IN — see
|
|
42
42
|
// REACHABILITY_GATE and DEFAULT_REACHABILITY below.
|
|
43
43
|
'reachabilityAddressed',
|
|
44
|
+
// APPENDED by the design-review change. Also OPT-IN, and required on exactly one
|
|
45
|
+
// edge — see DESIGN_REVIEW_GATE / DESIGN_REVIEW_TRANSITION_FROM.
|
|
46
|
+
'designReviewCompleted',
|
|
47
|
+
// APPENDED by the human-acceptance change. HUMAN-OWNED: no reviewer's record can
|
|
48
|
+
// stand in for the person who accepts the work. OPT-IN, and required only when an
|
|
49
|
+
// epic closes — see HUMAN_ACCEPTANCE_GATE.
|
|
50
|
+
'humanAcceptanceConfirmed',
|
|
51
|
+
// APPENDED by the architecture-fitness change. OPT-IN, and required only when the
|
|
52
|
+
// project has declared checks — see ARCHITECTURE_GATE.
|
|
53
|
+
'architectureFitnessPassed',
|
|
44
54
|
]);
|
|
45
55
|
|
|
46
56
|
/**
|
|
@@ -82,6 +92,183 @@ export const DEFAULT_REACHABILITY = Object.freeze({
|
|
|
82
92
|
command: null,
|
|
83
93
|
});
|
|
84
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The formal design-review gate.
|
|
97
|
+
*
|
|
98
|
+
* It guards ONE edge: from a completed design into story breakdown. A review that
|
|
99
|
+
* runs after the work items exist cannot remove work; a review that runs before them
|
|
100
|
+
* is the last point at which "do not build this" is still cheap, which is the whole
|
|
101
|
+
* reason the gate exists.
|
|
102
|
+
*/
|
|
103
|
+
export const DESIGN_REVIEW_GATE = 'designReviewCompleted';
|
|
104
|
+
|
|
105
|
+
/** The `from` phase the design-review gate attaches to (`-> story-breakdown`). */
|
|
106
|
+
export const DESIGN_REVIEW_TRANSITION_FROM = 'architectureComplete';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Default design-review policy.
|
|
110
|
+
*
|
|
111
|
+
* `enabled: false` keeps adopting this framework version a no-op for a repository
|
|
112
|
+
* that has not asked for the review gate — the same property `reachability` and
|
|
113
|
+
* `strictClosure` have. The shipped policy file for a NEW consumer turns it on,
|
|
114
|
+
* which is where a new project meets the review; an existing consumer owns its own
|
|
115
|
+
* file and therefore its own answer.
|
|
116
|
+
*/
|
|
117
|
+
export const DEFAULT_DESIGN_REVIEW = Object.freeze({
|
|
118
|
+
enabled: false,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The human-acceptance gate.
|
|
123
|
+
*
|
|
124
|
+
* It answers a question no test can: did a person accept what was built. It is
|
|
125
|
+
* required on `validation -> closed` — epic closure — and never on
|
|
126
|
+
* `validation -> implementation`, the next-story loop, because a story moving on to
|
|
127
|
+
* the next one is not a release and the loop must stay unblocked.
|
|
128
|
+
*/
|
|
129
|
+
export const HUMAN_ACCEPTANCE_GATE = 'humanAcceptanceConfirmed';
|
|
130
|
+
|
|
131
|
+
/** The `from` phase the human-acceptance gate attaches to (`-> closed`). */
|
|
132
|
+
export const HUMAN_ACCEPTANCE_TRANSITION_FROM = 'validation';
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Default human-acceptance policy. Opt-in, on the same reasoning as every other
|
|
136
|
+
* switch in this file: adopting a framework version must not add a requirement to a
|
|
137
|
+
* repository that did not ask for it. The shipped policy file turns it on for a new
|
|
138
|
+
* consumer.
|
|
139
|
+
*/
|
|
140
|
+
export const DEFAULT_HUMAN_ACCEPTANCE = Object.freeze({
|
|
141
|
+
enabled: false,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The architecture-fitness gate.
|
|
146
|
+
*
|
|
147
|
+
* The project declares executable constraints — dependency direction, forbidden
|
|
148
|
+
* references — as registered checks, and a failing required check blocks review. The
|
|
149
|
+
* gate is opt-in, and it is doubly so: it joins `implementation -> review` only when
|
|
150
|
+
* the block is enabled AND at least one check is declared, so a repository that
|
|
151
|
+
* declares nothing sees exactly the transition matrix it saw before.
|
|
152
|
+
*/
|
|
153
|
+
export const ARCHITECTURE_GATE = 'architectureFitnessPassed';
|
|
154
|
+
|
|
155
|
+
/** The transition the gate joins: `implementation -> review`. */
|
|
156
|
+
export const ARCHITECTURE_TRANSITION_FROM = 'implementation';
|
|
157
|
+
export const ARCHITECTURE_TRANSITION_TO = 'review';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Default architecture-fitness policy.
|
|
161
|
+
*
|
|
162
|
+
* `checks` may be declared while `enabled` is false: the block is then documentation of
|
|
163
|
+
* what the project intends to enforce, and nothing runs. The reverse — enabled with no
|
|
164
|
+
* checks — is inert as well, which is the property that keeps this gate out of the
|
|
165
|
+
* frozen gate lists of every project that has not declared one.
|
|
166
|
+
*/
|
|
167
|
+
export const DEFAULT_ARCHITECTURE_FITNESS = Object.freeze({
|
|
168
|
+
enabled: false,
|
|
169
|
+
checks: Object.freeze([]),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** Severities a check may declare. `advisory` failures are reported, never blocking. */
|
|
173
|
+
export const CHECK_SEVERITIES = Object.freeze(['required', 'advisory']);
|
|
174
|
+
|
|
175
|
+
/** Check keys, and their defaults. One table, so a new key cannot be half-wired. */
|
|
176
|
+
const CHECK_FIELDS = Object.freeze({
|
|
177
|
+
id: null, command: null, cwd: '.', files: null, timeoutMs: null,
|
|
178
|
+
severity: 'required', refs: null, artifact: null, artifactFormat: 'text',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** A repository-relative path: no absolute paths, no walks out of the tree. */
|
|
182
|
+
function assertRelativePath(value, field, id) {
|
|
183
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
184
|
+
throw new PolicyError(`"architectureFitness.checks[].${field}" must be a non-empty string (check "${id}").`);
|
|
185
|
+
}
|
|
186
|
+
const normalised = value.replace(/\\/g, '/');
|
|
187
|
+
if (normalised.startsWith('/') || /^[A-Za-z]:/.test(normalised) || normalised.split('/').includes('..')) {
|
|
188
|
+
throw new PolicyError(`"architectureFitness.checks[].${field}" must be repository-relative and must not walk out of the tree (check "${id}", value "${value}").`);
|
|
189
|
+
}
|
|
190
|
+
return normalised;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveArchitectureCheck(raw, seenIds) {
|
|
194
|
+
if (!isPlainObject(raw)) throw new PolicyError('"architectureFitness.checks[]" must be an object.');
|
|
195
|
+
for (const key of Object.keys(raw)) {
|
|
196
|
+
if (!(key in CHECK_FIELDS)) throw new PolicyError(`Unknown "architectureFitness.checks[]" key "${key}".`);
|
|
197
|
+
}
|
|
198
|
+
if (typeof raw.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(raw.id)) {
|
|
199
|
+
throw new PolicyError('"architectureFitness.checks[].id" must be a stable lowercase slug (a-z, 0-9, hyphen), so a report can name the same check across runs.');
|
|
200
|
+
}
|
|
201
|
+
if (seenIds.has(raw.id)) {
|
|
202
|
+
throw new PolicyError(`duplicate "architectureFitness.checks[].id" "${raw.id}": a check id must identify one check, or a report cannot say which one failed.`);
|
|
203
|
+
}
|
|
204
|
+
seenIds.add(raw.id);
|
|
205
|
+
if (typeof raw.command !== 'string' || raw.command.trim() === '') {
|
|
206
|
+
throw new PolicyError(`"architectureFitness.checks[].command" is required (check "${raw.id}"): a check without a command cannot prove anything.`);
|
|
207
|
+
}
|
|
208
|
+
const cwd = raw.cwd === undefined ? '.' : assertRelativePath(raw.cwd, 'cwd', raw.id);
|
|
209
|
+
const severity = raw.severity === undefined ? 'required' : raw.severity;
|
|
210
|
+
if (!CHECK_SEVERITIES.includes(severity)) {
|
|
211
|
+
throw new PolicyError(`"architectureFitness.checks[].severity" must be one of ${CHECK_SEVERITIES.join(', ')} (check "${raw.id}").`);
|
|
212
|
+
}
|
|
213
|
+
let timeoutMs = null;
|
|
214
|
+
if (raw.timeoutMs !== undefined && raw.timeoutMs !== null) {
|
|
215
|
+
if (!Number.isInteger(raw.timeoutMs) || raw.timeoutMs <= 0) {
|
|
216
|
+
throw new PolicyError(`"architectureFitness.checks[].timeoutMs" must be a positive integer (check "${raw.id}").`);
|
|
217
|
+
}
|
|
218
|
+
timeoutMs = raw.timeoutMs;
|
|
219
|
+
}
|
|
220
|
+
const files = raw.files === undefined || raw.files === null ? [] : raw.files;
|
|
221
|
+
if (!Array.isArray(files)) throw new PolicyError(`"architectureFitness.checks[].files" must be an array of repository-relative paths (check "${raw.id}").`);
|
|
222
|
+
const scopes = files.map((f) => assertRelativePath(f, 'files', raw.id));
|
|
223
|
+
const refs = raw.refs === undefined || raw.refs === null ? [] : raw.refs;
|
|
224
|
+
if (!Array.isArray(refs) || refs.some((r) => typeof r !== 'string' || r.trim() === '')) {
|
|
225
|
+
throw new PolicyError(`"architectureFitness.checks[].refs" must be an array of non-empty strings — the design or ADR identifiers this check enforces (check "${raw.id}").`);
|
|
226
|
+
}
|
|
227
|
+
let artifact = null;
|
|
228
|
+
let artifactFormat = 'text';
|
|
229
|
+
if (raw.artifact !== undefined && raw.artifact !== null) {
|
|
230
|
+
artifact = assertRelativePath(raw.artifact, 'artifact', raw.id);
|
|
231
|
+
if (raw.artifactFormat !== undefined) {
|
|
232
|
+
if (!['json', 'text'].includes(raw.artifactFormat)) {
|
|
233
|
+
throw new PolicyError(`"architectureFitness.checks[].artifactFormat" must be "json" or "text" (check "${raw.id}").`);
|
|
234
|
+
}
|
|
235
|
+
artifactFormat = raw.artifactFormat;
|
|
236
|
+
}
|
|
237
|
+
} else if (raw.artifactFormat !== undefined && raw.artifactFormat !== null && raw.artifactFormat !== 'text') {
|
|
238
|
+
throw new PolicyError(`"architectureFitness.checks[].artifactFormat" needs an "artifact": there is no file to read as ${raw.artifactFormat} (check "${raw.id}").`);
|
|
239
|
+
}
|
|
240
|
+
return { id: raw.id, command: raw.command, cwd, files: scopes, timeoutMs, severity, refs: [...refs], artifact, artifactFormat };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function resolveArchitectureFitness(raw) {
|
|
244
|
+
if (raw === undefined) return { enabled: false, checks: [] };
|
|
245
|
+
if (!isPlainObject(raw)) throw new PolicyError('"architectureFitness" must be an object.');
|
|
246
|
+
for (const key of Object.keys(raw)) {
|
|
247
|
+
if (!['enabled', 'checks'].includes(key)) throw new PolicyError(`Unknown "architectureFitness" key "${key}".`);
|
|
248
|
+
}
|
|
249
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
|
|
250
|
+
throw new PolicyError('"architectureFitness.enabled" must be a boolean.');
|
|
251
|
+
}
|
|
252
|
+
if (raw.checks !== undefined && !Array.isArray(raw.checks)) {
|
|
253
|
+
throw new PolicyError('"architectureFitness.checks" must be an array of checks.');
|
|
254
|
+
}
|
|
255
|
+
const seenIds = new Set();
|
|
256
|
+
const checks = (raw.checks || []).map((c) => resolveArchitectureCheck(c, seenIds));
|
|
257
|
+
return { enabled: raw.enabled === true, checks };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Is the block doing anything? Enabled AND given a check.
|
|
262
|
+
*
|
|
263
|
+
* Both halves are required, and this is the single definition the evaluator, the CLI and
|
|
264
|
+
* the tests share: a project with no checks must not see this gate in any list, and a
|
|
265
|
+
* block that is declared but disabled must behave as though it were absent.
|
|
266
|
+
*/
|
|
267
|
+
export function architectureFitnessActive(policy) {
|
|
268
|
+
const block = policy?.architectureFitness;
|
|
269
|
+
return block?.enabled === true && Array.isArray(block.checks) && block.checks.length > 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
85
272
|
/**
|
|
86
273
|
* Legal phase transitions (compatibility invariant C4, revised in contract v3).
|
|
87
274
|
*
|
|
@@ -206,6 +393,11 @@ export const EXCEPTION_CATEGORIES = Object.freeze([
|
|
|
206
393
|
'documentation-only',
|
|
207
394
|
'tooling-gap',
|
|
208
395
|
'pre-harness-story',
|
|
396
|
+
// Work a user cannot reach or observe: framework internals, tooling, refactors that
|
|
397
|
+
// change no behaviour. It is a property of the work item rather than a gap in the
|
|
398
|
+
// evidence, so it carries no default expiry (see below) and it must name who
|
|
399
|
+
// accepted the judgement (EXCEPTION_REQUIRES_REVIEW_NOTE).
|
|
400
|
+
'non-user-facing',
|
|
209
401
|
]);
|
|
210
402
|
|
|
211
403
|
/** Default expiry (in days) per category. `null` means "no default bound". */
|
|
@@ -221,6 +413,10 @@ export const EXCEPTION_EXPIRY_DAYS = Object.freeze({
|
|
|
221
413
|
// The gap will never close on its own, so a time-bounded exception would only
|
|
222
414
|
// re-raise the same finding every N days without anything having changed.
|
|
223
415
|
'pre-harness-story': null,
|
|
416
|
+
// Scoped to the work item, like `documentation-only`: "this epic is not
|
|
417
|
+
// user-facing" is not a fact that expires, it is a statement about the epic, and a
|
|
418
|
+
// time bound would only re-raise a finding that nothing has changed.
|
|
419
|
+
'non-user-facing': null,
|
|
224
420
|
});
|
|
225
421
|
|
|
226
422
|
/** Categories whose exception must carry a closure review note. */
|
|
@@ -230,14 +426,40 @@ export const EXCEPTION_REQUIRES_REVIEW_NOTE = Object.freeze([
|
|
|
230
426
|
'analyzer-fallback',
|
|
231
427
|
'unscoped-freshness',
|
|
232
428
|
'tooling-gap',
|
|
429
|
+
// Who decided that this work is not user-facing, and what would change that
|
|
430
|
+
// judgement. Without the note the exception is an unattributed claim that the
|
|
431
|
+
// acceptance gate did not apply, which is exactly the claim a reviewer must see.
|
|
432
|
+
'non-user-facing',
|
|
233
433
|
]);
|
|
234
434
|
|
|
235
435
|
/**
|
|
236
|
-
* Gates
|
|
237
|
-
*
|
|
238
|
-
*
|
|
436
|
+
* Gates with no automated builder: no command can produce their evidence, so a
|
|
437
|
+
* record someone makes is the only route.
|
|
438
|
+
*
|
|
439
|
+
* The class is named for that fact alone. It holds two kinds of gate, and the
|
|
440
|
+
* distinction between them is `owner` in the registry: an **agent** gate records a
|
|
441
|
+
* judgement a reviewer made (`codeReviewCompleted`), and a **human** gate records a
|
|
442
|
+
* person's own decision (`humanAcceptanceConfirmed`), which no reviewer's record can
|
|
443
|
+
* stand in for. Either way a human may write the record — the framework never forces
|
|
444
|
+
* a judgement onto an agent — so `harness confirm` accepts every gate in this list.
|
|
445
|
+
*
|
|
446
|
+
* Why they are refused in `strictClosure.disallowManualFor`: that flag forbids manual
|
|
447
|
+
* confirmation, and for these gates manual confirmation is the ONLY route. Listing one
|
|
448
|
+
* would leave the gate unsatisfiable rather than stricter, so the refusal protects a
|
|
449
|
+
* consumer from a policy it cannot satisfy. (The earlier name and wording said
|
|
450
|
+
* "agent-owned … can never be satisfied by a human assertion", which the code never
|
|
451
|
+
* enforced, a consumer's own records contradicted, and a human-owned gate would have
|
|
452
|
+
* made nonsense of. Corrected 2026-09-30 by owner decision.)
|
|
453
|
+
*
|
|
454
|
+
* The set must agree with the registry's non-automated gates
|
|
455
|
+
* (`src/harness/gates.mjs`); `auditGateRegistry` fails if the two drift.
|
|
239
456
|
*/
|
|
240
|
-
export const
|
|
457
|
+
export const MANUAL_ONLY_GATES = Object.freeze([
|
|
458
|
+
'codeReviewCompleted',
|
|
459
|
+
'securityReviewPassed',
|
|
460
|
+
'designArtifactSyncConfirmed',
|
|
461
|
+
'humanAcceptanceConfirmed',
|
|
462
|
+
]);
|
|
241
463
|
|
|
242
464
|
/**
|
|
243
465
|
* Strict-closure policy (contract v3 §2). Absent or `enabled: false` means
|
|
@@ -264,7 +486,21 @@ export const DEFAULT_STRICT_CLOSURE = Object.freeze({
|
|
|
264
486
|
// probe configured — so a manual assertion can add nothing and can skip the
|
|
265
487
|
// declaration entirely (contract v6 §2). With strictClosure off the refusal
|
|
266
488
|
// does not apply, matching how `testsPassed` is treated.
|
|
267
|
-
|
|
489
|
+
// Every gate here has an automated path in EVERY environment Cadet supports, so
|
|
490
|
+
// a manual confirmation is always a substitute for something available.
|
|
491
|
+
// `compileCheckConfirmed`, `unityAnalyzerClean` and `storyTrackingUpdated` are
|
|
492
|
+
// deliberately absent: their automated path can be missing (no Unity CLI, no
|
|
493
|
+
// project script), so forbidding manual confirmation would leave them
|
|
494
|
+
// unsatisfiable instead of stricter. `acceptanceCriteriaValidated` joined in
|
|
495
|
+
// 2026-09-30 (owner decision) — `harness verify-acs` is its path everywhere, and
|
|
496
|
+
// the two lists (this fallback and the seeded policy file) must name one set.
|
|
497
|
+
// A gate joins this list when a command can always prove it, so a hand record would
|
|
498
|
+
// substitute for something available. `architectureFitnessPassed` belongs here for the
|
|
499
|
+
// same reason: the check that proves it is declared in the project's own policy.
|
|
500
|
+
disallowManualFor: Object.freeze([
|
|
501
|
+
'testsPassed', 'acceptanceCriteriaValidated', 'reachabilityAddressed',
|
|
502
|
+
'architectureFitnessPassed',
|
|
503
|
+
]),
|
|
268
504
|
});
|
|
269
505
|
|
|
270
506
|
const STRICT_CLOSURE_KEYS = new Set([
|
|
@@ -435,10 +671,10 @@ function resolveStrictClosure(raw) {
|
|
|
435
671
|
if (!GATES.includes(gate)) {
|
|
436
672
|
throw new PolicyError(`"strictClosure.disallowManualFor" contains unknown gate "${gate}".`);
|
|
437
673
|
}
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
if (
|
|
441
|
-
throw new PolicyError(`"strictClosure.disallowManualFor" cannot contain
|
|
674
|
+
// No command produces this gate's evidence, so forbidding manual confirmation
|
|
675
|
+
// would make the gate unsatisfiable instead of stricter.
|
|
676
|
+
if (MANUAL_ONLY_GATES.includes(gate)) {
|
|
677
|
+
throw new PolicyError(`"strictClosure.disallowManualFor" cannot contain "${gate}": no command produces its evidence, so forbidding manual confirmation would leave the gate unsatisfiable.`);
|
|
442
678
|
}
|
|
443
679
|
}
|
|
444
680
|
out.disallowManualFor = [...out.disallowManualFor];
|
|
@@ -489,6 +725,37 @@ function resolveReachability(raw) {
|
|
|
489
725
|
return out;
|
|
490
726
|
}
|
|
491
727
|
|
|
728
|
+
/**
|
|
729
|
+
* Resolve and validate the `designReview` policy block.
|
|
730
|
+
*
|
|
731
|
+
* One key, and unknown keys are rejected so a typo fails loudly: a misspelled
|
|
732
|
+
* `enable` would leave the gate off while reading as if it were on, which is the
|
|
733
|
+
* failure mode this whole registry exists to remove.
|
|
734
|
+
*/
|
|
735
|
+
function resolveHumanAcceptance(raw) {
|
|
736
|
+
if (raw === undefined) return { ...DEFAULT_HUMAN_ACCEPTANCE };
|
|
737
|
+
if (!isPlainObject(raw)) throw new PolicyError('"humanAcceptance" must be an object.');
|
|
738
|
+
for (const key of Object.keys(raw)) {
|
|
739
|
+
if (key !== 'enabled') throw new PolicyError(`Unknown "humanAcceptance" key "${key}".`);
|
|
740
|
+
}
|
|
741
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
|
|
742
|
+
throw new PolicyError('"humanAcceptance.enabled" must be a boolean.');
|
|
743
|
+
}
|
|
744
|
+
return { enabled: raw.enabled === true };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function resolveDesignReview(raw) {
|
|
748
|
+
if (raw === undefined) return { ...DEFAULT_DESIGN_REVIEW };
|
|
749
|
+
if (!isPlainObject(raw)) throw new PolicyError('"designReview" must be an object.');
|
|
750
|
+
for (const key of Object.keys(raw)) {
|
|
751
|
+
if (key !== 'enabled') throw new PolicyError(`Unknown "designReview" key "${key}".`);
|
|
752
|
+
}
|
|
753
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
|
|
754
|
+
throw new PolicyError('"designReview.enabled" must be a boolean.');
|
|
755
|
+
}
|
|
756
|
+
return { enabled: raw.enabled === true };
|
|
757
|
+
}
|
|
758
|
+
|
|
492
759
|
/**
|
|
493
760
|
* Parse and validate a repository harness policy document.
|
|
494
761
|
* Unknown top-level keys are rejected so misconfiguration fails loudly.
|
|
@@ -502,7 +769,7 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
|
|
|
502
769
|
'budgets', 'archive', 'output', 'retention', 'estimation', 'hook',
|
|
503
770
|
'allowBudgetCeilingOverride', 'scopes', 'model', 'analyzerCommand',
|
|
504
771
|
'compileCommand', 'testCommand', 'allowEmptyFreshness', 'strictClosure',
|
|
505
|
-
'reachability',
|
|
772
|
+
'reachability', 'designReview', 'humanAcceptance', 'architectureFitness',
|
|
506
773
|
]);
|
|
507
774
|
for (const key of Object.keys(raw)) {
|
|
508
775
|
if (!allowed.has(key)) {
|
|
@@ -570,6 +837,9 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
|
|
|
570
837
|
const allowCeilingOverride = raw.allowBudgetCeilingOverride === true;
|
|
571
838
|
const strictClosure = resolveStrictClosure(raw.strictClosure);
|
|
572
839
|
const reachability = resolveReachability(raw.reachability);
|
|
840
|
+
const designReview = resolveDesignReview(raw.designReview);
|
|
841
|
+
const humanAcceptance = resolveHumanAcceptance(raw.humanAcceptance);
|
|
842
|
+
const architectureFitness = resolveArchitectureFitness(raw.architectureFitness);
|
|
573
843
|
|
|
574
844
|
const resolved = {
|
|
575
845
|
budgets,
|
|
@@ -582,6 +852,9 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
|
|
|
582
852
|
allowEmptyFreshness: raw.allowEmptyFreshness === true,
|
|
583
853
|
strictClosure,
|
|
584
854
|
reachability,
|
|
855
|
+
designReview,
|
|
856
|
+
humanAcceptance,
|
|
857
|
+
architectureFitness,
|
|
585
858
|
scopes: raw.scopes || { perRun: {}, perStory: {} },
|
|
586
859
|
model: raw.model || null,
|
|
587
860
|
analyzerCommand: raw.analyzerCommand || null,
|
package/src/harness/routing.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
import { HOSTS } from './hosts.mjs';
|
|
11
13
|
import { join } from 'node:path';
|
|
12
14
|
import { spawnSync } from 'node:child_process';
|
|
13
15
|
|
|
@@ -38,13 +40,23 @@ export function detectCapabilities({ targetDir = process.cwd(), env = process.en
|
|
|
38
40
|
|| existsSync(join(targetDir, '.cursor', 'mcp.json'))
|
|
39
41
|
|| existsSync(join(targetDir, '.mcp.json'));
|
|
40
42
|
|
|
43
|
+
// `hook` used to be `{ copilot: existsSync('.github/hooks/git-guard.json') }`, which the framework
|
|
44
|
+
// itself ships — so it read `true` for every consumer repository no matter which host ran it, and
|
|
45
|
+
// a claim that cannot be false is not a measurement. What is reported now is what is configured,
|
|
46
|
+
// and whether the mechanism actually answers (see src/harness/hosts.mjs). The level is reported
|
|
47
|
+
// per action, never per repository.
|
|
48
|
+
const hookConfig = join(targetDir, '.github', 'hooks', 'git-guard.json');
|
|
49
|
+
const hookScript = join(targetDir, '.github', 'hooks', 'scripts', 'git-guard.sh');
|
|
41
50
|
return {
|
|
42
51
|
cli: true,
|
|
43
52
|
unityCli: unity.available ? { available: true, path: unity.path, version: unity.version } : { available: false },
|
|
44
53
|
mcp: mcpConfigured ? { available: true, configured: true } : { available: false, configured: false },
|
|
45
54
|
hook: {
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
configured: existsSync(hookConfig),
|
|
56
|
+
scriptPresent: existsSync(hookScript),
|
|
57
|
+
verified: null, // measured only when a caller asks for the probe: `harness capabilities --verify-host`
|
|
58
|
+
note: 'a configured hook is not an active control: run "harness capabilities --verify-host" for the measured level per action',
|
|
59
|
+
hosts: HOSTS.map((h) => h.id),
|
|
48
60
|
},
|
|
49
61
|
tokenTelemetry: { provider: false, source: 'estimate' },
|
|
50
62
|
costTelemetry: { available: false, reason: 'no model rate card configured' },
|