arkgate 4.8.23 → 4.8.24

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 (131) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +24 -77
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +596 -112
  6. package/bin/ark-mcp-runtime.mjs +412 -138
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +39 -0
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/agent-projection-command.mjs +5 -1
  13. package/bin/lib/analysis-engine.mjs +15 -11
  14. package/bin/lib/architecture-scan.mjs +50 -6
  15. package/bin/lib/ark-order-doctor.mjs +1 -1
  16. package/bin/lib/ark-order-facts.mjs +410 -15
  17. package/bin/lib/ark-order-invariants.mjs +51 -9
  18. package/bin/lib/ark-order-sensors.mjs +28 -101
  19. package/bin/lib/ark-order-types.mjs +2 -0
  20. package/bin/lib/ark-run-doctor.mjs +14 -0
  21. package/bin/lib/ark-run-facts.mjs +434 -11
  22. package/bin/lib/ark-run-sensors.mjs +5 -3
  23. package/bin/lib/arkgate-pins.mjs +91 -0
  24. package/bin/lib/arkrules-sensors.mjs +50 -249
  25. package/bin/lib/check-args.mjs +23 -1
  26. package/bin/lib/ci-and-commands.mjs +16 -6
  27. package/bin/lib/class-source-scan.mjs +479 -0
  28. package/bin/lib/codex-home.mjs +4 -2
  29. package/bin/lib/config-contract-slices.mjs +701 -0
  30. package/bin/lib/config-contract.mjs +5 -493
  31. package/bin/lib/config-warnings.mjs +32 -0
  32. package/bin/lib/dashboard-cli.mjs +62 -0
  33. package/bin/lib/design-delta.mjs +1 -1
  34. package/bin/lib/diagnostic-catalog.mjs +16 -9
  35. package/bin/lib/doctor-advisories.mjs +25 -1
  36. package/bin/lib/doctor-next-actions.mjs +17 -2
  37. package/bin/lib/doctor-plan.mjs +6 -8
  38. package/bin/lib/effective-contract-load.mjs +121 -59
  39. package/bin/lib/enforcement-profiles.mjs +40 -4
  40. package/bin/lib/first-run-help.mjs +3 -2
  41. package/bin/lib/flat-parent-importers.mjs +137 -0
  42. package/bin/lib/flat-parent-pilot.mjs +40 -100
  43. package/bin/lib/gate-files.mjs +29 -2
  44. package/bin/lib/git-change-scope.mjs +109 -0
  45. package/bin/lib/github-enforcement.mjs +1 -1
  46. package/bin/lib/hook-templates.mjs +18 -16
  47. package/bin/lib/host-support-matrix.mjs +3 -1
  48. package/bin/lib/import-resolve.mjs +13 -1
  49. package/bin/lib/install-migrate.mjs +50 -73
  50. package/bin/lib/invariant-coverage.mjs +59 -11
  51. package/bin/lib/mcp-adoption.mjs +9 -1
  52. package/bin/lib/mcp-hook-payload.mjs +401 -61
  53. package/bin/lib/package-manager.mjs +59 -1
  54. package/bin/lib/passthrough-commands.mjs +83 -0
  55. package/bin/lib/persistence-write-hint.mjs +81 -0
  56. package/bin/lib/pin-dependent-gates.mjs +117 -0
  57. package/bin/lib/policy-delta-io.mjs +168 -10
  58. package/bin/lib/prepare-change.mjs +120 -6
  59. package/bin/lib/prepare-write.mjs +1 -0
  60. package/bin/lib/presets.mjs +3 -5
  61. package/bin/lib/remediation.mjs +23 -4
  62. package/bin/lib/resolved-candidate-facts.mjs +315 -55
  63. package/bin/lib/rules-inventory.mjs +22 -3
  64. package/bin/lib/rules-under-contract.mjs +179 -5
  65. package/bin/lib/scan-files.mjs +32 -0
  66. package/bin/lib/snippet-analysis.mjs +140 -6
  67. package/bin/lib/source-policy.mjs +34 -0
  68. package/bin/lib/stable-hash.mjs +90 -0
  69. package/bin/lib/start-install-recovery.mjs +71 -1
  70. package/bin/lib/start-preview.mjs +30 -0
  71. package/bin/lib/status-command.mjs +33 -4
  72. package/bin/lib/status-manifest.mjs +32 -4
  73. package/bin/lib/team-parliament-io.mjs +77 -75
  74. package/bin/lib/team-parliament.mjs +6 -1
  75. package/bin/lib/write-path-capabilities.mjs +4 -2
  76. package/dist/chunk-A5EIY3HI.cjs +5 -0
  77. package/dist/chunk-MBXCFLO4.js +16 -0
  78. package/dist/chunk-RNQHRDCQ.cjs +16 -0
  79. package/dist/chunk-SVSQVGFJ.js +5 -0
  80. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-BJbaeoj4.d.cts} +18 -3
  81. package/dist/configTypes-BJbaeoj4.d.ts +246 -0
  82. package/dist/eslint/index.cjs +17 -4
  83. package/dist/eslint/index.d.cts +359 -0
  84. package/dist/eslint/index.d.ts +130 -156
  85. package/dist/eslint/index.js +10 -4
  86. package/dist/index.cjs +35 -34
  87. package/dist/index.d.cts +5475 -0
  88. package/dist/index.d.ts +3312 -299
  89. package/dist/index.js +35 -34
  90. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  91. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  92. package/dist/nestjs/index.cjs +1 -18
  93. package/dist/nestjs/index.d.cts +22 -0
  94. package/dist/nestjs/index.d.ts +1 -3
  95. package/dist/nestjs/index.js +1 -18
  96. package/dist/order/index.cjs +1 -1
  97. package/dist/order/index.d.cts +155 -0
  98. package/dist/order/index.d.ts +15 -3
  99. package/dist/order/index.js +1 -1
  100. package/dist/runtime/index.cjs +13 -30
  101. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +804 -290
  102. package/dist/runtime/index.d.ts +2678 -108
  103. package/dist/runtime/index.js +13 -30
  104. package/dist/{types-Bdj32QwA.d.ts → types-DRinG-vT.d.cts} +576 -140
  105. package/dist/types-DRinG-vT.d.ts +1792 -0
  106. package/docs/README.md +2 -1
  107. package/docs/agent-guide.md +84 -15
  108. package/docs/ai-gates.md +160 -26
  109. package/docs/arkorder.md +32 -15
  110. package/docs/brownfield-adoption.md +5 -1
  111. package/docs/configuration.md +196 -26
  112. package/docs/develop.md +2 -1
  113. package/docs/diagnostics.md +96 -19
  114. package/docs/package-surface.md +31 -8
  115. package/docs/product-voice.md +1 -1
  116. package/package.json +59 -18
  117. package/schemas/ark.analysis-result.schema.json +15 -0
  118. package/schemas/ark.config.schema.json +47 -10
  119. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  120. package/schemas/ark.status-manifest.schema.json +22 -0
  121. package/server.json +3 -9
  122. package/templates/agent-skills/README.md +1 -1
  123. package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
  124. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  125. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  126. package/templates/arkrules/PersistenceAdapters.json +1 -1
  127. package/templates/arkrules/PresentationAdapters.json +1 -1
  128. package/templates/skills/ark-adopt.md +4 -1
  129. package/templates/skills/ark-order.md +4 -2
  130. package/templates/skills/ark-runtime.md +6 -1
  131. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -138,50 +138,151 @@ export function normalizeHookPayload(payload, grokHookEvent = Boolean(process.en
138
138
  };
139
139
  }
140
140
 
141
- export function applyCodexUpdatePatch(current, lines) {
142
- let source = current.split('\n');
143
- let cursor = 0;
144
- const hunks = [];
145
- let hunk = null;
146
- for (const line of lines) {
141
+ /** Realpath of the nearest existing ancestor plus the missing tail (never throws). */
142
+ export function canonicalPathLoose(candidate) {
143
+ const absolute = path.resolve(candidate);
144
+ let existing = absolute;
145
+ const missing = [];
146
+ while (!fs.existsSync(existing)) {
147
+ const parent = path.dirname(existing);
148
+ if (parent === existing) return absolute;
149
+ missing.unshift(path.basename(existing));
150
+ existing = parent;
151
+ }
152
+ try {
153
+ return path.join(fs.realpathSync(existing), ...missing);
154
+ } catch {
155
+ return absolute;
156
+ }
157
+ }
158
+
159
+ function relativeInside(root, candidate) {
160
+ const relative = path.relative(root, candidate);
161
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`)) return null;
162
+ if (path.isAbsolute(relative)) return null;
163
+ return relative;
164
+ }
165
+
166
+ /**
167
+ * Project-relative path when `filePath` is inside `root` under the caller's spelling
168
+ * or, failing that, the canonical (realpath) spelling — /tmp vs /private/tmp, a
169
+ * symlinked checkout. Null only when the path is truly outside the root.
170
+ */
171
+ export function relativeToHookRoot(root, filePath) {
172
+ const absolute = path.resolve(root, filePath);
173
+ const direct = relativeInside(path.resolve(root), absolute);
174
+ if (direct !== null) return direct;
175
+ return relativeInside(canonicalPathLoose(root), canonicalPathLoose(absolute));
176
+ }
177
+
178
+ const CODEX_FILE_DIRECTIVE = /^\*\*\* (Add|Update|Delete) File: (.+)$/;
179
+ const CODEX_MOVE_DIRECTIVE = /^\*\*\* Move to: (.+)$/;
180
+ const CODEX_END_OF_FILE = '*** End of File';
181
+
182
+ /** Codex-style sequence seek: exact, then trailing-whitespace, then trimmed match. */
183
+ function seekSequence(source, pattern, start, eof) {
184
+ if (pattern.length === 0) return start;
185
+ if (pattern.length > source.length) return -1;
186
+ const normalizers = [(line) => line, (line) => line.trimEnd(), (line) => line.trim()];
187
+ const lastStart = source.length - pattern.length;
188
+ for (const normalize of normalizers) {
189
+ const matchesAt = (at) =>
190
+ pattern.every((line, index) => normalize(source[at + index]) === normalize(line));
191
+ if (eof && lastStart >= start && matchesAt(lastStart)) return lastStart;
192
+ for (let at = start; at <= lastStart; at += 1) {
193
+ if (matchesAt(at)) return at;
194
+ }
195
+ }
196
+ return -1;
197
+ }
198
+
199
+ /**
200
+ * Parse one Update File body into chunks. The first chunk may omit its `@@` header;
201
+ * `*** End of File` may close a chunk; a bare empty line is an empty context line.
202
+ * Returns null for any line outside the Codex apply_patch grammar.
203
+ */
204
+ export function parseCodexUpdateChunks(lines) {
205
+ const body = [...lines];
206
+ while (body.length > 0 && body[body.length - 1] === '') body.pop();
207
+ const chunks = [];
208
+ let chunk = null;
209
+ for (let index = 0; index < body.length; index += 1) {
210
+ const line = body[index];
147
211
  if (line.startsWith('@@')) {
148
- if (hunk) hunks.push(hunk);
149
- hunk = { anchor: line.slice(2).trim(), entries: [] };
150
- } else if (/^[ +\-]/.test(line)) {
151
- if (!hunk) return null;
152
- hunk.entries.push(line);
212
+ if (chunk) chunks.push(chunk);
213
+ chunk = { anchor: line.slice(2).trim(), entries: [], eof: false };
214
+ continue;
153
215
  }
216
+ if (line === CODEX_END_OF_FILE) {
217
+ const next = body[index + 1];
218
+ if (!chunk || chunk.entries.length === 0 || (next !== undefined && !next.startsWith('@@'))) {
219
+ return null;
220
+ }
221
+ chunk.eof = true;
222
+ continue;
223
+ }
224
+ if (line === '' || /^[ +\-]/.test(line)) {
225
+ if (!chunk) {
226
+ // Only the FIRST chunk may omit `@@` (implicit, anchorless).
227
+ if (chunks.length > 0) return null;
228
+ chunk = { anchor: '', entries: [], eof: false };
229
+ }
230
+ if (chunk.eof) return null;
231
+ chunk.entries.push(line === '' ? ' ' : line);
232
+ continue;
233
+ }
234
+ return null;
154
235
  }
155
- if (hunk) hunks.push(hunk);
156
- for (const { anchor, entries } of hunks) {
236
+ if (chunk) chunks.push(chunk);
237
+ if (chunks.length === 0 || chunks.some((entry) => entry.entries.length === 0)) return null;
238
+ return chunks;
239
+ }
240
+
241
+ export function applyCodexUpdatePatch(current, lines) {
242
+ const chunks = parseCodexUpdateChunks(lines);
243
+ if (!chunks) return null;
244
+ const source = current.split('\n');
245
+ // Codex matches against the file's lines without the final newline's empty tail.
246
+ if (source.length > 0 && source[source.length - 1] === '') source.pop();
247
+ let cursor = 0;
248
+ for (const { anchor, entries, eof } of chunks) {
157
249
  if (anchor) {
158
- const anchorAt = source.findIndex((line, index) => index >= cursor && line === anchor);
250
+ const anchorAt = seekSequence(source, [anchor], cursor, false);
159
251
  if (anchorAt < 0) return null;
160
252
  cursor = anchorAt + 1;
161
253
  }
162
254
  const oldLines = entries.filter((line) => !line.startsWith('+')).map((line) => line.slice(1));
163
255
  const newLines = entries.filter((line) => !line.startsWith('-')).map((line) => line.slice(1));
164
- let found = -1;
165
- for (let at = cursor; at <= source.length - oldLines.length; at += 1) {
166
- if (oldLines.every((line, index) => source[at + index] === line)) {
167
- found = at;
168
- break;
169
- }
256
+ let found;
257
+ if (oldLines.length === 0) {
258
+ // Pure insertion lands at the end of the file (Codex semantics).
259
+ found = source.length;
260
+ } else {
261
+ found = seekSequence(source, oldLines, cursor, eof);
262
+ if (found < 0) return null;
170
263
  }
171
- if (found < 0) return null;
172
264
  source.splice(found, oldLines.length, ...newLines);
173
265
  cursor = found + newLines.length;
174
266
  }
175
- return source.join('\n');
267
+ return `${source.join('\n')}\n`;
268
+ }
269
+
270
+ function codexPatchTarget(root, rawPath) {
271
+ const relative = relativeToHookRoot(root, rawPath.trim());
272
+ if (relative === null) return null;
273
+ return {
274
+ filePath: path.resolve(root, relative),
275
+ path: relative.split(path.sep).join('/'),
276
+ };
176
277
  }
177
278
 
178
279
  export function codexPatchWrites(patch, root) {
179
280
  if (typeof patch !== 'string') {
180
281
  return { writes: [], complete: false };
181
282
  }
182
- const lines = patch.split('\n');
183
- const begin = lines.indexOf('*** Begin Patch');
184
- const end = lines.indexOf('*** End Patch', begin + 1);
283
+ const lines = patch.replace(/\r\n/g, '\n').split('\n');
284
+ const begin = lines.findIndex((line) => line.trim() === '*** Begin Patch');
285
+ const end = lines.findIndex((line, index) => index > begin && line.trim() === '*** End Patch');
185
286
  if (begin < 0 || end <= begin) return { writes: [], complete: false };
186
287
  const writes = [];
187
288
  const seenPaths = new Set();
@@ -191,31 +292,35 @@ export function codexPatchWrites(patch, root) {
191
292
  ].every((line) => line.trim() === '');
192
293
  let sawFileDirective = false;
193
294
  for (let index = begin + 1; index < end; index += 1) {
194
- const match = lines[index].match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
295
+ const match = lines[index].match(CODEX_FILE_DIRECTIVE);
195
296
  if (!match) {
196
297
  if (lines[index].trim() !== '') complete = false;
197
298
  continue;
198
299
  }
199
300
  sawFileDirective = true;
200
301
  const [, action, relativePath] = match;
302
+ let moveTo = null;
303
+ if (action === 'Update') {
304
+ const move = (lines[index + 1] ?? '').match(CODEX_MOVE_DIRECTIVE);
305
+ if (move) {
306
+ moveTo = move[1];
307
+ index += 1;
308
+ }
309
+ }
201
310
  const body = [];
202
- for (index += 1; index < end && !lines[index].startsWith('*** '); index += 1) {
311
+ // Hunk bodies end only at the next file directive or End Patch — never at
312
+ // `*** End of File`, which belongs to the hunk grammar.
313
+ for (index += 1; index < end && !CODEX_FILE_DIRECTIVE.test(lines[index]); index += 1) {
203
314
  body.push(lines[index]);
204
315
  }
205
316
  index -= 1;
206
- const filePath = path.resolve(root, relativePath);
207
- const rel = path.relative(root, filePath);
208
- if (
209
- seenPaths.has(filePath) ||
210
- rel.startsWith(`..${path.sep}`) ||
211
- rel === '..' ||
212
- path.isAbsolute(rel)
213
- ) {
317
+ const target = codexPatchTarget(root, relativePath);
318
+ if (!target || seenPaths.has(target.filePath)) {
214
319
  complete = false;
215
320
  continue;
216
321
  }
217
- seenPaths.add(filePath);
218
- const canonicalRelativePath = rel.split(path.sep).join('/');
322
+ seenPaths.add(target.filePath);
323
+ const { filePath, path: canonicalRelativePath } = target;
219
324
  if (action === 'Delete') {
220
325
  if (body.some((line) => line.trim() !== '') || !fs.existsSync(filePath)) {
221
326
  complete = false;
@@ -224,39 +329,54 @@ export function codexPatchWrites(patch, root) {
224
329
  writes.push({ path: canonicalRelativePath, filePath, delete: true });
225
330
  continue;
226
331
  }
227
- let content;
228
332
  if (action === 'Add') {
333
+ const addBody = [...body];
334
+ while (addBody.length > 0 && addBody[addBody.length - 1] === '') addBody.pop();
229
335
  if (
230
- body.length === 0 ||
336
+ addBody.length === 0 ||
231
337
  fs.existsSync(filePath) ||
232
- body.some((line) => !line.startsWith('+'))
233
- ) {
234
- complete = false;
235
- continue;
236
- }
237
- content = body.filter((line) => line.startsWith('+')).map((line) => line.slice(1)).join('\n');
238
- if (body.some((line) => line.startsWith('+'))) content += '\n';
239
- } else {
240
- if (
241
- !body.some((line) => line.startsWith('@@')) ||
242
- body.some((line) => !line.startsWith('@@') && !/^[ +\-]/.test(line))
338
+ addBody.some((line) => !line.startsWith('+'))
243
339
  ) {
244
340
  complete = false;
245
341
  continue;
246
342
  }
247
- let current;
248
- try {
249
- current = fs.readFileSync(filePath, 'utf8');
250
- } catch {
251
- complete = false;
252
- continue;
253
- }
254
- content = applyCodexUpdatePatch(current, body);
255
- if (content === null) complete = false;
343
+ const content = `${addBody.map((line) => line.slice(1)).join('\n')}\n`;
344
+ writes.push({ path: canonicalRelativePath, filePath, content });
345
+ continue;
346
+ }
347
+ let current;
348
+ try {
349
+ current = fs.readFileSync(filePath, 'utf8');
350
+ } catch {
351
+ complete = false;
352
+ continue;
256
353
  }
257
- if (typeof content === 'string') {
354
+ const content = applyCodexUpdatePatch(current, body);
355
+ if (content === null) {
356
+ complete = false;
357
+ continue;
358
+ }
359
+ if (moveTo === null) {
258
360
  writes.push({ path: canonicalRelativePath, filePath, content });
361
+ continue;
362
+ }
363
+ // `*** Move to:` — judge the patched content at its destination, delete the source.
364
+ const destination = codexPatchTarget(root, moveTo);
365
+ if (!destination) {
366
+ complete = false;
367
+ continue;
259
368
  }
369
+ if (destination.filePath === filePath) {
370
+ writes.push({ path: canonicalRelativePath, filePath, content });
371
+ continue;
372
+ }
373
+ if (seenPaths.has(destination.filePath) || fs.existsSync(destination.filePath)) {
374
+ complete = false;
375
+ continue;
376
+ }
377
+ seenPaths.add(destination.filePath);
378
+ writes.push({ path: destination.path, filePath: destination.filePath, content });
379
+ writes.push({ path: canonicalRelativePath, filePath, delete: true });
260
380
  }
261
381
  return { writes, complete: complete && sawFileDirective };
262
382
  }
@@ -371,8 +491,12 @@ export function requiredOwnerWriteDeny(config, layerName, relativePath) {
371
491
  export function formatWriteGateDeny({ file, reason, ruleId, nextAction, extraLines = [] }) {
372
492
  const target = file || 'this write';
373
493
  const why = String(reason || 'a bad import — the write doesn’t land').replace(/\s+/g, ' ').trim();
494
+ // Import-plane denies keep the short "move / place" line unless the rule's own action
495
+ // already says that. Extra planes (ArkRules, ArkRun, ArkOrder, invariants, gate
496
+ // availability) are not import problems: their catalog action is the only right one.
497
+ const extraPlane = /^(ARKRULE_|ARKRUN_|ARKORDER_|INVARIANT_|WRITE_GATE_)/.test(String(ruleId ?? ''));
374
498
  const next =
375
- nextAction && /place|move|import|port/i.test(nextAction)
499
+ nextAction && (extraPlane || /place|move|import|port/i.test(nextAction))
376
500
  ? nextAction
377
501
  : 'Move the import or run /ark-place. Do not weaken ark.config.json.';
378
502
  const lines = [`blocked ${target} — ${why}`, `Next: ${next}`];
@@ -382,3 +506,219 @@ export function formatWriteGateDeny({ file, reason, ruleId, nextAction, extraLin
382
506
  }
383
507
  return lines.join('\n');
384
508
  }
509
+
510
+ /**
511
+ * Fail-closed envelope for the one-shot PreToolUse hook (`ark-mcp --hook`).
512
+ *
513
+ * Claude Code, Grok Build, and Codex treat any exit code other than 2 as a
514
+ * non-blocking error: the write lands. When the write gate itself cannot run
515
+ * (ark.config.json missing / unparsable / invalid, a referenced ArkRules file
516
+ * missing, dist/ missing or broken), exiting 1 would silently turn the hard
517
+ * write gate off. "No checker, no write": governed source writes are denied
518
+ * with the host's blocking contract instead.
519
+ *
520
+ * Plumbing cases stay fail-open, exactly like the healthy hook: no stdin or a
521
+ * malformed payload, non-file tools, non-source files (so ark.config.json itself
522
+ * can still be repaired), files outside the root, and node_modules.
523
+ *
524
+ * This section (like the whole module) must not import dist/ or the MCP runtime — it runs precisely when
525
+ * those are the broken part.
526
+ */
527
+ const FAIL_CLOSED_SOURCE_FILE = /\.[cm]?[jt]sx?$/;
528
+ const PATCH_FILE_DIRECTIVE = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/;
529
+ const PATCH_MOVE_DIRECTIVE = /^\*\*\* Move to: (.+)$/;
530
+
531
+ /** Same root resolution as the runtime: --root, then the first non-empty --root-env var. */
532
+ export function hookRootFromArgv(argv, env = process.env, cwd = process.cwd()) {
533
+ let root = cwd;
534
+ const rootEnv = [];
535
+ for (let index = 2; index < argv.length; index += 1) {
536
+ const value = argv[index];
537
+ if (value === '--root' && argv[index + 1]) root = path.resolve(argv[++index]);
538
+ else if (value === '--root-env' && argv[index + 1]) {
539
+ rootEnv.push(
540
+ ...String(argv[++index])
541
+ .split(',')
542
+ .map((name) => name.trim())
543
+ .filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
544
+ );
545
+ }
546
+ }
547
+ for (const name of rootEnv) {
548
+ const value = env[name];
549
+ if (typeof value === 'string' && value.trim() !== '') return path.resolve(value.trim());
550
+ }
551
+ return root;
552
+ }
553
+
554
+ function hookArgvConfig(argv) {
555
+ let config = 'ark.config.json';
556
+ let explicit = false;
557
+ for (let index = 2; index < argv.length; index += 1) {
558
+ if (argv[index] === '--config' && argv[index + 1]) {
559
+ config = argv[++index];
560
+ explicit = true;
561
+ }
562
+ }
563
+ return { config, explicit };
564
+ }
565
+
566
+ function walkUpForConfig(start, config) {
567
+ let dir = path.resolve(start);
568
+ for (;;) {
569
+ if (fs.existsSync(path.join(dir, config))) return dir;
570
+ const parent = path.dirname(dir);
571
+ if (parent === dir) return null;
572
+ dir = parent;
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Hook project root. A set `--root-env` variable or a root that already holds the
578
+ * config wins (the runtime's own resolution). Otherwise — the host ran the hook from a
579
+ * subdirectory, or the env var is unset — walk up from the payload (each written file,
580
+ * then `cwd`, then `workspace_roots`, then the argv root) to the nearest config, the
581
+ * way `ark-check` walks up.
582
+ * `configFound: false` means the write is not inside any Ark project.
583
+ * @returns {{ root: string, configFound: boolean, configExplicit: boolean, source: string }}
584
+ */
585
+ export function discoverHookRoot({
586
+ argv,
587
+ hookInput,
588
+ env = process.env,
589
+ cwd = process.cwd(),
590
+ grokHookEvent = Boolean(process.env.GROK_HOOK_EVENT),
591
+ }) {
592
+ const base = hookRootFromArgv(argv, env, cwd);
593
+ const { config, explicit } = hookArgvConfig(argv);
594
+ const result = (root, configFound, source) => ({
595
+ root,
596
+ configFound,
597
+ configExplicit: explicit,
598
+ source,
599
+ });
600
+ if (path.isAbsolute(config)) return result(base, fs.existsSync(config), 'argv');
601
+ if (fs.existsSync(path.join(base, config))) return result(base, true, 'argv');
602
+ let payload;
603
+ try {
604
+ payload = JSON.parse(hookInput ?? '');
605
+ } catch {
606
+ payload = undefined;
607
+ }
608
+ const payloadCwd =
609
+ payload && typeof payload.cwd === 'string' && payload.cwd.trim() ? payload.cwd.trim() : null;
610
+ const starts = [];
611
+ if (payload && typeof payload === 'object') {
612
+ const targets = hookPayloadTargets(normalizeHookPayload(payload, grokHookEvent)) ?? [];
613
+ for (const target of targets) {
614
+ if (typeof target !== 'string' || !target) continue;
615
+ starts.push(path.dirname(path.resolve(payloadCwd ?? base, target)));
616
+ }
617
+ if (payloadCwd) starts.push(path.resolve(payloadCwd));
618
+ for (const workspace of Array.isArray(payload.workspace_roots) ? payload.workspace_roots : []) {
619
+ if (typeof workspace === 'string' && workspace.trim()) starts.push(path.resolve(workspace));
620
+ }
621
+ }
622
+ starts.push(base);
623
+ for (const start of starts) {
624
+ const found = walkUpForConfig(start, config);
625
+ if (found) return result(found, true, 'payload');
626
+ }
627
+ return result(base, false, 'none');
628
+ }
629
+
630
+ /**
631
+ * The write is outside any Ark project (no config found from the hook root or the
632
+ * payload): ArkGate does not govern it, so the hook allows it.
633
+ * @returns {{ status: number, stdout: string, stderr: string }}
634
+ */
635
+ export function hookOutsideArkProjectResponse({ hookInput, config = 'ark.config.json', grokHookEvent }) {
636
+ let stdout = '';
637
+ let payload;
638
+ try {
639
+ payload = JSON.parse(hookInput ?? '');
640
+ } catch {
641
+ return { status: 0, stdout, stderr: '' };
642
+ }
643
+ emitHostAllow(
644
+ { stdout: (value) => { stdout += value; }, stderr: () => {} },
645
+ normalizeHookPayload(payload, grokHookEvent)
646
+ );
647
+ return {
648
+ status: 0,
649
+ stdout,
650
+ stderr: `[ark-mcp] no ${config} found for this write; ArkGate does not govern it.\n`,
651
+ };
652
+ }
653
+
654
+ function patchTargets(patch) {
655
+ if (typeof patch !== 'string') return [];
656
+ const targets = [];
657
+ for (const line of patch.split('\n')) {
658
+ const match = line.match(PATCH_FILE_DIRECTIVE) ?? line.match(PATCH_MOVE_DIRECTIVE);
659
+ if (match) targets.push(match[1].trim());
660
+ }
661
+ return targets;
662
+ }
663
+
664
+ /** File paths the hook payload would write, or null for non-file tools. */
665
+ export function hookPayloadTargets(normalized) {
666
+ const { toolName, toolInput } = normalized;
667
+ if (toolName === 'ApplyPatch') {
668
+ return patchTargets(
669
+ toolInput.command ?? toolInput.patch ?? toolInput.input ?? toolInput.content
670
+ );
671
+ }
672
+ if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) return null;
673
+ return typeof toolInput.file_path === 'string' ? [toolInput.file_path] : [];
674
+ }
675
+
676
+ function isGovernedCandidate(root, target) {
677
+ if (!FAIL_CLOSED_SOURCE_FILE.test(target) || target.endsWith('.d.ts')) return false;
678
+ const relative = relativeToHookRoot(root, target);
679
+ if (relative === null) return false;
680
+ return !relative.split(path.sep).includes('node_modules');
681
+ }
682
+
683
+ /**
684
+ * Map an internal hook failure onto the host's blocking contract.
685
+ * @returns {{ status: number, stdout: string, stderr: string }}
686
+ */
687
+ export function hookFailClosedResponse({ hookInput, error, root, grokHookEvent }) {
688
+ let stdout = '';
689
+ let stderr = '';
690
+ const output = {
691
+ stdout: (value) => {
692
+ stdout += value;
693
+ },
694
+ stderr: (value) => {
695
+ stderr += value;
696
+ },
697
+ };
698
+ let payload;
699
+ try {
700
+ payload = JSON.parse(hookInput ?? '');
701
+ } catch {
702
+ // Same as the healthy hook: no stdin / malformed payload never blocks.
703
+ return { status: 0, stdout, stderr };
704
+ }
705
+ const normalized = normalizeHookPayload(payload, grokHookEvent);
706
+ const targets = hookPayloadTargets(normalized);
707
+ const governed = (targets ?? []).filter((target) => isGovernedCandidate(root, target));
708
+ if (governed.length === 0) {
709
+ emitHostAllow(output, normalized);
710
+ return { status: 0, stdout, stderr };
711
+ }
712
+ const reason = error instanceof Error ? error.message : String(error);
713
+ const relative = relativeToHookRoot(root, governed[0]) ?? governed[0];
714
+ const file = String(relative).split(path.sep).join('/');
715
+ const message = formatWriteGateDeny({
716
+ file,
717
+ reason: `ArkGate write gate could not run: ${reason}`,
718
+ ruleId: 'WRITE_GATE_UNAVAILABLE',
719
+ nextAction:
720
+ 'Fix ark.config.json (or the ArkRules file it references), or run npm run build / reinstall arkgate from npm, then retry this write. No checker, no write — do not remove the hook to get past it.',
721
+ });
722
+ emitHostDeny(output, { ...normalized, message, file });
723
+ return { status: 2, stdout, stderr };
724
+ }
@@ -83,9 +83,66 @@ const RUNNER_BY_PM = { pnpm: PNPM_EXEC, yarn: 'yarn', npm: 'npx' };
83
83
  * same detection.
84
84
  */
85
85
  export function execRunner(root) {
86
+ if (!arkgateIsProjectDependency(root)) return pinnedArkgateRunner();
86
87
  return RUNNER_BY_PM[detectPackageManager(root)];
87
88
  }
88
89
 
90
+ /** The exact arkgate version that ships this CLI (the package.json next to bin/). */
91
+ export function shippedArkgateVersion() {
92
+ try {
93
+ const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
94
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * True when the project resolves arkgate locally: a `dependencies` / `devDependencies` entry,
102
+ * an installed node_modules/arkgate, or the arkgate package itself (self-host). Only then
103
+ * does a bare `npx ark-check` / `pnpm exec` / `yarn` runner reach this package's bins.
104
+ */
105
+ export function arkgateIsProjectDependency(root) {
106
+ // An installed copy (node_modules/arkgate) also resolves the local runner.
107
+ if (fs.existsSync(path.join(root, 'node_modules', 'arkgate', 'package.json'))) return true;
108
+ const pkg = readPackageJson(root);
109
+ if (!pkg || typeof pkg !== 'object') return false;
110
+ if (pkg.name === 'arkgate') return true;
111
+ return [pkg.dependencies, pkg.devDependencies].some(
112
+ (deps) => deps && typeof deps === 'object' && typeof deps.arkgate === 'string'
113
+ );
114
+ }
115
+
116
+ /** `arkgate@<exact shipped version>` — the package an unpinned project runs through npx. */
117
+ export function pinnedArkgateSpec(version = shippedArkgateVersion()) {
118
+ return version ? `arkgate@${version}` : 'arkgate';
119
+ }
120
+
121
+ /**
122
+ * Runner for a project that does not pin arkgate. Bare `npx ark-check` asks npm for a
123
+ * package named `ark-check` (404) or runs a stale global copy; `-p arkgate@<exact>` names the
124
+ * package that owns the bin, and `-y` keeps CI and host hooks from blocking on a prompt.
125
+ */
126
+ export function pinnedArkgateRunner(version) {
127
+ return `npx -y -p ${pinnedArkgateSpec(version)}`;
128
+ }
129
+
130
+ /**
131
+ * Number of npx arguments before the bin: 0 for `npx <bin>`, more for the pinned form
132
+ * (`npx -y -p arkgate@x <bin>`, `--yes`, `--package arkgate@x`, `--package=arkgate@x`).
133
+ */
134
+ export function npxArkgatePrefixLength(args) {
135
+ if (!Array.isArray(args)) return 0;
136
+ let index = 0;
137
+ if (args[index] === '-y' || args[index] === '--yes') index += 1;
138
+ const flag = args[index];
139
+ if ((flag === '-p' || flag === '--package') && /^arkgate(?:@\S+)?$/.test(args[index + 1] ?? '')) {
140
+ return index + 2;
141
+ }
142
+ if (typeof flag === 'string' && /^--package=arkgate(?:@\S+)?$/.test(flag)) return index + 1;
143
+ return 0;
144
+ }
145
+
89
146
  /** Full runnable command string for an installed Ark binary, package-manager aware. */
90
147
  export function arkCommand(root, bin, argsStr = '') {
91
148
  return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
@@ -116,7 +173,8 @@ export function execCommandParts(root, bin, binArgs = []) {
116
173
  };
117
174
  }
118
175
  if (runner === 'yarn') return { command: 'yarn', args: [bin, ...binArgs] };
119
- return { command: 'npx', args: [bin, ...binArgs] };
176
+ // Pinned form (`npx -y -p arkgate@x`) keeps its prefix as real argv entries.
177
+ return { command: 'npx', args: [...runner.split(' ').slice(1), bin, ...binArgs] };
120
178
  }
121
179
 
122
180
  /**