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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.10.0 — 2026-07-10
8
+
9
+ Track W — **Constrained write → verified repair**: write-boundary autoPatch, prepare_write,
10
+ loop-cost measurement, opt-in hook repair payloads, doctor write-path awareness, and a
11
+ proof-gated port-inject transform (judgment for auto-apply).
12
+
13
+ ### Added
14
+
15
+ - **W1 write-boundary autoPatch:** `validate_code` and PreToolUse `--hook` may return
16
+ additive `autoPatch: { source, remediationKind, confidence, valid }` for mechanical-safe
17
+ **import type** rewrites (`import-type-from-pure-type-module`, `import-type-of-type-exports`).
18
+ Post-patch revalidation must be green or the patch is discarded (never silent write).
19
+ Implementation: `bin/lib/auto-patch.mjs`.
20
+ - **W2 `ark_prepare_write` MCP tool:** place + constrain + validate + optional autoPatch +
21
+ judgmentBrief + contentHash in one call (`bin/lib/prepare-write.mjs`). Composes
22
+ `ark_place` + write gate — not a second contract.
23
+ - **W3 loop-cost eval harness:** `eval/loop-cost-run.mjs` / `npm run eval:loop-cost`
24
+ records turns-to-green, optional tokens, CHEATED (fixture-measured). Baseline
25
+ `eval/loop-cost-baseline.json` (medianTurnsTypeOnly=1, cheatedRate=0).
26
+ - **W4 opt-in hook repair payload:** `--hook-repair` / `ARK_HOOK_REPAIR=1` on deny emits
27
+ `ARK_REPAIR_JSON` + `ARK_AUTOPATCH_JSON` (stderr) and optional Grok `autoPatch` (stdout).
28
+ Default `--hook` remains hard-block prose only. Install templates (Claude/Grok) include
29
+ `--hook-repair`. Never silent write.
30
+ - **W5 doctor write-path awareness:** `ark-check --doctor` (JSON + human) surfaces
31
+ `writePath.mode` (`repair` | `reject-only` | `mcp-only` | `none`) and
32
+ `prepareWrite` / `autoPatch` flags from installed hooks/MCP. Reject-only gap is
33
+ additive (info) with install fix.
34
+ - **W6 port-proof inject binding (eval-gated):** prove+transform for
35
+ `port-proof-inject-binding` — single named value import used only as
36
+ `binding.method(...)` inside function declarations. Removes the import, emits a
37
+ port type, injects the binding as a parameter (call sites preserved). **Judgment for
38
+ auto-apply** (call arity changes; not write-path autoPatch). Fail-closed static proof;
39
+ rest params refuse apply. Labeled eval case. Implementation: `bin/lib/port-proof.mjs` +
40
+ scan flag `portProofEligible`.
41
+
42
+ ### Changed
43
+
44
+ - **Write gate type-only edges:** `import type` / `export type` no longer hard-block
45
+ LAYER_IMPORT / infra heuristics on the write path (erased at runtime). Value imports
46
+ and peerIsolation still deny. ark-check plan continues to surface type placement debt.
47
+ - **`ark_prepare_write` isError:** always `isError` when proposed source is invalid
48
+ (autoPatch is additive recovery, not soft-success).
49
+ - **`resolveImportFileAbs`:** confines disk reads under project root (no path escape).
50
+
7
51
  ## 2.9.2 — 2026-07-09
8
52
 
9
53
  Skill surface hardening: dual-engine, explore, STOP handoffs, AGENTS routing, subagent fan-out.
package/README.md CHANGED
@@ -151,6 +151,11 @@ critical handoffs say **STOP** and name the next skill (hosts must follow — ma
151
151
  When the host allows it, skills may **fan out parallel subagents** (disjoint scopes);
152
152
  otherwise they **fall back to sequential**.
153
153
 
154
+ **Write path (Track W):** Prefer MCP **`ark_prepare_write`** when you have a snippet (place +
155
+ constrain + validate + optional `autoPatch` + `judgmentBrief`). PreToolUse hooks with
156
+ `--hook-repair` emit machine-readable repair payloads on deny (still hard block; never silent
157
+ write). See [docs/ai-gates.md](docs/ai-gates.md).
158
+
154
159
  | Need | Skill |
155
160
  |------|--------|
156
161
  | Only the apply loop (plan already exists) | `/ark-loop` |
@@ -184,6 +189,7 @@ ark.config.json
184
189
  - **Frameworks:** Nest / Next / express / library layouts get sensible globs on init so day-one coverage is real.
185
190
  - **Brownfield:** baseline ratchet, refuse to freeze a wrong contract, `/ark-adopt` for mature trees.
186
191
  - **Agents:** skills install into Claude / Cursor / Codex / Grok; `ark start` freezes an origin report under `.ark/reports/`.
192
+ - **Write protocol (2.10 / Track W):** mechanical-safe **autoPatch** on the write gate (`import type`); MCP **`ark_prepare_write`** (place + validate + patch + judgmentBrief); opt-in hook **`--hook-repair`** (`ARK_REPAIR_JSON`); doctor **`writePath`** (repair vs reject-only); loop-cost eval (`npm run eval:loop-cost`). Port-proof inject is **judgment** (arity change), not silent auto-apply.
187
193
  - **TypeScript:** project compilers 5.x / 6.x / 7.x — gate falls back to a nested JS-API TypeScript when TS 7’s main export is version-only ([docs/typescript-support.md](docs/typescript-support.md)).
188
194
 
189
195
  ### Why not only ESLint / dependency-cruiser / Nx?
package/bin/ark-mcp.mjs CHANGED
@@ -12,18 +12,27 @@
12
12
  * - resource ark://manifest — the architectural contract (layers + rules, or a project
13
13
  * manifest file when --manifest is provided)
14
14
  * - tool validate_code — runs Ark's AI code gate on a source snippet; returns
15
- * { valid, violations } and sets isError when invalid
15
+ * { valid, violations, autoPatch? } and sets isError when invalid.
16
+ * autoPatch (W1) is a gate-revalidated rewrite for mechanical-safe
17
+ * import-type kinds only (not W6 port-proof — signature change is judgment);
18
+ * discarded if post-patch still invalid.
19
+ * - tool ark_prepare_write — W2: place + constrain + validate + autoPatch + judgmentBrief
20
+ * + contentHash (composes ark_place + write gate; not a second contract).
16
21
  * - tool ark_recommend — deterministic application-shape plan (same as
17
22
  * ark-check --recommend --json)
18
23
  *
19
24
  * Usage: ark-mcp [--root <dir>] [--config ark.config.json] [--manifest <manifest.json>]
20
- * ark-mcp --hook [--root <dir>] [--config ark.config.json]
25
+ * ark-mcp --hook [--hook-repair] [--root <dir>] [--config ark.config.json]
21
26
  *
22
27
  * --hook runs one-shot instead of serving: it reads a Claude Code PreToolUse payload from
23
28
  * stdin, validates the file content a Write/Edit/MultiEdit is about to produce, and exits
24
29
  * 2 with the violations on stderr when the write must be blocked (0 otherwise). This is
25
30
  * the copy-paste integration for agent runtimes whose hooks run shell commands.
26
31
  *
32
+ * --hook-repair (W4, also ARK_HOOK_REPAIR=1): on deny, emit machine-readable
33
+ * ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on stderr (and autoPatch in Grok deny JSON).
34
+ * Never silently writes the file — default and repair mode both hard-block.
35
+ *
27
36
  * --session-context runs one-shot and prints a compact contract summary (layers, rule
28
37
  * count, forbidden globals, baseline state, check command) to stdout. Bind it to a
29
38
  * SessionStart hook so the agent has the architecture in context from the first token,
@@ -46,9 +55,22 @@ import {
46
55
  resolveIncludeRoots,
47
56
  } from './ark-shared.mjs';
48
57
  import { createImportTargetResolver } from './lib/import-resolve.mjs';
58
+ import { validateWithAutoPatch, resolveImportFileAbs } from './lib/auto-patch.mjs';
59
+ import { composePrepareWrite } from './lib/prepare-write.mjs';
49
60
 
50
61
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
51
62
 
63
+ /**
64
+ * W4 — opt-in hook repair payload.
65
+ * True when CLI `--hook-repair` or env ARK_HOOK_REPAIR is 1/true/yes.
66
+ * Default remains hard block with prose violations only (no machine-readable patch).
67
+ */
68
+ function envTruthy(name) {
69
+ const v = process.env[name];
70
+ if (v == null || v === '') return false;
71
+ return /^(1|true|yes|on)$/i.test(String(v).trim());
72
+ }
73
+
52
74
  function parseArgs(argv) {
53
75
  const args = {
54
76
  root: process.cwd(),
@@ -56,18 +78,27 @@ function parseArgs(argv) {
56
78
  configExplicit: false,
57
79
  manifest: undefined,
58
80
  hook: false,
81
+ /** When true with --hook: emit ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON (never silent write). */
82
+ hookRepair: false,
59
83
  sessionContext: false,
60
84
  };
61
85
  for (let i = 2; i < argv.length; i += 1) {
62
86
  const a = argv[i];
63
87
  if (a === '--hook') args.hook = true;
64
- else if (a === '--session-context') args.sessionContext = true;
88
+ else if (a === '--hook-repair') {
89
+ args.hook = true;
90
+ args.hookRepair = true;
91
+ } else if (a === '--session-context') args.sessionContext = true;
65
92
  else if (a === '--root') args.root = path.resolve(argv[++i]);
66
93
  else if (a === '--config') {
67
94
  args.config = argv[++i];
68
95
  args.configExplicit = true;
69
96
  } else if (a === '--manifest') args.manifest = argv[++i];
70
97
  }
98
+ // Env can enable repair without rewriting host templates (ARK_HOOK_REPAIR=1).
99
+ if (envTruthy('ARK_HOOK_REPAIR')) {
100
+ args.hookRepair = true;
101
+ }
71
102
  return args;
72
103
  }
73
104
 
@@ -191,7 +222,7 @@ function proposedSource(toolName, toolInput) {
191
222
  * decision JSON on stdout. Gate plumbing problems (no stdin, malformed JSON, non-file
192
223
  * tools, non-source files) never block the agent.
193
224
  */
194
- function runHook(gate, config, args) {
225
+ function runHook(gate, config, args, ts) {
195
226
  let payload;
196
227
  try {
197
228
  payload = JSON.parse(fs.readFileSync(0, 'utf8'));
@@ -213,14 +244,34 @@ function runHook(gate, config, args) {
213
244
  if (typeof source !== 'string') return;
214
245
 
215
246
  const layer = inferLayer(filePath, config, args.root);
216
- const result = gate.validate(source, { layer, filePath });
247
+ const validateOnce = (src) => gate.validate(src, { layer, filePath });
248
+ // W1: one validation pass (+ optional autoPatch). Original write still blocked when
249
+ // invalid; hosts must apply autoPatch explicitly (never silent write).
250
+ const result = ts
251
+ ? validateWithAutoPatch({
252
+ source,
253
+ filePath,
254
+ root: args.root,
255
+ ts,
256
+ validate: validateOnce,
257
+ resolveTargetAbs: resolveImportFileAbs,
258
+ })
259
+ : (() => {
260
+ const once = validateOnce(source);
261
+ return {
262
+ valid: Boolean(once.valid),
263
+ violations: once.violations ?? [],
264
+ autoPatch: null,
265
+ };
266
+ })();
217
267
  if (result.valid) return;
218
268
 
219
269
  // Ratchet semantics (same philosophy as ark-check --baseline): an edit is blocked only
220
270
  // when it ADDS violations relative to the file's current on-disk state. Otherwise a
221
271
  // pre-existing violation — frozen in a baseline or predating Ark adoption — would make
222
- // every subsequent edit to that file un-writable while CI passes. Keys ignore line
223
- // numbers (edits shift them) and collapse duplicates, mirroring ark-check's baselineKey.
272
+ // every subsequent edit to that file un-writable while CI passes. Same-file keys ignore
273
+ // line numbers (edits shift them); simpler than full baselineKey (no file/layer fields
274
+ // needed — this file is fixed).
224
275
  const violationKey = (violation) => `${violation.ruleId}|${violation.target ?? violation.message}`;
225
276
  let existingKeys = new Set();
226
277
  try {
@@ -231,7 +282,7 @@ function runHook(gate, config, args) {
231
282
  } catch {
232
283
  // New file: nothing pre-exists, every violation is new.
233
284
  }
234
- const newViolations = result.violations.filter(
285
+ const newViolations = (result.violations ?? []).filter(
235
286
  (violation) => !existingKeys.has(violationKey(violation))
236
287
  );
237
288
  if (newViolations.length === 0) return;
@@ -246,16 +297,68 @@ function runHook(gate, config, args) {
246
297
  const suggestions = [
247
298
  ...new Set(newViolations.map((violation) => violation.suggestion).filter(Boolean)),
248
299
  ];
300
+ const autoPatch = result.autoPatch;
301
+ // W4: structured repair payload is opt-in (--hook-repair / ARK_HOOK_REPAIR).
302
+ // Default remains hard block with prose only — hosts that cannot re-inject stay clean.
303
+ const repair = Boolean(args.hookRepair);
249
304
  const message = [
250
305
  `Ark architecture gate blocked this write to ${rel}${layer ? ` (layer: ${layer})` : ''}:`,
251
306
  ...lines,
252
307
  ...(suggestions.length > 0 ? ['Fix:', ...suggestions.map((s) => ` ${s}`)] : []),
308
+ ...(autoPatch && repair
309
+ ? [
310
+ `autoPatch available (${autoPatch.remediationKind}, confidence ${autoPatch.confidence}): ` +
311
+ 'apply the patched source from ARK_AUTOPATCH_JSON / ARK_REPAIR_JSON on stderr' +
312
+ (grokStyle ? ' (or autoPatch in the deny JSON on stdout)' : '') +
313
+ ' instead of re-drafting. Gate still denies this write (never silent apply).',
314
+ ]
315
+ : []),
316
+ ...(autoPatch && !repair
317
+ ? [
318
+ `Mechanical-safe autoPatch is available (${autoPatch.remediationKind}). ` +
319
+ 'Enable repair payload with ARK_HOOK_REPAIR=1 or --hook-repair to receive ' +
320
+ 'machine-readable source (still hard-blocks; host re-injects).',
321
+ ]
322
+ : []),
253
323
  'Fix the violations and retry. The architecture contract is available as the ark://manifest MCP resource.',
254
324
  ].join('\n');
255
325
  process.stderr.write(message + '\n');
326
+
327
+ if (repair) {
328
+ // Structured envelope for any host that can re-inject. Never writes the file.
329
+ const repairPayload = {
330
+ mode: 'repair',
331
+ decision: 'deny',
332
+ filePath: rel.split(path.sep).join('/'),
333
+ ...(layer ? { layer } : {}),
334
+ ...(autoPatch
335
+ ? {
336
+ autoPatch: {
337
+ source: autoPatch.source,
338
+ remediationKind: autoPatch.remediationKind,
339
+ confidence: autoPatch.confidence,
340
+ valid: autoPatch.valid,
341
+ },
342
+ }
343
+ : { autoPatch: null }),
344
+ };
345
+ process.stderr.write(`ARK_REPAIR_JSON:${JSON.stringify(repairPayload)}\n`);
346
+ if (autoPatch) {
347
+ process.stderr.write(`ARK_AUTOPATCH_JSON:${JSON.stringify(autoPatch)}\n`);
348
+ }
349
+ }
350
+
256
351
  // Grok Build honors { decision: "deny" } on stdout (exit 2 alone is also deny).
352
+ // autoPatch in stdout only when repair mode is on (same opt-in as stderr).
257
353
  if (grokStyle) {
258
- process.stdout.write(JSON.stringify({ decision: 'deny', reason: message }) + '\n');
354
+ process.stdout.write(
355
+ JSON.stringify({
356
+ decision: 'deny',
357
+ reason: message,
358
+ ...(repair && autoPatch ? { autoPatch } : {}),
359
+ ...(repair ? { repair: true } : {}),
360
+ }) + '\n'
361
+ );
259
362
  }
260
363
  process.exitCode = 2;
261
364
  }
@@ -444,7 +547,7 @@ async function main() {
444
547
  });
445
548
 
446
549
  if (args.hook) {
447
- runHook(gate, config, args);
550
+ runHook(gate, config, args, ts);
448
551
  return;
449
552
  }
450
553
 
@@ -463,7 +566,9 @@ async function main() {
463
566
  "Validate a source snippet about to be written against Ark's architecture " +
464
567
  '(forbidden infra imports, unknown intents, and layer-reference violations). ' +
465
568
  'Bind to PreToolUse on Write/Edit to block architecturally-invalid generated code. ' +
466
- 'Returns { valid, violations }; isError is true when the code is invalid.',
569
+ 'Returns { valid, violations, autoPatch? }. autoPatch (when present) is a ' +
570
+ 'mechanical-safe rewrite of the source (import type conversion) that re-validates green; ' +
571
+ 'hosts may apply it instead of re-drafting. isError is true when valid is false.',
467
572
  inputSchema: {
468
573
  type: 'object',
469
574
  properties: {
@@ -518,7 +623,8 @@ async function main() {
518
623
  description:
519
624
  'Place a file in the architecture: pass filePath (preferred) and/or description. ' +
520
625
  'Returns layer, mayImport / mustNotImport, forbiddenGlobals. Call BEFORE writing a new file. ' +
521
- 'If only description is given, returns a conventional path proposal under a governed layer.',
626
+ 'If only description is given, returns a conventional path proposal under a governed layer. ' +
627
+ 'Prefer ark_prepare_write when you already have the source snippet (place+validate+autoPatch in one call).',
522
628
  inputSchema: {
523
629
  type: 'object',
524
630
  properties: {
@@ -534,6 +640,34 @@ async function main() {
534
640
  },
535
641
  },
536
642
  },
643
+ {
644
+ name: 'ark_prepare_write',
645
+ description:
646
+ 'Prepare a write against the architecture contract: place (filePath and/or description) + ' +
647
+ 'constrain (layer, mayImport, mustNotImport, forbiddenGlobals) + validate source + optional ' +
648
+ 'mechanical-safe autoPatch + judgmentBrief when judgment is needed + contentHash for host commit. ' +
649
+ 'Composes ark_place + write-gate — call BEFORE Write/Edit when you have the snippet. ' +
650
+ 'Returns { filePath, layer, valid, violations?, autoPatch?, judgmentBrief?, contentHash, ... }.',
651
+ inputSchema: {
652
+ type: 'object',
653
+ properties: {
654
+ source: { type: 'string', description: 'Full source text about to be written.' },
655
+ filePath: {
656
+ type: 'string',
657
+ description: 'Target path (preferred). Used for layer inference and autoPatch resolution.',
658
+ },
659
+ description: {
660
+ type: 'string',
661
+ description: 'When filePath omitted: propose a conventional path from this description.',
662
+ },
663
+ layer: {
664
+ type: 'string',
665
+ description: 'Optional explicit layer override (otherwise inferred from filePath).',
666
+ },
667
+ },
668
+ required: ['source'],
669
+ },
670
+ },
537
671
  {
538
672
  name: 'ark_recommend',
539
673
  description:
@@ -626,13 +760,38 @@ async function main() {
626
760
  if (typeof source !== 'string') {
627
761
  return { content: [{ type: 'text', text: 'Missing required "source" argument.' }], isError: true };
628
762
  }
629
- const layer = params.arguments.layer ?? inferLayer(params.arguments.filePath, config, args.root);
630
- const result = gate.validate(source, {
631
- layer,
632
- filePath: params.arguments.filePath,
763
+ const filePath = params.arguments.filePath;
764
+ const layer = params.arguments.layer ?? inferLayer(filePath, config, args.root);
765
+ const validateOnce = (src) =>
766
+ gate.validate(src, {
767
+ layer,
768
+ filePath,
769
+ });
770
+ // W1: attempt mechanical-safe single-file autoPatch (import type), re-validate or discard.
771
+ const result = validateWithAutoPatch({
772
+ source,
773
+ filePath,
774
+ root: args.root,
775
+ ts,
776
+ validate: validateOnce,
777
+ resolveTargetAbs: resolveImportFileAbs,
633
778
  });
634
779
  return {
635
- content: [{ type: 'text', text: JSON.stringify({ ...result, layer }, null, 2) }],
780
+ content: [
781
+ {
782
+ type: 'text',
783
+ text: JSON.stringify(
784
+ {
785
+ valid: result.valid,
786
+ violations: result.violations,
787
+ ...(result.autoPatch ? { autoPatch: result.autoPatch } : {}),
788
+ layer,
789
+ },
790
+ null,
791
+ 2
792
+ ),
793
+ },
794
+ ],
636
795
  isError: !result.valid,
637
796
  };
638
797
  }
@@ -691,11 +850,8 @@ async function main() {
691
850
  // Deterministic placement guidance (in-process; no TS resolver needed): which layer a
692
851
  // path falls in, and — from the same rules ark-check enforces (default allow, explicit
693
852
  // `allowed:false` denies) — which layers it may and must not import.
694
- function runPlace(params) {
695
- const filePath = params?.arguments?.filePath;
696
- const description = params?.arguments?.description;
853
+ function placeResult(filePath, description) {
697
854
  if ((typeof filePath !== 'string' || !filePath) && typeof description === 'string' && description.trim()) {
698
- // Description-only: propose a governed path under PresentationAdapters (UI default).
699
855
  const slug = description
700
856
  .trim()
701
857
  .toLowerCase()
@@ -705,73 +861,39 @@ async function main() {
705
861
  const proposedPath = `src/components/${slug}.tsx`;
706
862
  const layerName = inferLayer(proposedPath, config, args.root) || 'PresentationAdapters';
707
863
  return {
708
- content: [
709
- {
710
- type: 'text',
711
- text: JSON.stringify(
712
- {
713
- filePath: proposedPath,
714
- proposed: true,
715
- description: description.trim(),
716
- layer: layerName,
717
- governed: Boolean(inferLayer(proposedPath, config, args.root)),
718
- note:
719
- 'filePath was omitted — proposed a conventional path from description. ' +
720
- 'Pass filePath explicitly for authoritative placement. Then validate_code the snippet.',
721
- },
722
- null,
723
- 2
724
- ),
725
- },
726
- ],
727
- isError: false,
864
+ filePath: proposedPath,
865
+ proposed: true,
866
+ description: description.trim(),
867
+ layer: layerName,
868
+ governed: Boolean(inferLayer(proposedPath, config, args.root)),
869
+ note:
870
+ 'filePath was omitted — proposed a conventional path from description. ' +
871
+ 'Pass filePath explicitly for authoritative placement.',
728
872
  };
729
873
  }
730
874
  if (typeof filePath !== 'string' || !filePath) {
731
875
  return {
732
- content: [
733
- {
734
- type: 'text',
735
- text:
736
- 'ark_place needs filePath and/or description. ' +
737
- 'Example: { "filePath": "src/components/Foo.tsx" } or { "description": "caption overlay UI component" }.',
738
- },
739
- ],
740
- isError: true,
876
+ error:
877
+ 'Needs filePath and/or description. ' +
878
+ 'Example: { "filePath": "src/components/Foo.tsx" } or { "description": "caption overlay UI component" }.',
741
879
  };
742
880
  }
743
881
  const layerName = inferLayer(filePath, config, args.root);
744
882
  if (!layerName) {
745
- // Two distinct reasons the path matched no layer: either this project declares no
746
- // path-based layers at all (the gate still enforces the default 11-layer profile by
747
- // intent-name PREFIX — placement just can't be inferred from the path), or it does
748
- // declare layers and this path falls outside all of them (genuinely ungoverned).
749
883
  const noLayers = configLayers.length === 0;
750
884
  return {
751
- content: [
752
- {
753
- type: 'text',
754
- text: JSON.stringify(
755
- {
756
- filePath,
757
- layer: null,
758
- governed: noLayers, // default-profile intent rules still apply when no layers configured
759
- message: noLayers
760
- ? 'This project declares no path-based layers in ark.config.json, so a ' +
761
- 'layer cannot be inferred from the path. The gate still enforces the ' +
762
- 'default 11-layer profile by intent-name prefix — read ark://manifest ' +
763
- 'for the layers and validate the actual snippet with validate_code.'
764
- : 'No layer pattern matches this path — code here is UNGOVERNED (no import ' +
765
- 'rules enforced). Place it under a directory a layer in ark.config.json ' +
766
- 'matches, or add a layer. See suggestedLayers for conventional homes.',
767
- suggestedLayers: suggestedLayers(),
768
- },
769
- null,
770
- 2
771
- ),
772
- },
773
- ],
774
- isError: false,
885
+ filePath,
886
+ layer: null,
887
+ governed: noLayers,
888
+ message: noLayers
889
+ ? 'This project declares no path-based layers in ark.config.json, so a ' +
890
+ 'layer cannot be inferred from the path. The gate still enforces the ' +
891
+ 'default 11-layer profile by intent-name prefix — read ark://manifest ' +
892
+ 'for the layers and validate the actual snippet with validate_code.'
893
+ : 'No layer pattern matches this path — code here is UNGOVERNED (no import ' +
894
+ 'rules enforced). Place it under a directory a layer in ark.config.json ' +
895
+ 'matches, or add a layer. See suggestedLayers for conventional homes.',
896
+ suggestedLayers: suggestedLayers(),
775
897
  };
776
898
  }
777
899
  const layerMeta = configLayers.find((layer) => layer.name === layerName);
@@ -782,34 +904,91 @@ async function main() {
782
904
  );
783
905
  const mayImport = otherNames.filter((name) => !mustNotImport.includes(name));
784
906
  return {
785
- content: [
786
- {
787
- type: 'text',
788
- text: JSON.stringify(
789
- {
790
- filePath,
791
- layer: layerName,
792
- governed: true,
793
- description: layerMeta?.description,
794
- forbiddenGlobals: layerMeta?.forbiddenGlobals ?? [],
795
- ...(layerMeta?.mayImportInfrastructure
796
- ? { mayImportInfrastructure: true }
797
- : {}),
798
- mayImport,
799
- mustNotImport,
800
- note:
801
- 'mayImport = layers with no explicit deny (default is allow). Respect ' +
802
- 'forbiddenGlobals, then verify the actual snippet with validate_code.',
803
- },
804
- null,
805
- 2
806
- ),
807
- },
808
- ],
907
+ filePath,
908
+ layer: layerName,
909
+ governed: true,
910
+ description: layerMeta?.description,
911
+ forbiddenGlobals: layerMeta?.forbiddenGlobals ?? [],
912
+ ...(layerMeta?.mayImportInfrastructure ? { mayImportInfrastructure: true } : {}),
913
+ mayImport,
914
+ mustNotImport,
915
+ note:
916
+ 'mayImport = layers with no explicit deny (default is allow). Respect ' +
917
+ 'forbiddenGlobals, then verify the actual snippet with validate_code or ark_prepare_write.',
918
+ };
919
+ }
920
+
921
+ function runPlace(params) {
922
+ const placement = placeResult(params?.arguments?.filePath, params?.arguments?.description);
923
+ if (placement.error) {
924
+ return {
925
+ content: [{ type: 'text', text: `ark_place: ${placement.error}` }],
926
+ isError: true,
927
+ };
928
+ }
929
+ return {
930
+ content: [{ type: 'text', text: JSON.stringify(placement, null, 2) }],
809
931
  isError: false,
810
932
  };
811
933
  }
812
934
 
935
+ /**
936
+ * W2: place + constrain + validate + autoPatch + judgmentBrief + contentHash.
937
+ * Composes ark_place + write-boundary gate — not a second contract.
938
+ */
939
+ function runPrepareWrite(params) {
940
+ const source = params?.arguments?.source;
941
+ const filePath = params?.arguments?.filePath;
942
+ const description = params?.arguments?.description;
943
+ if (typeof source !== 'string') {
944
+ return {
945
+ content: [
946
+ {
947
+ type: 'text',
948
+ text: 'ark_prepare_write requires "source" (string). Optional: filePath, description.',
949
+ },
950
+ ],
951
+ isError: true,
952
+ };
953
+ }
954
+ const placement = placeResult(filePath, description);
955
+ if (placement.error) {
956
+ return {
957
+ content: [{ type: 'text', text: `ark_prepare_write: ${placement.error}` }],
958
+ isError: true,
959
+ };
960
+ }
961
+ const layer =
962
+ placement.layer ||
963
+ params?.arguments?.layer ||
964
+ inferLayer(placement.filePath, config, args.root);
965
+ const validateOnce = (src) =>
966
+ gate.validate(src, {
967
+ layer,
968
+ filePath: placement.filePath,
969
+ });
970
+ const result = composePrepareWrite({
971
+ source,
972
+ placement: { ...placement, layer },
973
+ root: args.root,
974
+ ts,
975
+ validate: validateOnce,
976
+ resolveTargetAbs: resolveImportFileAbs,
977
+ });
978
+ if (!result.ok) {
979
+ return {
980
+ content: [{ type: 'text', text: result.error || 'prepare_write failed' }],
981
+ isError: true,
982
+ };
983
+ }
984
+ return {
985
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
986
+ // Align with validate_code / --hook: proposed source still invalid → isError.
987
+ // autoPatch is additive recovery guidance in the body, never soft-success.
988
+ isError: !result.valid,
989
+ };
990
+ }
991
+
813
992
  function runSuggestIncludeTool() {
814
993
  try {
815
994
  const workspaces = detectWorkspaces(args.root);
@@ -852,6 +1031,7 @@ async function main() {
852
1031
  ark_check: runCheckTool,
853
1032
  ark_coverage: runCoverageTool,
854
1033
  ark_place: runPlace,
1034
+ ark_prepare_write: runPrepareWrite,
855
1035
  ark_recommend: runRecommendTool,
856
1036
  ark_suggest_include: runSuggestIncludeTool,
857
1037
  };