arkgate 4.1.0 → 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 +46 -2
- package/README.md +12 -13
- package/bin/lib/ark-gitignore.mjs +88 -0
- package/bin/lib/ci-and-commands.mjs +17 -1
- package/bin/lib/doctor-plan.mjs +11 -3
- package/bin/lib/enforcement-honesty.mjs +78 -22
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/github-enforcement.mjs +152 -4
- package/bin/lib/host-support-matrix.mjs +91 -17
- package/bin/lib/html-report-depth.mjs +13 -2
- package/bin/lib/html-report.mjs +7 -8
- package/bin/lib/write-path-detect.mjs +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/docs/README.md +4 -4
- package/docs/agent-guide.md +5 -3
- package/docs/ai-gates.md +30 -7
- package/docs/develop.md +16 -6
- package/docs/package-surface.md +5 -3
- package/docs/product-voice.md +9 -3
- package/docs/use.md +3 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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
|
}
|
|
@@ -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}
|
package/bin/lib/html-report.mjs
CHANGED
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
} from './html-report-depth.mjs';
|
|
22
22
|
import { FIX_HINTS } from './violations.mjs';
|
|
23
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';
|
|
24
27
|
|
|
25
28
|
export function detectEnforcement(root) {
|
|
26
29
|
const has = (rel) => fs.existsSync(path.join(root, rel));
|
|
@@ -336,20 +339,16 @@ export function archiveReportSnapshots(root, { html, snapshot, resetOrigin = fal
|
|
|
336
339
|
}
|
|
337
340
|
}
|
|
338
341
|
|
|
339
|
-
//
|
|
342
|
+
// EH03: cover .ark reports in .gitignore without defeating ! exceptions.
|
|
340
343
|
const gitignore = path.join(root, '.gitignore');
|
|
341
344
|
if (fs.existsSync(gitignore)) {
|
|
342
345
|
const text = fs.readFileSync(gitignore, 'utf8');
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
const t = line.trim();
|
|
346
|
-
return t === '.ark/' || t === '.ark' || t === '/.ark/' || t === '**/.ark/';
|
|
347
|
-
});
|
|
348
|
-
if (!hasArk) {
|
|
346
|
+
const decision = arkGitignoreAppendDecision(text);
|
|
347
|
+
if (decision.append && decision.rule) {
|
|
349
348
|
const suffix = text.endsWith('\n') || text.length === 0 ? '' : '\n';
|
|
350
349
|
fs.writeFileSync(
|
|
351
350
|
gitignore,
|
|
352
|
-
`${text}${suffix}\n# Ark generated reports / local state\n.
|
|
351
|
+
`${text}${suffix}\n# Ark generated reports / local state\n${decision.rule}\n`
|
|
353
352
|
);
|
|
354
353
|
}
|
|
355
354
|
}
|
|
@@ -117,7 +117,7 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
|
|
|
117
117
|
: activeHost === 'opencode'
|
|
118
118
|
? 'OpenCode local write is advisory (MCP + optional experimental plugin — not a hard boundary; ' +
|
|
119
119
|
'not equivalent to Claude/Grok/Antigravity PreToolUse hard-write). ' +
|
|
120
|
-
'The hard merge backstop is
|
|
120
|
+
'The hard merge backstop is a required GitHub status context running --strict-merge.'
|
|
121
121
|
: `Active host ${activeHost} has advisory prepare-write/autoPatch tools, ` +
|
|
122
122
|
'but no hard write boundary; CI can report failure, while merge blocking requires provider policy.';
|
|
123
123
|
gap = {
|
|
@@ -127,7 +127,7 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
|
|
|
127
127
|
message: honesty,
|
|
128
128
|
fix:
|
|
129
129
|
activeHost === 'codex' || activeHost === 'opencode'
|
|
130
|
-
? 'Keep
|
|
130
|
+
? 'Keep a required GitHub status context on arkgate-check --strict-merge (alias ark-check); ' +
|
|
131
131
|
`refresh ${activeHost} MCP/skills with ${arkCommand(root, 'ark-check', `--install-agent-gates --tools ${activeHost}`)}`
|
|
132
132
|
: arkCommand(root, 'ark-check', `--install-agent-gates --tools ${tools}`),
|
|
133
133
|
};
|