arkgate 3.0.5 → 3.1.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.
@@ -417,6 +417,8 @@ jobs:
417
417
  steps:
418
418
  - name: Checkout
419
419
  uses: actions/checkout@v4
420
+ with:
421
+ fetch-depth: 0
420
422
  ${setupSteps ? `${setupSteps}\n` : ''} - name: Setup Node
421
423
  uses: actions/setup-node@v4
422
424
  with:
@@ -425,6 +427,8 @@ ${nodeSetup}
425
427
  - name: Install dependencies
426
428
  run: ${pm.install}
427
429
  ${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
430
+ env:
431
+ ARK_POLICY_BASE_REF: \${{ github.event.pull_request.base.sha || github.event.before }}
428
432
  run: ${pm.run}
429
433
  `;
430
434
  }
@@ -491,6 +491,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
491
491
  capabilities: writePath.capabilities,
492
492
  capabilityEvidence: writePath.capabilityEvidence,
493
493
  inventory: writePath.inventory,
494
+ enforcementLadder: writePath.enforcementLadder,
494
495
  mode: writePath.mode,
495
496
  prepareWrite: writePath.prepareWrite,
496
497
  autoPatch: writePath.autoPatch,
@@ -738,17 +739,19 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
738
739
  line(' ', `Active host: ${writePath.activeHost}`);
739
740
  line(' ', `Supported profile: ${writePath.supportSummary}`);
740
741
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
742
+ const ladder = writePath.enforcementLadder;
743
+ const state = (value) => value === true ? 'yes' : value === false ? 'no' : String(value);
741
744
  line(
742
- capabilities['hard-write'] ? ok : warn,
743
- `Hard write boundary: ${capabilities['hard-write'] ? 'yes' : 'no'}`
745
+ ladder.localWrite.installed ? ok : warn,
746
+ `Hard hook — supported: ${state(ladder.localWrite.supported)} · installed: ${state(ladder.localWrite.installed)} · active/trusted: ${state(ladder.localWrite.active)} · bypassable: ${state(ladder.localWrite.bypassable)}`
744
747
  );
745
748
  line(
746
749
  warn,
747
- `Advisory write tools (MCP): ${capabilities['advisory-write'] ? 'yes' : 'no'}`
750
+ `Advisory MCP supported: ${state(ladder.advisoryMcp.supported)} · installed: ${state(ladder.advisoryMcp.installed)} · active: ${state(ladder.advisoryMcp.active)} · bypassable: ${state(ladder.advisoryMcp.bypassable)}`
748
751
  );
749
752
  line(
750
753
  capabilities['merge-gate'] ? ok : bad,
751
- `CI check (--strict-merge): ${capabilities['merge-gate'] ? 'yes' : 'no'} (merge blocking requires a required status)`
754
+ `Merge gate — supported: ${state(ladder.ciMerge.supported)} · installed: ${state(ladder.ciMerge.installed)} · active: ${state(ladder.ciMerge.active)} · bypassable: ${state(ladder.ciMerge.bypassable)} · required status: ${state(ladder.ciMerge.requiredStatus)}`
752
755
  );
753
756
  line(
754
757
  capabilities['repair-payload'] ? ok : warn,
@@ -6,11 +6,12 @@
6
6
  * reported separately by write-path-capabilities.mjs.
7
7
  */
8
8
 
9
- function hostProfile(label, hookPath, hookSurface, hardWrite, repairPayload) {
9
+ function hostProfile(label, hookPath, hookSurface, hookOperations, hardWrite, repairPayload) {
10
10
  return Object.freeze({
11
11
  label,
12
12
  hookPath,
13
13
  hookSurface,
14
+ hookOperations: Object.freeze(hookOperations),
14
15
  capabilities: Object.freeze({
15
16
  'hard-write': hardWrite,
16
17
  'advisory-write': true,
@@ -25,6 +26,7 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
25
26
  'Claude Code',
26
27
  '.claude/settings.json',
27
28
  'PreToolUse `Write` / `Edit` / `MultiEdit`',
29
+ ['Write', 'Edit', 'MultiEdit'],
28
30
  true,
29
31
  true
30
32
  ),
@@ -32,14 +34,16 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
32
34
  'Grok Build',
33
35
  '.grok/hooks/ark-write-gate.json',
34
36
  'PreToolUse `write` / `search_replace` (plus aliases)',
37
+ ['write', 'search_replace'],
35
38
  true,
36
39
  true
37
40
  ),
38
- cursor: hostProfile('Cursor', null, null, false, false),
41
+ cursor: hostProfile('Cursor', null, null, [], false, false),
39
42
  codex: hostProfile(
40
43
  'OpenAI Codex',
41
44
  '.codex/hooks.json',
42
45
  'Best-effort PreToolUse `apply_patch`; Code Mode hosts may bypass the event',
46
+ ['apply_patch'],
43
47
  false,
44
48
  false
45
49
  ),
@@ -0,0 +1,161 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { analyzePolicyDelta } from './analysis-engine.mjs';
5
+
6
+ function readJsonFile(filePath, label) {
7
+ if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
8
+ try {
9
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
10
+ } catch (error) {
11
+ throw new Error(
12
+ `${label} is not valid JSON (${filePath}): ${error instanceof Error ? error.message : String(error)}`
13
+ );
14
+ }
15
+ }
16
+
17
+ function runGit(cwd, args) {
18
+ return spawnSync('git', ['-C', cwd, ...args], {
19
+ encoding: 'utf8',
20
+ stdio: ['ignore', 'pipe', 'pipe'],
21
+ });
22
+ }
23
+
24
+ function safeRef(value) {
25
+ return (
26
+ typeof value === 'string' &&
27
+ /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(value) &&
28
+ !value.includes('..')
29
+ );
30
+ }
31
+
32
+ export function normalizePolicyBaseRef(value) {
33
+ const ref = typeof value === 'string' ? value.trim() : '';
34
+ return /^0{40,64}$/.test(ref) ? '' : ref;
35
+ }
36
+
37
+ function repositoryRoot(root) {
38
+ const result = runGit(root, ['rev-parse', '--show-toplevel']);
39
+ return result.status === 0 ? result.stdout.trim() : null;
40
+ }
41
+
42
+ function discoverLocalBaseRef(root) {
43
+ const top = repositoryRoot(root);
44
+ if (!top) return null;
45
+ const remoteHead = runGit(top, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
46
+ const candidates = [
47
+ remoteHead.status === 0 ? remoteHead.stdout.trim() : null,
48
+ 'origin/main',
49
+ 'origin/master',
50
+ ].filter(Boolean);
51
+ const current = runGit(top, ['branch', '--show-current']);
52
+ const currentBranch = current.status === 0 ? current.stdout.trim() : '';
53
+
54
+ for (const candidate of candidates) {
55
+ if (!safeRef(candidate)) continue;
56
+ const exists = runGit(top, ['rev-parse', '--verify', `${candidate}^{commit}`]);
57
+ if (exists.status !== 0) continue;
58
+ if (currentBranch && candidate === `origin/${currentBranch}`) return null;
59
+ const mergeBase = runGit(top, ['merge-base', 'HEAD', candidate]);
60
+ if (mergeBase.status === 0 && safeRef(mergeBase.stdout.trim())) return mergeBase.stdout.trim();
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function configPathInRepository(root, configPath, top) {
66
+ const requested = path.isAbsolute(configPath) ? configPath : path.resolve(root, configPath);
67
+ const absolute = fs.realpathSync(requested);
68
+ const canonicalTop = fs.realpathSync(top);
69
+ const relative = path.relative(canonicalTop, absolute).split(path.sep).join('/');
70
+ if (!relative || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) {
71
+ throw new Error(`Policy config must be inside the Git repository: ${absolute}`);
72
+ }
73
+ return relative;
74
+ }
75
+
76
+ export function resolvePolicyBaseConfig({
77
+ root,
78
+ configPath,
79
+ basePath,
80
+ baseRef,
81
+ env = process.env,
82
+ }) {
83
+ if (basePath) {
84
+ const absolute = path.isAbsolute(basePath) ? basePath : path.resolve(root, basePath);
85
+ return { config: readJsonFile(absolute, 'Policy base'), source: absolute, ref: null };
86
+ }
87
+
88
+ const envRef = normalizePolicyBaseRef(env.ARK_POLICY_BASE_REF);
89
+ const githubBase = typeof env.GITHUB_BASE_REF === 'string' ? env.GITHUB_BASE_REF.trim() : '';
90
+ const requestedRef = baseRef || envRef || (githubBase ? `origin/${githubBase}` : '');
91
+ const ref = requestedRef || discoverLocalBaseRef(root);
92
+ if (!ref) return null;
93
+ if (!safeRef(ref)) throw new Error(`Unsafe policy base ref: ${ref}`);
94
+
95
+ const top = repositoryRoot(root);
96
+ if (!top) {
97
+ // GitHub and ARK_POLICY_BASE_REF describe the process workspace, which may
98
+ // be different from an explicitly checked nested/temporary project root.
99
+ // Only the CLI flag is an unambiguous request to resolve this exact root.
100
+ if (baseRef) throw new Error(`Cannot resolve policy base ref outside a Git repository: ${ref}`);
101
+ return null;
102
+ }
103
+ const relativeConfig = configPathInRepository(root, configPath, top);
104
+ const result = runGit(top, ['show', `${ref}:${relativeConfig}`]);
105
+ if (result.status !== 0) {
106
+ const refExists = runGit(top, ['rev-parse', '--verify', `${ref}^{commit}`]);
107
+ // A newly adopted contract has no predecessor to weaken. CI-provided and
108
+ // auto-discovered bases may therefore omit the config; an explicit CLI ref
109
+ // remains fail-closed because the caller asked to compare that exact input.
110
+ if (refExists.status === 0 && !baseRef) return null;
111
+ if (requestedRef) {
112
+ throw new Error(
113
+ `Cannot read policy base ${ref}:${relativeConfig}: ${result.stderr.trim() || 'git show failed'}`
114
+ );
115
+ }
116
+ return null;
117
+ }
118
+ try {
119
+ return {
120
+ config: JSON.parse(result.stdout),
121
+ source: `git:${ref}:${relativeConfig}`,
122
+ ref,
123
+ };
124
+ } catch (error) {
125
+ throw new Error(
126
+ `Policy base ${ref}:${relativeConfig} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
127
+ );
128
+ }
129
+ }
130
+
131
+ export function readPolicyAcknowledgement(root, acknowledgementPath) {
132
+ if (!acknowledgementPath) return undefined;
133
+ const absolute = path.isAbsolute(acknowledgementPath)
134
+ ? acknowledgementPath
135
+ : path.resolve(root, acknowledgementPath);
136
+ return readJsonFile(absolute, 'Policy acknowledgement');
137
+ }
138
+
139
+ export function analyzePolicyTransition({
140
+ root,
141
+ configPath,
142
+ candidateConfig,
143
+ strictMerge,
144
+ basePath,
145
+ baseRef,
146
+ acknowledgementPath,
147
+ }) {
148
+ if (!strictMerge && !basePath && !baseRef && !acknowledgementPath) return undefined;
149
+ const base = resolvePolicyBaseConfig({ root, configPath, basePath, baseRef });
150
+ if (!base && (basePath || baseRef || acknowledgementPath)) {
151
+ throw new Error('Policy delta was requested but no policy base could be resolved.');
152
+ }
153
+ if (!base) return undefined;
154
+ return analyzePolicyDelta({
155
+ baseConfig: base.config,
156
+ candidateConfig,
157
+ acknowledgement: readPolicyAcknowledgement(root, acknowledgementPath),
158
+ baseSource: base.source,
159
+ candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
160
+ });
161
+ }
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadArchitectureChangeMap, loadContract, preflightChange } from './analysis-engine.mjs';
4
+ import { createAdapterResult } from './adapter-contract.mjs';
5
+ import { collectGovernedFiles, isGovernableSourceFile, normalize } from './scan-files.mjs';
6
+ import { isScanExcludedRelative, layerForRelativePath } from '../ark-shared.mjs';
7
+
8
+ function candidatePath(value) {
9
+ if (typeof value !== 'string' || value.trim() === '') {
10
+ throw new Error('Every change requires a non-empty project-relative path.');
11
+ }
12
+ const portable = value.trim().replace(/\\/g, '/').replace(/^\.\//, '');
13
+ if (path.posix.isAbsolute(portable) || /^[A-Za-z]:\//.test(portable)) {
14
+ throw new Error(`Change path must be project-relative: ${value}`);
15
+ }
16
+ const normalized = path.posix.normalize(portable);
17
+ if (normalized === '.' || normalized === '..' || normalized.startsWith('../') || normalized.includes('\0')) {
18
+ throw new Error(`Change path escapes the project root: ${value}`);
19
+ }
20
+ return normalized;
21
+ }
22
+
23
+ function isIncluded(relativePath, include) {
24
+ return (include ?? []).some((entry) => {
25
+ const root = String(entry).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
26
+ return root === '.' || relativePath === root || relativePath.startsWith(`${root}/`);
27
+ });
28
+ }
29
+
30
+ function assertGovernedSource(config, relativePath) {
31
+ if (!isGovernableSourceFile(path.basename(relativePath))) {
32
+ throw new Error(`Atomic preflight only accepts governed production source files: ${relativePath}`);
33
+ }
34
+ if (!isIncluded(relativePath, config.include) || isScanExcludedRelative(relativePath, config)) {
35
+ throw new Error(`Change path is outside the configured source scope: ${relativePath}`);
36
+ }
37
+ if (!layerForRelativePath(relativePath, config.layers)) {
38
+ throw new Error(`Change path is not assigned to an architecture layer: ${relativePath}`);
39
+ }
40
+ }
41
+
42
+ function assertInsideProject(root, relativePath) {
43
+ const canonicalRoot = fs.realpathSync(root);
44
+ let existing = path.join(root, relativePath);
45
+ while (!fs.existsSync(existing)) {
46
+ const parent = path.dirname(existing);
47
+ if (parent === existing) break;
48
+ existing = parent;
49
+ }
50
+ const canonicalExisting = fs.realpathSync(existing);
51
+ const relative = path.relative(canonicalRoot, canonicalExisting);
52
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
53
+ throw new Error(`Change path resolves outside the project root: ${relativePath}`);
54
+ }
55
+ }
56
+
57
+ export function normalizeChangeSet(input) {
58
+ if (!Array.isArray(input)) throw new Error('changes must be an array.');
59
+ return input.map((change, index) => {
60
+ if (!change || typeof change !== 'object' || Array.isArray(change)) {
61
+ throw new Error(`changes[${index}] must be an object.`);
62
+ }
63
+ const normalizedPath = candidatePath(change.path);
64
+ if (change.delete === true && change.content === undefined) {
65
+ return { path: normalizedPath, delete: true };
66
+ }
67
+ if (typeof change.content === 'string' && change.delete === undefined) {
68
+ return { path: normalizedPath, content: change.content };
69
+ }
70
+ throw new Error(
71
+ `changes[${index}] must contain either content (string) or delete: true, but not both.`
72
+ );
73
+ });
74
+ }
75
+
76
+ function baseFilesForChange(root, config, changes) {
77
+ const byPath = new Map(
78
+ collectGovernedFiles(root, config).map((absolute) => [
79
+ normalize(path.relative(root, absolute)),
80
+ { path: normalize(path.relative(root, absolute)), content: fs.readFileSync(absolute, 'utf8') },
81
+ ])
82
+ );
83
+ for (const change of changes) {
84
+ if (byPath.has(change.path) || !isGovernableSourceFile(path.basename(change.path))) continue;
85
+ const absolute = path.join(root, change.path);
86
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
87
+ byPath.set(change.path, { path: change.path, content: fs.readFileSync(absolute, 'utf8') });
88
+ }
89
+ return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
90
+ }
91
+
92
+ export function prepareChangeFromRoot({
93
+ root,
94
+ config,
95
+ configSource,
96
+ changes,
97
+ changeMap,
98
+ changeMapSource,
99
+ compilerOptions,
100
+ }) {
101
+ const normalizedChanges = normalizeChangeSet(changes);
102
+ for (const change of normalizedChanges) {
103
+ assertInsideProject(root, change.path);
104
+ assertGovernedSource(config, change.path);
105
+ }
106
+ const contract = loadContract(config, configSource ?? path.join(root, 'ark.config.json'));
107
+ const loadedChangeMap =
108
+ changeMap === undefined
109
+ ? undefined
110
+ : loadArchitectureChangeMap(changeMap, contract.config, changeMapSource);
111
+ const result = preflightChange({
112
+ contract,
113
+ files: baseFilesForChange(root, config, normalizedChanges),
114
+ changes: normalizedChanges,
115
+ ...(loadedChangeMap ? { changeMap: loadedChangeMap } : {}),
116
+ ...(compilerOptions ? { compilerOptions } : {}),
117
+ });
118
+ return {
119
+ ...createAdapterResult({
120
+ valid: result.valid,
121
+ violations: result.violations,
122
+ warnings: result.warnings,
123
+ }),
124
+ ...result,
125
+ };
126
+ }
127
+
128
+ export function renderChangePreflight(result) {
129
+ const convergence = result.convergence;
130
+ if (result.valid) {
131
+ console.log(`✔ Atomic preflight passed for ${result.changes.length} change(s).`);
132
+ console.log(` candidate ${result.candidateTreeHash} · policy ${result.policyHash}`);
133
+ } else {
134
+ const structuralFindings = convergence
135
+ ? convergence.summary.missing + convergence.summary.contradictory + convergence.summary.unplanned
136
+ : 0;
137
+ console.error(
138
+ `Atomic preflight rejected ${result.violations.length + structuralFindings} finding(s):`
139
+ );
140
+ for (const finding of result.diagnostics.filter(({ severity }) => severity === 'error')) {
141
+ console.error(
142
+ ` - ${finding.ruleId} ${finding.location.file}:${finding.location.line} — ${finding.message}`
143
+ );
144
+ console.error(` Next action: ${finding.nextAction}`);
145
+ }
146
+ for (const finding of convergence?.findings ?? []) {
147
+ if (finding.classification !== 'satisfied') {
148
+ console.error(` - ${finding.id} — ${finding.message}`);
149
+ console.error(` Next action: ${finding.nextAction}`);
150
+ }
151
+ }
152
+ console.error('No project file was written. Fix the complete change set and preflight again.');
153
+ }
154
+ if (convergence) {
155
+ const { satisfied, missing, contradictory, unplanned } = convergence.summary;
156
+ const write = convergence.structurallyConverged ? console.log : console.error;
157
+ write(
158
+ `Structural convergence: ${convergence.structurallyConverged ? 'passed' : 'failed'} · satisfied ${satisfied} · missing ${missing} · contradictory ${contradictory} · unplanned ${unplanned}.`
159
+ );
160
+ write('Behavioral completion: not evaluated; run the feature acceptance tests separately.');
161
+ }
162
+ }
163
+
164
+ export function readChangeSetFile(root, requestPath) {
165
+ const absolute = path.isAbsolute(requestPath) ? requestPath : path.join(root, requestPath);
166
+ let parsed;
167
+ try {
168
+ parsed = JSON.parse(fs.readFileSync(absolute, 'utf8'));
169
+ } catch (error) {
170
+ throw new Error(
171
+ `Cannot read atomic change set ${absolute}: ${error instanceof Error ? error.message : String(error)}`
172
+ );
173
+ }
174
+ return Array.isArray(parsed) ? parsed : parsed?.changes;
175
+ }
176
+
177
+ export function readChangeMapFile(root, requestPath) {
178
+ const absolute = path.isAbsolute(requestPath) ? requestPath : path.join(root, requestPath);
179
+ try {
180
+ return { source: absolute, input: JSON.parse(fs.readFileSync(absolute, 'utf8')) };
181
+ } catch (error) {
182
+ throw new Error(
183
+ `Cannot read architecture change map ${absolute}: ${error instanceof Error ? error.message : String(error)}`
184
+ );
185
+ }
186
+ }
@@ -41,6 +41,29 @@ export const KNOWN_FIX_CLASSES = [
41
41
  'break-cycle',
42
42
  'review-contract',
43
43
  ];
44
+ /** One deterministic re-entry action shared by human and machine denial surfaces. */
45
+ export function deterministicNextAction(violation) {
46
+ switch (violation.ruleId) {
47
+ case 'LAYER_IMPORT_VIOLATION':
48
+ if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
49
+ return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
50
+ }
51
+ if (violation.peerIsolation) {
52
+ return 'Extract the shared dependency to a shared layer, then preflight again.';
53
+ }
54
+ return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, then preflight again.`;
55
+ case 'FORBIDDEN_GLOBAL':
56
+ return `Inject ${violation.target ?? 'the capability'} through a port, then preflight again.`;
57
+ case 'CIRCULAR_DEPENDENCY':
58
+ return 'Extract the shared dependency into a third module, then preflight again.';
59
+ case 'RAW_EVENT_PUBLISH':
60
+ return 'Publish through a registered intent creator, then run Ark again.';
61
+ case 'PUBLISH_MISSING_SOURCE':
62
+ return 'Add metadata.source to the publish call, then run Ark again.';
63
+ default:
64
+ return `Resolve ${typeof violation.ruleId === 'string' && violation.ruleId.length > 0 ? violation.ruleId : 'ARK_UNKNOWN'} without weakening ark.config.json, then run Ark again.`;
65
+ }
66
+ }
44
67
  /**
45
68
  * Co-pilot work classifier — the TRUST BOUNDARY for auto-apply.
46
69
  * Biased toward 'judgment': false mechanical-safe is worse than an extra human approval.
@@ -214,5 +237,6 @@ export function enrichViolationWithFixClass(violation) {
214
237
  enriched.enthusiastHint =
215
238
  'Read the violation message and the layer rules in ark.config.json, then adjust imports or move code to the correct layer.';
216
239
  }
240
+ enriched.nextAction = deterministicNextAction(violation);
217
241
  return enriched;
218
242
  }
@@ -12,6 +12,7 @@ const color = {
12
12
 
13
13
  /** Canonical: src/domain/baselineKey.ts → bin/lib/baseline-key.mjs (R4). */
14
14
  import { baselineKey, baselineOccurrenceKeys } from './baseline-key.mjs';
15
+ import { toAdapterDiagnostic } from './adapter-contract.mjs';
15
16
  export { baselineKey, baselineOccurrenceKeys };
16
17
 
17
18
  export function readBaseline(root, baselinePath) {
@@ -56,8 +57,7 @@ export function printViolation(violation) {
56
57
  console.error(` ${violation.fromLayer} → ${violation.toLayer}${target}`);
57
58
  }
58
59
  console.error(` ${violation.message}`);
59
- const hint = FIX_HINTS[violation.ruleId];
60
- if (hint) console.error(` ${color.dim(`fix: ${hint}`)}`);
60
+ console.error(` ${color.dim(`Next action: ${toAdapterDiagnostic(violation).nextAction}`)}`);
61
61
  console.error('');
62
62
  }
63
63
 
@@ -25,6 +25,66 @@ export const WRITE_CAPABILITY_NAMES = [
25
25
  'repair-payload',
26
26
  ];
27
27
 
28
+ function boundaryState({ supported, evidence, active, bypassable, hard = false, extra = {} }) {
29
+ return {
30
+ supported,
31
+ installed: evidence.length > 0,
32
+ active,
33
+ bypassable,
34
+ hard,
35
+ evidence: [...evidence],
36
+ ...extra,
37
+ };
38
+ }
39
+
40
+ function operationCovered(profile, operation) {
41
+ if (!profile || typeof operation !== 'string') return false;
42
+ const normalized = operation.trim().toLowerCase();
43
+ return profile.hookOperations.some((candidate) => candidate.toLowerCase() === normalized);
44
+ }
45
+
46
+ function buildEnforcementLadder(activeHost, support, evidence, attempt) {
47
+ const localInstalled = evidence['hard-write'].length > 0;
48
+ const observedPreTool = attempt?.boundary === 'pre-tool';
49
+ const covered = observedPreTool && operationCovered(support, attempt.operation);
50
+ const hard = Boolean(
51
+ support?.capabilities['hard-write'] && (localInstalled || observedPreTool) && covered
52
+ );
53
+ const inferredActive = (installed) => (installed ? 'unverified' : false);
54
+ return {
55
+ schemaVersion: '1.0',
56
+ activeHost,
57
+ localWrite: boundaryState({
58
+ supported: Boolean(support?.capabilities['hard-write']),
59
+ evidence: evidence['hard-write'],
60
+ active: observedPreTool ? covered : inferredActive(localInstalled),
61
+ bypassable: !hard,
62
+ hard,
63
+ extra: {
64
+ installed: localInstalled || observedPreTool,
65
+ completePatch: Boolean(covered && attempt?.completePatch),
66
+ coverage: covered && attempt?.completePatch ? 'complete-patch' : support?.hookSurface ?? null,
67
+ ...(observedPreTool
68
+ ? { operation: attempt.operation, operationCovered: covered }
69
+ : { operationCovered: 'unverified' }),
70
+ },
71
+ }),
72
+ advisoryMcp: boundaryState({
73
+ supported: Boolean(support?.capabilities['advisory-write']),
74
+ evidence: evidence['advisory-write'],
75
+ active: inferredActive(evidence['advisory-write'].length > 0),
76
+ bypassable: true,
77
+ }),
78
+ ciMerge: boundaryState({
79
+ supported: true,
80
+ evidence: evidence['merge-gate'],
81
+ active: inferredActive(evidence['merge-gate'].length > 0),
82
+ bypassable: 'unknown',
83
+ extra: { requiredStatus: 'unverified' },
84
+ }),
85
+ };
86
+ }
87
+
28
88
  const KNOWN_HOSTS = HOST_SUPPORT_HOSTS;
29
89
 
30
90
  function unique(values) {
@@ -164,7 +224,7 @@ export function detectWritePathInventory(root) {
164
224
  };
165
225
  }
166
226
 
167
- export function buildWritePathCapabilityModel(root, explicitHost) {
227
+ export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
168
228
  const inventory = detectWritePathInventory(root);
169
229
  const detectedHost = explicitHost ?? detectActiveAgentHost();
170
230
  const activeHost = KNOWN_HOSTS.includes(detectedHost) ? detectedHost : 'unknown';
@@ -183,6 +243,12 @@ export function buildWritePathCapabilityModel(root, explicitHost) {
183
243
  support: getHostSupportProfile(activeHost),
184
244
  capabilities: capabilityMap(capabilityEvidence),
185
245
  capabilityEvidence,
246
+ enforcementLadder: buildEnforcementLadder(
247
+ activeHost,
248
+ getHostSupportProfile(activeHost),
249
+ capabilityEvidence,
250
+ attempt
251
+ ),
186
252
  inventory,
187
253
  };
188
254
  }
@@ -15,9 +15,9 @@ function installToolsForHost(activeHost) {
15
15
  : activeHost;
16
16
  }
17
17
 
18
- export function detectWritePathCapabilities(root, explicitHost) {
19
- const model = buildWritePathCapabilityModel(root, explicitHost);
20
- const { activeHost, support, capabilities, capabilityEvidence, inventory } = model;
18
+ export function detectWritePathCapabilities(root, explicitHost, attempt) {
19
+ const model = buildWritePathCapabilityModel(root, explicitHost, attempt);
20
+ const { activeHost, support, capabilities, capabilityEvidence, enforcementLadder, inventory } = model;
21
21
  const hardWrite = capabilities['hard-write'];
22
22
  const advisoryWrite = capabilities['advisory-write'];
23
23
  const repairPayload = capabilities['repair-payload'];
@@ -98,6 +98,7 @@ export function detectWritePathCapabilities(root, explicitHost) {
98
98
  supportSummary: formatHostSupportSummary(support),
99
99
  capabilities,
100
100
  capabilityEvidence,
101
+ enforcementLadder,
101
102
  inventory,
102
103
  // Compatibility projection for existing doctor/API consumers.
103
104
  mode,