arkgate 4.6.6 → 4.7.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 (66) hide show
  1. package/CHANGELOG.md +139 -2
  2. package/README.md +22 -10
  3. package/SECURITY.md +1 -1
  4. package/bin/ark-check-runtime.mjs +21 -341
  5. package/bin/ark-mcp-runtime.mjs +71 -325
  6. package/bin/ark-shared.mjs +24 -158
  7. package/bin/lib/adapter-contract.mjs +17 -36
  8. package/bin/lib/analysis-engine.mjs +6 -6
  9. package/bin/lib/ark-run-doctor.mjs +144 -0
  10. package/bin/lib/ark-run-facts.mjs +472 -0
  11. package/bin/lib/ark-run-report.mjs +57 -0
  12. package/bin/lib/ark-run-sensors.mjs +309 -0
  13. package/bin/lib/check-args.mjs +173 -0
  14. package/bin/lib/check-config-detect.mjs +101 -0
  15. package/bin/lib/check-watch.mjs +80 -0
  16. package/bin/lib/config-contract.mjs +86 -11
  17. package/bin/lib/deep-module-coach.mjs +3 -0
  18. package/bin/lib/diagnostic-catalog.mjs +8 -0
  19. package/bin/lib/doctor-advisories.mjs +45 -8
  20. package/bin/lib/doctor-human.mjs +519 -0
  21. package/bin/lib/doctor-plan.mjs +62 -445
  22. package/bin/lib/extra-merge-teeth.mjs +187 -0
  23. package/bin/lib/github-enforcement.mjs +22 -9
  24. package/bin/lib/html-report-advisories.mjs +2 -0
  25. package/bin/lib/html-report-depth.mjs +22 -2
  26. package/bin/lib/html-report.mjs +40 -7
  27. package/bin/lib/mcp-hook-payload.mjs +328 -0
  28. package/bin/lib/package-manager.mjs +174 -0
  29. package/bin/lib/policy-delta-io.mjs +4 -0
  30. package/bin/lib/remediation.mjs +132 -0
  31. package/bin/lib/resolved-candidate-facts.mjs +67 -2
  32. package/bin/lib/rules-under-contract.mjs +37 -89
  33. package/bin/lib/snippet-analysis.mjs +43 -2
  34. package/bin/lib/status-command.mjs +28 -0
  35. package/bin/lib/status-manifest.mjs +23 -0
  36. package/bin/lib/team-parliament-io.mjs +4 -0
  37. package/dist/{configTypes-l6XiwiC1.d.ts → configTypes-CgJimx9o.d.ts} +17 -3
  38. package/dist/eslint/index.cjs +6 -2
  39. package/dist/eslint/index.d.ts +70 -2
  40. package/dist/eslint/index.js +6 -2
  41. package/dist/index.cjs +35 -35
  42. package/dist/index.d.ts +787 -272
  43. package/dist/index.js +35 -35
  44. package/docs/README.md +4 -3
  45. package/docs/agent-guide.md +21 -15
  46. package/docs/ai-gates.md +13 -0
  47. package/docs/configuration.md +24 -11
  48. package/docs/develop.md +12 -3
  49. package/docs/diagnostics.md +75 -0
  50. package/docs/enthusiast/README.md +4 -3
  51. package/docs/package-surface.md +17 -13
  52. package/docs/product-voice.md +6 -3
  53. package/docs/threat-model.md +1 -1
  54. package/docs/use.md +5 -4
  55. package/package.json +1 -1
  56. package/schemas/ark.config.schema.json +41 -2
  57. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  58. package/schemas/ark.status-manifest.schema.json +47 -0
  59. package/server.json +2 -2
  60. package/templates/agent-skills/README.md +1 -1
  61. package/templates/agent-skills/ark-adopt/SKILL.md +23 -2
  62. package/templates/agent-skills/ark-place/SKILL.md +26 -2
  63. package/templates/agent-skills/ark-runtime/SKILL.md +66 -24
  64. package/templates/skills/ark-adopt.md +23 -2
  65. package/templates/skills/ark-place.md +26 -2
  66. package/templates/skills/ark-runtime.md +66 -24
@@ -76,6 +76,13 @@ import { detectWritePathCapabilities } from './lib/write-path-detect.mjs';
76
76
  import { collectGovernedFiles, isGovernableSourceFile } from './lib/scan-files.mjs';
77
77
  import { classifyChangeSet, evaluateTeamGate } from './lib/team-parliament.mjs';
78
78
  import { contractSessionFrom } from './lib/team-parliament-io.mjs';
79
+ import {
80
+ normalizeHookPayload,
81
+ codexPatchWrites,
82
+ proposedSource,
83
+ emitHostAllow,
84
+ formatWriteGateDeny,
85
+ } from './lib/mcp-hook-payload.mjs';
79
86
  import {
80
87
  canonicalizeCandidateChanges,
81
88
  resolvedCompilerInputPaths,
@@ -293,269 +300,48 @@ function isResolvedAnalysisInput(relativePath, args, compilerInputs = new Set())
293
300
  return /(?:^|\/)configs?\/[^/]+\.jsonc?$/i.test(relative);
294
301
  }
295
302
 
296
- /**
297
- * Map Google Antigravity write tools (PascalCase args) onto Claude Write/Edit/MultiEdit.
298
- * @returns {{ toolName: string, toolInput: object }|null}
299
- */
300
- function mapAntigravityToolCall(toolCall) {
301
- if (!toolCall || typeof toolCall !== 'object') return null;
302
- const name = toolCall.name ?? '';
303
- const args = toolCall.args && typeof toolCall.args === 'object' ? toolCall.args : {};
304
- const filePath = args.TargetFile ?? args.targetFile ?? args.file_path ?? args.path;
305
- if (name === 'write_to_file') {
306
- return {
307
- toolName: 'Write',
308
- toolInput: {
309
- file_path: filePath,
310
- content: args.CodeContent ?? args.codeContent ?? args.content ?? '',
311
- },
312
- operation: 'write_to_file',
313
- };
314
- }
315
- if (name === 'replace_file_content') {
316
- return {
317
- toolName: 'Edit',
318
- toolInput: {
319
- file_path: filePath,
320
- old_string: args.TargetContent ?? args.targetContent ?? args.old_string ?? '',
321
- new_string: args.ReplacementContent ?? args.replacementContent ?? args.new_string ?? '',
322
- replace_all: Boolean(args.AllowMultiple ?? args.allowMultiple),
323
- },
324
- operation: 'replace_file_content',
325
- };
326
- }
327
- if (name === 'multi_replace_file_content') {
328
- const chunks = Array.isArray(args.ReplacementChunks)
329
- ? args.ReplacementChunks
330
- : Array.isArray(args.replacementChunks)
331
- ? args.replacementChunks
332
- : [];
333
- return {
334
- toolName: 'MultiEdit',
335
- toolInput: {
336
- file_path: filePath,
337
- edits: chunks.map((chunk) => ({
338
- old_string: chunk?.TargetContent ?? chunk?.targetContent ?? chunk?.old_string ?? '',
339
- new_string:
340
- chunk?.ReplacementContent ?? chunk?.replacementContent ?? chunk?.new_string ?? '',
341
- replace_all: Boolean(chunk?.AllowMultiple ?? chunk?.allowMultiple),
342
- })),
343
- },
344
- operation: 'multi_replace_file_content',
345
- };
346
- }
347
- return {
348
- toolName: name,
349
- toolInput: { ...args, file_path: filePath },
350
- operation: name,
351
- };
303
+ function hookEnforcement(root, host, operation, completePatch = false) {
304
+ return detectWritePathCapabilities(root, host, {
305
+ boundary: 'pre-tool',
306
+ operation,
307
+ completePatch,
308
+ }).enforcementLadder;
352
309
  }
353
310
 
354
- /**
355
- * Normalize agent PreToolUse payloads.
356
- * Claude Code: { tool_name, tool_input: { file_path, content | old_string/new_string } }
357
- * Grok Build: { toolName, toolInput: { file_path, content | old_string/new_string } }
358
- * (aliases Write/Edit/MultiEdit write/search_replace; matcher keeps both)
359
- * Antigravity: { toolCall: { name, args: { TargetFile, CodeContent, … } } }
360
- * Cursor: { tool_name, tool_input, hook_event_name?, workspace_roots? }
361
- * Write uses `contents`; StrReplace maps to Edit (path/old_string/new_string).
362
- * Codex: { tool_name: "apply_patch", tool_input: { command: "*** Begin Patch..." } }
363
- */
364
- function normalizeHookPayload(payload, grokHookEvent = Boolean(process.env.GROK_HOOK_EVENT)) {
365
- const antigravityStyle =
366
- payload != null && typeof payload === 'object' && 'toolCall' in payload;
367
- if (antigravityStyle) {
368
- const mapped = mapAntigravityToolCall(payload.toolCall);
369
- const filePath =
370
- mapped?.toolInput?.file_path ??
371
- mapped?.toolInput?.filePath ??
372
- mapped?.toolInput?.path ??
373
- mapped?.toolInput?.target_file;
374
- return {
375
- toolName: mapped?.toolName ?? '',
376
- toolInput: { ...(mapped?.toolInput ?? {}), file_path: filePath },
377
- grokStyle: true, // decision JSON on stdout (deny)
378
- antigravityStyle: true,
379
- cursorStyle: false,
380
- operation: mapped?.operation ?? mapped?.toolName ?? null,
381
- };
311
+ function extraMergeTeethClassification(root, config) {
312
+ const files = collectGovernedFiles(root, config);
313
+ const layers = config.layers ?? [];
314
+ let classified = 0;
315
+ const populated = new Set();
316
+ for (const abs of files) {
317
+ const layer = layerForFile(root, abs, layers);
318
+ if (layer) {
319
+ classified += 1;
320
+ populated.add(layer);
321
+ }
382
322
  }
383
-
384
- const rawName = payload?.tool_name ?? payload?.toolName ?? '';
385
- const toolInputRaw = payload?.tool_input ?? payload?.toolInput ?? {};
386
- const toolInput =
387
- toolInputRaw && typeof toolInputRaw === 'object' ? { ...toolInputRaw } : {};
388
- // Cursor Write uses `contents`; Claude/Grok use `content`.
389
- if (toolInput.content == null && typeof toolInput.contents === 'string') {
390
- toolInput.content = toolInput.contents;
391
- }
392
- const nameMap = {
393
- Write: 'Write',
394
- write: 'Write',
395
- Edit: 'Edit',
396
- search_replace: 'Edit',
397
- StrReplace: 'Edit',
398
- MultiEdit: 'MultiEdit',
399
- ApplyPatch: 'ApplyPatch',
400
- apply_patch: 'ApplyPatch',
401
- write_to_file: 'Write',
402
- replace_file_content: 'Edit',
403
- multi_replace_file_content: 'MultiEdit',
404
- };
405
- const toolName = nameMap[rawName] ?? rawName;
406
- const filePath =
407
- toolInput.file_path ?? toolInput.filePath ?? toolInput.path ?? toolInput.target_file;
408
- const cursorStyle =
409
- Boolean(process.env.CURSOR_PROJECT_DIR) ||
410
- Boolean(process.env.CURSOR_VERSION) ||
411
- (payload != null &&
412
- typeof payload === 'object' &&
413
- (payload.hook_event_name === 'preToolUse' ||
414
- Array.isArray(payload.workspace_roots) ||
415
- rawName === 'StrReplace' ||
416
- (rawName === 'Write' && typeof toolInputRaw?.contents === 'string')));
417
323
  return {
418
- toolName,
419
- toolInput: { ...toolInput, file_path: filePath },
420
- // Grok-style camelCase (or GROK_HOOK_EVENT) → also emit deny JSON on stdout.
421
- grokStyle:
422
- grokHookEvent ||
423
- (payload != null && typeof payload === 'object' && 'toolName' in payload),
424
- antigravityStyle: false,
425
- cursorStyle,
426
- operation: rawName === 'StrReplace' ? 'StrReplace' : null,
324
+ governedPercent: files.length > 0 ? Math.round((classified / files.length) * 100) : 0,
325
+ populatedLayerCount: populated.size,
427
326
  };
428
327
  }
429
328
 
430
- function applyCodexUpdatePatch(current, lines) {
431
- let source = current.split('\n');
432
- let cursor = 0;
433
- const hunks = [];
434
- let hunk = null;
435
- for (const line of lines) {
436
- if (line.startsWith('@@')) {
437
- if (hunk) hunks.push(hunk);
438
- hunk = { anchor: line.slice(2).trim(), entries: [] };
439
- } else if (/^[ +\-]/.test(line)) {
440
- if (!hunk) return null;
441
- hunk.entries.push(line);
442
- }
443
- }
444
- if (hunk) hunks.push(hunk);
445
- for (const { anchor, entries } of hunks) {
446
- if (anchor) {
447
- const anchorAt = source.findIndex((line, index) => index >= cursor && line === anchor);
448
- if (anchorAt < 0) return null;
449
- cursor = anchorAt + 1;
450
- }
451
- const oldLines = entries.filter((line) => !line.startsWith('+')).map((line) => line.slice(1));
452
- const newLines = entries.filter((line) => !line.startsWith('-')).map((line) => line.slice(1));
453
- let found = -1;
454
- for (let at = cursor; at <= source.length - oldLines.length; at += 1) {
455
- if (oldLines.every((line, index) => source[at + index] === line)) {
456
- found = at;
457
- break;
458
- }
459
- }
460
- if (found < 0) return null;
461
- source.splice(found, oldLines.length, ...newLines);
462
- cursor = found + newLines.length;
463
- }
464
- return source.join('\n');
465
- }
466
-
467
- function codexPatchWrites(patch, root) {
468
- if (typeof patch !== 'string') {
469
- return { writes: [], complete: false };
470
- }
471
- const lines = patch.split('\n');
472
- const begin = lines.indexOf('*** Begin Patch');
473
- const end = lines.indexOf('*** End Patch', begin + 1);
474
- if (begin < 0 || end <= begin) return { writes: [], complete: false };
475
- const writes = [];
476
- const seenPaths = new Set();
477
- let complete = [
478
- ...lines.slice(0, begin),
479
- ...lines.slice(end + 1),
480
- ].every((line) => line.trim() === '');
481
- let sawFileDirective = false;
482
- for (let index = begin + 1; index < end; index += 1) {
483
- const match = lines[index].match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
484
- if (!match) {
485
- if (lines[index].trim() !== '') complete = false;
486
- continue;
487
- }
488
- sawFileDirective = true;
489
- const [, action, relativePath] = match;
490
- const body = [];
491
- for (index += 1; index < end && !lines[index].startsWith('*** '); index += 1) {
492
- body.push(lines[index]);
493
- }
494
- index -= 1;
495
- const filePath = path.resolve(root, relativePath);
496
- const rel = path.relative(root, filePath);
497
- if (
498
- seenPaths.has(filePath) ||
499
- rel.startsWith(`..${path.sep}`) ||
500
- rel === '..' ||
501
- path.isAbsolute(rel)
502
- ) {
503
- complete = false;
504
- continue;
505
- }
506
- seenPaths.add(filePath);
507
- const canonicalRelativePath = rel.split(path.sep).join('/');
508
- if (action === 'Delete') {
509
- if (body.some((line) => line.trim() !== '') || !fs.existsSync(filePath)) {
510
- complete = false;
511
- continue;
512
- }
513
- writes.push({ path: canonicalRelativePath, filePath, delete: true });
514
- continue;
515
- }
516
- let content;
517
- if (action === 'Add') {
518
- if (
519
- body.length === 0 ||
520
- fs.existsSync(filePath) ||
521
- body.some((line) => !line.startsWith('+'))
522
- ) {
523
- complete = false;
524
- continue;
525
- }
526
- content = body.filter((line) => line.startsWith('+')).map((line) => line.slice(1)).join('\n');
527
- if (body.some((line) => line.startsWith('+'))) content += '\n';
528
- } else {
529
- if (
530
- !body.some((line) => line.startsWith('@@')) ||
531
- body.some((line) => !line.startsWith('@@') && !/^[ +\-]/.test(line))
532
- ) {
533
- complete = false;
534
- continue;
535
- }
536
- let current;
537
- try {
538
- current = fs.readFileSync(filePath, 'utf8');
539
- } catch {
540
- complete = false;
541
- continue;
542
- }
543
- content = applyCodexUpdatePatch(current, body);
544
- if (content === null) complete = false;
545
- }
546
- if (typeof content === 'string') {
547
- writes.push({ path: canonicalRelativePath, filePath, content });
548
- }
549
- }
550
- return { writes, complete: complete && sawFileDirective };
551
- }
552
-
553
- function hookEnforcement(root, host, operation, completePatch = false) {
554
- return detectWritePathCapabilities(root, host, {
555
- boundary: 'pre-tool',
556
- operation,
557
- completePatch,
558
- }).enforcementLadder;
329
+ function arkRunSnippetContext({ root, config, filePath, layer, relFile, classification }) {
330
+ const extra = config?.arkRun;
331
+ if (!extra) return { layer, filePath };
332
+ const relative =
333
+ relFile ||
334
+ (typeof filePath === 'string'
335
+ ? path.relative(root, path.resolve(root, filePath)).split(path.sep).join('/')
336
+ : undefined);
337
+ return {
338
+ layer,
339
+ filePath,
340
+ relFile: relative,
341
+ arkRun: extra,
342
+ layers: config.layers ?? [],
343
+ classification: classification ?? extraMergeTeethClassification(root, config),
344
+ };
559
345
  }
560
346
 
561
347
  function designDeltaViolations(delta) {
@@ -571,36 +357,6 @@ function designDeltaViolations(delta) {
571
357
  }));
572
358
  }
573
359
 
574
- /**
575
- * Compute the file content a Write/Edit/MultiEdit is about to produce. Edits are applied
576
- * to the CURRENT on-disk file so the gate judges the real post-edit state, not the edit
577
- * snippet out of context. Replacement uses a function argument so `$&`-style sequences in
578
- * generated code are inserted literally, never interpreted as replacement patterns.
579
- */
580
- function proposedSource(toolName, toolInput) {
581
- if (toolName === 'Write') return toolInput.content ?? toolInput.contents;
582
-
583
- let text = '';
584
- try {
585
- text = fs.readFileSync(toolInput.file_path, 'utf8');
586
- } catch {
587
- // New file created via Edit: fall through with an empty base.
588
- }
589
- const edits = toolName === 'MultiEdit' ? toolInput.edits ?? [] : [toolInput];
590
- for (const edit of edits) {
591
- const from = edit.old_string ?? '';
592
- const to = edit.new_string ?? '';
593
- if (from === '') {
594
- text = to;
595
- } else if (edit.replace_all) {
596
- text = text.split(from).join(to);
597
- } else {
598
- text = text.replace(from, () => to);
599
- }
600
- }
601
- return text;
602
- }
603
-
604
360
  /**
605
361
  * One-shot PreToolUse gate (Claude Code + Grok Build hook contracts): payload on stdin,
606
362
  * exit 2 + violations on stderr to block, exit 0 to allow. Grok also receives a deny
@@ -628,42 +384,6 @@ function processHookOutput() {
628
384
  };
629
385
  }
630
386
 
631
- /** Antigravity PreToolUse requires stdout `decision` on every response (allow included). */
632
- function emitAntigravityAllow(output, antigravityStyle) {
633
- if (!antigravityStyle) return;
634
- output.stdout(`${JSON.stringify({ decision: 'allow' })}\n`);
635
- }
636
-
637
- /** Cursor preToolUse accepts explicit allow; exit 0 alone also works. */
638
- function emitCursorAllow(output, cursorStyle) {
639
- if (!cursorStyle) return;
640
- output.stdout(`${JSON.stringify({ permission: 'allow' })}\n`);
641
- }
642
-
643
- function emitHostAllow(output, { antigravityStyle, cursorStyle }) {
644
- emitAntigravityAllow(output, antigravityStyle);
645
- emitCursorAllow(output, cursorStyle);
646
- }
647
-
648
- /**
649
- * Socket-style write-gate deny: two lines first. Pass/fail, no score.
650
- * Rule id stays on a following line, not the first sentence.
651
- */
652
- function formatWriteGateDeny({ file, reason, ruleId, nextAction, extraLines = [] }) {
653
- const target = file || 'this write';
654
- const why = String(reason || 'this change breaks the architecture layers').replace(/\s+/g, ' ').trim();
655
- const next =
656
- nextAction && /place|move|import|port/i.test(nextAction)
657
- ? nextAction
658
- : 'Move the import or run /ark-place. Do not weaken ark.config.json.';
659
- const lines = [`blocked ${target} — ${why}`, `Next: ${next}`];
660
- if (ruleId) lines.push(`[${ruleId}]`);
661
- for (const extra of extraLines) {
662
- if (extra) lines.push(extra);
663
- }
664
- return lines.join('\n');
665
- }
666
-
667
387
  function runHookPayload(payload, gate, config, args, ts, attemptContext, output = processHookOutput()) {
668
388
  const { toolName, toolInput, grokStyle, antigravityStyle, cursorStyle, operation } =
669
389
  normalizeHookPayload(
@@ -881,7 +601,18 @@ function runHookPayload(payload, gate, config, args, ts, attemptContext, output
881
601
 
882
602
  const layer = inferLayer(filePath, config, args.root);
883
603
  const validateOnce = (src) =>
884
- validateSnippetAnalysis({ gate, ts, source: src, context: { layer, filePath } });
604
+ validateSnippetAnalysis({
605
+ gate,
606
+ ts,
607
+ source: src,
608
+ context: arkRunSnippetContext({
609
+ root: args.root,
610
+ config,
611
+ filePath,
612
+ layer,
613
+ relFile: normalizedRel,
614
+ }),
615
+ });
885
616
  // W1: one validation pass (+ optional autoPatch). Original write still blocked when
886
617
  // invalid; hosts must apply autoPatch explicitly (never silent write).
887
618
  const result = ts
@@ -2452,7 +2183,17 @@ export async function runArkMcp({ hookInput } = {}) {
2452
2183
  const filePath = params.arguments.filePath;
2453
2184
  const layer = params.arguments.layer ?? inferLayer(filePath, config, args.root);
2454
2185
  const validateOnce = (src) =>
2455
- validateSnippetAnalysis({ gate, ts, source: src, context: { layer, filePath } });
2186
+ validateSnippetAnalysis({
2187
+ gate,
2188
+ ts,
2189
+ source: src,
2190
+ context: arkRunSnippetContext({
2191
+ root: args.root,
2192
+ config,
2193
+ filePath,
2194
+ layer,
2195
+ }),
2196
+ });
2456
2197
  // W1: attempt mechanical-safe single-file autoPatch (import type), re-validate or discard.
2457
2198
  const result = validateWithAutoPatch({
2458
2199
  source,
@@ -2745,7 +2486,12 @@ export async function runArkMcp({ hookInput } = {}) {
2745
2486
  gate,
2746
2487
  ts,
2747
2488
  source: src,
2748
- context: { layer, filePath: placement.filePath },
2489
+ context: arkRunSnippetContext({
2490
+ root: args.root,
2491
+ config,
2492
+ filePath: placement.filePath,
2493
+ layer,
2494
+ }),
2749
2495
  });
2750
2496
  const result = composePrepareWrite({
2751
2497
  source,
@@ -11,6 +11,18 @@ import {
11
11
  looksLikeArkIntent,
12
12
  resolveIntentLayer as resolveConfiguredIntentLayer,
13
13
  } from './lib/source-policy.mjs';
14
+ import {
15
+ presentLockfiles,
16
+ detectPackageManager,
17
+ execRunner,
18
+ arkCommand,
19
+ execCommandParts,
20
+ isPnpmWorkspaceRoot,
21
+ isNpmYarnWorkspaceRoot,
22
+ normalizeArkgateInstallSpec,
23
+ packageInstallArgv,
24
+ installDevHint,
25
+ } from './lib/package-manager.mjs';
14
26
 
15
27
  /**
16
28
  * Default intent-prefix map shared by both CLIs and the ark-mcp write-path gate. The rule
@@ -575,152 +587,18 @@ export function typescriptUsabilityHint(mod) {
575
587
  return 'unknown shape incompatibility';
576
588
  }
577
589
 
578
- /** The three package managers Ark emits commands for. */
579
- const LOCKFILES = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', npm: 'package-lock.json' };
580
-
581
- /**
582
- * The Corepack `packageManager` field (and the newer `devEngines.packageManager`) is the
583
- * project's OWN authoritative statement of its package manager. When present it wins over any
584
- * lockfile guess. Returns 'pnpm' | 'yarn' | 'npm' | undefined.
585
- */
586
- function declaredPackageManager(root) {
587
- let pkg;
588
- try {
589
- pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
590
- } catch {
591
- return undefined;
592
- }
593
- const raw =
594
- (typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : undefined) ??
595
- (typeof pkg.devEngines?.packageManager?.name === 'string'
596
- ? pkg.devEngines.packageManager.name
597
- : undefined);
598
- const name = raw?.trim().toLowerCase();
599
- return name === 'pnpm' || name === 'yarn' || name === 'npm' ? name : undefined;
600
- }
601
-
602
- /** Lockfiles present in the project root, in { pnpm, yarn, npm } key order. */
603
- export function presentLockfiles(root) {
604
- return Object.entries(LOCKFILES)
605
- .filter(([, file]) => fs.existsSync(path.join(root, file)))
606
- .map(([pm]) => pm);
607
- }
608
-
609
- /**
610
- * Detect the project's package manager: 'pnpm' | 'yarn' | 'npm'.
611
- *
612
- * Priority: (1) the `packageManager` / `devEngines` field (the project's own declaration);
613
- * (2) a single lockfile; (3) on CONFLICT (more than one lockfile and no declaration) prefer
614
- * npm whenever a package-lock.json is present. Rationale: `npx` runs fine inside a pnpm/yarn
615
- * repo, but `pnpm exec` / `yarn` in an npm repo BREAKS (frozen-lockfile / no-TTY / a spurious
616
- * pnpm-lock). So a stray pnpm-lock.yaml left in an npm project must NOT hijack it into pnpm —
617
- * package-lock.json wins the tie, and the field is the escape hatch for a genuine pnpm repo
618
- * that still carries a package-lock.json. Falls back to npm when nothing is detectable.
619
- */
620
- export function detectPackageManager(root) {
621
- const declared = declaredPackageManager(root);
622
- if (declared) return declared;
623
- const locks = presentLockfiles(root);
624
- if (locks.length <= 1) return locks[0] ?? 'npm';
625
- if (locks.includes('npm')) return 'npm';
626
- return locks[0]; // pnpm over yarn when only those two collide
627
- }
628
-
629
- // pnpm 10+ `pnpm exec` runs a deps-status pre-check that fails with ERR_PNPM_IGNORED_BUILDS
630
- // when the repo has un-approved native build scripts (sharp, esbuild, tailwind oxide, …) —
631
- // the common state of real pnpm apps. Skip that gate so Ark's emitted commands still run.
632
- const PNPM_EXEC = 'pnpm --config.verify-deps-before-run=false exec';
633
- const RUNNER_BY_PM = { pnpm: PNPM_EXEC, yarn: 'yarn', npm: 'npx' };
634
-
635
- /**
636
- * The command prefix that runs an INSTALLED package binary, matched to the project's
637
- * package manager. `npx` is used for npm and as the safe fallback.
638
- *
639
- * This is the single source of truth that makes every command Ark EMITS — the AGENTS.md
640
- * contract, .mcp.json, the Claude/Codex hooks, the check:architecture script, the
641
- * SessionStart summary and every console hint — respect a pnpm-only or yarn repo instead
642
- * of hardcoding `npx`. (A "pnpm only, never npx" repo treats an emitted `npx` as a policy
643
- * violation.) `packageManager()` in ark-check.mjs builds the CI-workflow variant on the
644
- * same detection.
645
- */
646
- export function execRunner(root) {
647
- return RUNNER_BY_PM[detectPackageManager(root)];
648
- }
649
-
650
- /** Full runnable command string for an installed Ark binary, package-manager aware. */
651
- export function arkCommand(root, bin, argsStr = '') {
652
- return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
653
- }
654
-
655
- /**
656
- * Split { command, args } form for JSON/TOML configs (.mcp.json, config.toml) that spawn
657
- * the binary directly. `pnpm exec ark-mcp` becomes command "pnpm" + args ["exec","ark-mcp",…]
658
- * so the runner is a real argv[0], not a space-joined string a shell would mis-split.
659
- */
660
- export function execCommandParts(root, bin, binArgs = []) {
661
- const runner = execRunner(root);
662
- if (runner === PNPM_EXEC || runner.startsWith('pnpm ')) {
663
- return {
664
- command: 'pnpm',
665
- args: ['--config.verify-deps-before-run=false', 'exec', bin, ...binArgs],
666
- };
667
- }
668
- if (runner === 'yarn') return { command: 'yarn', args: [bin, ...binArgs] };
669
- return { command: 'npx', args: [bin, ...binArgs] };
670
- }
671
-
672
- /**
673
- * True when this directory is a pnpm workspace root (needs `pnpm add -w` for root deps).
674
- * Nested packages under the workspace are not roots.
675
- */
676
- export function isPnpmWorkspaceRoot(root) {
677
- return fs.existsSync(path.join(root, 'pnpm-workspace.yaml'));
678
- }
679
-
680
- /**
681
- * True when package.json declares npm/yarn workspaces (yarn classic needs `-W` at root).
682
- */
683
- export function isNpmYarnWorkspaceRoot(root) {
684
- const pkg = readPackageJson(root);
685
- if (!pkg) return false;
686
- const ws = pkg.workspaces;
687
- return Array.isArray(ws) || (ws && typeof ws === 'object' && Array.isArray(ws.packages));
688
- }
689
-
690
- /**
691
- * Normalize a version/range/spec into an installable package argument for arkgate.
692
- * Accepts `latest`, `^3.8.2`, `arkgate@latest`, or a full package name.
693
- */
694
- export function normalizeArkgateInstallSpec(versionSpec) {
695
- const raw = typeof versionSpec === 'string' && versionSpec.trim() ? versionSpec.trim() : 'latest';
696
- if (raw.startsWith('arkgate@') || raw === 'arkgate') return raw === 'arkgate' ? 'arkgate@latest' : raw;
697
- if (raw.includes('/') || raw.startsWith('file:') || raw.startsWith('link:')) return raw;
698
- return `arkgate@${raw}`;
699
- }
700
-
701
- /**
702
- * Package-manager argv to add a dev dependency (e.g. arkgate@latest).
703
- * pnpm workspace roots get `-w`; yarn classic workspaces get `-W`.
704
- *
705
- * @param {string} root
706
- * @param {string} [versionSpec] package name or name@version (default arkgate@latest)
707
- * @returns {[string, string[]]}
708
- */
709
- export function packageInstallArgv(root, versionSpec = 'latest') {
710
- const pkgSpec = normalizeArkgateInstallSpec(versionSpec);
711
- const pm = detectPackageManager(root);
712
- if (pm === 'pnpm') {
713
- const args = ['add', '-D', pkgSpec];
714
- if (isPnpmWorkspaceRoot(root)) args.push('-w');
715
- return ['pnpm', args];
716
- }
717
- if (pm === 'yarn') {
718
- const args = ['add', '-D', pkgSpec];
719
- if (isNpmYarnWorkspaceRoot(root)) args.push('-W');
720
- return ['yarn', args];
721
- }
722
- return ['npm', ['install', '-D', pkgSpec]];
723
- }
590
+ export {
591
+ presentLockfiles,
592
+ detectPackageManager,
593
+ execRunner,
594
+ arkCommand,
595
+ execCommandParts,
596
+ isPnpmWorkspaceRoot,
597
+ isNpmYarnWorkspaceRoot,
598
+ normalizeArkgateInstallSpec,
599
+ packageInstallArgv,
600
+ installDevHint,
601
+ };
724
602
 
725
603
  // FX01–FX02: registry-aware skip lives in upgrade-package-decision (injectable probe).
726
604
  export {
@@ -731,18 +609,6 @@ export {
731
609
  probeRegistryArkgateLatest,
732
610
  } from './lib/upgrade-package-decision.mjs';
733
611
 
734
- /** Package-manager aware "install a dev dependency" hint (e.g. for a missing typescript). */
735
- export function installDevHint(root, pkg) {
736
- const pm = detectPackageManager(root);
737
- if (pm === 'pnpm') {
738
- return isPnpmWorkspaceRoot(root) ? `pnpm add -D ${pkg} -w` : `pnpm add -D ${pkg}`;
739
- }
740
- if (pm === 'yarn') {
741
- return isNpmYarnWorkspaceRoot(root) ? `yarn add -D ${pkg} -W` : `yarn add -D ${pkg}`;
742
- }
743
- return `npm install -D ${pkg}`;
744
- }
745
-
746
612
  export const ARCHETYPE_IDS = [
747
613
  'crud-product',
748
614
  'api-backend',