arkgate 4.8.14 → 4.8.16

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 (103) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +17 -7
  3. package/bin/ark-check-runtime.mjs +17 -49
  4. package/bin/ark-mcp-runtime.mjs +111 -2
  5. package/bin/ark-shared.mjs +142 -11
  6. package/bin/ark.mjs +24 -33
  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 +43 -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 +6 -1
  16. package/bin/lib/check-args.mjs +52 -2
  17. package/bin/lib/config-contract.mjs +65 -2
  18. package/bin/lib/diagnostic-catalog.mjs +5 -1
  19. package/bin/lib/doctor-advisories.mjs +120 -10
  20. package/bin/lib/doctor-green-cite.mjs +139 -0
  21. package/bin/lib/doctor-human.mjs +115 -67
  22. package/bin/lib/doctor-next-actions.mjs +23 -0
  23. package/bin/lib/doctor-plan.mjs +35 -2
  24. package/bin/lib/enforcement-honesty.mjs +47 -2
  25. package/bin/lib/field-install.mjs +47 -7
  26. package/bin/lib/first-run-help.mjs +9 -1
  27. package/bin/lib/host-support-matrix.mjs +9 -1
  28. package/bin/lib/html-report-depth.mjs +2 -0
  29. package/bin/lib/html-report.mjs +13 -2
  30. package/bin/lib/import-resolve.mjs +74 -13
  31. package/bin/lib/improvement-compass-doctor.mjs +8 -1
  32. package/bin/lib/improvement-compass-map.mjs +5 -1
  33. package/bin/lib/invariant-coverage.mjs +121 -0
  34. package/bin/lib/invariant-tests-path.mjs +212 -0
  35. package/bin/lib/layer-description.mjs +90 -8
  36. package/bin/lib/mcp-hook-payload.mjs +56 -0
  37. package/bin/lib/no-domain-frontend.mjs +91 -0
  38. package/bin/lib/package-manager.mjs +8 -0
  39. package/bin/lib/presets.mjs +3 -4
  40. package/bin/lib/product-copy.mjs +15 -0
  41. package/bin/lib/projected-governed-coverage.mjs +114 -0
  42. package/bin/lib/prototype-shortcuts.mjs +224 -0
  43. package/bin/lib/recommend-cli.mjs +54 -0
  44. package/bin/lib/remediation.mjs +15 -0
  45. package/bin/lib/resolved-candidate-facts.mjs +83 -66
  46. package/bin/lib/rules-under-contract.mjs +117 -2
  47. package/bin/lib/snippet-analysis.mjs +14 -8
  48. package/bin/lib/start-preview.mjs +24 -2
  49. package/bin/lib/states-transitions-presence.mjs +212 -0
  50. package/bin/lib/status-command.mjs +2 -0
  51. package/bin/lib/status-transition-catalog.mjs +410 -0
  52. package/bin/lib/team-parliament-io.mjs +10 -0
  53. package/bin/lib/violations.mjs +13 -1
  54. package/bin/lib/write-path-capabilities.mjs +20 -5
  55. package/bin/lib/write-path-detect.mjs +27 -2
  56. package/dist/{configTypes-j7so8B4O.d.ts → configTypes-VD0qcubY.d.ts} +21 -1
  57. package/dist/{diagnosticCatalog-DVx_2RmF.d.ts → diagnosticCatalog-KWvGLI1U.d.ts} +24 -3
  58. package/dist/eslint/index.cjs +4 -4
  59. package/dist/eslint/index.d.ts +1 -1
  60. package/dist/eslint/index.js +4 -4
  61. package/dist/index.cjs +34 -34
  62. package/dist/index.d.ts +71 -10
  63. package/dist/index.js +34 -34
  64. package/dist/nestjs/index.cjs +5 -5
  65. package/dist/nestjs/index.d.ts +3 -3
  66. package/dist/nestjs/index.js +5 -5
  67. package/dist/runtime/index.cjs +15 -15
  68. package/dist/runtime/index.d.ts +6 -6
  69. package/dist/runtime/index.js +15 -15
  70. package/dist/{types-tGhZUiGX.d.ts → types-BSzRy2X1.d.ts} +1 -1
  71. package/dist/{types-Djbs3KjE.d.ts → types-D5GT5ZT8.d.ts} +3 -1
  72. package/docs/README.md +1 -1
  73. package/docs/agent-guide.md +8 -4
  74. package/docs/ai-gates.md +10 -3
  75. package/docs/brownfield-adoption.md +4 -1
  76. package/docs/configuration.md +66 -10
  77. package/docs/develop.md +4 -1
  78. package/docs/diagnostics.md +48 -4
  79. package/docs/enthusiast/how-to-agent-gates.md +5 -0
  80. package/docs/enthusiast/how-to-pick-shape.md +1 -1
  81. package/docs/package-surface.md +7 -5
  82. package/docs/use.md +10 -3
  83. package/package.json +1 -1
  84. package/schemas/ark.config.schema.json +24 -3
  85. package/server.json +2 -2
  86. package/templates/agent-skills/README.md +1 -1
  87. package/templates/agent-skills/ark-adopt/SKILL.md +97 -1
  88. package/templates/agent-skills/ark-autopilot/SKILL.md +12 -3
  89. package/templates/agent-skills/ark-coverage/SKILL.md +2 -2
  90. package/templates/agent-skills/ark-explain/SKILL.md +6 -3
  91. package/templates/agent-skills/ark-explore/SKILL.md +33 -1
  92. package/templates/agent-skills/ark-place/SKILL.md +42 -3
  93. package/templates/agent-skills/ark-runtime/SKILL.md +4 -2
  94. package/templates/agent-skills/ark-upgrade/SKILL.md +1 -1
  95. package/templates/arkrules/DomainModel.json +14 -1
  96. package/templates/skills/ark-adopt.md +97 -1
  97. package/templates/skills/ark-autopilot.md +12 -3
  98. package/templates/skills/ark-coverage.md +2 -2
  99. package/templates/skills/ark-explain.md +6 -3
  100. package/templates/skills/ark-explore.md +33 -1
  101. package/templates/skills/ark-place.md +42 -3
  102. package/templates/skills/ark-runtime.md +4 -2
  103. package/templates/skills/ark-upgrade.md +1 -1
@@ -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.
@@ -83,6 +83,37 @@ function addDevDependencyPreservingFormat(source, version) {
83
83
  return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
84
84
  }
85
85
 
86
+ function replaceArkgateDependencyPreservingFormat(source, version) {
87
+ const encoded = JSON.stringify(version);
88
+ const next = source.replace(/("arkgate"\s*:\s*)(?:"(?:\\.|[^"\\])*")/, `$1${encoded}`);
89
+ if (next === source) {
90
+ throw new Error('arkgate pin not found in package.json');
91
+ }
92
+ return next;
93
+ }
94
+
95
+ /** True when a caret/tilde/exact pin is an older semver than the CLI. Non-semver stays. */
96
+ function pinCoreIsBehind(declaredPin, cliVersion) {
97
+ const pinCore = String(declaredPin).replace(/^[\^~>=<\s]+/, '').split(/\s+/)[0];
98
+ const pinParts = pinCore.split('.').map((p) => Number.parseInt(p, 10));
99
+ const cliParts = String(cliVersion).split('.').map((p) => Number.parseInt(p, 10));
100
+ if (
101
+ pinParts.length < 1 ||
102
+ cliParts.length < 1 ||
103
+ !pinParts.every((n) => Number.isFinite(n)) ||
104
+ !cliParts.every((n) => Number.isFinite(n))
105
+ ) {
106
+ return false;
107
+ }
108
+ for (let i = 0; i < 3; i += 1) {
109
+ const p = pinParts[i] ?? 0;
110
+ const c = cliParts[i] ?? 0;
111
+ if (p < c) return true;
112
+ if (p > c) return false;
113
+ }
114
+ return false;
115
+ }
116
+
86
117
  const ARK_CHECK_BIN_RE = /\b(?:ark-check|arkgate-check)(?:\.mjs|\.js)?\b/;
87
118
  const ARK_CHECK_RUNNER_RE =
88
119
  /(?:^|[\s"'`;|&])(?:npx|pnpm|yarn|npm|bunx?|node)(?:\s|$)/;
@@ -379,13 +410,6 @@ export function pinArkgateDevDependency(root, opts = {}) {
379
410
  }
380
411
  const deps = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
381
412
  const dev = pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {};
382
- if (typeof deps.arkgate === 'string' || typeof dev.arkgate === 'string') {
383
- return {
384
- changed: false,
385
- reason: 'already-present',
386
- version: deps.arkgate || dev.arkgate,
387
- };
388
- }
389
413
  const shipped = arkPackageVersion();
390
414
  const version =
391
415
  typeof opts.version === 'string' && opts.version
@@ -393,6 +417,22 @@ export function pinArkgateDevDependency(root, opts = {}) {
393
417
  : shipped
394
418
  ? `^${shipped}`
395
419
  : 'latest';
420
+ if (typeof deps.arkgate === 'string' || typeof dev.arkgate === 'string') {
421
+ const current = deps.arkgate || dev.arkgate;
422
+ const shouldBump =
423
+ opts.force === true || (Boolean(shipped) && pinCoreIsBehind(current, shipped));
424
+ if (!shouldBump || current === version) {
425
+ return {
426
+ changed: false,
427
+ reason: 'already-present',
428
+ version: current,
429
+ };
430
+ }
431
+ if (opts.write !== false) {
432
+ fs.writeFileSync(pkgPath, replaceArkgateDependencyPreservingFormat(source, version));
433
+ }
434
+ return { changed: true, reason: 'bumped', version };
435
+ }
396
436
  if (opts.write !== false) {
397
437
  fs.writeFileSync(pkgPath, addDevDependencyPreservingFormat(source, version));
398
438
  }
@@ -11,7 +11,9 @@ ${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
16
+ (if missing: npx --package=arkgate arkgate-check --doctor)
15
17
 
16
18
  Stuck? Run status (--doctor). Do #1.
17
19
  `;
@@ -167,6 +169,7 @@ export function checkUsage() {
167
169
  NORTH_STAR_ONE_LINE,
168
170
  '',
169
171
  ' arkgate-check --doctor where you are: one status light, one next action',
172
+ ' arkgate-check --local --base <ref> optional local / multi-worktree cheap check',
170
173
  ' arkgate-check --changed --base <ref> local / pre-push: checks touched files only',
171
174
  ' arkgate-check --strict-merge CI / merge gate (required GitHub status)',
172
175
  ' arkgate-check --sensors which sensors can ever be enforced (does not run analysis)',
@@ -183,7 +186,7 @@ export function checkUsageAll() {
183
186
  '',
184
187
  'Usage: arkgate-check | ark-check (identical bins; product name ArkGate)',
185
188
  ' 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]',
189
+ ' 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
190
  ' 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
191
  ' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
189
192
  ' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
@@ -225,6 +228,9 @@ export function checkUsageAll() {
225
228
  '',
226
229
  'Team parliament: law files (ark.config / arkrules / .ark-baseline.json) cannot ship in',
227
230
  'the same diff as product source. --changed --base <ref> checks touched files only.',
231
+ '--local (or ARK_CHECK_LOCAL=1) is the same cheap path: it turns on --changed and',
232
+ 'stays per worktree root. It cannot combine with --strict-merge. Write hooks stay',
233
+ 'on the lexical snippet path; they do not run a full-tree check.',
228
234
  '--against <ref> ratchets new keys vs that ref\'s baseline. --contract-session is a',
229
235
  'steward law-only PR. Loosen / baseline-grow need stewards[] + --author when set.',
230
236
  '',
@@ -279,6 +285,8 @@ export function checkUsageAll() {
279
285
  '--require-gates implies --strict-config and fails when the Ark contract in AGENTS.md,',
280
286
  'the project-rooted Ark server in .mcp.json, or fail-closed CI is missing/invalid.',
281
287
  'Included but unclassified source files therefore stay red instead of false-green.',
288
+ 'If gates are required and there is no short decision note yet, it points at docs/adr/',
289
+ '(or docs/decisions/) — a hint, not a fail. Off without --require-gates.',
282
290
  '',
283
291
  '--install-agent-gates writes AGENTS.md, .mcp.json, and the CI workflow for every',
284
292
  '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,14 @@ 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
+ id === 'INVARIANT_TESTS_PATH_MISSING' ||
50
+ id === 'INVARIANT_COVERAGE_ROOTS_MISSING'
51
+ )
52
+ arkRulesStructureResidual += 1;
46
53
  }
47
54
 
48
55
  const pcFindings = input.physicalCohesion?.findings;
@@ -260,7 +260,11 @@ 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' ||
266
+ upper === 'INVARIANT_TESTS_PATH_MISSING' ||
267
+ upper === 'INVARIANT_COVERAGE_ROOTS_MISSING') {
264
268
  attach('encapsulation', 'ArkRules structure / invariant residual inside a layer.', {
265
269
  kind: 'skill',
266
270
  ref: '/ark-autopilot',
@@ -8,6 +8,12 @@
8
8
  * Pure CLI helper (bin/lib/invariant-coverage.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
+ /** Adopted + catalogued invariants, but no declared tests path (P2 §10). */
12
+ export const INVARIANT_TESTS_PATH_RULE_ID = 'INVARIANT_TESTS_PATH_MISSING';
13
+ export const INVARIANT_TESTS_PATH_MESSAGE = 'This project is adopted and has domain invariants, but ark.config.json does not name a real tests path. Add coverage.testGlobs or coverage.coverageRoots pointing at the folder where those tests live, then re-run. Without that path, coverage is an empty checkbox.';
14
+ /** Enforced invariant, but no declared runner roots (P2 §10 residual). */
15
+ export const INVARIANT_COVERAGE_ROOTS_RULE_ID = 'INVARIANT_COVERAGE_ROOTS_MISSING';
16
+ export const INVARIANT_COVERAGE_ROOTS_MESSAGE = 'A domain invariant is enforced, but ark.config.json does not name coverage.coverageRoots — the folders where this project\'s test runner actually goes. Add coverage.coverageRoots pointing at that folder, then re-run. Without it, coverage can certify a test no runner runs.';
11
17
  /**
12
18
  * Human-readable discard tail. Empty when the scan discarded nothing.
13
19
  * `omitBudget` drops the budget clause and the load totals for messages whose
@@ -149,6 +155,7 @@ export function evaluateInvariantCoverage(input) {
149
155
  description: inv.description,
150
156
  ...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
151
157
  ...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
158
+ coverageRootsDeclared: rootsDeclared,
152
159
  });
153
160
  // The covering test exists but sits outside the roots the project declared
154
161
  // its runner walks. ArkGate does not execute tests, so it cannot tell the
@@ -233,5 +240,119 @@ export function canPromoteInvariant(coverage) {
233
240
  reason: `Invariant ${coverage.invariantId} is covered only by ${coverage.testEvidenceFile ?? 'a test'}, outside the declared coverage roots; ArkGate cannot tell whether that test runs, so it will not promote on it.`,
234
241
  };
235
242
  }
243
+ // Explicit false only: hand-built evidence may omit the field. The evaluator
244
+ // always sets it. Promoting without roots would make OUTSIDE_ROOTS silent.
245
+ if (coverage.coverageRootsDeclared === false) {
246
+ return {
247
+ ok: false,
248
+ reason: `Declare coverage.coverageRoots in ark.config.json before promoting ${coverage.invariantId} to enforced. Without that, ArkGate cannot tell whether a covering test is one the runner executes.`,
249
+ };
250
+ }
236
251
  return { ok: true, reason: `Invariant ${coverage.invariantId} has coverage evidence.` };
237
252
  }
253
+ function nonEmptyPathStrings(value) {
254
+ if (!Array.isArray(value))
255
+ return [];
256
+ const out = [];
257
+ for (const item of value) {
258
+ if (typeof item !== 'string')
259
+ continue;
260
+ const trimmed = item.trim();
261
+ if (trimmed.length > 0)
262
+ out.push(trimmed);
263
+ }
264
+ return out;
265
+ }
266
+ /**
267
+ * Declared tests homes: `coverage.testGlobs` and/or `coverage.coverageRoots`.
268
+ * Either is a configured path. Empty strings do not count.
269
+ */
270
+ export function configuredInvariantTestsPaths(coverage) {
271
+ if (!coverage || typeof coverage !== 'object')
272
+ return [];
273
+ return [...nonEmptyPathStrings(coverage.testGlobs), ...nonEmptyPathStrings(coverage.coverageRoots)];
274
+ }
275
+ export function hasConfiguredInvariantTestsPath(coverage) {
276
+ return configuredInvariantTestsPaths(coverage).length > 0;
277
+ }
278
+ /**
279
+ * True when at least one catalogued invariant wants test evidence.
280
+ * Same default as AR10: `coverage.test !== false`. `test: false` is an explicit
281
+ * opt-out (starter Domain phrases use it) and does not demand a tests path.
282
+ */
283
+ export function catalogDemandsInvariantTestsPath(invariants) {
284
+ if (!Array.isArray(invariants) || invariants.length === 0)
285
+ return false;
286
+ return invariants.some((inv) => inv != null && inv.coverage?.test !== false);
287
+ }
288
+ /**
289
+ * §10 — adopted + invariants that want tests require a real tests path.
290
+ * Fail-closed. Not freezable. Silent when not adopted, the catalog is empty,
291
+ * or every entry sets `coverage.test: false`.
292
+ */
293
+ export function collectMissingInvariantTestsPathFindings(input) {
294
+ const demanded = input.hasDomainInvariants === true ||
295
+ (input.hasDomainInvariants !== false && catalogDemandsInvariantTestsPath(input.invariants));
296
+ if (input.adopted !== true || !demanded)
297
+ return [];
298
+ const configured = hasConfiguredInvariantTestsPath(input.coverage);
299
+ if (configured && input.declaredPathPresent !== false)
300
+ return [];
301
+ return [
302
+ {
303
+ ruleId: INVARIANT_TESTS_PATH_RULE_ID,
304
+ message: INVARIANT_TESTS_PATH_MESSAGE,
305
+ file: 'ark.config.json',
306
+ line: 1,
307
+ severity: 'error',
308
+ failsStrict: true,
309
+ freezable: false,
310
+ },
311
+ ];
312
+ }
313
+ /**
314
+ * Declared runner homes: `coverage.coverageRoots` only.
315
+ * Empty strings do not count. testGlobs is not a runner root.
316
+ */
317
+ export function configuredCoverageRoots(coverage) {
318
+ if (!coverage || typeof coverage !== 'object')
319
+ return [];
320
+ return nonEmptyPathStrings(coverage.coverageRoots);
321
+ }
322
+ export function hasConfiguredCoverageRoots(coverage) {
323
+ return configuredCoverageRoots(coverage).length > 0;
324
+ }
325
+ /**
326
+ * True when at least one catalogued invariant is `mode: "enforced"`.
327
+ * Structure-sensor enforced is not this — only `invariants[]`.
328
+ */
329
+ export function catalogHasEnforcedInvariant(invariants) {
330
+ if (!Array.isArray(invariants) || invariants.length === 0)
331
+ return false;
332
+ return invariants.some((inv) => inv != null && inv.mode === 'enforced');
333
+ }
334
+ /**
335
+ * P2 §10 residual — any enforced invariant requires `coverage.coverageRoots`.
336
+ * Fail-closed. Not freezable. Silent when no invariant is enforced.
337
+ * testGlobs alone does not satisfy this: without roots, OUTSIDE_ROOTS cannot fire.
338
+ */
339
+ export function collectMissingCoverageRootsFindings(input) {
340
+ const enforced = input.hasEnforcedInvariant === true ||
341
+ (input.hasEnforcedInvariant !== false && catalogHasEnforcedInvariant(input.invariants));
342
+ if (!enforced)
343
+ return [];
344
+ const configured = hasConfiguredCoverageRoots(input.coverage);
345
+ if (configured && input.declaredPathPresent !== false)
346
+ return [];
347
+ return [
348
+ {
349
+ ruleId: INVARIANT_COVERAGE_ROOTS_RULE_ID,
350
+ message: INVARIANT_COVERAGE_ROOTS_MESSAGE,
351
+ file: 'ark.config.json',
352
+ line: 1,
353
+ severity: 'error',
354
+ failsStrict: true,
355
+ freezable: false,
356
+ },
357
+ ];
358
+ }