arkgate 3.0.4 → 3.1.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 (42) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +29 -9
  3. package/bin/ark-check.mjs +69 -54
  4. package/bin/ark-mcp.mjs +267 -26
  5. package/bin/ark.mjs +50 -3
  6. package/bin/lib/adapter-contract.mjs +27 -1
  7. package/bin/lib/agent-gates.mjs +9 -0
  8. package/bin/lib/analysis-engine.mjs +7 -1169
  9. package/bin/lib/ci-and-commands.mjs +4 -0
  10. package/bin/lib/codex-home.mjs +10 -1
  11. package/bin/lib/doctor-plan.mjs +37 -9
  12. package/bin/lib/host-support-matrix.mjs +6 -2
  13. package/bin/lib/install-migrate.mjs +81 -25
  14. package/bin/lib/mcp-adoption.mjs +8 -0
  15. package/bin/lib/policy-delta-io.mjs +161 -0
  16. package/bin/lib/prepare-change.mjs +186 -0
  17. package/bin/lib/remediation.mjs +24 -0
  18. package/bin/lib/skill-install.mjs +302 -22
  19. package/bin/lib/violations.mjs +2 -2
  20. package/bin/lib/weakest-link.mjs +61 -12
  21. package/bin/lib/write-path-capabilities.mjs +70 -2
  22. package/bin/lib/write-path-detect.mjs +18 -11
  23. package/dist/eslint/index.cjs +3 -977
  24. package/dist/eslint/index.js +3 -931
  25. package/dist/index.cjs +6 -1960
  26. package/dist/index.d.cts +152 -5
  27. package/dist/index.d.ts +152 -5
  28. package/dist/index.js +6 -1908
  29. package/docs/agent-guide.md +16 -2
  30. package/docs/ai-gates.md +35 -3
  31. package/docs/configuration.md +44 -0
  32. package/docs/package-surface.md +8 -1
  33. package/docs/threat-model.md +7 -4
  34. package/package.json +6 -5
  35. package/schemas/ark.analysis-result.schema.json +5 -1
  36. package/schemas/ark.change-map.schema.json +77 -0
  37. package/server.json +2 -2
  38. package/templates/skills/ark-upgrade.md +9 -5
  39. package/docs/ark-check-example.json +0 -87
  40. package/docs/demos/03-copilot-autopilot.md +0 -93
  41. package/docs/migrate-from-ark-runtime-kernel.md +0 -174
  42. package/docs/production-hardening.md +0 -100
@@ -417,6 +417,8 @@ jobs:
417
417
  steps:
418
418
  - name: Checkout
419
419
  uses: actions/checkout@v4
420
+ with:
421
+ fetch-depth: 0
420
422
  ${setupSteps ? `${setupSteps}\n` : ''} - name: Setup Node
421
423
  uses: actions/setup-node@v4
422
424
  with:
@@ -425,6 +427,8 @@ ${nodeSetup}
425
427
  - name: Install dependencies
426
428
  run: ${pm.install}
427
429
  ${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
430
+ env:
431
+ ARK_POLICY_BASE_REF: \${{ github.event.pull_request.base.sha || github.event.before }}
428
432
  run: ${pm.run}
429
433
  `;
430
434
  }
@@ -10,12 +10,21 @@ import { execCommandParts } from '../ark-shared.mjs';
10
10
 
11
11
  export const PREFERRED_CODEX_MCP_BIN = 'arkgate-mcp';
12
12
 
13
- /** Where Codex loads slash-command prompts ($CODEX_HOME/prompts). */
13
+ /** Where Codex loads slash-command prompts ($CODEX_HOME/prompts) — legacy, not the skill catalog. */
14
14
  export function codexPromptsDir() {
15
15
  const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
16
16
  return path.join(base, 'prompts');
17
17
  }
18
18
 
19
+ /**
20
+ * Where Codex loads user/home SKILL.md skills ($CODEX_HOME/skills/<name>/SKILL.md).
21
+ * Repo-scoped skills live at `.agents/skills/<name>/SKILL.md` (Agent Skills standard).
22
+ */
23
+ export function codexSkillsDir() {
24
+ const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
25
+ return path.join(base, 'skills');
26
+ }
27
+
19
28
  /** Where Codex loads MCP servers ($CODEX_HOME/config.toml) — global, not project-local. */
20
29
  export function codexConfigPath() {
21
30
  const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
@@ -14,6 +14,8 @@ import {
14
14
  import {
15
15
  collectAdoptionGaps,
16
16
  detectSkillGaps,
17
+ detectCodexHomeGap,
18
+ codexConcernIsActive,
17
19
  detectWritePathCapabilities,
18
20
  missingGates,
19
21
  staleRunnerGateFiles,
@@ -405,8 +407,6 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
405
407
  ? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
406
408
  : 0;
407
409
  const activeCount = violations.length - suppressed;
408
- const missingSkills = skillGaps.reduce((sum, gap) => sum + gap.missing, 0);
409
- const staleSkills = skillGaps.reduce((sum, gap) => sum + gap.stale, 0);
410
410
  const designSmells = detectDesignSmells(root, config, files, cov);
411
411
  const designFitness = summarizeDesignFitness(designSmells, {
412
412
  activeViolations: activeCount,
@@ -491,6 +491,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
491
491
  capabilities: writePath.capabilities,
492
492
  capabilityEvidence: writePath.capabilityEvidence,
493
493
  inventory: writePath.inventory,
494
+ enforcementLadder: writePath.enforcementLadder,
494
495
  mode: writePath.mode,
495
496
  prepareWrite: writePath.prepareWrite,
496
497
  autoPatch: writePath.autoPatch,
@@ -738,17 +739,19 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
738
739
  line(' ', `Active host: ${writePath.activeHost}`);
739
740
  line(' ', `Supported profile: ${writePath.supportSummary}`);
740
741
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
742
+ const ladder = writePath.enforcementLadder;
743
+ const state = (value) => value === true ? 'yes' : value === false ? 'no' : String(value);
741
744
  line(
742
- capabilities['hard-write'] ? ok : warn,
743
- `Hard write boundary: ${capabilities['hard-write'] ? 'yes' : 'no'}`
745
+ ladder.localWrite.installed ? ok : warn,
746
+ `Hard hook — supported: ${state(ladder.localWrite.supported)} · installed: ${state(ladder.localWrite.installed)} · active/trusted: ${state(ladder.localWrite.active)} · bypassable: ${state(ladder.localWrite.bypassable)}`
744
747
  );
745
748
  line(
746
749
  warn,
747
- `Advisory write tools (MCP): ${capabilities['advisory-write'] ? 'yes' : 'no'}`
750
+ `Advisory MCP supported: ${state(ladder.advisoryMcp.supported)} · installed: ${state(ladder.advisoryMcp.installed)} · active: ${state(ladder.advisoryMcp.active)} · bypassable: ${state(ladder.advisoryMcp.bypassable)}`
748
751
  );
749
752
  line(
750
753
  capabilities['merge-gate'] ? ok : bad,
751
- `CI check (--strict-merge): ${capabilities['merge-gate'] ? 'yes' : 'no'} (merge blocking requires a required status)`
754
+ `Merge gate — supported: ${state(ladder.ciMerge.supported)} · installed: ${state(ladder.ciMerge.installed)} · active: ${state(ladder.ciMerge.active)} · bypassable: ${state(ladder.ciMerge.bypassable)} · required status: ${state(ladder.ciMerge.requiredStatus)}`
752
755
  );
753
756
  line(
754
757
  capabilities['repair-payload'] ? ok : warn,
@@ -769,11 +772,36 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
769
772
  line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
770
773
  actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
771
774
  }
772
- if (missingSkills + staleSkills === 0) line(ok, '/ark-* skills current for detected tools');
773
- else {
774
- line(warn, `${missingSkills} missing / ${staleSkills} outdated /ark-* skill(s) for ${skillGaps.map((g) => g.tool).join(', ')}`);
775
+ // Report Codex legacy prompts and other-host missing/stale independently (never exclusive).
776
+ const legacyCodex = skillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
777
+ const remainingGaps = skillGaps.filter((g) => !(g.tool === 'codex' && g.legacyPromptsOnly));
778
+ const remMiss = remainingGaps.reduce((s, g) => s + g.missing, 0);
779
+ const remStale = remainingGaps.reduce((s, g) => s + g.stale, 0);
780
+ if (remMiss + remStale === 0 && !legacyCodex) line(ok, '/ark-* skills current for detected tools');
781
+ if (legacyCodex) {
782
+ line(warn, 'Codex: legacy flat .codex/prompts only (not a loadable skill catalog)');
783
+ actions.push('install Codex SKILL.md catalog (--install-agent-gates --skills-only --tools codex --force)');
784
+ }
785
+ if (remMiss + remStale > 0) {
786
+ line(warn, `${remMiss} missing / ${remStale} outdated /ark-* skill(s) for ${remainingGaps.map((g) => g.tool).join(', ')}`);
775
787
  actions.push('refresh /ark-* skills (--install-agent-gates --skills-only --force)');
776
788
  }
789
+ const codexHomeGap = detectCodexHomeGap(root);
790
+ if (codexHomeGap) {
791
+ const parts = [
792
+ codexHomeGap.legacyPromptsOnly ? 'legacy-prompts-only' : null,
793
+ codexHomeGap.missing > 0 ? `${codexHomeGap.missing} missing` : null,
794
+ codexHomeGap.stale > 0 ? `${codexHomeGap.stale} outdated` : null,
795
+ ].filter(Boolean);
796
+ const deferred = !codexConcernIsActive();
797
+ // Deferred home debt is dim/info (not warn) so non-Codex sessions are not "incomplete".
798
+ if (deferred) {
799
+ line(color.dim('·'), color.dim(`Codex home skills ${parts.join(', ')} (deferred — not on Codex session)`));
800
+ } else {
801
+ line(warn, `Codex home skills ${parts.join(', ')}`);
802
+ actions.push('refresh Codex home skills (--install-agent-gates --skills-only --codex-home --force)');
803
+ }
804
+ }
777
805
 
778
806
  console.log('');
779
807
  console.log(color.bold('Baseline'));
@@ -6,11 +6,12 @@
6
6
  * reported separately by write-path-capabilities.mjs.
7
7
  */
8
8
 
9
- function hostProfile(label, hookPath, hookSurface, hardWrite, repairPayload) {
9
+ function hostProfile(label, hookPath, hookSurface, hookOperations, hardWrite, repairPayload) {
10
10
  return Object.freeze({
11
11
  label,
12
12
  hookPath,
13
13
  hookSurface,
14
+ hookOperations: Object.freeze(hookOperations),
14
15
  capabilities: Object.freeze({
15
16
  'hard-write': hardWrite,
16
17
  'advisory-write': true,
@@ -25,6 +26,7 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
25
26
  'Claude Code',
26
27
  '.claude/settings.json',
27
28
  'PreToolUse `Write` / `Edit` / `MultiEdit`',
29
+ ['Write', 'Edit', 'MultiEdit'],
28
30
  true,
29
31
  true
30
32
  ),
@@ -32,14 +34,16 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
32
34
  'Grok Build',
33
35
  '.grok/hooks/ark-write-gate.json',
34
36
  'PreToolUse `write` / `search_replace` (plus aliases)',
37
+ ['write', 'search_replace'],
35
38
  true,
36
39
  true
37
40
  ),
38
- cursor: hostProfile('Cursor', null, null, false, false),
41
+ cursor: hostProfile('Cursor', null, null, [], false, false),
39
42
  codex: hostProfile(
40
43
  'OpenAI Codex',
41
44
  '.codex/hooks.json',
42
45
  'Best-effort PreToolUse `apply_patch`; Code Mode hosts may bypass the event',
46
+ ['apply_patch'],
43
47
  false,
44
48
  false
45
49
  ),
@@ -12,6 +12,7 @@ import {
12
12
  } from '../ark-shared.mjs';
13
13
  import {
14
14
  codexPromptsDir,
15
+ codexSkillsDir,
15
16
  codexConfigPath,
16
17
  isTempOrUpgradeRoot,
17
18
  usesDefaultCodexHome,
@@ -52,6 +53,7 @@ import {
52
53
  isVersionOlder,
53
54
  detectSkillGaps,
54
55
  arkPackageVersion,
56
+ verifyHostSkillCatalog,
55
57
  } from './skill-install.mjs';
56
58
  import { detectDeployPathQuality } from './deploy-path.mjs';
57
59
  import {
@@ -383,15 +385,15 @@ export function runInstallAgentGates(args) {
383
385
  console.log(` ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`);
384
386
  }
385
387
 
386
- // --codex-home writes the canonical skills straight to $CODEX_HOME/prompts.
387
- // Codex reads prompts from there (not the repo), so this is the only way to
388
- // refresh them for a repo that isn't itself configured for Codex. It writes to
389
- // the user's home dir, hence explicit opt-in rather than part of a normal run.
388
+ // --codex-home writes SKILL.md skills to $CODEX_HOME/skills/<name>/SKILL.md.
389
+ // Codex's real catalog loads skill directories (not flat $CODEX_HOME/prompts).
390
+ // Repo installs already write `.agents/skills/<name>/SKILL.md` when `codex` is
391
+ // selected; home install is for multi-project / non-repo-local refresh.
390
392
  const homeResults = [];
391
393
  if (args.codexHome) {
392
- const dir = codexPromptsDir();
394
+ const dir = codexSkillsDir();
393
395
  console.log('');
394
- console.log(`Codex home skills (${dir}):`);
396
+ console.log(`Codex home skills (${dir}/<name>/SKILL.md):`);
395
397
  try {
396
398
  fs.mkdirSync(dir, { recursive: true });
397
399
  } catch (error) {
@@ -400,23 +402,25 @@ export function runInstallAgentGates(args) {
400
402
  }
401
403
  if (homeResults.length === 0) {
402
404
  for (const [name, content] of skills) {
403
- const file = path.join(dir, `${name}.md`);
405
+ const skillDir = path.join(dir, name);
406
+ const file = path.join(skillDir, 'SKILL.md');
404
407
  if (fs.existsSync(file) && !args.force) {
405
408
  const installed = installedSkillVersion(file);
406
409
  const behind = installed === null || (version && isVersionOlder(installed, version));
407
410
  const note = behind
408
411
  ? ` (stale: ${installed ?? 'no stamp'} < ${version}; use --force)`
409
412
  : ' (up to date)';
410
- console.log(` ${'skipped'.padEnd(7)} ${name}.md${note}`);
413
+ console.log(` ${'skipped'.padEnd(7)} ${name}/SKILL.md${note}`);
411
414
  homeResults.push({ status: 'skipped' });
412
415
  continue;
413
416
  }
414
417
  try {
418
+ fs.mkdirSync(skillDir, { recursive: true });
415
419
  fs.writeFileSync(file, content);
416
- console.log(` ${'wrote'.padEnd(7)} ${name}.md`);
420
+ console.log(` ${'wrote'.padEnd(7)} ${name}/SKILL.md`);
417
421
  homeResults.push({ status: 'written' });
418
422
  } catch (error) {
419
- console.log(` ${'FAILED'.padEnd(7)} ${name}.md (${error.message})`);
423
+ console.log(` ${'FAILED'.padEnd(7)} ${name}/SKILL.md (${error.message})`);
420
424
  homeResults.push({ status: 'failed' });
421
425
  }
422
426
  }
@@ -429,18 +433,15 @@ export function runInstallAgentGates(args) {
429
433
  // home-dir merge instead. Fires whenever Codex is in play so `ark://manifest` is live
430
434
  // without a manual copy step.
431
435
  //
432
- // Skip home-dir mutation when the project root is a temp/upgrade scratch *and*
433
- // CODEX_HOME resolves to the default (~/.codex). Codex itself may export that exact
434
- // path, so presence alone does not prove isolation. Fixtures must not rewrite the
435
- // developer's real config. A genuinely redirected CODEX_HOME or explicit
436
- // --codex-home still wires as requested.
436
+ // Skip home MCP mutation when the project root is a temp/upgrade scratch *and*
437
+ // CODEX_HOME is the default (~/.codex). Fixtures and agent smokes must not rewrite
438
+ // the developer's real config.toml with a temp --root. --codex-home still refreshes
439
+ // home *skills* below; MCP binding of a temp root into default home is never safe.
440
+ // A redirected CODEX_HOME (tests/isolation) may still wire as requested.
437
441
  let codexMcp = null;
438
442
  const wantCodexWire = !args.compact && (tools.has('codex') || args.codexHome);
439
443
  const skipHomeWire =
440
- wantCodexWire &&
441
- isTempOrUpgradeRoot(root) &&
442
- !args.codexHome &&
443
- usesDefaultCodexHome();
444
+ wantCodexWire && isTempOrUpgradeRoot(root) && usesDefaultCodexHome();
444
445
  if (wantCodexWire && !skipHomeWire) {
445
446
  codexMcp = wireCodexMcp(root, args.force);
446
447
  console.log('');
@@ -498,13 +499,68 @@ export function runInstallAgentGates(args) {
498
499
  if (codexMcp && codexMcp.status !== 'failed') {
499
500
  console.log(` Codex: ark MCP registered in ${codexMcp.file} — restart Codex so \`ark://manifest\` loads.`);
500
501
  }
502
+ if (tools.has('codex') && !args.compact) {
503
+ console.log(' Codex write path (honest):');
504
+ console.log(' - Local: advisory MCP + best-effort .codex/hooks.json (not a hard boundary).');
505
+ console.log(' - Hard merge backstop: CI --strict-merge + required status check.');
506
+ console.log(' - Not equivalent to Claude/Grok PreToolUse hard-write + repair.');
507
+ }
501
508
  if (args.codexHome) {
502
- console.log(` Codex: refreshed the /ark-* skills in ${codexPromptsDir()} — Codex loads them from there.`);
503
- } else if (skills.length > 0) {
504
- console.log(' Codex loads slash-command prompts from $CODEX_HOME/prompts (~/.codex/prompts),');
505
- console.log(' not the repo. Install the /ark-* skills there with:');
506
- console.log(` ${arkCommand(root, 'ark-check', '--install-agent-gates --codex-home')}`);
507
- console.log(' (writes to your home dir; agents driving this setup should offer to run it).');
509
+ console.log(
510
+ ` Codex: refreshed home skills under ${codexSkillsDir()}/<name>/SKILL.md (Codex skill catalog).`
511
+ );
512
+ } else if (tools.has('codex') && skills.length > 0 && !args.compact) {
513
+ console.log(
514
+ ' Codex: project skills at `.agents/skills/<name>/SKILL.md` (Agent Skills REPO scope).'
515
+ );
516
+ console.log(
517
+ ` Optional multi-project home copy: ${arkCommand(root, 'ark-check', '--install-agent-gates --codex-home')}`
518
+ );
519
+ console.log(
520
+ ` (writes $CODEX_HOME/skills; legacy flat prompts at ${codexPromptsDir()} are not the skill catalog).`
521
+ );
522
+ // Best-effort note when dead prompts still sit under the repo.
523
+ try {
524
+ const promptDir = path.join(root, '.codex', 'prompts');
525
+ if (fs.existsSync(promptDir)) {
526
+ const legacy = fs.readdirSync(promptDir).filter((n) => /^ark-[a-z0-9-]+\.md$/.test(n));
527
+ if (legacy.length > 0) {
528
+ console.log(
529
+ ` Note: ignoring ${legacy.length} legacy .codex/prompts/ark-*.md file(s) — not loadable as Codex skills.`
530
+ );
531
+ }
532
+ }
533
+ } catch {
534
+ // ignore
535
+ }
536
+ }
537
+ }
538
+
539
+ // Post-install: AGENTS.md /ark-* refs must exist in each selected host catalog.
540
+ // Compact routers intentionally omit those refs (package/MCP is the router).
541
+ if (!args.compact && skills.length > 0) {
542
+ const catalog = verifyHostSkillCatalog(root, tools, {
543
+ skillNames: skills.map(([name]) => name),
544
+ });
545
+ if (!catalog.ok) {
546
+ console.log('');
547
+ console.error(
548
+ `Skill catalog verification failed: ${catalog.missing.length} AGENTS.md /ark-* reference(s) missing from host catalogs.`
549
+ );
550
+ for (const miss of catalog.missing.slice(0, 12)) {
551
+ console.error(` missing ${miss.tool}: ${miss.path}`);
552
+ }
553
+ if (catalog.missing.length > 12) {
554
+ console.error(` …and ${catalog.missing.length - 12} more`);
555
+ }
556
+ process.exitCode = 1;
557
+ return;
558
+ }
559
+ if (catalog.checkedTools.length > 0 && catalog.referenced.length > 0) {
560
+ console.log('');
561
+ console.log(
562
+ `Skill catalog verified: ${catalog.referenced.length} AGENTS.md /ark-* skill(s) present for ${catalog.checkedTools.join(', ')}.`
563
+ );
508
564
  }
509
565
  }
510
566
  warnLockfileConflict(root);
@@ -143,6 +143,14 @@ export function collectAdoptionGaps(root, config, coverage) {
143
143
  extras: [['.cursor/mcp.json', 'MCP config']],
144
144
  toolsFlag: 'cursor',
145
145
  },
146
+ {
147
+ host: 'codex',
148
+ dir: '.codex',
149
+ // Official Codex REPO skill catalog (Agent Skills standard) — not .codex/prompts.
150
+ skill: (n) => path.join(root, '.agents', 'skills', n, 'SKILL.md'),
151
+ extras: [['.codex/hooks.json', 'hooks']],
152
+ toolsFlag: 'codex',
153
+ },
146
154
  ];
147
155
  for (const h of hostChecks) {
148
156
  if (!fs.existsSync(path.join(root, h.dir))) continue;
@@ -0,0 +1,161 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { analyzePolicyDelta } from './analysis-engine.mjs';
5
+
6
+ function readJsonFile(filePath, label) {
7
+ if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
8
+ try {
9
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
10
+ } catch (error) {
11
+ throw new Error(
12
+ `${label} is not valid JSON (${filePath}): ${error instanceof Error ? error.message : String(error)}`
13
+ );
14
+ }
15
+ }
16
+
17
+ function runGit(cwd, args) {
18
+ return spawnSync('git', ['-C', cwd, ...args], {
19
+ encoding: 'utf8',
20
+ stdio: ['ignore', 'pipe', 'pipe'],
21
+ });
22
+ }
23
+
24
+ function safeRef(value) {
25
+ return (
26
+ typeof value === 'string' &&
27
+ /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(value) &&
28
+ !value.includes('..')
29
+ );
30
+ }
31
+
32
+ export function normalizePolicyBaseRef(value) {
33
+ const ref = typeof value === 'string' ? value.trim() : '';
34
+ return /^0{40,64}$/.test(ref) ? '' : ref;
35
+ }
36
+
37
+ function repositoryRoot(root) {
38
+ const result = runGit(root, ['rev-parse', '--show-toplevel']);
39
+ return result.status === 0 ? result.stdout.trim() : null;
40
+ }
41
+
42
+ function discoverLocalBaseRef(root) {
43
+ const top = repositoryRoot(root);
44
+ if (!top) return null;
45
+ const remoteHead = runGit(top, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
46
+ const candidates = [
47
+ remoteHead.status === 0 ? remoteHead.stdout.trim() : null,
48
+ 'origin/main',
49
+ 'origin/master',
50
+ ].filter(Boolean);
51
+ const current = runGit(top, ['branch', '--show-current']);
52
+ const currentBranch = current.status === 0 ? current.stdout.trim() : '';
53
+
54
+ for (const candidate of candidates) {
55
+ if (!safeRef(candidate)) continue;
56
+ const exists = runGit(top, ['rev-parse', '--verify', `${candidate}^{commit}`]);
57
+ if (exists.status !== 0) continue;
58
+ if (currentBranch && candidate === `origin/${currentBranch}`) return null;
59
+ const mergeBase = runGit(top, ['merge-base', 'HEAD', candidate]);
60
+ if (mergeBase.status === 0 && safeRef(mergeBase.stdout.trim())) return mergeBase.stdout.trim();
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function configPathInRepository(root, configPath, top) {
66
+ const requested = path.isAbsolute(configPath) ? configPath : path.resolve(root, configPath);
67
+ const absolute = fs.realpathSync(requested);
68
+ const canonicalTop = fs.realpathSync(top);
69
+ const relative = path.relative(canonicalTop, absolute).split(path.sep).join('/');
70
+ if (!relative || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) {
71
+ throw new Error(`Policy config must be inside the Git repository: ${absolute}`);
72
+ }
73
+ return relative;
74
+ }
75
+
76
+ export function resolvePolicyBaseConfig({
77
+ root,
78
+ configPath,
79
+ basePath,
80
+ baseRef,
81
+ env = process.env,
82
+ }) {
83
+ if (basePath) {
84
+ const absolute = path.isAbsolute(basePath) ? basePath : path.resolve(root, basePath);
85
+ return { config: readJsonFile(absolute, 'Policy base'), source: absolute, ref: null };
86
+ }
87
+
88
+ const envRef = normalizePolicyBaseRef(env.ARK_POLICY_BASE_REF);
89
+ const githubBase = typeof env.GITHUB_BASE_REF === 'string' ? env.GITHUB_BASE_REF.trim() : '';
90
+ const requestedRef = baseRef || envRef || (githubBase ? `origin/${githubBase}` : '');
91
+ const ref = requestedRef || discoverLocalBaseRef(root);
92
+ if (!ref) return null;
93
+ if (!safeRef(ref)) throw new Error(`Unsafe policy base ref: ${ref}`);
94
+
95
+ const top = repositoryRoot(root);
96
+ if (!top) {
97
+ // GitHub and ARK_POLICY_BASE_REF describe the process workspace, which may
98
+ // be different from an explicitly checked nested/temporary project root.
99
+ // Only the CLI flag is an unambiguous request to resolve this exact root.
100
+ if (baseRef) throw new Error(`Cannot resolve policy base ref outside a Git repository: ${ref}`);
101
+ return null;
102
+ }
103
+ const relativeConfig = configPathInRepository(root, configPath, top);
104
+ const result = runGit(top, ['show', `${ref}:${relativeConfig}`]);
105
+ if (result.status !== 0) {
106
+ const refExists = runGit(top, ['rev-parse', '--verify', `${ref}^{commit}`]);
107
+ // A newly adopted contract has no predecessor to weaken. CI-provided and
108
+ // auto-discovered bases may therefore omit the config; an explicit CLI ref
109
+ // remains fail-closed because the caller asked to compare that exact input.
110
+ if (refExists.status === 0 && !baseRef) return null;
111
+ if (requestedRef) {
112
+ throw new Error(
113
+ `Cannot read policy base ${ref}:${relativeConfig}: ${result.stderr.trim() || 'git show failed'}`
114
+ );
115
+ }
116
+ return null;
117
+ }
118
+ try {
119
+ return {
120
+ config: JSON.parse(result.stdout),
121
+ source: `git:${ref}:${relativeConfig}`,
122
+ ref,
123
+ };
124
+ } catch (error) {
125
+ throw new Error(
126
+ `Policy base ${ref}:${relativeConfig} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
127
+ );
128
+ }
129
+ }
130
+
131
+ export function readPolicyAcknowledgement(root, acknowledgementPath) {
132
+ if (!acknowledgementPath) return undefined;
133
+ const absolute = path.isAbsolute(acknowledgementPath)
134
+ ? acknowledgementPath
135
+ : path.resolve(root, acknowledgementPath);
136
+ return readJsonFile(absolute, 'Policy acknowledgement');
137
+ }
138
+
139
+ export function analyzePolicyTransition({
140
+ root,
141
+ configPath,
142
+ candidateConfig,
143
+ strictMerge,
144
+ basePath,
145
+ baseRef,
146
+ acknowledgementPath,
147
+ }) {
148
+ if (!strictMerge && !basePath && !baseRef && !acknowledgementPath) return undefined;
149
+ const base = resolvePolicyBaseConfig({ root, configPath, basePath, baseRef });
150
+ if (!base && (basePath || baseRef || acknowledgementPath)) {
151
+ throw new Error('Policy delta was requested but no policy base could be resolved.');
152
+ }
153
+ if (!base) return undefined;
154
+ return analyzePolicyDelta({
155
+ baseConfig: base.config,
156
+ candidateConfig,
157
+ acknowledgement: readPolicyAcknowledgement(root, acknowledgementPath),
158
+ baseSource: base.source,
159
+ candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
160
+ });
161
+ }