arkgate 4.8.14 → 4.8.15

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 (97) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +13 -5
  3. package/bin/ark-check-runtime.mjs +17 -49
  4. package/bin/ark-mcp-runtime.mjs +111 -2
  5. package/bin/ark-shared.mjs +140 -11
  6. package/bin/ark.mjs +7 -25
  7. package/bin/lib/adr-presence.mjs +97 -0
  8. package/bin/lib/agent-skills-package.mjs +179 -1
  9. package/bin/lib/analysis-completeness.mjs +38 -2
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/architecture-scan.mjs +26 -4
  12. package/bin/lib/ark-run-doctor.mjs +6 -0
  13. package/bin/lib/ark-run-report.mjs +6 -2
  14. package/bin/lib/arkrules-sensors.mjs +81 -0
  15. package/bin/lib/baseline-key.mjs +4 -1
  16. package/bin/lib/check-args.mjs +52 -2
  17. package/bin/lib/config-contract.mjs +64 -1
  18. package/bin/lib/diagnostic-catalog.mjs +3 -1
  19. package/bin/lib/doctor-advisories.mjs +94 -10
  20. package/bin/lib/doctor-human.mjs +18 -5
  21. package/bin/lib/doctor-next-actions.mjs +14 -0
  22. package/bin/lib/doctor-plan.mjs +32 -2
  23. package/bin/lib/enforcement-honesty.mjs +47 -2
  24. package/bin/lib/first-run-help.mjs +8 -1
  25. package/bin/lib/host-support-matrix.mjs +9 -1
  26. package/bin/lib/html-report-depth.mjs +2 -0
  27. package/bin/lib/html-report.mjs +13 -2
  28. package/bin/lib/import-resolve.mjs +74 -13
  29. package/bin/lib/improvement-compass-doctor.mjs +6 -1
  30. package/bin/lib/improvement-compass-map.mjs +3 -1
  31. package/bin/lib/layer-description.mjs +90 -8
  32. package/bin/lib/mcp-hook-payload.mjs +56 -0
  33. package/bin/lib/no-domain-frontend.mjs +91 -0
  34. package/bin/lib/presets.mjs +3 -4
  35. package/bin/lib/product-copy.mjs +15 -0
  36. package/bin/lib/projected-governed-coverage.mjs +114 -0
  37. package/bin/lib/recommend-cli.mjs +54 -0
  38. package/bin/lib/remediation.mjs +4 -0
  39. package/bin/lib/resolved-candidate-facts.mjs +83 -66
  40. package/bin/lib/rules-under-contract.mjs +117 -2
  41. package/bin/lib/snippet-analysis.mjs +14 -8
  42. package/bin/lib/start-preview.mjs +5 -2
  43. package/bin/lib/states-transitions-presence.mjs +212 -0
  44. package/bin/lib/status-command.mjs +2 -0
  45. package/bin/lib/status-transition-catalog.mjs +410 -0
  46. package/bin/lib/team-parliament-io.mjs +10 -0
  47. package/bin/lib/violations.mjs +13 -1
  48. package/bin/lib/write-path-capabilities.mjs +20 -5
  49. package/bin/lib/write-path-detect.mjs +27 -2
  50. package/dist/{configTypes-j7so8B4O.d.ts → configTypes-Dt3DpVbd.d.ts} +19 -0
  51. package/dist/{diagnosticCatalog-DVx_2RmF.d.ts → diagnosticCatalog-BEg85XlE.d.ts} +3 -3
  52. package/dist/eslint/index.cjs +4 -4
  53. package/dist/eslint/index.d.ts +1 -1
  54. package/dist/eslint/index.js +4 -4
  55. package/dist/index.cjs +31 -31
  56. package/dist/index.d.ts +70 -9
  57. package/dist/index.js +31 -31
  58. package/dist/nestjs/index.cjs +5 -5
  59. package/dist/nestjs/index.d.ts +3 -3
  60. package/dist/nestjs/index.js +5 -5
  61. package/dist/runtime/index.cjs +15 -15
  62. package/dist/runtime/index.d.ts +6 -6
  63. package/dist/runtime/index.js +15 -15
  64. package/dist/{types-Djbs3KjE.d.ts → types-CN9tVMPz.d.ts} +3 -1
  65. package/dist/{types-tGhZUiGX.d.ts → types-TBiv0WHL.d.ts} +1 -1
  66. package/docs/README.md +1 -1
  67. package/docs/agent-guide.md +6 -4
  68. package/docs/ai-gates.md +10 -3
  69. package/docs/brownfield-adoption.md +4 -1
  70. package/docs/configuration.md +55 -7
  71. package/docs/develop.md +1 -0
  72. package/docs/diagnostics.md +23 -3
  73. package/docs/enthusiast/how-to-agent-gates.md +5 -0
  74. package/docs/enthusiast/how-to-pick-shape.md +1 -1
  75. package/docs/package-surface.md +7 -5
  76. package/docs/use.md +5 -0
  77. package/package.json +1 -1
  78. package/schemas/ark.config.schema.json +23 -2
  79. package/server.json +2 -2
  80. package/templates/agent-skills/README.md +1 -1
  81. package/templates/agent-skills/ark-adopt/SKILL.md +82 -1
  82. package/templates/agent-skills/ark-autopilot/SKILL.md +6 -2
  83. package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
  84. package/templates/agent-skills/ark-explain/SKILL.md +4 -2
  85. package/templates/agent-skills/ark-explore/SKILL.md +14 -1
  86. package/templates/agent-skills/ark-place/SKILL.md +38 -3
  87. package/templates/agent-skills/ark-runtime/SKILL.md +4 -2
  88. package/templates/agent-skills/ark-upgrade/SKILL.md +1 -1
  89. package/templates/arkrules/DomainModel.json +14 -1
  90. package/templates/skills/ark-adopt.md +82 -1
  91. package/templates/skills/ark-autopilot.md +6 -2
  92. package/templates/skills/ark-coverage.md +1 -1
  93. package/templates/skills/ark-explain.md +4 -2
  94. package/templates/skills/ark-explore.md +14 -1
  95. package/templates/skills/ark-place.md +38 -3
  96. package/templates/skills/ark-runtime.md +4 -2
  97. package/templates/skills/ark-upgrade.md +1 -1
@@ -34,6 +34,9 @@ export function collectDoctorNextActions(ctx) {
34
34
  ) {
35
35
  actions.push(nudge.nextAction);
36
36
  }
37
+ if (ctx.layerOwners?.required && ctx.layerOwners.nextAction) {
38
+ actions.push(ctx.layerOwners.nextAction);
39
+ }
37
40
  const enforceEmptyPlan =
38
41
  ctx.operatingMode === 'enforce' && planAEmpty && gatesInstalled && !notAdopted;
39
42
  if (enforceEmptyPlan) {
@@ -42,6 +45,17 @@ export function collectDoctorNextActions(ctx) {
42
45
  '/ark-explore, then one small refactor with /ark-autopilot and your OK'
43
46
  );
44
47
  }
48
+ if (ctx.adrPresence?.missing && ctx.adrPresence.nextAction) {
49
+ actions.push(ctx.adrPresence.nextAction);
50
+ }
51
+ if (ctx.statusTransitionCatalog?.nextAction) {
52
+ actions.push(ctx.statusTransitionCatalog.nextAction);
53
+ } else if (ctx.statesTransitions?.nextAction) {
54
+ actions.push(ctx.statesTransitions.nextAction);
55
+ }
56
+ if (ctx.noDomainFrontend?.nextAction) {
57
+ actions.push(ctx.noDomainFrontend.nextAction);
58
+ }
45
59
  if (!ctx.analysisComplete) actions.push('restore complete analysis, then rerun ark-check --doctor');
46
60
  if (ctx.designSmells.length > 0 && ctx.postGreenPath) actions.push(ctx.postGreenPath.action);
47
61
  if (ctx.coverageHonesty.greenIsNotEnforcement && ctx.coverageHonesty.worseThanNoGate) {
@@ -15,7 +15,12 @@ import { describePackageVersionDualTruth } from './field-install.mjs';
15
15
  import { detectAgentHomeGaps } from './agent-homes.mjs';
16
16
  import { collectDoctorNextActions } from './doctor-next-actions.mjs';
17
17
  import { printDoctorCompactHuman, printDoctorDetailsHuman } from './doctor-human.mjs';
18
- import { placementDescriptionFields } from './layer-description.mjs';
18
+ import { collectLayerOwnerResidual, layerGuidanceLine, placementDescriptionFields } from './layer-description.mjs';
19
+ import { collectAdrPresenceResidual, printAdrPresenceHint } from './adr-presence.mjs';
20
+ import { collectStatesTransitionsResidual } from './states-transitions-presence.mjs';
21
+ import { collectStatusTransitionCatalogResidual } from './status-transition-catalog.mjs';
22
+ import { collectNoDomainFrontendResidual } from './no-domain-frontend.mjs';
23
+ export { printAdrPresenceHint };
19
24
  export { printDoctorCompactHuman, printDoctorDetailsHuman };
20
25
  export { summarizeRulesUnderContract };
21
26
 
@@ -173,7 +178,8 @@ export function runCoverage(root, config, files, rules, asJson) {
173
178
  console.log(` ${pad('Layer')} Files`);
174
179
  for (const row of layerRows) {
175
180
  const flag = row.files === 0 ? ' (pattern matches nothing)' : '';
176
- const caption = row.description ? ` ${row.description}` : '';
181
+ const guidance = layerGuidanceLine(row);
182
+ const caption = guidance ? ` ${guidance}` : '';
177
183
  console.log(` ${pad(row.name)} ${String(row.files).padStart(5)}${flag}${caption}`);
178
184
  }
179
185
  console.log(` ${pad('(unclassified)')} ${String(unclassified.length).padStart(5)}`);
@@ -616,6 +622,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
616
622
  options.facts ?? options.architectureFacts,
617
623
  activeViolations
618
624
  );
625
+ const layerOwners = collectLayerOwnerResidual(config);
619
626
  const rulesUnderContract = doctorAdvisories.rulesUnderContract;
620
627
  const arkRun = doctorAdvisories.arkRun;
621
628
  // Single residual expression (nextPilot || extractionCard) — HTML report uses the same.
@@ -636,6 +643,13 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
636
643
  github: githubForBoundary,
637
644
  ci: ciMergeBoundary?.ci,
638
645
  });
646
+ const adrPresence = collectAdrPresenceResidual({
647
+ root,
648
+ demanded: options.requireGates === true || adopted === 'required-merge',
649
+ });
650
+ const statesTransitions = collectStatesTransitionsResidual({ root });
651
+ const statusTransitionCatalog = collectStatusTransitionCatalogResidual({ root, config, files, statesTransitions });
652
+ const noDomainFrontend = collectNoDomainFrontendResidual({ config, coverage: cov, designSmells });
639
653
  const { coverageHonesty, baselineHonesty, writePathHonesty, productHonesty } =
640
654
  computeDoctorEnforcementHonesty({
641
655
  governedPercent: cov.governed.percent,
@@ -671,6 +685,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
671
685
  selfHost:
672
686
  packageVersionTruth?.selfHost === true ||
673
687
  packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
688
+ nativeFailClosed: writePath.nativeFailClosed,
689
+ nativeFailClosedPolicy: writePath.nativeFailClosedPolicy,
674
690
  adopted,
675
691
  ciMergeBoundary,
676
692
  github: githubForBoundary,
@@ -763,6 +779,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
763
779
  governed: cov.governed,
764
780
  coverageHonesty,
765
781
  layers: cov.layers,
782
+ ...(layerOwners ? { layerOwners } : {}),
783
+ ...(adrPresence ? { adrPresence } : {}),
784
+ ...(statesTransitions ? { statesTransitions } : {}),
785
+ ...(statusTransitionCatalog ? { statusTransitionCatalog } : {}), ...(noDomainFrontend ? { noDomainFrontend } : {}),
766
786
  emptyLayers: cov.emptyLayers,
767
787
  layersWithoutRules: cov.layersWithoutRules,
768
788
  ungovernedDirs: cov.suggestions.length,
@@ -827,6 +847,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
827
847
  mcpPresent: writePath.mcpPresent,
828
848
  evidence: writePath.evidence,
829
849
  honesty: writePathHonesty,
850
+ nativeFailClosed: writePath.nativeFailClosed ?? null,
851
+ nativeFailClosedPolicy: writePath.nativeFailClosedPolicy ?? 'unsupported',
830
852
  ...(writePath.sessionNote ? { sessionNote: writePath.sessionNote } : {}),
831
853
  ...(writePath.gap
832
854
  ? {
@@ -902,12 +924,20 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
902
924
  operatingMode,
903
925
  adopted,
904
926
  stewardNudge: doctorAdvisories.stewardNudge,
927
+ layerOwners,
928
+ adrPresence,
929
+ statesTransitions,
930
+ statusTransitionCatalog, noDomainFrontend,
905
931
  });
906
932
  const humanView = {
907
933
  root,
908
934
  analysisComplete,
909
935
  completeness,
910
936
  doctorAdvisories,
937
+ layerOwners,
938
+ adrPresence,
939
+ statesTransitions,
940
+ statusTransitionCatalog, noDomainFrontend,
911
941
  operatingMode,
912
942
  designFitness,
913
943
  adopted,
@@ -180,6 +180,8 @@ export function buildBaselineHonesty(input = {}) {
180
180
  * packagePinAbsent?: boolean,
181
181
  * selfHost?: boolean,
182
182
  * motherCli?: boolean,
183
+ * nativeFailClosed?: boolean | null,
184
+ * nativeFailClosedPolicy?: 'required' | 'unsupported' | 'none',
183
185
  * }} [extras]
184
186
  */
185
187
  export function buildWritePathHonesty(activeHost, hardWriteActive = false, extras = {}) {
@@ -202,7 +204,21 @@ export function buildWritePathHonesty(activeHost, hardWriteActive = false, extra
202
204
  const hardAllowed = packageInstalled && !pinAbsentForUser;
203
205
  const effectiveHard =
204
206
  Boolean(hardWriteActive) && hardCapable && !softWriteHost && hardAllowed;
205
- const message = doctorWritePathHonestyMessage(host, effectiveHard);
207
+ const nativeFailClosedPolicy =
208
+ extras.nativeFailClosedPolicy === 'required' ||
209
+ extras.nativeFailClosedPolicy === 'none' ||
210
+ extras.nativeFailClosedPolicy === 'unsupported'
211
+ ? extras.nativeFailClosedPolicy
212
+ : HOST_SUPPORT_MATRIX[host]?.nativeFailClosed ?? (hardCapable ? 'unsupported' : 'none');
213
+ const nativeFailClosed =
214
+ extras.nativeFailClosed === true ? true : extras.nativeFailClosed === false ? false : null;
215
+ const failOpen = nativeFailClosedPolicy === 'required' && nativeFailClosed === false;
216
+ const honestyMessage = doctorWritePathHonestyMessage(host, effectiveHard);
217
+ const failOpenMessage =
218
+ `${HOST_SUPPORT_MATRIX[host]?.label ?? 'This host'}: the write hook is fail-open. ` +
219
+ 'If the checker cannot run, the write still lands. Set failClosed: true on the write hook — ' +
220
+ 'same idea as a file permission: no checker, no write. Required CI is the shared merge line.';
221
+ const message = failOpen ? failOpenMessage : honestyMessage;
206
222
 
207
223
  /** @type {Record<string, unknown>} */
208
224
  const out = {
@@ -212,6 +228,8 @@ export function buildWritePathHonesty(activeHost, hardWriteActive = false, extra
212
228
  hardWriteSupported: hardCapable,
213
229
  hardWriteActive: effectiveHard,
214
230
  hardWriteUnverified: hardCapable && !effectiveHard,
231
+ nativeFailClosed,
232
+ nativeFailClosedPolicy,
215
233
  hardMergeBoundary:
216
234
  'required-github-status-context (CLI: arkgate-check --strict-merge / ark-check --strict-merge)',
217
235
  packageInstalled,
@@ -385,6 +403,16 @@ export function buildProductHonesty(input = {}) {
385
403
  });
386
404
  }
387
405
 
406
+ if (write?.nativeFailClosed === false && write?.nativeFailClosedPolicy === 'required') {
407
+ reasons.push({
408
+ id: 'native-fail-open',
409
+ bucket: 'environment',
410
+ message:
411
+ write.message ||
412
+ 'The write hook is fail-open. If the checker cannot run, the write still lands. Set failClosed: true on the write hook.',
413
+ });
414
+ }
415
+
388
416
  const adopted =
389
417
  typeof input.adopted === 'string'
390
418
  ? input.adopted
@@ -442,7 +470,7 @@ export function buildProductHonesty(input = {}) {
442
470
  }
443
471
 
444
472
  // EH05: environment residual deny-list (future reason ids stay architecture debt by default).
445
- const ENVIRONMENT_REASON_IDS = new Set(['soft-write-host']);
473
+ const ENVIRONMENT_REASON_IDS = new Set(['soft-write-host', 'native-fail-open']);
446
474
 
447
475
  const environmentResiduals = reasons.filter((r) => ENVIRONMENT_REASON_IDS.has(r.id));
448
476
  const architectureReasons = reasons.filter((r) => !ENVIRONMENT_REASON_IDS.has(r.id));
@@ -457,6 +485,8 @@ export function buildProductHonesty(input = {}) {
457
485
 
458
486
  const softWriteOnly =
459
487
  !unfinished && environmentResiduals.some((r) => r.id === 'soft-write-host');
488
+ const failOpenOnly =
489
+ !unfinished && environmentResiduals.some((r) => r.id === 'native-fail-open');
460
490
  const hostLabel = (() => {
461
491
  const h = typeof write?.activeHost === 'string' ? write.activeHost.trim().toLowerCase() : '';
462
492
  if (h === 'codex') return 'Codex';
@@ -493,6 +523,10 @@ export function buildProductHonesty(input = {}) {
493
523
  'This tree acked advisory-only in .ark/adoption-stance.json. That is not a required GitHub merge status.';
494
524
  } else if (softWriteOnly) {
495
525
  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).`;
526
+ } else if (failOpenOnly) {
527
+ primaryMessage =
528
+ write?.message ||
529
+ `${hostLabel} write hook is fail-open — if the checker cannot run, the write still lands. Set failClosed: true. Required CI is the shared merge line.`;
496
530
  } else if (wholeTreeGoverned) {
497
531
  primaryMessage =
498
532
  'No residual honesty blockers on this slice — still not a numeric architecture score; re-doctor after material change.';
@@ -509,6 +543,10 @@ export function buildProductHonesty(input = {}) {
509
543
  headline = wholeTreeGoverned
510
544
  ? `Architecture contract ready; ${hostLabel} local writes are advisory`
511
545
  : `Contract residual clear; ${hostLabel} local writes are advisory`;
546
+ } else if (!unfinished && failOpenOnly) {
547
+ headline = wholeTreeGoverned
548
+ ? `Architecture contract ready; ${hostLabel} write hook is fail-open`
549
+ : `Contract residual clear; ${hostLabel} write hook is fail-open`;
512
550
  } else if (!unfinished && adopted === 'advisory-only-acked') {
513
551
  headline = 'Advisory-only adoption — merge status is not required';
514
552
  } else if (!unfinished) {
@@ -536,6 +574,9 @@ export function buildProductHonesty(input = {}) {
536
574
  primaryNextAction =
537
575
  input.stewardNudge?.nextAction ||
538
576
  '/ark-adopt (ask, then update stewards[] — do not invent names)';
577
+ } else if (!primaryNextAction && failOpenOnly) {
578
+ primaryNextAction =
579
+ 'Set failClosed: true on the write hook, then re-run doctor. Required CI is the shared merge line.';
539
580
  } else if (!primaryNextAction && softWriteOnly) {
540
581
  primaryNextAction =
541
582
  '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.';
@@ -613,6 +654,8 @@ export function computeDoctorEnforcementHonesty({
613
654
  adoptionStance,
614
655
  emptyStewards,
615
656
  stewardNudge,
657
+ nativeFailClosed,
658
+ nativeFailClosedPolicy,
616
659
  } = {}) {
617
660
  const coverageHonesty = buildCoverageHonesty({
618
661
  percent: governedPercent,
@@ -632,6 +675,8 @@ export function computeDoctorEnforcementHonesty({
632
675
  packagePinAbsent: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT',
633
676
  selfHost,
634
677
  motherCli,
678
+ nativeFailClosed,
679
+ nativeFailClosedPolicy,
635
680
  });
636
681
  // Prefer explicit blocking count; fall back to activeViolations only when callers
637
682
  // already pass blocking-only totals (legacy tests). Type-only must not invent debt.
@@ -11,6 +11,7 @@ ${NORTH_STAR_ONE_LINE}
11
11
 
12
12
  arkgate start preview (no writes)
13
13
  arkgate start --apply write host + CI setup
14
+ (refuses weak coverage/shape; lock with --archetype/--preset/--force)
14
15
  arkgate-check --doctor status — one next step
15
16
 
16
17
  Stuck? Run status (--doctor). Do #1.
@@ -167,6 +168,7 @@ export function checkUsage() {
167
168
  NORTH_STAR_ONE_LINE,
168
169
  '',
169
170
  ' arkgate-check --doctor where you are: one status light, one next action',
171
+ ' arkgate-check --local --base <ref> optional local / multi-worktree cheap check',
170
172
  ' arkgate-check --changed --base <ref> local / pre-push: checks touched files only',
171
173
  ' arkgate-check --strict-merge CI / merge gate (required GitHub status)',
172
174
  ' arkgate-check --sensors which sensors can ever be enforced (does not run analysis)',
@@ -183,7 +185,7 @@ export function checkUsageAll() {
183
185
  '',
184
186
  'Usage: arkgate-check | ark-check (identical bins; product name ArkGate)',
185
187
  ' arkgate-check --version',
186
- ' 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]',
188
+ ' 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] [--local] [--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]',
187
189
  ' 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',
188
190
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
189
191
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
@@ -225,6 +227,9 @@ export function checkUsageAll() {
225
227
  '',
226
228
  'Team parliament: law files (ark.config / arkrules / .ark-baseline.json) cannot ship in',
227
229
  'the same diff as product source. --changed --base <ref> checks touched files only.',
230
+ '--local (or ARK_CHECK_LOCAL=1) is the same cheap path: it turns on --changed and',
231
+ 'stays per worktree root. It cannot combine with --strict-merge. Write hooks stay',
232
+ 'on the lexical snippet path; they do not run a full-tree check.',
228
233
  '--against <ref> ratchets new keys vs that ref\'s baseline. --contract-session is a',
229
234
  'steward law-only PR. Loosen / baseline-grow need stewards[] + --author when set.',
230
235
  '',
@@ -279,6 +284,8 @@ export function checkUsageAll() {
279
284
  '--require-gates implies --strict-config and fails when the Ark contract in AGENTS.md,',
280
285
  'the project-rooted Ark server in .mcp.json, or fail-closed CI is missing/invalid.',
281
286
  'Included but unclassified source files therefore stay red instead of false-green.',
287
+ 'If gates are required and there is no short decision note yet, it points at docs/adr/',
288
+ '(or docs/decisions/) — a hint, not a fail. Off without --require-gates.',
282
289
  '',
283
290
  '--install-agent-gates writes AGENTS.md, .mcp.json, and the CI workflow for every',
284
291
  'project, plus tool-specific templates. Known tools: claude, cursor, codex, grok, antigravity',
@@ -35,6 +35,11 @@ function hostProfile(label, hookPath, hookSurface, hookOperations, hardWrite, re
35
35
  'repair-envelope-emitted': repairEnvelopeEmitted,
36
36
  'repair-reinjection-guaranteed': repairReinjectionGuaranteed,
37
37
  }),
38
+ // Host-native "deny when the checker cannot run" flag (FS-permission analog).
39
+ // required = schema field must be true for hard-write evidence (Cursor failClosed).
40
+ // unsupported = host has no documented flag; hook-crash behavior is host-owned.
41
+ // none = advisory host; no local write block.
42
+ nativeFailClosed: extras.nativeFailClosed ?? (hardWrite ? 'unsupported' : 'none'),
38
43
  // EH07 minimum ops matrix (hard=false for soft hosts on every listed op).
39
44
  operationCoverage: Object.freeze(
40
45
  extras.operationCoverage ||
@@ -83,6 +88,7 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
83
88
  false,
84
89
  {
85
90
  repairEnvelopeEmitted: true,
91
+ nativeFailClosed: 'required',
86
92
  operationCoverage: {
87
93
  Write: true,
88
94
  StrReplace: true,
@@ -186,7 +192,9 @@ ${rows}
186
192
  **Read the CI column:** for every host, the repository-wide hard guarantee is a **required**
187
193
  GitHub **status context** that runs the CLI — not “CI file present,” and not the CLI binary name alone.
188
194
  Codex hard write covers only a complete local \`apply_patch\`; Cursor covers only listed
189
- \`preToolUse\` ops. In both cases the project hook must be installed + trusted, while shell/direct
195
+ \`preToolUse\` ops with \`failClosed: true\`. A Cursor hook without that flag is fail-open
196
+ (if the checker cannot run, the write still lands). In both cases the project hook must be
197
+ installed + trusted, while shell/direct
190
198
  filesystem writes, hosted or specialized opt-out paths, and human edits still rely on CI.
191
199
 
192
200
  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.`;
@@ -127,6 +127,8 @@ export function buildReportDepthPayload(
127
127
  selfHost:
128
128
  packageVersionTruth?.selfHost === true ||
129
129
  packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
130
+ nativeFailClosed: writePath?.nativeFailClosed,
131
+ nativeFailClosedPolicy: writePath?.nativeFailClosedPolicy,
130
132
  });
131
133
  const classification = {
132
134
  governedPercent: coverage?.governed?.percent ?? null,
@@ -25,7 +25,7 @@ import { capabilityBadgesFor, renderAdvisorySections } from './html-report-advis
25
25
  import { renderEvolutionSection } from './html-report-evolution.mjs';
26
26
  import { arkGitignoreAppendDecision } from './ark-gitignore.mjs';
27
27
  import { captureGitSnapshot } from './report-snapshot-context.mjs';
28
- import { layerDescriptionCaption } from './layer-description.mjs';
28
+ import { layerDescriptionCaption, layerGuidanceLine, layerOwners, layerTrustBoundary } from './layer-description.mjs';
29
29
 
30
30
  export { arkGitignoreAppendDecision, gitignoreCoversArkState, gitignoreHasArkNegationException } from './ark-gitignore.mjs';
31
31
 
@@ -423,8 +423,13 @@ export function renderBeginnerHtmlReport({ root, config, violations, ok, version
423
423
  const placementRows = layers
424
424
  .map((layer) => {
425
425
  const purpose = layerDescriptionCaption(layer) || 'See ark.config.json';
426
+ const guidance = layerGuidanceLine(layer);
427
+ const extra = guidance && guidance !== purpose ? guidance.slice(purpose.length).replace(/^ · /, '') : '';
428
+ const purposeCell = extra
429
+ ? `${esc(purpose)} <span class="tag">${esc(extra)}</span>`
430
+ : esc(purpose);
426
431
  const folders = (layer.patterns || []).join(', ') || '—';
427
- return `<tr><td><strong>${esc(layer.name)}</strong></td><td>${esc(purpose)}</td><td><code>${esc(folders)}</code></td></tr>`;
432
+ return `<tr><td><strong>${esc(layer.name)}</strong></td><td>${purposeCell}</td><td><code>${esc(folders)}</code></td></tr>`;
428
433
  })
429
434
  .join('\n');
430
435
 
@@ -776,6 +781,12 @@ export function renderHtmlReport({
776
781
  ? `<span class="tag">${layer.intentPrefixes.map(esc).join(' ')}</span>`
777
782
  : '',
778
783
  layer.optional ? '<span class="tag dim-tag">optional</span>' : '',
784
+ layerTrustBoundary(layer)
785
+ ? `<span class="tag">trust: ${esc(layerTrustBoundary(layer))}</span>`
786
+ : '',
787
+ layerOwners(layer)
788
+ ? `<span class="tag">owner: ${esc(layerOwners(layer).map((id) => (id.includes('@') ? id : `@${id}`)).join(', '))}</span>`
789
+ : '',
779
790
  ].join(' ');
780
791
  const example = exampleByLayer?.get?.(layer.name);
781
792
  const files = counts.get(layer.name) || 0;
@@ -4,7 +4,46 @@
4
4
  */
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { layerForFile } from '../ark-layer-match.mjs';
7
+ import {
8
+ layerForRelativePath,
9
+ matchingLayersForRelativePath,
10
+ } from '../ark-layer-match.mjs';
11
+
12
+ /**
13
+ * Same candidate list as ark-check `resolveSpecifier` (src/kernel/moduleGraph.ts).
14
+ * The write hook must feed `layerForRelativePath` a path with the extension
15
+ * ark-check already sees on disk — otherwise an explicit `money.ts` pattern
16
+ * loses to a broader `src/lib/**` bag.
17
+ */
18
+ const SPECIFIER_SUFFIXES = Object.freeze([
19
+ '',
20
+ '.ts',
21
+ '.tsx',
22
+ '.mts',
23
+ '.cts',
24
+ '/index.ts',
25
+ '/index.tsx',
26
+ ]);
27
+
28
+ const SOURCE_EXT = /\.(?:[cm]?[jt]sx?)$/i;
29
+
30
+ function posixRel(value) {
31
+ return String(value).split(/[/\\]/).join('/');
32
+ }
33
+
34
+ function specifierRelCandidates(rel) {
35
+ const base = posixRel(rel);
36
+ if (SOURCE_EXT.test(base)) return [base];
37
+ return SPECIFIER_SUFFIXES.map((suffix) => `${base}${suffix}`);
38
+ }
39
+
40
+ function isOnDiskFile(root, rel) {
41
+ try {
42
+ return fs.statSync(path.join(root, rel)).isFile();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
8
47
 
9
48
  /**
10
49
  * Read tsconfig path aliases via the TypeScript config parser (JSONC + extends).
@@ -86,16 +125,38 @@ function filePathToRel(filePath, root) {
86
125
  return relative.split(path.sep).join('/');
87
126
  }
88
127
 
128
+ /**
129
+ * Classify a specifier-relative path with the same specificity scorer as ark-check.
130
+ * Prefer an on-disk candidate; otherwise pick the candidate whose winning
131
+ * `layerForRelativePath` pattern scores highest (explicit file beats `src/lib/**`).
132
+ */
89
133
  function classifyProbe(root, rel, layers) {
90
- let probe = rel;
91
- try {
92
- if (fs.statSync(path.join(root, rel)).isDirectory()) probe = `${rel}/index.ts`;
93
- } catch {
94
- /* not on disk */
134
+ const candidates = specifierRelCandidates(rel);
135
+ const existing = candidates.find((candidate) => isOnDiskFile(root, candidate));
136
+ if (existing) {
137
+ return {
138
+ relPath: existing,
139
+ layer: layerForRelativePath(existing, layers),
140
+ onDisk: true,
141
+ };
95
142
  }
96
- return (
97
- layerForFile(root, probe, layers) || layerForFile(root, `${rel}/index.ts`, layers)
98
- );
143
+ let bestRel = posixRel(rel);
144
+ let bestLayer;
145
+ let bestScore = -1;
146
+ for (const candidate of candidates) {
147
+ const layer = layerForRelativePath(candidate, layers);
148
+ if (!layer) continue;
149
+ const hit = matchingLayersForRelativePath(candidate, layers).find(
150
+ (row) => row.layer === layer
151
+ );
152
+ const score = hit?.score ?? -1;
153
+ if (score > bestScore) {
154
+ bestScore = score;
155
+ bestLayer = layer;
156
+ bestRel = candidate;
157
+ }
158
+ }
159
+ return { relPath: bestRel, layer: bestLayer, onDisk: false };
99
160
  }
100
161
 
101
162
  /**
@@ -114,7 +175,7 @@ export function createImportTargetResolver(ts, root, config) {
114
175
  if (path.isAbsolute(specifierOrFilePath)) {
115
176
  const relPath = filePathToRel(specifierOrFilePath, root);
116
177
  if (!relPath) return undefined;
117
- return { relPath, layer: classifyProbe(root, relPath, layers) };
178
+ return classifyProbe(root, relPath, layers);
118
179
  }
119
180
 
120
181
  // Relative or path-alias import
@@ -130,7 +191,7 @@ export function createImportTargetResolver(ts, root, config) {
130
191
  tsAliases
131
192
  );
132
193
  if (!rel) return undefined;
133
- return { relPath: rel, layer: classifyProbe(root, rel, layers) };
194
+ return classifyProbe(root, rel, layers);
134
195
  }
135
196
 
136
197
  // Try as import alias / bare package first
@@ -141,13 +202,13 @@ export function createImportTargetResolver(ts, root, config) {
141
202
  tsAliases
142
203
  );
143
204
  if (asImport) {
144
- return { relPath: asImport, layer: classifyProbe(root, asImport, layers) };
205
+ return classifyProbe(root, asImport, layers);
145
206
  }
146
207
 
147
208
  // Repo-relative source file path (not an import specifier)
148
209
  const asFile = filePathToRel(specifierOrFilePath, root);
149
210
  if (asFile) {
150
- return { relPath: asFile, layer: classifyProbe(root, asFile, layers) };
211
+ return classifyProbe(root, asFile, layers);
151
212
  }
152
213
 
153
214
  return undefined;
@@ -42,7 +42,12 @@ export function buildDoctorImprovementCompass(input = {}) {
42
42
  if (id.includes('PEER_ISOLATION')) peerIsolationCount += 1;
43
43
  if (id === 'CAPABILITY_VIOLATION') pureOrCapabilityResidual += 1;
44
44
  if (id === 'FORBIDDEN_GLOBAL' || id.startsWith('FORBIDDEN_')) forbiddenGlobalResidual += 1;
45
- if (id.startsWith('ARKRULE_') || id === 'INVARIANT_UNCOVERED') arkRulesStructureResidual += 1;
45
+ if (
46
+ id.startsWith('ARKRULE_') ||
47
+ id === 'INVARIANT_UNCOVERED' ||
48
+ id === 'INVARIANT_CATALOG_EMPTY'
49
+ )
50
+ arkRulesStructureResidual += 1;
46
51
  }
47
52
 
48
53
  const pcFindings = input.physicalCohesion?.findings;
@@ -260,7 +260,9 @@ function mapViolations(byId, violations) {
260
260
  });
261
261
  continue;
262
262
  }
263
- if (upper.startsWith('ARKRULE_') || upper === 'INVARIANT_UNCOVERED') {
263
+ if (upper.startsWith('ARKRULE_') ||
264
+ upper === 'INVARIANT_UNCOVERED' ||
265
+ upper === 'INVARIANT_CATALOG_EMPTY') {
264
266
  attach('encapsulation', 'ArkRules structure / invariant residual inside a layer.', {
265
267
  kind: 'skill',
266
268
  ref: '/ark-autopilot',
@@ -1,8 +1,12 @@
1
1
  /**
2
- * App-context caption from `layers[].description`.
2
+ * App-context caption, optional trust tag, and optional owners from layer metadata.
3
3
  * Metadata only — callers project it; policyHash strips it elsewhere.
4
- * Present non-empty string is returned; absence/empty/non-string is undefined.
5
- *
4
+ * Present values are returned; absence/empty/invalid is omitted.
5
+ */
6
+
7
+ export const LAYER_TRUST_BOUNDARIES = Object.freeze(['public', 'auth', 'admin', 'internal']);
8
+
9
+ /**
6
10
  * @param {{ description?: unknown } | null | undefined} layerOrPlacement
7
11
  * @returns {string | undefined}
8
12
  */
@@ -15,13 +19,91 @@ export function layerDescriptionCaption(layerOrPlacement) {
15
19
  }
16
20
 
17
21
  /**
18
- * Project the caption onto place / prepare-write / coverage / doctor JSON.
19
- * Absence omits the field (never empty string).
22
+ * @param {{ trustBoundary?: unknown } | null | undefined} layerOrPlacement
23
+ * @returns {'public' | 'auth' | 'admin' | 'internal' | undefined}
24
+ */
25
+ export function layerTrustBoundary(layerOrPlacement) {
26
+ const tag =
27
+ layerOrPlacement && typeof layerOrPlacement === 'object'
28
+ ? layerOrPlacement.trustBoundary
29
+ : undefined;
30
+ return typeof tag === 'string' && LAYER_TRUST_BOUNDARIES.includes(tag) ? tag : undefined;
31
+ }
32
+
33
+ /**
34
+ * @param {{ owners?: unknown } | null | undefined} layerOrPlacement
35
+ * @returns {string[] | undefined}
36
+ */
37
+ export function layerOwners(layerOrPlacement) {
38
+ const raw =
39
+ layerOrPlacement && typeof layerOrPlacement === 'object' ? layerOrPlacement.owners : undefined;
40
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
41
+ const ids = raw.filter((entry) => typeof entry === 'string' && entry.length > 0);
42
+ return ids.length > 0 ? ids : undefined;
43
+ }
44
+
45
+ function formatOwnerMention(id) {
46
+ return id.includes('@') ? id : `@${id}`;
47
+ }
48
+
49
+ /**
50
+ * One guidance fragment: caption and/or `trust: public` and/or `owner: @handle`.
51
+ * @param {{ description?: unknown, trustBoundary?: unknown, owners?: unknown } | null | undefined} layerOrPlacement
52
+ * @returns {string | undefined}
53
+ */
54
+ export function layerGuidanceLine(layerOrPlacement) {
55
+ const caption = layerDescriptionCaption(layerOrPlacement);
56
+ const trust = layerTrustBoundary(layerOrPlacement);
57
+ const owners = layerOwners(layerOrPlacement);
58
+ const ownerBit = owners ? `owner: ${owners.map(formatOwnerMention).join(', ')}` : undefined;
59
+ const bits = [caption, trust ? `trust: ${trust}` : undefined, ownerBit].filter(Boolean);
60
+ return bits.length > 0 ? bits.join(' · ') : undefined;
61
+ }
62
+
63
+ /**
64
+ * Project caption + trust tag + owners onto place / prepare-write / coverage / doctor JSON.
65
+ * Absence omits the field (never empty string / empty array).
20
66
  *
21
- * @param {{ description?: unknown } | null | undefined} layerOrPlacement
22
- * @returns {{ description: string } | {}}
67
+ * @param {{ description?: unknown, trustBoundary?: unknown, owners?: unknown } | null | undefined} layerOrPlacement
68
+ * @returns {{ description?: string, trustBoundary?: string, owners?: string[] }}
23
69
  */
24
70
  export function placementDescriptionFields(layerOrPlacement) {
25
71
  const caption = layerDescriptionCaption(layerOrPlacement);
26
- return caption ? { description: caption } : {};
72
+ const trust = layerTrustBoundary(layerOrPlacement);
73
+ const owners = layerOwners(layerOrPlacement);
74
+ return {
75
+ ...(caption ? { description: caption } : {}),
76
+ ...(trust ? { trustBoundary: trust } : {}),
77
+ ...(owners ? { owners } : {}),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Doctor residual when requireLayerOwners is on and a live house has no owners.
83
+ * Absent/false require → null (silent). Not a computeDoctorAdvisories key.
84
+ *
85
+ * @param {{ requireLayerOwners?: unknown, layers?: Array<{ name?: string, owners?: unknown, optional?: boolean, reserved?: boolean, allowEmpty?: boolean }> } | null | undefined} config
86
+ * @returns {{ required: true, missingLayers: string[], ask: string, nextAction: string } | null}
87
+ */
88
+ export function collectLayerOwnerResidual(config) {
89
+ if (config?.requireLayerOwners !== true || !Array.isArray(config.layers)) return null;
90
+ const missing = config.layers
91
+ .filter(
92
+ (layer) =>
93
+ layer &&
94
+ layer.optional !== true &&
95
+ layer.reserved !== true &&
96
+ layer.allowEmpty !== true &&
97
+ !layerOwners(layer)
98
+ )
99
+ .map((layer) => layer.name)
100
+ .filter((name) => typeof name === 'string' && name.length > 0);
101
+ if (missing.length === 0) return null;
102
+ const named = missing[0];
103
+ return {
104
+ required: true,
105
+ missingLayers: missing,
106
+ ask: `${named} has no owner. Add a GitHub handle or email to layers[].owners.`,
107
+ nextAction: `Add a GitHub handle or email to ${named}'s owners in ark.config.json (/ark-adopt).`,
108
+ };
27
109
  }