arkgate 2.9.2 → 2.11.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +12 -2
  3. package/SECURITY.md +3 -4
  4. package/bin/ark-check.mjs +41 -16
  5. package/bin/ark-mcp.mjs +335 -111
  6. package/bin/ark.mjs +35 -5
  7. package/bin/lib/agent-gates.mjs +161 -8
  8. package/bin/lib/architecture-scan.mjs +23 -1
  9. package/bin/lib/auto-patch.mjs +264 -0
  10. package/bin/lib/baseline-key.mjs +17 -0
  11. package/bin/lib/config-warnings.mjs +22 -0
  12. package/bin/lib/core-layers.mjs +7 -0
  13. package/bin/lib/core-ratchet.mjs +3 -7
  14. package/bin/lib/doctor-plan.mjs +83 -5
  15. package/bin/lib/port-proof.mjs +309 -0
  16. package/bin/lib/prepare-write.mjs +130 -0
  17. package/bin/lib/remediation.mjs +21 -0
  18. package/bin/lib/safety-diagnostics.mjs +263 -0
  19. package/bin/lib/scan-files.mjs +51 -6
  20. package/bin/lib/violations.mjs +3 -3
  21. package/dist/index.cjs +115 -11
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +5 -3
  24. package/dist/index.d.ts +5 -3
  25. package/dist/index.js +115 -11
  26. package/dist/index.js.map +1 -1
  27. package/dist/nestjs/index.cjs +18 -5
  28. package/dist/nestjs/index.cjs.map +1 -1
  29. package/dist/nestjs/index.d.cts +1 -1
  30. package/dist/nestjs/index.d.ts +1 -1
  31. package/dist/nestjs/index.js +18 -5
  32. package/dist/nestjs/index.js.map +1 -1
  33. package/dist/runtime/index.cjs +115 -11
  34. package/dist/runtime/index.cjs.map +1 -1
  35. package/dist/runtime/index.d.cts +1 -1
  36. package/dist/runtime/index.d.ts +1 -1
  37. package/dist/runtime/index.js +115 -11
  38. package/dist/runtime/index.js.map +1 -1
  39. package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
  40. package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
  41. package/docs/agent-guide.md +15 -1
  42. package/docs/ai-gates.md +63 -5
  43. package/docs/enthusiast/how-to-agent-gates.md +8 -0
  44. package/docs/enthusiast/reference-commands.md +1 -1
  45. package/docs/package-surface.md +2 -2
  46. package/docs/production-hardening.md +5 -0
  47. package/package.json +6 -2
  48. package/server.json +2 -2
  49. package/templates/skills/ark-explain.md +1 -1
  50. package/templates/skills/ark-loop.md +2 -1
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,
@@ -39,6 +48,7 @@ import {
39
48
  DEFAULT_LAYER_DIRECTORIES,
40
49
  DEFAULT_RULES,
41
50
  arkCommand,
51
+ globToRegExp,
42
52
  layerForFile,
43
53
  shouldShowNewHereNudge,
44
54
  detectWorkspaces,
@@ -46,9 +56,22 @@ import {
46
56
  resolveIncludeRoots,
47
57
  } from './ark-shared.mjs';
48
58
  import { createImportTargetResolver } from './lib/import-resolve.mjs';
59
+ import { validateWithAutoPatch, resolveImportFileAbs } from './lib/auto-patch.mjs';
60
+ import { composePrepareWrite } from './lib/prepare-write.mjs';
49
61
 
50
62
  const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
51
63
 
64
+ /**
65
+ * W4 — opt-in hook repair payload.
66
+ * True when CLI `--hook-repair` or env ARK_HOOK_REPAIR is 1/true/yes.
67
+ * Default remains hard block with prose violations only (no machine-readable patch).
68
+ */
69
+ function envTruthy(name) {
70
+ const v = process.env[name];
71
+ if (v == null || v === '') return false;
72
+ return /^(1|true|yes|on)$/i.test(String(v).trim());
73
+ }
74
+
52
75
  function parseArgs(argv) {
53
76
  const args = {
54
77
  root: process.cwd(),
@@ -56,18 +79,27 @@ function parseArgs(argv) {
56
79
  configExplicit: false,
57
80
  manifest: undefined,
58
81
  hook: false,
82
+ /** When true with --hook: emit ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON (never silent write). */
83
+ hookRepair: false,
59
84
  sessionContext: false,
60
85
  };
61
86
  for (let i = 2; i < argv.length; i += 1) {
62
87
  const a = argv[i];
63
88
  if (a === '--hook') args.hook = true;
64
- else if (a === '--session-context') args.sessionContext = true;
89
+ else if (a === '--hook-repair') {
90
+ args.hook = true;
91
+ args.hookRepair = true;
92
+ } else if (a === '--session-context') args.sessionContext = true;
65
93
  else if (a === '--root') args.root = path.resolve(argv[++i]);
66
94
  else if (a === '--config') {
67
95
  args.config = argv[++i];
68
96
  args.configExplicit = true;
69
97
  } else if (a === '--manifest') args.manifest = argv[++i];
70
98
  }
99
+ // Env can enable repair without rewriting host templates (ARK_HOOK_REPAIR=1).
100
+ if (envTruthy('ARK_HOOK_REPAIR')) {
101
+ args.hookRepair = true;
102
+ }
71
103
  return args;
72
104
  }
73
105
 
@@ -191,7 +223,7 @@ function proposedSource(toolName, toolInput) {
191
223
  * decision JSON on stdout. Gate plumbing problems (no stdin, malformed JSON, non-file
192
224
  * tools, non-source files) never block the agent.
193
225
  */
194
- function runHook(gate, config, args) {
226
+ function runHook(gate, config, args, ts) {
195
227
  let payload;
196
228
  try {
197
229
  payload = JSON.parse(fs.readFileSync(0, 'utf8'));
@@ -213,27 +245,52 @@ function runHook(gate, config, args) {
213
245
  if (typeof source !== 'string') return;
214
246
 
215
247
  const layer = inferLayer(filePath, config, args.root);
216
- const result = gate.validate(source, { layer, filePath });
248
+ const validateOnce = (src) => gate.validate(src, { layer, filePath });
249
+ // W1: one validation pass (+ optional autoPatch). Original write still blocked when
250
+ // invalid; hosts must apply autoPatch explicitly (never silent write).
251
+ const result = ts
252
+ ? validateWithAutoPatch({
253
+ source,
254
+ filePath,
255
+ root: args.root,
256
+ ts,
257
+ validate: validateOnce,
258
+ resolveTargetAbs: resolveImportFileAbs,
259
+ })
260
+ : (() => {
261
+ const once = validateOnce(source);
262
+ return {
263
+ valid: Boolean(once.valid),
264
+ violations: once.violations ?? [],
265
+ autoPatch: null,
266
+ };
267
+ })();
217
268
  if (result.valid) return;
218
269
 
219
270
  // Ratchet semantics (same philosophy as ark-check --baseline): an edit is blocked only
220
271
  // when it ADDS violations relative to the file's current on-disk state. Otherwise a
221
272
  // 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.
273
+ // every subsequent edit to that file un-writable while CI passes. Same-file keys ignore
274
+ // line numbers (edits shift them); simpler than full baselineKey (no file/layer fields
275
+ // needed — this file is fixed).
224
276
  const violationKey = (violation) => `${violation.ruleId}|${violation.target ?? violation.message}`;
225
- let existingKeys = new Set();
277
+ let existingCounts = new Map();
226
278
  try {
227
279
  const current = fs.readFileSync(filePath, 'utf8');
228
- existingKeys = new Set(
229
- gate.validate(current, { layer, filePath }).violations.map(violationKey)
230
- );
280
+ for (const violation of gate.validate(current, { layer, filePath }).violations) {
281
+ const key = violationKey(violation);
282
+ existingCounts.set(key, (existingCounts.get(key) ?? 0) + 1);
283
+ }
231
284
  } catch {
232
285
  // New file: nothing pre-exists, every violation is new.
233
286
  }
234
- const newViolations = result.violations.filter(
235
- (violation) => !existingKeys.has(violationKey(violation))
236
- );
287
+ const newViolations = (result.violations ?? []).filter((violation) => {
288
+ const key = violationKey(violation);
289
+ const remaining = existingCounts.get(key) ?? 0;
290
+ if (remaining === 0) return true;
291
+ existingCounts.set(key, remaining - 1);
292
+ return false;
293
+ });
237
294
  if (newViolations.length === 0) return;
238
295
 
239
296
  const lines = newViolations.map(
@@ -246,16 +303,68 @@ function runHook(gate, config, args) {
246
303
  const suggestions = [
247
304
  ...new Set(newViolations.map((violation) => violation.suggestion).filter(Boolean)),
248
305
  ];
306
+ const autoPatch = result.autoPatch;
307
+ // W4: structured repair payload is opt-in (--hook-repair / ARK_HOOK_REPAIR).
308
+ // Default remains hard block with prose only — hosts that cannot re-inject stay clean.
309
+ const repair = Boolean(args.hookRepair);
249
310
  const message = [
250
311
  `Ark architecture gate blocked this write to ${rel}${layer ? ` (layer: ${layer})` : ''}:`,
251
312
  ...lines,
252
313
  ...(suggestions.length > 0 ? ['Fix:', ...suggestions.map((s) => ` ${s}`)] : []),
314
+ ...(autoPatch && repair
315
+ ? [
316
+ `autoPatch available (${autoPatch.remediationKind}, confidence ${autoPatch.confidence}): ` +
317
+ 'apply the patched source from ARK_AUTOPATCH_JSON / ARK_REPAIR_JSON on stderr' +
318
+ (grokStyle ? ' (or autoPatch in the deny JSON on stdout)' : '') +
319
+ ' instead of re-drafting. Gate still denies this write (never silent apply).',
320
+ ]
321
+ : []),
322
+ ...(autoPatch && !repair
323
+ ? [
324
+ `Mechanical-safe autoPatch is available (${autoPatch.remediationKind}). ` +
325
+ 'Enable repair payload with ARK_HOOK_REPAIR=1 or --hook-repair to receive ' +
326
+ 'machine-readable source (still hard-blocks; host re-injects).',
327
+ ]
328
+ : []),
253
329
  'Fix the violations and retry. The architecture contract is available as the ark://manifest MCP resource.',
254
330
  ].join('\n');
255
331
  process.stderr.write(message + '\n');
332
+
333
+ if (repair) {
334
+ // Structured envelope for any host that can re-inject. Never writes the file.
335
+ const repairPayload = {
336
+ mode: 'repair',
337
+ decision: 'deny',
338
+ filePath: rel.split(path.sep).join('/'),
339
+ ...(layer ? { layer } : {}),
340
+ ...(autoPatch
341
+ ? {
342
+ autoPatch: {
343
+ source: autoPatch.source,
344
+ remediationKind: autoPatch.remediationKind,
345
+ confidence: autoPatch.confidence,
346
+ valid: autoPatch.valid,
347
+ },
348
+ }
349
+ : { autoPatch: null }),
350
+ };
351
+ process.stderr.write(`ARK_REPAIR_JSON:${JSON.stringify(repairPayload)}\n`);
352
+ if (autoPatch) {
353
+ process.stderr.write(`ARK_AUTOPATCH_JSON:${JSON.stringify(autoPatch)}\n`);
354
+ }
355
+ }
356
+
256
357
  // Grok Build honors { decision: "deny" } on stdout (exit 2 alone is also deny).
358
+ // autoPatch in stdout only when repair mode is on (same opt-in as stderr).
257
359
  if (grokStyle) {
258
- process.stdout.write(JSON.stringify({ decision: 'deny', reason: message }) + '\n');
360
+ process.stdout.write(
361
+ JSON.stringify({
362
+ decision: 'deny',
363
+ reason: message,
364
+ ...(repair && autoPatch ? { autoPatch } : {}),
365
+ ...(repair ? { repair: true } : {}),
366
+ }) + '\n'
367
+ );
259
368
  }
260
369
  process.exitCode = 2;
261
370
  }
@@ -265,8 +374,14 @@ function runArkCheckJsonFromRoot(root, config, extraArgs, manifest) {
265
374
  const result = spawnSync(
266
375
  process.execPath,
267
376
  [arkCheckBin, '--root', root, '--config', config, ...manifestArgs, '--json', ...extraArgs],
268
- { encoding: 'utf8' }
377
+ { encoding: 'utf8', timeout: 120_000, maxBuffer: 20 * 1024 * 1024 }
269
378
  );
379
+ if (result.error) {
380
+ return {
381
+ data: null,
382
+ raw: `ark-check failed to execute: ${result.error.message}`,
383
+ };
384
+ }
270
385
  const stdout = result.stdout ?? '';
271
386
  try {
272
387
  return { data: JSON.parse(stdout), raw: stdout };
@@ -441,10 +556,22 @@ async function main() {
441
556
  name: layer.name,
442
557
  patterns: layer.patterns,
443
558
  })),
559
+ allowNonLiteralDynamicImport: (filePath) => {
560
+ if (!filePath || !Array.isArray(config.dynamicImportAllowlist)) return false;
561
+ const rel = path.relative(args.root, path.resolve(args.root, filePath)).split(path.sep).join('/');
562
+ return config.dynamicImportAllowlist.some((pattern) => {
563
+ if (typeof pattern !== 'string') return false;
564
+ try {
565
+ return globToRegExp(pattern).test(rel);
566
+ } catch {
567
+ return false;
568
+ }
569
+ });
570
+ },
444
571
  });
445
572
 
446
573
  if (args.hook) {
447
- runHook(gate, config, args);
574
+ runHook(gate, config, args, ts);
448
575
  return;
449
576
  }
450
577
 
@@ -463,7 +590,9 @@ async function main() {
463
590
  "Validate a source snippet about to be written against Ark's architecture " +
464
591
  '(forbidden infra imports, unknown intents, and layer-reference violations). ' +
465
592
  'Bind to PreToolUse on Write/Edit to block architecturally-invalid generated code. ' +
466
- 'Returns { valid, violations }; isError is true when the code is invalid.',
593
+ 'Returns { valid, violations, autoPatch? }. autoPatch (when present) is a ' +
594
+ 'mechanical-safe rewrite of the source (import type conversion) that re-validates green; ' +
595
+ 'hosts may apply it instead of re-drafting. isError is true when valid is false.',
467
596
  inputSchema: {
468
597
  type: 'object',
469
598
  properties: {
@@ -518,7 +647,8 @@ async function main() {
518
647
  description:
519
648
  'Place a file in the architecture: pass filePath (preferred) and/or description. ' +
520
649
  '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.',
650
+ 'If only description is given, returns a conventional path proposal under a governed layer. ' +
651
+ 'Prefer ark_prepare_write when you already have the source snippet (place+validate+autoPatch in one call).',
522
652
  inputSchema: {
523
653
  type: 'object',
524
654
  properties: {
@@ -534,6 +664,34 @@ async function main() {
534
664
  },
535
665
  },
536
666
  },
667
+ {
668
+ name: 'ark_prepare_write',
669
+ description:
670
+ 'Prepare a write against the architecture contract: place (filePath and/or description) + ' +
671
+ 'constrain (layer, mayImport, mustNotImport, forbiddenGlobals) + validate source + optional ' +
672
+ 'mechanical-safe autoPatch + judgmentBrief when judgment is needed + contentHash for host commit. ' +
673
+ 'Composes ark_place + write-gate — call BEFORE Write/Edit when you have the snippet. ' +
674
+ 'Returns { filePath, layer, valid, violations?, autoPatch?, judgmentBrief?, contentHash, ... }.',
675
+ inputSchema: {
676
+ type: 'object',
677
+ properties: {
678
+ source: { type: 'string', description: 'Full source text about to be written.' },
679
+ filePath: {
680
+ type: 'string',
681
+ description: 'Target path (preferred). Used for layer inference and autoPatch resolution.',
682
+ },
683
+ description: {
684
+ type: 'string',
685
+ description: 'When filePath omitted: propose a conventional path from this description.',
686
+ },
687
+ layer: {
688
+ type: 'string',
689
+ description: 'Optional explicit layer override (otherwise inferred from filePath).',
690
+ },
691
+ },
692
+ required: ['source'],
693
+ },
694
+ },
537
695
  {
538
696
  name: 'ark_recommend',
539
697
  description:
@@ -572,7 +730,10 @@ async function main() {
572
730
  // DomainModel there would tell the agent to create a second layer for the same
573
731
  // prefix, making longest-prefix resolution ambiguous.
574
732
  function suggestedLayers() {
575
- const activeNames = new Set(profile.layers.map((layer) => layer.name));
733
+ const activeNames = new Set([
734
+ ...configLayers.map((layer) => layer.name),
735
+ ...profile.layers.map((layer) => layer.name),
736
+ ]);
576
737
  const claimedPrefixes = new Set(
577
738
  profile.layers.flatMap((layer) =>
578
739
  (layer.prefixes ?? []).map((p) => (p.endsWith('.') ? p : `${p}.`))
@@ -598,13 +759,30 @@ async function main() {
598
759
  );
599
760
  }
600
761
  const suggestions = suggestedLayers();
762
+ const contractLayers = usedProjectConfig
763
+ ? configLayers.map((layer) => ({
764
+ ...layer,
765
+ prefixes: Array.isArray(layer.intentPrefixes) ? layer.intentPrefixes : [],
766
+ }))
767
+ : profile.layers;
601
768
  return JSON.stringify(
602
769
  {
603
770
  source: profile === ark.elevenLayerProfile ? 'strictDefaultElevenLayerProfile' : 'project',
604
771
  name: profile.name,
605
- layers: profile.layers,
772
+ // File placement contract: every configured layer, including layers that do not
773
+ // own intent prefixes (e.g. Tooling / FrameworkAdapters).
774
+ layers: contractLayers,
775
+ // Runtime/intent resolution profile kept explicit so consumers never have to infer
776
+ // why a prefix-less file layer is absent from intent resolution.
777
+ intentLayers: profile.layers,
606
778
  rules: profile.rules,
607
779
  ...(Object.keys(forbiddenGlobals).length > 0 ? { forbiddenGlobals } : {}),
780
+ ...(Array.isArray(config.dynamicImportAllowlist)
781
+ ? { dynamicImportAllowlist: config.dynamicImportAllowlist }
782
+ : {}),
783
+ ...(config.safety && typeof config.safety === 'object'
784
+ ? { safety: config.safety }
785
+ : {}),
608
786
  ...(suggestions.length > 0
609
787
  ? {
610
788
  suggestedLayers: suggestions,
@@ -626,13 +804,38 @@ async function main() {
626
804
  if (typeof source !== 'string') {
627
805
  return { content: [{ type: 'text', text: 'Missing required "source" argument.' }], isError: true };
628
806
  }
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,
807
+ const filePath = params.arguments.filePath;
808
+ const layer = params.arguments.layer ?? inferLayer(filePath, config, args.root);
809
+ const validateOnce = (src) =>
810
+ gate.validate(src, {
811
+ layer,
812
+ filePath,
813
+ });
814
+ // W1: attempt mechanical-safe single-file autoPatch (import type), re-validate or discard.
815
+ const result = validateWithAutoPatch({
816
+ source,
817
+ filePath,
818
+ root: args.root,
819
+ ts,
820
+ validate: validateOnce,
821
+ resolveTargetAbs: resolveImportFileAbs,
633
822
  });
634
823
  return {
635
- content: [{ type: 'text', text: JSON.stringify({ ...result, layer }, null, 2) }],
824
+ content: [
825
+ {
826
+ type: 'text',
827
+ text: JSON.stringify(
828
+ {
829
+ valid: result.valid,
830
+ violations: result.violations,
831
+ ...(result.autoPatch ? { autoPatch: result.autoPatch } : {}),
832
+ layer,
833
+ },
834
+ null,
835
+ 2
836
+ ),
837
+ },
838
+ ],
636
839
  isError: !result.valid,
637
840
  };
638
841
  }
@@ -691,11 +894,8 @@ async function main() {
691
894
  // Deterministic placement guidance (in-process; no TS resolver needed): which layer a
692
895
  // path falls in, and — from the same rules ark-check enforces (default allow, explicit
693
896
  // `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;
897
+ function placeResult(filePath, description) {
697
898
  if ((typeof filePath !== 'string' || !filePath) && typeof description === 'string' && description.trim()) {
698
- // Description-only: propose a governed path under PresentationAdapters (UI default).
699
899
  const slug = description
700
900
  .trim()
701
901
  .toLowerCase()
@@ -705,73 +905,39 @@ async function main() {
705
905
  const proposedPath = `src/components/${slug}.tsx`;
706
906
  const layerName = inferLayer(proposedPath, config, args.root) || 'PresentationAdapters';
707
907
  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,
908
+ filePath: proposedPath,
909
+ proposed: true,
910
+ description: description.trim(),
911
+ layer: layerName,
912
+ governed: Boolean(inferLayer(proposedPath, config, args.root)),
913
+ note:
914
+ 'filePath was omitted — proposed a conventional path from description. ' +
915
+ 'Pass filePath explicitly for authoritative placement.',
728
916
  };
729
917
  }
730
918
  if (typeof filePath !== 'string' || !filePath) {
731
919
  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,
920
+ error:
921
+ 'Needs filePath and/or description. ' +
922
+ 'Example: { "filePath": "src/components/Foo.tsx" } or { "description": "caption overlay UI component" }.',
741
923
  };
742
924
  }
743
925
  const layerName = inferLayer(filePath, config, args.root);
744
926
  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
927
  const noLayers = configLayers.length === 0;
750
928
  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,
929
+ filePath,
930
+ layer: null,
931
+ governed: noLayers,
932
+ message: noLayers
933
+ ? 'This project declares no path-based layers in ark.config.json, so a ' +
934
+ 'layer cannot be inferred from the path. The gate still enforces the ' +
935
+ 'default 11-layer profile by intent-name prefix — read ark://manifest ' +
936
+ 'for the layers and validate the actual snippet with validate_code.'
937
+ : 'No layer pattern matches this path — code here is UNGOVERNED (no import ' +
938
+ 'rules enforced). Place it under a directory a layer in ark.config.json ' +
939
+ 'matches, or add a layer. See suggestedLayers for conventional homes.',
940
+ suggestedLayers: suggestedLayers(),
775
941
  };
776
942
  }
777
943
  const layerMeta = configLayers.find((layer) => layer.name === layerName);
@@ -782,34 +948,91 @@ async function main() {
782
948
  );
783
949
  const mayImport = otherNames.filter((name) => !mustNotImport.includes(name));
784
950
  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
- ],
951
+ filePath,
952
+ layer: layerName,
953
+ governed: true,
954
+ description: layerMeta?.description,
955
+ forbiddenGlobals: layerMeta?.forbiddenGlobals ?? [],
956
+ ...(layerMeta?.mayImportInfrastructure ? { mayImportInfrastructure: true } : {}),
957
+ mayImport,
958
+ mustNotImport,
959
+ note:
960
+ 'mayImport = layers with no explicit deny (default is allow). Respect ' +
961
+ 'forbiddenGlobals, then verify the actual snippet with validate_code or ark_prepare_write.',
962
+ };
963
+ }
964
+
965
+ function runPlace(params) {
966
+ const placement = placeResult(params?.arguments?.filePath, params?.arguments?.description);
967
+ if (placement.error) {
968
+ return {
969
+ content: [{ type: 'text', text: `ark_place: ${placement.error}` }],
970
+ isError: true,
971
+ };
972
+ }
973
+ return {
974
+ content: [{ type: 'text', text: JSON.stringify(placement, null, 2) }],
809
975
  isError: false,
810
976
  };
811
977
  }
812
978
 
979
+ /**
980
+ * W2: place + constrain + validate + autoPatch + judgmentBrief + contentHash.
981
+ * Composes ark_place + write-boundary gate — not a second contract.
982
+ */
983
+ function runPrepareWrite(params) {
984
+ const source = params?.arguments?.source;
985
+ const filePath = params?.arguments?.filePath;
986
+ const description = params?.arguments?.description;
987
+ if (typeof source !== 'string') {
988
+ return {
989
+ content: [
990
+ {
991
+ type: 'text',
992
+ text: 'ark_prepare_write requires "source" (string). Optional: filePath, description.',
993
+ },
994
+ ],
995
+ isError: true,
996
+ };
997
+ }
998
+ const placement = placeResult(filePath, description);
999
+ if (placement.error) {
1000
+ return {
1001
+ content: [{ type: 'text', text: `ark_prepare_write: ${placement.error}` }],
1002
+ isError: true,
1003
+ };
1004
+ }
1005
+ const layer =
1006
+ placement.layer ||
1007
+ params?.arguments?.layer ||
1008
+ inferLayer(placement.filePath, config, args.root);
1009
+ const validateOnce = (src) =>
1010
+ gate.validate(src, {
1011
+ layer,
1012
+ filePath: placement.filePath,
1013
+ });
1014
+ const result = composePrepareWrite({
1015
+ source,
1016
+ placement: { ...placement, layer },
1017
+ root: args.root,
1018
+ ts,
1019
+ validate: validateOnce,
1020
+ resolveTargetAbs: resolveImportFileAbs,
1021
+ });
1022
+ if (!result.ok) {
1023
+ return {
1024
+ content: [{ type: 'text', text: result.error || 'prepare_write failed' }],
1025
+ isError: true,
1026
+ };
1027
+ }
1028
+ return {
1029
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1030
+ // Align with validate_code / --hook: proposed source still invalid → isError.
1031
+ // autoPatch is additive recovery guidance in the body, never soft-success.
1032
+ isError: !result.valid,
1033
+ };
1034
+ }
1035
+
813
1036
  function runSuggestIncludeTool() {
814
1037
  try {
815
1038
  const workspaces = detectWorkspaces(args.root);
@@ -852,6 +1075,7 @@ async function main() {
852
1075
  ark_check: runCheckTool,
853
1076
  ark_coverage: runCoverageTool,
854
1077
  ark_place: runPlace,
1078
+ ark_prepare_write: runPrepareWrite,
855
1079
  ark_recommend: runRecommendTool,
856
1080
  ark_suggest_include: runSuggestIncludeTool,
857
1081
  };