arkgate 4.8.13 → 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 (126) hide show
  1. package/CHANGELOG.md +219 -4
  2. package/README.md +33 -22
  3. package/SECURITY.md +5 -3
  4. package/bin/ark-check-runtime.mjs +25 -57
  5. package/bin/ark-mcp-runtime.mjs +111 -2
  6. package/bin/ark-shared.mjs +140 -11
  7. package/bin/ark.mjs +7 -25
  8. package/bin/lib/adr-presence.mjs +97 -0
  9. package/bin/lib/agent-projection-formatters.mjs +2 -0
  10. package/bin/lib/agent-skills-package.mjs +241 -8
  11. package/bin/lib/analysis-completeness.mjs +38 -2
  12. package/bin/lib/analysis-engine.mjs +6 -6
  13. package/bin/lib/architecture-scan.mjs +33 -5
  14. package/bin/lib/ark-order-doctor.mjs +7 -1
  15. package/bin/lib/ark-order-report.mjs +2 -1
  16. package/bin/lib/ark-run-doctor.mjs +6 -0
  17. package/bin/lib/ark-run-report.mjs +6 -2
  18. package/bin/lib/arkrules-sensors.mjs +81 -0
  19. package/bin/lib/baseline-key.mjs +4 -1
  20. package/bin/lib/check-args.mjs +52 -2
  21. package/bin/lib/ci-and-commands.mjs +7 -2
  22. package/bin/lib/config-contract.mjs +64 -1
  23. package/bin/lib/design-smells.mjs +21 -1
  24. package/bin/lib/diagnostic-catalog.mjs +5 -3
  25. package/bin/lib/doctor-advisories.mjs +109 -17
  26. package/bin/lib/doctor-human.mjs +20 -6
  27. package/bin/lib/doctor-next-actions.mjs +14 -0
  28. package/bin/lib/doctor-plan.mjs +32 -2
  29. package/bin/lib/enforcement-honesty.mjs +47 -2
  30. package/bin/lib/first-run-help.mjs +19 -3
  31. package/bin/lib/gate-files.mjs +40 -3
  32. package/bin/lib/host-support-matrix.mjs +9 -1
  33. package/bin/lib/html-report-depth.mjs +2 -0
  34. package/bin/lib/html-report.mjs +13 -2
  35. package/bin/lib/import-resolve.mjs +74 -13
  36. package/bin/lib/improvement-compass-doctor.mjs +6 -1
  37. package/bin/lib/improvement-compass-map.mjs +3 -1
  38. package/bin/lib/install-migrate.mjs +23 -0
  39. package/bin/lib/layer-description.mjs +90 -8
  40. package/bin/lib/mcp-hook-payload.mjs +57 -1
  41. package/bin/lib/no-domain-frontend.mjs +91 -0
  42. package/bin/lib/presets.mjs +3 -4
  43. package/bin/lib/product-copy.mjs +19 -0
  44. package/bin/lib/projected-governed-coverage.mjs +114 -0
  45. package/bin/lib/recommend-cli.mjs +54 -0
  46. package/bin/lib/remediation.mjs +6 -2
  47. package/bin/lib/resolved-candidate-facts.mjs +225 -100
  48. package/bin/lib/rules-under-contract.mjs +117 -2
  49. package/bin/lib/scan-files.mjs +39 -0
  50. package/bin/lib/snippet-analysis.mjs +14 -8
  51. package/bin/lib/start-preview.mjs +8 -2
  52. package/bin/lib/states-transitions-presence.mjs +212 -0
  53. package/bin/lib/status-command.mjs +2 -0
  54. package/bin/lib/status-transition-catalog.mjs +410 -0
  55. package/bin/lib/team-parliament-io.mjs +10 -0
  56. package/bin/lib/upgrade-whats-new.mjs +3 -3
  57. package/bin/lib/violations.mjs +43 -1
  58. package/bin/lib/write-path-capabilities.mjs +20 -5
  59. package/bin/lib/write-path-detect.mjs +27 -2
  60. package/dist/{configTypes-j7so8B4O.d.ts → configTypes-Dt3DpVbd.d.ts} +19 -0
  61. package/dist/{diagnosticCatalog-DA565Lja.d.ts → diagnosticCatalog-BEg85XlE.d.ts} +3 -3
  62. package/dist/eslint/index.cjs +4 -4
  63. package/dist/eslint/index.d.ts +1 -1
  64. package/dist/eslint/index.js +4 -4
  65. package/dist/index.cjs +31 -31
  66. package/dist/index.d.ts +126 -19
  67. package/dist/index.js +31 -31
  68. package/dist/nestjs/index.cjs +5 -5
  69. package/dist/nestjs/index.d.ts +3 -3
  70. package/dist/nestjs/index.js +5 -5
  71. package/dist/runtime/index.cjs +15 -15
  72. package/dist/runtime/index.d.ts +6 -6
  73. package/dist/runtime/index.js +15 -15
  74. package/dist/{types-Djbs3KjE.d.ts → types-CN9tVMPz.d.ts} +3 -1
  75. package/dist/{types-tGhZUiGX.d.ts → types-TBiv0WHL.d.ts} +1 -1
  76. package/docs/README.md +8 -6
  77. package/docs/agent-guide.md +35 -17
  78. package/docs/ai-gates.md +13 -4
  79. package/docs/arkorder.md +11 -4
  80. package/docs/brownfield-adoption.md +4 -1
  81. package/docs/configuration.md +63 -14
  82. package/docs/develop.md +4 -1
  83. package/docs/diagnostics.md +30 -10
  84. package/docs/enthusiast/README.md +6 -1
  85. package/docs/enthusiast/how-to-agent-gates.md +5 -0
  86. package/docs/enthusiast/how-to-gallery-starter.md +2 -1
  87. package/docs/enthusiast/how-to-pick-shape.md +1 -1
  88. package/docs/package-surface.md +10 -6
  89. package/docs/product-voice.md +32 -6
  90. package/docs/threat-model.md +2 -2
  91. package/docs/typescript-support.md +3 -3
  92. package/docs/use.md +23 -11
  93. package/package.json +1 -1
  94. package/schemas/ark.config.schema.json +23 -2
  95. package/server.json +2 -2
  96. package/templates/agent-skills/README.md +7 -4
  97. package/templates/agent-skills/ark-adopt/SKILL.md +91 -6
  98. package/templates/agent-skills/ark-architect/SKILL.md +5 -18
  99. package/templates/agent-skills/ark-autopilot/SKILL.md +14 -6
  100. package/templates/agent-skills/ark-contract/SKILL.md +9 -20
  101. package/templates/agent-skills/ark-coverage/SKILL.md +13 -9
  102. package/templates/agent-skills/ark-explain/SKILL.md +11 -5
  103. package/templates/agent-skills/ark-explore/SKILL.md +39 -5
  104. package/templates/agent-skills/ark-fix/SKILL.md +15 -20
  105. package/templates/agent-skills/ark-loop/SKILL.md +14 -20
  106. package/templates/agent-skills/ark-order/SKILL.md +200 -0
  107. package/templates/agent-skills/ark-place/SKILL.md +49 -11
  108. package/templates/agent-skills/ark-runtime/SKILL.md +21 -6
  109. package/templates/agent-skills/ark-think/SKILL.md +24 -126
  110. package/templates/agent-skills/ark-upgrade/SKILL.md +14 -3
  111. package/templates/arkrules/DomainModel.json +14 -1
  112. package/templates/skills/ark-adopt.md +91 -6
  113. package/templates/skills/ark-architect.md +5 -18
  114. package/templates/skills/ark-autopilot.md +14 -6
  115. package/templates/skills/ark-contract.md +9 -20
  116. package/templates/skills/ark-coverage.md +13 -9
  117. package/templates/skills/ark-explain.md +11 -5
  118. package/templates/skills/ark-explore.md +39 -5
  119. package/templates/skills/ark-fix.md +15 -20
  120. package/templates/skills/ark-loop.md +14 -20
  121. package/templates/skills/ark-order.md +200 -0
  122. package/templates/skills/ark-place.md +49 -11
  123. package/templates/skills/ark-runtime.md +21 -6
  124. package/templates/skills/ark-think.md +24 -126
  125. package/templates/skills/ark-upgrade.md +14 -3
  126. package/templates/tests/ark-adoption-gaps.test.ts +5 -4
@@ -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',
@@ -37,6 +37,8 @@ import {
37
37
  hasCheckArchitectureScript,
38
38
  ensureTypecheckScript,
39
39
  compactRouterHost,
40
+ mergeArkMcpJson,
41
+ MCP_JSON_GATE_FILES,
40
42
  writeTemplate,
41
43
  } from './gate-files.mjs';
42
44
  import {
@@ -573,6 +575,27 @@ export function runInstallAgentGates(args) {
573
575
  // Upsert ark-write-gate without requiring --force; never wipe sibling named hooks.
574
576
  return writeTemplate(root, relativePath, merged, true);
575
577
  }
578
+ if (MCP_JSON_GATE_FILES.includes(relativePath)) {
579
+ const fullPath = path.join(root, relativePath);
580
+ let existing = '';
581
+ try {
582
+ existing = fs.readFileSync(fullPath, 'utf8');
583
+ } catch {
584
+ // Missing MCP JSON → write generated ark server.
585
+ }
586
+ if (!existing) {
587
+ return writeTemplate(root, relativePath, content, true);
588
+ }
589
+ const merged = mergeArkMcpJson(existing, content);
590
+ if (merged == null) {
591
+ return args.force
592
+ ? writeTemplate(root, relativePath, content, true)
593
+ : { relativePath, status: 'skipped-non-ark' };
594
+ }
595
+ if (merged === existing) return { relativePath, status: 'skipped' };
596
+ // Upsert mcpServers.ark without requiring --force; never wipe sibling servers.
597
+ return writeTemplate(root, relativePath, merged, true);
598
+ }
576
599
  return writeTemplate(
577
600
  root,
578
601
  relativePath,
@@ -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
  }
@@ -308,13 +308,69 @@ export function emitHostAllow(output, { antigravityStyle, cursorStyle }) {
308
308
  emitCursorAllow(output, cursorStyle);
309
309
  }
310
310
 
311
+ /** Host-native deny envelopes. Exit 2 is still set by the caller. */
312
+ export function emitHostDeny(output, { antigravityStyle, cursorStyle, grokStyle, message, file }) {
313
+ const text = String(message || '').endsWith('\n') ? String(message) : `${message}\n`;
314
+ output.stderr(text);
315
+ if (antigravityStyle || grokStyle) {
316
+ output.stdout(`${JSON.stringify({ decision: 'deny', reason: String(message || '').trim() })}\n`);
317
+ }
318
+ if (cursorStyle) {
319
+ output.stdout(
320
+ JSON.stringify({
321
+ permission: 'deny',
322
+ agent_message: String(message || '').trim(),
323
+ user_message: `ArkGate blocked write to ${file || 'this file'}`,
324
+ }) + '\n'
325
+ );
326
+ }
327
+ }
328
+
329
+ /** File-local twin of CONFIG_UNCLASSIFIED_FILES — included, no layer, write must not land. */
330
+ export function unclassifiedIncludedWriteDeny(relativePath) {
331
+ const file = String(relativePath || 'this file');
332
+ return {
333
+ ruleId: 'CONFIG_UNCLASSIFIED_FILES',
334
+ message: `${file} is included but matches no layer, so import rules will not run on it.`,
335
+ nextAction:
336
+ 'Put it in a layer folder with /ark-place, or extend layer patterns / narrow include.',
337
+ };
338
+ }
339
+
340
+ /** File-local twin of CONFIG_LAYER_MISSING_OWNER — required owners, this house has none. */
341
+ export function unownedLayerWriteDeny(relativePath, layerName) {
342
+ const file = String(relativePath || 'this file');
343
+ const house = String(layerName || 'this layer');
344
+ return {
345
+ ruleId: 'CONFIG_LAYER_MISSING_OWNER',
346
+ message: `${file} is in ${house}, and that folder has no owner.`,
347
+ nextAction: `Add a GitHub handle or email to ${house}'s owners in ark.config.json (/ark-adopt).`,
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Fail-closed write when requireLayerOwners is on and this house has no owners.
353
+ * Silent when the flag is off, the layer is reserved, or owners are present.
354
+ */
355
+ export function requiredOwnerWriteDeny(config, layerName, relativePath) {
356
+ if (config?.requireLayerOwners !== true || !layerName) return null;
357
+ const layer = (config.layers ?? []).find((entry) => entry?.name === layerName);
358
+ if (!layer) return null;
359
+ if (layer.optional === true || layer.reserved === true || layer.allowEmpty === true) return null;
360
+ const owners = Array.isArray(layer.owners)
361
+ ? layer.owners.filter((entry) => typeof entry === 'string' && entry.length > 0)
362
+ : [];
363
+ if (owners.length > 0) return null;
364
+ return unownedLayerWriteDeny(relativePath, layerName);
365
+ }
366
+
311
367
  /**
312
368
  * Socket-style write-gate deny: two lines first. Pass/fail, no score.
313
369
  * Rule id stays on a following line, not the first sentence.
314
370
  */
315
371
  export function formatWriteGateDeny({ file, reason, ruleId, nextAction, extraLines = [] }) {
316
372
  const target = file || 'this write';
317
- const why = String(reason || 'this change breaks the architecture layers').replace(/\s+/g, ' ').trim();
373
+ const why = String(reason || 'a bad import the write doesn’t land').replace(/\s+/g, ' ').trim();
318
374
  const next =
319
375
  nextAction && /place|move|import|port/i.test(nextAction)
320
376
  ? nextAction
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Soft no-Domain / all-logic-in-frontend residual (P2 §3 / Guiar).
3
+ * Tooling I/O. Never a gate fail.
4
+ *
5
+ * Projects facts doctor already has: empty Domain-role layer + presentation
6
+ * file share, or the existing `domain-logic-in-ui` smell. No second tree walk.
7
+ * Silent when there is no frontend, Domain already has files, or the bag is
8
+ * too thin to call “all logic in the UI.”
9
+ */
10
+
11
+ import { isDomainRoleLayerName } from './arkrules-sensors.mjs';
12
+
13
+ export const NO_DOMAIN_FRONTEND_ASK =
14
+ 'Business rules live in the UI, and Domain is still empty.';
15
+
16
+ export const NO_DOMAIN_FRONTEND_NEXT =
17
+ 'Put one pure rule in a Domain file (/ark-place), then one small refactor with /ark-autopilot. Do not pile more rules in pages.';
18
+
19
+ /** Presentation-role house by name. Align with design-smells presentation heuristic. */
20
+ export function isPresentationRoleLayerName(name) {
21
+ return typeof name === 'string' && /presentation|ui|view|frontend/i.test(name);
22
+ }
23
+
24
+ function layerRows(input) {
25
+ return Array.isArray(input.coverage?.layers) ? input.coverage.layers : [];
26
+ }
27
+
28
+ function configLayers(input) {
29
+ return Array.isArray(input.config?.layers) ? input.config.layers : [];
30
+ }
31
+
32
+ function emptyLayerSet(input) {
33
+ return new Set(Array.isArray(input.coverage?.emptyLayers) ? input.coverage.emptyLayers : []);
34
+ }
35
+
36
+ function totalGovernedFiles(input) {
37
+ const governed = input.coverage?.governed?.totalFiles;
38
+ if (Number.isFinite(governed) && governed >= 0) return governed;
39
+ const total = input.coverage?.totalFiles;
40
+ return Number.isFinite(total) && total >= 0 ? total : 0;
41
+ }
42
+
43
+ function hasDomainLogicInUiSmell(designSmells) {
44
+ return (Array.isArray(designSmells) ? designSmells : []).some(
45
+ (smell) => smell && smell.id === 'domain-logic-in-ui'
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Soft residual when Domain is declared but empty and the UI holds the rules.
51
+ * Never flips valid / goal.met.
52
+ *
53
+ * @param {{ config?: object, coverage?: object, designSmells?: object[] }} [input]
54
+ * @returns {{ kind: 'ui-logic' | 'presentation-bag', ask: string, nextAction: string, domainLayers: string[], presentationFiles: number } | null}
55
+ */
56
+ export function collectNoDomainFrontendResidual(input = {}) {
57
+ const totalFiles = totalGovernedFiles(input);
58
+ if (totalFiles <= 0) return null;
59
+
60
+ const declared = configLayers(input);
61
+ const domainLayers = declared.filter((layer) =>
62
+ isDomainRoleLayerName(layer.name, layer.intentPrefixes ?? [])
63
+ );
64
+ if (domainLayers.length === 0) return null;
65
+
66
+ const rows = layerRows(input);
67
+ const empty = emptyLayerSet(input);
68
+ const domainEmpty = domainLayers.every((layer) => {
69
+ const row = rows.find((entry) => entry.name === layer.name);
70
+ const files = Number(row?.files) || 0;
71
+ return files === 0 || empty.has(layer.name);
72
+ });
73
+ if (!domainEmpty) return null;
74
+
75
+ const presentationFiles = rows
76
+ .filter((row) => isPresentationRoleLayerName(row.name))
77
+ .reduce((sum, row) => sum + (Number(row.files) || 0), 0);
78
+ if (presentationFiles <= 0) return null;
79
+
80
+ const uiLogic = hasDomainLogicInUiSmell(input.designSmells);
81
+ const presentationShare = presentationFiles / totalFiles;
82
+ if (!uiLogic && (presentationShare < 0.5 || presentationFiles < 3)) return null;
83
+
84
+ return {
85
+ kind: uiLogic ? 'ui-logic' : 'presentation-bag',
86
+ ask: NO_DOMAIN_FRONTEND_ASK,
87
+ nextAction: NO_DOMAIN_FRONTEND_NEXT,
88
+ domainLayers: domainLayers.map((layer) => layer.name),
89
+ presentationFiles,
90
+ };
91
+ }
@@ -670,8 +670,7 @@ export const ARCHITECTURE_PRESETS = {
670
670
  },
671
671
  {
672
672
  name: 'ApplicationOrchestration',
673
- description:
674
- 'Use cases and services that coordinate the domain through ports. Next App Router API (`app/api/**`) and Pages API (`pages/api/**`) are orchestration shells, not UI.',
673
+ description: 'Use cases and services that coordinate the domain through ports.',
675
674
  patterns: [
676
675
  '**/application/**',
677
676
  '**/use-cases/**',
@@ -687,7 +686,7 @@ export const ARCHITECTURE_PRESETS = {
687
686
  {
688
687
  name: 'PresentationAdapters',
689
688
  description:
690
- 'Entrypoints — UI, framework app/pages dirs, controllers. Next `app/api` is Application, not this layer. Never bare lib/** (data clients are Persistence).',
689
+ 'Entrypoints — UI, pages, controllers when present. Never bare lib/** (data clients are Persistence).',
691
690
  patterns: [
692
691
  '**/app/**',
693
692
  '**/pages/**',
@@ -760,7 +759,7 @@ export const ARCHITECTURE_PRESETS = {
760
759
  {
761
760
  name: 'ApplicationOrchestration',
762
761
  description:
763
- 'Server actions, features, Next API routes (`app/api/**` / `pages/api/**`), and non-UI lib orchestration (when present).',
762
+ 'Server actions, features, and non-UI lib orchestration (when present).',
764
763
  patterns: [
765
764
  'src/features/**',
766
765
  'src/server/**',
@@ -6,6 +6,25 @@
6
6
  * common software words. See docs/product-voice.md.
7
7
  */
8
8
 
9
+ /** First-contact north star. Ordinary English. Spanish names once. */
10
+ export const NORTH_STAR_ONE_LINE =
11
+ 'Contener · Guiar · Ordenar — contain the write, guide the next step, order leftover mess.';
12
+
13
+ /**
14
+ * Compact doctor / details — only when `arkRules` is on.
15
+ * Absence stays silent (unlike ArkOrder, which speaks when off).
16
+ */
17
+ export const ARKRULES_ONE_BREATH =
18
+ 'Layers stop a bad import. ArkRules is optional policies inside one folder.';
19
+
20
+ /** Next step when the map is on and something still needs a human. */
21
+ export const ARKRULES_FIRST_CONTACT_NEXT =
22
+ 'Next: ark-check --rules-inventory — then /ark-adopt to write one rule, or /ark-autopilot to promote.';
23
+
24
+ /** Next step when Domain has code but invariants[] is empty. */
25
+ export const ARKRULES_EMPTY_CATALOG_NEXT =
26
+ 'Next: add 1–2 short phrases to invariants[] in the Domain rules file, then ark-check --doctor.';
27
+
9
28
  /** Status-light leftover-design qualifier (was “design-weak”). */
10
29
  export const LEFTOVER_DESIGN_LABEL = 'leftover design work';
11
30