Sign In

arkgate

Package Overview
Dependencies
Maintainers
1
Versions
52
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

arkgate - npm Package Compare versions

Comparing version
4.1.1
to
4.2.0
+114
bin/lib/html-report-evolution.mjs
export function renderEvolutionSection({
originSnapshot,
currentSnapshot,
originJustCreated,
esc,
formatDelta,
historyMax,
}) {
if (!currentSnapshot) return '';
if (originJustCreated || !originSnapshot) {
return `<div class="section card evolve">
<h2>Origin baseline captured</h2>
<p class="dim" style="margin:.2rem 0 0;font-size:.9rem">
This is the <b>first</b> architecture snapshot for this project
(<code>.ark/reports/origin.json</code> + <code>origin.html</code>).
Future reports will show deltas against this starting point so you can prove evolution.
</p>
</div>`;
}
const scoreComparable =
typeof originSnapshot.arkVersion === 'string' &&
originSnapshot.arkVersion.length > 0 &&
originSnapshot.arkVersion === currentSnapshot.arkVersion;
const rows = [
['Ark score', originSnapshot.score, currentSnapshot.score, '', scoreComparable],
['Governed %', originSnapshot.governedPercent, currentSnapshot.governedPercent, 'pp', true],
['Files in scope', originSnapshot.totalFiles, currentSnapshot.totalFiles, '', true],
['Classified files', originSnapshot.classifiedFiles, currentSnapshot.classifiedFiles, '', true],
['Active violations', originSnapshot.activeViolations, currentSnapshot.activeViolations, '', true],
['Value violations', originSnapshot.valueViolations, currentSnapshot.valueViolations, '', true],
['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, '', true],
['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, '', true],
['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, '', true],
['Gates configured', originSnapshot.gatesOn, currentSnapshot.gatesOn, '', true],
];
const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';
const tr = rows
.map(([label, from, to, unit, comparable]) => {
const d =
comparable && typeof from === 'number' && typeof to === 'number'
? to - from
: null;
const good =
label.includes('violation') || label.includes('Violation')
? d != null && d <= 0
: label.includes('Governed') ||
label.includes('score') ||
label.includes('Classified') ||
label.includes('Gates')
? d != null && d >= 0
: null;
const cls =
d == null || d === 0 ? 'flat' : good === true ? 'up' : good === false ? 'down' : 'flat';
const delta =
d == null
? '—'
: unit === 'pp'
? formatDelta(Math.round(d * 10) / 10, { suffix: ' pp' })
: formatDelta(d);
return `<tr>
<td>${esc(label)}</td>
<td class="num">${from ?? '—'}</td>
<td class="num">${to ?? '—'}</td>
<td class="num delta ${cls}">${esc(delta)}</td>
</tr>`;
})
.join('\n');
const originLayers = originSnapshot.layerFiles || {};
const currentLayers = currentSnapshot.layerFiles || {};
const layerKeys = [
...new Set([...Object.keys(originLayers), ...Object.keys(currentLayers)]),
].sort();
const layerTr = layerKeys
.map((name) => {
const from = originLayers[name] || 0;
const to = currentLayers[name] || 0;
const d = to - from;
const cls = d === 0 ? 'flat' : d > 0 ? 'up' : 'down';
return `<tr>
<td class="ln">${esc(name)}</td>
<td class="num">${from}</td>
<td class="num">${to}</td>
<td class="num delta ${cls}">${esc(formatDelta(d))}</td>
</tr>`;
})
.join('\n');
const scoreNote = scoreComparable
? ''
: `<p class="dim" style="margin:-.35rem 0 .75rem;font-size:.88rem">
Ark score is not comparable across Ark versions
(<code>${esc(originSnapshot.arkVersion ?? 'unknown')}</code> →
<code>${esc(currentSnapshot.arkVersion ?? 'unknown')}</code>); its Δ is shown as —.
Raw coverage, files, violations, layers, rules, and gate metrics remain visible.
</p>`;
return `<div class="section card evolve">
<h2>Evolution vs origin</h2>
<p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
Origin snapshot <code>${esc(originDate)}</code> → this report <code>${esc(nowDate)}</code>
· frozen at <code>.ark/reports/origin.*</code> · reopen origin HTML anytime for the starting picture.
</p>
${scoreNote}
<table class="layers">
<tr><th>Metric</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
${tr}
</table>
<h3>Files per layer</h3>
<table class="layers">
<tr><th>Layer</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
${layerTr || '<tr><td colspan="4" class="dim">No layer file data in snapshots.</td></tr>'}
</table>
<p class="legend">Green Δ = improvement for that metric (↑ coverage/score/gates, ↓ violations). Score Δ is comparable only within the same Ark version. History JSON under <code>.ark/reports/history/</code> (last ${historyMax}).</p>
</div>`;
}
import fs from 'node:fs';
import path from 'node:path';
import { arkCommand } from '../ark-shared.mjs';
import { codexProjectMcpIsValid } from './codex-home.mjs';
import { codexRuntimeActivation } from './enforcement-state.mjs';
export function inspectCodexInstallActivation(root, enabled) {
let configuredOnDisk = false;
if (enabled) {
try {
configuredOnDisk = codexProjectMcpIsValid(
fs.readFileSync(path.join(root, '.codex', 'config.toml'), 'utf8'),
root
);
} catch {
// Missing/unreadable configuration remains explicitly unverified.
}
}
return {
codexProjectConfigured: configuredOnDisk,
runtimeActivation: codexRuntimeActivation({
configuredOnDisk,
restartRequired: configuredOnDisk,
}),
};
}
export function reportPartialInstall({
root,
tools,
results,
homeResults,
earlyWritten,
codexMcp,
runtimeActivation,
}) {
const failed = [...results, ...homeResults]
.filter((result) => result.status === 'failed')
.map((result) => ({
target: result.relativePath ?? '(unknown template)',
reason: 'write failed',
}));
if (codexMcp?.status === 'failed') {
failed.push({
target: codexMcp.file,
reason: codexMcp.message ?? 'Codex MCP registration failed',
});
}
if (failed.length === 0) return false;
const written = new Set([
...earlyWritten,
...[...results, ...homeResults]
.filter((result) => result.status === 'written' || result.status === 'merged')
.map((result) => result.relativePath),
]);
console.error('\nINSTALL PARTIAL — some artifacts were written; activation is not complete.');
console.error('Written:');
if (written.size === 0) console.error(' - (none)');
for (const relativePath of written) console.error(` - ${relativePath}`);
console.error('Failed:');
for (const failure of failed) console.error(` - ${failure.target}: ${failure.reason}`);
console.error('Recovery:');
console.error(
` - Fix the listed path or permission error, then re-run ${arkCommand(root, 'ark-check', `--install-agent-gates${tools.size > 0 ? ` --tools ${[...tools].join(',')}` : ''}`)}.`
);
console.error(' - Existing customized files were preserved; no destructive rollback was attempted.');
if (tools.has('codex')) {
console.error(` - Runtime activation: ${JSON.stringify(runtimeActivation)}`);
}
return true;
}
export function printCodexActivationHandoff(root, configuredOnDisk, runtimeActivation) {
console.log(
configuredOnDisk
? ' CODEX MCP CONFIGURED — RUNTIME NOT VERIFIED'
: ' CODEX MCP CONFIGURATION UNRESOLVED — RUNTIME NOT VERIFIED'
);
console.log(` Runtime activation: ${JSON.stringify(runtimeActivation)}`);
console.log(
configuredOnDisk
? ` Restart Codex, then call ark_identity with expectedRoot "${path.resolve(root)}".`
: ' Repair `.codex/config.toml`, then restart Codex and call ark_identity.'
);
console.log(' Do not trust MCP verdicts before the project identity matches.');
}
/**
* GENERATED FILE — do not edit by hand.
*
* Canonical algorithm: src/domain/projectIdentity.ts
* Regenerate: node scripts/generate-cli-pure.mjs
* Drift check: node scripts/generate-cli-pure.mjs --check
*
* Pure CLI helper (bin/lib/project-identity.mjs). Zero Node I/O.
*/
export const ARK_PROJECT_IDENTITY_SCHEMA_VERSION = '1.0';
export const ARK_PROJECT_IDENTITY_SCHEMA_URL = 'https://unpkg.com/arkgate@4/schemas/ark.project-identity.schema.json';
const sha256Pattern = '^sha256:[a-f0-9]{64}$';
export const PROJECT_EXPECTATION_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
expectedRoot: {
type: 'string',
minLength: 1,
description: 'Absolute expected workspace/project directory. The initial authoritative handshake ' +
'requires the exact project root; descendant calls also require expectedProjectId.',
},
expectedProjectId: {
type: 'string',
pattern: sha256Pattern,
description: 'Project id previously returned by ark_identity or ark_manifest.',
},
},
};
export const PROJECT_BINDING_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['status', 'authoritative'],
properties: {
status: { enum: ['matched', 'unverified', 'mismatch'] },
authoritative: { type: 'boolean' },
expectedRoot: { type: 'string', minLength: 1 },
expectedProjectId: { type: 'string', pattern: sha256Pattern },
code: {
enum: [
'PROJECT_ROOT_MISMATCH',
'PROJECT_ID_MISMATCH',
'INVALID_PROJECT_EXPECTATION',
],
},
message: { type: 'string', minLength: 1 },
},
};
export const ARK_PROJECT_IDENTITY_SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: ARK_PROJECT_IDENTITY_SCHEMA_URL,
title: 'ArkGate MCP project identity',
description: 'Stable project binding plus separate runtime and architecture-contract evidence.',
type: 'object',
additionalProperties: false,
required: [
'schemaVersion',
'projectId',
'resolvedRoot',
'resolvedConfigPath',
'arkgateVersion',
'contractHash',
'contractSource',
'runtimeId',
'processStartedAt',
],
properties: {
schemaVersion: { const: ARK_PROJECT_IDENTITY_SCHEMA_VERSION },
projectId: { type: 'string', pattern: sha256Pattern },
resolvedRoot: { type: 'string', minLength: 1 },
resolvedConfigPath: { type: 'string', minLength: 1 },
arkgateVersion: { type: 'string', minLength: 1 },
contractHash: { type: 'string', pattern: sha256Pattern },
contractSource: { enum: ['project', 'default-profile', 'manifest'] },
runtimeId: { type: 'string', minLength: 1 },
processStartedAt: { type: 'string', format: 'date-time' },
},
$defs: {
expectation: PROJECT_EXPECTATION_SCHEMA,
binding: PROJECT_BINDING_SCHEMA,
},
};
/**
* Stable identity: contract edits and MCP restarts must not change which project
* this is. Callers must pass canonical real paths and a SHA-256 hex function.
*/
export function createProjectId(resolvedRoot, resolvedConfigPath, sha256Hex) {
if (!resolvedRoot || !resolvedConfigPath) {
throw new Error('Project identity requires resolvedRoot and resolvedConfigPath.');
}
const digest = sha256Hex(JSON.stringify({ resolvedRoot, resolvedConfigPath })).toLowerCase();
if (!/^[a-f0-9]{64}$/.test(digest)) {
throw new Error('Project identity hash adapter must return 64 hexadecimal SHA-256 characters.');
}
return `sha256:${digest}`;
}
export function createProjectIdentity(input) {
return {
schemaVersion: ARK_PROJECT_IDENTITY_SCHEMA_VERSION,
...input,
};
}
import { execFileSync } from 'node:child_process';
/** Best-effort, shell-free Git/worktree evidence for report snapshots. */
export function captureGitSnapshot(root) {
const run = (args) =>
execFileSync('git', ['-C', root, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
try {
const headSha = run(['rev-parse', '--verify', 'HEAD']);
let branch = null;
try {
branch = run(['symbolic-ref', '--quiet', '--short', 'HEAD']) || null;
} catch {
// Detached HEAD is valid release/report evidence.
}
let dirty = null;
try {
dirty = run(['status', '--porcelain=v1', '--untracked-files=normal']).length > 0;
} catch {
// Keep the commit identity even when worktree state is unavailable.
}
return { available: true, headSha, branch, dirty };
} catch {
return { available: false, headSha: null, branch: null, dirty: null };
}
}
/**
* Managed skill file IO shared by repo and Codex-home installation.
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
isValidSemver,
isVersionOlder,
planSkillInstall,
skillContentIdentity,
} from './skill-install.mjs';
export const HOME_SKILL_CATALOG = '.arkgate-catalog.json';
export const HOME_SKILL_PENDING_CATALOG = '.arkgate-catalog.pending.json';
const HOME_SKILL_LOCK = '.arkgate-install.lock';
const HOME_SKILL_CATALOG_SCHEMA = '1.0';
const HOME_SKILL_LOCK_STALE_MS = 5 * 60 * 1000;
const HOME_SKILL_LOCK_ATTEMPTS = 100;
const HOME_SKILL_LOCK_RETRY_MS = 25;
const UUID_PATTERN =
/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
function messageOf(error) {
return error instanceof Error ? error.message : String(error);
}
function isWithin(root, candidate) {
const relative = path.relative(root, candidate);
return (
relative === '' ||
(!path.isAbsolute(relative) &&
relative !== '..' &&
!relative.startsWith(`..${path.sep}`))
);
}
function rootContext(root) {
const resolved = path.resolve(root);
fs.mkdirSync(resolved, { recursive: true });
const stat = fs.lstatSync(resolved);
if (
!stat.isDirectory() &&
!(stat.isSymbolicLink() && fs.statSync(resolved).isDirectory())
) {
throw new Error(`Managed skill root is not a directory: ${resolved}`);
}
return { path: resolved, real: fs.realpathSync(resolved) };
}
function targetWithinRoot(context, file) {
const resolved = path.resolve(file);
if (!isWithin(context.path, resolved) || resolved === context.path) {
throw new Error(`Managed skill path escapes its catalog root: ${file}`);
}
return resolved;
}
function validateParents(context, file, create) {
const resolved = targetWithinRoot(context, file);
const rootStat = fs.lstatSync(context.path);
if (
!rootStat.isDirectory() &&
!(rootStat.isSymbolicLink() && fs.statSync(context.path).isDirectory())
) {
throw new Error(`Managed skill root is no longer a directory: ${context.path}`);
}
if (fs.realpathSync(context.path) !== context.real) {
throw new Error(`Managed skill root changed during installation: ${context.path}`);
}
const relativeParent = path.relative(context.path, path.dirname(resolved));
const segments = relativeParent === '' ? [] : relativeParent.split(path.sep);
let cursor = context.path;
for (const segment of segments) {
cursor = path.join(cursor, segment);
let stat = fs.lstatSync(cursor, { throwIfNoEntry: false });
if (!stat) {
if (!create) return false;
fs.mkdirSync(cursor);
stat = fs.lstatSync(cursor);
}
if (stat.isSymbolicLink()) {
throw new Error(`Managed skill parent must not be a symlink or junction: ${cursor}`);
}
if (!stat.isDirectory()) {
throw new Error(`Managed skill parent is not a directory: ${cursor}`);
}
const real = fs.realpathSync(cursor);
if (!isWithin(context.real, real)) {
throw new Error(`Managed skill parent resolves outside its catalog root: ${cursor}`);
}
}
return true;
}
function sameFileState(left, right) {
if (!left || !right) return false;
if (
left.dev !== undefined &&
left.ino !== undefined &&
(left.dev !== 0 || left.ino !== 0) &&
(left.dev !== right.dev || left.ino !== right.ino)
) {
return false;
}
return (
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs
);
}
function sameFileIdentity(left, right) {
if (!left || !right) return false;
if (left.dev !== undefined && left.ino !== undefined && (left.dev !== 0 || left.ino !== 0)) {
return left.dev === right.dev && left.ino === right.ino;
}
return left.size === right.size && left.birthtimeMs === right.birthtimeMs;
}
function readSafeFile(context, file) {
const resolved = targetWithinRoot(context, file);
if (!validateParents(context, resolved, false)) return null;
const before = fs.lstatSync(resolved, { throwIfNoEntry: false });
if (!before) return null;
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
throw new Error(`Managed target is not a single-link regular file: ${resolved}`);
}
let content;
try {
content = fs.readFileSync(resolved, 'utf8');
} catch (error) {
throw new Error(`Existing managed target is unreadable (${resolved}): ${messageOf(error)}`);
}
validateParents(context, resolved, false);
const after = fs.lstatSync(resolved, { throwIfNoEntry: false });
if (
!after ||
!after.isFile() ||
after.isSymbolicLink() ||
after.nlink !== 1 ||
!sameFileState(before, after)
) {
throw new Error(`Managed target changed while it was being read: ${resolved}`);
}
return { content, stat: after };
}
function fsyncDirectory(directory) {
let descriptor;
try {
descriptor = fs.openSync(directory, 'r');
fs.fsyncSync(descriptor);
} catch {
// Some filesystems/platforms do not support syncing directory handles.
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function cleanupOwnedTemp(context, file, createdStat) {
try {
const current = readSafeFile(context, file);
if (current && sameFileIdentity(current.stat, createdStat)) {
fs.unlinkSync(file);
}
} catch {
// Never delete a temp path that no longer identifies as the file we created.
}
}
function atomicReplaceFile(context, file, content, expectedContent) {
const resolved = targetWithinRoot(context, file);
validateParents(context, resolved, true);
const observed = readSafeFile(context, resolved);
if ((observed?.content ?? null) !== expectedContent) {
throw new Error(`Managed target changed concurrently before write: ${resolved}`);
}
const token = randomUUID();
const temp = path.join(
path.dirname(resolved),
`.${path.basename(resolved)}.arkgate-${token}.tmp`
);
let descriptor;
let createdStat;
try {
validateParents(context, temp, false);
descriptor = fs.openSync(temp, 'wx', observed ? observed.stat.mode & 0o777 : 0o666);
createdStat = fs.fstatSync(descriptor);
fs.writeFileSync(descriptor, content, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
const beforeReplace = readSafeFile(context, resolved);
if ((beforeReplace?.content ?? null) !== expectedContent) {
throw new Error(`Managed target changed concurrently during write: ${resolved}`);
}
const staged = readSafeFile(context, temp);
if (staged?.content !== content) {
throw new Error(`Atomic managed write staging verification failed: ${resolved}`);
}
validateParents(context, resolved, false);
fs.renameSync(temp, resolved);
fsyncDirectory(path.dirname(resolved));
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
cleanupOwnedTemp(context, temp, createdStat);
}
}
function sleepSync(milliseconds) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
}
function parseLock(content) {
try {
const value = JSON.parse(content);
if (
typeof value?.token !== 'string' ||
!UUID_PATTERN.test(value.token) ||
!Number.isInteger(value.pid) ||
value.pid <= 0 ||
!Number.isFinite(value.createdAtMs) ||
value.createdAtMs <= 0
) {
return null;
}
return value;
} catch {
return null;
}
}
function processIsAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
}
function restoreClaimedFile(context, claimed, original) {
try {
validateParents(context, claimed, false);
validateParents(context, original, false);
fs.linkSync(claimed, original);
fs.unlinkSync(claimed);
return true;
} catch {
return false;
}
}
function removeOwnedTokenFile(context, file, token, parseMetadata, claimKind) {
const claimed = `${file}.${claimKind}-${token}`;
try {
const current = readSafeFile(context, file);
if (parseMetadata(current?.content)?.token !== token) return false;
validateParents(context, file, false);
const confirmed = readSafeFile(context, file);
if (parseMetadata(confirmed?.content)?.token !== token) return false;
validateParents(context, claimed, false);
fs.renameSync(file, claimed);
const moved = readSafeFile(context, claimed);
if (parseMetadata(moved?.content)?.token !== token) {
restoreClaimedFile(context, claimed, file);
return false;
}
fs.unlinkSync(claimed);
fsyncDirectory(path.dirname(file));
return true;
} catch {
return false;
}
}
function removeOwnedLock(context, file, token) {
return removeOwnedTokenFile(context, file, token, parseLock, 'release');
}
function createOwnedLock(context, file) {
const token = randomUUID();
const content = `${JSON.stringify({
token,
pid: process.pid,
createdAtMs: Date.now(),
})}\n`;
validateParents(context, file, true);
let descriptor;
let createdStat;
try {
descriptor = fs.openSync(file, 'wx', 0o600);
createdStat = fs.fstatSync(descriptor);
fs.writeFileSync(descriptor, content, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
fsyncDirectory(path.dirname(file));
return { file, token };
} catch (error) {
if (descriptor !== undefined) fs.closeSync(descriptor);
if (createdStat) {
try {
validateParents(context, file, false);
const current = fs.lstatSync(file, { throwIfNoEntry: false });
if (sameFileIdentity(createdStat, current)) fs.unlinkSync(file);
} catch {
// Preserve anything that no longer identifies as the inode we created.
}
}
throw error;
}
}
function recoverStaleLock(context, file) {
const current = readSafeFile(context, file);
if (!current) return true;
const metadata = parseLock(current.content);
const timestamp = metadata?.createdAtMs ?? current.stat.mtimeMs;
if (Date.now() - timestamp <= HOME_SKILL_LOCK_STALE_MS) return false;
if (metadata && processIsAlive(metadata.pid)) return false;
const recoveryToken = randomUUID();
const claimed = path.join(
path.dirname(file),
`${HOME_SKILL_LOCK}.recovery-${recoveryToken}`
);
validateParents(context, claimed, false);
validateParents(context, file, false);
const confirmed = readSafeFile(context, file);
if (
!confirmed ||
confirmed.content !== current.content ||
!sameFileState(confirmed.stat, current.stat)
) {
return false;
}
try {
fs.renameSync(file, claimed);
} catch (error) {
if (error?.code === 'ENOENT') return true;
throw error;
}
const recovered = readSafeFile(context, claimed);
if (
!recovered ||
recovered.content !== current.content ||
!sameFileIdentity(recovered.stat, current.stat)
) {
restoreClaimedFile(context, claimed, file);
throw new Error(`Stale home skill lock changed during recovery: ${HOME_SKILL_LOCK}`);
}
const ownedContent = `${JSON.stringify({
token: recoveryToken,
pid: process.pid,
createdAtMs: Date.now(),
})}\n`;
atomicReplaceFile(context, claimed, ownedContent, current.content);
if (!removeOwnedLock(context, claimed, recoveryToken)) {
throw new Error(`Could not release recovered home skill lock: ${HOME_SKILL_LOCK}`);
}
return true;
}
function acquireHomeLock(context) {
const file = path.join(context.path, HOME_SKILL_LOCK);
let lastConflict = null;
for (let attempt = 0; attempt < HOME_SKILL_LOCK_ATTEMPTS; attempt += 1) {
try {
return createOwnedLock(context, file);
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
lastConflict = error;
if (recoverStaleLock(context, file)) continue;
if (attempt + 1 < HOME_SKILL_LOCK_ATTEMPTS) {
sleepSync(HOME_SKILL_LOCK_RETRY_MS);
}
}
}
throw new Error(
`another home skill install is active (${HOME_SKILL_LOCK}); retry after it finishes` +
(lastConflict?.message ? `: ${lastConflict.message}` : '')
);
}
export function skillInstallNote(plan) {
const scope = plan.scope === 'home' ? 'home-shared' : 'repo';
const source = plan.sourceVersion ? `arkgate@${plan.sourceVersion}` : 'arkgate@unknown';
const installed = plan.installedVersion ?? 'no stamp';
if (plan.reason === 'content-current') {
return `scope=${scope}; body current; installed=${installed}; source=${source}; no write`;
}
if (plan.reason === 'newer-home-version') {
return `scope=${scope}; CONFLICT installed=${installed} newer than source=${source}; downgrade blocked`;
}
if (plan.reason === 'unknown-source-version') {
return `scope=${scope}; CONFLICT installed=${installed}; source version unknown; overwrite blocked`;
}
if (plan.reason === 'existing-preserved') {
return `scope=${scope}; CONFLICT body differs; installed=${installed}; source=${source}; preserved without --force`;
}
return `scope=${scope}; source=${source}; ${plan.reason === 'missing' ? 'missing' : 'body update'}`;
}
/**
* @param {{
* root: string,
* file: string,
* relativePath: string,
* targetContent: string,
* packageVersion: string|null,
* force: boolean,
* scope: 'repo'|'home',
* }} input
*/
export function installSkillFile(input) {
if (input.scope === 'home' && !isValidSemver(input.packageVersion)) {
return {
relativePath: input.relativePath,
status: 'failed',
message: 'Shared-home skill writes require a valid SemVer package version.',
};
}
let context;
let existingContent = null;
try {
context = rootContext(input.root);
existingContent = readSafeFile(context, input.file)?.content ?? null;
} catch (error) {
return {
relativePath: input.relativePath,
status: 'failed',
message: messageOf(error),
};
}
const skillPlan = planSkillInstall({
existingContent,
targetContent: input.targetContent,
packageVersion: input.packageVersion,
force: input.force,
scope: input.scope,
});
if (skillPlan.action === 'skip') {
return { relativePath: input.relativePath, status: 'skipped', skillPlan };
}
try {
atomicReplaceFile(context, input.file, input.targetContent, existingContent);
return { relativePath: input.relativePath, status: 'written', skillPlan };
} catch (error) {
return {
relativePath: input.relativePath,
status: 'failed',
skillPlan,
message: messageOf(error),
};
}
}
export function installRepoSkillFile(root, relativePath, targetContent, packageVersion, force) {
return installSkillFile({
root,
file: path.join(root, relativePath),
relativePath,
targetContent,
packageVersion,
force,
scope: 'repo',
});
}
function failedCatalog(directory, message, displayPath = HOME_SKILL_CATALOG) {
return [{
relativePath: path.join(directory, displayPath),
displayPath,
status: 'failed',
message,
}];
}
function readHomeCatalog(context) {
const file = path.join(context.path, HOME_SKILL_CATALOG);
const current = readSafeFile(context, file);
if (!current) return { file, value: null, content: null };
let value;
try {
value = JSON.parse(current.content);
} catch {
throw new Error(
`${HOME_SKILL_CATALOG} is unreadable or invalid JSON; no home skills changed. ` +
'Repair it or move it aside after verifying no newer ArkGate catalog is active.'
);
}
const valid =
value?.schemaVersion === HOME_SKILL_CATALOG_SCHEMA &&
isValidSemver(value.packageVersion) &&
Array.isArray(value.skills);
if (!valid) {
throw new Error(
`${HOME_SKILL_CATALOG} has an unsupported shape; no home skills changed. ` +
'Expected schemaVersion, packageVersion, and managed skill identities.'
);
}
const seen = new Set();
for (const skill of value.skills) {
if (
!skill ||
typeof skill.name !== 'string' ||
!/^ark-[a-z0-9-]+$/.test(skill.name) ||
typeof skill.contentIdentity !== 'string' ||
!/^sha256:[a-f0-9]{64}$/.test(skill.contentIdentity) ||
seen.has(skill.name)
) {
throw new Error(
`${HOME_SKILL_CATALOG} has invalid managed skill identities; no home skills changed.`
);
}
seen.add(skill.name);
}
return { file, value, content: current.content };
}
function parsePendingCatalog(content) {
try {
const value = JSON.parse(content);
const keys =
value && typeof value === 'object' && !Array.isArray(value)
? Object.keys(value).sort()
: [];
if (
keys.join(',') !== 'packageVersion,schemaVersion,token' ||
value.schemaVersion !== HOME_SKILL_CATALOG_SCHEMA ||
!isValidSemver(value.packageVersion) ||
typeof value.token !== 'string' ||
!UUID_PATTERN.test(value.token)
) {
return null;
}
return value;
} catch {
return null;
}
}
function readPendingCatalog(context) {
const file = path.join(context.path, HOME_SKILL_PENDING_CATALOG);
const current = readSafeFile(context, file);
if (!current) return { file, value: null, content: null };
const value = parsePendingCatalog(current.content);
if (!value) {
throw new Error(
`${HOME_SKILL_PENDING_CATALOG} is unreadable or invalid; no home skills changed. ` +
'Repair it only after verifying the newest ArkGate version that may have started an install.'
);
}
return { file, value, content: current.content };
}
function newestInstalledVersion(context, catalogVersion, pendingVersion) {
let newest = null;
for (const version of [catalogVersion, pendingVersion]) {
if (isValidSemver(version) && (!newest || isVersionOlder(newest, version))) {
newest = version;
}
}
const entries = fs.readdirSync(context.path, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !/^ark-[a-z0-9-]+$/.test(entry.name)) continue;
const current = readSafeFile(
context,
path.join(context.path, entry.name, 'SKILL.md')
);
const version = current?.content.match(/^arkVersion:\s*(.+)$/m)?.[1]?.trim() ?? null;
if (
isValidSemver(version) &&
(!newest || isVersionOlder(newest, version))
) {
newest = version;
}
}
return newest;
}
function pendingCatalogContent(packageVersion, token) {
return `${JSON.stringify({
schemaVersion: HOME_SKILL_CATALOG_SCHEMA,
packageVersion,
token,
}, null, 2)}\n`;
}
function removeOwnedPendingCatalog(context, file, token) {
try {
if (!readSafeFile(context, file)) return true;
} catch {
return false;
}
return removeOwnedTokenFile(
context,
file,
token,
parsePendingCatalog,
'commit'
);
}
function catalogContent(packageVersion, entries) {
return `${JSON.stringify({
schemaVersion: HOME_SKILL_CATALOG_SCHEMA,
packageVersion,
skills: entries.sort((left, right) => left.name.localeCompare(right.name)),
}, null, 2)}\n`;
}
function retireManagedHomeSkills(context, priorEntries, targetNames, force) {
const results = [];
const retainedEntries = [];
for (const entry of priorEntries) {
if (targetNames.has(entry.name)) continue;
const file = path.join(context.path, entry.name, 'SKILL.md');
let current;
try {
current = readSafeFile(context, file);
} catch (error) {
results.push({
relativePath: file,
displayPath: `${entry.name}/SKILL.md`,
status: 'failed',
message: messageOf(error),
});
continue;
}
if (!current) continue;
if (skillContentIdentity(current.content) !== entry.contentIdentity) {
results.push({
relativePath: file,
displayPath: `${entry.name}/SKILL.md`,
status: 'skipped',
note: 'scope=home-shared; retired but customized; preserved and ownership released',
});
continue;
}
if (!force) {
retainedEntries.push(entry);
results.push({
relativePath: file,
displayPath: `${entry.name}/SKILL.md`,
status: 'skipped',
note: 'scope=home-shared; retired Ark-owned skill preserved without --force',
});
continue;
}
try {
const confirmed = readSafeFile(context, file);
if (
!confirmed ||
skillContentIdentity(confirmed.content) !== entry.contentIdentity
) {
throw new Error(`Retired managed skill changed before removal: ${file}`);
}
validateParents(context, file, false);
fs.unlinkSync(file);
try {
const parent = path.dirname(file);
validateParents(context, path.join(parent, '.arkgate-parent-check'), false);
const parentStat = fs.lstatSync(parent);
if (!parentStat.isSymbolicLink() && parentStat.isDirectory()) fs.rmdirSync(parent);
} catch {
// Keep a non-empty directory and any user-owned sibling files.
}
results.push({
relativePath: file,
displayPath: `${entry.name}/SKILL.md`,
status: 'removed',
note: 'scope=home-shared; retired Ark-owned skill removed',
});
} catch (error) {
results.push({
relativePath: file,
displayPath: `${entry.name}/SKILL.md`,
status: 'failed',
message: error instanceof Error ? error.message : String(error),
});
}
}
return { results, retainedEntries };
}
function installHomeSkillCatalog({ directory, skills, packageVersion, force }) {
if (!isValidSemver(packageVersion)) {
return failedCatalog(
directory,
'ArkGate package version is unavailable or not valid SemVer; refusing to mutate the shared home catalog.'
);
}
const seenNames = new Set();
let validSkills = Array.isArray(skills);
if (validSkills) {
for (const entry of skills) {
if (
!Array.isArray(entry) ||
entry.length !== 2 ||
typeof entry[0] !== 'string' ||
!/^ark-[a-z0-9-]+$/.test(entry[0]) ||
typeof entry[1] !== 'string' ||
seenNames.has(entry[0])
) {
validSkills = false;
break;
}
seenNames.add(entry[0]);
}
}
if (!validSkills) {
return failedCatalog(
directory,
'Home skill input contains an invalid or duplicate managed skill name; no home skills changed.'
);
}
let context;
try {
context = rootContext(directory);
} catch (error) {
return failedCatalog(directory, messageOf(error));
}
let lock;
try {
lock = acquireHomeLock(context);
} catch (error) {
return failedCatalog(directory, messageOf(error));
}
try {
const catalog = readHomeCatalog(context);
let pending;
try {
pending = readPendingCatalog(context);
} catch (error) {
return failedCatalog(
directory,
messageOf(error),
HOME_SKILL_PENDING_CATALOG
);
}
const installedVersion = newestInstalledVersion(
context,
catalog.value?.packageVersion ?? null,
pending.value?.packageVersion ?? null
);
if (installedVersion && isVersionOlder(packageVersion, installedVersion)) {
return [{
relativePath: catalog.file,
displayPath: HOME_SKILL_CATALOG,
status: 'skipped',
note:
`scope=home-shared; CONFLICT catalog=${installedVersion} newer than ` +
`source=arkgate@${packageVersion}; entire home update and retirements blocked`,
}];
}
const pendingToken = randomUUID();
const nextPendingContent = pendingCatalogContent(packageVersion, pendingToken);
try {
atomicReplaceFile(
context,
pending.file,
nextPendingContent,
pending.content
);
} catch (error) {
return [{
relativePath: pending.file,
displayPath: HOME_SKILL_PENDING_CATALOG,
status: 'failed',
message:
`${messageOf(error)}; no home skills changed because the durable ` +
'catalog journal could not be committed.',
}];
}
const installed = skills.map(([name, targetContent]) => ({
...installSkillFile({
root: context.path,
file: path.join(context.path, name, 'SKILL.md'),
relativePath: path.join(context.path, name, 'SKILL.md'),
targetContent,
packageVersion,
force,
scope: 'home',
}),
name,
targetContent,
displayPath: `${name}/SKILL.md`,
}));
if (installed.some((result) => result.status === 'failed')) return installed;
const targetNames = new Set(skills.map(([name]) => name));
const retired = retireManagedHomeSkills(
context,
catalog.value?.skills ?? [],
targetNames,
force
);
if (retired.results.some((result) => result.status === 'failed')) {
return [...installed, ...retired.results];
}
const managedEntries = installed
.filter(
(result) =>
result.status === 'written' || result.skillPlan?.reason === 'content-current'
)
.map((result) => ({
name: result.name,
contentIdentity: skillContentIdentity(result.targetContent),
}))
.concat(retired.retainedEntries);
const nextContent = catalogContent(packageVersion, managedEntries);
const catalogResult =
nextContent === catalog.content
? {
relativePath: catalog.file,
displayPath: HOME_SKILL_CATALOG,
status: 'skipped',
note: `scope=home-shared; catalog current at arkgate@${packageVersion}; no write`,
}
: (() => {
try {
atomicReplaceFile(context, catalog.file, nextContent, catalog.content);
return {
relativePath: catalog.file,
displayPath: HOME_SKILL_CATALOG,
status: 'written',
note: `scope=home-shared; catalog advanced to arkgate@${packageVersion}`,
};
} catch (error) {
return {
relativePath: catalog.file,
displayPath: HOME_SKILL_CATALOG,
status: 'failed',
message: messageOf(error),
};
}
})();
const results = [...installed, ...retired.results, catalogResult];
if (catalogResult.status === 'failed') return results;
if (!removeOwnedPendingCatalog(context, pending.file, pendingToken)) {
return [
...results,
{
relativePath: pending.file,
displayPath: HOME_SKILL_PENDING_CATALOG,
status: 'failed',
message:
'The shared catalog committed, but its durable journal changed or could not be ' +
'removed; it was preserved for a safe same-or-newer retry.',
},
];
}
return results;
} catch (error) {
return failedCatalog(directory, messageOf(error));
} finally {
if (lock) removeOwnedLock(context, lock.file, lock.token);
}
}
export function installSkillCatalog(input) {
if (input.scope === 'home') return installHomeSkillCatalog(input);
return input.skills.map(([name, targetContent]) => ({
...installSkillFile({
root: input.directory,
file: path.join(input.directory, name, 'SKILL.md'),
relativePath: path.join(input.directory, name, 'SKILL.md'),
targetContent,
packageVersion: input.packageVersion,
force: input.force,
scope: input.scope,
}),
displayPath: `${name}/SKILL.md`,
}));
}
export function skillInstallLine(result) {
const marker =
result.status === 'written'
? 'wrote'
: result.status === 'removed'
? 'removed'
: result.status === 'failed'
? 'FAILED'
: 'skipped';
const detail =
result.status === 'failed'
? ` (${result.message})`
: ` (${result.note ?? skillInstallNote(result.skillPlan)})`;
return ` ${marker.padEnd(7)} ${result.displayPath}${detail}`;
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://unpkg.com/arkgate@4/schemas/ark.project-identity.schema.json",
"title": "ArkGate MCP project identity",
"description": "Stable project binding plus separate runtime and architecture-contract evidence.",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"projectId",
"resolvedRoot",
"resolvedConfigPath",
"arkgateVersion",
"contractHash",
"contractSource",
"runtimeId",
"processStartedAt"
],
"properties": {
"schemaVersion": {
"const": "1.0"
},
"projectId": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"resolvedRoot": {
"type": "string",
"minLength": 1
},
"resolvedConfigPath": {
"type": "string",
"minLength": 1
},
"arkgateVersion": {
"type": "string",
"minLength": 1
},
"contractHash": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"contractSource": {
"enum": [
"project",
"default-profile",
"manifest"
]
},
"runtimeId": {
"type": "string",
"minLength": 1
},
"processStartedAt": {
"type": "string",
"format": "date-time"
}
},
"$defs": {
"expectation": {
"type": "object",
"additionalProperties": false,
"properties": {
"expectedRoot": {
"type": "string",
"minLength": 1,
"description": "Absolute expected workspace/project directory. The initial authoritative handshake requires the exact project root; descendant calls also require expectedProjectId."
},
"expectedProjectId": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$",
"description": "Project id previously returned by ark_identity or ark_manifest."
}
}
},
"binding": {
"type": "object",
"additionalProperties": false,
"required": [
"status",
"authoritative"
],
"properties": {
"status": {
"enum": [
"matched",
"unverified",
"mismatch"
]
},
"authoritative": {
"type": "boolean"
},
"expectedRoot": {
"type": "string",
"minLength": 1
},
"expectedProjectId": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"code": {
"enum": [
"PROJECT_ROOT_MISMATCH",
"PROJECT_ID_MISMATCH",
"INVALID_PROJECT_EXPECTATION"
]
},
"message": {
"type": "string",
"minLength": 1
}
}
}
}
}
+1
-0

@@ -106,2 +106,3 @@ /**

detectSkillGaps,
skillGapsForActiveHost,
agentsMdSkillRefs,

@@ -108,0 +109,0 @@ verifyHostSkillCatalog,

+16
-7

@@ -186,5 +186,7 @@ /**

const AGENT_CONTRACT = {
manifestResource: 'ark://manifest',
manifestTool: 'ark_manifest',
compatibilityManifestResource: 'ark://manifest',
steps: (checkCommand) => [
`Read the Ark contract from \`ark://manifest\` when the MCP server is available.`,
`Before trusting Ark MCP evidence, call \`ark_identity\` with \`project.expectedRoot\` set to the exact project root's absolute path. Reuse that root plus the returned \`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only when the matching project id is also supplied. If the tool is missing, the binding is not \`matched\`, or the reported root differs, restart the host and use the local CLI until identity matches.`,
`Read the authoritative Ark contract with \`ark_manifest\` using the same project expectation. The \`ark://manifest\` resource is compatibility-only and always unverified/non-authoritative.`,
`Keep source files inside the layer boundaries declared in \`ark.config.json\`.`,

@@ -340,3 +342,3 @@ `Do not bypass Ark publishers, event contracts, or source metadata for runtime mutations.`,

* Compact onboarding uses one project router instead of copied slash-command
* skills. The package and ark MCP resources remain the canonical capability
* skills. The package and ark MCP tools remain the canonical capability
* source; the marker makes the selected host verifiable by the strict gate.

@@ -363,4 +365,5 @@ */

1. Status anytime: \`${doctorCmd}\` — one status light, one next action (control plane).
2. Day to day: read \`ark://manifest\` when MCP is available; place new files with \`ark_place\`; validate after edits; run \`${checkCmd}\`. On a gate deny, fix the architecture — do not weaken the contract.
3. If MCP is unavailable: inspect \`ark.config.json\` and run \`${checkCmd}\`.
2. Before trusting MCP evidence: call \`ark_identity\` with \`project.expectedRoot\` set to this project's exact absolute root, then reuse that root plus the returned \`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only with that matching id. Missing tool, non-\`matched\` binding, or wrong root means the process is stale: restart the host and use the local CLI meanwhile.
3. Day to day: call \`ark_manifest\` with the same project expectation; place new files with \`ark_place\`; validate after edits; run \`${checkCmd}\`. The \`ark://manifest\` resource is compatibility-only and always unverified/non-authoritative. On a gate deny, fix the architecture — do not weaken the contract.
4. If MCP is unavailable: inspect \`ark.config.json\` and run \`${checkCmd}\`.

@@ -437,4 +440,10 @@ The selected host is \`${selectedHost}\`. Host registration and CI are installed with this file.

Before writing or editing TypeScript or JavaScript source files, read the
\`${AGENT_CONTRACT.manifestResource}\` resource from the \`ark\` MCP server when available.
Before trusting Ark MCP evidence, call \`ark_identity\` with \`project.expectedRoot\`
set to the exact project root's absolute path. Reuse that root plus the returned
\`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only
when that matching id is also supplied. If the tool is missing, the binding is not \`matched\`,
or the root differs, restart the host and use the local CLI until identity matches. Then call
\`${AGENT_CONTRACT.manifestTool}\` with the same project expectation. The
\`${AGENT_CONTRACT.compatibilityManifestResource}\` resource is compatibility-only and always
unverified/non-authoritative.

@@ -441,0 +450,0 @@ ${AGENT_CONTRACT.cursorValidateStep} After edits, run:

@@ -168,5 +168,81 @@ /**

function extractCodexCommandFromBlock(block) {
const matches = [
...String(block || '').matchAll(
/^[ \t]*command[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')[ \t]*(?:#.*)?$/gm
),
];
if (matches.length !== 1) return null;
try {
return matches[0][1].startsWith('"')
? JSON.parse(matches[0][1])
: matches[0][1].slice(1, -1);
} catch {
return null;
}
}
function executableName(value) {
if (typeof value !== 'string' || value !== value.trim() || value.length === 0) return '';
return path.posix
.basename(value.replace(/\\/g, '/'))
.replace(/\.(?:cmd|exe)$/i, '')
.toLowerCase();
}
function isArkMcpToken(value) {
return /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(executableName(value));
}
function codexArkMcpInvocation(command, args) {
if (
!command ||
!Array.isArray(args) ||
[command, ...args].filter(isArkMcpToken).length !== 1
) {
return false;
}
const argv = [command, ...args];
if (isArkMcpToken(command)) return { binArgs: argv.slice(1) };
const runner = executableName(command);
if ((runner === 'npx' || runner === 'yarn') && isArkMcpToken(args[0])) {
return { binArgs: argv.slice(2) };
}
if (runner === 'node') {
const script = args[0]?.replace(/\\/g, '/');
return isArkMcpToken(script) && /(?:^|\/)bin\/ark-mcp\.mjs$/.test(script)
? { binArgs: argv.slice(2) }
: false;
}
if (runner !== 'pnpm') return false;
const binIndex =
args[0] === 'exec'
? 1
: args[0] === '--config.verify-deps-before-run=false' && args[1] === 'exec'
? 2
: -1;
return binIndex >= 0 && isArkMcpToken(args[binIndex])
? { binArgs: argv.slice(binIndex + 2) }
: false;
}
function singleOptionValue(args, option) {
const indexes = args.flatMap((value, index) => (value === option ? [index] : []));
return indexes.length === 1 ? args[indexes[0] + 1] ?? null : null;
}
function projectPathFlavor(projectRoot) {
const windows = /^(?:[A-Za-z]:[\\/]|\\\\)/.test(projectRoot);
return {
api: windows ? path.win32 : path,
comparable: (value) => (windows ? value.toLowerCase() : value),
};
}
function blockHasWorkingDirectoryOverride(block) {
return /^[ \t]*(?:cwd|"cwd"|'cwd')[ \t]*=/m.test(String(block || ''));
}
/** True when project TOML owns the primary Ark MCP binding for that project. */
export function codexProjectMcpIsValid(tomlText, projectRoot) {
const resolvedRoot = path.resolve(projectRoot);
if (listCodexArkServerTables(tomlText).filter((entry) => entry.table === 'ark').length !== 1) {

@@ -177,10 +253,16 @@ return false;

const args = extractCodexArgsFromBlock(primary?.block);
if (!primary?.root || !args?.some((value) => /^(ark|arkgate)-mcp$/.test(value))) return false;
const configIndex = args.indexOf('--config');
const config = configIndex >= 0 ? args[configIndex + 1] : null;
if (!config) return false;
const command = extractCodexCommandFromBlock(primary?.block);
const invocation = args && codexArkMcpInvocation(command, args);
if (!invocation || blockHasWorkingDirectoryOverride(primary?.block)) return false;
const rootArg = singleOptionValue(invocation.binArgs, '--root');
const configArg = singleOptionValue(invocation.binArgs, '--config');
if (!rootArg || !configArg || invocation.binArgs.length !== 4) return false;
try {
const { api, comparable } = projectPathFlavor(projectRoot);
const resolvedRoot = api.resolve(projectRoot);
const requestedRoot = api.resolve(resolvedRoot, rootArg);
const requestedConfig = api.resolve(resolvedRoot, configArg);
return (
path.resolve(resolvedRoot, primary.root) === resolvedRoot &&
path.resolve(resolvedRoot, config) === path.join(resolvedRoot, 'ark.config.json')
comparable(requestedRoot) === comparable(resolvedRoot) &&
comparable(requestedConfig) === comparable(api.join(resolvedRoot, 'ark.config.json'))
);

@@ -286,3 +368,3 @@ } catch {

`this project is registered as [mcp_servers.${scopedTable}]. ` +
`Install the project-scoped binding so this repo owns ark://manifest when active.`
`Install the project-scoped binding so ark_identity and ark_manifest match this repo when active.`
: `Codex home primary MCP --root is another permanent project ` +

@@ -289,0 +371,0 @@ `(${rootArg || 'missing'} ≠ ${resolvedRoot}). ` +

@@ -36,3 +36,3 @@ /**

'domain-logic-in-ui':
'Business rules (can*/calculate*/policy) sit in UI components — the AI will duplicate them in pages. Move pure rules into Domain (or a pure domain module) and import from the UI.',
'Business rules (can*/calculate*/policy) sit in UI components — the AI will duplicate them in pages. Move the pure rule into Domain, expose it through Application, and keep UI imports on that Application boundary.',
'facade-sql-in-routes':

@@ -80,4 +80,9 @@ 'Routes/controllers import the ORM or SQL client — the AI will keep growing “smart controllers.” Keep queries in a repository/adapter; routes only call that port.',

/\b(?:export\s+)?(?:declare\s+)?(?:async\s+)?function\s+defineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/g;
const DOMAIN_LOGIC_UI_RE =
/\b(?:export\s+)?(?:async\s+)?function\s+(?:can|calculate|compute|should)[A-Z]\w*|\b(?:export\s+)?const\s+(?:can|calculate|compute|should)[A-Z]\w*\s*=/;
const DOMAIN_LOGIC_UI_DECL_RE =
/\b(?:export\s+)?(?:(?:async\s+)?function\s+((?:can|calculate|compute|should)[A-Z]\w*)|const\s+((?:can|calculate|compute|should)[A-Z]\w*)\s*=)/g;
const UI_PERMISSION_OR_LOCAL_STATE_RE =
/\b(?:permissions?|roles?|acl|session|currentUser|isOwner|readOnly|useState|useMemo|useContext|localState|uiState|selected(?:Id|Row|Tab)?|is(?:Open|Closed|Expanded|Collapsed|Selected|Loading|Pending|Hovered|Focused|Disabled))\b(?:\s*\.\s*\w+|\s*\[[^\]]+\])*/gi;
const STRING_LITERAL_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
const BUSINESS_RULE_CONTEXT_RE =
/\b(?:amount|total|price|tax|discount|balance|quantity|inventory|order|cart|invoice|credit|policy|threshold|limit|status)\b/i;
const EXPORT_RE =

@@ -98,2 +103,15 @@ /\bexport\s+(?:async\s+)?(?:function|class|const|let|var|type|interface|enum|default)\b|\bexport\s*\{/g;

/** Files that may be intentionally broad or generated are not extraction pilots. */
export function isNonProductionPilotPath(filePath) {
const rel = String(filePath || '').replace(/\\/g, '/');
return (
/(?:^|\/)(?:tests?|__tests__|fixtures?|testdata|mocks?|stubs?|examples?|samples?|seeds?|seeders?|migrations?|demos?|generated|codegen)(?:\/|$)/i.test(
rel
) ||
/(?:^|\/)(?:(?:fixture|seed|seeder|migration|demo|generated)|[^/]*(?:\.generated|\.gen|[-_.](?:fixture|seed|seeder|migration|demo|generated)))\.(?:ts|tsx|js|jsx|mts|cts)$/i.test(
rel
)
);
}
function normalizeRel(root, filePath) {

@@ -181,2 +199,34 @@ const abs = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);

function hasGeneratedSourceBanner(source) {
return /(?:@generated|GENERATED FILE|generated by|do not edit)/i.test(source.slice(0, 400));
}
function hasDomainLogicInUi(source) {
for (const match of source.matchAll(DOMAIN_LOGIC_UI_DECL_RE)) {
const name = match[1] || match[2] || '';
const start = match.index ?? 0;
const statementEnd = source.indexOf(';', start);
const end =
statementEnd >= start && statementEnd <= start + 800
? statementEnd + 1
: Math.min(source.length, start + 800);
const declaration = source.slice(start, end);
const uiDecisionName =
/^(?:can(?:Edit|Delete|View|Manage|Select|Open|Close)|should(?:Show|Render|Display|Open|Close|Disable|Enable))/i.test(
name
);
// canEdit/shouldShow are common local UI or permission decisions. Require
// business evidence outside the helper name before treating them as domain logic.
const ruleContext = declaration
.replace(name, '')
.replace(STRING_LITERAL_RE, '')
.replace(UI_PERMISSION_OR_LOCAL_STATE_RE, '');
if (uiDecisionName && !BUSINESS_RULE_CONTEXT_RE.test(ruleContext)) {
continue;
}
return true;
}
return false;
}
/**

@@ -284,7 +334,12 @@ * @typedef {object} DesignSmell

if (loc >= GOD_LOC && exportsCount >= GOD_EXPORTS) {
if (
loc >= GOD_LOC &&
exportsCount >= GOD_EXPORTS &&
!isNonProductionPilotPath(rel) &&
!hasGeneratedSourceBanner(source)
) {
godEvidence.push(rel);
}
if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) && DOMAIN_LOGIC_UI_RE.test(source)) {
if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) && hasDomainLogicInUi(source)) {
domainInUi.push(rel);

@@ -361,3 +416,3 @@ }

evidence: domainInUi.slice(0, 12),
fix: 'Move pure rules into Domain (or shared pure module under Domain globs) and import from UI.',
fix: 'Move the pure rule into Domain, expose it through Application, and have UI import the Application boundary (never Presentation → Domain directly).',
})

@@ -408,3 +463,3 @@ );

/**
* Whether edge-clean ENFORCE should still report design-weak residual.
* Whether edge-clean analysis should still report design-weak residual.
*

@@ -426,5 +481,12 @@ * @param {DesignSmell[]} smells

* Design fitness summary for doctor JSON / human.
* @param {DesignSmell[]} smells
* @param {{ activeViolations?: number, governedPercent?: number|null, totalFiles?: number|null, operatingMode?: string }} ctx
*/
export function summarizeDesignFitness(smells, ctx = {}) {
const designWeak = isDesignWeak(smells, ctx);
const mode =
typeof ctx.operatingMode === 'string' &&
/^(?:suggest|adapt|enforce)$/.test(ctx.operatingMode)
? ctx.operatingMode.toUpperCase()
: null;
return {

@@ -436,3 +498,3 @@ status: designWeak ? 'design-weak' : smells.length > 0 ? 'smells-with-open-edges' : 'ok',

label: designWeak
? 'ENFORCE · design-weak — edges clean; Shape residual remains (see designSmells / plan B)'
? `${mode ? `${mode} · ` : ''}design-weak — edges clean; Shape residual remains (see designSmells / plan B)`
: smells.length > 0

@@ -483,3 +545,3 @@ ? 'Design smells present alongside open edge debt'

case 'domain-logic-in-ui':
return 'can*/calculate* pure rules live under Domain; UI imports them only';
return 'can*/calculate* pure rules live under Domain; Application exposes them; UI imports Application only';
case 'facade-sql-in-routes':

@@ -486,0 +548,0 @@ return '0 route/controller files import ORM/SQL clients; queries in adapters';

@@ -25,2 +25,3 @@ /** Coverage, plan, and doctor CLI surfaces (roadmap #11). */

detectSkillGaps,
skillGapsForActiveHost,
detectCodexHomeGap,

@@ -335,3 +336,3 @@ codexConcernIsActive,

statement =
'No active edge violations — contract edges are clean, but design smells remain (ENFORCE · design-weak). Shape residual is plan B only; not healthy finished.';
'No active edge violations — contract edges are clean, but design smells remain (design-weak). Shape residual is plan B only; not healthy finished.';
}

@@ -366,3 +367,3 @@ if (completeness !== ANALYSIS_COMPLETENESS.complete) statement = analysisIncompleteStatement(completeness);

designWeakLabel:
'ENFORCE · design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
'Design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
...DESIGN_WEAK_HONESTY_FLAGS,

@@ -428,3 +429,3 @@ }

color.yellow(
` ENFORCE · design-weak — ${plan.patternBets?.length ?? 0} pattern bet(s) (never auto-apply)`
` Design-weak — ${plan.patternBets?.length ?? 0} pattern bet(s) (never auto-apply)`
)

@@ -543,2 +544,22 @@ );

}).length;
const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
const totalFilesEarly = cov.governed.totalFiles || 0;
const operatingMode = resolveOperatingMode({
governedPercent: emptyScopeEarly ? 0 : cov.governed.percent,
// planMet uses blocking only; type-only placement debt alone must not force ADAPT.
planMet:
analysisComplete &&
blockingActive === 0 &&
!emptyScopeEarly &&
cov.governed.percent >= 50,
mature: cov.governed.totalFiles >= 150,
totalFiles: cov.governed.totalFiles,
emptyLayers: cov.emptyLayers,
coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
presentationShare:
totalFilesEarly > 0 && presentationRowEarly
? presentationRowEarly.files / totalFilesEarly
: null,
});
const designSmells = detectDesignSmells(root, config, files, cov);

@@ -549,2 +570,3 @@ const observedDesignFitness = summarizeDesignFitness(designSmells, {

totalFiles: cov.governed.totalFiles,
operatingMode,
});

@@ -585,23 +607,2 @@ const designFitness = analysisComplete ? observedDesignFitness : {

const residualPilot = pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
const totalFilesEarly = cov.governed.totalFiles || 0;
const operatingMode = resolveOperatingMode({
governedPercent: emptyScopeEarly ? 0 : cov.governed.percent,
// planMet uses blocking (failsStrict !== false) only — type-only placement debt alone
// must not force adapt via unmet plan (parity with merge/exit and productHonesty).
planMet:
analysisComplete &&
blockingActive === 0 &&
!emptyScopeEarly &&
cov.governed.percent >= 50,
mature: cov.governed.totalFiles >= 150,
totalFiles: cov.governed.totalFiles,
emptyLayers: cov.emptyLayers,
coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
presentationShare:
totalFilesEarly > 0 && presentationRowEarly
? presentationRowEarly.files / totalFilesEarly
: null,
});
// Evidence-backed hard only (never capabilities-from-hook-files alone).

@@ -806,3 +807,2 @@ const hardWriteActive = writePath.enforcementState?.localWrite?.hard === true;

: warn;
// Status lights are detected states, not user-picked settings (see docs/product-voice.md).
// modeTitle alone names the light — bodies must not re-prefix Suggest/Adapt/Enforce.

@@ -818,4 +818,4 @@ const modeHelp = {

const modeTitle =
mode === 'enforce' && designFitness.designWeak
? 'ENFORCE · design-weak'
designFitness.designWeak
? `${mode.toUpperCase()} · design-weak`
: mode.toUpperCase();

@@ -1011,3 +1011,3 @@ line(

} else if (designFitness.designWeak) {
line(warn, 'None on checked edges — edges match the contract; design residual remains (ENFORCE · design-weak). Not healthy finished.');
line(warn, `None on checked edges — edges match the contract; design residual remains (${modeTitle}). Not healthy finished.`);
} else {

@@ -1080,3 +1080,3 @@ line(ok, 'None — the code matches the contract on checked edges');

console.log(color.bold('Gates & skills'));
if (gatesMissing.length === 0) line(ok, 'Shared gate files present (AGENTS.md, .mcp.json, CI)');
if (gatesMissing.length === 0) line(ok, 'Shared gate artifacts found on disk (AGENTS.md, .mcp.json, CI); runtime activation is reported separately');
else {

@@ -1086,8 +1086,8 @@ line(bad, `Missing gates: ${gatesMissing.join(', ')}`);

}
// Report Codex legacy prompts and other-host missing/stale independently (never exclusive).
const legacyCodex = skillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
const codexLegacySafeDelete = skillGaps.some(
const humanSkillGaps = skillGapsForActiveHost(skillGaps);
const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
const codexLegacySafeDelete = humanSkillGaps.some(
(g) => g.tool === 'codex' && g.legacyAdvisory && g.catalogComplete
);
const remainingGaps = skillGaps.filter(
const remainingGaps = humanSkillGaps.filter(
(g) => !(g.tool === 'codex' && (g.legacyPromptsOnly || g.legacyAdvisory))

@@ -1122,3 +1122,3 @@ );

codexHomeGap.missing > 0 ? `${codexHomeGap.missing} missing` : null,
codexHomeGap.stale > 0 ? `${codexHomeGap.stale} content-behind-package` : null,
codexHomeGap.stale > 0 ? `${codexHomeGap.stale} content-behind-package` : null, codexHomeGap.catalogStateReason,
].filter(Boolean);

@@ -1131,3 +1131,3 @@ const deferred = !codexConcernIsActive();

line(warn, `Codex home skills ${parts.join(', ')}`);
actions.push('refresh Codex home skills (--install-agent-gates --skills-only --codex-home --force)');
actions.push(codexHomeGap.catalogMetadataInvalid ? 'repair invalid Codex home catalog metadata after verifying the newest installed version' : 'refresh Codex home skills (--install-agent-gates --skills-only --codex-home --force)');
}

@@ -1246,3 +1246,3 @@ }

color.dim(
' Shape residual is the primary door under ENFORCE · design-weak — do not skill-shop explore vs coverage vs think.'
` Shape residual is the primary door under ${modeTitle} — do not skill-shop explore vs coverage vs think.`
)

@@ -1249,0 +1249,0 @@ );

@@ -13,2 +13,31 @@ /**

function normalizeProjectRelativePath(value) {
const normalized = value.replace(/\\/g, '/');
if (
!normalized ||
normalized.startsWith('/') ||
/^[A-Za-z]:/.test(normalized) ||
normalized.includes('\0')
) {
return undefined;
}
const segments = [];
for (const segment of normalized.split('/')) {
if (!segment || segment === '.') continue;
if (segment === '..') return undefined;
segments.push(segment);
}
return segments.length > 0 ? segments.join('/') : undefined;
}
function isWithinRoot(root, candidate) {
const relative = path.relative(root, candidate);
return (
relative === '' ||
(!relative.startsWith(`..${path.sep}`) &&
relative !== '..' &&
!path.isAbsolute(relative))
);
}
/**

@@ -33,2 +62,3 @@ * @param {string} root

const referenced = new Set();
const canonicalRoot = fs.realpathSync(root);

@@ -42,6 +72,8 @@ for (const layer of Object.keys(refs).sort()) {

}
if (relRaw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(relRaw)) {
const rel = normalizeProjectRelativePath(relRaw);
if (!rel) {
errors.push({
path: pathKey,
message: 'must be a project-relative path (absolute paths are not allowed)',
message:
'must be a project-relative path without absolute roots or parent-directory traversal',
});

@@ -58,9 +90,14 @@ continue;

const rel = relRaw.replace(/\\/g, '/').replace(/^\.\//, '');
referenced.add(rel);
const absolute = path.resolve(root, rel);
opts.observeInput?.(absolute, 'arkrules');
if (!fs.existsSync(absolute)) {
const lexicalTarget = path.resolve(canonicalRoot, ...rel.split('/'));
if (!isWithinRoot(canonicalRoot, lexicalTarget)) {
errors.push({
path: pathKey,
message: `referenced ArkRules path ${JSON.stringify(rel)} resolves outside the project root`,
});
continue;
}
if (!fs.existsSync(lexicalTarget)) {
errors.push({
path: pathKey,
message: `referenced ArkRules file ${JSON.stringify(rel)} is missing`,

@@ -70,2 +107,22 @@ });

}
let absolute;
try {
absolute = fs.realpathSync(lexicalTarget);
} catch (error) {
errors.push({
path: pathKey,
message: `referenced ArkRules file ${JSON.stringify(rel)} could not be resolved: ${
error instanceof Error ? error.message : String(error)
}`,
});
continue;
}
if (!isWithinRoot(canonicalRoot, absolute)) {
errors.push({
path: pathKey,
message: `referenced ArkRules path ${JSON.stringify(rel)} resolves outside the project root`,
});
continue;
}
opts.observeInput?.(absolute, 'arkrules');
let content;

@@ -98,5 +155,12 @@ try {

// Drift: unreferenced files under arkrules/
const arkrulesDir = path.join(root, 'arkrules');
if (fs.existsSync(arkrulesDir) && fs.statSync(arkrulesDir).isDirectory()) {
for (const name of fs.readdirSync(arkrulesDir).sort()) {
const arkrulesDir = path.join(canonicalRoot, 'arkrules');
const resolvedArkRulesDir = fs.existsSync(arkrulesDir)
? fs.realpathSync(arkrulesDir)
: undefined;
if (
resolvedArkRulesDir &&
isWithinRoot(canonicalRoot, resolvedArkRulesDir) &&
fs.statSync(resolvedArkRulesDir).isDirectory()
) {
for (const name of fs.readdirSync(resolvedArkRulesDir).sort()) {
if (!name.endsWith('.json')) continue;

@@ -103,0 +167,0 @@ const rel = `arkrules/${name}`;

// Generated from enforcement-state.source.mjs — run npm run generate:packaged-tooling.
import m from"node:fs";import{createRequire as q}from"node:module";import h from"node:path";const n="unverified";function S(a){const e=h.join(a,"package.json");try{if(JSON.parse(m.readFileSync(e,"utf8"))?.name==="arkgate"&&m.statSync(h.join(a,"bin","ark-check.mjs"),{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"package.json + bin/ark-check.mjs (self-host)",selfHost:!0}}catch{}try{const r=q(e).resolve("arkgate/package.json"),o=h.dirname(r),i=JSON.parse(m.readFileSync(r,"utf8")),s=h.join(o,"bin","ark-check.mjs");if(i?.name==="arkgate"&&m.statSync(s,{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"arkgate/package.json via project resolver",selfHost:!1}}catch{}return{installed:!1,source:"arkgate/package.json unresolved from project",selfHost:!1}}function M(a){return a.length>0?a:["filesystem scan (no matching configuration)"]}function g({supported:a,configuredPaths:e,installed:r,active:o,runtimeObserved:i,operation:s,operationCoverage:v,bypassable:u,required:c,hard:t,sources:l}){const p=e.length>0,d=!!r.installed;return{supported:a,analyzed:!0,configured:p,installed:d,active:o,runtimeObserved:i,operation:s,operationCoverage:v,bypassable:u,required:c,hard:t,evidence:[...M(e).map(f=>({field:"configured",source:f,value:p})),{field:"installed",source:r.source,value:d},{field:"active",source:l.active,value:o},{field:"runtimeObserved",source:l.runtimeObserved,value:i},{field:"operationCoverage",source:l.operationCoverage,value:v},{field:"bypassable",source:l.bypassable,value:u},{field:"required",source:l.required,value:c},{field:"hard",source:l.hard,value:t}]}}function O(a,e){const r=S(a),o=!!e.support?.capabilities?.["hard-write"],i=!!e.support?.capabilities?.["advisory-write"],s=e.capabilityEvidence["hard-write"],v=e.capabilityEvidence["advisory-write"],u=e.capabilityEvidence["merge-gate"],c=e.enforcementLadder.localWrite,t=typeof c.operationCovered=="boolean",l=t?c.operationCovered:n,p=t&&l===!0,d=!!(o&&r.installed&&p&&c.hard===!0),f=t?p&&r.installed:o&&s.length>0&&r.installed?n:!1,b=i&&v.length>0&&r.installed?n:!1,y=!!(e.ci?.failClosed&&u.length>0),C=y&&r.installed?n:!1;return{schemaVersion:"1.1",activeHost:e.activeHost,localWrite:g({supported:o,configuredPaths:s,installed:r,active:f,runtimeObserved:t,operation:t?c.operation??null:null,operationCoverage:l,bypassable:d?!1:o&&!t?n:!0,required:n,hard:d,sources:{active:t?"observed PreToolUse attempt":"runtime observation unavailable",runtimeObserved:t?"fresh PreToolUse invocation":"runtime observation unavailable",operationCoverage:t?"active-host operation matcher":"operation not observed",bypassable:d?"observed hard write boundary":"host runtime bypass evidence unavailable",required:"local host policy unavailable",hard:d?"fresh covered active-host invocation":"hardness not proven for this invocation"}}),advisoryMcp:g({supported:i,configuredPaths:v,installed:r,active:b,runtimeObserved:!1,operation:null,operationCoverage:n,bypassable:!0,required:n,hard:!1,sources:{active:"MCP runtime observation unavailable",runtimeObserved:"doctor did not observe an MCP tool invocation",operationCoverage:"advisory MCP is caller-invoked",bypassable:"advisory MCP does not intercept every write",required:"local host policy unavailable",hard:"MCP presence is advisory and never proves a hard boundary"}}),ciMerge:g({supported:!0,configuredPaths:y?u:[],installed:r,active:C,runtimeObserved:!1,operation:"merge",operationCoverage:y?n:!1,bypassable:y?n:!0,required:n,hard:!1,sources:{active:"CI run and provider enforcement not observed",runtimeObserved:"provider evidence unavailable",operationCoverage:"required-status operation coverage unavailable",bypassable:"branch-protection evidence unavailable",required:"branch-protection evidence unavailable",hard:"merge hardness requires fresh provider evidence"}})}}function $(a,e,r,o){return{...a,...o,evidence:[...a.evidence.filter(i=>!e.includes(i.field)),...e.map(i=>({field:i,source:r,value:o[i]}))]}}function R(a,e){if(!e)return a;const r=e.reason==="provider-policy-unavailable-plan"||e.policyReason==="unavailable-plan",o=e.available===!0,i=!o&&!r&&(e.reason==="provider-enforcement-unverified"||e.reason==="gh-cli-unavailable"||e.reason==="gh-repo-unavailable"||!!e.reason);if(!o&&e.runtimeObserved!==!0&&!r&&!i)return a;const s=o?typeof e.arkCheckRequired=="boolean"?e.arkCheckRequired:n:r?!1:n,v=!!(a.enforcementState.ciMerge.configured&&a.enforcementState.ciMerge.installed),u=s===!0?v:s===!1?!1:v?n:!1,c=u===!0?e.arkCheckSourceBound===!1?!0:n:s===!1?!0:v?n:!0,t=e.runtimeObserved===!0,l=o?`GitHub branch protection (${e.repo??"repository"}:${e.branch??"default"})`:r?`GitHub provider policy unavailable (plan) (${e.repo??"repository"}:${e.branch??"default"})`:`GitHub CI runtime (${e.repo??"repository"})`,p=s,d=u===!0&&c===!1&&p===!0,f=$(a.enforcementState.ciMerge,["active","runtimeObserved","operationCoverage","bypassable","required","hard"],l,{active:u,runtimeObserved:t,operationCoverage:p,bypassable:c,required:s,hard:d}),b={...a,enforcementState:{...a.enforcementState,ciMerge:f},enforcementLadder:{...a.enforcementLadder,ciMerge:{...a.enforcementLadder.ciMerge,requiredStatus:s,...e.latestCiRun?{latestCiRun:e.latestCiRun}:{},...r?{providerPolicy:"unavailable-plan"}:{}}}};return(r||e.reason)&&(b.providerEnforcement={available:o,reason:e.reason||(r?"provider-policy-unavailable-plan":"provider-enforcement-unverified"),policyReason:e.policyReason||(r?"unavailable-plan":null),runtimeObserved:t,latestCiRun:e.latestCiRun??null,hard:d===!0}),b}function k(a,e){const r=o=>o===!0?"yes":o===!1?"no":String(o);return`${a} \u2014 supported: ${r(e.supported)} \xB7 analyzed: ${r(e.analyzed)} \xB7 configured: ${r(e.configured)} \xB7 installed: ${r(e.installed)} \xB7 runtime observed: ${r(e.runtimeObserved)} \xB7 operation: ${e.operation??"none"} \xB7 operation covered: ${r(e.operationCoverage)} \xB7 active: ${r(e.active)} \xB7 bypassable: ${r(e.bypassable)} \xB7 required: ${r(e.required)} \xB7 hard: ${r(e.hard)}`}function x(a){const e=[{level:a.localWrite.active===!0?"ok":"warn",text:k("Local write",a.localWrite)},{level:"warn",text:k("Advisory MCP",a.advisoryMcp)},{level:a.ciMerge.required===!0?"ok":"warn",text:k("CI merge",a.ciMerge)}];return a.localWrite.active===n&&a.localWrite.hard===!1&&e.push({level:"bad",text:"RED FLAG: local hook assets exist, but this active-host operation was not observed at runtime; hard blocking is unverified."}),a.activeHost==="unknown"&&e.push({level:"warn",text:"Active host unknown for this invocation \u2014 enforcementState is session projection only. See writePath.inventory for on-disk host hooks; hard write is never claimed without runtime proof."}),e}export{O as buildEnforcementState,x as enforcementDoctorLines,S as packageInstallation,R as withCiProviderEvidence};
import m from"node:fs";import{createRequire as q}from"node:module";import h from"node:path";const n="unverified";function O({configuredOnDisk:a=!1,restartRequired:e=a}={}){return{configuredOnDisk:!!a,restartRequired:!!e,runtimeObserved:!1,identityMatch:n,active:!1}}function M(a){const e=h.join(a,"package.json");try{if(JSON.parse(m.readFileSync(e,"utf8"))?.name==="arkgate"&&m.statSync(h.join(a,"bin","ark-check.mjs"),{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"package.json + bin/ark-check.mjs (self-host)",selfHost:!0}}catch{}try{const r=q(e).resolve("arkgate/package.json"),o=h.dirname(r),i=JSON.parse(m.readFileSync(r,"utf8")),s=h.join(o,"bin","ark-check.mjs");if(i?.name==="arkgate"&&m.statSync(s,{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"arkgate/package.json via project resolver",selfHost:!1}}catch{}return{installed:!1,source:"arkgate/package.json unresolved from project",selfHost:!1}}function S(a){return a.length>0?a:["filesystem scan (no matching configuration)"]}function g({supported:a,configuredPaths:e,installed:r,active:o,runtimeObserved:i,operation:s,operationCoverage:u,bypassable:v,required:c,hard:t,sources:l}){const p=e.length>0,d=!!r.installed;return{supported:a,analyzed:!0,configured:p,installed:d,active:o,runtimeObserved:i,operation:s,operationCoverage:u,bypassable:v,required:c,hard:t,evidence:[...S(e).map(f=>({field:"configured",source:f,value:p})),{field:"installed",source:r.source,value:d},{field:"active",source:l.active,value:o},{field:"runtimeObserved",source:l.runtimeObserved,value:i},{field:"operationCoverage",source:l.operationCoverage,value:u},{field:"bypassable",source:l.bypassable,value:v},{field:"required",source:l.required,value:c},{field:"hard",source:l.hard,value:t}]}}function E(a,e){const r=M(a),o=!!e.support?.capabilities?.["hard-write"],i=!!e.support?.capabilities?.["advisory-write"],s=e.capabilityEvidence["hard-write"],u=e.capabilityEvidence["advisory-write"],v=e.capabilityEvidence["merge-gate"],c=e.enforcementLadder.localWrite,t=typeof c.operationCovered=="boolean",l=t?c.operationCovered:n,p=t&&l===!0,d=!!(o&&r.installed&&p&&c.hard===!0),f=t?p&&r.installed:o&&s.length>0&&r.installed?n:!1,b=i&&u.length>0&&r.installed?n:!1,y=!!(e.ci?.failClosed&&v.length>0),C=y&&r.installed?n:!1;return{schemaVersion:"1.1",activeHost:e.activeHost,localWrite:g({supported:o,configuredPaths:s,installed:r,active:f,runtimeObserved:t,operation:t?c.operation??null:null,operationCoverage:l,bypassable:d?!1:o&&!t?n:!0,required:n,hard:d,sources:{active:t?"observed PreToolUse attempt":"runtime observation unavailable",runtimeObserved:t?"fresh PreToolUse invocation":"runtime observation unavailable",operationCoverage:t?"active-host operation matcher":"operation not observed",bypassable:d?"observed hard write boundary":"host runtime bypass evidence unavailable",required:"local host policy unavailable",hard:d?"fresh covered active-host invocation":"hardness not proven for this invocation"}}),advisoryMcp:g({supported:i,configuredPaths:u,installed:r,active:b,runtimeObserved:!1,operation:null,operationCoverage:n,bypassable:!0,required:n,hard:!1,sources:{active:"MCP runtime observation unavailable",runtimeObserved:"doctor did not observe an MCP tool invocation",operationCoverage:"advisory MCP is caller-invoked",bypassable:"advisory MCP does not intercept every write",required:"local host policy unavailable",hard:"MCP presence is advisory and never proves a hard boundary"}}),ciMerge:g({supported:!0,configuredPaths:y?v:[],installed:r,active:C,runtimeObserved:!1,operation:"merge",operationCoverage:y?n:!1,bypassable:y?n:!0,required:n,hard:!1,sources:{active:"CI run and provider enforcement not observed",runtimeObserved:"provider evidence unavailable",operationCoverage:"required-status operation coverage unavailable",bypassable:"branch-protection evidence unavailable",required:"branch-protection evidence unavailable",hard:"merge hardness requires fresh provider evidence"}})}}function $(a,e,r,o){return{...a,...o,evidence:[...a.evidence.filter(i=>!e.includes(i.field)),...e.map(i=>({field:i,source:r,value:o[i]}))]}}function x(a,e){if(!e)return a;const r=e.reason==="provider-policy-unavailable-plan"||e.policyReason==="unavailable-plan",o=e.available===!0,i=!o&&!r&&(e.reason==="provider-enforcement-unverified"||e.reason==="gh-cli-unavailable"||e.reason==="gh-repo-unavailable"||!!e.reason);if(!o&&e.runtimeObserved!==!0&&!r&&!i)return a;const s=o?typeof e.arkCheckRequired=="boolean"?e.arkCheckRequired:n:r?!1:n,u=!!(a.enforcementState.ciMerge.configured&&a.enforcementState.ciMerge.installed),v=s===!0?u:s===!1?!1:u?n:!1,c=v===!0?e.arkCheckSourceBound===!1?!0:n:s===!1?!0:u?n:!0,t=e.runtimeObserved===!0,l=o?`GitHub branch protection (${e.repo??"repository"}:${e.branch??"default"})`:r?`GitHub provider policy unavailable (plan) (${e.repo??"repository"}:${e.branch??"default"})`:`GitHub CI runtime (${e.repo??"repository"})`,p=s,d=v===!0&&c===!1&&p===!0,f=$(a.enforcementState.ciMerge,["active","runtimeObserved","operationCoverage","bypassable","required","hard"],l,{active:v,runtimeObserved:t,operationCoverage:p,bypassable:c,required:s,hard:d}),b={...a,enforcementState:{...a.enforcementState,ciMerge:f},enforcementLadder:{...a.enforcementLadder,ciMerge:{...a.enforcementLadder.ciMerge,requiredStatus:s,...e.latestCiRun?{latestCiRun:e.latestCiRun}:{},...r?{providerPolicy:"unavailable-plan"}:{}}}};return(r||e.reason)&&(b.providerEnforcement={available:o,reason:e.reason||(r?"provider-policy-unavailable-plan":"provider-enforcement-unverified"),policyReason:e.policyReason||(r?"unavailable-plan":null),runtimeObserved:t,latestCiRun:e.latestCiRun??null,hard:d===!0}),b}function k(a,e){const r=o=>o===!0?"yes":o===!1?"no":String(o);return`${a} \u2014 supported: ${r(e.supported)} \xB7 analyzed: ${r(e.analyzed)} \xB7 configured: ${r(e.configured)} \xB7 installed: ${r(e.installed)} \xB7 runtime observed: ${r(e.runtimeObserved)} \xB7 operation: ${e.operation??"none"} \xB7 operation covered: ${r(e.operationCoverage)} \xB7 active: ${r(e.active)} \xB7 bypassable: ${r(e.bypassable)} \xB7 required: ${r(e.required)} \xB7 hard: ${r(e.hard)}`}function B(a){const e=[{level:a.localWrite.active===!0?"ok":"warn",text:k("Local write",a.localWrite)},{level:"warn",text:k("Advisory MCP",a.advisoryMcp)},{level:a.ciMerge.required===!0?"ok":"warn",text:k("CI merge",a.ciMerge)}];return a.localWrite.active===n&&a.localWrite.hard===!1&&e.push({level:"bad",text:"RED FLAG: local hook assets exist, but this active-host operation was not observed at runtime; hard blocking is unverified."}),a.activeHost==="unknown"&&e.push({level:"warn",text:"Active host unknown for this invocation \u2014 enforcementState is session projection only. See writePath.inventory for on-disk host hooks; hard write is never claimed without runtime proof."}),e}export{E as buildEnforcementState,O as codexRuntimeActivation,B as enforcementDoctorLines,M as packageInstallation,x as withCiProviderEvidence};

@@ -7,2 +7,4 @@ /**

import { fileURLToPath } from 'node:url';
import { codexProjectMcpIsValid } from './codex-home.mjs';
import { enforcingArkRunText } from './github-enforcement.mjs';

@@ -124,2 +126,3 @@ export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');

const COMPACT_ROUTER = /<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/;
const FAIL_CLOSED_ARK_FLAG = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;

@@ -154,3 +157,5 @@ const COMPACT_HOST_FILES = {

function hasCompactHostRegistration(root, host) {
if (host === 'none') return fs.existsSync(path.join(root, '.mcp.json'));
if (host === 'none') return hasArkMcpRegistration(root);
if (host === 'cursor') return hasArkMcpRegistration(root, '.cursor/mcp.json');
if (host === 'codex') return hasCodexCompactRegistration(root);
const files = COMPACT_HOST_FILES[host];

@@ -160,5 +165,432 @@ return Boolean(files) && files.every((relativePath) => fs.existsSync(path.join(root, relativePath)));

function executableName(value) {
return path.basename(String(value).trim().replace(/\\/g, '/')).replace(/\.(?:cmd|exe)$/i, '');
}
function arkMcpArgs(server) {
if (!server || typeof server !== 'object' || typeof server.command !== 'string') return null;
if (server.args !== undefined && !Array.isArray(server.args)) return null;
const args = server.args ?? [];
if (!args.every((value) => typeof value === 'string')) return null;
const command = executableName(server.command);
const isArkBin = (value) => /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(executableName(value));
if ([server.command, ...args].filter(isArkBin).length !== 1) return null;
if (isArkBin(server.command)) return args;
if ((command === 'npx' || command === 'yarn') && isArkBin(args[0])) return args.slice(1);
if (command === 'pnpm') {
const binIndex =
args[0] === 'exec'
? 1
: args[0] === '--config.verify-deps-before-run=false' && args[1] === 'exec'
? 2
: -1;
return binIndex >= 0 && isArkBin(args[binIndex]) ? args.slice(binIndex + 1) : null;
}
if (command === 'node') {
const script = String(args[0] ?? '').replace(/\\/g, '/');
return /(?:^|\/)bin\/ark-mcp\.mjs$/.test(script) ? args.slice(1) : null;
}
return null;
}
function projectBindingArguments(args) {
if (args.length !== 4) return null;
const values = {};
for (let index = 0; index < args.length; index += 2) {
const name = args[index];
if ((name !== '--root' && name !== '--config') || values[name] !== undefined) return null;
const value = args[index + 1];
if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) return null;
values[name] = value;
}
return values['--root'] && values['--config']
? { root: values['--root'], config: values['--config'] }
: null;
}
function nativePathInput(value) {
const text = String(value).trim();
return path.sep === '/' ? text.replace(/\\/g, '/') : text.replace(/\//g, '\\');
}
function canonicalNativePath(value) {
const absolute = path.resolve(value);
let canonical = absolute;
try {
canonical = fs.realpathSync.native(absolute);
} catch {
/* A missing candidate still compares by its normalized absolute path. */
}
const normalized = path.normalize(canonical);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function bindingTargetsProject(binding, root, invocationRoot = root) {
const resolvedRoot = path.resolve(invocationRoot, nativePathInput(binding.root));
if (canonicalNativePath(resolvedRoot) !== canonicalNativePath(root)) return false;
const nativeConfig = nativePathInput(binding.config);
const resolvedConfig = path.isAbsolute(nativeConfig)
? nativeConfig
: path.resolve(resolvedRoot, nativeConfig);
return (
canonicalNativePath(resolvedConfig) ===
canonicalNativePath(path.join(root, 'ark.config.json'))
);
}
function registrationTargetsProject(server, args, root) {
const binding = projectBindingArguments(args);
if (!binding) return false;
if (server.cwd !== undefined && (typeof server.cwd !== 'string' || !server.cwd.trim())) {
return false;
}
const invocationRoot = server.cwd
? path.resolve(root, nativePathInput(server.cwd))
: root;
return bindingTargetsProject(binding, root, invocationRoot);
}
export function hasArkMcpRegistration(root, relativePath = '.mcp.json') {
try {
const server = readJson(path.join(root, relativePath))?.mcpServers?.ark;
const args = arkMcpArgs(server);
return Boolean(args && registrationTargetsProject(server, args, root));
} catch {
return false;
}
}
function commandArkMcpArgs(command) {
if (typeof command !== 'string') return null;
const words = [];
let consumed = 0;
for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(&&|\|\||[;|#])|([^\s;&|#]+)/g)) {
if (command.slice(consumed, match.index).trim() || match[3]) return null;
words.push(match[1] ?? match[2] ?? match[4]);
consumed = Number(match.index) + match[0].length;
}
if (command.slice(consumed).trim()) return null;
return arkMcpArgs({ command: words[0], args: words.slice(1) });
}
function codexHookArguments(args, expectedModes) {
const allowedModes = new Set(expectedModes);
const seenModes = new Set();
const values = {};
for (let index = 0; index < args.length; index += 1) {
const name = args[index];
if (allowedModes.has(name)) {
if (seenModes.has(name)) return null;
seenModes.add(name);
continue;
}
if (!['--root', '--root-env', '--config'].includes(name) || values[name] !== undefined) {
return null;
}
const value = args[++index];
if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) return null;
values[name] = value;
}
if (
seenModes.size !== allowedModes.size ||
!values['--root'] ||
!values['--root-env'] ||
!values['--config']
) {
return null;
}
return {
root: values['--root'],
rootEnv: values['--root-env'],
config: values['--config'],
};
}
function codexHookCommandIsValid(command, root, expectedModes) {
const args = commandArkMcpArgs(command);
if (!args) return false;
const binding = codexHookArguments(args, expectedModes);
return Boolean(
binding &&
binding.rootEnv === 'CODEX_PROJECT_DIR' &&
bindingTargetsProject(binding, root)
);
}
function matcherHasExactTools(matcher, expectedTools) {
if (typeof matcher !== 'string') return false;
const tools = matcher.split('|').map((tool) => tool.trim()).filter(Boolean);
const unique = new Set(tools);
return (
tools.length === expectedTools.length &&
unique.size === expectedTools.length &&
expectedTools.every((tool) => unique.has(tool))
);
}
function hookGroupHasValidCodexContract(group, root, expectedModes, expectedTools = null) {
if (!Array.isArray(group)) return false;
return group.some(
(entry) =>
entry &&
typeof entry === 'object' &&
(!expectedTools || matcherHasExactTools(entry.matcher, expectedTools)) &&
Array.isArray(entry.hooks) &&
entry.hooks.some(
(hook) =>
hook &&
typeof hook === 'object' &&
hook.type === 'command' &&
codexHookCommandIsValid(hook.command, root, expectedModes)
)
);
}
function hasCodexCompactRegistration(root) {
try {
const config = fs.readFileSync(path.join(root, '.codex', 'config.toml'), 'utf8');
const hooks = readJson(path.join(root, '.codex', 'hooks.json'))?.hooks;
return (
codexProjectMcpIsValid(config, root) &&
hookGroupHasValidCodexContract(hooks?.SessionStart, root, ['--session-context']) &&
hookGroupHasValidCodexContract(hooks?.PreToolUse, root, [
'--hook',
'--hook-repair',
'--fail-on-new-smells',
], ['ApplyPatch', 'apply_patch', 'Write', 'Edit', 'MultiEdit'])
);
} catch {
return false;
}
}
function architectureScript(root) {
try {
const script = readPackageJson(root)?.scripts?.['check:architecture'];
return typeof script === 'string' ? script : '';
} catch {
return '';
}
}
function isFailClosedArchitectureScript(script) {
if (!script) return false;
if (
/(?:^|;|\n|(?<!&)&(?!&))\s*exit(?:\s+\/b)?\s+0(?=\s*(?:;|&&|\|\||#|$))/im.test(
script
)
) {
return false;
}
const body = script
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
const workflow = `jobs:
ark:
runs-on: ubuntu-latest
steps:
- run: |
${body}
`;
return FAIL_CLOSED_ARK_FLAG.test(enforcingArkRunText(workflow));
}
export function hasArkAgentsContract(root) {
try {
const content = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
const directCheck =
/\b(?:arkgate-check|ark-check)\b[\s\S]{0,240}--(?:strict-config|strict-merge|strict)\b/.test(
content
);
const scriptCheck =
/\b(?:npm|pnpm)\s+run\s+check:architecture\b|\byarn(?:\s+run)?\s+check:architecture\b/.test(
content
) && isFailClosedArchitectureScript(architectureScript(root));
return (
/^#{1,6}\s+Ark(?:Gate)?\s+Enforcement\b/im.test(content) &&
/\bark\.config\.json\b/i.test(content) &&
/\bauthoritative\b/i.test(content) &&
(directCheck || scriptCheck)
);
} catch {
return false;
}
}
function withFailClosedArkActions(content) {
const lines = String(content).split('\n');
for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(
/^(\s*)(-\s+)?uses:\s*['"]?pedroknigge\/arkgate@[^'"\s#]+['"]?\s*(?:#.*)?$/i
);
if (!match) continue;
const propertyIndent = match[1].length + (match[2] ? 2 : 0);
let start = index;
let stepIndent = match[2] ? match[1].length : null;
if (stepIndent === null) {
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const indent = lines[cursor].match(/^\s*/)?.[0].length ?? 0;
if (/^\s*-\s+/.test(lines[cursor]) && indent < propertyIndent) {
start = cursor;
stepIndent = indent;
break;
}
}
}
if (stepIndent === null) continue;
let end = lines.length;
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
if (!lines[cursor].trim()) continue;
const indent = lines[cursor].match(/^\s*/)?.[0].length ?? 0;
if (indent < stepIndent || (indent === stepIndent && /^\s*-\s+/.test(lines[cursor]))) {
end = cursor;
break;
}
}
const block = lines.slice(start, end).join('\n');
const strictInput = block.match(/^\s*strict-config:\s*(.*?)\s*(?:#.*)?$/im)?.[1];
if (strictInput !== undefined && !/^['"]?true['"]?$/i.test(strictInput)) {
continue;
}
lines[index] = lines[index].replace(/\buses:/, 'run:').replace(
/['"]?pedroknigge\/arkgate@[^'"\s#]+['"]?/i,
'ark-check --strict-merge'
);
}
return lines.join('\n');
}
function workflowJobSections(content) {
const lines = String(content).split('\n');
const jobsIndex = lines.findIndex((line) =>
/^\s*(?:"jobs"|'jobs'|jobs):\s*(?:#.*)?$/.test(line)
);
if (jobsIndex < 0) return { lines, jobs: [] };
const jobsIndent = lines[jobsIndex].match(/^\s*/)?.[0].length ?? 0;
let jobIndent = null;
let jobsEnd = lines.length;
const headers = [];
for (let index = jobsIndex + 1; index < lines.length; index += 1) {
if (!lines[index].trim() || /^\s*#/.test(lines[index])) continue;
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
if (indent <= jobsIndent) {
jobsEnd = index;
break;
}
const header = lines[index].match(
/^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+)):\s*(?:#.*)?$/
);
if (!header) continue;
jobIndent ??= indent;
if (indent === jobIndent) {
headers.push({ id: header[1] ?? header[2] ?? header[3], start: index });
}
}
const jobs = headers.map((header, index) => {
const end = headers[index + 1]?.start ?? jobsEnd;
const propertyIndents = lines
.slice(header.start + 1, end)
.filter((line) => line.trim() && !/^\s*#/.test(line))
.map((line) => line.match(/^\s*/)?.[0].length ?? 0)
.filter((indent) => indent > Number(jobIndent));
return {
...header,
end,
propertyIndent:
propertyIndents.length > 0 ? Math.min(...propertyIndents) : Number(jobIndent) + 2,
};
});
return { lines, jobs };
}
function jobProperty(lines, job, name) {
const matcher = new RegExp(
`^\\s*(?:"${name}"|'${name}'|${name}):\\s*(.*?)\\s*(?:#.*)?$`,
'i'
);
for (let index = job.start + 1; index < job.end; index += 1) {
if ((lines[index].match(/^\s*/)?.[0].length ?? 0) !== job.propertyIndent) continue;
const match = lines[index].match(matcher);
if (match) return { index, value: match[1].trim() };
}
return null;
}
function unquoteYamlScalar(value) {
const text = String(value).trim();
const match = text.match(/^(['"])(.*)\1$/);
return match ? match[2].trim() : text;
}
function jobCondition(lines, job) {
const condition = jobProperty(lines, job, 'if');
if (!condition) return 'default';
const value = unquoteYamlScalar(condition.value);
if (/^(?:\$\{\{\s*)?always\(\)(?:\s*\}\})?$/i.test(value)) return 'always';
if (/^(?:true|\$\{\{\s*true\s*\}\})$/i.test(value)) return 'true';
return 'conditional';
}
function jobNeeds(lines, job) {
const property = jobProperty(lines, job, 'needs');
if (!property) return { ids: [], indexes: [], valid: true };
const indexes = [property.index];
if (property.value) {
const value = unquoteYamlScalar(property.value);
const raw = value.startsWith('[') && value.endsWith(']')
? value.slice(1, -1).split(',')
: [value];
const ids = raw.map(unquoteYamlScalar).filter((id) => /^[A-Za-z0-9_-]+$/.test(id));
return { ids, indexes, valid: ids.length === raw.length && ids.length > 0 };
}
const ids = [];
for (let index = property.index + 1; index < job.end; index += 1) {
if (!lines[index].trim() || /^\s*#/.test(lines[index])) {
indexes.push(index);
continue;
}
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
if (indent <= job.propertyIndent) break;
indexes.push(index);
const item = lines[index].match(/^\s*-\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/);
if (!item) return { ids: [], indexes, valid: false };
ids.push(item[2]);
}
return { ids, indexes, valid: ids.length > 0 };
}
function withVerifiedDependencyJobs(content) {
const { lines, jobs } = workflowJobSections(content);
const byId = new Map(jobs.map((job) => [job.id, job]));
const guaranteed = (job, seen = new Set()) => {
if (!job || seen.has(job.id)) return false;
const condition = jobCondition(lines, job);
if (condition === 'conditional') return false;
if (condition === 'always') return true;
const needs = jobNeeds(lines, job);
if (!needs.valid) return false;
const nextSeen = new Set(seen).add(job.id);
return needs.ids.every((id) => guaranteed(byId.get(id), nextSeen));
};
for (const job of jobs) {
const needs = jobNeeds(lines, job);
if (
needs.valid &&
needs.ids.length > 0 &&
needs.ids.every((id) => guaranteed(byId.get(id)))
) {
// The shared analyzer treats every `needs` as skippable. Hide it only after
// this dependency chain is proven unconditional; keep uncertain/skipped needs visible.
for (const index of needs.indexes) lines[index] = '';
}
}
return lines.join('\n');
}
export function hasArkWorkflow(root) {
const workflowsDir = path.join(root, '.github', 'workflows');
if (!fs.existsSync(workflowsDir)) return false;
const declaredScript = architectureScript(root);
const script = isFailClosedArchitectureScript(declaredScript) ? declaredScript : '';
return fs

@@ -170,6 +602,7 @@ .readdirSync(workflowsDir)

const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
return (
/\bark-check\b/.test(content) ||
/\bcheck:architecture\b/.test(content) ||
/\buses\s*:\s*['"]?[^'"\s#]+\/arkgate@/i.test(content)
return FAIL_CLOSED_ARK_FLAG.test(
enforcingArkRunText(
withVerifiedDependencyJobs(withFailClosedArkActions(content)),
script
)
);

@@ -184,6 +617,5 @@ } catch {

const compactHost = compactRouterHost(root);
const required = compactHost
? REQUIRED_GATE_FILES.filter((relativePath) => relativePath !== '.mcp.json')
: REQUIRED_GATE_FILES;
const missing = required.filter((relativePath) => !fs.existsSync(path.join(root, relativePath)));
const missing = [];
if (!hasArkAgentsContract(root)) missing.push('AGENTS.md');
if (!compactHost && !hasArkMcpRegistration(root)) missing.push('.mcp.json');
if (compactHost && !hasCompactHostRegistration(root, compactHost)) {

@@ -190,0 +622,0 @@ missing.push(`compact host registration (${compactHost})`);

@@ -54,3 +54,10 @@ /** Exact local workflow evidence plus GitHub classic-protection/ruleset correlation. */

}
if (char === ';' || char === '|' || char === '\n') {
if (
char === '&' &&
(input[index - 1] === '>' || input[index - 1] === '<' || input[index + 1] === '>')
) {
current += char;
continue;
}
if (char === ';' || char === '|' || char === '&' || char === '\n') {
push(char);

@@ -74,3 +81,6 @@ continue;

text: segment.text,
enforcing: segment.terminator !== '||' && segment.terminator !== '|',
enforcing:
segment.terminator !== '||' &&
segment.terminator !== '|' &&
segment.terminator !== '&',
}));

@@ -80,3 +90,6 @@ const found = [];

const executable = executableText(segment.text);
const outerEnforcing = segment.terminator !== '||' && segment.terminator !== '|';
const outerEnforcing =
segment.terminator !== '||' &&
segment.terminator !== '|' &&
segment.terminator !== '&';
if (DIRECT_ARK.test(executable)) {

@@ -83,0 +96,0 @@ found.push({ text: segment.text, enforcing: outerEnforcing });

// Generated from hook-templates.source.mjs — run npm run generate:packaged-tooling.
import{execCommandParts as i,execRunner as s}from"../ark-shared.mjs";const c="arkgate-mcp";function l(e){const r=s(e);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",command:`${r} ${c} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit",hooks:[{type:"command",command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}]}},null,2)}
`}function p(e){const r=s(e),o="${CODEX_PROJECT_DIR:-${PWD:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${r} ${c} --session-context --root "${o}" --config ark.config.json`}]}],PreToolUse:[{matcher:"ApplyPatch|apply_patch|Write|Edit|MultiEdit",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root "${o}" --config ark.config.json`}]}]}},null,2)}
`}function g(e){const{command:r,args:o}=i(e,c,["--root",".","--config","ark.config.json"]),t=a=>a.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),n=o.map(a=>`"${t(a)}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Codex project scope).
# Restart Codex after changes; MCP servers are loaded when the project session starts.
import{execCommandParts as i,execRunner as s}from"../ark-shared.mjs";const c="arkgate-mcp";function l(o){const r=s(o);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",command:`${r} ${c} --session-context --root . --root-env CLAUDE_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit",hooks:[{type:"command",command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root . --root-env CLAUDE_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
`}function p(o){const r=s(o);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${r} ${c} --session-context --root . --root-env CODEX_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"ApplyPatch|apply_patch|Write|Edit|MultiEdit",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root . --root-env CODEX_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
`}function f(o){const{command:r,args:e}=i(o,c,["--root",".","--config","ark.config.json"]),t=a=>a.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),n=e.map(a=>`"${t(a)}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Codex project scope).
# CONFIGURED ON DISK \u2014 RUNTIME NOT VERIFIED.
# Restart Codex, then call ark_identity with expectedRoot before trusting MCP verdicts.
[mcp_servers.ark]
command = "${t(r)}"
args = [${n}]
`}function f(e){const{command:r,args:o}=i(e,c,["--root",".","--config","ark.config.json"]),t=o.map(n=>`"${n.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Grok Build project scope).
`}function g(o){const{command:r,args:e}=i(o,c,["--root",".","--config","ark.config.json"]),t=e.map(n=>`"${n.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Grok Build project scope).
# Restart Grok (or /mcps \u2192 refresh) after changes. Also loads repo-root .mcp.json.

@@ -14,7 +15,7 @@ [mcp_servers.ark]

args = [${t}]
`}function u(e){const r=s(e),o="${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${r} ${c} --session-context --root "${o}" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit|write|search_replace",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root "${o}" --config ark.config.json`}]}]}},null,2)}
`}function k(e){const r=s(e);return`${JSON.stringify({"ark-write-gate":{PreToolUse:[{matcher:"write_to_file|replace_file_content|multi_replace_file_content",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root "\${PWD:-.}" --config ark.config.json`}]}]}},null,2)}
`}function d(e){const{command:r,args:o}=i(e,c,["--root",".","--config","ark.config.json"]);return`${JSON.stringify({$schema:"https://opencode.ai/config.json",mcp:{ark:{type:"local",command:[r,...o],enabled:!0}}},null,2)}
`}function $(e,r){let o,t;try{o=e&&e.trim()?JSON.parse(e):{},t=JSON.parse(r)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o)||!t||typeof t!="object"||Array.isArray(t))return null;const n=t["ark-write-gate"];if(!n||typeof n!="object")return null;const a={...o,"ark-write-gate":n};return`${JSON.stringify(a,null,2)}
`}function h(e,r){let o,t;try{o=e&&e.trim()?JSON.parse(e):{},t=JSON.parse(r)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o))return null;const n={...o};!n.$schema&&t.$schema&&(n.$schema=t.$schema);const a=o.mcp&&typeof o.mcp=="object"&&!Array.isArray(o.mcp)?{...o.mcp}:{};return a.ark=t.mcp.ark,n.mcp=a,`${JSON.stringify(n,null,2)}
`}export{c as PREFERRED_MCP_BIN,k as antigravityHooks,l as claudeSettings,p as codexHooks,g as codexProjectConfig,u as grokHooks,f as grokProjectConfig,$ as mergeAntigravityArkHook,h as mergeOpencodeArkMcp,d as opencodeProjectConfig};
`}function u(o){const r=s(o);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${r} ${c} --session-context --root . --root-env GROK_WORKSPACE_ROOT,CLAUDE_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit|write|search_replace",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root . --root-env GROK_WORKSPACE_ROOT,CLAUDE_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
`}function k(o){const r=s(o);return`${JSON.stringify({"ark-write-gate":{PreToolUse:[{matcher:"write_to_file|replace_file_content|multi_replace_file_content",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root . --config ark.config.json`}]}]}},null,2)}
`}function d(o){const{command:r,args:e}=i(o,c,["--root",".","--config","ark.config.json"]);return`${JSON.stringify({$schema:"https://opencode.ai/config.json",mcp:{ark:{type:"local",command:[r,...e],enabled:!0}}},null,2)}
`}function h(o,r){let e,t;try{e=o&&o.trim()?JSON.parse(o):{},t=JSON.parse(r)}catch{return null}if(!e||typeof e!="object"||Array.isArray(e)||!t||typeof t!="object"||Array.isArray(t))return null;const n=t["ark-write-gate"];if(!n||typeof n!="object")return null;const a={...e,"ark-write-gate":n};return`${JSON.stringify(a,null,2)}
`}function y(o,r){let e,t;try{e=o&&o.trim()?JSON.parse(o):{},t=JSON.parse(r)}catch{return null}if(!e||typeof e!="object"||Array.isArray(e))return null;const n={...e};!n.$schema&&t.$schema&&(n.$schema=t.$schema);const a=e.mcp&&typeof e.mcp=="object"&&!Array.isArray(e.mcp)?{...e.mcp}:{};return a.ark=t.mcp.ark,n.mcp=a,`${JSON.stringify(n,null,2)}
`}export{c as PREFERRED_MCP_BIN,k as antigravityHooks,l as claudeSettings,p as codexHooks,f as codexProjectConfig,u as grokHooks,g as grokProjectConfig,h as mergeAntigravityArkHook,y as mergeOpencodeArkMcp,d as opencodeProjectConfig};

@@ -38,2 +38,18 @@ /**

function canonicalPathWithMissingTail(value) {
const resolved = path.resolve(value);
let current = resolved;
const tail = [];
while (true) {
try {
return path.join(fs.realpathSync(current), ...tail.reverse());
} catch {
const parent = path.dirname(current);
if (parent === current) return resolved;
tail.push(path.basename(current));
current = parent;
}
}
}
/**

@@ -44,16 +60,20 @@ * Resolve an import specifier to a repo-relative path.

export function resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases) {
const canonicalRoot = canonicalPathWithMissingTail(root);
let abs;
if (specifier.startsWith('./') || specifier.startsWith('../')) {
if (!fromFilePath) return undefined;
const fromAbs = path.isAbsolute(fromFilePath)
? fromFilePath
: path.resolve(root, fromFilePath);
abs = path.resolve(path.dirname(fromAbs), specifier);
const fromAbs = canonicalPathWithMissingTail(
path.isAbsolute(fromFilePath) ? fromFilePath : path.resolve(root, fromFilePath)
);
abs = canonicalPathWithMissingTail(path.resolve(path.dirname(fromAbs), specifier));
} else {
const alias = tsAliases.aliases.find((a) => specifier.startsWith(a.from));
if (!alias) return undefined;
abs = path.resolve(tsAliases.baseUrl, `${alias.to}${specifier.slice(alias.from.length)}`);
abs = canonicalPathWithMissingTail(
path.resolve(tsAliases.baseUrl, `${alias.to}${specifier.slice(alias.from.length)}`)
);
}
const rel = path.relative(root, abs).split(path.sep).join('/');
return rel.startsWith('..') ? undefined : rel;
const relative = path.relative(canonicalRoot, abs);
if (path.isAbsolute(relative) || relative.startsWith('..')) return undefined;
return relative.split(path.sep).join('/');
}

@@ -63,5 +83,8 @@

if (!filePath || typeof filePath !== 'string') return undefined;
const abs = path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath);
const rel = path.relative(root, abs).split(path.sep).join('/');
return rel.startsWith('..') ? undefined : rel;
const abs = canonicalPathWithMissingTail(
path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath)
);
const relative = path.relative(canonicalPathWithMissingTail(root), abs);
if (path.isAbsolute(relative) || relative.startsWith('..')) return undefined;
return relative.split(path.sep).join('/');
}

@@ -136,2 +159,1 @@

}

@@ -66,4 +66,2 @@ /**

stampSkill,
installedSkillVersion,
isVersionOlder,
detectSkillGaps,

@@ -73,2 +71,3 @@ arkPackageVersion,

} from './skill-install.mjs';
import { installRepoSkillFile, installSkillCatalog, skillInstallLine, skillInstallNote } from './skill-write.mjs';
import { detectDeployPathQuality } from './deploy-path.mjs';

@@ -83,2 +82,3 @@ import {

} from './mcp-adoption.mjs';
import { inspectCodexInstallActivation, printCodexActivationHandoff, reportPartialInstall } from './install-activation.mjs';
import {

@@ -187,3 +187,3 @@ hasHardWriteHook,

// Always write project MCP registration — compact hosts other than Claude still need
// ark://manifest for agents (field: compact grok start left doctor reporting Missing .mcp.json
// project-bound ark_identity/ark_manifest (field: compact Grok start left doctor reporting Missing .mcp.json
// when AGENTS lost compact markers or hosts were mixed).

@@ -423,5 +423,7 @@ add('.mcp.json', mcpJson(root));

// Do not mutate package.json under --skills-only (typecheck bootstrap is gates/CI).
const earlyWritten = new Set();
if (!args.skillsOnly) {
// S4.4: always write check:architecture on gate install / start (including compact).
const checkBootstrap = ensureCheckArchitectureScript(root, { write: true });
if (checkBootstrap.changed) earlyWritten.add('package.json');
if (checkBootstrap.changed && !args.json) {

@@ -434,2 +436,3 @@ console.log(

const typecheckBootstrap = ensureTypecheckScript(root, { write: !args.compact });
if (typecheckBootstrap.changed && !args.compact) earlyWritten.add('package.json');
if (typecheckBootstrap.changed && !args.compact && !args.json) {

@@ -447,3 +450,3 @@ console.log(

});
const { skills, skillPaths, version } = catalog;
const { skills, version } = catalog;
const assetKindByPath = new Map(

@@ -460,3 +463,6 @@ catalog.assets.map((asset) => [asset.relativePath, asset.kind ?? 'gate'])

try {
if (fs.readFileSync(genericMcp, 'utf8') === mcpJson(root)) fs.rmSync(genericMcp);
if (fs.readFileSync(genericMcp, 'utf8') === mcpJson(root)) {
fs.rmSync(genericMcp);
earlyWritten.add('.mcp.json');
}
} catch {

@@ -470,2 +476,5 @@ // Missing or customized user MCP configuration is deliberately retained.

const kind = assetKindByPath.get(relativePath) ?? 'gate';
if (kind === 'skill') {
return installRepoSkillFile(root, relativePath, content, version, args.force);
}
if (

@@ -489,3 +498,10 @@ args.force &&

const generatedPrelude = tableStart > 0 ? content.slice(0, tableStart) : '';
const mergeBase = generatedPrelude ? existing.replace(generatedPrelude, '') : existing;
const mergeBase = generatedPrelude
? existing
.replace(generatedPrelude, '')
.replace(
/^# Generated by ark-check --install-agent-gates \(Codex project scope\)\.\n# Restart Codex after changes; MCP servers are loaded when the project session starts\.\n/m,
''
)
: existing;
const merged = upsertCodexMcpTable(mergeBase, 'ark', content);

@@ -540,3 +556,5 @@ if (merged === existing) return { relativePath, status: 'skipped' };

console.log('Ark agent gate templates:');
console.log(
`Ark agent gate templates (scope=repo; source=${version ? `arkgate@${version}` : 'arkgate@unknown'}):`
);
let staleSkipped = 0;

@@ -563,9 +581,6 @@ let preservedCustomized = 0;

note = ' (customized — content-identity preserved)';
} else if (result.status === 'skipped' && skillPaths.has(result.relativePath) && version) {
const installed = installedSkillVersion(path.join(root, result.relativePath));
if (installed === null || isVersionOlder(installed, version)) {
} else if (result.status === 'skipped' && result.skillPlan) {
note = ` (${skillInstallNote(result.skillPlan)})`;
if (result.skillPlan.reason === 'existing-preserved') {
staleSkipped += 1;
note = ` (stale: ${installed ?? 'no stamp'} < ${version})`;
} else {
note = ' (up to date)';
}

@@ -596,3 +611,9 @@ }

console.log('');
console.log(`Codex home skills (${dir}/<name>/SKILL.md):`);
console.log(
`Codex home skills (scope=home-shared; source=${version ? `arkgate@${version}` : 'arkgate@unknown'}; target=${dir}/<name>/SKILL.md):`
);
console.log(
' Compatibility: monotonic downgrade protection requires every shared-catalog writer ' +
'to use ArkGate 4.2.0+; pre-4.2 --codex-home ignores this catalog. Upgrade legacy repos first.'
);
try {

@@ -602,27 +623,14 @@ fs.mkdirSync(dir, { recursive: true });

console.error(` FAILED to create ${dir} (${error.message})`);
homeResults.push({ status: 'failed' });
homeResults.push({ relativePath: dir, status: 'failed' });
}
if (homeResults.length === 0) {
for (const [name, content] of skills) {
const skillDir = path.join(dir, name);
const file = path.join(skillDir, 'SKILL.md');
if (fs.existsSync(file) && !args.force) {
const installed = installedSkillVersion(file);
const behind = installed === null || (version && isVersionOlder(installed, version));
const note = behind
? ` (stale: ${installed ?? 'no stamp'} < ${version}; use --force)`
: ' (up to date)';
console.log(` ${'skipped'.padEnd(7)} ${name}/SKILL.md${note}`);
homeResults.push({ status: 'skipped' });
continue;
}
try {
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(file, content);
console.log(` ${'wrote'.padEnd(7)} ${name}/SKILL.md`);
homeResults.push({ status: 'written' });
} catch (error) {
console.log(` ${'FAILED'.padEnd(7)} ${name}/SKILL.md (${error.message})`);
homeResults.push({ status: 'failed' });
}
for (const result of installSkillCatalog({
directory: dir,
skills,
packageVersion: version,
force: args.force,
scope: 'home',
})) {
console.log(skillInstallLine(result));
homeResults.push(result);
}

@@ -660,3 +668,5 @@ }

console.log(' RESTART Codex — it does not hot-load MCP servers.');
console.log(' Then expect: resource ark://manifest + tools validate_code, ark_check, ark_coverage, ark_place.');
console.log(
' Then expect: tools ark_identity, ark_manifest, validate_code, ark_check, ark_coverage, ark_place.'
);
}

@@ -667,16 +677,16 @@ } else if (skipHomeWire) {

// Repo templates + explicit --codex-home skill writes are hard failures.
// Home MCP wire is best-effort: unreadable ~/.codex (sandbox, permissions) must not
// mark an otherwise successful repo gate install as failed.
const hardFailed = [...results, ...homeResults].filter((result) => result.status === 'failed');
if (hardFailed.length > 0) {
console.error(`\nFailed to write ${hardFailed.length} template(s).`);
const { codexProjectConfigured, runtimeActivation } =
inspectCodexInstallActivation(root, tools.has('codex') && !args.skillsOnly);
if (reportPartialInstall({
root,
tools,
results,
homeResults,
earlyWritten,
codexMcp,
runtimeActivation,
})) {
process.exitCode = 1;
return;
}
if (codexMcp?.status === 'failed') {
console.error(
`\nWarning: Codex home MCP registration failed (${codexMcp.message}). Repo gates were written; fix ~/.codex access or re-run with --codex-home --force.`
);
}
if (writeRequest.host) {

@@ -750,4 +760,10 @@ if (!hasHardWriteHook(root, writeRequest.host)) {

console.log('');
if (tools.has('codex') && !args.skillsOnly) {
printCodexActivationHandoff(root, codexProjectConfigured, runtimeActivation);
}
if (codexMcp && codexMcp.status !== 'failed') {
console.log(` Codex: ark MCP registered in ${codexMcp.file} — restart Codex so \`ark://manifest\` loads.`);
console.log(
` Codex: ark MCP registered in ${codexMcp.file} — restart Codex, then bind with ` +
'`ark_identity` and read the contract with `ark_manifest`.'
);
}

@@ -754,0 +770,0 @@ if (tools.has('codex') && !args.compact) {

@@ -374,5 +374,5 @@ import { createHash } from 'node:crypto';

const applying = assets.filter((asset) => asset.willApply);
// Content writes (stale/missing/conflicted accepted) — not version-stamp metadata-only.
const wouldWrite = applying.filter((asset) => asset.action !== 'refresh-metadata').length;
const metadataRefresh = applying.filter((asset) => asset.action === 'refresh-metadata').length;
const wouldWrite = applying.length;
// Public-summary compatibility: stamp-only writes are no longer scheduled.
const metadataRefresh = 0;
const customizedPreserved = assets.filter((asset) => asset.state === 'customized').length;

@@ -389,3 +389,2 @@ const fileChanges = applying.length;

manifestChanged,
// Full apply count still includes optional stamp refresh + manifest bookkeeping.
changed: fileChanges + (manifestChanged ? 1 : 0),

@@ -452,8 +451,3 @@ blocked: assets.filter((asset) => asset.blocked).length,

const accepted = options.acceptConflicts === true;
const refreshMetadata =
classified.state === 'current' &&
catalogAsset.kind === 'skill' &&
currentScoped !== desiredScoped;
const canApply =
refreshMetadata ||
classified.state === 'stale' ||

@@ -471,3 +465,3 @@ (classified.state === 'missing' && (!recorded || accepted)) ||

...(unparsedScope ? { reason: 'unparsed managed TOML scope preserved' } : {}),
action: refreshMetadata ? 'refresh-metadata' : canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
willApply: canApply,

@@ -622,5 +616,3 @@ blocked,

const wouldWrite = plan.summary.wouldWrite ?? 0;
const metadataRefresh = plan.summary.metadataRefresh ?? 0;
// Content already matches: unbound --apply is a no-op (exit success), not a digest error.
// Optional stamp-only refresh still requires the preview's exact --plan-digest.
if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {

@@ -633,3 +625,2 @@ if (wouldWrite === 0 && (plan.summary.blocked ?? 0) === 0 && !expectedPlanDigest) {

nothingToApply: true,
optionalStampRefresh: metadataRefresh,
});

@@ -750,3 +741,2 @@ }

const wouldWrite = summary.wouldWrite ?? 0;
const metadataRefresh = summary.metadataRefresh ?? 0;
const customizedPreserved = summary.customizedPreserved ?? summary.states?.customized ?? 0;

@@ -756,22 +746,10 @@ const blocked = summary.blocked ?? 0;

`Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
`customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}` +
(metadataRefresh > 0 ? `; optional stamp refresh: ${metadataRefresh}` : '') +
'.'
`customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
);
if (plan.applied) {
// Distinguish content writes from optional stamp/metadata bookkeeping.
if (wouldWrite === 0 && metadataRefresh > 0) {
console.log(
`Refreshed ${metadataRefresh} version stamp(s)` +
(summary.manifestChanged ? ' and managed manifest' : '') +
' (no content body changes).'
);
} else {
console.log(
`Applied ${wouldWrite} content write(s)` +
(metadataRefresh > 0 ? `, ${metadataRefresh} stamp refresh(es)` : '') +
(summary.manifestChanged ? ', managed manifest' : '') +
'.'
);
}
console.log(
`Applied ${wouldWrite} content write(s)` +
(summary.manifestChanged ? ', managed manifest' : '') +
'.'
);
return;

@@ -786,11 +764,2 @@ }

);
if (metadataRefresh > 0) {
console.log(
`Optional: ${metadataRefresh} skill stamp(s) lag package version while content is already current.`
);
const stampCmd = options.optionalStampApply ?? options.next;
if (stampCmd) {
console.log(`Optional stamp-only apply (not required): ${stampCmd}`);
}
}
return;

@@ -797,0 +766,0 @@ }

@@ -19,3 +19,3 @@ /**

import { collectWeakestLinkGaps } from './weakest-link.mjs';
import { withCiProviderEvidence } from './enforcement-state.mjs';
import { codexRuntimeActivation, withCiProviderEvidence } from './enforcement-state.mjs';

@@ -124,3 +124,3 @@ export { detectDeployPathQuality };

* Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
* @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
* @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object, runtimeActivation: object }}
*/

@@ -264,3 +264,7 @@ export function collectAdoptionGaps(root, config, coverage) {

})();
if (adopted && !isProducer && !codexProjectMcp) {
const runtimeActivation = codexRuntimeActivation({
configuredOnDisk: codexProjectMcp,
restartRequired: codexProjectMcp,
});
if (adopted && !isProducer) {
const codexFile = codexConfigPath();

@@ -284,2 +288,4 @@ let toml = '';

scopedTable: assessed.scopedTable,
projectConfiguredOnDisk: codexProjectMcp,
runtimeIdentityVerified: false,
};

@@ -295,5 +301,8 @@ if (assessed.gap) {

const severity = deferred ? 'info' : assessed.gap.severity;
const localRisk = codexProjectMcp
? 'Project config exists on disk, but the active runtime identity is unverified. '
: '';
const message = deferred
? `Deferred (fix when using Codex): ${assessed.gap.message}`
: assessed.gap.message;
? `Deferred (fix when using Codex): ${localRisk}${assessed.gap.message}`
: `${localRisk}${assessed.gap.message}`;
gaps.push({

@@ -534,2 +543,3 @@ id: assessed.gap.id,

writePath,
runtimeActivation,
enforcement: {

@@ -536,0 +546,0 @@ ci: weakest.ci,

@@ -8,3 +8,6 @@ /**

*/
import { buildPatternBetsFromSmells } from './design-smells.mjs';
import {
buildPatternBetsFromSmells,
isNonProductionPilotPath,
} from './design-smells.mjs';

@@ -48,2 +51,17 @@ /** Stable product id for JSON / tests. */

function pilotFilesForBet(bet, preferredFiles) {
const rawFiles = preferredFiles?.length
? preferredFiles
: fileEvidencePaths(bet?.evidence);
if (bet?.smellId !== 'god-module') return rawFiles;
const files = rawFiles.filter((file) => !isNonProductionPilotPath(file));
const excludedPilot =
rawFiles.length === 0 &&
typeof bet.pilot === 'string' &&
isNonProductionPilotPath(bet.pilot);
return (rawFiles.length > 0 && files.length === 0) || excludedPilot
? null
: files;
}
/**

@@ -55,4 +73,3 @@ * Score a pattern bet for "do this pilot first".

*/
function scoreBet(bet, index) {
const files = fileEvidencePaths(bet?.evidence);
function scoreBet(bet, index, files = fileEvidencePaths(bet?.evidence)) {
const smellPri = SMELL_PRIORITY[bet?.smellId] ?? 50;

@@ -70,5 +87,4 @@ // Higher score wins; concrete files dominate; then smell priority; stable by index.

if (!bet || typeof bet !== 'object') return null;
const files = preferredFiles?.length
? preferredFiles
: fileEvidencePaths(bet.evidence);
const files = pilotFilesForBet(bet, preferredFiles);
if (files === null) return null;
const evidence = files.length ? files : (bet.evidence || []).slice(0, 8);

@@ -130,4 +146,5 @@ const pilotTarget =

if (bet.class === 'mechanical-safe') continue;
const files = fileEvidencePaths(bet.evidence);
const sc = scoreBet(bet, i);
const files = pilotFilesForBet(bet);
if (files === null) continue;
const sc = scoreBet(bet, i, files);
if (sc > bestScore) {

@@ -134,0 +151,0 @@ bestScore = sc;

@@ -70,16 +70,40 @@ /** Z07 transport only; ark-mcp-runtime owns every hook decision. */

function realpathOrResolve(value) {
const resolved = path.resolve(value);
try { return fs.realpathSync(resolved); } catch { return resolved; }
}
export function residentInvocationIdentity({ root, config, manifest, tsconfig }) {
const lexicalRoot = path.resolve(root);
const realRoot = realpathOrResolve(lexicalRoot);
const projectPath = (value) => {
if (!value) return null;
const absolute = path.isAbsolute(value)
? path.resolve(value)
: path.resolve(lexicalRoot, value);
const relative = path.relative(lexicalRoot, absolute);
const contained =
relative === '' ||
(!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
return realpathOrResolve(contained ? path.resolve(realRoot, relative) : absolute);
};
return {
root: realRoot,
config: projectPath(config),
manifest: projectPath(manifest),
tsconfig: projectPath(tsconfig),
};
}
export function residentHookEndpoint({ root, config, manifest, tsconfig, launcher }) {
let realRoot;
try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); }
const invocation = residentInvocationIdentity({ root, config, manifest, tsconfig });
const realLauncher = realpathOrResolve(launcher);
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
const directory = path.join(os.tmpdir(), `arkgate-${uid}`);
const digest = createHash('sha256').update(JSON.stringify({
root: realRoot,
config: path.resolve(root, config),
manifest: manifest ? path.resolve(root, manifest) : null,
tsconfig: tsconfig ? path.resolve(root, tsconfig) : null,
launcher: path.resolve(launcher),
executable: process.execPath,
...invocation,
launcher: realLauncher,
executable: realpathOrResolve(process.execPath),
protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION,
runtimeIdentity: residentRuntimeIdentity(path.resolve(launcher)),
runtimeIdentity: residentRuntimeIdentity(realLauncher),
})).digest('hex').slice(0, 24);

@@ -86,0 +110,0 @@ return {

@@ -14,7 +14,59 @@ /**

}
function normalizeInventoryPath(file) {
return file.replace(/\\/g, '/').replace(/^\.\//, '');
}
function ownsIntent(intentPrefixes, intentRoots) {
return intentPrefixes.some((prefix) => {
const normalized = prefix.trim().replace(/\.+$/, '');
return intentRoots.some((root) => normalized === root || normalized.startsWith(`${root}.`));
});
}
function isDomainLayer(layer, intentPrefixes = []) {
return (/domain|entity|aggregate|model/i.test(layer) ||
ownsIntent(intentPrefixes, ['Domain']));
}
function isControllerEligibleLayer(layer, intentPrefixes = []) {
return (/application|orchestration|presentation|adapter|framework|interface|delivery|transport|inbound|controller/i.test(layer) ||
ownsIntent(intentPrefixes, [
'Application',
'Orchestration',
'Presentation',
'Adapter',
'Interface',
'Delivery',
'Transport',
]));
}
function isNonPilotSurface(file) {
return (/(?:^|\/)(?:tests?|__tests__|fixtures?|testdata|mocks?|stubs?|examples?|samples?|seeds?|seeders?|migrations?|excluded|exclusions?)(?:\/|$)/i.test(file) ||
/(?:^|\/)[^/]*\.(?:test|spec|fixture|mock|stub|seed|seeder)\.[^/]+$/i.test(file) ||
/(?:^|\/)(?:seed|seeder|fixture|mock|stub)\.[^/]+$/i.test(file));
}
export function buildRulesInventory(input) {
const candidates = [];
let seq = 0;
const fileLayers = new Map(Object.entries(input.fileLayers ?? {}).map(([file, layer]) => [
normalizeInventoryPath(file),
layer,
]));
const layerIntentPrefixes = new Map((input.layerContexts ?? []).map((layer) => [
layer.name,
layer.intentPrefixes ?? [],
]));
const domainLayer = (input.layerContexts ?? []).find((layer) => isDomainLayer(layer.name, layer.intentPrefixes))?.name ?? 'DomainModel';
for (const [file, content] of Object.entries(input.fileContents).sort(([a], [b]) => a.localeCompare(b))) {
const posix = file.replace(/\\/g, '/');
const posix = normalizeInventoryPath(file);
// Test data, fixtures, seeds, migrations, and explicit exclusions may retain
// representative smells, but are not production extraction pilots.
if (isNonPilotSurface(posix))
continue;
// Generated mirrors are evidence for their canonical source, not a second
// extraction candidate.
if (/GENERATED FILE\s+[—-]\s+do not edit by hand/i.test(content.slice(0, 320)))
continue;
const hasGovernedLayer = fileLayers.has(posix);
const governedLayer = fileLayers.get(posix);
const governedIntentPrefixes = governedLayer
? layerIntentPrefixes.get(governedLayer) ?? []
: [];
// P2-N — clear UI bags only (components/theme/styles). Do NOT blanket-skip all

@@ -27,3 +79,3 @@ // app/pages (server actions / route handlers live there and stay inventoriable).

const isServerAction = /(?:^|\/)actions?(?:\/|\.|$)/i.test(posix) || /['"]use server['"]/.test(content);
const isController = /controller|handler|resolver/i.test(file) ||
const controllerShape = /controller|handler|resolver/i.test(file) ||
isApiRoute ||

@@ -35,3 +87,11 @@ isServerAction ||

/\bexport\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=/.test(content);
const isDomain = /domain|entity|aggregate|model/i.test(file);
const isController = hasGovernedLayer
? Boolean(governedLayer &&
isControllerEligibleLayer(governedLayer, governedIntentPrefixes) &&
controllerShape)
: controllerShape;
const isDomain = hasGovernedLayer
? Boolean(governedLayer && isDomainLayer(governedLayer, governedIntentPrefixes))
: /domain|entity|aggregate|model/i.test(file);
const magicConstantEligible = !hasGovernedLayer || isDomain || isController;
// validation-in-controller (API/Nest/server-action handlers — not pure UI chrome)

@@ -50,4 +110,5 @@ if (isController && !isUiChrome) {

confidence: 'direct-evidence',
governedLayer,
suggestedArkRule: {
layer: 'DomainModel',
layer: domainLayer,
invariantId: `INV-EXTRACT-${seq}`,

@@ -74,5 +135,17 @@ sensor: 'invariant-coverage',

// Known I/O bag prefixes that are never domain seeds in field clones
/^(?:FAVORITES_STORAGE|LISTINGS_CACHE|DOCS_PATH|METRICS_INTERVAL)/i.test(name);
/^(?:FAVORITES_STORAGE|LISTINGS_CACHE|DOCS_PATH|METRICS_INTERVAL)/i.test(name) ||
// Development identities and PostgreSQL type OIDs are technical wiring, not
// business literals. Keep this narrow so Domain limits/status seeds still surface.
/^(?:DEV|DEMO|SEED|FIXTURE)_[A-Z0-9_]+$/i.test(name) ||
/^(?:PG|POSTGRES|OID)_[A-Z0-9_]+$/i.test(name) ||
/_(?:OID|OIDS)$/i.test(name) ||
/^(?:INT2|INT4|INT8|FLOAT4|FLOAT8|NUMERIC|DATE|TIME|TIMESTAMP|TIMESTAMPTZ|JSON|JSONB|UUID)OID$/i.test(name) ||
/(?:^|_)(?:SCHEMA|PROTOCOL|RESOLVER|FORMAT)_(?:URL|URI|VERSION|ID|IDENTITY)$/i.test(name);
while ((magic = magicRe.exec(content)) !== null) {
const name = magic[2];
// With governed layer evidence, generic Tooling/Kernel constants are not
// business-rule candidates. Controller-shaped boundaries stay eligible
// because business policy can leak into them.
if (!magicConstantEligible)
continue;
if (isInfraMagicName(name))

@@ -96,3 +169,4 @@ continue;

confidence: 'heuristic',
suggestedArkRule: { layer: 'DomainModel', invariantId: `INV-${name}` },
governedLayer,
suggestedArkRule: { layer: domainLayer, invariantId: `INV-${name}` },
neverMechanicalSafe: true,

@@ -118,4 +192,5 @@ });

confidence: 'heuristic',
governedLayer,
suggestedArkRule: {
layer: 'DomainModel',
layer: domainLayer,
structureId: 'no-anemic-model',

@@ -141,2 +216,12 @@ sensor: 'no-anemic-model',

while ((mut = mutRe.exec(content)) !== null) {
const classStart = content.lastIndexOf('class ', mut.index);
const classHeaderEnd = classStart >= 0 ? content.indexOf('{', classStart) : -1;
const classHeader = classStart >= 0 && classHeaderEnd >= classStart && classHeaderEnd < mut.index
? content.slice(classStart, classHeaderEnd)
: '';
// Error metadata assignment is constructor wiring, not aggregate
// mutation. Keep the exclusion local to the containing class header.
if (/\bextends\s+(?:Error|[A-Za-z_$][A-Za-z0-9_$]*Error)\b/.test(classHeader)) {
continue;
}
const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);

@@ -152,4 +237,5 @@ if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {

confidence: 'heuristic',
governedLayer,
suggestedArkRule: {
layer: 'DomainModel',
layer: domainLayer,
structureId: 'events-on-mutation',

@@ -167,2 +253,8 @@ sensor: 'domain-event-on-mutation',

const contracted = new Set(input.contractedRuleIds ?? []);
candidates.sort((a, b) => Number(b.confidence === 'direct-evidence') -
Number(a.confidence === 'direct-evidence') ||
a.file.localeCompare(b.file) ||
a.line - b.line ||
a.kind.localeCompare(b.kind) ||
a.id.localeCompare(b.id));
const underContract = candidates.filter((c) => (c.suggestedArkRule?.invariantId && contracted.has(c.suggestedArkRule.invariantId)) ||

@@ -169,0 +261,0 @@ (c.suggestedArkRule?.structureId && contracted.has(c.suggestedArkRule.structureId))).length;

@@ -227,6 +227,8 @@ /**

// Insert `arkVersion: <v>` into a skill's YAML frontmatter (before its closing
// `---`). No frontmatter → returned unchanged. Idempotent for a given version.
// `---`). No frontmatter → returned unchanged. Idempotent for a given version
// and preserves the checked-out line ending on Windows.
export function stampSkill(content, version) {
if (!version) return content;
const lines = content.split('\n');
const newline = content.includes('\r\n') ? '\r\n' : '\n';
const lines = content.split(/\r?\n/);
if (lines[0] !== '---') return content;

@@ -243,3 +245,3 @@ const closeIdx = lines.indexOf('---', 1);

}
return lines.join('\n');
return lines.join(newline);
}

@@ -265,14 +267,58 @@

// Numeric-tuple compare of dotted versions; true when `a` is strictly older than
// `b`. Non-numeric/absent segments compare as 0, so "1.7" < "1.7.5".
const VERSION_PATTERN =
/^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
function parseVersion(value, strict) {
if (typeof value !== 'string') return null;
const match = value.match(VERSION_PATTERN);
if (!match || (strict && (match[2] === undefined || match[3] === undefined))) {
return null;
}
const prerelease = match[4]?.split('.') ?? [];
if (
prerelease.some(
(identifier) =>
/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')
)
) {
return null;
}
return {
core: [match[1], match[2] ?? '0', match[3] ?? '0'],
prerelease,
};
}
/** True only for a complete SemVer 2.0.0 version. */
export function isValidSemver(value) {
return parseVersion(value, true) !== null;
}
// SemVer precedence compare. A one- or two-component numeric core remains
// accepted for legacy skill stamps, so "1.7" < "1.7.5"; shared catalog metadata
// uses isValidSemver and therefore requires the complete x.y.z form.
export function isVersionOlder(a, b) {
const parse = (v) => String(v).split('.').map((n) => Number.parseInt(n, 10) || 0);
const av = parse(a);
const bv = parse(b);
const len = Math.max(av.length, bv.length);
for (let i = 0; i < len; i += 1) {
const x = av[i] ?? 0;
const y = bv[i] ?? 0;
if (x !== y) return x < y;
const av = parseVersion(a, false);
const bv = parseVersion(b, false);
if (!av || !bv) return false;
for (let index = 0; index < 3; index += 1) {
const left = BigInt(av.core[index]);
const right = BigInt(bv.core[index]);
if (left !== right) return left < right;
}
if (av.prerelease.length === 0 || bv.prerelease.length === 0) {
return av.prerelease.length > 0 && bv.prerelease.length === 0;
}
const length = Math.max(av.prerelease.length, bv.prerelease.length);
for (let index = 0; index < length; index += 1) {
const left = av.prerelease[index];
const right = bv.prerelease[index];
if (left === undefined || right === undefined) return left === undefined;
if (left === right) continue;
const leftNumeric = /^\d+$/.test(left);
const rightNumeric = /^\d+$/.test(right);
if (leftNumeric && rightNumeric) return BigInt(left) < BigInt(right);
if (leftNumeric !== rightNumeric) return leftNumeric;
return left < right;
}
return false;

@@ -321,2 +367,70 @@ }

/**
* Decide whether one managed skill should be written.
*
* Repo catalogs belong to that repo's installed package, so an explicit --force
* may move them in either direction. Codex home is shared by every repo on the
* machine: a package older than the installed home stamp must never win, even
* under --force. In both scopes a version-stamp-only difference is a no-op; the
* skill body is the capability contract.
*
* @param {{
* existingContent?: string|null,
* targetContent: string,
* packageVersion?: string|null,
* force?: boolean,
* scope?: 'repo'|'home',
* }} input
* @returns {{
* action: 'write'|'skip',
* reason: 'missing'|'content-current'|'newer-home-version'|'unknown-source-version'|'existing-preserved'|'content-update',
* scope: 'repo'|'home',
* sourceVersion: string|null,
* installedVersion: string|null,
* conflict: boolean,
* downgradeBlocked: boolean,
* }}
*/
export function planSkillInstall(input) {
const scope = input.scope === 'home' ? 'home' : 'repo';
const existingContent = input.existingContent ?? null;
const targetContent = String(input.targetContent);
const sourceVersion =
input.packageVersion ?? skillVersionFromContent(targetContent);
const installedVersion = skillVersionFromContent(existingContent);
const result = (action, reason, conflict = false, downgradeBlocked = false) => ({
action,
reason,
scope,
sourceVersion,
installedVersion,
conflict,
downgradeBlocked,
});
if (existingContent === null) return result('write', 'missing');
if (
existingContent === targetContent ||
skillContentIdentity(existingContent) === skillContentIdentity(targetContent)
) {
return result('skip', 'content-current');
}
if (scope === 'home') {
if (installedVersion && !sourceVersion) {
return result('skip', 'unknown-source-version', true, true);
}
if (
installedVersion &&
sourceVersion &&
isVersionOlder(sourceVersion, installedVersion)
) {
return result('skip', 'newer-home-version', true, true);
}
}
if (!input.force) return result('skip', 'existing-preserved', true);
return result('write', 'content-update');
}
/** @returns {Record<string, string>} skill name → template body from package */

@@ -429,2 +543,92 @@ export function skillTemplateBodies() {

const CODEX_HOME_CATALOG = '.arkgate-catalog.json';
const CODEX_HOME_PENDING_CATALOG = '.arkgate-catalog.pending.json';
const CATALOG_TOKEN_PATTERN =
/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
function readCodexHomeCatalogMetadata(file, kind) {
try {
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
if (!stat) return { exists: false, valid: false, version: null };
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
return { exists: true, valid: false, version: null };
}
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
if (kind === 'pending') {
const keys =
value && typeof value === 'object' && !Array.isArray(value)
? Object.keys(value).sort()
: [];
const valid =
keys.join(',') === 'packageVersion,schemaVersion,token' &&
value.schemaVersion === '1.0' &&
isValidSemver(value.packageVersion) &&
typeof value.token === 'string' &&
CATALOG_TOKEN_PATTERN.test(value.token);
return {
exists: true,
valid,
version: valid ? value.packageVersion : null,
};
}
if (
value?.schemaVersion !== '1.0' ||
!isValidSemver(value.packageVersion) ||
!Array.isArray(value.skills)
) {
return { exists: true, valid: false, version: null };
}
const seen = new Set();
for (const skill of value.skills) {
if (
!skill ||
typeof skill.name !== 'string' ||
!/^ark-[a-z0-9-]+$/.test(skill.name) ||
typeof skill.contentIdentity !== 'string' ||
!/^sha256:[a-f0-9]{64}$/.test(skill.contentIdentity) ||
seen.has(skill.name)
) {
return { exists: true, valid: false, version: null };
}
seen.add(skill.name);
}
return { exists: true, valid: true, version: value.packageVersion };
} catch {
return { exists: true, valid: false, version: null };
}
}
function codexHomeCatalogState(skillsDir) {
const catalog = readCodexHomeCatalogMetadata(
path.join(skillsDir, CODEX_HOME_CATALOG),
'catalog'
);
const pending = readCodexHomeCatalogMetadata(
path.join(skillsDir, CODEX_HOME_PENDING_CATALOG),
'pending'
);
let floorVersion = catalog.version;
if (
pending.version &&
(!floorVersion || isVersionOlder(floorVersion, pending.version))
) {
floorVersion = pending.version;
}
return {
floorVersion,
pendingVersion: pending.version,
hasMetadata: catalog.exists || pending.exists,
metadataInvalid:
(catalog.exists && !catalog.valid) || (pending.exists && !pending.valid),
};
}
function newerCodexHomeCatalogVersion(skillsDir, packageVersion, state = null) {
if (!isValidSemver(packageVersion)) return null;
const floorVersion = (state ?? codexHomeCatalogState(skillsDir)).floorVersion;
return floorVersion && isVersionOlder(packageVersion, floorVersion)
? floorVersion
: null;
}
/**

@@ -467,2 +671,10 @@ * Repo + home Codex skill parity against the shipping package skill set.

});
const homeCatalogState = codexHomeCatalogState(skillsDir);
const newerHomeCatalog = newerCodexHomeCatalogVersion(
skillsDir,
packageVersion,
homeCatalogState
);
const pendingRecoveryRequired =
homeCatalogState.pendingVersion !== null && newerHomeCatalog === null;

@@ -476,3 +688,6 @@ // Repo catalog matters when .codex is present (Codex host adopted) or repo skills/prompts exist.

// (empty $CODEX_HOME/skills is optional multi-project — not debt).
const homeInPlay = home.presentCount > 0 || home.hasLegacyPrompts;
const homeInPlay =
home.presentCount > 0 ||
home.hasLegacyPrompts ||
homeCatalogState.hasMetadata;

@@ -484,3 +699,9 @@ if (!repoInPlay && !homeInPlay) return null;

const homeNeedsAttention =
homeInPlay && (home.missing > 0 || home.stale > 0 || home.legacyPromptsOnly);
newerHomeCatalog === null &&
homeInPlay &&
(home.missing > 0 ||
home.stale > 0 ||
home.legacyPromptsOnly ||
pendingRecoveryRequired ||
homeCatalogState.metadataInvalid);

@@ -496,2 +717,7 @@ return {

promptsDir,
catalogVersion: homeCatalogState.floorVersion,
catalogNewerThanPackage: newerHomeCatalog !== null,
pendingCatalogVersion: homeCatalogState.pendingVersion,
pendingRecoveryRequired,
catalogMetadataInvalid: homeCatalogState.metadataInvalid,
},

@@ -527,2 +753,10 @@ skillsDir,

skillsDir,
catalogVersion: home.catalogVersion,
pendingRecoveryRequired: Boolean(home.pendingRecoveryRequired),
catalogMetadataInvalid: Boolean(home.catalogMetadataInvalid),
catalogStateReason: home.catalogMetadataInvalid
? 'invalid catalog metadata'
: home.pendingRecoveryRequired
? 'interrupted catalog commit'
: null,
};

@@ -684,15 +918,27 @@ }

/**
* Preserve the full detected inventory for JSON/reporting, but keep immediate
* human remediation scoped to the host running this process.
*/
export function skillGapsForActiveHost(skillGaps, env = process.env) {
const activeHost = detectActiveAgentHost(env);
if (!activeHost) return skillGaps ?? [];
return (skillGaps ?? []).filter((gap) => gap.tool === activeHost);
}
/**
* Human-facing skill / Codex catalog gap lines for ark-check (non-JSON).
* @param {string} root
* @param {{ skillGaps: object[], codexHomeGap: object|null, codexRepoSkillGap: object|null, codexSessionActive: boolean, color: { dim: Function, yellow: Function } }} opts
* @param {{ skillGaps: object[], codexHomeGap: object|null, codexRepoSkillGap: object|null, codexSessionActive: boolean, env?: NodeJS.ProcessEnv, color: { dim: Function, yellow: Function } }} opts
*/
export function printSkillAndCodexGapHints(root, opts) {
const { skillGaps, codexHomeGap, codexRepoSkillGap, codexSessionActive, color } = opts;
if (skillGaps?.length > 0) {
const legacyCodex = skillGaps.some((gap) => gap.tool === 'codex' && gap.legacyPromptsOnly);
const legacyAdvisory = skillGaps.some(
const activeSkillGaps = skillGapsForActiveHost(skillGaps, opts.env);
if (activeSkillGaps.length > 0) {
const legacyCodex = activeSkillGaps.some(
(gap) => gap.tool === 'codex' && gap.legacyPromptsOnly
);
const legacyAdvisory = activeSkillGaps.some(
(gap) => gap.tool === 'codex' && gap.legacyAdvisory && gap.catalogComplete
);
// Report Codex legacy separately; never suppress missing/stale for other hosts.
const remaining = skillGaps.filter(
const remaining = activeSkillGaps.filter(
(gap) =>

@@ -741,2 +987,4 @@ !(gap.tool === 'codex' && (gap.legacyPromptsOnly || gap.legacyAdvisory))

if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} content-behind-package`);
if (codexHomeGap.pendingRecoveryRequired) parts.push('interrupted catalog commit');
if (codexHomeGap.catalogMetadataInvalid) parts.push('invalid catalog metadata');
const deferred = !codexSessionActive;

@@ -750,3 +998,5 @@ const deferredNote = deferred

`Catalog is $CODEX_HOME/skills/<name>/SKILL.md (not flat prompts). ` +
`When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`;
(codexHomeGap.catalogMetadataInvalid
? 'Inspect the shared catalog metadata before retrying; invalid metadata fails safe.'
: `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`);
console.log(deferred ? color.dim(msg) : color.yellow(msg));

@@ -753,0 +1003,0 @@ }

@@ -12,2 +12,3 @@ import crypto from 'node:crypto';

codexHooks,
codexProjectConfig,
grokHooks,

@@ -17,2 +18,4 @@ grokProjectConfig,

} from './hook-templates.mjs';
import { codexPrimaryTable, codexProjectMcpIsValid } from './codex-home.mjs';
import { codexRuntimeActivation } from './enforcement-state.mjs';

@@ -33,3 +36,6 @@ const COMPACT_HOST_TEMPLATES = {

cursor: (root) => [['.cursor/mcp.json', mcpJson(root)]],
codex: (root) => [['.codex/hooks.json', codexHooks(root)]],
codex: (root) => [
['.codex/hooks.json', codexHooks(root)],
['.codex/config.toml', codexProjectConfig(root)],
],
opencode: (root) => [['opencode.json', opencodeProjectConfig(root)]],

@@ -165,2 +171,8 @@ windsurf: (root) => [['.windsurf/rules/ark.md', instructionRule(root)]],

for (const guarantee of preview.hostGuarantees) console.log(` ${guarantee}`);
if (preview.runtimeActivation) {
console.log('Codex MCP CONFIGURED — RUNTIME NOT VERIFIED.');
console.log(` Runtime activation: ${JSON.stringify(preview.runtimeActivation)}`);
console.log(` Restart Codex, then call ark_identity with expectedRoot "${preview.root}".`);
console.log(' Do not trust MCP verdicts before the project identity matches.');
}
if (preview.unresolvedDecisions.length > 0) {

@@ -205,2 +217,27 @@ console.log('Unresolved decisions:');

const before = fs.readFileSync(target);
if (host === 'codex' && relativePath === '.codex/config.toml') {
const currentText = before.toString('utf8');
const currentTable = codexPrimaryTable(currentText);
const expectedTable = codexPrimaryTable(expected);
if (!currentTable) continue;
if (
!expectedTable ||
currentTable.block.trimEnd() !== expectedTable.block.trimEnd()
) {
unresolvedDecisions.push(
'.codex/config.toml Ark MCP binding was customized and was left untouched.'
);
continue;
}
const expectedPrelude = expected.slice(0, expectedTable.start);
let prefix = currentText.slice(0, currentTable.start);
if (expectedPrelude && prefix.endsWith(expectedPrelude)) {
prefix = prefix.slice(0, -expectedPrelude.length);
}
let next = `${prefix}${currentText.slice(currentTable.end)}`;
if (prefix.length === 0) next = next.replace(/^\n+/, '');
const after = next.trim().length > 0 ? Buffer.from(next) : null;
changes.push(change(relativePath, before, after));
continue;
}
if (before.toString('utf8') !== expected) {

@@ -228,2 +265,28 @@ unresolvedDecisions.push(`${relativePath} was customized and was left untouched.`);

const runtimeActivation =
host === 'codex'
? codexRuntimeActivation({
configuredOnDisk: (() => {
const configPath = path.join(args.root, '.codex', 'config.toml');
const plannedConfig = changes.find(
(item) => item.path === '.codex/config.toml'
);
if (plannedConfig?.action === 'delete') return false;
if (plannedConfig?.afterBase64) {
return codexProjectMcpIsValid(
Buffer.from(plannedConfig.afterBase64, 'base64').toString('utf8'),
args.root
);
}
if (!fs.existsSync(configPath)) return false;
try {
return codexProjectMcpIsValid(fs.readFileSync(configPath, 'utf8'), args.root);
} catch {
return false;
}
})(),
restartRequired: true,
})
: null;
return {

@@ -242,2 +305,3 @@ version: 1,

unresolvedDecisions,
...(runtimeActivation ? { runtimeActivation } : {}),
};

@@ -322,2 +386,14 @@ }

const percent = coverage.governed?.percent ?? null;
const codexSelected = args.tools === 'codex';
const runtimeActivation = codexSelected
? codexRuntimeActivation({
configuredOnDisk: (() => {
const config = after.get('.codex/config.toml');
return Boolean(
config && codexProjectMcpIsValid(config.toString('utf8'), root)
);
})(),
restartRequired: true,
})
: null;
return {

@@ -340,4 +416,11 @@ version: 1,

'apply writes the exact bytes identified by each afterHash',
...(codexSelected
? [
'Codex MCP is configured on disk, not runtime-verified',
'restart Codex and match ark_identity expectedRoot before trusting MCP verdicts',
]
: []),
],
unresolvedDecisions: percent !== null && percent < 90 ? [`Projected governed coverage is ${percent}%; review unclassified files before treating the contract as complete.`] : [],
...(runtimeActivation ? { runtimeActivation } : {}),
};

@@ -344,0 +427,0 @@ } finally {

@@ -430,3 +430,3 @@ /** Read-only-first `ark upgrade` orchestration. Managed identity logic lives separately. */

if (args.json) {
// Always expose nextCommand for digest-bound apply (metadata/manifest optional);
// Always expose nextCommand for digest-bound content/manifest apply;
// nothingToApply flags when content writes are zero so UIs do not urge apply.

@@ -447,3 +447,2 @@ console.log(

} else {
const metadataRefresh = plan.summary?.metadataRefresh ?? 0;
renderManagedUpgrade(plan, {

@@ -455,4 +454,2 @@ next: needsApply

: undefined,
// Human path: optional digest-bound stamp refresh without urging content apply.
...( !needsApply && metadataRefresh > 0 ? { optionalStampApply: command } : {}),
});

@@ -487,3 +484,3 @@ if (args.install === false) {

renderManagedUpgrade(applied);
console.log('No managed content writes pending (optional stamp refresh needs --plan-digest).');
console.log('No managed content writes pending.');
}

@@ -490,0 +487,0 @@ return 0;

@@ -49,2 +49,9 @@ # ArkGate — Agent Integration Guide

**MCP project identity (4.2.0):** before trusting project-specific MCP evidence, call
`ark_identity` with `project.expectedRoot` set to the exact project's absolute root. Reuse that
root plus the returned `projectIdentity.projectId` on every later Ark tool call. A descendant
path is authoritative only when that matching project id is also supplied. Only
`binding.status: "matched"` with `authoritative: true` is authoritative; calls that omit the
expectation remain compatible but are explicitly `unverified`.
## Architecture playbook and `ark-check --recommend`

@@ -116,2 +123,6 @@

`.ark/reports/history/`.
Snapshots record best-effort, shell-free Git provenance (`HEAD`, attached branch, and dirty
worktree state). Evolution keeps raw metrics visible across ArkGate upgrades, but an Ark score
delta is comparable—and therefore rendered—only when origin and current snapshots use the same
ArkGate version.

@@ -135,3 +146,5 @@ Once the **raw** graph has zero violations (the baseline is deliberately ignored) and governed

**Design fitness (3.0.1+ / Phase Q 3.0.3):** after edges are clean, doctor can still report **ENFORCE · design-weak**.
**Design fitness (3.0.1+ / Phase Q 3.0.3):** after checked edges are clean, doctor can still
report **SUGGEST / ADAPT / ENFORCE · design-weak** using the mode it actually observed; a weak
design does not imply that enforcement is active.

@@ -151,3 +164,6 @@ ```bash

Success = reduced smell evidence on pilot paths; residual outside the pilot may remain.
Never multi-pilot batch; never mechanical-safe; never claim healthy finished while design-weak.
Never select seed/fixture/demo/migration/generated files as god-module pilots. A real UI business
rule moves Domain → Application → UI; local permission/UI-state helpers are not selected by their
`canEdit`-style name alone. Never multi-pilot batch; never mechanical-safe; never claim healthy
finished while design-weak.

@@ -463,3 +479,3 @@ **AI-velocity evidence (Q05):** deterministic fixture bench (no live LLM) compares the same

| Cursor | `.cursor/mcp.json` + `.cursor/rules/ark.mdc` | `.cursor/commands/` |
| OpenAI Codex | `.codex/config.toml` (project primary, relative `--root .`); optional legacy `$CODEX_HOME/config.toml` fallback uses absolute roots and scoped secondaries — see [ai-gates.md](ai-gates.md) | **Repo:** `.agents/skills/<name>/SKILL.md`; **home:** `$CODEX_HOME/skills/<name>/SKILL.md` (`--codex-home`) |
| OpenAI Codex | `.codex/config.toml` (project primary, relative `--root .`; configured on disk is not runtime-active until restart + `ark_identity` match); optional legacy `$CODEX_HOME/config.toml` fallback uses absolute roots and scoped secondaries — see [ai-gates.md](ai-gates.md) | **Repo:** `.agents/skills/<name>/SKILL.md`; **home:** `$CODEX_HOME/skills/<name>/SKILL.md` (`--codex-home`) |
| **Grok Build** | `.grok/hooks/ark-write-gate.json` + `.grok/config.toml` / `.mcp.json` | `.grok/skills/<name>/SKILL.md` |

@@ -472,2 +488,7 @@ | Google Antigravity | `.agents/hooks.json` (+ `GEMINI.md` for shared Gemini consumers) | `.agents/skills/<name>/SKILL.md` |

[README](../README.md#other-skills-only-when-you-need-them).
When several repositories share one machine, repo catalogs stay pinned and isolated; unchanged
skill bodies are not rewritten for a version stamp. The optional `$CODEX_HOME/skills` catalog is
monotonic across ArkGate 4.2.0+ installers. Pre-4.2 binaries ignore its metadata and lock, so
upgrade legacy repos before they write the optional home catalog. See
[AI gates — Codex skill catalog](ai-gates.md#codex-skill-catalog-skillmd-not-flat-prompts).

@@ -855,13 +876,37 @@ For an optional executable adoption check, copy the shipped template into a Vitest/Jest suite

- **Resource `ark://manifest`** — contract discovery. Serve an exported
`ark.manifest().toJSON()` via `--manifest`. Without that flag, the resource uses every active
layer and the effective rules from `ark.config.json`; the strict 11-layer profile is the
fallback only when the project config declares no layers.
- **Identity handshake** — first call `ark_identity` with:
The server exposes these nine tools:
```json
{
"project": {
"expectedRoot": "/absolute/exact-project-root"
}
}
```
Then reuse both `expectedRoot` and the returned `projectIdentity.projectId` as
`project.expectedProjectId` on every later Ark tool call. `expectedProjectId` without
`expectedRoot` can detect the wrong id, but it remains non-authoritative because it does not
prove the current workspace root. The first handshake requires the exact project root; a
contained descendant becomes authoritative only on later calls that also send the matching
project id.
- **Tool `ark_manifest`** — authoritative contract discovery after the identity handshake.
Serve an exported `ark.manifest().toJSON()` via `--manifest`. Without that flag, the tool uses
every active layer and the effective rules from `ark.config.json`; the strict 11-layer profile
is the fallback only when the project config declares no layers. Call it with the same root +
project id expectation.
- **Resource `ark://manifest`** — compatibility discovery for standard MCP `resources/read`
clients. That protocol request has no portable project-expectation field, so Ark always marks
this resource `unverified` and non-authoritative. It never substitutes for `ark_manifest` in
a project verdict.
The server exposes these twelve tools. Every tool accepts the additive
`project: { expectedRoot, expectedProjectId? }` input:
| Tool | Primary input and purpose |
|------|---------------------------|
| `ark_identity` | `{ project: { expectedRoot, expectedProjectId? } }`: return the canonical root/config, stable project id, contract identity, and live runtime identity; use it before every other project-bound surface. |
| `ark_manifest` | No non-project args: return the machine-readable architecture contract with an authoritative binding after the identity handshake. |
| `validate_code` | `{ source, layer?, filePath? }`: validate one snippet; infer the layer from `filePath` when possible; return an error result when invalid. |
| `ark_check` | `{ strict?, baseline? }`: run the full project architecture check with structured diagnostics. |
| `ark_check` | `{ strict?, baseline? }`: run the full project architecture check. `verdict` separates `identity`, `completeness`, `graph`, `coverage`, `gates`, and `overallOk`; no individual green fact substitutes for the combined verdict. |
| `ark_policy_delta` | `{ baseConfig, candidateConfig?, acknowledgement? }`: classify a complete contract transition; never edits the contract. |

@@ -874,4 +919,51 @@ | `ark_coverage` | No args: report per-layer counts, every unclassified file, unmatched layers, and missing rule edges. |

| `ark_suggest_include` | No args: propose TypeScript/JavaScript include roots from workspaces and nested packages. |
| `ark_rules_inventory` | No args: inventory possible intra-layer rules using configured layer evidence when available; test/fixture/seed/migration surfaces and narrow technical constants are excluded from extraction pilots. Counts are not a score. |
Current diagnostic envelopes use schema `1.3` and require `mode`,
Every project-bound tool success, tool error, and JSON-RPC error data carries:
```json
{
"projectIdentity": {
"schemaVersion": "1.0",
"projectId": "sha256:…",
"resolvedRoot": "/absolute/project",
"resolvedConfigPath": "/absolute/project/ark.config.json",
"arkgateVersion": "4.2.0",
"contractHash": "sha256:…",
"contractSource": "project",
"runtimeId": "process-specific",
"processStartedAt": "2026-07-30T00:00:00.000Z"
},
"binding": {
"status": "matched",
"authoritative": true
},
"authoritative": true
}
```
`projectId` stays stable across process restarts and contract edits; `runtimeId` and
`processStartedAt` identify this live process. Binding states are:
- `matched` — canonical `expectedRoot` is the exact project root, or it is a contained
descendant and the caller also supplied the matching project id; `authoritative` is `true`;
- `unverified` — no expectation was supplied, or only the id matched; callable for legacy
clients, but `authoritative` is `false`;
- `mismatch` — invalid/wrong root or id; `authoritative` is `false` and Ark returns
`PROJECT_ROOT_MISMATCH`, `PROJECT_ID_MISMATCH`, or `INVALID_PROJECT_EXPECTATION`.
Roots, configs, manifests, TypeScript configs, and project-bound tool paths are canonicalized
through real paths. A config or file path outside the bound project fails before Ark returns
placement, golden-pattern, Layers, or ArkRules evidence. The MCP process never retargets itself
from tool input; disjoint projects need disjoint processes. The compatibility `ark://manifest`
resource also carries the identity envelope, but its binding is always `unverified` and
non-authoritative.
For `ark_check`, treat `verdict.overallOk` as the combined control-plane fact. It can be true only
when the binding is matched, analysis is complete, the graph is valid, coverage is complete
(non-empty, 100% governed, zero unclassified files), and both local-write and CI-merge gate state
are active. The underlying CLI fields remain present for diagnosis, but are not an authoritative
whole-project green on their own.
Current diagnostic envelopes use schema `1.4` and require `mode`,
`completeness: "complete" | "partial" | "unavailable"`, and structured

@@ -886,3 +978,5 @@ `completenessReasons`. Resolved results expose `policyHash`, `resolverIdentity`, `factsHash`, and

from stdin, validates the post-edit file content, and exits `2` with violations on stderr
to block the write (`0` to allow). Working Claude Code configuration
to block the write (`0` to allow). `--root-env` accepts a prioritized comma-separated
environment-variable list; ArkGate uses the first populated value and otherwise keeps
the explicit `--root` fallback. Working Claude Code configuration
(`.claude/settings.json`):

@@ -899,3 +993,3 @@

"type": "command",
"command": "npx ark-mcp --hook --root \"$CLAUDE_PROJECT_DIR\""
"command": "npx ark-mcp --hook --root . --root-env CLAUDE_PROJECT_DIR"
}

@@ -909,4 +1003,4 @@ ]

Register the server itself in `.mcp.json` so the agent can read `ark://manifest` and call
`validate_code` on demand:
Register the server itself in `.mcp.json` so the agent can handshake with `ark_identity`, call
`ark_manifest`, and use `validate_code` on demand:

@@ -924,2 +1018,4 @@ ```json

Decision rationale: [ADR 0017 — MCP verdicts require explicit project identity](adr/0017-mcp-project-identity-binding.md).
## Experimental runtime kernel workflow (not the default path)

@@ -926,0 +1022,0 @@

@@ -88,5 +88,8 @@ # Gating AI Agents with ArkGate

If your project uses Codex or Grok, treat MCP registration as part of the default
setup, not an optional extra. Ark works best when the agent can read `ark://manifest`
setup, not an optional extra. Ark works best when the agent can call `ark_manifest`
before it writes code; that is the fast path to avoiding architecture drift during
generation.
generation. Registration on disk is not runtime proof: after the host starts the server, call
`ark_identity` with `project.expectedRoot` set to the exact project's absolute root and require
a `matched`, authoritative binding. Then call `ark_manifest` with that root plus the returned
project id before using project evidence.

@@ -162,3 +165,3 @@ ## Claude Code — hook (recommended, hard block)

"type": "command",
"command": "npx ark-mcp --hook --hook-repair --root \"$CLAUDE_PROJECT_DIR\" --config ark.config.json"
"command": "npx ark-mcp --hook --hook-repair --root . --root-env CLAUDE_PROJECT_DIR --config ark.config.json"
}

@@ -178,3 +181,3 @@ ]

- [FORBIDDEN_IMPORT] Forbidden import target: "../adapters/persistence/pg-order-repository". (line 1)
Fix the violations and retry. The architecture contract is available as the ark://manifest MCP resource.
Fix the violations and retry. After ark_identity matches, call ark_manifest with the same project expectation for the authoritative contract.
```

@@ -197,3 +200,3 @@

"type": "command",
"command": "npx ark-mcp --session-context --root \"$CLAUDE_PROJECT_DIR\" --config ark.config.json"
"command": "npx ark-mcp --session-context --root . --root-env CLAUDE_PROJECT_DIR --config ark.config.json"
}

@@ -214,3 +217,3 @@ ]

- PersistenceAdapters: src/adapters/persistence/**
Rules: 10 denied layer edge(s). Full contract: ark://manifest MCP resource.
Rules: 10 denied layer edge(s). Full authoritative contract: ark_manifest after ark_identity.
Baseline: 3 frozen violation(s) — only NEW violations fail; do not add to them.

@@ -227,5 +230,16 @@ After edits run: npx ark-check --root . --config ark.config.json --strict

The MCP server exposes a resource and tools agents can use proactively (not an exhaustive list — `tools/list` is authoritative):
The MCP server exposes tools plus one compatibility resource that agents can use proactively
(not an exhaustive list — `tools/list` is authoritative):
- **`ark://manifest`** (resource) — the machine-readable architecture contract (layers + rules), so the agent can read the architecture before generating code.
- **`ark_identity`** (tool) — returns the canonical project/config identity and this live MCP
runtime identity. Call it first with
`{ "project": { "expectedRoot": "/absolute/exact-project-root" } }`, then reuse the same root
and returned `projectIdentity.projectId` as `project.expectedProjectId` on later calls. A
descendant path is authoritative only when that matching id is also supplied.
- **`ark_manifest`** (tool) — returns the authoritative machine-readable architecture contract
(layers + rules) after the identity handshake. Call it with the same project expectation before
generating code.
- **`ark://manifest`** (resource) — compatibility-only discovery for standard MCP
`resources/read`. Because that request has no portable project-expectation field, this resource
is always `unverified` and non-authoritative.
- **`validate_code`** (tool) — validates a snippet against the architecture on demand (the write-path gate). May return additive **`autoPatch`** (W1) for mechanical-safe import-type rewrites.

@@ -244,4 +258,17 @@ - **`ark_prepare_write`** (tool) — **W2:** place + constrain + validate + optional autoPatch + judgmentBrief + contentHash in one call (composes `ark_place` + write gate).

Tools appear in the agent's tool list automatically — no skill or doc-reading needed — so the agent can query the contract instead of shelling out and parsing.
Every Ark tool accepts the additive `project` expectation. Project-bound tool successes and
errors carry `projectIdentity`, `binding`, and `authoritative`. A legacy call with no expectation
stays callable but returns `binding.status: "unverified"` and `authoritative: false`; a wrong
root/id returns a mismatch before project evidence. Absolute project paths and
config/manifest/tsconfig inputs are real-path contained inside the server root. The process never
switches projects from input. The compatibility resource carries the same identity envelope but
is always unverified/non-authoritative.
`ark_check.verdict` keeps `identity`, `completeness`, `graph`, `coverage`, and `gates` separate.
Only `overallOk` combines them; a graph-clean result cannot paint an unverified binding,
incomplete analysis, empty/partial coverage, or inactive gates green.
Tools appear in the agent's tool list automatically — no skill or doc-reading needed — so the
agent can query the contract instead of shelling out and parsing.
```bash

@@ -293,3 +320,4 @@ claude mcp add ark -- npx ark-mcp --root . --config ark.config.json

path. If it reports violations, fix them before writing. The architecture
contract is available as the `ark://manifest` resource.
contract is available authoritatively from `ark_manifest` after `ark_identity`
matches. `ark://manifest` is compatibility-only and always unverified.
```

@@ -306,4 +334,5 @@

`.codex/hooks.json` with `ApplyPatch|apply_patch|Write|Edit|MultiEdit` aliases and reconstructs
every added or updated file in a multi-file patch before allowing it. The hook root uses
`${CODEX_PROJECT_DIR:-${PWD:-.}}`; it must not use Claude-only `CLAUDE_PROJECT_DIR`.
every added or updated file in a multi-file patch before allowing it. The hook passes
`--root . --root-env CODEX_PROJECT_DIR`: ArkGate reads the environment directly without
POSIX shell expansion and safely falls back to the hook working directory.

@@ -342,5 +371,42 @@ This hook is best-effort in Codex Code Mode: some hosts execute deferred nested `apply_patch`

Then **restart Codex** — it does not hot-load MCP servers. Expect resource `ark://manifest`
and tools `validate_code`, `ark_check`, `ark_coverage`, `ark_place`.
This file means **configured on disk**, not active. Install and compact
`ark start --tools codex --json` therefore report the setup-time state explicitly:
```json
{
"runtimeActivation": {
"configuredOnDisk": true,
"restartRequired": true,
"runtimeObserved": false,
"identityMatch": "unverified",
"active": false
}
}
```
Then **restart Codex** — it does not hot-load MCP servers — and call:
```json
{
"tool": "ark_identity",
"arguments": {
"project": {
"expectedRoot": "/absolute/exact-project-root"
}
}
}
```
The live response must say `binding.status: "matched"` and `authoritative: true`. Reuse its
`projectIdentity.projectId` with the same root on every subsequent Ark call, starting with
`ark_manifest`. A missing `ark_identity` or `ark_manifest` means the process is from an older
ArkGate build; restart the host and use the workspace-local CLI until the new server answers. A
mismatch means Codex is connected to another project's process; do not use its contract,
placement, Layers, ArkRules, or check evidence.
Setup-time `runtimeActivation` remains intentionally conservative: the installer/doctor cannot
observe a later MCP conversation from files alone. The matched `ark_identity` response is the
runtime observation for that caller and live process; it does not mutate the setup JSON.
`.codex/config.toml` by itself never upgrades `runtimeObserved` or `active`.
Codex uses the best-effort local patch hook plus advisory MCP for discovery/validation and

@@ -372,5 +438,7 @@ `ark-check` as the hard merge backstop. Register all three as soon as the repo is adopted.

When a valid project `.codex/config.toml` exists, doctor treats it as the effective binding and
does not report an unrelated home primary. Without a project binding, doctor surfaces the
legacy multi-project state. **Deferred (fix when using Codex):**
When a valid project `.codex/config.toml` exists, it is the expected binding, but files alone
cannot prove which already-running process answered. Doctor therefore keeps an unrelated home
primary as collision evidence until the live `ark_identity` matches; it does not label that home
entry as the active project. Without a project binding, doctor surfaces the legacy multi-project
state directly. **Deferred (fix when using Codex):**
non-temp Codex-home gaps (`codex-home-multi-project`, stale `$CODEX_HOME/skills`) are

@@ -395,2 +463,19 @@ severity **info**, marked `deferred: true`, and omitted from doctor **Primary next action** /

**Several repos on one machine:** repo catalogs are independent and follow each repository's
locally installed ArkGate package. A managed upgrade rewrites a repo skill only when its normalized
body changes; a package-version stamp alone is a no-op. The optional home catalog is shared, so
ArkGate 4.2.0+ installation is monotonic there: an older bundled source cannot replace a newer
managed home skill, even with `--force`. A pre-4.2 binary ignores the catalog/lock protocol and
can still overwrite those files, so upgrade every legacy repo before it runs `--codex-home`
(especially with `--force`). Identical content is idempotent, and preserved customization still
follows the explicit force contract. The shared skill only routes the workflow; project evidence remains
bound to the repo through `ark_identity`. Ark records the shared catalog in
`$CODEX_HOME/skills/.arkgate-catalog.json`, writes a durable
`.arkgate-catalog.pending.json` floor before mutations, and serializes concurrent installs.
Doctor suppresses home refresh gaps only when the maximum valid committed/pending floor is newer
than the repo package; an equal pending version remains actionable for recovery, and corrupt
metadata never counts as a floor. When a newer package retires a skill, Ark removes it only if the
bytes still match the prior managed identity; customized retired content is preserved and
released from Ark ownership.
**Parity & honesty (doctor / install):**

@@ -397,0 +482,0 @@

@@ -20,4 +20,4 @@ <svg xmlns="http://www.w3.org/2000/svg" width="760" height="330" viewBox="0 0 760 330" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="13">

<text x="40" y="174" fill="#f85149">- [FORBIDDEN_IMPORT] Forbidden import target: "../adapters/persistence/pg-order-repository".</text>
<text x="24" y="196" fill="#8b949e">Fix the violations and retry. The architecture contract is available as the</text>
<text x="24" y="214" fill="#8b949e">ark://manifest MCP resource.</text>
<text x="24" y="196" fill="#8b949e">Fix the violations and retry. The architecture contract is available through</text>
<text x="24" y="214" fill="#8b949e">the project-bound ark_manifest MCP tool.</text>

@@ -24,0 +24,0 @@ <text x="24" y="248" fill="#c9d1d9">● The domain layer can't import persistence adapters. I'll define the port in the</text>

@@ -45,2 +45,8 @@ # How to install agent gates

`--require-gates` implies strict config validation. It verifies content, not filenames alone:
`AGENTS.md` must contain the Ark contract and strict check, MCP registration must launch one Ark
server with an explicit project root, compact Codex must contain valid project config plus
SessionStart/PreToolUse Ark hooks, and CI must execute a fail-closed Ark command. Included but
unclassified source files therefore remain red.
Doctor JSON includes `writePath.mode` plus `enforcementLadder`: support, installation, observed

@@ -47,0 +53,0 @@ evidence, covered operations, bypassability, and CI honesty. MCP registration stays advisory.

@@ -19,5 +19,5 @@ # ArkGate package surface policy

|---------|----------------|-----------------|
| **CLI** | `arkgate` / `arkgate-check` (aliases `ark` / `ark-check`) | Flags and human text may improve; **JSON output shapes** for `--json` (check, doctor, plan, coverage, recommend) are stable within a major. Additive fields OK; removals/renames are major. |
| **CLI** | `arkgate` / `arkgate-check` (aliases `ark` / `ark-check`) | Flags and human text may improve; **JSON output shapes** for `--json` (check, doctor, plan, coverage, recommend) are stable within a major. Additive fields OK; removals/renames are major. In 4.2, `--require-gates` implies strict config and verifies semantic Ark AGENTS, project-rooted MCP/compact Codex registration, and fail-closed CI rather than file presence alone. |
| **Programmatic gate API** | `import { analyzeProject, loadContract, createAICodeGate, ... } from 'arkgate'` | The root export is the static gate/config/analysis contract listed below. It intentionally contains no runtime-kernel implementation. |
| **Doctor design fitness (P02+)** | `ark-check --doctor --json` → `doctor.designFitness`, `doctor.designSmells[]` | Additive. Stable smell `id`s: `io-under-application`, `handler-in-persistence`, `god-module`, `domain-logic-in-ui`, `facade-sql-in-routes`, `mixed-pattern-cluster`, `soft-contract`. Y02 extends `handler-in-persistence` to static ES imports/re-exports of framework HTTP surfaces (`next/server`), `defineRoute` calls, and existing handler bodies inside Persistence-role layers or specific persistence paths; `require()` and dynamic `import()` are outside this narrow advisory, and a generic `Infrastructure` role alone is not Persistence. Persistence candidates are filtered and sorted before the bounded content scan so large application prefixes cannot hide the advisory. The detector inspects the first 800 sorted Persistence candidates; later candidates are uninspected, so **absence of a smell is not full-tree proof** above that envelope (incomplete/`partial` analysis also never proves “no smells”). Each smell has `evidence[]`, `fix`, technical `message`, and plain-language **`outcome`** (Q02). Does **not** fail the gate by itself. |
| **Doctor design fitness (P02+)** | `ark-check --doctor --json` → `doctor.designFitness`, `doctor.designSmells[]` | Additive. Stable smell `id`s: `io-under-application`, `handler-in-persistence`, `god-module`, `domain-logic-in-ui`, `facade-sql-in-routes`, `mixed-pattern-cluster`, `soft-contract`. Y02 extends `handler-in-persistence` to static ES imports/re-exports of framework HTTP surfaces (`next/server`), `defineRoute` calls, and existing handler bodies inside Persistence-role layers or specific persistence paths; `require()` and dynamic `import()` are outside this narrow advisory, and a generic `Infrastructure` role alone is not Persistence. Persistence candidates are filtered and sorted before the bounded content scan so large application prefixes cannot hide the advisory. The detector inspects the first 800 sorted Persistence candidates; later candidates are uninspected, so **absence of a smell is not full-tree proof** above that envelope (incomplete/`partial` analysis also never proves “no smells”). **4.2 feedback hardening:** mode labels preserve the observed SUGGEST/ADAPT/ENFORCE state; a local permission/UI-state `canEdit` name alone is not a domain smell; real UI business rules route Domain → Application → UI; seed/fixture/demo/migration/generated files are not god-module pilots. Each smell has `evidence[]`, `fix`, technical `message`, and plain-language **`outcome`** (Q02). Does **not** fail the gate by itself. |
| **Post-green path (Q01)** | `doctor.postGreenPath`, `doctor.primaryNextAction`, `doctor.healthyFinishedForbidden` | Additive when `designFitness.designWeak`. Single Shape door (`id: clarify-for-ai`): explore shape-focus → dual-plan B → autopilot only with OK. Never empty plan A = healthy finished. |

@@ -36,6 +36,8 @@ | **Golden pattern (Q03)** | Optional `.ark/golden-pattern.json`; doctor JSON `doctor.goldenPattern`; MCP `ark_place` / `ark_prepare_write` → `goldenPattern` | Additive, **advisory for NEW code only**. Required fields: `name`, `norm`; optional `newCodeHome`, `examplePath`, `schemaVersion`. **Absent is normal** (no claim). Never ENFORCE; never clears design-weak. Malformed → `invalid: true`, not silent guidance. |

| **Governance weight (W02)** | `ark-check --doctor --json` → `doctor.contractHealth.governanceWeight` | Additive, **advisory only** — raw facts (`declaredLayers`, `populatedLayers`, `governedFiles`, `rules`, `deniedEdges`, `allowedEdges`, `filesPerLayer`, `rulesPerLayer`) plus a fixed comparative band `weight: heavy | typical | light | unknown` and its fixed `note`. Fixed deterministic thresholds: **heavy** = fewer than 25 governed files per declared layer AND (6+ layers OR 4+ well-formed rules per layer); **light** = at most 2 layers over 150+ governed files; **unknown** = no layers or no governed files; everything else is **typical** (banding uses raw ratios; the reported ratios are rounded for display). `notAScore: true` is explicit: never a composite score, ranking, or gate input; the heavy note asks to justify NEW layers/rules and never suggests deleting working ones. Human doctor prints a line only for `heavy`/`light`. |
| **Report parity (X01)** | `ark-check --report` → advisory sections (`data-advisory="contractHealth\|ambientState\|parseHealth"`, nested `governanceWeight`) + layer wall badges | The report is a rendering of doctor truth. **Standing rule:** every doctor advisory ships with its report section — enforced by the `reportParity` guard, which enumerates the doctor's advisory keys and fails on any missing section. |
| **MCP tools** | `arkgate-mcp` / `ark://…` resources | Tool names and primary argument shapes are stable within a major. |
| **Report parity and snapshot evidence (X01/4.2)** | `ark-check --report` → advisory sections (`data-advisory="contractHealth\|ambientState\|parseHealth"`, nested `governanceWeight`) + layer wall badges; `.ark/reports/*.json` | The report is a rendering of doctor truth. **Standing rule:** every doctor advisory ships with its report section — enforced by the `reportParity` guard, which enumerates the doctor's advisory keys and fails on any missing section. Snapshots add best-effort Git `HEAD`/branch/dirty provenance without a shell; unavailable Git is explicit. Evolution renders the Ark score delta only when both snapshots name the same ArkGate version, while retaining raw facts across versions. |
| **MCP project identity (4.2)** | `ark_identity`; `arkgate/schema/project-identity` or `arkgate/schema/ark.project-identity.schema.json`; root API constants/helpers/types | Schema `1.0`. `projectId` hashes canonical root + config path and stays stable across contract edits/restarts; runtime id/start time are separate. Every project-bound tool result and error carries `projectIdentity`, `binding` (`matched` / `unverified` / `mismatch`), and `authoritative`. Canonical out-of-root config/file evidence fails before project data. |
| **MCP tools and compatibility resource** | `arkgate-mcp`; `ark_manifest`; `ark://manifest` | Tool names and primary argument shapes are stable within a major. Every tool accepts additive `project.expectedRoot` / optional `expectedProjectId`. The initial handshake requires the exact project root; a contained descendant is authoritative only together with the matching project id. Legacy tool calls remain callable but `unverified` and non-authoritative. `ark_manifest` is the authoritative contract surface after binding. Standard `resources/read` cannot portably carry the expectation, so `ark://manifest` remains compatibility-only and always unverified/non-authoritative. The server never retargets from input. |
| **`ark.config.json`** | Layer globs, rules, include/exclude, forbiddenGlobals, intent prefixes, `peerIsolation`, `dynamicImportAllowlist`, `safety` thresholds; optional **`arkRules`** map (schema `1.1+`) | Versioned by `schemaVersion`; unknown fields fail closed and migrations preserve the previous supported major. Absence of `arkRules` is byte-for-byte silent on inter-layer verdicts. |
| **ArkRules inventory / under-contract (4.0)** | `ark-check --rules-inventory [--json]`; doctor `rulesUnderContract`; MCP `ark_rules_inventory` | Additive. Honest counts (inventoried / under-contract / frozen) — **never a score**. Structure/invariant diagnostics use adapter `1.4` provenance. |
| **ArkRules inventory / under-contract (4.0; layer context 4.2)** | `ark-check --rules-inventory [--json]`; doctor `rulesUnderContract`; MCP `ark_rules_inventory` | Additive. Honest counts (inventoried / under-contract / frozen) — **never a score**. When configured layer evidence exists it overrides filename role guesses: a Domain file named `handler` is not a controller candidate. Test/fixture/seed/migration/exclusion surfaces plus narrow development-identity, PostgreSQL OID, and technical I/O constants are silent. Without layer evidence, backward-compatible path/content heuristics remain. Structure/invariant diagnostics use adapter `1.4` provenance. |
| **`arkgate/schema/project-identity`** or **`arkgate/schema/ark.project-identity.schema.json`** | MCP canonical project, contract, runtime, expectation, and binding envelope | Schema `1.0`. Initial `expectedRoot` must be the exact project root. A contained descendant can match only when `expectedProjectId` is also present and correct; id-only matching stays non-authoritative. Mismatch codes are `PROJECT_ROOT_MISMATCH`, `PROJECT_ID_MISMATCH`, and `INVALID_PROJECT_EXPECTATION`. |
| **Package pin dual-truth (4.0)** | doctor JSON `packageVersionTruth`; upgrade JSON/human note when pin behind CLI | Additive, advisory. Surfaces after `upgrade --no-install` when managed CLI is ahead of package.json. |

@@ -54,3 +56,3 @@ | **Product honesty readiness split (4.1.1 EH)** | doctor JSON `productHonesty` | Additive. `unfinished` / `headline` / `primaryNextAction` / `reasonIds` remain; EH adds `contractReadiness` (`ready`\|`partial`\|`not-ready`), `localWriteBoundary` (`advisory`\|`hard`\|`unverified`\|`unknown`), `architectureReasonIds`, `environmentResidualIds` / `environmentResiduals`. Soft-write hosts stay in evidence without alone forcing global **Not finished**. `notAScore: true` always. |

| **Config JSON Schema** | `arkgate/schema` or `arkgate/schema/ark.config.schema.json` | Stable package resource subpaths for editor completion and contract tooling. |
| **Agent skills** | `/ark-*` templates; install via `--install-agent-gates` (often `--skills-only` on top of compact) | **Day zero** is the compact router from `ark start` / `start --apply` + doctor control plane — not the full skill pack. Skill *names* and the guided expert path (`/ark-autopilot` after pack install) are stable; internal skill prose may evolve. **4.0:** all skills except experimental `/ark-runtime` integrate **layers + ArkRules** and must label residual `[Layer]` vs `[ArkRules]`. |
| **Agent skills** | `/ark-*` templates; install via `--install-agent-gates` (often `--skills-only` on top of compact) | **Day zero** is the compact router from `ark start` / `start --apply` + doctor control plane — not the full skill pack. Skill *names* and the guided expert path (`/ark-autopilot` after pack install) are stable; internal skill prose may evolve. **4.0:** all skills except experimental `/ark-runtime` integrate **layers + ArkRules** and must label residual `[Layer]` vs `[ArkRules]`. **4.2:** repo catalogs are content-idempotent; the optional shared Codex home catalog is monotonic across 4.2.0+ installers. Pre-4.2 writers are outside that protocol and must be upgraded first. A durable pending-catalog journal preserves the floor across an interrupted install and is cleared only by its owning same/newer recovery. |
| **ESLint subpath** | `arkgate/eslint` | Config-driven layer/import rules; loads consumer `ark.config.json`. |

@@ -92,2 +94,3 @@ | **GitHub Action** | `pedroknigge/arkgate` (see `action.yml`) | The `uses:` tag/SHA selects the checker source; `version` remains an optional exact npm compatibility override. |

| Metadata and adapter diagnostics | `version`, `ARK_ANALYSIS_RESULT_SCHEMA_VERSION`, `ARK_ANALYSIS_RESULT_SCHEMA`, `createAdapterResult`, `toAdapterDiagnostic` |
| MCP project identity | `ARK_PROJECT_IDENTITY_SCHEMA_VERSION`, `ARK_PROJECT_IDENTITY_SCHEMA_URL`, `ARK_PROJECT_IDENTITY_SCHEMA`, `PROJECT_EXPECTATION_SCHEMA`, `PROJECT_BINDING_SCHEMA`, `createProjectId`, `createProjectIdentity` |
| AI snippet gate | `createAICodeGate` |

@@ -105,2 +108,3 @@ | Profiles and config factories | `createArchitectureProfile`, `createArchitectureProfileFromArkConfig`, `createElevenLayerArkConfig`, `elevenLayerProfile` |

`AdapterViolationInput`, `AdapterCompletenessReason`, `AnalysisCompleteness`, `AnalysisMode`.
- MCP project identity: `ProjectIdentity`, `ProjectExpectation`, `ProjectBinding`.
- Resolved facts: `ResolvedCandidateFacts`, `ResolvedCandidateFactsInput`, and their

@@ -198,6 +202,7 @@ dependency/file/evidence component types.

Ship notes for a version live under [releases/](https://github.com/pedroknigge/arkgate/tree/main/docs/releases)
(current published: [4.1.0.md](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/4.1.0.md);
prepared next: [4.1.1.md](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/4.1.1.md);
previous: [4.0.1.md](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/4.0.1.md),
(prepared: [4.2.0.md](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/4.2.0.md);
current published: [4.1.1.md](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/4.1.1.md);
previous: [4.1.0.md](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/4.1.0.md),
[4.0.1.md](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/4.0.1.md),
[4.0.0.md](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/4.0.0.md)).
Publish path: signed annotated tag → GitHub Release → `publish-npm.yml` (see [CONTRIBUTING.md](https://github.com/pedroknigge/arkgate/blob/main/CONTRIBUTING.md)).

@@ -25,3 +25,4 @@ # ArkGate product voice

- **False done is forbidden:** Enforce ≠ elegant design. `design-weak` / residual must not
read as “healthy finished.” Empty ArkRules inventory is not a score.
read as “healthy finished.” Empty ArkRules inventory is not a score. MCP configuration on
disk is not proof that the current process belongs to this project.

@@ -77,2 +78,8 @@ ---

| **advisory write** | MCP/rules coach only (Cursor/Codex at write time) — not a hard block |
| **project identity** | Stable canonical root + config identity returned by `ark_identity`; separate from contract and process identity |
| **matched binding** | Live MCP answered for the exact project root, or for a contained descendant together with the matching project id; only this binding is authoritative |
| **authoritative manifest** | Contract returned by `ark_manifest` after a matched identity handshake |
| **compatibility manifest resource** | `ark://manifest` through standard `resources/read`; always unverified/non-authoritative because the request cannot portably carry a project expectation |
| **configured on disk** | Host files name an Ark MCP command; says nothing about which process is currently running |
| **runtime observed** | A live `ark_identity` response matched this workspace; never infer it from `.codex/config.toml` or hook files |
| **required CI / status context** | Merge hard boundary when the repository makes the Ark job a **required GitHub status context** (CLI: `arkgate-check --strict-merge` / `ark-check --strict-merge`) |

@@ -94,2 +101,4 @@ | **contract ready** | Project/layers/ArkRules honesty residual clear — not the same as “hard local write” |

| Prefer fail-closed over fake hard | Incomplete analysis, unobserved hooks, and soft MCP never paint as hard green |
| State project binding before verdict | “Ark MCP matched this workspace; `ark_manifest` evidence is authoritative.” Otherwise: “Ark MCP is configured, but runtime identity is unverified. Restart and call `ark_identity` with the exact project root.” |
| Keep inventory claims evidence-bound | “Possible rule candidate in the configured Application layer.” A filename or technical constant alone is not Domain evidence. |
| Honesty clear ≠ architecture healthy | `productHonesty.finished` means residual **architecture** honesty sensors are clear — not a green graph score. Open blocking violations, ADAPT/SUGGEST with debt, dual-truth pin, or design residual keep `unfinished: true`. Permanent soft-write alone does **not**. |

@@ -111,2 +120,5 @@ | Separate CI runtime from provider policy | Successful CI run ≠ required status; GitHub Free plan 403 → `unavailable-plan`, not “CI never ran.” |

| “Not finished” solely because host is Codex/Cursor | Soft-write is environment residual; do not paint a green whole-tree project as unfinished architecture |
| “MCP installed / active” because a config file exists | Say **configured on disk · runtime unverified** until `ark_identity` matches the expected root |
| Treating an unverified legacy MCP call as authoritative | Compatibility is not proof; require `binding.status: "matched"` and `authoritative: true` |
| “Handler means controller” / “every constant is a business rule” | ArkRules inventory uses configured layer context and suppresses narrow technical/test evidence; candidates remain prompts for judgment |
| Conflating CLI name with required status | `ark-check` is the command; the hard boundary is the GitHub required **status context** |

@@ -113,0 +125,0 @@ | “ArkRules prove business correctness” | They enforce *declared* structure/coverage evidence, not arbitrary logic or full semantic proof |

@@ -55,3 +55,3 @@ # ArkGate documentation

| Release notes (by version) | [releases/](releases/) · [CHANGELOG.md](../CHANGELOG.md) |
| Epic plans (seeded + shipped) | [plans/](plans/) · latest: [enforcement-evidence-and-docs-truth](plans/enforcement-evidence-and-docs-truth/README.md) (Phase EH shipped; 4.1.1 prepared) |
| Epic plans (seeded + shipped) | [plans/](plans/) · active: [workspace identity and activation truth](plans/workspace-identity-activation-truth/README.md) (`WI01`, 4.2.0 corrective minor) |
| Claims audit | [audit/claims-matrix.md](audit/claims-matrix.md) |

@@ -61,4 +61,6 @@ | Field adoption kit (scaffolding, not closed) | [field/](field/) |

Current published: [releases/4.1.0.md](releases/4.1.0.md) (`arkgate@4.1.0` on npm `latest`).
Prepared next: [releases/4.1.1.md](releases/4.1.1.md) (Phase EH — **Status: prepared**, not on npm until publish).
Prepared candidate: [releases/4.2.0.md](releases/4.2.0.md) (`arkgate@4.2.0`, not published).
Current published: [releases/4.1.1.md](releases/4.1.1.md) (`arkgate@4.1.1` on npm `latest`).
Previous: [releases/4.1.0.md](releases/4.1.0.md) (`arkgate@4.1.0`).
Previous major: [releases/4.0.0.md](releases/4.0.0.md) (`arkgate@4.0.0`).

@@ -65,0 +67,0 @@ Config: [configuration.md](configuration.md) · Agent skills dual-plane: [agent-guide.md](agent-guide.md).

{
"name": "arkgate",
"version": "4.1.1",
"version": "4.2.0",
"description": "ArkGate — architecture co-pilot for AI TypeScript (write gate, CI gate, plan/loop; optional ArkRules)",

@@ -24,2 +24,4 @@ "type": "module",

"./schema/ark.analysis-result.schema.json": "./schemas/ark.analysis-result.schema.json",
"./schema/project-identity": "./schemas/ark.project-identity.schema.json",
"./schema/ark.project-identity.schema.json": "./schemas/ark.project-identity.schema.json",
"./schema/change-map": "./schemas/ark.change-map.schema.json",

@@ -26,0 +28,0 @@ "./schema/ark.change-map.schema.json": "./schemas/ark.change-map.schema.json",

@@ -19,4 +19,5 @@ <div align="center">

> **ArkGate 4.1.0** is on npm `latest`. Tree prepares **4.1.1** (Phase EH honesty + CI/report fixes) — [4.1.1 notes](docs/releases/4.1.1.md) (**prepared**, not on npm until publish).
> [4.1.0 notes](docs/releases/4.1.0.md) · [4.0.1](docs/releases/4.0.1.md) · [Docs hub](docs/README.md) · [Product voice](docs/product-voice.md)
> **ArkGate 4.2.0** is prepared (workspace identity + safe multi-repo skills);
> **4.1.1** remains on npm `latest` until publication.
> [4.2.0 candidate](docs/releases/4.2.0.md) · [4.1.1](docs/releases/4.1.1.md) · [4.1.0](docs/releases/4.1.0.md) · [Docs hub](docs/README.md) · [Product voice](docs/product-voice.md)

@@ -114,2 +115,3 @@ ---

This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair **envelopes** may be emitted without reinjection being guaranteed; silent auto-apply never happens. Run `arkgate-check --doctor` (or `ark-check --doctor`) for the evidence actually detected in the current repository.
<!-- arkgate-host-support:end -->

@@ -135,2 +137,8 @@

For authoritative MCP contract evidence, call `ark_identity` with the exact project root, then
call `ark_manifest` with that root plus the returned project id. A contained descendant requires
the matching id. The legacy `ark://manifest` resource remains compatibility-only and always
unverified/non-authoritative because standard `resources/read` cannot portably carry that
expectation.
---

@@ -144,3 +152,3 @@

| Hard-block AI writes on supported hosts | ✅ | ❌ |
| Contract agents can read (`ark://manifest`) | ✅ | ❌ |
| Project-bound contract agents can read (`ark_manifest`) | ✅ | ❌ |
| Placement + preflight for multi-file changes | ✅ | ❌ |

@@ -193,3 +201,6 @@ | Honest governed % + dual plan (edges vs shape) | ✅ | ❌ |

| Security | [SECURITY.md](SECURITY.md) |
| Current published (4.1.0) | [docs/releases/4.1.0.md](docs/releases/4.1.0.md) · [CHANGELOG](CHANGELOG.md) |
| Prepared candidate (4.2.0) | [docs/releases/4.2.0.md](docs/releases/4.2.0.md) · [CHANGELOG](CHANGELOG.md) |
| Current published (4.1.1) | [docs/releases/4.1.1.md](docs/releases/4.1.1.md) |
| Previous (4.1.0) | [docs/releases/4.1.0.md](docs/releases/4.1.0.md) |
| Previous patch (4.0.1) | [docs/releases/4.0.1.md](docs/releases/4.0.1.md) |
| Previous (4.0.0) | [docs/releases/4.0.0.md](docs/releases/4.0.0.md) |

@@ -196,0 +207,0 @@ | Previous (3.9.2) | [docs/releases/3.9.2.md](docs/releases/3.9.2.md) |

@@ -9,3 +9,3 @@ {

},
"version": "4.1.1",
"version": "4.2.0",
"packages": [

@@ -15,3 +15,3 @@ {

"identifier": "arkgate",
"version": "4.1.1",
"version": "4.2.0",
"runtimeHint": "npx",

@@ -18,0 +18,0 @@ "transport": {

@@ -33,2 +33,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -35,0 +44,0 @@

@@ -34,2 +34,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -75,4 +84,5 @@

1. **Detect the shape** — call MCP tool **`ark_recommend`** (or run
`ark-check --recommend --json`). Read `archetype`, `preset`, `confidence`,
1. **Bind and detect the shape** — complete the mandatory `ark_identity` preflight first, then
call MCP tool **`ark_recommend`** with the bound `project` envelope (or run the workspace-local
`ark-check --recommend --json`). Never use an unverified recommendation. Read `archetype`, `preset`, `confidence`,
`adoptInOrder.phase1`, `analogy`, `why`, `evidence`, and `requiresConfirmation`.

@@ -79,0 +89,0 @@ Ask at most **two** questions only if `requiresConfirmation` is true (or for compatibility

@@ -68,2 +68,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -70,0 +79,0 @@

@@ -16,3 +16,4 @@ ---

The **one sanctioned way** to change layers/rules/`intentPrefixes`/includes.
Also used to **land mined business rules** into the executable manifest (`ark.config.json` + intent naming that `ark://manifest` exposes).
Also used to **land mined business rules** into the executable manifest (`ark.config.json` +
intent naming that the project-bound `ark_manifest` tool exposes authoritatively).

@@ -31,2 +32,11 @@

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -33,0 +43,0 @@

@@ -46,2 +46,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -48,0 +57,0 @@

@@ -30,2 +30,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -150,3 +159,6 @@

1. **Load the real contract**: `ark.config.json`, `ark://manifest` if available, `AGENTS.md`.
1. **Load the real contract**: `ark.config.json`, `AGENTS.md`, and—when MCP is available—
`ark_identity` with the exact project root followed by `ark_manifest` with the same root plus
returned project id. `ark://manifest` is compatibility-only and always
unverified/non-authoritative.
2. **If asked generally** ("explain the architecture"), produce a guided tour:

@@ -153,0 +165,0 @@ - Operating mode + governed% (honest: low coverage means green checks almost nothing).

@@ -71,2 +71,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -73,0 +82,0 @@

@@ -55,2 +55,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -133,3 +142,3 @@

- Propose intent name + layer placement.
- Register / place code so `ark://manifest` / config can enforce it.
- Register / place code so `ark_manifest` / config can enforce it.
- Do not only delete the import.

@@ -136,0 +145,0 @@

@@ -45,2 +45,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -124,4 +133,4 @@

2. Moves are **proposed only** — enumerate the full move set for the pilot anchor, express it as
an architecture change map, and validate through the atomic preflight (`ark_prepare_change` /
the write gate) **before** any file moves. A move the preflight rejects is a finding, not a
an architecture change map, and validate through the atomic preflight (`ark_prepare_change`
with the matched `project` envelope / the write gate) **before** any file moves. A move the preflight rejects is a finding, not a
thing to force.

@@ -128,0 +137,0 @@ 3. Never move anything under `app/` or `pages/` (fixed by framework convention). Never merge

@@ -39,2 +39,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -70,4 +79,5 @@

1. **Read the contract, not your intuition.** If the `ark` MCP server is available,
call the **`ark_place`** tool with the target file path — it returns the layer,
1. **Read the contract, not your intuition.** If the `ark` MCP server is available, complete
the mandatory `ark_identity` preflight first, then call **`ark_place`** with the target file
path and bound `project` envelope — it returns the layer,
its forbidden globals, and exactly which layers the file may / must not import,

@@ -77,6 +87,7 @@ straight from the contract (no guessing). When present, also honor optional

advisory layout norm; never overrides the gate and never clears design-weak.
Absent golden is normal. Otherwise load `ark.config.json` and the
`ark://manifest` MCP resource (it includes `suggestedLayers` with conventional
directories for layers not yet adopted). The project's `AGENTS.md` placement table,
if present, is authoritative too.
Absent golden is normal. Otherwise load `ark.config.json`; after the matched preflight,
`ark_manifest` with the same bound envelope includes `suggestedLayers` with conventional
directories for layers not yet adopted. The `ark://manifest` resource is compatibility-only and always
unverified/non-authoritative. The project's `AGENTS.md` placement table, if present, is
authoritative too.
2. **Classify the artifact** by what it does, not what it's called:

@@ -83,0 +94,0 @@ - Pure business rules/entities/value objects → domain-model layer.

@@ -27,3 +27,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Out of scope for ArkRules

@@ -30,0 +38,0 @@

@@ -34,2 +34,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -75,4 +84,7 @@

1. **Load the contract** — `ark.config.json`, MCP `ark://manifest` if available, and
`ark-check --coverage --json` / `--doctor` for honesty about governed% and false-green.
1. **Load the contract** — `ark.config.json`; when MCP is available, call `ark_identity` with
the exact project root followed by `ark_manifest` with the same root plus returned project
id. The `ark://manifest` resource is compatibility-only and always
unverified/non-authoritative. Use `ark-check --coverage --json` / `--doctor` for honesty
about governed% and false-green.
2. **Touch the decision surface** — README skim + **≥5 source files** on the feature/package/boundary

@@ -79,0 +91,0 @@ under discussion. Name paths in the answer.

@@ -23,2 +23,11 @@ ---

## MCP workspace binding (mandatory)
Before any `ark_*` MCP tool, call `ark_identity` with `project.expectedRoot` set to the exact
workspace root. Continue only when `binding.status === "matched"` and `authoritative === true`;
retain `projectIdentity.projectId`, then pass both `expectedRoot` and `expectedProjectId` under
`project` on every later MCP call. If identity is missing, mismatched, unverified, or the root is
uncertain, do not consume MCP analysis: use the workspace-local CLI and report that MCP
restart/retargeting is required. `ark://manifest` never satisfies this preflight.
## Dual plane — layers + ArkRules (mandatory, except /ark-runtime)

@@ -25,0 +34,0 @@

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display