arkgate 2.9.2 → 2.10.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.
@@ -702,7 +702,9 @@ export function claudeSettings(root) {
702
702
  hooks: [
703
703
  {
704
704
  type: 'command',
705
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
705
+ // W4: --hook-repair emits ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on deny
706
+ // (still exit 2 — never silent write). Omit --hook-repair for reject-only prose.
707
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
706
708
  },
707
709
  ],
708
710
  },
@@ -758,7 +760,8 @@ export function grokHooks(root) {
758
760
  {
759
761
  type: 'command',
760
762
  timeout: 30,
761
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --root "${grokRoot}" --config ark.config.json`,
763
+ // W4: --hook-repair structured autoPatch on deny (hard block still).
764
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${grokRoot}" --config ark.config.json`,
762
765
  },
763
766
  ],
764
767
  },
@@ -1284,15 +1287,160 @@ function collectCiWorkflowTexts(root) {
1284
1287
  return texts;
1285
1288
  }
1286
1289
 
1290
+ /**
1291
+ * W5 — Write-path capability surface for doctor (stable additive JSON).
1292
+ *
1293
+ * Detects whether installed agent gates expose:
1294
+ * - MCP prepare-write / validate_code (autoPatch) tools
1295
+ * - PreToolUse hook in reject-only vs repair mode (--hook-repair / ARK_HOOK_REPAIR)
1296
+ *
1297
+ * Never claims silent apply; "repair" means host can re-inject a patch after hard deny.
1298
+ *
1299
+ * @returns {{
1300
+ * mode: 'repair' | 'reject-only' | 'mcp-only' | 'none',
1301
+ * prepareWrite: boolean,
1302
+ * autoPatch: boolean,
1303
+ * hookPresent: boolean,
1304
+ * hookRepair: boolean,
1305
+ * mcpPresent: boolean,
1306
+ * evidence: string[],
1307
+ * gap: null | { id: string, severity: string, message: string, fix: string },
1308
+ * }}
1309
+ */
1310
+ export function detectWritePathCapabilities(root) {
1311
+ const evidence = [];
1312
+ let hookPresent = false;
1313
+ let hookRepair = false;
1314
+
1315
+ const hookFiles = [
1316
+ '.claude/settings.json',
1317
+ '.grok/hooks/ark-write-gate.json',
1318
+ ];
1319
+ for (const rel of hookFiles) {
1320
+ const abs = path.join(root, rel);
1321
+ if (!fs.existsSync(abs)) continue;
1322
+ let text = '';
1323
+ try {
1324
+ text = fs.readFileSync(abs, 'utf8');
1325
+ } catch {
1326
+ continue;
1327
+ }
1328
+ // PreToolUse / write-gate command referencing ark(-gate)?-mcp --hook
1329
+ if (
1330
+ /--hook\b/.test(text) ||
1331
+ /\b(ark|arkgate)-mcp\b[\s\S]{0,80}--hook\b/.test(text) ||
1332
+ /\b--hook\b[\s\S]{0,80}\b(ark|arkgate)-mcp\b/.test(text)
1333
+ ) {
1334
+ hookPresent = true;
1335
+ evidence.push(rel);
1336
+ }
1337
+ if (
1338
+ /--hook-repair\b/.test(text) ||
1339
+ /ARK_HOOK_REPAIR\s*=\s*['"]?(1|true|yes|on)/i.test(text)
1340
+ ) {
1341
+ hookRepair = true;
1342
+ if (!evidence.includes(rel)) evidence.push(rel);
1343
+ }
1344
+ }
1345
+
1346
+ let mcpPresent = false;
1347
+ const mcpFiles = ['.mcp.json', '.cursor/mcp.json', '.grok/config.toml'];
1348
+ for (const rel of mcpFiles) {
1349
+ const abs = path.join(root, rel);
1350
+ if (!fs.existsSync(abs)) continue;
1351
+ let text = '';
1352
+ try {
1353
+ text = fs.readFileSync(abs, 'utf8');
1354
+ } catch {
1355
+ continue;
1356
+ }
1357
+ if (
1358
+ /\b(ark|arkgate)-mcp\b/.test(text) ||
1359
+ /mcp_servers\.ark\b/.test(text) ||
1360
+ /"ark"\s*:\s*\{/.test(text) ||
1361
+ /mcpServers[\s\S]*\bark\b/.test(text)
1362
+ ) {
1363
+ mcpPresent = true;
1364
+ evidence.push(rel);
1365
+ }
1366
+ }
1367
+
1368
+ // Package tools when MCP is wired: ark_prepare_write + validate_code(autoPatch).
1369
+ // Hook repair emits machine-readable autoPatch without silent write.
1370
+ const prepareWrite = mcpPresent;
1371
+ const autoPatch = mcpPresent || hookRepair;
1372
+
1373
+ /** @type {'repair' | 'reject-only' | 'mcp-only' | 'none'} */
1374
+ let mode = 'none';
1375
+ if (hookPresent && hookRepair) mode = 'repair';
1376
+ else if (hookPresent && !hookRepair) mode = 'reject-only';
1377
+ else if (mcpPresent) mode = 'mcp-only';
1378
+
1379
+ let gap = null;
1380
+ if (mode === 'none') {
1381
+ gap = {
1382
+ id: 'write-path-none',
1383
+ severity: 'warn',
1384
+ message:
1385
+ 'Write path is not installed — no PreToolUse hook and no Ark MCP. Agents write without architecture gate or prepare-write.',
1386
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
1387
+ };
1388
+ } else if (mode === 'reject-only') {
1389
+ gap = {
1390
+ id: 'write-path-reject-only',
1391
+ severity: 'info',
1392
+ message: mcpPresent
1393
+ ? 'PreToolUse hook is reject-only (hard block, no ARK_REPAIR_JSON). MCP still exposes prepare-write/autoPatch — enable --hook-repair so the write boundary itself can re-inject patches.'
1394
+ : 'Write path is reject-only (hard block with prose; no repair payload). Enable --hook-repair or ARK_HOOK_REPAIR=1 so hosts can re-inject patches without full re-draft.',
1395
+ fix: arkCommand(
1396
+ root,
1397
+ 'ark-check',
1398
+ '--install-agent-gates --tools claude,grok --force'
1399
+ ),
1400
+ };
1401
+ } else if (mode === 'mcp-only') {
1402
+ gap = {
1403
+ id: 'write-path-mcp-only',
1404
+ severity: 'info',
1405
+ message:
1406
+ 'MCP exposes prepare-write / autoPatch tools, but no PreToolUse write hook is installed — enforcement is advisory unless the agent calls tools.',
1407
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates --tools claude,grok'),
1408
+ };
1409
+ }
1410
+
1411
+ return {
1412
+ mode,
1413
+ prepareWrite,
1414
+ autoPatch,
1415
+ hookPresent,
1416
+ hookRepair,
1417
+ mcpPresent,
1418
+ evidence: [...new Set(evidence)],
1419
+ gap,
1420
+ };
1421
+ }
1422
+
1287
1423
  /**
1288
1424
  * Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
1289
- * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null }}
1425
+ * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
1290
1426
  */
1291
1427
  export function collectAdoptionGaps(root, config, coverage) {
1292
1428
  const gaps = [];
1293
1429
  const adopted = fs.existsSync(path.join(root, 'AGENTS.md'));
1294
1430
  const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
1295
1431
 
1432
+ // --- Write path: prepare-write / autoPatch / reject-only (W5) ---
1433
+ const writePath = detectWritePathCapabilities(root);
1434
+ // Only surface write-path gaps when the project has adopted gates (or has partial install).
1435
+ // Producer package tree always has templates — still report capability for dogfood honesty.
1436
+ if (writePath.gap && (adopted || writePath.hookPresent || writePath.mcpPresent || isProducer)) {
1437
+ // Producer may be repair-capable via own templates; still useful. Skip "none" on pure
1438
+ // consumer repos with zero Ark files? missingGates already covers that.
1439
+ if (!(writePath.mode === 'none' && !adopted && !isProducer)) {
1440
+ gaps.push(writePath.gap);
1441
+ }
1442
+ }
1443
+
1296
1444
  // --- Repo MCP dual-bin ---
1297
1445
  const dualMcp = brokenMcpGateFiles(root);
1298
1446
  const mcp = {
@@ -1609,6 +1757,7 @@ export function collectAdoptionGaps(root, config, coverage) {
1609
1757
  layerBalance,
1610
1758
  deployPath,
1611
1759
  contractFalseGreen,
1760
+ writePath,
1612
1761
  };
1613
1762
  }
1614
1763
 
@@ -25,6 +25,7 @@ import {
25
25
  textOfModuleSpecifier,
26
26
  typeOnlyExportNames,
27
27
  } from './ast-scan.mjs';
28
+ import { provePortProofInject } from './port-proof.mjs';
28
29
  import {
29
30
  intentLayersFromManifest,
30
31
  layerForIntent,
@@ -264,6 +265,23 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
264
265
  !targetCached?.hasTopLevelSideEffects &&
265
266
  named.every((n) => targetTypeNames.has(n));
266
267
  const peerIsolation = Boolean(rule.peerIsolation);
268
+ // W6: port-proof eligibility (value import only; fail-closed static proof).
269
+ let portProofEligible = false;
270
+ if (
271
+ !edge.typeOnly &&
272
+ !peerIsolation &&
273
+ edge.kind === 'import' &&
274
+ !targetTypeOnlyExports &&
275
+ !namedBindingsTypeOnly
276
+ ) {
277
+ try {
278
+ const srcText = fs.readFileSync(file, 'utf8');
279
+ const proof = provePortProofInject(ts, srcText, { filePath: file });
280
+ portProofEligible = Boolean(proof.eligible);
281
+ } catch {
282
+ portProofEligible = false;
283
+ }
284
+ }
267
285
  violations.push({
268
286
  ruleId: 'LAYER_IMPORT_VIOLATION',
269
287
  file: relFile,
@@ -275,6 +293,7 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
275
293
  ...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
276
294
  ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
277
295
  ...(namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
296
+ ...(portProofEligible ? { portProofEligible: true } : {}),
278
297
  ...(edge.kind ? { edgeKind: edge.kind } : {}),
279
298
  ...(peerIsolation ? { peerIsolation: true } : {}),
280
299
  message:
@@ -0,0 +1,264 @@
1
+ /**
2
+ * W1 — Write-boundary autoPatch for mechanical-safe kinds that can be fixed
3
+ * by rewriting only the file being written (import type conversions).
4
+ *
5
+ * Multi-file kinds (type-only-import-move, pure-type-file-relocate) are classified
6
+ * but do not emit a single-file autoPatch — they need agent judgment / multi-file moves.
7
+ *
8
+ * Trust: post-patch revalidation must be green or the patch is discarded.
9
+ * Never invents new mechanical-safe kinds beyond classifyRemediation.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import {
14
+ isTypeOnlyModuleReference,
15
+ namedModuleBindings,
16
+ sourceFileExportsOnlyTypes,
17
+ sourceFileHasTopLevelSideEffects,
18
+ typeOnlyExportNames,
19
+ } from './ast-scan.mjs';
20
+ import { classifyRemediation } from './remediation.mjs';
21
+
22
+ const EXT_CANDIDATES = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', ''];
23
+
24
+ /**
25
+ * True when abs stays under root (no path.relative escape).
26
+ * Empty relative path (abs === root) counts as inside.
27
+ * @param {string} root
28
+ * @param {string} abs
29
+ */
30
+ function isUnderRoot(root, abs) {
31
+ if (!root || !abs) return false;
32
+ const rel = path.relative(path.resolve(root), path.resolve(abs));
33
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
34
+ }
35
+
36
+ export function resolveImportFileAbs(root, fromFilePath, specifier) {
37
+ if (typeof specifier !== 'string' || !specifier) return null;
38
+ if (!specifier.startsWith('./') && !specifier.startsWith('../')) return null;
39
+ if (!fromFilePath || !root) return null;
40
+ const rootAbs = path.resolve(root);
41
+ const fromAbs = path.isAbsolute(fromFilePath)
42
+ ? path.resolve(fromFilePath)
43
+ : path.resolve(rootAbs, fromFilePath);
44
+ // Refuse resolution when the importer itself is outside the project root.
45
+ if (!isUnderRoot(rootAbs, fromAbs)) return null;
46
+ const base = path.resolve(path.dirname(fromAbs), specifier);
47
+ const tryFile = (candidate) => {
48
+ try {
49
+ if (!isUnderRoot(rootAbs, candidate)) return null;
50
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
51
+ } catch {
52
+ /* continue */
53
+ }
54
+ return null;
55
+ };
56
+ for (const ext of EXT_CANDIDATES) {
57
+ const hit = tryFile(base + ext);
58
+ if (hit) return hit;
59
+ }
60
+ for (const ext of EXT_CANDIDATES) {
61
+ if (!ext) continue;
62
+ const hit = tryFile(path.join(base, `index${ext}`));
63
+ if (hit) return hit;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Classify a target module for import-type conversion eligibility.
70
+ * @returns {{ pureTypeModule: boolean, typeOnlyNames: Set<string>, hasTopLevelSideEffects: boolean } | null}
71
+ */
72
+ export function inspectTargetModule(ts, targetSource) {
73
+ if (!ts || typeof targetSource !== 'string') return null;
74
+ const sf = ts.createSourceFile('target.ts', targetSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
75
+ const pureTypeModule = sourceFileExportsOnlyTypes(ts, sf);
76
+ const typeOnlyNames = new Set(typeOnlyExportNames(ts, sf));
77
+ const hasTopLevelSideEffects = sourceFileHasTopLevelSideEffects(ts, sf);
78
+ return { pureTypeModule, typeOnlyNames, hasTopLevelSideEffects };
79
+ }
80
+
81
+ /**
82
+ * Decide remediation kind for converting a non-type-only import of `specifier`
83
+ * with named bindings `bindingNames` (or null for non-named).
84
+ * @returns {{ kind: string, confidence: number } | null}
85
+ */
86
+ export function classifyImportTypeConversion(inspect, bindingNames) {
87
+ if (!inspect) return null;
88
+ if (inspect.pureTypeModule) {
89
+ return {
90
+ kind: 'import-type-from-pure-type-module',
91
+ confidence: 0.85,
92
+ };
93
+ }
94
+ // R6 parity with architecture-scan: refuse import-type convert when target has
95
+ // top-level side effects (would skip runtime init after import type).
96
+ if (inspect.hasTopLevelSideEffects) return null;
97
+ if (Array.isArray(bindingNames) && bindingNames.length > 0) {
98
+ if (bindingNames.every((n) => inspect.typeOnlyNames.has(n))) {
99
+ return {
100
+ kind: 'import-type-of-type-exports',
101
+ confidence: 0.86,
102
+ };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Rewrite eligible static imports/exports to type-only form.
110
+ * @returns {{ source: string, remediationKind: string, confidence: number } | null}
111
+ */
112
+ export function applyImportTypeAutoPatch(ts, source, opts = {}) {
113
+ if (!ts || typeof source !== 'string') return null;
114
+ const { root, filePath, resolveTargetAbs = resolveImportFileAbs } = opts;
115
+ const sf = ts.createSourceFile(
116
+ filePath || 'file.ts',
117
+ source,
118
+ ts.ScriptTarget.Latest,
119
+ true,
120
+ ts.ScriptKind.TS
121
+ );
122
+
123
+ /** @type {Array<{ start: number, end: number, text: string }>} */
124
+ const replacements = [];
125
+ let bestKind = null;
126
+ let bestConfidence = 0;
127
+
128
+ for (const stmt of sf.statements) {
129
+ if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt)) continue;
130
+ if (isTypeOnlyModuleReference(ts, stmt)) continue;
131
+ const specNode = stmt.moduleSpecifier;
132
+ if (!specNode || !ts.isStringLiteralLike(specNode)) continue;
133
+ const specifier = specNode.text;
134
+ const abs = resolveTargetAbs(root, filePath, specifier);
135
+ if (!abs) continue;
136
+ let targetText;
137
+ try {
138
+ targetText = fs.readFileSync(abs, 'utf8');
139
+ } catch {
140
+ continue;
141
+ }
142
+ const inspect = inspectTargetModule(ts, targetText);
143
+ const bindings = namedModuleBindings(ts, stmt);
144
+ // Named imports/re-exports only. Default, namespace, and side-effect imports stay
145
+ // judgment (never auto-convert to import type).
146
+ const conversion = classifyImportTypeConversion(inspect, bindings);
147
+ if (ts.isImportDeclaration(stmt)) {
148
+ const clause = stmt.importClause;
149
+ if (!clause) continue; // side-effect
150
+ if (clause.name) continue; // default import — judgment
151
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) continue;
152
+ }
153
+ if (!conversion) continue;
154
+
155
+ const full = source.slice(stmt.getStart(sf), stmt.getEnd());
156
+ let next = full;
157
+ if (ts.isImportDeclaration(stmt)) {
158
+ // `import { A } from 'x'` → `import type { A } from 'x'`
159
+ // `import { type A, B }` partial already type-only per binding — only full convert
160
+ if (/^\s*import\s+type\b/.test(full)) continue;
161
+ next = full.replace(/^(\s*import)(\s+)/, '$1 type$2');
162
+ } else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) {
163
+ if (/^\s*export\s+type\b/.test(full)) continue;
164
+ next = full.replace(/^(\s*export)(\s+)/, '$1 type$2');
165
+ }
166
+ if (next === full) continue;
167
+ replacements.push({ start: stmt.getStart(sf), end: stmt.getEnd(), text: next });
168
+ if (conversion.confidence >= bestConfidence) {
169
+ bestConfidence = conversion.confidence;
170
+ bestKind = conversion.kind;
171
+ }
172
+ }
173
+
174
+ if (replacements.length === 0 || !bestKind) return null;
175
+ // Apply from end so offsets stay valid
176
+ replacements.sort((a, b) => b.start - a.start);
177
+ let out = source;
178
+ for (const r of replacements) {
179
+ out = out.slice(0, r.start) + r.text + out.slice(r.end);
180
+ }
181
+ if (out === source) return null;
182
+ return {
183
+ source: out,
184
+ remediationKind: bestKind,
185
+ confidence: bestConfidence,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Run gate validation, try mechanical-safe single-file autoPatch, re-validate.
191
+ *
192
+ * @param {{
193
+ * source: string,
194
+ * filePath?: string,
195
+ * root: string,
196
+ * ts: object,
197
+ * validate: (source: string) => { valid: boolean, violations?: any[] },
198
+ * resolveTargetAbs?: Function,
199
+ * }} opts
200
+ */
201
+ export function validateWithAutoPatch(opts) {
202
+ const { source, filePath, root, ts, validate, resolveTargetAbs } = opts;
203
+ const result = validate(source);
204
+ const base = {
205
+ valid: Boolean(result.valid),
206
+ violations: Array.isArray(result.violations) ? result.violations : [],
207
+ };
208
+
209
+ // Attach remediation classification for LAYER_IMPORT violations when flags known
210
+ const violations = base.violations.map((v) => {
211
+ const verdict = classifyRemediation({
212
+ ruleId: v.ruleId || v.code,
213
+ typeOnly: v.typeOnly,
214
+ sourcePureTypeModule: v.sourcePureTypeModule,
215
+ targetTypeOnlyExports: v.targetTypeOnlyExports,
216
+ namedBindingsTypeOnly: v.namedBindingsTypeOnly,
217
+ portProofEligible: v.portProofEligible ?? v.details?.portProofEligible,
218
+ peerIsolation: v.peerIsolation ?? v.details?.peerIsolation,
219
+ edgeKind: v.edgeKind ?? v.details?.importKind,
220
+ fromLayer: v.fromLayer,
221
+ toLayer: v.toLayer,
222
+ target: v.target,
223
+ });
224
+ return {
225
+ ...v,
226
+ remediationClass: verdict.class,
227
+ remediationKind: verdict.remediationKind,
228
+ remediationConfidence: verdict.confidence,
229
+ };
230
+ });
231
+
232
+ if (base.valid) {
233
+ return { valid: true, violations: [], autoPatch: null };
234
+ }
235
+
236
+ // Write-path autoPatch: import-type mechanical-safe only (W1).
237
+ // W6 port-proof inject is judgment (signature change) — never re-inject on write path.
238
+ const attempt = applyImportTypeAutoPatch(ts, source, {
239
+ root,
240
+ filePath,
241
+ resolveTargetAbs: resolveTargetAbs || resolveImportFileAbs,
242
+ });
243
+
244
+ if (!attempt) {
245
+ return { valid: false, violations, autoPatch: null };
246
+ }
247
+
248
+ const after = validate(attempt.source);
249
+ if (!after.valid) {
250
+ // Discard — never return an unvalidated patch
251
+ return { valid: false, violations, autoPatch: null };
252
+ }
253
+
254
+ return {
255
+ valid: false,
256
+ violations,
257
+ autoPatch: {
258
+ source: attempt.source,
259
+ remediationKind: attempt.remediationKind,
260
+ confidence: attempt.confidence,
261
+ valid: true,
262
+ },
263
+ };
264
+ }
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  collectAdoptionGaps,
16
16
  detectSkillGaps,
17
+ detectWritePathCapabilities,
17
18
  missingGates,
18
19
  staleRunnerGateFiles,
19
20
  } from './agent-gates.mjs';
@@ -268,6 +269,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
268
269
  const skillGaps = detectSkillGaps(root);
269
270
  const staleRunners = staleRunnerGateFiles(root);
270
271
  const adoption = collectAdoptionGaps(root, config, cov);
272
+ // Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
273
+ const writePath = adoption.writePath ?? detectWritePathCapabilities(root);
271
274
  const baseline = readBaseline(root, '.ark-baseline.json');
272
275
  const currentKeys = new Set(violations.map(baselineKey));
273
276
  const suppressed = baseline.exists
@@ -323,6 +326,26 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
323
326
  gatesMissing,
324
327
  skillGaps,
325
328
  staleRunnerFiles: staleRunners,
329
+ // W5 — prepare-write / autoPatch / reject-only awareness (stable additive)
330
+ writePath: {
331
+ mode: writePath.mode,
332
+ prepareWrite: writePath.prepareWrite,
333
+ autoPatch: writePath.autoPatch,
334
+ hookPresent: writePath.hookPresent,
335
+ hookRepair: writePath.hookRepair,
336
+ mcpPresent: writePath.mcpPresent,
337
+ evidence: writePath.evidence,
338
+ ...(writePath.gap
339
+ ? {
340
+ gap: {
341
+ id: writePath.gap.id,
342
+ severity: writePath.gap.severity,
343
+ message: writePath.gap.message,
344
+ fix: writePath.gap.fix,
345
+ },
346
+ }
347
+ : { gap: null }),
348
+ },
326
349
  adoption,
327
350
  newHere: showNewHere
328
351
  ? {
@@ -469,6 +492,37 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
469
492
  }
470
493
  }
471
494
 
495
+ console.log('');
496
+ console.log(color.bold('Write path (agent)'));
497
+ const writePathLabels = {
498
+ repair: 'repair-capable — hard block + machine-readable autoPatch / ARK_REPAIR_JSON',
499
+ 'reject-only': 'reject-only — hard block with prose; no repair payload',
500
+ 'mcp-only': 'MCP tools only — prepare-write/autoPatch available; no PreToolUse hook',
501
+ none: 'none — no write gate hook and no Ark MCP',
502
+ };
503
+ const wpMark =
504
+ writePath.mode === 'repair'
505
+ ? ok
506
+ : writePath.mode === 'none'
507
+ ? bad
508
+ : warn;
509
+ line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
510
+ line(
511
+ writePath.prepareWrite ? ok : warn,
512
+ `prepare-write (MCP): ${writePath.prepareWrite ? 'yes' : 'no'}`
513
+ );
514
+ line(
515
+ writePath.autoPatch ? ok : warn,
516
+ `autoPatch surface: ${writePath.autoPatch ? 'yes' : 'no'}`
517
+ );
518
+ if (writePath.gap) {
519
+ line(writePath.gap.severity === 'warn' ? warn : warn, writePath.gap.message);
520
+ if (writePath.gap.fix) {
521
+ line(' ', color.dim(`Fix: ${writePath.gap.fix}`));
522
+ actions.push(writePath.gap.fix);
523
+ }
524
+ }
525
+
472
526
  console.log('');
473
527
  console.log(color.bold('Gates & skills'));
474
528
  if (gatesMissing.length === 0) line(ok, 'Gate files present (AGENTS.md, .mcp.json, CI, write gate)');