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.
Files changed (59) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +13 -13
  3. package/bin/ark-check-runtime.mjs +244 -25
  4. package/bin/ark-check.mjs +10 -1
  5. package/bin/ark-layer-match.mjs +80 -5
  6. package/bin/ark-shared.mjs +170 -9
  7. package/bin/ark.mjs +52 -5
  8. package/bin/lib/adapter-contract.mjs +7 -1
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/ark-gitignore.mjs +88 -0
  12. package/bin/lib/arkrules-sensors.mjs +63 -22
  13. package/bin/lib/ci-and-commands.mjs +165 -9
  14. package/bin/lib/core-ratchet.mjs +9 -4
  15. package/bin/lib/doctor-advisories.mjs +8 -1
  16. package/bin/lib/doctor-plan.mjs +287 -61
  17. package/bin/lib/enforcement-honesty.mjs +408 -27
  18. package/bin/lib/enforcement-state.mjs +1 -1
  19. package/bin/lib/field-install.mjs +35 -2
  20. package/bin/lib/github-enforcement.mjs +152 -4
  21. package/bin/lib/host-support-matrix.mjs +91 -17
  22. package/bin/lib/html-report-depth.mjs +178 -3
  23. package/bin/lib/html-report.mjs +19 -13
  24. package/bin/lib/install-migrate.mjs +109 -6
  25. package/bin/lib/managed-upgrade.mjs +99 -0
  26. package/bin/lib/presets.mjs +314 -46
  27. package/bin/lib/project-root.mjs +268 -0
  28. package/bin/lib/remediation.mjs +12 -11
  29. package/bin/lib/rules-inventory.mjs +71 -29
  30. package/bin/lib/rules-under-contract.mjs +134 -4
  31. package/bin/lib/start-preview.mjs +48 -14
  32. package/bin/lib/suggestions.mjs +118 -3
  33. package/bin/lib/unavailable-analysis.mjs +2 -0
  34. package/bin/lib/write-path-capabilities.mjs +38 -9
  35. package/bin/lib/write-path-detect.mjs +2 -2
  36. package/dist/eslint/index.cjs +2 -2
  37. package/dist/eslint/index.d.ts +27 -2
  38. package/dist/eslint/index.js +2 -2
  39. package/dist/index.cjs +16 -14
  40. package/dist/index.d.ts +3 -1
  41. package/dist/index.js +16 -14
  42. package/docs/README.md +5 -5
  43. package/docs/agent-guide.md +5 -3
  44. package/docs/ai-gates.md +45 -18
  45. package/docs/brownfield-adoption.md +36 -0
  46. package/docs/configuration.md +36 -0
  47. package/docs/develop.md +16 -6
  48. package/docs/package-surface.md +5 -3
  49. package/docs/product-voice.md +15 -2
  50. package/docs/typescript-support.md +9 -5
  51. package/docs/use.md +3 -1
  52. package/package.json +3 -1
  53. package/server.json +3 -3
  54. package/templates/architecture-playbook.json +3 -0
  55. package/templates/layers/shared-types.starter.json +29 -0
  56. package/templates/skills/ark-adopt.md +2 -0
  57. package/templates/skills/ark-explain.md +5 -0
  58. package/templates/skills/ark-explore.md +21 -1
  59. 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: available ? 'ok' : 'provider-enforcement-unverified',
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
- raw: { classic, rules, ...(error ? { error } : {}) },
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
- function hostProfile(label, hookPath, hookSurface, hookOperations, hardWrite, repairPayload) {
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
- const repair = capabilities['repair-payload'] ? 'repair payload' : 'no hard-boundary repair';
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
- const repair = capabilities['repair-payload']
108
- ? 'Emitted on hook deny; host must re-inject'
109
- : 'No hard-boundary payload';
110
- const merge = capabilities['hard-write']
111
- ? '**Required status** = hard merge boundary (`arkgate-check --strict-merge`)'
112
- : '**Required status** = hard merge boundary (same CI)';
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
- merge check — not “CI file present.” Cursor/Codex/OpenCode never get a fake hard write claim.
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
- 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 payloads never write code silently: the host must re-inject the candidate and ArkGate revalidates it. Run \`arkgate-check --doctor\` for the evidence actually detected in the current repository.`;
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 'Cursor: write path is advisory (MCP/rules; no hard PreToolUse). Required CI status (arkgate-check --strict-merge) is the hard merge boundary.';
207
+ return `Cursor: write path is advisory (MCP/rules; no hard PreToolUse). ${mergeBoundary}.`;
134
208
  }
135
209
  if (host === 'codex') {
136
- return 'Codex: write path is advisory / best-effort at write (not Claude/Grok hard). Required CI status (arkgate-check --strict-merge) is the hard merge boundary.';
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 'OpenCode: write path is advisory / best-effort (MCP + optional plugin; not Claude/Grok/Antigravity hard). Required CI status (arkgate-check --strict-merge) is the hard merge boundary.';
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. Required CI remains the merge hard boundary.`;
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(root, config, files, coverage, activeViolations = []) {
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
- activeViolations: activeViolations.length,
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 : [];
@@ -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
- // Ensure .ark/ is gitignored when a .gitignore exists.
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 hasArk =
343
- text.split('\n').some((line) => {
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.ark/\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>