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.
@@ -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 that are agent-owned and can never be satisfied by a human assertion,
237
- * so listing them in `disallowManualFor` would be meaningless. Rejecting them
238
- * keeps the flag's intent legible (contract v3 §2).
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 AGENT_OWNED_GATES = Object.freeze(['codeReviewCompleted']);
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
- disallowManualFor: Object.freeze(['testsPassed', 'reachabilityAddressed']),
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
- // A gate that is agent-owned can never be manual, so listing it is a no-op
439
- // that would mislead a reader into thinking a restriction was added.
440
- if (AGENT_OWNED_GATES.includes(gate)) {
441
- throw new PolicyError(`"strictClosure.disallowManualFor" cannot contain agent-owned gate "${gate}"; it is never satisfied by manual confirmation.`);
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,
@@ -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
- copilot: existsSync(join(targetDir, '.github', 'hooks', 'git-guard.json')),
47
- note: 'Cursor, Continue, and Claude Code have no native PreToolUse hook',
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' },