arkgate 4.6.4 → 4.6.6

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 (72) hide show
  1. package/CHANGELOG.md +77 -2105
  2. package/README.md +11 -9
  3. package/bin/ark-check-runtime.mjs +136 -16
  4. package/bin/ark-mcp-runtime.mjs +18 -30
  5. package/bin/ark.mjs +13 -3
  6. package/bin/lib/adapter-contract.mjs +13 -9
  7. package/bin/lib/adoption-stance.mjs +104 -0
  8. package/bin/lib/agent-projection-command.mjs +18 -0
  9. package/bin/lib/agent-projection.mjs +2 -2
  10. package/bin/lib/analysis-engine.mjs +5 -5
  11. package/bin/lib/ci-and-commands.mjs +3 -3
  12. package/bin/lib/ci-merge-boundary.mjs +91 -0
  13. package/bin/lib/config-contract.mjs +2 -0
  14. package/bin/lib/design-delta.mjs +2 -2
  15. package/bin/lib/design-smells.mjs +1 -1
  16. package/bin/lib/diagnostic-catalog.mjs +6 -5
  17. package/bin/lib/doctor-advisories.mjs +2 -2
  18. package/bin/lib/doctor-next-actions.mjs +35 -5
  19. package/bin/lib/doctor-plan.mjs +164 -133
  20. package/bin/lib/enforcement-honesty.mjs +72 -0
  21. package/bin/lib/first-run-help.mjs +8 -7
  22. package/bin/lib/graph-blind.mjs +15 -6
  23. package/bin/lib/html-report-advisories.mjs +10 -2
  24. package/bin/lib/html-report.mjs +2 -2
  25. package/bin/lib/install-migrate.mjs +10 -0
  26. package/bin/lib/invariant-coverage.mjs +6 -2
  27. package/bin/lib/managed-upgrade.mjs +8 -3
  28. package/bin/lib/mcp-adoption.mjs +19 -0
  29. package/bin/lib/policy-delta-io.mjs +1 -1
  30. package/bin/lib/post-green-path.mjs +5 -1
  31. package/bin/lib/presets.mjs +22 -0
  32. package/bin/lib/product-copy.mjs +6 -3
  33. package/bin/lib/remediation.mjs +74 -10
  34. package/bin/lib/skill-install.mjs +2 -0
  35. package/bin/lib/snippet-analysis.mjs +40 -8
  36. package/bin/lib/start-preview.mjs +12 -22
  37. package/bin/lib/status-command.mjs +16 -0
  38. package/bin/lib/status-manifest.mjs +8 -2
  39. package/bin/lib/team-parliament-io.mjs +62 -2
  40. package/bin/lib/team-parliament.mjs +25 -5
  41. package/bin/lib/unavailable-analysis.mjs +1 -0
  42. package/dist/{configTypes-B8uIcLaG.d.ts → configTypes-l6XiwiC1.d.ts} +7 -0
  43. package/dist/eslint/index.cjs +3 -3
  44. package/dist/eslint/index.d.ts +1 -1
  45. package/dist/eslint/index.js +3 -3
  46. package/dist/index.cjs +26 -26
  47. package/dist/index.d.ts +20 -3
  48. package/dist/index.js +29 -29
  49. package/docs/README.md +6 -9
  50. package/docs/agent-guide.md +10 -0
  51. package/docs/ai-gates.md +12 -5
  52. package/docs/brownfield-adoption.md +7 -1
  53. package/docs/configuration.md +11 -2
  54. package/docs/develop.md +4 -2
  55. package/docs/diagnostics.md +17 -7
  56. package/docs/package-surface.md +6 -4
  57. package/docs/product-voice.md +6 -4
  58. package/docs/threat-model.md +2 -2
  59. package/docs/use.md +5 -4
  60. package/package.json +1 -1
  61. package/schemas/ark.config.schema.json +6 -0
  62. package/schemas/ark.design-delta.schema.json +1 -1
  63. package/server.json +2 -2
  64. package/templates/agent-skills/README.md +1 -1
  65. package/templates/agent-skills/ark-adopt/SKILL.md +7 -0
  66. package/templates/agent-skills/ark-explore/SKILL.md +6 -0
  67. package/templates/agent-skills/ark-place/SKILL.md +11 -4
  68. package/templates/agent-skills/ark-upgrade/SKILL.md +9 -2
  69. package/templates/skills/ark-adopt.md +7 -0
  70. package/templates/skills/ark-explore.md +6 -0
  71. package/templates/skills/ark-place.md +11 -4
  72. package/templates/skills/ark-upgrade.md +9 -2
@@ -16,8 +16,16 @@ import { normalize } from './scan-files.mjs';
16
16
 
17
17
  const MAX_LIST = 8;
18
18
  const MAX_FILE_BYTES = 256 * 1024;
19
- /** Full AST scan stays off huge trees so doctor resident warm keeps the 500 ms UX ceiling. */
20
- const MAX_FULL_SCAN_FILES = 2500;
19
+ /** Floor for tiny trees. Large included Next.js trees must still scan. */
20
+ const GRAPH_SCAN_FLOOR = 2500;
21
+ /** Hard cap so a 10k fixture cannot blow the doctor resident warm UX ceiling. */
22
+ const GRAPH_SCAN_HARD_CAP = 8000;
23
+
24
+ /** Threshold scales with the included tree — never a hard 2500 that defers a 3300-file app. */
25
+ export function graphScanLimit(includedCount) {
26
+ const count = Number(includedCount) || 0;
27
+ return Math.min(GRAPH_SCAN_HARD_CAP, Math.max(GRAPH_SCAN_FLOOR, count));
28
+ }
21
29
  const LEXICAL_GATE = /\b(?:import|require)\s*\(|\bimport\s+\w+\s*=\s*require\s*\(/;
22
30
 
23
31
  /**
@@ -123,9 +131,9 @@ export function detectGraphBlindSpots(ts, root, files = []) {
123
131
  };
124
132
  }
125
133
 
126
- // Large-tree deferral: re-reading every path for an advisory sensor would blow the
127
- // doctorResidentWarm 500 ms absolute UX ceiling at the 10k perf fixture.
128
- if (files.length > MAX_FULL_SCAN_FILES) {
134
+ // Large-tree deferral: only above the proportional cap (included count, floor 2500, cap 8000).
135
+ const scanLimit = graphScanLimit(files.length);
136
+ if (files.length > scanLimit) {
129
137
  return {
130
138
  available: true,
131
139
  advisory: true,
@@ -136,7 +144,7 @@ export function detectGraphBlindSpots(ts, root, files = []) {
136
144
  otherNonLiteralCount: 0,
137
145
  truncated: 0,
138
146
  edges: [],
139
- note: `Graph-blind full scan deferred (${files.length} files > ${MAX_FULL_SCAN_FILES}). Non-literal dynamic import/require remain unresolvable in architecture analysis; advisory incomplete-graph honesty is not enumerated at this scale.`,
147
+ note: `Graph-blind full scan deferred (${files.length} files > proportional limit ${scanLimit}). Non-literal dynamic import/require remain unresolvable in architecture analysis; advisory incomplete-graph honesty is not enumerated at this scale.`,
140
148
  };
141
149
  }
142
150
 
@@ -177,6 +185,7 @@ export function detectGraphBlindSpots(ts, root, files = []) {
177
185
  available: true,
178
186
  advisory: true,
179
187
  blockerGrade: false,
188
+ deferred: false,
180
189
  count: edges.length,
181
190
  templateInterpolationCount,
182
191
  otherNonLiteralCount,
@@ -313,17 +313,25 @@ function deepModuleCoachHtml(coach) {
313
313
 
314
314
  function stewardNudgeHtml(nudge) {
315
315
  if (!nudge || nudge.notAScore !== true) return '';
316
+ const unfinished = Boolean(
317
+ nudge.emptyStewardsPastGrace || (nudge.needsStewards && (nudge.stewardCount ?? 0) === 0)
318
+ );
316
319
  const ask =
317
- (nudge.needsStewards || nudge.drift) && typeof nudge.ask === 'string' && nudge.ask
320
+ (nudge.needsStewards || nudge.drift || nudge.emptyStewardsPastGrace) &&
321
+ typeof nudge.ask === 'string' &&
322
+ nudge.ask
318
323
  ? `<p>${esc(nudge.ask)}</p>`
319
324
  : '<p class="muted">No steward list gap (advisory).</p>';
320
325
  const next =
321
326
  typeof nudge.nextAction === 'string' && nudge.nextAction
322
327
  ? `<p class="muted">Next: ${esc(nudge.nextAction)}</p>`
323
328
  : '';
329
+ const qualifier = unfinished
330
+ ? '(unfinished residual — changes finished, not check valid)'
331
+ : '(advisory — never changes the check valid bit)';
324
332
  return `
325
333
  <section class="section card" data-advisory="stewardNudge">
326
- <h2>Stewards <span class="muted">(advisory — never changes the verdict)</span></h2>
334
+ <h2>Stewards <span class="muted">${qualifier}</span></h2>
327
335
  ${ask}
328
336
  ${next}
329
337
  <p class="muted">GitHub handle or email. Never invent names. Always <code>notAScore</code>.</p>
@@ -110,7 +110,7 @@ export function baselineSignalHint(signal) {
110
110
  export function modeBadgeHint(mode) {
111
111
  switch (String(mode || '').toLowerCase()) {
112
112
  case 'enforce':
113
- return 'Contract matches the tree: cores are required where populated, coverage is honest, gates can hold the line.';
113
+ return 'Contract matches the tree on checked import edges. Required GitHub status (or an advisory-only ack) is what adopts the merge boundary.';
114
114
  case 'adapt':
115
115
  return 'Contract is live but still aligning (optional cores with files, empty cores, or presentation-bag false green).';
116
116
  case 'suggest':
@@ -255,7 +255,7 @@ export function computeReportFitness({ coverage, violations, ok, enforcement, co
255
255
  const modeBlurb = {
256
256
  suggest: 'Starter shape — expand layers as the codebase grows.',
257
257
  adapt: 'Contract is live; raise governed coverage or match real folders.',
258
- enforce: 'Contract governs the tree. Gates can honestly hold the line.',
258
+ enforce: 'Contract matches the tree on checked import edges. Merge is adopted only with a required GitHub status or an advisory-only ack.',
259
259
  }[mode];
260
260
  const scoreCoverage = governedPercent == null ? 50 : governedPercent;
261
261
  const scoreClean =
@@ -635,6 +635,16 @@ export function runInstallAgentGates(args) {
635
635
  homeResults.push({ relativePath: dir, status: 'failed' });
636
636
  }
637
637
  if (homeResults.length === 0) {
638
+ const skillName = (skill) =>
639
+ Array.isArray(skill) ? skill[0] : skill?.name || skill;
640
+ const projectHasCatalog = skills.some((skill) =>
641
+ fs.existsSync(path.join(root, '.agents', 'skills', skillName(skill), 'SKILL.md'))
642
+ );
643
+ if (projectHasCatalog) {
644
+ console.log(
645
+ ' Project .agents/skills already has this catalog; home write is optional. Prefer the project copy.'
646
+ );
647
+ }
638
648
  for (const result of installSkillCatalog({
639
649
  directory: dir,
640
650
  skills,
@@ -79,11 +79,14 @@ export function evaluateInvariantCoverage(input) {
79
79
  if (!covered || partial) {
80
80
  // Enforced + proven uncovered → failsStrict; partial always advisory (never fake green).
81
81
  const failsStrict = inv.mode === 'enforced' && !partial;
82
+ const kind = testGlobsMissing || testFiles.length === 0 ? 'never-had-tests' : 'tests-disappeared';
82
83
  violations.push({
83
84
  ruleId: 'INVARIANT_UNCOVERED',
84
85
  message: partial
85
- ? `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered.`
86
- : `Invariant ${inv.id} is not covered by a test title or declared symbol.`,
86
+ ? `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered (never-had-tests).`
87
+ : kind === 'tests-disappeared'
88
+ ? `Invariant ${inv.id} is not covered by a test title or declared symbol (tests-disappeared — suite exists).`
89
+ : `Invariant ${inv.id} is not covered by a test title or declared symbol (never-had-tests).`,
87
90
  file: inv.provenance.sourceFile,
88
91
  line: 1,
89
92
  arkruleId: inv.id,
@@ -91,6 +94,7 @@ export function evaluateInvariantCoverage(input) {
91
94
  fromLayer: inv.provenance.layer,
92
95
  severity: failsStrict ? 'error' : 'warning',
93
96
  failsStrict,
97
+ kind,
94
98
  });
95
99
  }
96
100
  }
@@ -134,7 +134,7 @@ const HOST_SIGNALS = {
134
134
  ],
135
135
  codex: ['.codex/hooks.json', '.codex/config.toml', '.agents/skills/ark-upgrade/SKILL.md'],
136
136
  grok: ['.grok/config.toml', '.grok/hooks/ark-write-gate.json', '.grok/skills/ark-upgrade/SKILL.md'],
137
- antigravity: ['.agents/hooks.json', '.agents/skills/ark-upgrade/SKILL.md'],
137
+ antigravity: ['.agents/hooks.json'],
138
138
  opencode: ['opencode.json', '.opencode/skills/ark-upgrade/SKILL.md'],
139
139
  windsurf: ['.windsurf/rules/ark.md', '.windsurf/workflows/ark-upgrade.md'],
140
140
  cline: ['.clinerules/ark.md', '.clinerules/workflows/ark-upgrade.md'],
@@ -307,8 +307,13 @@ function resolveSelection(root, options, manifest) {
307
307
  const explicit = normalizeToolsList(options.tools);
308
308
  const compactHost = compactRouterHost(root);
309
309
  let hosts;
310
- if (explicit.length > 0) hosts = normalizeHosts(explicit);
311
- else if (manifest) hosts = normalizeHosts(manifest.hosts);
310
+ if (explicit.length > 0) {
311
+ const kept = manifest
312
+ ? normalizeHosts(manifest.hosts)
313
+ : detectedManagedHosts(root);
314
+ // Preview/apply default is hosts-keep: --tools unions, never retires other hosts.
315
+ hosts = normalizeHosts([...kept, ...explicit]);
316
+ } else if (manifest) hosts = normalizeHosts(manifest.hosts);
312
317
  else if (compactHost !== null) hosts = compactHost === 'none' ? [] : normalizeHosts([compactHost]);
313
318
  else {
314
319
  hosts = detectedManagedHosts(root);
@@ -17,6 +17,11 @@ import { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
17
17
  import { detectDeployPathQuality } from './deploy-path.mjs';
18
18
  import { collectWeakestLinkGaps } from './weakest-link.mjs';
19
19
  import { codexRuntimeActivation, withCiProviderEvidence } from './enforcement-state.mjs';
20
+ import {
21
+ classifyAdopted,
22
+ readAdoptionStance,
23
+ NOT_ADOPTED_NEXT_ACTION,
24
+ } from './adoption-stance.mjs';
20
25
 
21
26
  export { detectDeployPathQuality };
22
27
 
@@ -528,6 +533,20 @@ export function collectAdoptionGaps(root, config, coverage) {
528
533
  gaps.push(g);
529
534
  }
530
535
 
536
+ const adoptedKind = classifyAdopted({
537
+ stance: readAdoptionStance(root),
538
+ github: weakest.github,
539
+ });
540
+ if (adoptedKind === 'not-adopted') {
541
+ gaps.push({
542
+ id: 'adoption-stance-missing',
543
+ severity: 'warn',
544
+ message:
545
+ 'Merge boundary not adopted: require a GitHub status on arkgate-check --strict-merge, or write .ark/adoption-stance.json with stance: "advisory-only".',
546
+ fix: NOT_ADOPTED_NEXT_ACTION,
547
+ });
548
+ }
549
+
531
550
  return {
532
551
  gaps,
533
552
  hosts,
@@ -42,7 +42,7 @@ function repositoryRoot(root) {
42
42
  return result.status === 0 ? result.stdout.trim() : null;
43
43
  }
44
44
 
45
- function discoverLocalBaseRef(root) {
45
+ export function discoverLocalBaseRef(root) {
46
46
  const top = repositoryRoot(root);
47
47
  if (!top) return null;
48
48
  const remoteHead = runGit(top, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
@@ -92,10 +92,14 @@ export function mergePostGreenTopActions(actions, postGreen) {
92
92
 
93
93
  /**
94
94
  * Whether doctor may print “Healthy — nothing to do”.
95
+ * Empty actions + !designWeak is not Healthy unless the merge boundary is
96
+ * required-merge (advisory-only ack is adopted but not this Healthy string).
95
97
  * @param {{ designWeak?: boolean } | null | undefined} designFitness
96
98
  * @param {string[]} topActions
99
+ * @param {string | null | undefined} adopted
97
100
  */
98
- export function isDoctorHealthyNothingToDo(designFitness, topActions = []) {
101
+ export function isDoctorHealthyNothingToDo(designFitness, topActions = [], adopted = null) {
99
102
  if (designFitness?.designWeak) return false;
103
+ if (adopted !== 'required-merge') return false;
100
104
  return !topActions.some(Boolean);
101
105
  }
@@ -179,6 +179,28 @@ export const PERSISTENCE_PATH_PATTERNS = Object.freeze([
179
179
  * Application orchestration under lib without the whole-src-lib vacuum.
180
180
  * Never use lone `src/**` or bare `src/lib/**` as Application on Next/event trees.
181
181
  */
182
+ /** Types and constants — SharedKernel, not Application vacuum. */
183
+ export const SHARED_KERNEL_PATH_PATTERNS = Object.freeze([
184
+ 'src/shared/**',
185
+ 'src/types/**',
186
+ 'src/**/types.ts',
187
+ 'src/**/constants.ts',
188
+ 'src/**/types/**',
189
+ 'src/**/constants/**',
190
+ '**/shared/kernel/**',
191
+ 'src/shared/kernel/**',
192
+ ]);
193
+
194
+ /** Wiring / DI / bootstrap — CompositionRoot, not Domain. */
195
+ export const COMPOSITION_ROOT_PATH_PATTERNS = Object.freeze([
196
+ 'src/**/composition/**',
197
+ 'src/**/factories/**',
198
+ 'src/**/container.ts',
199
+ 'src/**/bootstrap.ts',
200
+ '**/composition/**',
201
+ '**/factories/**',
202
+ ]);
203
+
182
204
  export const APPLICATION_LIB_ORCHESTRATION_PATTERNS = Object.freeze([
183
205
  'src/lib/actions/**',
184
206
  'src/lib/services/**',
@@ -13,17 +13,20 @@ export const LEFTOVER_DESIGN_LABEL = 'leftover design work';
13
13
  * Operating-mode title for humans (and doctor JSON `designFitness.label` prefix).
14
14
  * @param {string|null|undefined} mode suggest|adapt|enforce
15
15
  * @param {boolean} leftoverDesign
16
+ * @param {boolean} [stewardsUnset]
16
17
  */
17
- export function operatingModeTitle(mode, leftoverDesign) {
18
+ export function operatingModeTitle(mode, leftoverDesign, stewardsUnset) {
18
19
  const light = String(mode || 'enforce').toUpperCase();
19
- return leftoverDesign ? `${light} · ${LEFTOVER_DESIGN_LABEL}` : light;
20
+ if (leftoverDesign) return `${light} · ${LEFTOVER_DESIGN_LABEL}`;
21
+ if (stewardsUnset) return `${light} · stewards unset`;
22
+ return light;
20
23
  }
21
24
 
22
25
  /** Short HTML/doctor badge text. */
23
26
  export const LEFTOVER_DESIGN_BADGE = LEFTOVER_DESIGN_LABEL;
24
27
 
25
28
  export const POST_GREEN_HUMAN =
26
- 'Imports check out, but the design is still messy. Map leftover work with /ark-explore shape-focus, then apply one small refactor via /ark-autopilot. A clean import check is not done; pattern bets are never auto-applied.';
29
+ 'Imports check out, but the design is still messy. Next: /ark-explore, then one small refactor with /ark-autopilot and your OK.';
27
30
 
28
31
  export const POST_GREEN_LEDE =
29
32
  'Import rules are clean, but leftover design work remains. That does not fail the check — it only means “done” is still wrong until you tidy shape.';
@@ -41,17 +41,57 @@ export const KNOWN_FIX_CLASSES = [
41
41
  'break-cycle',
42
42
  'review-contract',
43
43
  ];
44
+ const PURE_SHARED_RE = /(^|\/)(constants|types|enums|shared-types|shared\/(?:types|constants)|test-projects)(\/|\.|$)|(?:^|\/)[^/]*(?:constants|types)(?:\.[cm]?[jt]sx?)?$/i;
45
+ const KERNEL_EMIT_RE = /(^|\/)(?:kernel(?:\/|$)|events?(?:\/|\.|$)|bootstrap(?:\.[cm]?[jt]sx?)?$|emitter(?:\.[cm]?[jt]sx?)?$)|(?:^|\/)(?:intents?|publish)(?:\/|\.|$)/i;
46
+ const USE_CASE_RE = /(use-?cases?|usecases?|application|orchestrat|services?|handlers?)(\/|\.|$)/i;
47
+ export function classifyLayerImportKind(target, extra) {
48
+ const value = String(target ?? '')
49
+ .replace(/\\/g, '/')
50
+ .trim();
51
+ const from = String(extra?.fromLayer ?? '');
52
+ const to = String(extra?.toLayer ?? '');
53
+ if (PURE_SHARED_RE.test(value))
54
+ return 'pure-shared';
55
+ if (from === 'PersistenceAdapters' &&
56
+ (KERNEL_EMIT_RE.test(value) || /events?|intents?|kernel|bootstrap/i.test(`${to} ${value}`))) {
57
+ return 'kernel-emit';
58
+ }
59
+ if (USE_CASE_RE.test(value) ||
60
+ ((from === 'DomainModel' || from === 'ApplicationOrchestration') &&
61
+ to === 'PersistenceAdapters')) {
62
+ return 'use-case';
63
+ }
64
+ if (!value)
65
+ return 'unknown';
66
+ return 'unknown';
67
+ }
68
+ export function layerImportNextAction(violation) {
69
+ if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
70
+ return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
71
+ }
72
+ if (violation.peerIsolation) {
73
+ return 'Extract the shared dependency to a shared layer, test at the public interface, then preflight again.';
74
+ }
75
+ const kind = classifyLayerImportKind(typeof violation.target === 'string' ? violation.target : '', {
76
+ fromLayer: typeof violation.fromLayer === 'string' ? violation.fromLayer : undefined,
77
+ toLayer: typeof violation.toLayer === 'string' ? violation.toLayer : undefined,
78
+ });
79
+ if (kind === 'pure-shared') {
80
+ return 'Adopt the imported constants/types/pure module into DomainModel or SharedKernel (do not inject a port). Then preflight again.';
81
+ }
82
+ if (kind === 'kernel-emit') {
83
+ return 'Persistence must not emit. Inject a port or move the event map to SharedTypes; do not import kernel/events/bootstrap from a repository. Then preflight again.';
84
+ }
85
+ if (kind === 'use-case' || violation.portProofEligible) {
86
+ return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, test at the public interface, then preflight again.`;
87
+ }
88
+ return 'Classify the import: if it is constants/types/pure, adopt into DomainModel or SharedKernel; define a port only if the target is a real use-case. Then preflight again.';
89
+ }
44
90
  /** One deterministic re-entry action shared by human and machine denial surfaces. */
45
91
  export function deterministicNextAction(violation) {
46
92
  switch (violation.ruleId) {
47
93
  case 'LAYER_IMPORT_VIOLATION':
48
- if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
49
- return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
50
- }
51
- if (violation.peerIsolation) {
52
- return 'Extract the shared dependency to a shared layer, test at the public interface, then preflight again.';
53
- }
54
- return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, test at the public interface, then preflight again.`;
94
+ return layerImportNextAction(violation);
55
95
  case 'FORBIDDEN_GLOBAL':
56
96
  return `Inject ${violation.target ?? 'the capability'} through a port, test at the public interface, then preflight again.`;
57
97
  case 'CAPABILITY_VIOLATION':
@@ -222,9 +262,33 @@ export function enrichViolationWithFixClass(violation) {
222
262
  'Cross-slice import blocked (peerIsolation). Do not import another feature/context directly — extract shared code to a shared layer, or coordinate via events/ports. Moving code across slices is a judgment call, not a mechanical auto-fix.';
223
263
  }
224
264
  else {
225
- enriched.fixClass = 'port-inversion';
226
- enriched.effort = 'medium';
227
- enriched.enthusiastHint = `${violation.fromLayer ?? 'This layer'} must not import ${violation.toLayer ?? 'that layer'} directly. Define an interface (port) where you need the capability and inject the implementation from the outer layer.`;
265
+ const kind = classifyLayerImportKind(typeof violation.target === 'string' ? violation.target : '', {
266
+ fromLayer: typeof violation.fromLayer === 'string' ? violation.fromLayer : undefined,
267
+ toLayer: typeof violation.toLayer === 'string' ? violation.toLayer : undefined,
268
+ });
269
+ if (kind === 'pure-shared') {
270
+ enriched.fixClass = 'file-move';
271
+ enriched.effort = 'small';
272
+ enriched.enthusiastHint =
273
+ 'This import is constants/types/pure — adopt it into DomainModel or SharedKernel. Do not inject a port.';
274
+ }
275
+ else if (kind === 'kernel-emit') {
276
+ enriched.fixClass = 'port-inversion';
277
+ enriched.effort = 'medium';
278
+ enriched.enthusiastHint =
279
+ 'A repository must not import kernel/events/bootstrap. Persistence does not emit — inject a port or move the map to SharedTypes.';
280
+ }
281
+ else if (kind === 'use-case' || violation.portProofEligible) {
282
+ enriched.fixClass = 'port-inversion';
283
+ enriched.effort = 'medium';
284
+ enriched.enthusiastHint = `${violation.fromLayer ?? 'This layer'} must not import ${violation.toLayer ?? 'that layer'} directly. Define an interface (port) where you need the capability and inject the implementation from the outer layer.`;
285
+ }
286
+ else {
287
+ enriched.fixClass = 'review-contract';
288
+ enriched.effort = 'medium';
289
+ enriched.enthusiastHint =
290
+ 'Do not assume a port. If the target is constants/types/pure, adopt it into Domain or SharedKernel; define a port only for a real use-case.';
291
+ }
228
292
  }
229
293
  break;
230
294
  case 'FORBIDDEN_GLOBAL':
@@ -688,7 +688,9 @@ export function assessCodexSkillParity(root) {
688
688
 
689
689
  const repoNeedsAttention =
690
690
  repoInPlay && (repo.missing > 0 || repo.stale > 0 || repo.legacyPromptsOnly);
691
+ const repoCatalogComplete = repoInPlay && repo.missing === 0 && !repo.legacyPromptsOnly;
691
692
  const homeNeedsAttention =
693
+ !repoCatalogComplete &&
692
694
  newerHomeCatalog === null &&
693
695
  homeInPlay &&
694
696
  (home.missing > 0 ||
@@ -1,6 +1,26 @@
1
1
  /** Fail-closed completeness evidence for one proposed source snippet. */
2
2
  import { ANALYSIS_COMPLETENESS } from './analysis-completeness.mjs';
3
3
 
4
+ export function flattenTsParseDiagnostics(ts, diagnostics, sourceFile) {
5
+ if (!Array.isArray(diagnostics) || !ts) return [];
6
+ return diagnostics.map((diagnostic) => {
7
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
8
+ let line = 1;
9
+ let column = 1;
10
+ if (typeof diagnostic.start === 'number' && sourceFile?.getLineAndCharacterOfPosition) {
11
+ const pos = sourceFile.getLineAndCharacterOfPosition(diagnostic.start);
12
+ line = (pos.line ?? 0) + 1;
13
+ column = (pos.character ?? 0) + 1;
14
+ }
15
+ return {
16
+ line,
17
+ column,
18
+ message: String(message || '').trim() || 'parse error',
19
+ code: diagnostic.code,
20
+ };
21
+ });
22
+ }
23
+
4
24
  function finding(ruleId, message, file, nextAction) {
5
25
  return {
6
26
  ruleId,
@@ -54,6 +74,11 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
54
74
  if (!Array.isArray(parsed.parseDiagnostics)) throw new Error('parse diagnostics unavailable');
55
75
  const diagnosticCount = parsed.parseDiagnostics.length;
56
76
  if (diagnosticCount > 0) {
77
+ const tsDiagnostics = flattenTsParseDiagnostics(ts, parsed.parseDiagnostics, parsed);
78
+ const first = tsDiagnostics[0];
79
+ const detail = first
80
+ ? `line ${first.line}: ${first.message}`
81
+ : `${diagnosticCount} parse diagnostic(s)`;
57
82
  return {
58
83
  mode: 'lexical-compatibility',
59
84
  valid: false,
@@ -62,18 +87,25 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
62
87
  completenessReasons: [
63
88
  {
64
89
  code: 'ANALYSIS_PARSE_INCOMPLETE',
65
- message: `The proposed source has ${diagnosticCount} parse diagnostic(s).`,
90
+ message: `The proposed source has ${diagnosticCount} parse diagnostic(s). ${detail}`,
66
91
  ...(file ? { file } : {}),
92
+ line: first?.line,
93
+ tsDiagnostics,
67
94
  },
68
95
  ],
69
96
  violations: [
70
97
  ...base.violations,
71
- finding(
72
- 'ANALYSIS_PARSE_INCOMPLETE',
73
- `Analysis partial: proposed source has ${diagnosticCount} parse diagnostic(s).`,
74
- file,
75
- 'Fix the syntax until the TypeScript parser reports zero diagnostics, then validate again.'
76
- ),
98
+ {
99
+ ...finding(
100
+ 'ANALYSIS_PARSE_INCOMPLETE',
101
+ `Analysis partial: ${detail}`,
102
+ file,
103
+ 'Incremental mid-edit parse errors are normal. Finish the source, then re-run `npx arkgate-check` (or the write hook). Do not call ark_prepare_change from a hook deny.'
104
+ ),
105
+ line: first?.line ?? 1,
106
+ column: first?.column ?? 1,
107
+ evidence: { tsDiagnostics, diagnosticCount },
108
+ },
77
109
  ],
78
110
  };
79
111
  }
@@ -87,7 +119,7 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
87
119
  {
88
120
  code: 'LEXICAL_EVIDENCE_INCOMPLETE',
89
121
  message:
90
- 'Single-file validation cannot prove project module resolution or complete candidate evidence; use ark_prepare_change for a parity-capable verdict.',
122
+ 'Single-file validation cannot prove project module resolution. The write hook is already the verdict, or re-run `npx arkgate-check --root . --config ark.config.json`. Do not call ark_prepare_change from a hook deny.',
91
123
  ...(file ? { file } : {}),
92
124
  },
93
125
  ],
@@ -151,7 +151,7 @@ export function renderStartPreview(preview, options = {}) {
151
151
  console.log('Apply this plan with: arkgate start --apply');
152
152
  }
153
153
  if (preview.analysis) {
154
- console.log(`Your project looks like: ${preview.analysis.label} (${preview.analysis.archetype}, confidence ${preview.analysis.confidence}).`);
154
+ console.log(`Your project looks like: ${preview.analysis.label}.`);
155
155
  }
156
156
  console.log(applying ? 'Files create/edit/delete:' : 'Files to create/edit/delete:');
157
157
  if (preview.changes.length === 0) console.log(' (none)');
@@ -159,34 +159,24 @@ export function renderStartPreview(preview, options = {}) {
159
159
  console.log(` ${change.action.padEnd(6)} ${change.path}`);
160
160
  }
161
161
  if (!applying) {
162
- console.log('Commands in the approved setup plan:');
163
- for (const command of preview.commands) console.log(` ${command}`);
162
+ console.log('Setup: install package + host gates (see --json).');
163
+ console.log('Preview does not write. Apply installs CI.');
164
164
  }
165
- console.log('Host guarantees:');
166
- for (const guarantee of preview.hostGuarantees) console.log(` ${guarantee}`);
167
165
  if (preview.runtimeActivation) {
168
- console.log('Codex MCP CONFIGURED RUNTIME NOT VERIFIED.');
169
- console.log(` Runtime activation: ${JSON.stringify(preview.runtimeActivation)}`);
170
- console.log(` Restart Codex, then call ark_identity with expectedRoot "${preview.root}".`);
171
- console.log(' Do not trust MCP verdicts before the project identity matches.');
166
+ console.log('Host: Codex is configured but not verified yet. Restart the host, then confirm this project.');
172
167
  }
173
168
  if (preview.unresolvedDecisions.length > 0) {
174
169
  console.log('Unresolved decisions:');
175
170
  for (const decision of preview.unresolvedDecisions) console.log(` ${decision}`);
176
171
  }
177
- console.log('Details (optional):');
178
- console.log(`Projected governed coverage: ${preview.projectedCoverage.percent ?? 'unknown'}% (${preview.projectedCoverage.classifiedFiles}/${preview.projectedCoverage.totalFiles} files)`);
179
- const budget = preview.setupBudget;
180
- const arkrulesNote =
181
- budget.arkrulesFiles > 0 ? ` (+${budget.arkrulesFiles} arkrules)` : '';
182
- const gateCount = budget.gateFiles ?? budget.files;
183
- console.log(
184
- `Compact setup budget: ${gateCount}/${budget.maxFiles} gate files${arkrulesNote}, ${budget.bytes}/${budget.maxBytes} bytes${budget.ok ? '' : ' (exceeded)'}.`
185
- );
186
- for (const change of preview.changes) {
187
- console.log(` ${change.action.padEnd(6)} ${change.path} ${change.afterHash ?? '(deleted)'}`);
188
- }
189
- if (!applying) {
172
+ if (applying) {
173
+ const percent = preview.projectedCoverage?.percent;
174
+ if (percent != null) console.log(`Projected governed coverage: ${percent}%`);
175
+ const verified = (preview.hostGuarantees || []).find((line) =>
176
+ String(line).startsWith('Hard-write hook verified')
177
+ );
178
+ if (verified) console.log(verified);
179
+ } else {
190
180
  console.log('Review complete file contents with --json.');
191
181
  }
192
182
  }
@@ -26,6 +26,7 @@ import { readBaseline } from './violations.mjs';
26
26
  import { reportsDir, readJsonSafe } from './html-report.mjs';
27
27
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
28
28
  import { collectVsBaseFacts, discoverTeamBaseRef } from './team-parliament-io.mjs';
29
+ import { classifyAdopted, readAdoptionStance } from './adoption-stance.mjs';
29
30
 
30
31
  function sha256Hex(value) {
31
32
  return createHash('sha256').update(value, 'utf8').digest('hex');
@@ -387,6 +388,21 @@ export function collectStatusFacts(options = {}) {
387
388
  latest?.leftoverDesignWork === true ||
388
389
  latest?.designFitness?.designWeak === true ||
389
390
  latest?.doctor?.designFitness?.designWeak === true,
391
+ adopted:
392
+ options.adopted ??
393
+ classifyAdopted({
394
+ stance: readAdoptionStance(resolvedRoot),
395
+ github: {
396
+ requiredStatusConfigured: writePath?.enforcementState?.ciMerge?.required === true,
397
+ arkCheckRequired: writePath?.enforcementState?.ciMerge?.required === true,
398
+ },
399
+ ci: {
400
+ state:
401
+ writePath?.enforcementState?.ciMerge?.required === true
402
+ ? 'required'
403
+ : undefined,
404
+ },
405
+ }),
390
406
  improvementCompass,
391
407
  vsBase: (() => {
392
408
  const vsRef = typeof options.vs === 'string' ? options.vs.trim() : '';
@@ -242,9 +242,15 @@ export function resolveStatusNextAction(facts, binding, activation, lastCheck, r
242
242
  summary: 'ArkRules residual remains frozen — review inventory debt without claiming a score.',
243
243
  };
244
244
  }
245
+ if (facts.adopted === 'required-merge' || facts.adopted === 'advisory-only-acked') {
246
+ return {
247
+ id: 'stay-enforced',
248
+ summary: 'Contract looks enforceable for this session — keep writing through the gate and re-check after structural edits.',
249
+ };
250
+ }
245
251
  return {
246
- id: 'stay-enforced',
247
- summary: 'Contract looks enforceable for this session keep writing through the gate and re-check after structural edits.',
252
+ id: 'require-ci-merge-status',
253
+ summary: 'Make arkgate-check --strict-merge a required GitHub status, or write .ark/adoption-stance.json with stance: "advisory-only".',
248
254
  };
249
255
  }
250
256
  export function buildStatusManifest(facts) {