arkgate 3.6.1 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -1145
- package/README.md +59 -19
- package/bin/ark-check-runtime.mjs +1598 -0
- package/bin/ark-check.mjs +32 -1565
- package/bin/ark-layer-match.mjs +2 -1
- package/bin/ark-mcp-runtime.mjs +1976 -0
- package/bin/ark-mcp.mjs +84 -1495
- package/bin/ark-shared.mjs +34 -38
- package/bin/ark.mjs +33 -66
- package/bin/lib/adapter-contract.mjs +161 -9
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/analysis-completeness.mjs +28 -0
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/analysis-policy.mjs +27 -0
- package/bin/lib/architecture-scan.mjs +70 -304
- package/bin/lib/auto-patch.mjs +76 -8
- package/bin/lib/ci-and-commands.mjs +1 -1
- package/bin/lib/codex-home.mjs +43 -16
- package/bin/lib/design-delta.mjs +4 -0
- package/bin/lib/design-smells.mjs +67 -14
- package/bin/lib/doctor-advisories.mjs +23 -7
- package/bin/lib/doctor-plan.mjs +44 -47
- package/bin/lib/enforcement-state.mjs +2 -0
- package/bin/lib/github-enforcement.mjs +443 -0
- package/bin/lib/hook-templates.mjs +12 -148
- package/bin/lib/html-report-advisories.mjs +59 -0
- package/bin/lib/html-report-depth.mjs +9 -0
- package/bin/lib/html-report.mjs +5 -5
- package/bin/lib/install-migrate.mjs +83 -79
- package/bin/lib/managed-upgrade.mjs +622 -0
- package/bin/lib/mcp-adoption.mjs +3 -1
- package/bin/lib/parse-health.mjs +75 -0
- package/bin/lib/port-proof.mjs +2 -2
- package/bin/lib/prepare-change.mjs +68 -38
- package/bin/lib/prepare-write.mjs +7 -1
- package/bin/lib/reshape-decisions.mjs +284 -0
- package/bin/lib/resident-doctor-client.mjs +55 -0
- package/bin/lib/resident-hook.mjs +247 -0
- package/bin/lib/resolved-candidate-facts.mjs +1160 -0
- package/bin/lib/scan-files.mjs +19 -6
- package/bin/lib/snippet-analysis.mjs +119 -0
- package/bin/lib/source-policy.mjs +24 -0
- package/bin/lib/typescript-host.mjs +15 -18
- package/bin/lib/unavailable-analysis.mjs +76 -0
- package/bin/lib/upgrade-command.mjs +115 -0
- package/bin/lib/weakest-link.mjs +21 -179
- package/bin/lib/write-path-capabilities.mjs +167 -16
- package/bin/lib/write-path-detect.mjs +3 -2
- package/dist/eslint/index.cjs +3 -3
- package/dist/eslint/index.d.ts +3 -0
- package/dist/eslint/index.js +3 -3
- package/dist/index.cjs +7 -7
- package/dist/index.d.ts +1073 -141
- package/dist/index.js +7 -7
- package/docs/agent-guide.md +127 -52
- package/docs/ai-gates.md +100 -18
- package/docs/configuration.md +6 -0
- package/docs/demos/01-write-gate-self-correction.md +2 -2
- package/docs/enthusiast/README.md +10 -10
- package/docs/enthusiast/how-to-gallery-starter.md +2 -2
- package/docs/enthusiast/reference-commands.md +18 -1
- package/docs/enthusiast/tutorial-first-project.md +2 -2
- package/docs/package-surface.md +101 -14
- package/docs/typescript-support.md +118 -37
- package/package.json +33 -4
- package/schemas/ark.analysis-result.schema.json +159 -2
- package/schemas/ark.design-delta.schema.json +1 -0
- package/schemas/ark.enforcement-state.schema.json +84 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
- package/server.json +2 -2
- package/templates/skills/ark-autopilot.md +12 -0
- package/templates/skills/ark-explore.md +12 -5
- package/templates/skills/ark-fix.md +12 -2
- package/templates/skills/ark-loop.md +14 -1
- package/templates/skills/ark-runtime.md +15 -8
- package/templates/skills/ark-upgrade.md +122 -182
- package/bin/lib/ai-velocity.mjs +0 -293
- package/bin/lib/graph-cycles.mjs +0 -6
- package/bin/lib/safety-diagnostics.mjs +0 -284
- package/bin/lib/ts-resolve.mjs +0 -227
- package/dist/configTypes-DAPvBqK6.d.cts +0 -61
- package/dist/eslint/index.d.cts +0 -146
- package/dist/index.d.cts +0 -986
package/bin/lib/weakest-link.mjs
CHANGED
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
* Q3 — Weakest-link enforcement sensors (local, pure FS; optional gh for protection).
|
|
3
3
|
* Missing CI, config drift, pre-commit human-edit path, and honest branch-protection report.
|
|
4
4
|
*/
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
5
|
import fs from 'node:fs';
|
|
7
6
|
import path from 'node:path';
|
|
8
7
|
import { arkCommand } from '../ark-shared.mjs';
|
|
8
|
+
import {
|
|
9
|
+
enforcingArkRunText,
|
|
10
|
+
reportGithubBranchProtection,
|
|
11
|
+
runsArkCheck,
|
|
12
|
+
} from './github-enforcement.mjs';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
isArkRequiredStatusCheck,
|
|
16
|
+
jobIdsThatRunArkCheck,
|
|
17
|
+
reportGithubBranchProtection,
|
|
18
|
+
} from './github-enforcement.mjs';
|
|
9
19
|
|
|
10
20
|
const PRECOMMIT_MARKERS = [
|
|
11
21
|
'ark-check',
|
|
@@ -60,13 +70,12 @@ export function classifyArkCheckFlags(text) {
|
|
|
60
70
|
if (!text || typeof text !== 'string') {
|
|
61
71
|
return { hasFailClosedFlag: false, hasStrictConfigOnly: false, hasStrictFlag: false };
|
|
62
72
|
}
|
|
63
|
-
const hasStrictMerge =
|
|
64
|
-
const hasRequireGates =
|
|
73
|
+
const hasStrictMerge = /(?:^|\s)--strict-merge(?=\s|$)/.test(text);
|
|
74
|
+
const hasRequireGates = /(?:^|\s)--require-gates(?=\s|$)/.test(text);
|
|
65
75
|
// Bare --strict (alias of --strict-merge), not --strict-config / already-matched merge.
|
|
66
|
-
const
|
|
67
|
-
const hasBareStrict = /--strict\b/.test(withoutLong);
|
|
76
|
+
const hasBareStrict = /(?:^|\s)--strict(?=\s|$)/.test(text);
|
|
68
77
|
const hasFailClosedFlag = hasStrictMerge || hasRequireGates || hasBareStrict;
|
|
69
|
-
const hasStrictConfig =
|
|
78
|
+
const hasStrictConfig = /(?:^|\s)--strict-config(?=\s|$)/.test(text);
|
|
70
79
|
const hasStrictConfigOnly = hasStrictConfig && !hasFailClosedFlag;
|
|
71
80
|
const hasStrictFlag = hasFailClosedFlag || hasStrictConfigOnly;
|
|
72
81
|
return { hasFailClosedFlag, hasStrictConfigOnly, hasStrictFlag };
|
|
@@ -118,8 +127,6 @@ export function detectCiEnforcement(root) {
|
|
|
118
127
|
} catch {
|
|
119
128
|
checkArchScript = '';
|
|
120
129
|
}
|
|
121
|
-
const scriptFlags = classifyArkCheckFlags(checkArchScript);
|
|
122
|
-
|
|
123
130
|
for (const f of files) {
|
|
124
131
|
let text = '';
|
|
125
132
|
try {
|
|
@@ -127,17 +134,13 @@ export function detectCiEnforcement(root) {
|
|
|
127
134
|
} catch {
|
|
128
135
|
continue;
|
|
129
136
|
}
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
/\bark-check\b/.test(text) ||
|
|
133
|
-
/check:architecture/.test(text) ||
|
|
134
|
-
/pedroknigge\/arkgate/.test(text);
|
|
137
|
+
const arkRunText = enforcingArkRunText(text, checkArchScript);
|
|
138
|
+
const mentionsArk = runsArkCheck(text, checkArchScript);
|
|
135
139
|
if (mentionsArk) {
|
|
136
140
|
out.hasArkCheckWorkflow = true;
|
|
137
141
|
out.arkWorkflowFiles.push(`.github/workflows/${f}`);
|
|
138
|
-
const flags = classifyArkCheckFlags(
|
|
139
|
-
|
|
140
|
-
if (flags.hasFailClosedFlag || viaScript) {
|
|
142
|
+
const flags = classifyArkCheckFlags(arkRunText);
|
|
143
|
+
if (flags.hasFailClosedFlag) {
|
|
141
144
|
out.hasFailClosedFlag = true;
|
|
142
145
|
out.failClosed = true;
|
|
143
146
|
out.hasStrictFlag = true;
|
|
@@ -156,60 +159,6 @@ export function detectCiEnforcement(root) {
|
|
|
156
159
|
return out;
|
|
157
160
|
}
|
|
158
161
|
|
|
159
|
-
/**
|
|
160
|
-
* Job ids (GitHub Actions) whose job body runs ark-check / check:architecture.
|
|
161
|
-
* Used so required status check "build" counts when the build job runs Ark.
|
|
162
|
-
* @param {string} root
|
|
163
|
-
* @returns {Set<string>}
|
|
164
|
-
*/
|
|
165
|
-
export function jobIdsThatRunArkCheck(root) {
|
|
166
|
-
const ids = new Set();
|
|
167
|
-
const wfDir = path.join(root, '.github', 'workflows');
|
|
168
|
-
if (!fs.existsSync(wfDir)) return ids;
|
|
169
|
-
let files = [];
|
|
170
|
-
try {
|
|
171
|
-
files = fs.readdirSync(wfDir).filter((f) => /\.ya?ml$/i.test(f));
|
|
172
|
-
} catch {
|
|
173
|
-
return ids;
|
|
174
|
-
}
|
|
175
|
-
for (const f of files) {
|
|
176
|
-
let text = '';
|
|
177
|
-
try {
|
|
178
|
-
text = fs.readFileSync(path.join(wfDir, f), 'utf8');
|
|
179
|
-
} catch {
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
if (!/\barkgate-check\b|\bark-check\b|check:architecture/.test(text)) continue;
|
|
183
|
-
const jobsIdx = text.search(/^jobs:\s*$/m);
|
|
184
|
-
if (jobsIdx < 0) continue;
|
|
185
|
-
const jobsSection = text.slice(jobsIdx);
|
|
186
|
-
const re = /^ {2}([A-Za-z0-9_-]+):\s*$/gm;
|
|
187
|
-
const matches = [...jobsSection.matchAll(re)];
|
|
188
|
-
for (let i = 0; i < matches.length; i++) {
|
|
189
|
-
const jobId = matches[i][1];
|
|
190
|
-
const start = matches[i].index ?? 0;
|
|
191
|
-
const end = i + 1 < matches.length ? (matches[i + 1].index ?? jobsSection.length) : jobsSection.length;
|
|
192
|
-
const body = jobsSection.slice(start, end);
|
|
193
|
-
if (/\barkgate-check\b|\bark-check\b|check:architecture/.test(body)) {
|
|
194
|
-
ids.add(jobId);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return ids;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Whether required status checks include Ark (by name or by matching a job that runs Ark).
|
|
203
|
-
* @param {string} root
|
|
204
|
-
* @param {string[]} requiredNames
|
|
205
|
-
*/
|
|
206
|
-
export function isArkRequiredStatusCheck(root, requiredNames) {
|
|
207
|
-
if (!Array.isArray(requiredNames) || requiredNames.length === 0) return false;
|
|
208
|
-
if (requiredNames.some((n) => /ark|architecture|arkgate/i.test(String(n)))) return true;
|
|
209
|
-
const jobIds = jobIdsThatRunArkCheck(root);
|
|
210
|
-
return requiredNames.some((n) => jobIds.has(String(n)));
|
|
211
|
-
}
|
|
212
|
-
|
|
213
162
|
/**
|
|
214
163
|
* Config / gate surface drift (adopted projects only).
|
|
215
164
|
* @param {string} root
|
|
@@ -258,106 +207,6 @@ export function detectConfigGateDrift(root, opts = {}) {
|
|
|
258
207
|
return { hasConfig, hasAgents, hasCheckScript, issues };
|
|
259
208
|
}
|
|
260
209
|
|
|
261
|
-
/**
|
|
262
|
-
* Optional GitHub branch-protection / required-check report.
|
|
263
|
-
* Never fakes green: unavailable when gh missing, no remote, or API error.
|
|
264
|
-
*
|
|
265
|
-
* @param {{ cwd?: string, repo?: string, branch?: string, env?: NodeJS.ProcessEnv }} [opts]
|
|
266
|
-
* @returns {{
|
|
267
|
-
* available: boolean,
|
|
268
|
-
* reason?: string,
|
|
269
|
-
* requiredStatusChecks?: string[] | null,
|
|
270
|
-
* strict?: boolean | null,
|
|
271
|
-
* enforcesAdmins?: boolean | null,
|
|
272
|
-
* arkCheckRequired?: boolean | null,
|
|
273
|
-
* raw?: unknown,
|
|
274
|
-
* }}
|
|
275
|
-
*/
|
|
276
|
-
export function reportGithubBranchProtection(opts = {}) {
|
|
277
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
278
|
-
const env = opts.env ?? process.env;
|
|
279
|
-
const gh = spawnSync('gh', ['--version'], { encoding: 'utf8', env });
|
|
280
|
-
if (gh.status !== 0) {
|
|
281
|
-
return { available: false, reason: 'gh-cli-unavailable' };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
let repo = opts.repo;
|
|
285
|
-
if (!repo) {
|
|
286
|
-
const r = spawnSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], {
|
|
287
|
-
cwd,
|
|
288
|
-
encoding: 'utf8',
|
|
289
|
-
env,
|
|
290
|
-
});
|
|
291
|
-
if (r.status !== 0 || !r.stdout?.trim()) {
|
|
292
|
-
return { available: false, reason: 'gh-repo-unavailable', raw: r.stderr };
|
|
293
|
-
}
|
|
294
|
-
repo = r.stdout.trim();
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
let branch = opts.branch;
|
|
298
|
-
if (!branch) {
|
|
299
|
-
const b = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
300
|
-
cwd,
|
|
301
|
-
encoding: 'utf8',
|
|
302
|
-
env,
|
|
303
|
-
});
|
|
304
|
-
branch = b.status === 0 ? b.stdout.trim() : 'main';
|
|
305
|
-
if (branch === 'HEAD') branch = 'main';
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const view = spawnSync(
|
|
309
|
-
'gh',
|
|
310
|
-
[
|
|
311
|
-
'api',
|
|
312
|
-
`repos/${repo}/branches/${encodeURIComponent(branch)}/protection`,
|
|
313
|
-
'--jq',
|
|
314
|
-
'{strict: .required_status_checks.strict, contexts: .required_status_checks.contexts, checks: .required_status_checks.checks, enforcesAdmins: .enforce_admins.enabled}',
|
|
315
|
-
],
|
|
316
|
-
{ cwd, encoding: 'utf8', env }
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
if (view.status !== 0) {
|
|
320
|
-
const err = `${view.stderr || ''}${view.stdout || ''}`;
|
|
321
|
-
if (/Not Found|404|Branch not protected/i.test(err)) {
|
|
322
|
-
return {
|
|
323
|
-
available: true,
|
|
324
|
-
reason: 'branch-not-protected',
|
|
325
|
-
requiredStatusChecks: [],
|
|
326
|
-
strict: false,
|
|
327
|
-
enforcesAdmins: false,
|
|
328
|
-
arkCheckRequired: false,
|
|
329
|
-
raw: err.slice(0, 400),
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
return { available: false, reason: 'gh-api-error', raw: err.slice(0, 400) };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
let parsed = null;
|
|
336
|
-
try {
|
|
337
|
-
parsed = JSON.parse(view.stdout || '{}');
|
|
338
|
-
} catch {
|
|
339
|
-
return { available: false, reason: 'gh-api-parse-error', raw: view.stdout };
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const contexts = Array.isArray(parsed.contexts) ? parsed.contexts.map(String) : [];
|
|
343
|
-
const checkNames = Array.isArray(parsed.checks)
|
|
344
|
-
? parsed.checks.map((c) => String(c?.context || c?.name || '')).filter(Boolean)
|
|
345
|
-
: [];
|
|
346
|
-
const all = [...new Set([...contexts, ...checkNames])];
|
|
347
|
-
// Name match OR required check id equals a workflow job that runs ark-check (e.g. "build").
|
|
348
|
-
const arkCheckRequired = isArkRequiredStatusCheck(cwd, all);
|
|
349
|
-
|
|
350
|
-
return {
|
|
351
|
-
available: true,
|
|
352
|
-
reason: 'ok',
|
|
353
|
-
requiredStatusChecks: all,
|
|
354
|
-
strict: Boolean(parsed.strict),
|
|
355
|
-
enforcesAdmins: Boolean(parsed.enforcesAdmins),
|
|
356
|
-
arkCheckRequired,
|
|
357
|
-
raw: parsed,
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
|
|
361
210
|
/**
|
|
362
211
|
* Adoption gaps for weakest-link (Q3). Does not require network.
|
|
363
212
|
* @param {string} root
|
|
@@ -434,14 +283,7 @@ export function collectWeakestLinkGaps(root, opts = {}) {
|
|
|
434
283
|
let github = null;
|
|
435
284
|
if (opts.includeGithub) {
|
|
436
285
|
github = reportGithubBranchProtection({ cwd: root });
|
|
437
|
-
if (
|
|
438
|
-
gaps.push({
|
|
439
|
-
id: 'enforcement-branch-unprotected',
|
|
440
|
-
severity: 'warn',
|
|
441
|
-
message: 'Default branch has no GitHub branch protection (architecture check cannot be required)',
|
|
442
|
-
fix: 'Enable branch protection and require the architecture / ark-check status check',
|
|
443
|
-
});
|
|
444
|
-
} else if (
|
|
286
|
+
if (
|
|
445
287
|
github.available &&
|
|
446
288
|
github.arkCheckRequired === false &&
|
|
447
289
|
Array.isArray(github.requiredStatusChecks)
|
|
@@ -450,7 +292,7 @@ export function collectWeakestLinkGaps(root, opts = {}) {
|
|
|
450
292
|
id: 'enforcement-ark-check-not-required',
|
|
451
293
|
severity: 'warn',
|
|
452
294
|
message:
|
|
453
|
-
'Branch protection exists but no required status check
|
|
295
|
+
'Branch protection exists but no required status check matches a local Ark-running job',
|
|
454
296
|
fix: 'Add the architecture CI job name to required status checks',
|
|
455
297
|
});
|
|
456
298
|
}
|
|
@@ -10,6 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import {
|
|
11
11
|
codexProjectMcpIsValid,
|
|
12
12
|
codexConfigPath,
|
|
13
|
+
extractCodexArgsFromBlock,
|
|
13
14
|
listCodexArkServerTables,
|
|
14
15
|
} from './codex-home.mjs';
|
|
15
16
|
import {
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
} from './host-support-matrix.mjs';
|
|
19
20
|
import { detectActiveAgentHost } from './skill-install.mjs';
|
|
20
21
|
import { detectCiEnforcement } from './weakest-link.mjs';
|
|
22
|
+
import { buildEnforcementState } from './enforcement-state.mjs';
|
|
21
23
|
|
|
22
24
|
export const WRITE_CAPABILITY_NAMES = [
|
|
23
25
|
'hard-write',
|
|
@@ -126,22 +128,161 @@ function relativeEvidencePath(root, file) {
|
|
|
126
128
|
return file.split(path.sep).join('/');
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
function
|
|
130
|
-
return (
|
|
131
|
-
|
|
132
|
-
/mcp_servers\.ark\b/.test(text) ||
|
|
133
|
-
/"ark"\s*:\s*\{/.test(text) ||
|
|
134
|
-
/mcpServers[\s\S]*\bark\b/.test(text)
|
|
131
|
+
function isArkMcpToken(value) {
|
|
132
|
+
return /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(
|
|
133
|
+
path.basename(value.trim().replace(/\\/g, '/'))
|
|
135
134
|
);
|
|
136
135
|
}
|
|
137
136
|
|
|
137
|
+
function arkMcpInvocation(server) {
|
|
138
|
+
if (!server || typeof server !== 'object' || typeof server.command !== 'string') return false;
|
|
139
|
+
if (server.args !== undefined && !Array.isArray(server.args)) return false;
|
|
140
|
+
const args = server.args ?? [];
|
|
141
|
+
if (!args.every((value) => typeof value === 'string')) return false;
|
|
142
|
+
const argv = [server.command, ...args];
|
|
143
|
+
if (argv.filter(isArkMcpToken).length !== 1) return false;
|
|
144
|
+
if (isArkMcpToken(server.command)) return { argv, binIndex: 0 };
|
|
145
|
+
const runner = path.basename(server.command.trim().replace(/\\/g, '/'));
|
|
146
|
+
if (['npx', 'yarn'].includes(runner) && isArkMcpToken(args[0])) {
|
|
147
|
+
return { argv, binIndex: 1 };
|
|
148
|
+
}
|
|
149
|
+
if (runner === 'node') {
|
|
150
|
+
const script = args[0]?.replace(/\\/g, '/');
|
|
151
|
+
return isArkMcpToken(script) && /(?:^|\/)bin\/ark-mcp\.mjs$/.test(script)
|
|
152
|
+
? { argv, binIndex: 1 }
|
|
153
|
+
: false;
|
|
154
|
+
}
|
|
155
|
+
if (runner === 'pnpm') {
|
|
156
|
+
const prefix = args[0] === 'exec'
|
|
157
|
+
? 1
|
|
158
|
+
: args[0] === '--config.verify-deps-before-run=false' && args[1] === 'exec'
|
|
159
|
+
? 2
|
|
160
|
+
: 0;
|
|
161
|
+
return prefix > 0 && isArkMcpToken(args[prefix])
|
|
162
|
+
? { argv, binIndex: prefix + 1 }
|
|
163
|
+
: false;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function mcpServerRunsArk(server) {
|
|
169
|
+
return Boolean(arkMcpInvocation(server));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function commandArkMcpInvocation(command) {
|
|
173
|
+
if (typeof command !== 'string') return false;
|
|
174
|
+
const words = [];
|
|
175
|
+
for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(&&|\|\||[;|#])|([^\s;&|#]+)/g)) {
|
|
176
|
+
if (match[3]) break;
|
|
177
|
+
words.push(match[1] ?? match[2] ?? match[4]);
|
|
178
|
+
}
|
|
179
|
+
if (path.basename(words[0]?.replace(/\\/g, '/') ?? '') === 'env') words.shift();
|
|
180
|
+
const environment = {};
|
|
181
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) {
|
|
182
|
+
const assignment = words.shift();
|
|
183
|
+
const split = assignment.indexOf('=');
|
|
184
|
+
const value = assignment.slice(split + 1);
|
|
185
|
+
environment[assignment.slice(0, split)] = /^(['"]).*\1$/.test(value)
|
|
186
|
+
? value.slice(1, -1)
|
|
187
|
+
: value;
|
|
188
|
+
}
|
|
189
|
+
const [executable, ...args] = words;
|
|
190
|
+
const invocation = arkMcpInvocation({ command: executable, args });
|
|
191
|
+
return invocation
|
|
192
|
+
? { ...invocation, binArgs: invocation.argv.slice(invocation.binIndex + 1), environment }
|
|
193
|
+
: false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function requiredWriteOperations(relativePath) {
|
|
197
|
+
return relativePath.startsWith('.grok/')
|
|
198
|
+
? ['write', 'search_replace']
|
|
199
|
+
: ['Write', 'Edit', 'MultiEdit'];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function matcherOperations(relativePath, matcher) {
|
|
203
|
+
const required = requiredWriteOperations(relativePath);
|
|
204
|
+
if (typeof matcher !== 'string' || matcher.trim() === '') return required;
|
|
205
|
+
try {
|
|
206
|
+
const pattern = new RegExp(`^(?:${matcher})$`);
|
|
207
|
+
return required.filter((operation) => pattern.test(operation));
|
|
208
|
+
} catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function jsonArkMcpIsValid(text) {
|
|
214
|
+
try {
|
|
215
|
+
return mcpServerRunsArk(JSON.parse(text)?.mcpServers?.ark);
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function tomlMcpBlockIsValid(block) {
|
|
222
|
+
const matches = [
|
|
223
|
+
...block.matchAll(
|
|
224
|
+
/^[ \t]*command[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')[ \t]*(?:#.*)?$/gm
|
|
225
|
+
),
|
|
226
|
+
];
|
|
227
|
+
const args = extractCodexArgsFromBlock(block);
|
|
228
|
+
if (matches.length !== 1 || !args) return false;
|
|
229
|
+
const validLine = (line) =>
|
|
230
|
+
/^[ \t]*(?:#.*)?$/.test(line) ||
|
|
231
|
+
/^[ \t]*\[.*\][ \t]*(?:#.*)?$/.test(line) ||
|
|
232
|
+
/^[ \t]*command[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')[ \t]*(?:#.*)?$/.test(line) ||
|
|
233
|
+
/^[ \t]*args[ \t]*=[ \t]*\[[^\]\r\n]*\][ \t]*(?:#.*)?$/.test(line);
|
|
234
|
+
if (block.split('\n').some((line) => !validLine(line))) return false;
|
|
235
|
+
let command;
|
|
236
|
+
try {
|
|
237
|
+
command = matches[0][1].startsWith('"')
|
|
238
|
+
? JSON.parse(matches[0][1])
|
|
239
|
+
: matches[0][1].slice(1, -1);
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return mcpServerRunsArk({ command, args });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function tomlArkMcpIsValid(text) {
|
|
247
|
+
const primary = listCodexArkServerTables(text).filter((entry) => entry.table === 'ark');
|
|
248
|
+
return primary.length === 1 && tomlMcpBlockIsValid(primary[0].block);
|
|
249
|
+
}
|
|
250
|
+
|
|
138
251
|
function hookEvidence(root, relativePath) {
|
|
139
252
|
const text = readText(path.join(root, relativePath));
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
253
|
+
let hooks = [];
|
|
254
|
+
try {
|
|
255
|
+
const groups = JSON.parse(text)?.hooks?.PreToolUse;
|
|
256
|
+
if (Array.isArray(groups)) {
|
|
257
|
+
hooks = groups.flatMap((group) =>
|
|
258
|
+
Array.isArray(group?.hooks)
|
|
259
|
+
? group.hooks
|
|
260
|
+
.filter((hook) => !hook?.type || hook.type === 'command')
|
|
261
|
+
.map((hook) => ({
|
|
262
|
+
hook,
|
|
263
|
+
invocation: commandArkMcpInvocation(hook?.command),
|
|
264
|
+
operations: matcherOperations(relativePath, group.matcher),
|
|
265
|
+
}))
|
|
266
|
+
.filter((entry) => entry.invocation)
|
|
267
|
+
: []
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
hooks = [];
|
|
272
|
+
}
|
|
273
|
+
const hardHooks = hooks.filter((entry) => entry.invocation.binArgs.includes('--hook'));
|
|
274
|
+
const required = requiredWriteOperations(relativePath);
|
|
275
|
+
const hard = required.every((operation) =>
|
|
276
|
+
hardHooks.some((entry) => entry.operations.includes(operation))
|
|
277
|
+
);
|
|
278
|
+
const repair = hard && required.every((operation) =>
|
|
279
|
+
hardHooks.some(({ hook, invocation, operations }) =>
|
|
280
|
+
operations.includes(operation) &&
|
|
281
|
+
(invocation.binArgs.includes('--hook-repair') ||
|
|
282
|
+
/^(?:1|true|yes|on)$/i.test(
|
|
283
|
+
String(hook.env?.ARK_HOOK_REPAIR ?? invocation.environment.ARK_HOOK_REPAIR ?? '')
|
|
284
|
+
))
|
|
285
|
+
)
|
|
145
286
|
);
|
|
146
287
|
return {
|
|
147
288
|
hard: hard ? [relativePath] : [],
|
|
@@ -151,20 +292,25 @@ function hookEvidence(root, relativePath) {
|
|
|
151
292
|
|
|
152
293
|
function mcpEvidence(root, relativePath) {
|
|
153
294
|
const text = readText(path.join(root, relativePath));
|
|
154
|
-
|
|
295
|
+
const valid = relativePath.endsWith('.toml')
|
|
296
|
+
? tomlArkMcpIsValid(text)
|
|
297
|
+
: jsonArkMcpIsValid(text);
|
|
298
|
+
return valid ? [relativePath] : [];
|
|
155
299
|
}
|
|
156
300
|
|
|
157
301
|
function codexMcpEvidence(root) {
|
|
158
302
|
const projectFile = path.join(root, '.codex', 'config.toml');
|
|
159
303
|
const projectText = readText(projectFile);
|
|
160
|
-
const projectRegistered =
|
|
304
|
+
const projectRegistered =
|
|
305
|
+
codexProjectMcpIsValid(projectText, root) && tomlArkMcpIsValid(projectText);
|
|
161
306
|
if (projectRegistered) return ['.codex/config.toml'];
|
|
162
307
|
|
|
163
308
|
const file = codexConfigPath();
|
|
164
309
|
const text = readText(file);
|
|
165
310
|
const resolvedRoot = path.resolve(root);
|
|
166
|
-
const
|
|
167
|
-
|
|
311
|
+
const tables = listCodexArkServerTables(text);
|
|
312
|
+
const registered = new Set(tables.map((entry) => entry.table)).size === tables.length && tables.some((entry) => {
|
|
313
|
+
if (!entry.root || !tomlMcpBlockIsValid(entry.block)) return false;
|
|
168
314
|
return path.resolve(entry.root) === resolvedRoot;
|
|
169
315
|
});
|
|
170
316
|
return registered ? [relativeEvidencePath(root, file)] : [];
|
|
@@ -227,11 +373,13 @@ export function detectWritePathInventory(root) {
|
|
|
227
373
|
capabilities: capabilityMap(evidence),
|
|
228
374
|
evidence,
|
|
229
375
|
hosts,
|
|
376
|
+
ci,
|
|
230
377
|
};
|
|
231
378
|
}
|
|
232
379
|
|
|
233
380
|
export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
|
|
234
381
|
const inventory = detectWritePathInventory(root);
|
|
382
|
+
const ci = inventory.ci;
|
|
235
383
|
const detectedHost = explicitHost ?? detectActiveAgentHost();
|
|
236
384
|
const activeHost = KNOWN_HOSTS.includes(detectedHost) ? detectedHost : 'unknown';
|
|
237
385
|
const activeRecord = inventory.hosts[activeHost];
|
|
@@ -244,7 +392,7 @@ export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
|
|
|
244
392
|
'merge-gate': [...inventory.evidence['merge-gate']],
|
|
245
393
|
};
|
|
246
394
|
|
|
247
|
-
|
|
395
|
+
const model = {
|
|
248
396
|
activeHost,
|
|
249
397
|
support: getHostSupportProfile(activeHost),
|
|
250
398
|
capabilities: capabilityMap(capabilityEvidence),
|
|
@@ -257,4 +405,7 @@ export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
|
|
|
257
405
|
),
|
|
258
406
|
inventory,
|
|
259
407
|
};
|
|
408
|
+
model.enforcementState = buildEnforcementState(root, { ...model, ci });
|
|
409
|
+
model.enforcementLadder.ciMerge.requiredStatus = model.enforcementState.ciMerge.required;
|
|
410
|
+
return model;
|
|
260
411
|
}
|
|
@@ -17,7 +17,7 @@ function installToolsForHost(activeHost) {
|
|
|
17
17
|
|
|
18
18
|
export function detectWritePathCapabilities(root, explicitHost, attempt) {
|
|
19
19
|
const model = buildWritePathCapabilityModel(root, explicitHost, attempt);
|
|
20
|
-
const { activeHost, support, capabilities, capabilityEvidence, enforcementLadder, inventory } = model;
|
|
20
|
+
const { activeHost, support, capabilities, capabilityEvidence, enforcementLadder, enforcementState, inventory } = model;
|
|
21
21
|
const hardWrite = capabilities['hard-write'];
|
|
22
22
|
const advisoryWrite = capabilities['advisory-write'];
|
|
23
23
|
const repairPayload = capabilities['repair-payload'];
|
|
@@ -78,7 +78,7 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
|
|
|
78
78
|
'not equivalent to Claude/Grok PreToolUse hard-write + repair). ' +
|
|
79
79
|
'The hard merge backstop is CI --strict-merge plus a required status check.'
|
|
80
80
|
: `Active host ${activeHost} has advisory prepare-write/autoPatch tools, ` +
|
|
81
|
-
'but no hard write boundary;
|
|
81
|
+
'but no hard write boundary; CI can report failure, while merge blocking requires provider policy.';
|
|
82
82
|
gap = {
|
|
83
83
|
id: 'write-path-mcp-only',
|
|
84
84
|
severity: 'info',
|
|
@@ -99,6 +99,7 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
|
|
|
99
99
|
capabilities,
|
|
100
100
|
capabilityEvidence,
|
|
101
101
|
enforcementLadder,
|
|
102
|
+
enforcementState,
|
|
102
103
|
inventory,
|
|
103
104
|
// Compatibility projection for existing doctor/API consumers.
|
|
104
105
|
mode,
|