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.
- package/CHANGELOG.md +82 -1
- package/README.md +29 -9
- package/bin/ark-check.mjs +69 -54
- package/bin/ark-mcp.mjs +267 -26
- package/bin/ark.mjs +50 -3
- package/bin/lib/adapter-contract.mjs +27 -1
- package/bin/lib/agent-gates.mjs +9 -0
- package/bin/lib/analysis-engine.mjs +7 -1169
- package/bin/lib/ci-and-commands.mjs +4 -0
- package/bin/lib/codex-home.mjs +10 -1
- package/bin/lib/doctor-plan.mjs +37 -9
- package/bin/lib/host-support-matrix.mjs +6 -2
- package/bin/lib/install-migrate.mjs +81 -25
- package/bin/lib/mcp-adoption.mjs +8 -0
- package/bin/lib/policy-delta-io.mjs +161 -0
- package/bin/lib/prepare-change.mjs +186 -0
- package/bin/lib/remediation.mjs +24 -0
- package/bin/lib/skill-install.mjs +302 -22
- package/bin/lib/violations.mjs +2 -2
- package/bin/lib/weakest-link.mjs +61 -12
- package/bin/lib/write-path-capabilities.mjs +70 -2
- package/bin/lib/write-path-detect.mjs +18 -11
- package/dist/eslint/index.cjs +3 -977
- package/dist/eslint/index.js +3 -931
- package/dist/index.cjs +6 -1960
- package/dist/index.d.cts +152 -5
- package/dist/index.d.ts +152 -5
- package/dist/index.js +6 -1908
- package/docs/agent-guide.md +16 -2
- package/docs/ai-gates.md +35 -3
- package/docs/configuration.md +44 -0
- package/docs/package-surface.md +8 -1
- package/docs/threat-model.md +7 -4
- package/package.json +6 -5
- package/schemas/ark.analysis-result.schema.json +5 -1
- package/schemas/ark.change-map.schema.json +77 -0
- package/server.json +2 -2
- package/templates/skills/ark-upgrade.md +9 -5
- package/docs/ark-check-example.json +0 -87
- package/docs/demos/03-copilot-autopilot.md +0 -93
- package/docs/migrate-from-ark-runtime-kernel.md +0 -174
- 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
|
}
|
package/bin/lib/codex-home.mjs
CHANGED
|
@@ -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');
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -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
|
-
|
|
743
|
-
`Hard
|
|
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
|
|
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
|
-
`
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
387
|
-
// Codex
|
|
388
|
-
//
|
|
389
|
-
//
|
|
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 =
|
|
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
|
|
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
|
|
433
|
-
// CODEX_HOME
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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(
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
console.log(
|
|
507
|
-
|
|
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);
|
package/bin/lib/mcp-adoption.mjs
CHANGED
|
@@ -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
|
+
}
|