arkgate 4.0.1 → 4.1.1
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 +134 -0
- package/README.md +13 -13
- package/bin/ark-check-runtime.mjs +244 -25
- package/bin/ark-check.mjs +10 -1
- package/bin/ark-layer-match.mjs +80 -5
- package/bin/ark-shared.mjs +170 -9
- package/bin/ark.mjs +52 -5
- package/bin/lib/adapter-contract.mjs +7 -1
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/ark-gitignore.mjs +88 -0
- package/bin/lib/arkrules-sensors.mjs +63 -22
- package/bin/lib/ci-and-commands.mjs +165 -9
- package/bin/lib/core-ratchet.mjs +9 -4
- package/bin/lib/doctor-advisories.mjs +8 -1
- package/bin/lib/doctor-plan.mjs +287 -61
- package/bin/lib/enforcement-honesty.mjs +408 -27
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/field-install.mjs +35 -2
- package/bin/lib/github-enforcement.mjs +152 -4
- package/bin/lib/host-support-matrix.mjs +91 -17
- package/bin/lib/html-report-depth.mjs +178 -3
- package/bin/lib/html-report.mjs +19 -13
- package/bin/lib/install-migrate.mjs +109 -6
- package/bin/lib/managed-upgrade.mjs +99 -0
- package/bin/lib/presets.mjs +314 -46
- package/bin/lib/project-root.mjs +268 -0
- package/bin/lib/remediation.mjs +12 -11
- package/bin/lib/rules-inventory.mjs +71 -29
- package/bin/lib/rules-under-contract.mjs +134 -4
- package/bin/lib/start-preview.mjs +48 -14
- package/bin/lib/suggestions.mjs +118 -3
- package/bin/lib/unavailable-analysis.mjs +2 -0
- package/bin/lib/write-path-capabilities.mjs +38 -9
- package/bin/lib/write-path-detect.mjs +2 -2
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +27 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +16 -14
- package/dist/index.d.ts +3 -1
- package/dist/index.js +16 -14
- package/docs/README.md +5 -5
- package/docs/agent-guide.md +5 -3
- package/docs/ai-gates.md +45 -18
- package/docs/brownfield-adoption.md +36 -0
- package/docs/configuration.md +36 -0
- package/docs/develop.md +16 -6
- package/docs/package-surface.md +5 -3
- package/docs/product-voice.md +15 -2
- package/docs/typescript-support.md +9 -5
- package/docs/use.md +3 -1
- package/package.json +3 -1
- package/server.json +3 -3
- package/templates/architecture-playbook.json +3 -0
- package/templates/layers/shared-types.starter.json +29 -0
- package/templates/skills/ark-adopt.md +2 -0
- package/templates/skills/ark-explain.md +5 -0
- package/templates/skills/ark-explore.md +21 -1
- package/templates/skills/ark-fix.md +16 -5
|
@@ -362,12 +362,132 @@ function parseJson(result) {
|
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Classify GitHub provider API failures (EH06).
|
|
367
|
+
* Plan/tier claims require **explicit upgrade/plan language** — bare HTTP 403
|
|
368
|
+
* (token/SSO/scope) stays generic `provider-enforcement-unverified` so we never
|
|
369
|
+
* overclaim "proven not required" / Free-plan walls.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} errorText combined stderr/stdout from gh api
|
|
372
|
+
* @param {{ classicAvailable?: boolean, rulesAvailable?: boolean }} [opts]
|
|
373
|
+
* @returns {'provider-policy-unavailable-plan'|'provider-enforcement-unverified'|'ok'}
|
|
374
|
+
*/
|
|
375
|
+
export function classifyGithubProviderFailure(errorText, opts = {}) {
|
|
376
|
+
const text = String(errorText || '');
|
|
377
|
+
const lower = text.toLowerCase();
|
|
378
|
+
// Explicit plan/tier walls only — not every 403 (token/SSO/scope stay unverified).
|
|
379
|
+
const planRestricted =
|
|
380
|
+
/upgrade to github (pro|team|enterprise)/i.test(lower) ||
|
|
381
|
+
/not available (on|for) (your|this) (current )?plan/i.test(lower) ||
|
|
382
|
+
/requires a paid github/i.test(lower) ||
|
|
383
|
+
/github pro.*branch protection|branch protection.*github pro/i.test(lower) ||
|
|
384
|
+
/only available (with|on) github (pro|team|enterprise)/i.test(lower) ||
|
|
385
|
+
/this feature is not available (on|for) (free|your plan)/i.test(lower);
|
|
386
|
+
if (planRestricted) return 'provider-policy-unavailable-plan';
|
|
387
|
+
if (opts.classicAvailable && opts.rulesAvailable) return 'ok';
|
|
388
|
+
return 'provider-enforcement-unverified';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* True when a workflow/job title is an Ark architecture check (not lint-only / spark-ci / dark-theme).
|
|
393
|
+
* Prefer exact product tokens; "architecture" only with gate/check phrasing.
|
|
394
|
+
*
|
|
395
|
+
* @param {{ name?: string, workflowName?: string, displayTitle?: string }} run
|
|
396
|
+
* @returns {boolean}
|
|
397
|
+
*/
|
|
398
|
+
export function isArkishCiRun(run) {
|
|
399
|
+
const blob = `${run?.name || ''} ${run?.workflowName || ''} ${run?.displayTitle || ''}`.toLowerCase();
|
|
400
|
+
if (!blob.trim()) return false;
|
|
401
|
+
// Product tokens with word boundaries (avoid spark, dark, lark false positives).
|
|
402
|
+
if (/\barkgate(?:-check)?\b/.test(blob)) return true;
|
|
403
|
+
if (/\bark-check\b/.test(blob)) return true;
|
|
404
|
+
if (/\bark\s+architecture\b/.test(blob)) return true;
|
|
405
|
+
if (/\barchitecture\s+(?:gate|check)\b/.test(blob)) return true;
|
|
406
|
+
if (/\b(?:arkgate|ark)\s+architecture\s+gate\b/.test(blob)) return true;
|
|
407
|
+
// Standalone workflow names generated by Ark (`name: Ark architecture gate`)
|
|
408
|
+
if (/\bark architecture gate\b/.test(blob)) return true;
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Observe recent GitHub Actions success for Ark architecture checks (EH06).
|
|
414
|
+
* Independent of branch-protection / required-status policy APIs.
|
|
415
|
+
* Never falls back to non-Ark green jobs (lint/test).
|
|
416
|
+
*
|
|
417
|
+
* @param {{ cwd?: string, env?: NodeJS.ProcessEnv, repo?: string, limit?: number }} [opts]
|
|
418
|
+
* @returns {{ runtimeObserved: boolean, latestCiRun: string|null, reason: string, runs?: unknown[] }}
|
|
419
|
+
*/
|
|
420
|
+
export function reportGithubCiRuntime(opts = {}) {
|
|
421
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
422
|
+
const env = opts.env ?? process.env;
|
|
423
|
+
const limit = Number.isFinite(Number(opts.limit)) ? Math.max(1, Number(opts.limit)) : 30;
|
|
424
|
+
if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
|
|
425
|
+
return { runtimeObserved: false, latestCiRun: null, reason: 'gh-cli-unavailable' };
|
|
426
|
+
}
|
|
427
|
+
const args = [
|
|
428
|
+
'run', 'list',
|
|
429
|
+
'--limit', String(limit),
|
|
430
|
+
'--json', 'name,conclusion,status,workflowName,displayTitle,event',
|
|
431
|
+
];
|
|
432
|
+
if (opts.repo) args.push('--repo', opts.repo);
|
|
433
|
+
const result = spawnSync('gh', args, { cwd, encoding: 'utf8', env });
|
|
434
|
+
if (result.status !== 0) {
|
|
435
|
+
const err = `${result.stderr || ''}${result.stdout || ''}`.slice(0, 400);
|
|
436
|
+
return {
|
|
437
|
+
runtimeObserved: false,
|
|
438
|
+
latestCiRun: null,
|
|
439
|
+
reason: classifyGithubProviderFailure(err) === 'provider-policy-unavailable-plan'
|
|
440
|
+
? 'provider-policy-unavailable-plan'
|
|
441
|
+
: 'ci-runtime-unverified',
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
let runs = [];
|
|
445
|
+
try {
|
|
446
|
+
runs = JSON.parse(result.stdout || '[]');
|
|
447
|
+
} catch {
|
|
448
|
+
return { runtimeObserved: false, latestCiRun: null, reason: 'ci-runtime-unverified' };
|
|
449
|
+
}
|
|
450
|
+
if (!Array.isArray(runs)) {
|
|
451
|
+
return { runtimeObserved: false, latestCiRun: null, reason: 'ci-runtime-unverified' };
|
|
452
|
+
}
|
|
453
|
+
const relevant = runs.filter(isArkishCiRun);
|
|
454
|
+
if (relevant.length === 0) {
|
|
455
|
+
return {
|
|
456
|
+
runtimeObserved: false,
|
|
457
|
+
latestCiRun: null,
|
|
458
|
+
reason: runs.length === 0 ? 'ci-runtime-empty' : 'ci-runtime-no-ark-runs',
|
|
459
|
+
runs: runs.slice(0, 5),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const success = relevant.find(
|
|
463
|
+
(run) => String(run?.conclusion || '').toLowerCase() === 'success'
|
|
464
|
+
);
|
|
465
|
+
if (success) {
|
|
466
|
+
return {
|
|
467
|
+
runtimeObserved: true,
|
|
468
|
+
latestCiRun: 'success',
|
|
469
|
+
reason: 'ok',
|
|
470
|
+
runs: relevant.slice(0, 5),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
const latest = relevant[0];
|
|
474
|
+
const latestConclusion = latest
|
|
475
|
+
? String(latest.conclusion || latest.status || 'unknown').toLowerCase()
|
|
476
|
+
: null;
|
|
477
|
+
return {
|
|
478
|
+
runtimeObserved: false,
|
|
479
|
+
latestCiRun: latestConclusion,
|
|
480
|
+
reason: 'ci-runtime-no-success',
|
|
481
|
+
runs: relevant.slice(0, 5),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
365
485
|
/** Query classic branch protection and all active branch rules before reporting absence. */
|
|
366
486
|
export function reportGithubBranchProtection(opts = {}) {
|
|
367
487
|
const cwd = opts.cwd ?? process.cwd();
|
|
368
488
|
const env = opts.env ?? process.env;
|
|
369
489
|
if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
|
|
370
|
-
return { available: false, reason: 'gh-cli-unavailable' };
|
|
490
|
+
return { available: false, reason: 'gh-cli-unavailable', runtimeObserved: false, latestCiRun: null };
|
|
371
491
|
}
|
|
372
492
|
let repo = opts.repo;
|
|
373
493
|
let branch = opts.branch;
|
|
@@ -375,7 +495,7 @@ export function reportGithubBranchProtection(opts = {}) {
|
|
|
375
495
|
const args = ['repo', 'view', ...(repo ? [repo] : []), '--json', 'nameWithOwner,defaultBranchRef'];
|
|
376
496
|
const metadata = parseJson(spawnSync('gh', args, { cwd, encoding: 'utf8', env }));
|
|
377
497
|
if (!metadata?.nameWithOwner || !metadata?.defaultBranchRef?.name) {
|
|
378
|
-
return { available: false, reason: 'gh-repo-unavailable' };
|
|
498
|
+
return { available: false, reason: 'gh-repo-unavailable', runtimeObserved: false, latestCiRun: null };
|
|
379
499
|
}
|
|
380
500
|
repo ??= metadata.nameWithOwner;
|
|
381
501
|
branch ??= metadata.defaultBranchRef.name;
|
|
@@ -426,9 +546,32 @@ export function reportGithubBranchProtection(opts = {}) {
|
|
|
426
546
|
const all = [...new Set([...contexts, ...checks.map((check) => check.context), ...statusRules.map((check) => check.context)])];
|
|
427
547
|
const error = `${classicResult.stderr || ''}${rulesResult.stderr || ''}`.slice(0, 400);
|
|
428
548
|
|
|
549
|
+
let reason = available ? 'ok' : 'provider-enforcement-unverified';
|
|
550
|
+
if (!available) {
|
|
551
|
+
// Plan-restriction only when neither classic nor ruleset evidence could be read.
|
|
552
|
+
// Partial success (one source OK, other 403) stays generic unverified — not a plan claim.
|
|
553
|
+
if (!classicAvailable && !rulesAvailable) {
|
|
554
|
+
const classicFail =
|
|
555
|
+
classicResult.status !== 0 ? String(classicResult.stderr || classicResult.stdout || '') : '';
|
|
556
|
+
const rulesFail =
|
|
557
|
+
rulesResult.status !== 0 ? String(rulesResult.stderr || rulesResult.stdout || '') : '';
|
|
558
|
+
reason = classifyGithubProviderFailure(`${classicFail}\n${rulesFail}\n${error}`, {
|
|
559
|
+
classicAvailable,
|
|
560
|
+
rulesAvailable,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// EH06: CI runtime observation is independent of branch-protection availability.
|
|
566
|
+
const ciRuntime = opts.includeCiRuntime === false
|
|
567
|
+
? { runtimeObserved: false, latestCiRun: null, reason: 'skipped' }
|
|
568
|
+
: reportGithubCiRuntime({ cwd, env, repo });
|
|
569
|
+
|
|
429
570
|
return {
|
|
430
571
|
available,
|
|
431
|
-
reason
|
|
572
|
+
reason,
|
|
573
|
+
// Alias for consumers that look for the short code
|
|
574
|
+
policyReason: reason === 'provider-policy-unavailable-plan' ? 'unavailable-plan' : reason,
|
|
432
575
|
repo,
|
|
433
576
|
branch,
|
|
434
577
|
requiredStatusChecks: all,
|
|
@@ -438,6 +581,11 @@ export function reportGithubBranchProtection(opts = {}) {
|
|
|
438
581
|
enforcesAdmins: classicAvailable ? Boolean(classic.enforcesAdmins) : null,
|
|
439
582
|
arkCheckRequired,
|
|
440
583
|
arkCheckSourceBound,
|
|
441
|
-
|
|
584
|
+
// hard merge remains false when status is not proven required
|
|
585
|
+
hard: arkCheckRequired === true ? undefined : false,
|
|
586
|
+
runtimeObserved: ciRuntime.runtimeObserved === true,
|
|
587
|
+
latestCiRun: ciRuntime.latestCiRun,
|
|
588
|
+
ciRuntimeReason: ciRuntime.reason,
|
|
589
|
+
raw: { classic, rules, ...(error ? { error } : {}), ciRuntime },
|
|
442
590
|
};
|
|
443
591
|
}
|
|
@@ -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
|
}
|
|
@@ -12,6 +12,15 @@ import { summarizePilotLoop } from './pilot-loop.mjs';
|
|
|
12
12
|
import { buildPostGreenNextAction } from './post-green-path.mjs';
|
|
13
13
|
import { loadGoldenPattern, summarizeGoldenPattern } from './golden-pattern.mjs';
|
|
14
14
|
import { collectAdoptionGaps } from './mcp-adoption.mjs';
|
|
15
|
+
import {
|
|
16
|
+
buildCoverageHonesty,
|
|
17
|
+
buildBaselineHonesty,
|
|
18
|
+
buildWritePathHonesty,
|
|
19
|
+
buildProductHonesty,
|
|
20
|
+
} from './enforcement-honesty.mjs';
|
|
21
|
+
import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
|
|
22
|
+
import { readBaseline, baselineOccurrenceKeys } from './violations.mjs';
|
|
23
|
+
import { describePackageVersionDualTruth } from './field-install.mjs';
|
|
15
24
|
|
|
16
25
|
function esc(value) {
|
|
17
26
|
return String(value)
|
|
@@ -27,12 +36,33 @@ function esc(value) {
|
|
|
27
36
|
* @param {object} config
|
|
28
37
|
* @param {string[]} files
|
|
29
38
|
* @param {object} coverage
|
|
30
|
-
* @param {object[]} activeViolations
|
|
39
|
+
* @param {object[]} activeViolations already baseline-filtered active findings
|
|
40
|
+
* @param {{
|
|
41
|
+
* suppressedCount?: number,
|
|
42
|
+
* totalViolationCount?: number,
|
|
43
|
+
* frozenKeys?: number,
|
|
44
|
+
* activeCount?: number,
|
|
45
|
+
* activeBlockingCount?: number,
|
|
46
|
+
* }} [baselineSplit] same numbers doctor uses (do not recompute from active-only list)
|
|
31
47
|
*/
|
|
32
|
-
export function buildReportDepthPayload(
|
|
48
|
+
export function buildReportDepthPayload(
|
|
49
|
+
root,
|
|
50
|
+
config,
|
|
51
|
+
files,
|
|
52
|
+
coverage,
|
|
53
|
+
activeViolations = [],
|
|
54
|
+
baselineSplit = {}
|
|
55
|
+
) {
|
|
56
|
+
// Blocking = failsStrict !== false only (type-only placement debt is non-blocking).
|
|
57
|
+
// Prefer caller-supplied count for doctor parity; else derive from active list.
|
|
58
|
+
const activeBlockingCount =
|
|
59
|
+
typeof baselineSplit.activeBlockingCount === 'number'
|
|
60
|
+
? baselineSplit.activeBlockingCount
|
|
61
|
+
: activeViolations.filter((v) => v?.failsStrict !== false).length;
|
|
33
62
|
const designSmells = detectDesignSmells(root, config, files, coverage);
|
|
34
63
|
const designFitness = summarizeDesignFitness(designSmells, {
|
|
35
|
-
|
|
64
|
+
// Doctor parity: fitness uses blocking count, not raw active (incl. type-only).
|
|
65
|
+
activeViolations: activeBlockingCount,
|
|
36
66
|
governedPercent: coverage?.governed?.percent,
|
|
37
67
|
totalFiles: coverage?.governed?.totalFiles,
|
|
38
68
|
});
|
|
@@ -45,6 +75,86 @@ export function buildReportDepthPayload(root, config, files, coverage, activeVio
|
|
|
45
75
|
});
|
|
46
76
|
const goldenPattern = summarizeGoldenPattern(loadGoldenPattern(root));
|
|
47
77
|
const adoption = collectAdoptionGaps(root, config, coverage);
|
|
78
|
+
const baseline = readBaseline(root, '.ark-baseline.json');
|
|
79
|
+
// Prefer caller-supplied baseline split (doctor parity). Recomputing suppressed from an
|
|
80
|
+
// already-filtered activeViolations list always yields 0 and hides dirty-freeze.
|
|
81
|
+
const suppressed =
|
|
82
|
+
typeof baselineSplit.suppressedCount === 'number'
|
|
83
|
+
? baselineSplit.suppressedCount
|
|
84
|
+
: baseline.exists
|
|
85
|
+
? baselineOccurrenceKeys(activeViolations).filter((key) => baseline.keys.has(key)).length
|
|
86
|
+
: 0;
|
|
87
|
+
const frozenKeys =
|
|
88
|
+
typeof baselineSplit.frozenKeys === 'number'
|
|
89
|
+
? baselineSplit.frozenKeys
|
|
90
|
+
: baseline.exists
|
|
91
|
+
? baseline.keys.size
|
|
92
|
+
: 0;
|
|
93
|
+
const activeCount =
|
|
94
|
+
typeof baselineSplit.activeCount === 'number'
|
|
95
|
+
? baselineSplit.activeCount
|
|
96
|
+
: Math.max(0, activeViolations.length - suppressed);
|
|
97
|
+
const totalViolations =
|
|
98
|
+
typeof baselineSplit.totalViolationCount === 'number'
|
|
99
|
+
? baselineSplit.totalViolationCount
|
|
100
|
+
: activeViolations.length + suppressed;
|
|
101
|
+
const coverageHonesty = buildCoverageHonesty({
|
|
102
|
+
percent: coverage?.governed?.percent,
|
|
103
|
+
totalFiles: coverage?.governed?.totalFiles,
|
|
104
|
+
emptyScope: coverage?.emptyScope === true || (coverage?.governed?.totalFiles ?? 0) === 0,
|
|
105
|
+
});
|
|
106
|
+
const baselineHonesty = buildBaselineHonesty({
|
|
107
|
+
exists: baseline.exists || frozenKeys > 0,
|
|
108
|
+
frozenKeys,
|
|
109
|
+
activeViolations: activeCount,
|
|
110
|
+
suppressed,
|
|
111
|
+
totalViolations,
|
|
112
|
+
});
|
|
113
|
+
const writePath = adoption.writePath;
|
|
114
|
+
const packageVersionTruth = describePackageVersionDualTruth(root);
|
|
115
|
+
const hardWriteActive = writePath?.enforcementState?.localWrite?.hard === true;
|
|
116
|
+
const packageInstalled = writePath?.enforcementState?.localWrite?.installed === true;
|
|
117
|
+
const writePathHonesty = buildWritePathHonesty(writePath?.activeHost, hardWriteActive, {
|
|
118
|
+
packageInstalled,
|
|
119
|
+
packagePinCode: packageVersionTruth?.code,
|
|
120
|
+
packagePinAbsent: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT',
|
|
121
|
+
selfHost:
|
|
122
|
+
packageVersionTruth?.selfHost === true ||
|
|
123
|
+
packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
|
|
124
|
+
});
|
|
125
|
+
const rulesUnderContract = summarizeRulesUnderContract(root, config, undefined, {
|
|
126
|
+
governedPercent: coverage?.governed?.percent ?? null,
|
|
127
|
+
populatedLayerCount: Array.isArray(coverage?.layers)
|
|
128
|
+
? coverage.layers.filter((row) => (row?.files ?? 0) > 0).length
|
|
129
|
+
: null,
|
|
130
|
+
classifiedFiles: coverage?.governed?.classifiedFiles ?? null,
|
|
131
|
+
});
|
|
132
|
+
// Single residual expression (parity with doctor): nextPilot || extractionCard.
|
|
133
|
+
const residualPilot =
|
|
134
|
+
pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
|
|
135
|
+
const dualTruthNext =
|
|
136
|
+
packageVersionTruth?.dualTruth === true
|
|
137
|
+
? `Bump package.json arkgate pin to ${packageVersionTruth.cliVersion || 'this CLI'} (or re-run install without --no-install)`
|
|
138
|
+
: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT'
|
|
139
|
+
? 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
|
|
140
|
+
: null;
|
|
141
|
+
const productHonesty = buildProductHonesty({
|
|
142
|
+
coverageHonesty,
|
|
143
|
+
baselineHonesty,
|
|
144
|
+
writePathHonesty,
|
|
145
|
+
designWeak: designFitness.designWeak === true,
|
|
146
|
+
designWeakLabel: designFitness.label,
|
|
147
|
+
designSmellCount: designSmells.length,
|
|
148
|
+
designSmellsWithOpenEdges: designSmells.length > 0 && activeBlockingCount > 0,
|
|
149
|
+
packageVersionTruth,
|
|
150
|
+
residualPilots: Boolean(residualPilot) && designFitness.designWeak === true,
|
|
151
|
+
pilotTarget: residualPilot?.pilotTarget ?? residualPilot?.pilot ?? null,
|
|
152
|
+
arkRulesMergeHonesty: rulesUnderContract?.mergePlanes
|
|
153
|
+
? { active: rulesUnderContract.active === true, ...rulesUnderContract.mergePlanes }
|
|
154
|
+
: null,
|
|
155
|
+
primaryNextAction: postGreenPath?.action ?? dualTruthNext,
|
|
156
|
+
activeBlockingViolations: activeBlockingCount,
|
|
157
|
+
});
|
|
48
158
|
return {
|
|
49
159
|
adoption,
|
|
50
160
|
designDepth: {
|
|
@@ -53,6 +163,9 @@ export function buildReportDepthPayload(root, config, files, coverage, activeVio
|
|
|
53
163
|
pilotLoop,
|
|
54
164
|
postGreenPath,
|
|
55
165
|
goldenPattern,
|
|
166
|
+
// P0-B / P1-M — folded into designDepth so --report stays a single payload.
|
|
167
|
+
productHonesty,
|
|
168
|
+
mergePlanes: rulesUnderContract?.mergePlanes ?? null,
|
|
56
169
|
},
|
|
57
170
|
};
|
|
58
171
|
}
|
|
@@ -167,6 +280,68 @@ function baselineLegendBody(signal) {
|
|
|
167
280
|
* mode?: string,
|
|
168
281
|
* }} depth
|
|
169
282
|
*/
|
|
283
|
+
/**
|
|
284
|
+
* P0-B — prominent anti-false-green honesty card (never a score).
|
|
285
|
+
* @param {object|null|undefined} productHonesty
|
|
286
|
+
* @param {object|null|undefined} [mergePlanes]
|
|
287
|
+
*/
|
|
288
|
+
export function renderProductHonestyCard(productHonesty, mergePlanes = null) {
|
|
289
|
+
if (!productHonesty || typeof productHonesty !== 'object') return '';
|
|
290
|
+
const unfinished = productHonesty.unfinished === true;
|
|
291
|
+
const envResiduals = Array.isArray(productHonesty.environmentResidualIds)
|
|
292
|
+
? productHonesty.environmentResidualIds
|
|
293
|
+
: [];
|
|
294
|
+
const envOnly = !unfinished && envResiduals.length > 0;
|
|
295
|
+
const headline = productHonesty.headline || (unfinished ? 'Not finished' : 'Honesty clear');
|
|
296
|
+
const primary = productHonesty.primaryMessage || '';
|
|
297
|
+
// Avoid repeating the same status label in title and body (past-issue pattern).
|
|
298
|
+
let body = primary;
|
|
299
|
+
if (primary && headline) {
|
|
300
|
+
const h = String(headline).trim();
|
|
301
|
+
const p = String(primary).trim();
|
|
302
|
+
if (p === h) {
|
|
303
|
+
body = unfinished
|
|
304
|
+
? 'Residual honesty signals remain — not a whole-tree guarantee and not a score.'
|
|
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.';
|
|
308
|
+
} else if (p.toLowerCase().startsWith(h.toLowerCase())) {
|
|
309
|
+
const stripped = p.slice(h.length).replace(/^[\s—–:-]+/, '').trim();
|
|
310
|
+
body = stripped || p;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const reasons = Array.isArray(productHonesty.reasonIds) ? productHonesty.reasonIds : [];
|
|
314
|
+
const reasonHtml =
|
|
315
|
+
reasons.length > 0
|
|
316
|
+
? `<p class="dim" style="margin:.35rem 0 0;font-size:.86rem">signals: ${reasons
|
|
317
|
+
.map((id) => `<code>${esc(id)}</code>`)
|
|
318
|
+
.join(' · ')} · <code>notAScore</code></p>`
|
|
319
|
+
: '';
|
|
320
|
+
const mergeHtml =
|
|
321
|
+
mergePlanes?.failMergeWhen
|
|
322
|
+
? `<p class="dim" style="margin:.35rem 0 0;font-size:.86rem">merge planes: ${esc(mergePlanes.failMergeWhen)}</p>`
|
|
323
|
+
: '';
|
|
324
|
+
const dual =
|
|
325
|
+
mergePlanes?.dualPlaneStamp
|
|
326
|
+
? `<p class="dim" style="margin:.25rem 0 0;font-size:.84rem">${esc(mergePlanes.dualPlaneStamp)}</p>`
|
|
327
|
+
: '';
|
|
328
|
+
const subtitle = unfinished
|
|
329
|
+
? 'architecture residual'
|
|
330
|
+
: envOnly
|
|
331
|
+
? 'environment residual (advisory write)'
|
|
332
|
+
: 'no residual honesty blockers';
|
|
333
|
+
return `<div class="section card design-strip ${unfinished ? 'is-weak' : 'is-clean'}" id="product-honesty" data-product-honesty="1">
|
|
334
|
+
<div class="design-head">
|
|
335
|
+
<span class="badge design" title="Product honesty — not a score">${esc(headline)}</span>
|
|
336
|
+
<span class="dim" style="font-size:.86rem">${esc(subtitle)}</span>
|
|
337
|
+
</div>
|
|
338
|
+
<p style="margin:.45rem 0 0">${esc(body)}</p>
|
|
339
|
+
${reasonHtml}
|
|
340
|
+
${mergeHtml}
|
|
341
|
+
${dual}
|
|
342
|
+
</div>`;
|
|
343
|
+
}
|
|
344
|
+
|
|
170
345
|
export function renderDesignDepthStrip(depth = {}) {
|
|
171
346
|
const fitness = depth.designFitness;
|
|
172
347
|
const smells = Array.isArray(depth.designSmells) ? depth.designSmells : [];
|
package/bin/lib/html-report.mjs
CHANGED
|
@@ -16,10 +16,14 @@ import {
|
|
|
16
16
|
renderBaselineSignalLegend,
|
|
17
17
|
renderDesignCleanNote,
|
|
18
18
|
renderDesignDepthStrip,
|
|
19
|
+
renderProductHonestyCard,
|
|
19
20
|
renderWritePathAdoptionBlock,
|
|
20
21
|
} from './html-report-depth.mjs';
|
|
21
22
|
import { FIX_HINTS } from './violations.mjs';
|
|
22
23
|
import { capabilityBadgesFor, renderAdvisorySections } from './html-report-advisories.mjs';
|
|
24
|
+
import { arkGitignoreAppendDecision } from './ark-gitignore.mjs';
|
|
25
|
+
|
|
26
|
+
export { arkGitignoreAppendDecision, gitignoreCoversArkState, gitignoreHasArkNegationException } from './ark-gitignore.mjs';
|
|
23
27
|
|
|
24
28
|
export function detectEnforcement(root) {
|
|
25
29
|
const has = (rel) => fs.existsSync(path.join(root, rel));
|
|
@@ -222,13 +226,13 @@ export function computeReportFitness({ coverage, violations, ok, enforcement, co
|
|
|
222
226
|
const row = (coverage?.layers ?? []).find((r) => r.name === layer.name);
|
|
223
227
|
return (row?.files ?? 0) > 0;
|
|
224
228
|
}).length;
|
|
229
|
+
// planMet: blocking (failsStrict !== false) only — type-only alone must not force ADAPT.
|
|
230
|
+
const blockingCount = Array.isArray(violations)
|
|
231
|
+
? violations.filter((v) => v?.failsStrict !== false).length
|
|
232
|
+
: 0;
|
|
225
233
|
const mode = resolveOperatingMode({
|
|
226
234
|
governedPercent: totalFiles === 0 ? 0 : governedPercent,
|
|
227
|
-
planMet:
|
|
228
|
-
ok &&
|
|
229
|
-
(violations?.length ?? 0) === 0 &&
|
|
230
|
-
totalFiles > 0 &&
|
|
231
|
-
(governedPercent == null || governedPercent >= 50),
|
|
235
|
+
planMet: ok && blockingCount === 0 && totalFiles > 0 && (governedPercent == null || governedPercent >= 50),
|
|
232
236
|
mature: totalFiles >= 150,
|
|
233
237
|
totalFiles,
|
|
234
238
|
emptyLayers,
|
|
@@ -335,20 +339,16 @@ export function archiveReportSnapshots(root, { html, snapshot, resetOrigin = fal
|
|
|
335
339
|
}
|
|
336
340
|
}
|
|
337
341
|
|
|
338
|
-
//
|
|
342
|
+
// EH03: cover .ark reports in .gitignore without defeating ! exceptions.
|
|
339
343
|
const gitignore = path.join(root, '.gitignore');
|
|
340
344
|
if (fs.existsSync(gitignore)) {
|
|
341
345
|
const text = fs.readFileSync(gitignore, 'utf8');
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
const t = line.trim();
|
|
345
|
-
return t === '.ark/' || t === '.ark' || t === '/.ark/' || t === '**/.ark/';
|
|
346
|
-
});
|
|
347
|
-
if (!hasArk) {
|
|
346
|
+
const decision = arkGitignoreAppendDecision(text);
|
|
347
|
+
if (decision.append && decision.rule) {
|
|
348
348
|
const suffix = text.endsWith('\n') || text.length === 0 ? '' : '\n';
|
|
349
349
|
fs.writeFileSync(
|
|
350
350
|
gitignore,
|
|
351
|
-
`${text}${suffix}\n# Ark generated reports / local state\n.
|
|
351
|
+
`${text}${suffix}\n# Ark generated reports / local state\n${decision.rule}\n`
|
|
352
352
|
);
|
|
353
353
|
}
|
|
354
354
|
}
|
|
@@ -531,6 +531,11 @@ export function renderHtmlReport({
|
|
|
531
531
|
ok,
|
|
532
532
|
mode,
|
|
533
533
|
});
|
|
534
|
+
// P0-B product honesty card — prefer payload from buildReportDepthPayload when present.
|
|
535
|
+
const productHonestyHtml = renderProductHonestyCard(
|
|
536
|
+
depth.productHonesty ?? null,
|
|
537
|
+
depth.mergePlanes ?? null
|
|
538
|
+
);
|
|
534
539
|
const writePathHtml = renderWritePathAdoptionBlock(adoptionView.writePath);
|
|
535
540
|
const baselineLegendHtml = renderBaselineSignalLegend();
|
|
536
541
|
|
|
@@ -1111,6 +1116,7 @@ export function renderHtmlReport({
|
|
|
1111
1116
|
</div>
|
|
1112
1117
|
|
|
1113
1118
|
${designStripHtml}
|
|
1119
|
+
${productHonestyHtml}
|
|
1114
1120
|
|
|
1115
1121
|
<div class="section card" id="adoption">
|
|
1116
1122
|
<h2>Adoption</h2>
|