arkgate 4.6.5 → 4.6.7

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 (51) hide show
  1. package/CHANGELOG.md +72 -2106
  2. package/README.md +11 -9
  3. package/bin/ark-check-runtime.mjs +36 -332
  4. package/bin/ark-mcp-runtime.mjs +7 -323
  5. package/bin/ark-shared.mjs +24 -158
  6. package/bin/ark.mjs +13 -3
  7. package/bin/lib/adoption-stance.mjs +104 -0
  8. package/bin/lib/check-args.mjs +173 -0
  9. package/bin/lib/check-config-detect.mjs +101 -0
  10. package/bin/lib/check-watch.mjs +80 -0
  11. package/bin/lib/ci-merge-boundary.mjs +4 -2
  12. package/bin/lib/deep-module-coach.mjs +3 -0
  13. package/bin/lib/design-delta.mjs +2 -2
  14. package/bin/lib/design-smells.mjs +1 -1
  15. package/bin/lib/diagnostic-catalog.mjs +1 -1
  16. package/bin/lib/doctor-advisories.mjs +2 -2
  17. package/bin/lib/doctor-human.mjs +509 -0
  18. package/bin/lib/doctor-next-actions.mjs +20 -2
  19. package/bin/lib/doctor-plan.mjs +86 -456
  20. package/bin/lib/enforcement-honesty.mjs +70 -0
  21. package/bin/lib/first-run-help.mjs +8 -7
  22. package/bin/lib/github-enforcement.mjs +22 -9
  23. package/bin/lib/html-report-advisories.mjs +10 -2
  24. package/bin/lib/html-report.mjs +26 -9
  25. package/bin/lib/mcp-adoption.mjs +19 -0
  26. package/bin/lib/mcp-hook-payload.mjs +328 -0
  27. package/bin/lib/package-manager.mjs +174 -0
  28. package/bin/lib/policy-delta-io.mjs +5 -1
  29. package/bin/lib/post-green-path.mjs +5 -1
  30. package/bin/lib/product-copy.mjs +6 -3
  31. package/bin/lib/start-preview.mjs +12 -22
  32. package/bin/lib/status-command.mjs +16 -0
  33. package/bin/lib/status-manifest.mjs +8 -2
  34. package/bin/lib/team-parliament-io.mjs +66 -2
  35. package/bin/lib/team-parliament.mjs +25 -5
  36. package/bin/lib/unavailable-analysis.mjs +1 -0
  37. package/dist/index.cjs +2 -2
  38. package/dist/index.d.ts +10 -2
  39. package/dist/index.js +2 -2
  40. package/docs/README.md +6 -10
  41. package/docs/ai-gates.md +12 -5
  42. package/docs/configuration.md +9 -1
  43. package/docs/diagnostics.md +2 -2
  44. package/docs/package-surface.md +6 -4
  45. package/docs/product-voice.md +6 -4
  46. package/docs/threat-model.md +2 -2
  47. package/docs/use.md +5 -4
  48. package/package.json +1 -1
  49. package/schemas/ark.design-delta.schema.json +1 -1
  50. package/server.json +2 -2
  51. package/templates/agent-skills/README.md +1 -1
@@ -10,6 +10,11 @@ import {
10
10
  HOST_SUPPORT_MATRIX,
11
11
  HOST_SUPPORT_HOSTS,
12
12
  } from './host-support-matrix.mjs';
13
+ import {
14
+ classifyAdopted,
15
+ MERGE_BOUNDARY_NOT_REQUIRED,
16
+ NOT_ADOPTED_NEXT_ACTION,
17
+ } from './adoption-stance.mjs';
13
18
 
14
19
  /** Soft = matrix hard-write false; hard-capable = matrix hard-write true. Single source of truth. */
15
20
  function hostWriteClassSets() {
@@ -380,6 +385,37 @@ export function buildProductHonesty(input = {}) {
380
385
  });
381
386
  }
382
387
 
388
+ const adopted =
389
+ typeof input.adopted === 'string'
390
+ ? input.adopted
391
+ : input.ciMergeBoundary || input.github || input.adoptionStance || input.considerMergeBoundary
392
+ ? classifyAdopted({
393
+ stance: input.adoptionStance,
394
+ github: input.github,
395
+ ci: input.ciMergeBoundary?.ci,
396
+ })
397
+ : null;
398
+ if (adopted === 'not-adopted') {
399
+ reasons.push({
400
+ id: MERGE_BOUNDARY_NOT_REQUIRED,
401
+ message:
402
+ 'Merge boundary not adopted — require a GitHub status running arkgate-check --strict-merge, or write .ark/adoption-stance.json with stance: "advisory-only".',
403
+ });
404
+ }
405
+
406
+ const emptyStewards =
407
+ input.emptyStewards === true ||
408
+ input.stewardNudge?.emptyStewardsPastGrace === true ||
409
+ (input.stewardNudge?.needsStewards === true &&
410
+ (input.stewardNudge?.stewardCount ?? 0) === 0);
411
+ if (emptyStewards) {
412
+ reasons.push({
413
+ id: 'empty-stewards',
414
+ message:
415
+ 'No stewards listed — not a finished Enforce. Name GitHub handles or emails for stewards[], or this stays Adapt-or-nudge. /ark-adopt asks; it does not invent names.',
416
+ });
417
+ }
418
+
383
419
  // Mode adapt/suggest (FG-FINISHED-ADAPT-DEBT): prefer unfinished unless the tree is
384
420
  // whole-tree green AND zero design smells AND zero blocking violations.
385
421
  // Type-only placement debt alone must not keep adapt unfinished via active-blocking.
@@ -442,6 +478,8 @@ export function buildProductHonesty(input = {}) {
442
478
  architectureReasons.find((r) => r.id === 'package-pin-absent') ||
443
479
  architectureReasons.find((r) => r.id === 'baseline-missing-with-debt') ||
444
480
  architectureReasons.find((r) => r.id === 'residual-pilot') ||
481
+ architectureReasons.find((r) => r.id === MERGE_BOUNDARY_NOT_REQUIRED) ||
482
+ architectureReasons.find((r) => r.id === 'empty-stewards') ||
445
483
  architectureReasons[0] ||
446
484
  environmentResiduals[0];
447
485
 
@@ -450,6 +488,9 @@ export function buildProductHonesty(input = {}) {
450
488
  primaryMessage =
451
489
  primary?.message ||
452
490
  'Not finished: residual honesty signals remain (violations, mode, coverage, freeze, design, package pin, or pilots).';
491
+ } else if (adopted === 'advisory-only-acked') {
492
+ primaryMessage =
493
+ 'This tree acked advisory-only in .ark/adoption-stance.json. That is not a required GitHub merge status.';
453
494
  } else if (softWriteOnly) {
454
495
  primaryMessage = `${hostLabel} local writes stay advisory/bypassable; architecture contract on this slice is ready. Hard merge boundary is a required GitHub status context running arkgate-check --strict-merge (alias ark-check --strict-merge).`;
455
496
  } else if (wholeTreeGoverned) {
@@ -468,6 +509,8 @@ export function buildProductHonesty(input = {}) {
468
509
  headline = wholeTreeGoverned
469
510
  ? `Architecture contract ready; ${hostLabel} local writes are advisory`
470
511
  : `Contract residual clear; ${hostLabel} local writes are advisory`;
512
+ } else if (!unfinished && adopted === 'advisory-only-acked') {
513
+ headline = 'Advisory-only adoption — merge status is not required';
471
514
  } else if (!unfinished) {
472
515
  headline = 'Honesty clear on residual signals';
473
516
  } else if (coverageIncomplete) {
@@ -487,6 +530,12 @@ export function buildProductHonesty(input = {}) {
487
530
  } else if (!primaryNextAction && pinAbsent) {
488
531
  primaryNextAction =
489
532
  'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)';
533
+ } else if (!primaryNextAction && adopted === 'not-adopted') {
534
+ primaryNextAction = NOT_ADOPTED_NEXT_ACTION;
535
+ } else if (!primaryNextAction && emptyStewards) {
536
+ primaryNextAction =
537
+ input.stewardNudge?.nextAction ||
538
+ '/ark-adopt (ask, then update stewards[] — do not invent names)';
490
539
  } else if (!primaryNextAction && softWriteOnly) {
491
540
  primaryNextAction =
492
541
  'Confirm the GitHub required status context name runs arkgate-check --strict-merge (or ark-check --strict-merge). Soft-write hosts stay advisory at local write; the required status is the hard merge boundary.';
@@ -558,6 +607,12 @@ export function computeDoctorEnforcementHonesty({
558
607
  packageInstalled,
559
608
  selfHost,
560
609
  motherCli,
610
+ adopted: adoptedInput,
611
+ ciMergeBoundary,
612
+ github,
613
+ adoptionStance,
614
+ emptyStewards,
615
+ stewardNudge,
561
616
  } = {}) {
562
617
  const coverageHonesty = buildCoverageHonesty({
563
618
  percent: governedPercent,
@@ -583,6 +638,14 @@ export function computeDoctorEnforcementHonesty({
583
638
  const blockingForHonesty = Number.isFinite(Number(activeBlockingViolations))
584
639
  ? Math.max(0, Number(activeBlockingViolations))
585
640
  : Number(activeViolations) || 0;
641
+ const adopted =
642
+ typeof adoptedInput === 'string'
643
+ ? adoptedInput
644
+ : classifyAdopted({
645
+ stance: adoptionStance,
646
+ github,
647
+ ci: ciMergeBoundary?.ci,
648
+ });
586
649
  const productHonesty = buildProductHonesty({
587
650
  coverageHonesty,
588
651
  baselineHonesty,
@@ -598,6 +661,13 @@ export function computeDoctorEnforcementHonesty({
598
661
  primaryNextAction,
599
662
  operatingMode,
600
663
  activeBlockingViolations: blockingForHonesty,
664
+ adopted,
665
+ ciMergeBoundary,
666
+ github,
667
+ adoptionStance,
668
+ considerMergeBoundary: true,
669
+ emptyStewards,
670
+ stewardNudge,
601
671
  });
602
672
  return {
603
673
  coverageHonesty,
@@ -5,14 +5,11 @@
5
5
  export function setupUsage() {
6
6
  return `arkgate (alias ark) — One architecture config. One check. One coach.
7
7
 
8
- arkgate start preview what will change (no writes)
9
- arkgate start --apply write the compact contract + host router + CI
10
- arkgate-check --doctor status light + primary next action
8
+ arkgate start preview (no writes)
9
+ arkgate start --apply write host + CI setup
10
+ arkgate-check --doctor where you are + one next action
11
11
 
12
- Then session 0 in your agent: /ark-adopt
13
12
  Stuck? Run doctor. Do #1.
14
-
15
- More commands and flags: arkgate --help --all
16
13
  `;
17
14
  }
18
15
 
@@ -114,7 +111,7 @@ export function checkUsageAll() {
114
111
  'Usage: arkgate-check | ark-check (identical bins; product name ArkGate)',
115
112
  ' arkgate-check --version',
116
113
  ' arkgate-check --root <project> --config <ark.config.json> [--manifest <ark.manifest.json>] [--tsconfig <tsconfig.json>] [--strict-merge | --strict | --strict-config] [--policy-base <file> | --policy-base-ref <git-ref>] [--policy-ack <file>] [--fail-on-new-smells --base-ref <git-ref>] [--contract-diff] [--contract-session] [--changed] [--against <git-ref>] [--base <git-ref>] [--persona touch|contributor|agent|steward] [--author <id>] [--require-gates] [--require-write-hook <host>] [--json] [--baseline [file]] [--report [file.html]] [--no-cache]',
117
- ' ark-check --doctor [--json] [--resident] [--fail-on-new-smells --base-ref <git-ref>] read-only diagnosis; resident JSON falls back cold',
114
+ ' ark-check --doctor [--json] [--all] [--resident] [--fail-on-new-smells --base-ref <git-ref>] compact first screen; --all prints Details; resident JSON falls back cold',
118
115
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
119
116
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
120
117
  ' ark-check --rules-inventory [--json] brownfield rules inventory (AR13; deterministic candidates, not a score)',
@@ -183,6 +180,10 @@ export function checkUsageAll() {
183
180
  'transition. Weakening or judgment-required findings fail unless --policy-ack names',
184
181
  'every finding and is bound to both policy hashes. Use --policy-base/--policy-base-ref',
185
182
  'for an explicit comparison; ARK_POLICY_BASE_REF is the CI environment equivalent.',
183
+ 'The same merge profile blocks new UI business-rule files (domain-logic-in-ui) created',
184
+ 'versus that base; leftover design on existing files stays green. Missing base skips',
185
+ 'this check (does not exit 2). --fail-on-new-smells --base-ref remains the full',
186
+ 'new+worsened-on-touched-paths ratchet.',
186
187
  'Add --require-write-hook claude|grok|antigravity|cursor|codex to validate a hard local',
187
188
  'write boundary for that specific host. Codex covers complete local apply_patch only;',
188
189
  'hosted/specialized/direct-write paths and OpenCode remain CI-backed. Merge blocking requires',
@@ -3,6 +3,19 @@ import { spawnSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
6
+ /** Kill hung gh instead of stalling CI. */
7
+ export const SPAWN_TIMEOUT_MS = 8000;
8
+
9
+ function runGh(args, opts = {}) {
10
+ const { run, ...rest } = opts;
11
+ const spawn = typeof run === 'function' ? run : spawnSync;
12
+ return spawn('gh', args, {
13
+ encoding: 'utf8',
14
+ ...rest,
15
+ timeout: SPAWN_TIMEOUT_MS,
16
+ });
17
+ }
18
+
6
19
  const IF_LINE = /^[ \t]*(?:-\s+)?(?:"if"|'if'|if):\s*(.*?)\s*(?:#.*)?$/i;
7
20
  const CONTINUE_LINE = /^[ \t]*(?:-\s+)?(?:"continue-on-error"|'continue-on-error'|continue-on-error):\s*(.*?)\s*(?:#.*)?$/i;
8
21
  const SAFE_IF = /^(?:['"]?true['"]?|['"]?\$\{\{\s*(?:true|always\(\))\s*\}\}['"]?)$/i;
@@ -434,7 +447,7 @@ export function reportGithubCiRuntime(opts = {}) {
434
447
  const cwd = opts.cwd ?? process.cwd();
435
448
  const env = opts.env ?? process.env;
436
449
  const limit = Number.isFinite(Number(opts.limit)) ? Math.max(1, Number(opts.limit)) : 30;
437
- if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
450
+ if (runGh(['--version'], { env, run: opts.run }).status !== 0) {
438
451
  return { runtimeObserved: false, latestCiRun: null, reason: 'gh-cli-unavailable' };
439
452
  }
440
453
  const args = [
@@ -443,7 +456,7 @@ export function reportGithubCiRuntime(opts = {}) {
443
456
  '--json', 'name,conclusion,status,workflowName,displayTitle,event',
444
457
  ];
445
458
  if (opts.repo) args.push('--repo', opts.repo);
446
- const result = spawnSync('gh', args, { cwd, encoding: 'utf8', env });
459
+ const result = runGh(args, { cwd, env, run: opts.run });
447
460
  if (result.status !== 0) {
448
461
  const err = `${result.stderr || ''}${result.stdout || ''}`.slice(0, 400);
449
462
  return {
@@ -499,14 +512,14 @@ export function reportGithubCiRuntime(opts = {}) {
499
512
  export function reportGithubBranchProtection(opts = {}) {
500
513
  const cwd = opts.cwd ?? process.cwd();
501
514
  const env = opts.env ?? process.env;
502
- if (spawnSync('gh', ['--version'], { encoding: 'utf8', env }).status !== 0) {
515
+ if (runGh(['--version'], { env, run: opts.run }).status !== 0) {
503
516
  return { available: false, reason: 'gh-cli-unavailable', runtimeObserved: false, latestCiRun: null };
504
517
  }
505
518
  let repo = opts.repo;
506
519
  let branch = opts.branch;
507
520
  if (!repo || !branch) {
508
521
  const args = ['repo', 'view', ...(repo ? [repo] : []), '--json', 'nameWithOwner,defaultBranchRef'];
509
- const metadata = parseJson(spawnSync('gh', args, { cwd, encoding: 'utf8', env }));
522
+ const metadata = parseJson(runGh(args, { cwd, env, run: opts.run }));
510
523
  if (!metadata?.nameWithOwner || !metadata?.defaultBranchRef?.name) {
511
524
  return { available: false, reason: 'gh-repo-unavailable', runtimeObserved: false, latestCiRun: null };
512
525
  }
@@ -514,13 +527,13 @@ export function reportGithubBranchProtection(opts = {}) {
514
527
  branch ??= metadata.defaultBranchRef.name;
515
528
  }
516
529
 
517
- const classicResult = spawnSync('gh', [
530
+ const classicResult = runGh([
518
531
  'api', `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`, '--jq',
519
532
  '{strict: .required_status_checks.strict, contexts: .required_status_checks.contexts, checks: .required_status_checks.checks, enforcesAdmins: .enforce_admins.enabled}',
520
- ], { cwd, encoding: 'utf8', env });
521
- const rulesResult = spawnSync(
522
- 'gh', ['api', `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`],
523
- { cwd, encoding: 'utf8', env }
533
+ ], { cwd, env, run: opts.run });
534
+ const rulesResult = runGh(
535
+ ['api', `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`],
536
+ { cwd, env, run: opts.run }
524
537
  );
525
538
  const classic = parseJson(classicResult);
526
539
  const rules = parseJson(rulesResult);
@@ -313,17 +313,25 @@ function deepModuleCoachHtml(coach) {
313
313
 
314
314
  function stewardNudgeHtml(nudge) {
315
315
  if (!nudge || nudge.notAScore !== true) return '';
316
+ const unfinished = Boolean(
317
+ nudge.emptyStewardsPastGrace || (nudge.needsStewards && (nudge.stewardCount ?? 0) === 0)
318
+ );
316
319
  const ask =
317
- (nudge.needsStewards || nudge.drift) && typeof nudge.ask === 'string' && nudge.ask
320
+ (nudge.needsStewards || nudge.drift || nudge.emptyStewardsPastGrace) &&
321
+ typeof nudge.ask === 'string' &&
322
+ nudge.ask
318
323
  ? `<p>${esc(nudge.ask)}</p>`
319
324
  : '<p class="muted">No steward list gap (advisory).</p>';
320
325
  const next =
321
326
  typeof nudge.nextAction === 'string' && nudge.nextAction
322
327
  ? `<p class="muted">Next: ${esc(nudge.nextAction)}</p>`
323
328
  : '';
329
+ const qualifier = unfinished
330
+ ? '(unfinished residual — changes finished, not check valid)'
331
+ : '(advisory — never changes the check valid bit)';
324
332
  return `
325
333
  <section class="section card" data-advisory="stewardNudge">
326
- <h2>Stewards <span class="muted">(advisory — never changes the verdict)</span></h2>
334
+ <h2>Stewards <span class="muted">${qualifier}</span></h2>
327
335
  ${ask}
328
336
  ${next}
329
337
  <p class="muted">GitHub handle or email. Never invent names. Always <code>notAScore</code>.</p>
@@ -28,6 +28,9 @@ import { captureGitSnapshot } from './report-snapshot-context.mjs';
28
28
 
29
29
  export { arkGitignoreAppendDecision, gitignoreCoversArkState, gitignoreHasArkNegationException } from './ark-gitignore.mjs';
30
30
 
31
+ /** Cap the rendered violation list so showcase HTML cannot dump unbounded findings. */
32
+ export const HTML_REPORT_VIOLATION_LIST_CAP = 12;
33
+
31
34
  export function detectEnforcement(root) {
32
35
  const has = (rel) => fs.existsSync(path.join(root, rel));
33
36
  const fileIncludes = (rel, needle) => {
@@ -110,7 +113,7 @@ export function baselineSignalHint(signal) {
110
113
  export function modeBadgeHint(mode) {
111
114
  switch (String(mode || '').toLowerCase()) {
112
115
  case 'enforce':
113
- return 'Contract matches the tree: cores are required where populated, coverage is honest, gates can hold the line.';
116
+ return 'Contract matches the tree on checked import edges. Required GitHub status (or an advisory-only ack) is what adopts the merge boundary.';
114
117
  case 'adapt':
115
118
  return 'Contract is live but still aligning (optional cores with files, empty cores, or presentation-bag false green).';
116
119
  case 'suggest':
@@ -255,7 +258,7 @@ export function computeReportFitness({ coverage, violations, ok, enforcement, co
255
258
  const modeBlurb = {
256
259
  suggest: 'Starter shape — expand layers as the codebase grows.',
257
260
  adapt: 'Contract is live; raise governed coverage or match real folders.',
258
- enforce: 'Contract governs the tree. Gates can honestly hold the line.',
261
+ enforce: 'Contract matches the tree on checked import edges. Merge is adopted only with a required GitHub status or an advisory-only ack.',
259
262
  }[mode];
260
263
  const scoreCoverage = governedPercent == null ? 50 : governedPercent;
261
264
  const scoreClean =
@@ -392,9 +395,13 @@ export function renderBeginnerHtmlReport({ root, config, violations, ok, version
392
395
  })
393
396
  .join('\n');
394
397
 
398
+ const listedBeginner = violations.slice(0, HTML_REPORT_VIOLATION_LIST_CAP);
399
+ const hiddenBeginner = Math.max(0, violations.length - listedBeginner.length);
400
+ const beginnerRemainder = hiddenBeginner
401
+ ? `<div class="dim">+${hiddenBeginner} more (${violations.length} total)</div>`
402
+ : '';
395
403
  const violationRows = violations.length
396
- ? violations
397
- .slice(0, 12)
404
+ ? listedBeginner
398
405
  .map((v) => {
399
406
  const enriched = enrichViolationWithFixClass(v);
400
407
  return `<li><code>${esc(v.file)}:${v.line}</code> — ${esc(enriched.enthusiastHint ?? v.message)}</li>`;
@@ -436,6 +443,7 @@ export function renderBeginnerHtmlReport({ root, config, violations, ok, version
436
443
  </table>
437
444
  <h2>What to fix first</h2>
438
445
  <ul>${violationRows}</ul>
446
+ ${beginnerRemainder}
439
447
  <h2>Next steps</h2>
440
448
  <p><code>${arkCheckCommand(root)}</code></p>
441
449
  <p><code>${arkCommand(root, 'ark-check', '--recommend')}</code></p>
@@ -781,13 +789,22 @@ export function renderHtmlReport({
781
789
  })
782
790
  .join('\n');
783
791
 
784
- const byRule = new Map();
792
+ const listedViolations = violations.slice(0, HTML_REPORT_VIOLATION_LIST_CAP);
793
+ const hiddenViolationCount = Math.max(0, violations.length - listedViolations.length);
794
+ const ruleTotals = new Map();
785
795
  for (const v of violations) {
796
+ ruleTotals.set(v.ruleId, (ruleTotals.get(v.ruleId) || 0) + 1);
797
+ }
798
+ const byRule = new Map();
799
+ for (const v of listedViolations) {
786
800
  if (!byRule.has(v.ruleId)) byRule.set(v.ruleId, []);
787
801
  byRule.get(v.ruleId).push(v);
788
802
  }
789
- const violationBlocks = violations.length
790
- ? [...byRule.entries()]
803
+ const remainderNote = hiddenViolationCount
804
+ ? `<div class="dim">+${hiddenViolationCount} more (${violations.length} total)</div>`
805
+ : '';
806
+ const violationBlocks = listedViolations.length
807
+ ? `${[...byRule.entries()]
791
808
  .map(([ruleId, items]) => {
792
809
  const hint = FIX_HINTS[ruleId];
793
810
  const rows = items
@@ -803,12 +820,12 @@ export function renderHtmlReport({
803
820
  })
804
821
  .join('\n');
805
822
  return `<div class="vgroup">
806
- <div class="vghead"><span class="rule">${esc(ruleId)}</span> <span class="dim">${items.length}</span></div>
823
+ <div class="vghead"><span class="rule">${esc(ruleId)}</span> <span class="dim">${ruleTotals.get(ruleId) ?? items.length}</span></div>
807
824
  <ul class="vitems">${rows}</ul>
808
825
  ${hint ? `<div class="fix">fix: ${esc(hint)}</div>` : ''}
809
826
  </div>`;
810
827
  })
811
- .join('\n')
828
+ .join('\n')}${remainderNote}`
812
829
  : `<div class="clean hero-clean">
813
830
  <div class="clean-title">Architecture matches the contract</div>
814
831
  <div class="clean-body">No active violations${suppressed ? ` · ${suppressed} frozen by baseline` : ''}. This is what “honest green” looks like when coverage is real.</div>
@@ -17,6 +17,11 @@ import { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
17
17
  import { detectDeployPathQuality } from './deploy-path.mjs';
18
18
  import { collectWeakestLinkGaps } from './weakest-link.mjs';
19
19
  import { codexRuntimeActivation, withCiProviderEvidence } from './enforcement-state.mjs';
20
+ import {
21
+ classifyAdopted,
22
+ readAdoptionStance,
23
+ NOT_ADOPTED_NEXT_ACTION,
24
+ } from './adoption-stance.mjs';
20
25
 
21
26
  export { detectDeployPathQuality };
22
27
 
@@ -528,6 +533,20 @@ export function collectAdoptionGaps(root, config, coverage) {
528
533
  gaps.push(g);
529
534
  }
530
535
 
536
+ const adoptedKind = classifyAdopted({
537
+ stance: readAdoptionStance(root),
538
+ github: weakest.github,
539
+ });
540
+ if (adoptedKind === 'not-adopted') {
541
+ gaps.push({
542
+ id: 'adoption-stance-missing',
543
+ severity: 'warn',
544
+ message:
545
+ 'Merge boundary not adopted: require a GitHub status on arkgate-check --strict-merge, or write .ark/adoption-stance.json with stance: "advisory-only".',
546
+ fix: NOT_ADOPTED_NEXT_ACTION,
547
+ });
548
+ }
549
+
531
550
  return {
532
551
  gaps,
533
552
  hosts,