arkgate 4.1.0 → 4.2.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 +124 -2
- package/README.md +24 -14
- package/bin/ark-check-runtime.mjs +16 -5
- package/bin/ark-mcp-runtime.mjs +766 -64
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/ark-gitignore.mjs +88 -0
- package/bin/lib/ci-and-commands.mjs +33 -8
- package/bin/lib/codex-home.mjs +90 -8
- package/bin/lib/design-smells.mjs +71 -9
- package/bin/lib/doctor-plan.mjs +47 -39
- package/bin/lib/effective-contract-load.mjs +73 -9
- package/bin/lib/enforcement-honesty.mjs +78 -22
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/gate-files.mjs +441 -9
- package/bin/lib/github-enforcement.mjs +168 -7
- package/bin/lib/hook-templates.mjs +12 -11
- package/bin/lib/host-support-matrix.mjs +91 -17
- package/bin/lib/html-report-depth.mjs +13 -2
- package/bin/lib/html-report-evolution.mjs +114 -0
- package/bin/lib/html-report.mjs +18 -97
- package/bin/lib/import-resolve.mjs +33 -11
- package/bin/lib/install-activation.mjs +87 -0
- package/bin/lib/install-migrate.mjs +66 -50
- package/bin/lib/managed-upgrade.mjs +10 -41
- package/bin/lib/mcp-adoption.mjs +15 -5
- package/bin/lib/pilot-loop.mjs +25 -8
- package/bin/lib/project-identity.mjs +103 -0
- package/bin/lib/report-snapshot-context.mjs +28 -0
- package/bin/lib/resident-hook.mjs +33 -9
- package/bin/lib/rules-inventory.mjs +100 -8
- package/bin/lib/skill-install.mjs +272 -22
- package/bin/lib/skill-write.mjs +899 -0
- package/bin/lib/start-preview.mjs +84 -1
- package/bin/lib/upgrade-command.mjs +2 -5
- package/bin/lib/write-path-detect.mjs +2 -2
- package/dist/index.cjs +13 -13
- package/dist/index.d.ts +194 -2
- package/dist/index.js +13 -13
- package/docs/README.md +6 -4
- package/docs/agent-guide.md +115 -17
- package/docs/ai-gates.md +133 -25
- package/docs/assets/ark-write-gate.svg +2 -2
- package/docs/develop.md +16 -6
- package/docs/enthusiast/how-to-agent-gates.md +6 -0
- package/docs/package-surface.md +16 -9
- package/docs/product-voice.md +22 -4
- package/docs/use.md +3 -1
- package/package.json +3 -1
- package/schemas/ark.project-identity.schema.json +116 -0
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +9 -0
- package/templates/skills/ark-architect.md +12 -2
- package/templates/skills/ark-autopilot.md +9 -0
- package/templates/skills/ark-contract.md +11 -1
- package/templates/skills/ark-coverage.md +9 -0
- package/templates/skills/ark-explain.md +13 -1
- package/templates/skills/ark-explore.md +9 -0
- package/templates/skills/ark-fix.md +10 -1
- package/templates/skills/ark-loop.md +11 -2
- package/templates/skills/ark-place.md +17 -6
- package/templates/skills/ark-runtime.md +8 -0
- package/templates/skills/ark-think.md +14 -2
- package/templates/skills/ark-upgrade.md +9 -0
|
@@ -51,7 +51,14 @@ function shellSegments(text) {
|
|
|
51
51
|
index++;
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
|
-
if (
|
|
54
|
+
if (
|
|
55
|
+
char === '&' &&
|
|
56
|
+
(input[index - 1] === '>' || input[index - 1] === '<' || input[index + 1] === '>')
|
|
57
|
+
) {
|
|
58
|
+
current += char;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (char === ';' || char === '|' || char === '&' || char === '\n') {
|
|
55
62
|
push(char);
|
|
56
63
|
continue;
|
|
57
64
|
}
|
|
@@ -70,12 +77,18 @@ function analyzeCommands(commands, script = '') {
|
|
|
70
77
|
.filter((segment) => DIRECT_ARK.test(executableText(segment.text)))
|
|
71
78
|
.map((segment) => ({
|
|
72
79
|
text: segment.text,
|
|
73
|
-
enforcing:
|
|
80
|
+
enforcing:
|
|
81
|
+
segment.terminator !== '||' &&
|
|
82
|
+
segment.terminator !== '|' &&
|
|
83
|
+
segment.terminator !== '&',
|
|
74
84
|
}));
|
|
75
85
|
const found = [];
|
|
76
86
|
for (const segment of shellSegments(commands)) {
|
|
77
87
|
const executable = executableText(segment.text);
|
|
78
|
-
const outerEnforcing =
|
|
88
|
+
const outerEnforcing =
|
|
89
|
+
segment.terminator !== '||' &&
|
|
90
|
+
segment.terminator !== '|' &&
|
|
91
|
+
segment.terminator !== '&';
|
|
79
92
|
if (DIRECT_ARK.test(executable)) {
|
|
80
93
|
found.push({ text: segment.text, enforcing: outerEnforcing });
|
|
81
94
|
} else if (CHECK_SCRIPT.test(executable)) {
|
|
@@ -362,12 +375,132 @@ function parseJson(result) {
|
|
|
362
375
|
}
|
|
363
376
|
}
|
|
364
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Classify GitHub provider API failures (EH06).
|
|
380
|
+
* Plan/tier claims require **explicit upgrade/plan language** — bare HTTP 403
|
|
381
|
+
* (token/SSO/scope) stays generic `provider-enforcement-unverified` so we never
|
|
382
|
+
* overclaim "proven not required" / Free-plan walls.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} errorText combined stderr/stdout from gh api
|
|
385
|
+
* @param {{ classicAvailable?: boolean, rulesAvailable?: boolean }} [opts]
|
|
386
|
+
* @returns {'provider-policy-unavailable-plan'|'provider-enforcement-unverified'|'ok'}
|
|
387
|
+
*/
|
|
388
|
+
export function classifyGithubProviderFailure(errorText, opts = {}) {
|
|
389
|
+
const text = String(errorText || '');
|
|
390
|
+
const lower = text.toLowerCase();
|
|
391
|
+
// Explicit plan/tier walls only — not every 403 (token/SSO/scope stay unverified).
|
|
392
|
+
const planRestricted =
|
|
393
|
+
/upgrade to github (pro|team|enterprise)/i.test(lower) ||
|
|
394
|
+
/not available (on|for) (your|this) (current )?plan/i.test(lower) ||
|
|
395
|
+
/requires a paid github/i.test(lower) ||
|
|
396
|
+
/github pro.*branch protection|branch protection.*github pro/i.test(lower) ||
|
|
397
|
+
/only available (with|on) github (pro|team|enterprise)/i.test(lower) ||
|
|
398
|
+
/this feature is not available (on|for) (free|your plan)/i.test(lower);
|
|
399
|
+
if (planRestricted) return 'provider-policy-unavailable-plan';
|
|
400
|
+
if (opts.classicAvailable && opts.rulesAvailable) return 'ok';
|
|
401
|
+
return 'provider-enforcement-unverified';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* True when a workflow/job title is an Ark architecture check (not lint-only / spark-ci / dark-theme).
|
|
406
|
+
* Prefer exact product tokens; "architecture" only with gate/check phrasing.
|
|
407
|
+
*
|
|
408
|
+
* @param {{ name?: string, workflowName?: string, displayTitle?: string }} run
|
|
409
|
+
* @returns {boolean}
|
|
410
|
+
*/
|
|
411
|
+
export function isArkishCiRun(run) {
|
|
412
|
+
const blob = `${run?.name || ''} ${run?.workflowName || ''} ${run?.displayTitle || ''}`.toLowerCase();
|
|
413
|
+
if (!blob.trim()) return false;
|
|
414
|
+
// Product tokens with word boundaries (avoid spark, dark, lark false positives).
|
|
415
|
+
if (/\barkgate(?:-check)?\b/.test(blob)) return true;
|
|
416
|
+
if (/\bark-check\b/.test(blob)) return true;
|
|
417
|
+
if (/\bark\s+architecture\b/.test(blob)) return true;
|
|
418
|
+
if (/\barchitecture\s+(?:gate|check)\b/.test(blob)) return true;
|
|
419
|
+
if (/\b(?:arkgate|ark)\s+architecture\s+gate\b/.test(blob)) return true;
|
|
420
|
+
// Standalone workflow names generated by Ark (`name: Ark architecture gate`)
|
|
421
|
+
if (/\bark architecture gate\b/.test(blob)) return true;
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Observe recent GitHub Actions success for Ark architecture checks (EH06).
|
|
427
|
+
* Independent of branch-protection / required-status policy APIs.
|
|
428
|
+
* Never falls back to non-Ark green jobs (lint/test).
|
|
429
|
+
*
|
|
430
|
+
* @param {{ cwd?: string, env?: NodeJS.ProcessEnv, repo?: string, limit?: number }} [opts]
|
|
431
|
+
* @returns {{ runtimeObserved: boolean, latestCiRun: string|null, reason: string, runs?: unknown[] }}
|
|
432
|
+
*/
|
|
433
|
+
export function reportGithubCiRuntime(opts = {}) {
|
|
434
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
435
|
+
const env = opts.env ?? process.env;
|
|
436
|
+
const limit = Number.isFinite(Number(opts.limit)) ? Math.max(1, Number(opts.limit)) : 30;
|
|
437
|
+
if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
|
|
438
|
+
return { runtimeObserved: false, latestCiRun: null, reason: 'gh-cli-unavailable' };
|
|
439
|
+
}
|
|
440
|
+
const args = [
|
|
441
|
+
'run', 'list',
|
|
442
|
+
'--limit', String(limit),
|
|
443
|
+
'--json', 'name,conclusion,status,workflowName,displayTitle,event',
|
|
444
|
+
];
|
|
445
|
+
if (opts.repo) args.push('--repo', opts.repo);
|
|
446
|
+
const result = spawnSync('gh', args, { cwd, encoding: 'utf8', env });
|
|
447
|
+
if (result.status !== 0) {
|
|
448
|
+
const err = `${result.stderr || ''}${result.stdout || ''}`.slice(0, 400);
|
|
449
|
+
return {
|
|
450
|
+
runtimeObserved: false,
|
|
451
|
+
latestCiRun: null,
|
|
452
|
+
reason: classifyGithubProviderFailure(err) === 'provider-policy-unavailable-plan'
|
|
453
|
+
? 'provider-policy-unavailable-plan'
|
|
454
|
+
: 'ci-runtime-unverified',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
let runs = [];
|
|
458
|
+
try {
|
|
459
|
+
runs = JSON.parse(result.stdout || '[]');
|
|
460
|
+
} catch {
|
|
461
|
+
return { runtimeObserved: false, latestCiRun: null, reason: 'ci-runtime-unverified' };
|
|
462
|
+
}
|
|
463
|
+
if (!Array.isArray(runs)) {
|
|
464
|
+
return { runtimeObserved: false, latestCiRun: null, reason: 'ci-runtime-unverified' };
|
|
465
|
+
}
|
|
466
|
+
const relevant = runs.filter(isArkishCiRun);
|
|
467
|
+
if (relevant.length === 0) {
|
|
468
|
+
return {
|
|
469
|
+
runtimeObserved: false,
|
|
470
|
+
latestCiRun: null,
|
|
471
|
+
reason: runs.length === 0 ? 'ci-runtime-empty' : 'ci-runtime-no-ark-runs',
|
|
472
|
+
runs: runs.slice(0, 5),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const success = relevant.find(
|
|
476
|
+
(run) => String(run?.conclusion || '').toLowerCase() === 'success'
|
|
477
|
+
);
|
|
478
|
+
if (success) {
|
|
479
|
+
return {
|
|
480
|
+
runtimeObserved: true,
|
|
481
|
+
latestCiRun: 'success',
|
|
482
|
+
reason: 'ok',
|
|
483
|
+
runs: relevant.slice(0, 5),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const latest = relevant[0];
|
|
487
|
+
const latestConclusion = latest
|
|
488
|
+
? String(latest.conclusion || latest.status || 'unknown').toLowerCase()
|
|
489
|
+
: null;
|
|
490
|
+
return {
|
|
491
|
+
runtimeObserved: false,
|
|
492
|
+
latestCiRun: latestConclusion,
|
|
493
|
+
reason: 'ci-runtime-no-success',
|
|
494
|
+
runs: relevant.slice(0, 5),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
365
498
|
/** Query classic branch protection and all active branch rules before reporting absence. */
|
|
366
499
|
export function reportGithubBranchProtection(opts = {}) {
|
|
367
500
|
const cwd = opts.cwd ?? process.cwd();
|
|
368
501
|
const env = opts.env ?? process.env;
|
|
369
502
|
if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
|
|
370
|
-
return { available: false, reason: 'gh-cli-unavailable' };
|
|
503
|
+
return { available: false, reason: 'gh-cli-unavailable', runtimeObserved: false, latestCiRun: null };
|
|
371
504
|
}
|
|
372
505
|
let repo = opts.repo;
|
|
373
506
|
let branch = opts.branch;
|
|
@@ -375,7 +508,7 @@ export function reportGithubBranchProtection(opts = {}) {
|
|
|
375
508
|
const args = ['repo', 'view', ...(repo ? [repo] : []), '--json', 'nameWithOwner,defaultBranchRef'];
|
|
376
509
|
const metadata = parseJson(spawnSync('gh', args, { cwd, encoding: 'utf8', env }));
|
|
377
510
|
if (!metadata?.nameWithOwner || !metadata?.defaultBranchRef?.name) {
|
|
378
|
-
return { available: false, reason: 'gh-repo-unavailable' };
|
|
511
|
+
return { available: false, reason: 'gh-repo-unavailable', runtimeObserved: false, latestCiRun: null };
|
|
379
512
|
}
|
|
380
513
|
repo ??= metadata.nameWithOwner;
|
|
381
514
|
branch ??= metadata.defaultBranchRef.name;
|
|
@@ -426,9 +559,32 @@ export function reportGithubBranchProtection(opts = {}) {
|
|
|
426
559
|
const all = [...new Set([...contexts, ...checks.map((check) => check.context), ...statusRules.map((check) => check.context)])];
|
|
427
560
|
const error = `${classicResult.stderr || ''}${rulesResult.stderr || ''}`.slice(0, 400);
|
|
428
561
|
|
|
562
|
+
let reason = available ? 'ok' : 'provider-enforcement-unverified';
|
|
563
|
+
if (!available) {
|
|
564
|
+
// Plan-restriction only when neither classic nor ruleset evidence could be read.
|
|
565
|
+
// Partial success (one source OK, other 403) stays generic unverified — not a plan claim.
|
|
566
|
+
if (!classicAvailable && !rulesAvailable) {
|
|
567
|
+
const classicFail =
|
|
568
|
+
classicResult.status !== 0 ? String(classicResult.stderr || classicResult.stdout || '') : '';
|
|
569
|
+
const rulesFail =
|
|
570
|
+
rulesResult.status !== 0 ? String(rulesResult.stderr || rulesResult.stdout || '') : '';
|
|
571
|
+
reason = classifyGithubProviderFailure(`${classicFail}\n${rulesFail}\n${error}`, {
|
|
572
|
+
classicAvailable,
|
|
573
|
+
rulesAvailable,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// EH06: CI runtime observation is independent of branch-protection availability.
|
|
579
|
+
const ciRuntime = opts.includeCiRuntime === false
|
|
580
|
+
? { runtimeObserved: false, latestCiRun: null, reason: 'skipped' }
|
|
581
|
+
: reportGithubCiRuntime({ cwd, env, repo });
|
|
582
|
+
|
|
429
583
|
return {
|
|
430
584
|
available,
|
|
431
|
-
reason
|
|
585
|
+
reason,
|
|
586
|
+
// Alias for consumers that look for the short code
|
|
587
|
+
policyReason: reason === 'provider-policy-unavailable-plan' ? 'unavailable-plan' : reason,
|
|
432
588
|
repo,
|
|
433
589
|
branch,
|
|
434
590
|
requiredStatusChecks: all,
|
|
@@ -438,6 +594,11 @@ export function reportGithubBranchProtection(opts = {}) {
|
|
|
438
594
|
enforcesAdmins: classicAvailable ? Boolean(classic.enforcesAdmins) : null,
|
|
439
595
|
arkCheckRequired,
|
|
440
596
|
arkCheckSourceBound,
|
|
441
|
-
|
|
597
|
+
// hard merge remains false when status is not proven required
|
|
598
|
+
hard: arkCheckRequired === true ? undefined : false,
|
|
599
|
+
runtimeObserved: ciRuntime.runtimeObserved === true,
|
|
600
|
+
latestCiRun: ciRuntime.latestCiRun,
|
|
601
|
+
ciRuntimeReason: ciRuntime.reason,
|
|
602
|
+
raw: { classic, rules, ...(error ? { error } : {}), ciRuntime },
|
|
442
603
|
};
|
|
443
604
|
}
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
// Generated from hook-templates.source.mjs — run npm run generate:packaged-tooling.
|
|
2
|
-
import{execCommandParts as i,execRunner as s}from"../ark-shared.mjs";const c="arkgate-mcp";function l(
|
|
3
|
-
`}function p(
|
|
4
|
-
`}function
|
|
5
|
-
#
|
|
2
|
+
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)}
|
|
3
|
+
`}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)}
|
|
4
|
+
`}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).
|
|
5
|
+
# CONFIGURED ON DISK \u2014 RUNTIME NOT VERIFIED.
|
|
6
|
+
# Restart Codex, then call ark_identity with expectedRoot before trusting MCP verdicts.
|
|
6
7
|
[mcp_servers.ark]
|
|
7
8
|
command = "${t(r)}"
|
|
8
9
|
args = [${n}]
|
|
9
|
-
`}function
|
|
10
|
+
`}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).
|
|
10
11
|
# Restart Grok (or /mcps \u2192 refresh) after changes. Also loads repo-root .mcp.json.
|
|
11
12
|
[mcp_servers.ark]
|
|
12
13
|
command = "${r.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"
|
|
13
14
|
args = [${t}]
|
|
14
|
-
`}function u(
|
|
15
|
-
`}function k(
|
|
16
|
-
`}function d(
|
|
17
|
-
`}function
|
|
18
|
-
`}function
|
|
19
|
-
`}export{c as PREFERRED_MCP_BIN,k as antigravityHooks,l as claudeSettings,p as codexHooks,
|
|
15
|
+
`}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)}
|
|
16
|
+
`}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)}
|
|
17
|
+
`}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)}
|
|
18
|
+
`}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)}
|
|
19
|
+
`}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)}
|
|
20
|
+
`}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};
|
|
@@ -6,7 +6,21 @@
|
|
|
6
6
|
* reported separately by write-path-capabilities.mjs.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} label
|
|
11
|
+
* @param {string|null} hookPath
|
|
12
|
+
* @param {string|null} hookSurface
|
|
13
|
+
* @param {string[]} hookOperations
|
|
14
|
+
* @param {boolean} hardWrite
|
|
15
|
+
* @param {boolean} repairPayload reinjection guaranteed under hard boundary (historical key)
|
|
16
|
+
* @param {{ repairEnvelopeEmitted?: boolean, operationCoverage?: Record<string, boolean> }} [extras]
|
|
17
|
+
*/
|
|
18
|
+
function hostProfile(label, hookPath, hookSurface, hookOperations, hardWrite, repairPayload, extras = {}) {
|
|
19
|
+
// EH07: repair envelope emission ≠ reinjection guarantee.
|
|
20
|
+
// Codex hooks may emit --hook-repair JSON while reinjection stays host-dependent / not hard.
|
|
21
|
+
const repairEnvelopeEmitted =
|
|
22
|
+
extras.repairEnvelopeEmitted === true || repairPayload === true;
|
|
23
|
+
const repairReinjectionGuaranteed = hardWrite === true && repairPayload === true;
|
|
10
24
|
return Object.freeze({
|
|
11
25
|
label,
|
|
12
26
|
hookPath,
|
|
@@ -16,8 +30,16 @@ function hostProfile(label, hookPath, hookSurface, hookOperations, hardWrite, re
|
|
|
16
30
|
'hard-write': hardWrite,
|
|
17
31
|
'advisory-write': true,
|
|
18
32
|
'merge-gate': true,
|
|
33
|
+
// Historical key: true only when hard reinjection path is package-supported.
|
|
19
34
|
'repair-payload': repairPayload,
|
|
35
|
+
'repair-envelope-emitted': repairEnvelopeEmitted,
|
|
36
|
+
'repair-reinjection-guaranteed': repairReinjectionGuaranteed,
|
|
20
37
|
}),
|
|
38
|
+
// EH07 minimum ops matrix (hard=false for soft hosts on every listed op).
|
|
39
|
+
operationCoverage: Object.freeze(
|
|
40
|
+
extras.operationCoverage ||
|
|
41
|
+
Object.fromEntries(hookOperations.map((op) => [op, hardWrite === true]))
|
|
42
|
+
),
|
|
21
43
|
});
|
|
22
44
|
}
|
|
23
45
|
|
|
@@ -48,14 +70,25 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
|
|
|
48
70
|
true,
|
|
49
71
|
true
|
|
50
72
|
),
|
|
51
|
-
cursor: hostProfile('Cursor', null, null, [], false, false
|
|
73
|
+
cursor: hostProfile('Cursor', null, null, [], false, false, {
|
|
74
|
+
operationCoverage: { shell: false, 'pre-commit': false },
|
|
75
|
+
}),
|
|
52
76
|
codex: hostProfile(
|
|
53
77
|
'OpenAI Codex',
|
|
54
78
|
'.codex/hooks.json',
|
|
55
79
|
'Best-effort PreToolUse `apply_patch`; Code Mode hosts may bypass the event',
|
|
56
80
|
['apply_patch'],
|
|
57
81
|
false,
|
|
58
|
-
false
|
|
82
|
+
false,
|
|
83
|
+
{
|
|
84
|
+
// Install writes --hook-repair; envelope can be emitted; reinjection is not guaranteed.
|
|
85
|
+
repairEnvelopeEmitted: true,
|
|
86
|
+
operationCoverage: {
|
|
87
|
+
apply_patch: false,
|
|
88
|
+
shell: false,
|
|
89
|
+
'pre-commit': false,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
59
92
|
),
|
|
60
93
|
// OpenCode: first-class MCP + permissions; plugin tool.execute.before is incomplete
|
|
61
94
|
// (subagent holes). Never claim hard write.
|
|
@@ -65,7 +98,10 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
|
|
|
65
98
|
'Advisory MCP + optional experimental plugin (`tool.execute.before`); not a hard boundary',
|
|
66
99
|
[],
|
|
67
100
|
false,
|
|
68
|
-
false
|
|
101
|
+
false,
|
|
102
|
+
{
|
|
103
|
+
operationCoverage: { shell: false, 'pre-commit': false },
|
|
104
|
+
}
|
|
69
105
|
),
|
|
70
106
|
});
|
|
71
107
|
|
|
@@ -82,7 +118,15 @@ export function formatHostSupportSummary(profile) {
|
|
|
82
118
|
const write = capabilities['hard-write']
|
|
83
119
|
? 'hard local write boundary'
|
|
84
120
|
: 'no hard local write boundary';
|
|
85
|
-
|
|
121
|
+
// EH07 three-way repair story: reinjection guaranteed / envelope-only / none.
|
|
122
|
+
let repair;
|
|
123
|
+
if (capabilities['repair-reinjection-guaranteed']) {
|
|
124
|
+
repair = 'repair reinjection (hard path)';
|
|
125
|
+
} else if (capabilities['repair-envelope-emitted']) {
|
|
126
|
+
repair = 'repair envelope may emit (reinjection not guaranteed)';
|
|
127
|
+
} else {
|
|
128
|
+
repair = 'no hard-boundary repair';
|
|
129
|
+
}
|
|
86
130
|
return `${write} + advisory MCP + CI check + ${repair}`;
|
|
87
131
|
}
|
|
88
132
|
|
|
@@ -104,12 +148,18 @@ export function renderHostSupportMatrixMarkdown() {
|
|
|
104
148
|
} else {
|
|
105
149
|
local = '**Advisory only** at write (no hard hook)';
|
|
106
150
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
151
|
+
// EH07: distinguish envelope emission vs reinjection guarantee in the repair column.
|
|
152
|
+
let repair;
|
|
153
|
+
if (capabilities['repair-reinjection-guaranteed']) {
|
|
154
|
+
repair = 'Emitted on hook deny; host must re-inject (hard path when installed + trusted)';
|
|
155
|
+
} else if (capabilities['repair-envelope-emitted']) {
|
|
156
|
+
repair = 'Envelope may emit (`--hook-repair`); reinjection **not** guaranteed (advisory host)';
|
|
157
|
+
} else {
|
|
158
|
+
repair = 'No hard-boundary payload';
|
|
159
|
+
}
|
|
160
|
+
// EH07: name the CLI explicitly; required status is a GitHub status context name, not the CLI alone.
|
|
161
|
+
const merge =
|
|
162
|
+
'**Required GitHub status context** running `arkgate-check --strict-merge` (alias `ark-check`)';
|
|
113
163
|
return `| ${profile.label} | ${local} | Advisory; the agent must call it | ${merge} | ${repair} |`;
|
|
114
164
|
}).join('\n');
|
|
115
165
|
|
|
@@ -118,9 +168,30 @@ export function renderHostSupportMatrixMarkdown() {
|
|
|
118
168
|
${rows}
|
|
119
169
|
|
|
120
170
|
**Read the CI column:** for every host, the repository-wide hard guarantee is a **required**
|
|
121
|
-
|
|
171
|
+
GitHub **status context** that runs the CLI — not “CI file present,” and not the CLI binary name alone.
|
|
172
|
+
Cursor/Codex/OpenCode never get a fake hard write claim.
|
|
173
|
+
|
|
174
|
+
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.`;
|
|
175
|
+
}
|
|
122
176
|
|
|
123
|
-
|
|
177
|
+
/**
|
|
178
|
+
* EH07 doctor/JSON host capability split for repair envelope vs reinjection.
|
|
179
|
+
* @param {string|null|undefined} host
|
|
180
|
+
*/
|
|
181
|
+
export function hostRepairCapabilities(host) {
|
|
182
|
+
const profile = getHostSupportProfile(host);
|
|
183
|
+
if (!profile) {
|
|
184
|
+
return {
|
|
185
|
+
repairEnvelopeEmitted: false,
|
|
186
|
+
repairReinjectionGuaranteed: false,
|
|
187
|
+
operationCoverage: {},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
repairEnvelopeEmitted: profile.capabilities['repair-envelope-emitted'] === true,
|
|
192
|
+
repairReinjectionGuaranteed: profile.capabilities['repair-reinjection-guaranteed'] === true,
|
|
193
|
+
operationCoverage: { ...(profile.operationCoverage || {}) },
|
|
194
|
+
};
|
|
124
195
|
}
|
|
125
196
|
|
|
126
197
|
/**
|
|
@@ -129,19 +200,22 @@ This table describes the supported profile **after its files are installed and t
|
|
|
129
200
|
*/
|
|
130
201
|
export function doctorWritePathHonestyMessage(activeHost, hardWriteActive) {
|
|
131
202
|
const host = typeof activeHost === 'string' ? activeHost.trim().toLowerCase() : '';
|
|
203
|
+
// EH07: distinguish CLI command (arkgate-check / ark-check) from the GitHub required status context name.
|
|
204
|
+
const mergeBoundary =
|
|
205
|
+
'Required CI hard merge boundary = a required GitHub status context that runs arkgate-check --strict-merge (alias ark-check --strict-merge)';
|
|
132
206
|
if (host === 'cursor') {
|
|
133
|
-
return
|
|
207
|
+
return `Cursor: write path is advisory (MCP/rules; no hard PreToolUse). ${mergeBoundary}.`;
|
|
134
208
|
}
|
|
135
209
|
if (host === 'codex') {
|
|
136
|
-
return
|
|
210
|
+
return `Codex: write path is advisory / best-effort at write (not Claude/Grok hard). ${mergeBoundary}.`;
|
|
137
211
|
}
|
|
138
212
|
if (host === 'opencode') {
|
|
139
|
-
return
|
|
213
|
+
return `OpenCode: write path is advisory / best-effort (MCP + optional plugin; not Claude/Grok/Antigravity hard). ${mergeBoundary}.`;
|
|
140
214
|
}
|
|
141
215
|
if ((host === 'claude' || host === 'grok' || host === 'antigravity') && !hardWriteActive) {
|
|
142
216
|
const label =
|
|
143
217
|
host === 'claude' ? 'Claude' : host === 'grok' ? 'Grok' : 'Antigravity';
|
|
144
|
-
return `${label}: hard PreToolUse is supported for listed ops when installed + trusted; without runtime-observed hook evidence, hard is unverified.
|
|
218
|
+
return `${label}: hard PreToolUse is supported for listed ops when installed + trusted; without runtime-observed hook evidence, hard is unverified. ${mergeBoundary}.`;
|
|
145
219
|
}
|
|
146
220
|
return null;
|
|
147
221
|
}
|
|
@@ -288,6 +288,10 @@ function baselineLegendBody(signal) {
|
|
|
288
288
|
export function renderProductHonestyCard(productHonesty, mergePlanes = null) {
|
|
289
289
|
if (!productHonesty || typeof productHonesty !== 'object') return '';
|
|
290
290
|
const unfinished = productHonesty.unfinished === true;
|
|
291
|
+
const envResiduals = Array.isArray(productHonesty.environmentResidualIds)
|
|
292
|
+
? productHonesty.environmentResidualIds
|
|
293
|
+
: [];
|
|
294
|
+
const envOnly = !unfinished && envResiduals.length > 0;
|
|
291
295
|
const headline = productHonesty.headline || (unfinished ? 'Not finished' : 'Honesty clear');
|
|
292
296
|
const primary = productHonesty.primaryMessage || '';
|
|
293
297
|
// Avoid repeating the same status label in title and body (past-issue pattern).
|
|
@@ -298,7 +302,9 @@ export function renderProductHonestyCard(productHonesty, mergePlanes = null) {
|
|
|
298
302
|
if (p === h) {
|
|
299
303
|
body = unfinished
|
|
300
304
|
? 'Residual honesty signals remain — not a whole-tree guarantee and not a score.'
|
|
301
|
-
:
|
|
305
|
+
: envOnly
|
|
306
|
+
? 'Architecture residual clear; host/environment residual remains (advisory write) — not a score.'
|
|
307
|
+
: 'No residual honesty blockers on this slice — still not a numeric architecture score.';
|
|
302
308
|
} else if (p.toLowerCase().startsWith(h.toLowerCase())) {
|
|
303
309
|
const stripped = p.slice(h.length).replace(/^[\s—–:-]+/, '').trim();
|
|
304
310
|
body = stripped || p;
|
|
@@ -319,10 +325,15 @@ export function renderProductHonestyCard(productHonesty, mergePlanes = null) {
|
|
|
319
325
|
mergePlanes?.dualPlaneStamp
|
|
320
326
|
? `<p class="dim" style="margin:.25rem 0 0;font-size:.84rem">${esc(mergePlanes.dualPlaneStamp)}</p>`
|
|
321
327
|
: '';
|
|
328
|
+
const subtitle = unfinished
|
|
329
|
+
? 'architecture residual'
|
|
330
|
+
: envOnly
|
|
331
|
+
? 'environment residual (advisory write)'
|
|
332
|
+
: 'no residual honesty blockers';
|
|
322
333
|
return `<div class="section card design-strip ${unfinished ? 'is-weak' : 'is-clean'}" id="product-honesty" data-product-honesty="1">
|
|
323
334
|
<div class="design-head">
|
|
324
335
|
<span class="badge design" title="Product honesty — not a score">${esc(headline)}</span>
|
|
325
|
-
<span class="dim" style="font-size:.86rem">${
|
|
336
|
+
<span class="dim" style="font-size:.86rem">${esc(subtitle)}</span>
|
|
326
337
|
</div>
|
|
327
338
|
<p style="margin:.45rem 0 0">${esc(body)}</p>
|
|
328
339
|
${reasonHtml}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export function renderEvolutionSection({
|
|
2
|
+
originSnapshot,
|
|
3
|
+
currentSnapshot,
|
|
4
|
+
originJustCreated,
|
|
5
|
+
esc,
|
|
6
|
+
formatDelta,
|
|
7
|
+
historyMax,
|
|
8
|
+
}) {
|
|
9
|
+
if (!currentSnapshot) return '';
|
|
10
|
+
if (originJustCreated || !originSnapshot) {
|
|
11
|
+
return `<div class="section card evolve">
|
|
12
|
+
<h2>Origin baseline captured</h2>
|
|
13
|
+
<p class="dim" style="margin:.2rem 0 0;font-size:.9rem">
|
|
14
|
+
This is the <b>first</b> architecture snapshot for this project
|
|
15
|
+
(<code>.ark/reports/origin.json</code> + <code>origin.html</code>).
|
|
16
|
+
Future reports will show deltas against this starting point so you can prove evolution.
|
|
17
|
+
</p>
|
|
18
|
+
</div>`;
|
|
19
|
+
}
|
|
20
|
+
const scoreComparable =
|
|
21
|
+
typeof originSnapshot.arkVersion === 'string' &&
|
|
22
|
+
originSnapshot.arkVersion.length > 0 &&
|
|
23
|
+
originSnapshot.arkVersion === currentSnapshot.arkVersion;
|
|
24
|
+
const rows = [
|
|
25
|
+
['Ark score', originSnapshot.score, currentSnapshot.score, '', scoreComparable],
|
|
26
|
+
['Governed %', originSnapshot.governedPercent, currentSnapshot.governedPercent, 'pp', true],
|
|
27
|
+
['Files in scope', originSnapshot.totalFiles, currentSnapshot.totalFiles, '', true],
|
|
28
|
+
['Classified files', originSnapshot.classifiedFiles, currentSnapshot.classifiedFiles, '', true],
|
|
29
|
+
['Active violations', originSnapshot.activeViolations, currentSnapshot.activeViolations, '', true],
|
|
30
|
+
['Value violations', originSnapshot.valueViolations, currentSnapshot.valueViolations, '', true],
|
|
31
|
+
['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, '', true],
|
|
32
|
+
['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, '', true],
|
|
33
|
+
['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, '', true],
|
|
34
|
+
['Gates configured', originSnapshot.gatesOn, currentSnapshot.gatesOn, '', true],
|
|
35
|
+
];
|
|
36
|
+
const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
|
|
37
|
+
const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';
|
|
38
|
+
const tr = rows
|
|
39
|
+
.map(([label, from, to, unit, comparable]) => {
|
|
40
|
+
const d =
|
|
41
|
+
comparable && typeof from === 'number' && typeof to === 'number'
|
|
42
|
+
? to - from
|
|
43
|
+
: null;
|
|
44
|
+
const good =
|
|
45
|
+
label.includes('violation') || label.includes('Violation')
|
|
46
|
+
? d != null && d <= 0
|
|
47
|
+
: label.includes('Governed') ||
|
|
48
|
+
label.includes('score') ||
|
|
49
|
+
label.includes('Classified') ||
|
|
50
|
+
label.includes('Gates')
|
|
51
|
+
? d != null && d >= 0
|
|
52
|
+
: null;
|
|
53
|
+
const cls =
|
|
54
|
+
d == null || d === 0 ? 'flat' : good === true ? 'up' : good === false ? 'down' : 'flat';
|
|
55
|
+
const delta =
|
|
56
|
+
d == null
|
|
57
|
+
? '—'
|
|
58
|
+
: unit === 'pp'
|
|
59
|
+
? formatDelta(Math.round(d * 10) / 10, { suffix: ' pp' })
|
|
60
|
+
: formatDelta(d);
|
|
61
|
+
return `<tr>
|
|
62
|
+
<td>${esc(label)}</td>
|
|
63
|
+
<td class="num">${from ?? '—'}</td>
|
|
64
|
+
<td class="num">${to ?? '—'}</td>
|
|
65
|
+
<td class="num delta ${cls}">${esc(delta)}</td>
|
|
66
|
+
</tr>`;
|
|
67
|
+
})
|
|
68
|
+
.join('\n');
|
|
69
|
+
const originLayers = originSnapshot.layerFiles || {};
|
|
70
|
+
const currentLayers = currentSnapshot.layerFiles || {};
|
|
71
|
+
const layerKeys = [
|
|
72
|
+
...new Set([...Object.keys(originLayers), ...Object.keys(currentLayers)]),
|
|
73
|
+
].sort();
|
|
74
|
+
const layerTr = layerKeys
|
|
75
|
+
.map((name) => {
|
|
76
|
+
const from = originLayers[name] || 0;
|
|
77
|
+
const to = currentLayers[name] || 0;
|
|
78
|
+
const d = to - from;
|
|
79
|
+
const cls = d === 0 ? 'flat' : d > 0 ? 'up' : 'down';
|
|
80
|
+
return `<tr>
|
|
81
|
+
<td class="ln">${esc(name)}</td>
|
|
82
|
+
<td class="num">${from}</td>
|
|
83
|
+
<td class="num">${to}</td>
|
|
84
|
+
<td class="num delta ${cls}">${esc(formatDelta(d))}</td>
|
|
85
|
+
</tr>`;
|
|
86
|
+
})
|
|
87
|
+
.join('\n');
|
|
88
|
+
const scoreNote = scoreComparable
|
|
89
|
+
? ''
|
|
90
|
+
: `<p class="dim" style="margin:-.35rem 0 .75rem;font-size:.88rem">
|
|
91
|
+
Ark score is not comparable across Ark versions
|
|
92
|
+
(<code>${esc(originSnapshot.arkVersion ?? 'unknown')}</code> →
|
|
93
|
+
<code>${esc(currentSnapshot.arkVersion ?? 'unknown')}</code>); its Δ is shown as —.
|
|
94
|
+
Raw coverage, files, violations, layers, rules, and gate metrics remain visible.
|
|
95
|
+
</p>`;
|
|
96
|
+
return `<div class="section card evolve">
|
|
97
|
+
<h2>Evolution vs origin</h2>
|
|
98
|
+
<p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
|
|
99
|
+
Origin snapshot <code>${esc(originDate)}</code> → this report <code>${esc(nowDate)}</code>
|
|
100
|
+
· frozen at <code>.ark/reports/origin.*</code> · reopen origin HTML anytime for the starting picture.
|
|
101
|
+
</p>
|
|
102
|
+
${scoreNote}
|
|
103
|
+
<table class="layers">
|
|
104
|
+
<tr><th>Metric</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
|
|
105
|
+
${tr}
|
|
106
|
+
</table>
|
|
107
|
+
<h3>Files per layer</h3>
|
|
108
|
+
<table class="layers">
|
|
109
|
+
<tr><th>Layer</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
|
|
110
|
+
${layerTr || '<tr><td colspan="4" class="dim">No layer file data in snapshots.</td></tr>'}
|
|
111
|
+
</table>
|
|
112
|
+
<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>
|
|
113
|
+
</div>`;
|
|
114
|
+
}
|