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
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Adopted-mode domain-invariant tests path (P2 §10 / Contener).
3
+ * Tooling I/O over Domain collectMissingInvariantTestsPathFindings.
4
+ * Fail-closed when adopted. Silent when not adopted or the catalog is empty.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import {
10
+ classifyAdopted,
11
+ isAdopted,
12
+ readAdoptionStance,
13
+ } from './adoption-stance.mjs';
14
+ import {
15
+ catalogDemandsInvariantTestsPath,
16
+ catalogHasEnforcedInvariant,
17
+ configuredCoverageRoots,
18
+ configuredInvariantTestsPaths,
19
+ collectMissingCoverageRootsFindings,
20
+ collectMissingInvariantTestsPathFindings,
21
+ hasConfiguredCoverageRoots,
22
+ hasConfiguredInvariantTestsPath,
23
+ INVARIANT_COVERAGE_ROOTS_MESSAGE,
24
+ INVARIANT_TESTS_PATH_MESSAGE,
25
+ } from './invariant-coverage.mjs';
26
+ import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
27
+
28
+ export const INVARIANT_TESTS_PATH_ASK = INVARIANT_TESTS_PATH_MESSAGE;
29
+
30
+ export const INVARIANT_TESTS_PATH_NEXT =
31
+ 'Add coverage.testGlobs or coverage.coverageRoots in ark.config.json pointing at a real tests folder, then re-run.';
32
+
33
+ export const INVARIANT_COVERAGE_ROOTS_ASK = INVARIANT_COVERAGE_ROOTS_MESSAGE;
34
+
35
+ export const INVARIANT_COVERAGE_ROOTS_NEXT =
36
+ 'Add coverage.coverageRoots in ark.config.json pointing at the folder the test runner uses, then re-run.';
37
+
38
+ /**
39
+ * --require-gates / --strict-merge, or an explicit advisory-only ack.
40
+ * GitHub required-merge is known later in doctor; the scan uses this cheap side.
41
+ *
42
+ * @param {string} root
43
+ * @param {{ requireGates?: boolean }} [args]
44
+ */
45
+ export function scanDemandsInvariantTestsPath(root, args = {}) {
46
+ if (args.requireGates === true) return true;
47
+ return isAdopted(classifyAdopted({ stance: readAdoptionStance(root) }));
48
+ }
49
+
50
+ /**
51
+ * First concrete path segment exists, or every declared glob is wildcard-only.
52
+ *
53
+ * @param {string} root
54
+ * @param {{ testGlobs?: unknown, coverageRoots?: unknown } | null | undefined} coverage
55
+ */
56
+ export function declaredInvariantTestsPathPresent(root, coverage) {
57
+ const declared = configuredInvariantTestsPaths(coverage);
58
+ if (declared.length === 0) return false;
59
+ if (typeof root !== 'string' || root.length === 0) return true;
60
+ let sawConcrete = false;
61
+ for (const entry of declared) {
62
+ const prefix = entry.split(/[*?]/)[0].replace(/\/$/, '');
63
+ if (!prefix) continue;
64
+ sawConcrete = true;
65
+ const abs = path.resolve(root, prefix);
66
+ const rel = path.relative(path.resolve(root), abs).replace(/\\/g, '/');
67
+ if (!rel || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) continue;
68
+ try {
69
+ if (fs.existsSync(abs)) return true;
70
+ } catch {
71
+ continue;
72
+ }
73
+ }
74
+ return !sawConcrete;
75
+ }
76
+
77
+ /**
78
+ * Doctor residual when adopted + invariants + missing/empty tests path.
79
+ *
80
+ * @param {{
81
+ * adopted?: boolean,
82
+ * coverage?: { testGlobs?: unknown, coverageRoots?: unknown } | null,
83
+ * config?: object,
84
+ * invariants?: unknown[],
85
+ * hasDomainInvariants?: boolean,
86
+ * root?: string,
87
+ * }} [input]
88
+ * @returns {{ missing: true, ask: string, nextAction: string } | null}
89
+ */
90
+ function residualDemandsInvariantTests(input) {
91
+ if (input.hasDomainInvariants === true) return true;
92
+ if (input.hasDomainInvariants === false) return false;
93
+ if (Array.isArray(input.invariants)) return catalogDemandsInvariantTestsPath(input.invariants);
94
+ if (typeof input.root === 'string' && input.config) {
95
+ try {
96
+ const loaded = loadEffectiveArkRulesFromDisk(input.root, input.config);
97
+ if (loaded.errors?.length) return false;
98
+ return catalogDemandsInvariantTestsPath(loaded.arkRules?.invariants);
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+
106
+ export function collectInvariantTestsPathResidual(input = {}) {
107
+ if (input.adopted !== true) return null;
108
+ const coverage = input.coverage ?? input.config?.coverage;
109
+ const present =
110
+ typeof input.root === 'string' && input.root.length > 0
111
+ ? declaredInvariantTestsPathPresent(input.root, coverage)
112
+ : hasConfiguredInvariantTestsPath(coverage);
113
+ const findings = collectMissingInvariantTestsPathFindings({
114
+ adopted: true,
115
+ hasDomainInvariants: residualDemandsInvariantTests(input),
116
+ coverage,
117
+ declaredPathPresent: present,
118
+ });
119
+ if (findings.length === 0) return null;
120
+ return {
121
+ missing: true,
122
+ ask: findings[0]?.message ?? INVARIANT_TESTS_PATH_ASK,
123
+ nextAction: INVARIANT_TESTS_PATH_NEXT,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * First concrete coverageRoots segment exists, or every declared glob is wildcard-only.
129
+ *
130
+ * @param {string} root
131
+ * @param {{ coverageRoots?: unknown } | null | undefined} coverage
132
+ */
133
+ export function declaredCoverageRootsPresent(root, coverage) {
134
+ const declared = configuredCoverageRoots(coverage);
135
+ if (declared.length === 0) return false;
136
+ if (typeof root !== 'string' || root.length === 0) return true;
137
+ let sawConcrete = false;
138
+ for (const entry of declared) {
139
+ const prefix = entry.split(/[*?]/)[0].replace(/\/$/, '');
140
+ if (!prefix) continue;
141
+ sawConcrete = true;
142
+ const abs = path.resolve(root, prefix);
143
+ const rel = path.relative(path.resolve(root), abs).replace(/\\/g, '/');
144
+ if (!rel || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) continue;
145
+ try {
146
+ if (fs.existsSync(abs)) return true;
147
+ } catch {
148
+ continue;
149
+ }
150
+ }
151
+ return !sawConcrete;
152
+ }
153
+
154
+ function residualHasEnforcedInvariant(input) {
155
+ if (input.hasEnforcedInvariant === true) return true;
156
+ if (input.hasEnforcedInvariant === false) return false;
157
+ if (Array.isArray(input.invariants)) return catalogHasEnforcedInvariant(input.invariants);
158
+ const refs = input.config?.arkRules;
159
+ if (!refs || typeof refs !== 'object' || Object.keys(refs).length === 0) return false;
160
+ if (typeof input.root === 'string' && input.config) {
161
+ try {
162
+ const loaded = loadEffectiveArkRulesFromDisk(input.root, input.config);
163
+ if (loaded.errors?.length) return false;
164
+ return catalogHasEnforcedInvariant(loaded.arkRules?.invariants);
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+
172
+ /** One doctor light: roots residual wins when both would fire. */
173
+ export function collectInvariantCoverageResiduals(input = {}) {
174
+ const invariantCoverageRoots = collectCoverageRootsResidual(input);
175
+ return {
176
+ invariantCoverageRoots,
177
+ invariantTestsPath: invariantCoverageRoots
178
+ ? null
179
+ : collectInvariantTestsPathResidual(input),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Doctor residual when any invariant is enforced and coverageRoots is missing/empty.
185
+ *
186
+ * @param {{
187
+ * coverage?: { coverageRoots?: unknown } | null,
188
+ * config?: object,
189
+ * invariants?: unknown[],
190
+ * hasEnforcedInvariant?: boolean,
191
+ * root?: string,
192
+ * }} [input]
193
+ * @returns {{ missing: true, ask: string, nextAction: string } | null}
194
+ */
195
+ export function collectCoverageRootsResidual(input = {}) {
196
+ const coverage = input.coverage ?? input.config?.coverage;
197
+ const present =
198
+ typeof input.root === 'string' && input.root.length > 0
199
+ ? declaredCoverageRootsPresent(input.root, coverage)
200
+ : hasConfiguredCoverageRoots(coverage);
201
+ const findings = collectMissingCoverageRootsFindings({
202
+ hasEnforcedInvariant: residualHasEnforcedInvariant(input),
203
+ coverage,
204
+ declaredPathPresent: present,
205
+ });
206
+ if (findings.length === 0) return null;
207
+ return {
208
+ missing: true,
209
+ ask: findings[0]?.message ?? INVARIANT_COVERAGE_ROOTS_ASK,
210
+ nextAction: INVARIANT_COVERAGE_ROOTS_NEXT,
211
+ };
212
+ }
@@ -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,6 +308,62 @@ 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.
@@ -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
+ }
@@ -91,6 +91,14 @@ export function arkCommand(root, bin, argsStr = '') {
91
91
  return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
92
92
  }
93
93
 
94
+ /**
95
+ * Run an Ark binary from package `arkgate` when local bins may be missing.
96
+ * Bare `npx arkgate-check` 404s — npx treats that bin name as its own package.
97
+ */
98
+ export function arkPackageRecoveryCommand(bin, argsStr = '') {
99
+ return `npx --package=arkgate ${bin}${argsStr ? ` ${argsStr}` : ''}`;
100
+ }
101
+
94
102
  /**
95
103
  * Split { command, args } form for JSON/TOML configs (.mcp.json, config.toml) that spawn
96
104
  * the binary directly. `pnpm exec ark-mcp` becomes command "pnpm" + args ["exec","ark-mcp",…]
@@ -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/**',
@@ -10,6 +10,21 @@
10
10
  export const NORTH_STAR_ONE_LINE =
11
11
  'Contener · Guiar · Ordenar — contain the write, guide the next step, order leftover mess.';
12
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
+
13
28
  /** Status-light leftover-design qualifier (was “design-weak”). */
14
29
  export const LEFTOVER_DESIGN_LABEL = 'leftover design work';
15
30
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * One definition of projected governed coverage: include-scoped files that
3
+ * match a layer glob, via computeCoverage. Shared by start, --recommend, and
4
+ * doctor so the same tree cannot advertise two "projected" percents.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { collectGovernedFiles } from './scan-files.mjs';
9
+ import { computeCoverage } from './doctor-plan.mjs';
10
+ import { ARCHITECTURE_PRESETS } from './presets.mjs';
11
+ import {
12
+ detectWorkspaces,
13
+ resolveIncludeRoots,
14
+ resolveStartInitPreset,
15
+ } from '../ark-shared.mjs';
16
+
17
+ export function measureGovernedCoverage(root, config) {
18
+ const files = collectGovernedFiles(root, config);
19
+ return computeCoverage(root, config, files, config.rules ?? []);
20
+ }
21
+
22
+ export function summarizeGovernedCoverage(coverage) {
23
+ const governed = coverage?.governed ?? {};
24
+ return {
25
+ percent: typeof governed.percent === 'number' ? governed.percent : 0,
26
+ classifiedFiles: governed.classifiedFiles ?? 0,
27
+ totalFiles: governed.totalFiles ?? coverage?.totalFiles ?? 0,
28
+ emptyScope: coverage?.emptyScope === true,
29
+ };
30
+ }
31
+
32
+ export function starterConfigForPreset(root, preset) {
33
+ const factory = ARCHITECTURE_PRESETS[preset];
34
+ if (typeof factory !== 'function') return null;
35
+ const workspacesOrInclude =
36
+ preset === 'monorepo' || preset === 'ui-surface'
37
+ ? resolveIncludeRoots(root)
38
+ : detectWorkspaces(root);
39
+ return factory(workspacesOrInclude, root);
40
+ }
41
+
42
+ /**
43
+ * Contract start/recommend/doctor should score: existing ark.config.json, else
44
+ * the starter --init would write for this tree (same preset resolution as start).
45
+ */
46
+ export function resolveProjectedCoverageConfig(root, recommendation = {}) {
47
+ const configPath = path.join(root, 'ark.config.json');
48
+ if (fs.existsSync(configPath)) {
49
+ try {
50
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
51
+ if (parsed && typeof parsed === 'object') {
52
+ return { config: parsed, source: 'existing-config' };
53
+ }
54
+ } catch {
55
+ /* fall through to starter */
56
+ }
57
+ }
58
+ const preset = resolveStartInitPreset(root, recommendation);
59
+ if (!preset) return { config: null, source: 'none', preset: null };
60
+ return { config: starterConfigForPreset(root, preset), source: 'start-starter', preset };
61
+ }
62
+
63
+ export function measureProjectedGovernedCoverage(root, recommendation = {}) {
64
+ const resolved = resolveProjectedCoverageConfig(root, recommendation);
65
+ if (!resolved.config) {
66
+ return {
67
+ percent: 0,
68
+ classifiedFiles: 0,
69
+ totalFiles: 0,
70
+ emptyScope: true,
71
+ source: resolved.source,
72
+ preset: resolved.preset ?? null,
73
+ };
74
+ }
75
+ return {
76
+ ...summarizeGovernedCoverage(measureGovernedCoverage(root, resolved.config)),
77
+ source: resolved.source,
78
+ preset: resolved.preset ?? recommendation.preset ?? null,
79
+ };
80
+ }
81
+
82
+ const PROJECTED_COVERAGE_REASON = /projected governed coverage is \d+% \(below 90%\)/;
83
+
84
+ export function withProjectedGovernedCoverage(recommendation, root) {
85
+ if (!recommendation || typeof recommendation !== 'object') return recommendation;
86
+ const measured = measureProjectedGovernedCoverage(root, recommendation);
87
+ const percent = measured.percent;
88
+ const confirmationReasons = (recommendation.confirmationReasons ?? []).filter(
89
+ (reason) => !PROJECTED_COVERAGE_REASON.test(String(reason))
90
+ );
91
+ if (percent < 90) {
92
+ confirmationReasons.push(`projected governed coverage is ${percent}% (below 90%)`);
93
+ }
94
+ return {
95
+ ...recommendation,
96
+ requiresConfirmation:
97
+ Boolean(recommendation.requiresConfirmation) ||
98
+ percent < 90 ||
99
+ Boolean(recommendation.thinTsSurface),
100
+ confirmationReasons,
101
+ signals: {
102
+ ...recommendation.signals,
103
+ projectedGovernedCoverage: percent,
104
+ projectedGovernedClassifiedFiles: measured.classifiedFiles,
105
+ projectedGovernedTotalFiles: measured.totalFiles,
106
+ projectedGovernedSource: measured.source,
107
+ },
108
+ projectedCoverage: {
109
+ percent,
110
+ classifiedFiles: measured.classifiedFiles,
111
+ totalFiles: measured.totalFiles,
112
+ },
113
+ };
114
+ }