arkgate 3.7.0 → 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.
Files changed (77) hide show
  1. package/CHANGELOG.md +76 -1145
  2. package/README.md +59 -19
  3. package/bin/ark-check-runtime.mjs +1598 -0
  4. package/bin/ark-check.mjs +32 -1565
  5. package/bin/ark-mcp-runtime.mjs +1976 -0
  6. package/bin/ark-mcp.mjs +84 -1495
  7. package/bin/ark-shared.mjs +34 -38
  8. package/bin/ark.mjs +33 -66
  9. package/bin/lib/adapter-contract.mjs +161 -9
  10. package/bin/lib/agent-gates.mjs +1 -0
  11. package/bin/lib/analysis-completeness.mjs +28 -0
  12. package/bin/lib/analysis-engine.mjs +8 -8
  13. package/bin/lib/analysis-policy.mjs +27 -0
  14. package/bin/lib/architecture-scan.mjs +70 -357
  15. package/bin/lib/auto-patch.mjs +76 -8
  16. package/bin/lib/ci-and-commands.mjs +1 -1
  17. package/bin/lib/codex-home.mjs +43 -16
  18. package/bin/lib/design-delta.mjs +4 -0
  19. package/bin/lib/doctor-advisories.mjs +4 -3
  20. package/bin/lib/doctor-plan.mjs +40 -41
  21. package/bin/lib/enforcement-state.mjs +2 -0
  22. package/bin/lib/github-enforcement.mjs +443 -0
  23. package/bin/lib/hook-templates.mjs +12 -148
  24. package/bin/lib/html-report-advisories.mjs +1 -1
  25. package/bin/lib/html-report-depth.mjs +9 -0
  26. package/bin/lib/html-report.mjs +5 -5
  27. package/bin/lib/install-migrate.mjs +83 -79
  28. package/bin/lib/managed-upgrade.mjs +622 -0
  29. package/bin/lib/mcp-adoption.mjs +3 -1
  30. package/bin/lib/parse-health.mjs +6 -5
  31. package/bin/lib/port-proof.mjs +2 -2
  32. package/bin/lib/prepare-change.mjs +68 -38
  33. package/bin/lib/prepare-write.mjs +7 -1
  34. package/bin/lib/resident-doctor-client.mjs +55 -0
  35. package/bin/lib/resident-hook.mjs +247 -0
  36. package/bin/lib/resolved-candidate-facts.mjs +1160 -0
  37. package/bin/lib/scan-files.mjs +19 -6
  38. package/bin/lib/snippet-analysis.mjs +119 -0
  39. package/bin/lib/source-policy.mjs +24 -0
  40. package/bin/lib/typescript-host.mjs +15 -18
  41. package/bin/lib/unavailable-analysis.mjs +76 -0
  42. package/bin/lib/upgrade-command.mjs +115 -0
  43. package/bin/lib/weakest-link.mjs +21 -179
  44. package/bin/lib/write-path-capabilities.mjs +167 -16
  45. package/bin/lib/write-path-detect.mjs +3 -2
  46. package/dist/eslint/index.cjs +3 -3
  47. package/dist/eslint/index.d.ts +3 -0
  48. package/dist/eslint/index.js +3 -3
  49. package/dist/index.cjs +7 -7
  50. package/dist/index.d.ts +1073 -141
  51. package/dist/index.js +7 -7
  52. package/docs/agent-guide.md +103 -59
  53. package/docs/ai-gates.md +97 -16
  54. package/docs/demos/01-write-gate-self-correction.md +2 -2
  55. package/docs/enthusiast/README.md +10 -10
  56. package/docs/enthusiast/how-to-gallery-starter.md +2 -2
  57. package/docs/enthusiast/reference-commands.md +18 -1
  58. package/docs/enthusiast/tutorial-first-project.md +2 -2
  59. package/docs/package-surface.md +98 -12
  60. package/docs/typescript-support.md +109 -37
  61. package/package.json +32 -4
  62. package/schemas/ark.analysis-result.schema.json +159 -2
  63. package/schemas/ark.design-delta.schema.json +1 -0
  64. package/schemas/ark.enforcement-state.schema.json +84 -0
  65. package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
  66. package/server.json +2 -2
  67. package/templates/skills/ark-explore.md +5 -5
  68. package/templates/skills/ark-fix.md +1 -1
  69. package/templates/skills/ark-runtime.md +15 -8
  70. package/templates/skills/ark-upgrade.md +122 -182
  71. package/bin/lib/ai-velocity.mjs +0 -293
  72. package/bin/lib/graph-cycles.mjs +0 -6
  73. package/bin/lib/safety-diagnostics.mjs +0 -284
  74. package/bin/lib/ts-resolve.mjs +0 -228
  75. package/dist/configTypes-DAPvBqK6.d.cts +0 -61
  76. package/dist/eslint/index.d.cts +0 -146
  77. package/dist/index.d.cts +0 -986
@@ -0,0 +1,443 @@
1
+ /** Exact local workflow evidence plus GitHub classic-protection/ruleset correlation. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ const IF_LINE = /^[ \t]*(?:-\s+)?(?:"if"|'if'|if):\s*(.*?)\s*(?:#.*)?$/i;
7
+ const CONTINUE_LINE = /^[ \t]*(?:-\s+)?(?:"continue-on-error"|'continue-on-error'|continue-on-error):\s*(.*?)\s*(?:#.*)?$/i;
8
+ const SAFE_IF = /^(?:['"]?true['"]?|['"]?\$\{\{\s*(?:true|always\(\))\s*\}\}['"]?)$/i;
9
+ const SAFE_NEEDS_IF = /^['"]?\$\{\{\s*always\(\)\s*\}\}['"]?$/i;
10
+ const SAFE_CONTINUE = /^['"]?false['"]?$/i;
11
+ const ENV_PREFIX = /^(?:(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*)/;
12
+ const DIRECT_ARK = /^(?:(?:npx|yarn)\s+(?:arkgate-check|ark-check)(?=\s|$)|pnpm(?:\s+--\S+)*\s+exec\s+(?:arkgate-check|ark-check)(?=\s|$)|node\s+(?:\S+\/)?bin\/(?:arkgate-check|ark-check)\.mjs(?=\s|$)|(?:arkgate-check|ark-check)(?=\s|$))/;
13
+ const CHECK_SCRIPT = /^(?:(?:npm|pnpm)\s+run\s+check:architecture(?=\s|$)|yarn(?:\s+run)?\s+check:architecture(?=\s|$))/;
14
+ const FAIL_CLOSED = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;
15
+
16
+ function shellSegments(text) {
17
+ const segments = [];
18
+ let current = '';
19
+ let quote = null;
20
+ let escaped = false;
21
+ const push = (terminator) => {
22
+ if (current.trim()) segments.push({ text: current.trim(), terminator });
23
+ current = '';
24
+ };
25
+ const input = String(text || '');
26
+ for (let index = 0; index < input.length; index++) {
27
+ const char = input[index];
28
+ if (escaped) {
29
+ current += char;
30
+ escaped = false;
31
+ continue;
32
+ }
33
+ if (quote) {
34
+ current += char;
35
+ if (char === '\\' && quote === '"') escaped = true;
36
+ else if (char === quote) quote = null;
37
+ continue;
38
+ }
39
+ if (char === '"' || char === "'") {
40
+ quote = char;
41
+ current += char;
42
+ continue;
43
+ }
44
+ if (char === '#') {
45
+ while (index + 1 < input.length && input[index + 1] !== '\n') index++;
46
+ continue;
47
+ }
48
+ const pair = input.slice(index, index + 2);
49
+ if (pair === '&&' || pair === '||') {
50
+ push(pair);
51
+ index++;
52
+ continue;
53
+ }
54
+ if (char === ';' || char === '|' || char === '\n') {
55
+ push(char);
56
+ continue;
57
+ }
58
+ current += char;
59
+ }
60
+ push(null);
61
+ return segments;
62
+ }
63
+
64
+ function executableText(segment) {
65
+ return String(segment || '').trim().replace(ENV_PREFIX, '');
66
+ }
67
+
68
+ function analyzeCommands(commands, script = '') {
69
+ const scriptArk = shellSegments(script)
70
+ .filter((segment) => DIRECT_ARK.test(executableText(segment.text)))
71
+ .map((segment) => ({
72
+ text: segment.text,
73
+ enforcing: segment.terminator !== '||' && segment.terminator !== '|',
74
+ }));
75
+ const found = [];
76
+ for (const segment of shellSegments(commands)) {
77
+ const executable = executableText(segment.text);
78
+ const outerEnforcing = segment.terminator !== '||' && segment.terminator !== '|';
79
+ if (DIRECT_ARK.test(executable)) {
80
+ found.push({ text: segment.text, enforcing: outerEnforcing });
81
+ } else if (CHECK_SCRIPT.test(executable)) {
82
+ for (const inner of scriptArk) {
83
+ found.push({ text: inner.text, enforcing: outerEnforcing && inner.enforcing });
84
+ }
85
+ }
86
+ }
87
+ return found;
88
+ }
89
+
90
+ function architectureScript(root) {
91
+ try {
92
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
93
+ return typeof pkg?.scripts?.['check:architecture'] === 'string'
94
+ ? pkg.scripts['check:architecture']
95
+ : '';
96
+ } catch {
97
+ return '';
98
+ }
99
+ }
100
+
101
+ function workflowJobs(text) {
102
+ const lines = String(text || '').split('\n');
103
+ const jobsLine = lines.findIndex((line) => /^(?:"jobs"|'jobs'|jobs):\s*(?:#.*)?$/.test(line));
104
+ if (jobsLine < 0) return [];
105
+ const jobsIndent = lines[jobsLine].match(/^\s*/)?.[0].length ?? 0;
106
+ let childIndent = null;
107
+ let jobsEnd = lines.length;
108
+ const headers = [];
109
+ for (let index = jobsLine + 1; index < lines.length; index++) {
110
+ const line = lines[index];
111
+ if (!line.trim() || /^\s*#/.test(line)) continue;
112
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
113
+ if (indent <= jobsIndent) {
114
+ jobsEnd = index;
115
+ break;
116
+ }
117
+ const hit = line.match(/^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+)):\s*(?:#.*)?$/);
118
+ if (!hit) continue;
119
+ childIndent ??= indent;
120
+ if (indent === childIndent) headers.push({ id: hit[1] ?? hit[2] ?? hit[3], index });
121
+ }
122
+ return headers.map((header, index) => ({
123
+ id: header.id,
124
+ body: lines.slice(header.index, headers[index + 1]?.index ?? jobsEnd).join('\n'),
125
+ indent: childIndent,
126
+ }));
127
+ }
128
+
129
+ function jobPropertyIndent(job) {
130
+ const indents = job.body.split('\n').slice(1)
131
+ .filter((line) => line.trim() && !/^\s*#/.test(line))
132
+ .map((line) => line.match(/^\s*/)?.[0].length ?? 0)
133
+ .filter((indent) => indent > Number(job.indent));
134
+ return indents.length > 0 ? Math.min(...indents) : Number(job.indent) + 2;
135
+ }
136
+
137
+ function propertyValue(line, pattern) {
138
+ return line.match(pattern)?.[1]?.trim() ?? null;
139
+ }
140
+
141
+ function unsafeControl(lines, propertyIndent, needs = false) {
142
+ const atIndent = lines.filter((line) => {
143
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
144
+ return indent + (/^\s*-\s+/.test(line) ? 2 : 0) === propertyIndent;
145
+ });
146
+ const condition = atIndent.map((line) => propertyValue(line, IF_LINE)).find((value) => value !== null);
147
+ const continuation = atIndent
148
+ .map((line) => propertyValue(line, CONTINUE_LINE))
149
+ .find((value) => value !== null);
150
+ if (condition !== undefined && !SAFE_IF.test(condition)) return true;
151
+ if (continuation !== undefined && !SAFE_CONTINUE.test(continuation)) return true;
152
+ return needs && (condition === undefined || !SAFE_NEEDS_IF.test(condition));
153
+ }
154
+
155
+ function stepBounds(lines, runIndex, runIndent, inline) {
156
+ let start = runIndex;
157
+ let stepIndent = inline ? runIndent : null;
158
+ if (!inline) {
159
+ for (let index = runIndex - 1; index >= 0; index--) {
160
+ const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
161
+ if (/^\s*-\s+/.test(lines[index]) && indent < runIndent) {
162
+ start = index;
163
+ stepIndent = indent;
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ if (stepIndent === null) return { start: runIndex, end: runIndex + 1, propertyIndent: runIndent };
169
+ let end = lines.length;
170
+ for (let index = start + 1; index < lines.length; index++) {
171
+ if (!lines[index].trim()) continue;
172
+ const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
173
+ if (indent < stepIndent + 2 || (indent === stepIndent && /^\s*-\s+/.test(lines[index]))) {
174
+ end = index;
175
+ break;
176
+ }
177
+ }
178
+ return { start, end, propertyIndent: stepIndent + 2 };
179
+ }
180
+
181
+ function unquoteRun(value) {
182
+ const trimmed = String(value || '').trim();
183
+ if (/^"(?:\\.|[^"\\])*"$/.test(trimmed)) {
184
+ try {
185
+ return JSON.parse(trimmed);
186
+ } catch {
187
+ return '';
188
+ }
189
+ }
190
+ if (/^'[^']*'$/.test(trimmed)) return trimmed.slice(1, -1).replace(/''/g, "'");
191
+ return trimmed;
192
+ }
193
+
194
+ export function workflowRunText(text) {
195
+ const lines = String(text || '').split('\n');
196
+ const commands = [];
197
+ for (let index = 0; index < lines.length; index++) {
198
+ const hit = lines[index].match(/^(\s*)(?:-\s+)?run:\s*(.*)$/);
199
+ if (!hit) continue;
200
+ const inline = /^\s*-\s+run:/.test(lines[index]);
201
+ const bounds = stepBounds(lines, index, hit[1].length, inline);
202
+ if (unsafeControl(lines.slice(bounds.start, bounds.end), bounds.propertyIndent)) continue;
203
+ if (hit[2] && !/^[|>][-+]?\s*$/.test(hit[2])) commands.push(unquoteRun(hit[2]));
204
+ else {
205
+ const folded = /^>/.test(hit[2]);
206
+ const indent = hit[1].length;
207
+ const block = [];
208
+ while (index + 1 < lines.length) {
209
+ const next = lines[index + 1];
210
+ if (next.trim() && (next.match(/^\s*/)?.[0].length ?? 0) <= indent) break;
211
+ block.push(next.trim());
212
+ index++;
213
+ }
214
+ commands.push(block.join(folded ? ' ' : '\n'));
215
+ }
216
+ }
217
+ return commands.join('\n');
218
+ }
219
+
220
+ function jobCannotEnforce(job) {
221
+ const propertyIndent = jobPropertyIndent(job);
222
+ const lines = job.body.split('\n');
223
+ const needs = lines.some((line) =>
224
+ (line.match(/^\s*/)?.[0].length ?? 0) === propertyIndent &&
225
+ /^\s*(?:"needs"|'needs'|needs):/.test(line)
226
+ );
227
+ return unsafeControl(lines, propertyIndent, needs);
228
+ }
229
+
230
+ function commandsRunArk(commands, script) {
231
+ return analyzeCommands(commands, script).length > 0;
232
+ }
233
+
234
+ function commandsEnforceArk(commands, script) {
235
+ return analyzeCommands(commands, script).some((entry) => entry.enforcing && FAIL_CLOSED.test(entry.text));
236
+ }
237
+
238
+ export function activeWorkflowRunText(text) {
239
+ const jobs = workflowJobs(text);
240
+ return jobs.filter((job) => !jobCannotEnforce(job)).map((job) => workflowRunText(job.body)).join('\n');
241
+ }
242
+
243
+ export function runsArkCheck(text, script = '') {
244
+ return commandsRunArk(activeWorkflowRunText(text), script);
245
+ }
246
+
247
+ export function enforcingArkRunText(text, script = '') {
248
+ return analyzeCommands(activeWorkflowRunText(text), script)
249
+ .filter((entry) => entry.enforcing)
250
+ .map((entry) => entry.text)
251
+ .join('\n');
252
+ }
253
+
254
+ function workflowFiles(root) {
255
+ const directory = path.join(root, '.github', 'workflows');
256
+ try {
257
+ return fs.readdirSync(directory)
258
+ .filter((file) => /\.ya?ml$/i.test(file))
259
+ .map((file) => ({ file, text: fs.readFileSync(path.join(directory, file), 'utf8') }));
260
+ } catch {
261
+ return [];
262
+ }
263
+ }
264
+
265
+ function jobName(job) {
266
+ const propertyIndent = jobPropertyIndent(job);
267
+ const line = job.body.split('\n').find((candidate) =>
268
+ (candidate.match(/^\s*/)?.[0].length ?? 0) === propertyIndent &&
269
+ /^\s*(?:"name"|'name'|name):/.test(candidate)
270
+ );
271
+ const raw = line?.match(/^\s*(?:"name"|'name'|name):\s*(.+?)\s*$/)?.[1];
272
+ if (!raw) return { name: null, dynamic: false };
273
+ const name = raw.replace(/^(['"])(.*)\1$/, '$2');
274
+ return { name: name.includes('${{') ? null : name, dynamic: name.includes('${{') };
275
+ }
276
+
277
+ function jobHasMatrix(job) {
278
+ const propertyIndent = jobPropertyIndent(job);
279
+ const lines = job.body.split('\n');
280
+ const strategy = lines.findIndex((line) =>
281
+ (line.match(/^\s*/)?.[0].length ?? 0) === propertyIndent &&
282
+ /^\s*(?:"strategy"|'strategy'|strategy):/.test(line)
283
+ );
284
+ if (strategy < 0) return false;
285
+ for (let index = strategy + 1; index < lines.length; index++) {
286
+ if (!lines[index].trim()) continue;
287
+ const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
288
+ if (indent <= propertyIndent) break;
289
+ if (/^\s*(?:"matrix"|'matrix'|matrix):/.test(lines[index])) return true;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ function localArkContexts(root) {
295
+ const script = architectureScript(root);
296
+ const known = new Set();
297
+ const occurrences = new Map();
298
+ const arkOccurrences = new Map();
299
+ let dynamic = false;
300
+ for (const workflow of workflowFiles(root)) {
301
+ for (const job of workflowJobs(workflow.text)) {
302
+ const explicit = jobName(job);
303
+ const matrix = jobHasMatrix(job);
304
+ const enforcing = !jobCannotEnforce(job) &&
305
+ commandsEnforceArk(workflowRunText(job.body), script);
306
+ if (enforcing) {
307
+ known.add(job.id);
308
+ if (explicit.name) known.add(explicit.name);
309
+ }
310
+ if (explicit.dynamic || matrix) {
311
+ dynamic ||= enforcing;
312
+ continue;
313
+ }
314
+ const context = explicit.name ?? job.id;
315
+ occurrences.set(context, (occurrences.get(context) ?? 0) + 1);
316
+ if (enforcing) arkOccurrences.set(context, (arkOccurrences.get(context) ?? 0) + 1);
317
+ }
318
+ }
319
+ const contexts = new Set();
320
+ for (const [context, count] of arkOccurrences) {
321
+ if (count === 1 && occurrences.get(context) === 1) contexts.add(context);
322
+ else dynamic = true;
323
+ }
324
+ return { contexts, known, dynamic };
325
+ }
326
+
327
+ /** Historical name retained; the set also contains literal, non-dynamic job names. */
328
+ export function jobIdsThatRunArkCheck(root) {
329
+ return localArkContexts(root).known;
330
+ }
331
+
332
+ export function isArkRequiredStatusCheck(root, requiredNames) {
333
+ if (!Array.isArray(requiredNames) || requiredNames.length === 0) return false;
334
+ const local = localArkContexts(root).contexts;
335
+ return requiredNames.some((name) => local.has(String(name)));
336
+ }
337
+
338
+ function classifyRequired(root, legacyContexts, checks, bindingField) {
339
+ const local = localArkContexts(root);
340
+ const exact = (name) => local.contexts.has(String(name));
341
+ const matching = checks.filter((check) => exact(check.context));
342
+ if (matching.some((check) => check[bindingField] == null || check[bindingField] === -1)) return true;
343
+ if (matching.length > 0) return 'unverified';
344
+ if (legacyContexts.some(exact)) return true;
345
+ return local.dynamic && (legacyContexts.length > 0 || checks.length > 0)
346
+ ? 'unverified'
347
+ : false;
348
+ }
349
+
350
+ function combineRequired(classic, rules, classicAvailable, rulesAvailable, hasWorkflowRule) {
351
+ if (classic === true || rules === true) return true;
352
+ if (classic === 'unverified' || rules === 'unverified' || hasWorkflowRule) return 'unverified';
353
+ return classicAvailable && rulesAvailable ? false : 'unverified';
354
+ }
355
+
356
+ function parseJson(result) {
357
+ if (result.status !== 0) return null;
358
+ try {
359
+ return JSON.parse(result.stdout || 'null');
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ /** Query classic branch protection and all active branch rules before reporting absence. */
366
+ export function reportGithubBranchProtection(opts = {}) {
367
+ const cwd = opts.cwd ?? process.cwd();
368
+ const env = opts.env ?? process.env;
369
+ if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
370
+ return { available: false, reason: 'gh-cli-unavailable' };
371
+ }
372
+ let repo = opts.repo;
373
+ let branch = opts.branch;
374
+ if (!repo || !branch) {
375
+ const args = ['repo', 'view', ...(repo ? [repo] : []), '--json', 'nameWithOwner,defaultBranchRef'];
376
+ const metadata = parseJson(spawnSync('gh', args, { cwd, encoding: 'utf8', env }));
377
+ if (!metadata?.nameWithOwner || !metadata?.defaultBranchRef?.name) {
378
+ return { available: false, reason: 'gh-repo-unavailable' };
379
+ }
380
+ repo ??= metadata.nameWithOwner;
381
+ branch ??= metadata.defaultBranchRef.name;
382
+ }
383
+
384
+ const classicResult = spawnSync('gh', [
385
+ 'api', `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`, '--jq',
386
+ '{strict: .required_status_checks.strict, contexts: .required_status_checks.contexts, checks: .required_status_checks.checks, enforcesAdmins: .enforce_admins.enabled}',
387
+ ], { cwd, encoding: 'utf8', env });
388
+ const rulesResult = spawnSync(
389
+ 'gh', ['api', `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`],
390
+ { cwd, encoding: 'utf8', env }
391
+ );
392
+ const classic = parseJson(classicResult);
393
+ const rules = parseJson(rulesResult);
394
+ const classicAvailable = classic !== null && !Array.isArray(classic);
395
+ const rulesAvailable = Array.isArray(rules);
396
+
397
+ const contexts = classicAvailable && Array.isArray(classic.contexts)
398
+ ? classic.contexts.map(String)
399
+ : [];
400
+ const checks = classicAvailable && Array.isArray(classic.checks)
401
+ ? classic.checks.map((check) => ({
402
+ context: String(check?.context || check?.name || ''),
403
+ app_id: Number.isInteger(check?.app_id) ? check.app_id : null,
404
+ })).filter((check) => check.context)
405
+ : [];
406
+ const statusRules = rulesAvailable
407
+ ? rules.filter((rule) => rule?.type === 'required_status_checks')
408
+ .flatMap((rule) => rule?.parameters?.required_status_checks ?? [])
409
+ .map((check) => ({
410
+ context: String(check?.context || ''),
411
+ integration_id: Number.isInteger(check?.integration_id) ? check.integration_id : null,
412
+ })).filter((check) => check.context)
413
+ : [];
414
+ const hasWorkflowRule = rulesAvailable && rules.some((rule) => rule?.type === 'workflows');
415
+ const classicRequired = classicAvailable
416
+ ? classifyRequired(cwd, contexts, checks, 'app_id')
417
+ : 'unverified';
418
+ const rulesRequired = rulesAvailable
419
+ ? classifyRequired(cwd, [], statusRules, 'integration_id')
420
+ : 'unverified';
421
+ const arkCheckRequired = combineRequired(
422
+ classicRequired, rulesRequired, classicAvailable, rulesAvailable, hasWorkflowRule
423
+ );
424
+ const arkCheckSourceBound = arkCheckRequired === true ? false : 'unverified';
425
+ const available = arkCheckRequired === true || (classicAvailable && rulesAvailable);
426
+ const all = [...new Set([...contexts, ...checks.map((check) => check.context), ...statusRules.map((check) => check.context)])];
427
+ const error = `${classicResult.stderr || ''}${rulesResult.stderr || ''}`.slice(0, 400);
428
+
429
+ return {
430
+ available,
431
+ reason: available ? 'ok' : 'provider-enforcement-unverified',
432
+ repo,
433
+ branch,
434
+ requiredStatusChecks: all,
435
+ requiredStatusCheckDetails: checks,
436
+ requiredStatusRuleDetails: statusRules,
437
+ strict: classicAvailable ? Boolean(classic.strict) : null,
438
+ enforcesAdmins: classicAvailable ? Boolean(classic.enforcesAdmins) : null,
439
+ arkCheckRequired,
440
+ arkCheckSourceBound,
441
+ raw: { classic, rules, ...(error ? { error } : {}) },
442
+ };
443
+ }
@@ -1,151 +1,15 @@
1
- /**
2
- * Host hook / MCP project templates for agent-gate install (Claude, Grok, Codex).
3
- * Kept out of agent-gates.mjs so install orchestration stays scannable (explore gap #5).
4
- */
5
- import { execCommandParts, execRunner } from '../ark-shared.mjs';
6
-
7
- /** Preferred MCP binary name for generated hooks (package dual-bin). */
8
- export const PREFERRED_MCP_BIN = 'arkgate-mcp';
9
-
10
- export function claudeSettings(root) {
11
- const runner = execRunner(root);
12
- return `${JSON.stringify({
13
- hooks: {
14
- // Inject the contract at session start so the agent knows the architecture from
15
- // the first token. Project-scoped by design; --session-context is also a silent
16
- // no-op when no ark.config.json exists, so it can never leak into other projects.
17
- SessionStart: [
18
- {
19
- hooks: [
20
- {
21
- type: 'command',
22
- command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
23
- },
24
- ],
25
- },
26
- ],
27
- PreToolUse: [
28
- {
29
- matcher: 'Write|Edit|MultiEdit',
30
- hooks: [
31
- {
32
- type: 'command',
33
- // W4: --hook-repair emits ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on deny
34
- // (still exit 2 — never silent write). Omit --hook-repair for reject-only prose.
35
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
36
- },
37
- ],
38
- },
39
- ],
40
- },
41
- }, null, 2)}\n`;
42
- }
43
-
44
- export function codexHooks(root) {
45
- const runner = execRunner(root);
46
- const codexRoot = '${CODEX_PROJECT_DIR:-${PWD:-.}}';
47
- return `${JSON.stringify({
48
- hooks: {
49
- SessionStart: [
50
- {
51
- hooks: [
52
- {
53
- type: 'command',
54
- timeout: 30,
55
- command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "${codexRoot}" --config ark.config.json`,
56
- },
57
- ],
58
- },
59
- ],
60
- PreToolUse: [
61
- {
62
- matcher: 'ApplyPatch|apply_patch|Write|Edit|MultiEdit',
63
- hooks: [
64
- {
65
- type: 'command',
66
- timeout: 30,
67
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${codexRoot}" --config ark.config.json`,
68
- },
69
- ],
70
- },
71
- ],
72
- },
73
- }, null, 2)}\n`;
74
- }
75
-
76
- // Codex project config: modern Codex resolves .codex/config.toml from the active
77
- // project, so the primary `ark` binding can stay local instead of competing in
78
- // the user's global $CODEX_HOME across every adopted repository.
79
- export function codexProjectConfig(root) {
80
- const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
81
- '--root',
82
- '.',
83
- '--config',
84
- 'ark.config.json',
85
- ]);
86
- const esc = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
87
- const argsToml = args.map((value) => `"${esc(value)}"`).join(', ');
88
- return `# Generated by ark-check --install-agent-gates (Codex project scope).
1
+ // Generated from hook-templates.source.mjs — run npm run generate:packaged-tooling.
2
+ import{execCommandParts as i,execRunner as a}from"../ark-shared.mjs";const r="arkgate-mcp";function g(e){const o=a(e);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",command:`${o} ${r} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit",hooks:[{type:"command",command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}]}},null,2)}
3
+ `}function l(e){const o=a(e),n="${CODEX_PROJECT_DIR:-${PWD:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${o} ${r} --session-context --root "${n}" --config ark.config.json`}]}],PreToolUse:[{matcher:"ApplyPatch|apply_patch|Write|Edit|MultiEdit",hooks:[{type:"command",timeout:30,command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "${n}" --config ark.config.json`}]}]}},null,2)}
4
+ `}function p(e){const{command:o,args:n}=i(e,r,["--root",".","--config","ark.config.json"]),t=c=>c.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),s=n.map(c=>`"${t(c)}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Codex project scope).
89
5
  # Restart Codex after changes; MCP servers are loaded when the project session starts.
90
6
  [mcp_servers.ark]
91
- command = "${esc(command)}"
92
- args = [${argsToml}]
93
- `;
94
- }
95
-
96
- // Grok Build project config: MCP registration (commit-friendly relative paths — unlike
97
- // the optional Codex home fallback, Grok loads .grok/config.toml from the project).
98
- export function grokProjectConfig(root) {
99
- const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
100
- '--root',
101
- '.',
102
- '--config',
103
- 'ark.config.json',
104
- ]);
105
- const argsToml = args.map((value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(', ');
106
- return `# Generated by ark-check --install-agent-gates (Grok Build project scope).
107
- # Restart Grok (or /mcps → refresh) after changes. Also loads repo-root .mcp.json.
7
+ command = "${t(o)}"
8
+ args = [${s}]
9
+ `}function k(e){const{command:o,args:n}=i(e,r,["--root",".","--config","ark.config.json"]),t=n.map(s=>`"${s.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Grok Build project scope).
10
+ # Restart Grok (or /mcps \u2192 refresh) after changes. Also loads repo-root .mcp.json.
108
11
  [mcp_servers.ark]
109
- command = "${command.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
110
- args = [${argsToml}]
111
- `;
112
- }
113
-
114
- // Grok Build hooks: same arkgate-mcp contracts as Claude. Grok sets both
115
- // GROK_WORKSPACE_ROOT and CLAUDE_PROJECT_DIR (Claude-compatible alias). Prefer
116
- // GROK_* with fallback so hooks still work if only one is present.
117
- // Matcher keeps Claude names (Write|Edit|MultiEdit) and Grok natives
118
- // (write|search_replace) — Grok aliases both directions.
119
- export function grokHooks(root) {
120
- const runner = execRunner(root);
121
- // Nested defaults: Grok native → Claude alias → project cwd (hook cwd is the workspace).
122
- const grokRoot = '${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}';
123
- return `${JSON.stringify({
124
- hooks: {
125
- SessionStart: [
126
- {
127
- hooks: [
128
- {
129
- type: 'command',
130
- timeout: 30,
131
- command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "${grokRoot}" --config ark.config.json`,
132
- },
133
- ],
134
- },
135
- ],
136
- PreToolUse: [
137
- {
138
- matcher: 'Write|Edit|MultiEdit|write|search_replace',
139
- hooks: [
140
- {
141
- type: 'command',
142
- timeout: 30,
143
- // W4: --hook-repair → structured autoPatch on deny (hard block still).
144
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${grokRoot}" --config ark.config.json`,
145
- },
146
- ],
147
- },
148
- ],
149
- },
150
- }, null, 2)}\n`;
151
- }
12
+ command = "${o.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"
13
+ args = [${t}]
14
+ `}function d(e){const o=a(e),n="${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${o} ${r} --session-context --root "${n}" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit|write|search_replace",hooks:[{type:"command",timeout:30,command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "${n}" --config ark.config.json`}]}]}},null,2)}
15
+ `}export{r as PREFERRED_MCP_BIN,g as claudeSettings,l as codexHooks,p as codexProjectConfig,d as grokHooks,k as grokProjectConfig};
@@ -230,7 +230,7 @@ function parseHealthHtml(health) {
230
230
  (health.truncated > 0 ? `<p class="muted">…(+${health.truncated} more affected file(s); doctor list capped)</p>` : '');
231
231
  return `
232
232
  <section data-advisory="parseHealth">
233
- <h2>Parse health <span class="muted">(advisoryunreadable syntax is never silently called clean)</span></h2>
233
+ <h2>Parse health <span class="muted">(completeness evidence affected syntax makes analysis partial)</span></h2>
234
234
  ${body}
235
235
  </section>`;
236
236
  }
@@ -106,6 +106,14 @@ export function renderWritePathAdoptionBlock(writePath) {
106
106
  const gapNote = writePath.gap
107
107
  ? ` Gap: <b>${esc(writePath.gap.id)}</b> — ${esc(writePath.gap.message || '')}`
108
108
  : '';
109
+ const state = writePath.enforcementState;
110
+ const value = (entry) => entry === true ? 'yes' : entry === false ? 'no' : String(entry);
111
+ const boundary = (label, entry) => entry
112
+ ? `<li><b>${esc(label)}</b> — supported ${esc(value(entry.supported))}; analyzed ${esc(value(entry.analyzed))}; configured ${esc(value(entry.configured))}; installed ${esc(value(entry.installed))}; runtime observed ${esc(value(entry.runtimeObserved))}; operation ${esc(entry.operation ?? 'none')}; operation covered ${esc(value(entry.operationCoverage))}; active ${esc(value(entry.active))}; bypassable ${esc(value(entry.bypassable))}; required ${esc(value(entry.required))}; hard ${esc(value(entry.hard))}</li>`
113
+ : '';
114
+ const stateHtml = state
115
+ ? `<ul class="senior-list" style="margin:.45rem 0 0">${boundary('Local write', state.localWrite)}${boundary('Advisory MCP', state.advisoryMcp)}${boundary('CI merge', state.ciMerge)}</ul>`
116
+ : '';
109
117
  // invLine / gapNote already include escaped user content; only plain strings go through esc().
110
118
  return `<div class="write-path-block" title="${esc(writePathModeHint(mode))}">
111
119
  <p class="dim" style="margin:.65rem 0 .2rem;font-size:.84rem">
@@ -117,6 +125,7 @@ export function renderWritePathAdoptionBlock(writePath) {
117
125
  <p class="kpi-hint" style="max-width:none;margin:0">
118
126
  ${esc(writePathModeHint(mode))} ${invLine}${esc(unknownNote)}${gapNote}
119
127
  </p>
128
+ ${stateHtml}
120
129
  </div>`;
121
130
  }
122
131
 
@@ -51,9 +51,9 @@ export function detectEnforcement(root) {
51
51
  '.grok/hooks/ark-write-gate.json') ||
52
52
  null;
53
53
  return [
54
- { name: 'Write gate', where: writeGateFile, what: 'blocks a bad edit as you type (PreToolUse hook / MCP)' },
54
+ { name: 'Write gate', where: writeGateFile, what: 'configured local gate; active blocking depends on observed host/runtime evidence' },
55
55
  { name: 'ESLint', where: eslintFile || null, what: 'flags violations in your editor' },
56
- { name: 'CI check', where: workflowsMentionArk(), what: 'blocks the merge if the architecture breaks' },
56
+ { name: 'CI check', where: workflowsMentionArk(), what: 'runs Ark; merge blocking requires provider-confirmed required status' },
57
57
  { name: 'Baseline', where: has('.ark-baseline.json') ? '.ark-baseline.json' : null, what: 'old violations frozen; new ones fail' },
58
58
  ].map((e) => ({ ...e, on: !!e.where }));
59
59
  }
@@ -1061,8 +1061,8 @@ export function renderHtmlReport({
1061
1061
  )}
1062
1062
  ${metricKpi(
1063
1063
  `${gatesOn}/${enforcement.length}`,
1064
- 'Gates live',
1065
- 'Write hook, CI workflow, ESLint plugin, and baseline file — how many enforcement points are actually present.'
1064
+ 'Gates configured',
1065
+ 'Write hook, CI workflow, ESLint plugin, and baseline file — how many enforcement surfaces are present, not proof that each is active or required.'
1066
1066
  )}
1067
1067
  ${metricKpi(
1068
1068
  `${violations.length}${suppressed ? ` · ${suppressed}Δ` : ''}`,
@@ -1222,7 +1222,7 @@ export function renderHtmlReport({
1222
1222
  ['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, ''],
1223
1223
  ['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, ''],
1224
1224
  ['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, ''],
1225
- ['Gates live', originSnapshot.gatesOn, currentSnapshot.gatesOn, ''],
1225
+ ['Gates configured', originSnapshot.gatesOn, currentSnapshot.gatesOn, ''],
1226
1226
  ];
1227
1227
  const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
1228
1228
  const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';