arkgate 4.6.3 → 4.6.5

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 (54) hide show
  1. package/CHANGELOG.md +64 -5
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +98 -14
  4. package/bin/ark-mcp-runtime.mjs +54 -33
  5. package/bin/ark.mjs +1 -0
  6. package/bin/lib/adapter-contract.mjs +13 -9
  7. package/bin/lib/agent-projection-command.mjs +18 -0
  8. package/bin/lib/agent-projection.mjs +2 -2
  9. package/bin/lib/analysis-engine.mjs +5 -5
  10. package/bin/lib/ci-and-commands.mjs +3 -3
  11. package/bin/lib/ci-merge-boundary.mjs +89 -0
  12. package/bin/lib/config-contract.mjs +2 -0
  13. package/bin/lib/diagnostic-catalog.mjs +5 -4
  14. package/bin/lib/doctor-next-actions.mjs +17 -5
  15. package/bin/lib/doctor-plan.mjs +14 -0
  16. package/bin/lib/enforcement-honesty.mjs +2 -0
  17. package/bin/lib/graph-blind.mjs +15 -6
  18. package/bin/lib/install-migrate.mjs +10 -0
  19. package/bin/lib/invariant-coverage.mjs +6 -2
  20. package/bin/lib/managed-upgrade.mjs +9 -4
  21. package/bin/lib/presets.mjs +22 -0
  22. package/bin/lib/remediation.mjs +74 -10
  23. package/bin/lib/skill-install.mjs +2 -0
  24. package/bin/lib/snippet-analysis.mjs +40 -8
  25. package/bin/lib/upgrade-command.mjs +37 -4
  26. package/bin/lib/upgrade-whats-new.mjs +19 -1
  27. package/dist/{configTypes-B8uIcLaG.d.ts → configTypes-l6XiwiC1.d.ts} +7 -0
  28. package/dist/eslint/index.cjs +3 -3
  29. package/dist/eslint/index.d.ts +1 -1
  30. package/dist/eslint/index.js +3 -3
  31. package/dist/index.cjs +26 -26
  32. package/dist/index.d.ts +11 -2
  33. package/dist/index.js +29 -29
  34. package/docs/README.md +5 -4
  35. package/docs/agent-guide.md +12 -1
  36. package/docs/ai-gates.md +5 -0
  37. package/docs/brownfield-adoption.md +7 -1
  38. package/docs/configuration.md +2 -1
  39. package/docs/develop.md +5 -2
  40. package/docs/diagnostics.md +15 -5
  41. package/docs/package-surface.md +6 -5
  42. package/docs/threat-model.md +1 -1
  43. package/package.json +1 -1
  44. package/schemas/ark.config.schema.json +6 -0
  45. package/server.json +2 -2
  46. package/templates/agent-skills/README.md +1 -1
  47. package/templates/agent-skills/ark-adopt/SKILL.md +7 -0
  48. package/templates/agent-skills/ark-explore/SKILL.md +6 -0
  49. package/templates/agent-skills/ark-place/SKILL.md +11 -4
  50. package/templates/agent-skills/ark-upgrade/SKILL.md +22 -4
  51. package/templates/skills/ark-adopt.md +7 -0
  52. package/templates/skills/ark-explore.md +6 -0
  53. package/templates/skills/ark-place.md +11 -4
  54. package/templates/skills/ark-upgrade.md +22 -4
@@ -0,0 +1,89 @@
1
+ /**
2
+ * First-class writePath / CI honesty file (`.ark/ci-merge-boundary.json`).
3
+ * Agents must read this instead of grepping node_modules dist.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export const CI_MERGE_BOUNDARY_REL = '.ark/ci-merge-boundary.json';
9
+ export const CI_MERGE_BOUNDARY_SCHEMA = '1.0';
10
+
11
+ /**
12
+ * @param {{
13
+ * writePath?: object,
14
+ * github?: { requiredStatusConfigured?: boolean, plan?: string, canRequire?: boolean, error?: string },
15
+ * }} input
16
+ */
17
+ export function buildCiMergeBoundary(input = {}) {
18
+ const writePath = input.writePath && typeof input.writePath === 'object' ? input.writePath : {};
19
+ const inventory = writePath.inventory?.hosts && typeof writePath.inventory.hosts === 'object'
20
+ ? writePath.inventory.hosts
21
+ : {};
22
+ const perHost = {};
23
+ for (const [host, record] of Object.entries(inventory)) {
24
+ const caps = record?.capabilities && typeof record.capabilities === 'object' ? record.capabilities : {};
25
+ const configured = Boolean(record?.configured || caps['hard-write'] || caps['advisory-write']);
26
+ const fired = Boolean(writePath.enforcementState?.localWrite?.runtimeObserved);
27
+ const hard = caps['hard-write'] === true;
28
+ perHost[host] = {
29
+ configured,
30
+ fired,
31
+ state: configured && !fired ? 'configured-not-fired' : fired ? 'observed' : 'absent',
32
+ writePath: hard ? 'hard' : caps['advisory-write'] ? 'soft' : 'none',
33
+ };
34
+ }
35
+
36
+ const hookConfigured = Object.values(perHost).some((h) => h.configured);
37
+ const hookFired = Object.values(perHost).some((h) => h.fired);
38
+ const github = input.github && typeof input.github === 'object' ? input.github : {};
39
+ const workflowPresent = Boolean(
40
+ writePath.capabilities?.['merge-gate'] || writePath.inventory?.capabilities?.['merge-gate']
41
+ );
42
+ const required = github.requiredStatusConfigured === true;
43
+ const canRequire = github.canRequire !== false && github.plan !== 'free';
44
+ let ciState = 'absent';
45
+ if (workflowPresent && required) ciState = 'required';
46
+ else if (workflowPresent && !canRequire) ciState = 'present-but-github-free-cannot-require';
47
+ else if (workflowPresent) ciState = 'present-but-not-required';
48
+
49
+ return {
50
+ schemaVersion: CI_MERGE_BOUNDARY_SCHEMA,
51
+ notAScore: true,
52
+ path: CI_MERGE_BOUNDARY_REL,
53
+ hook: {
54
+ configured: hookConfigured,
55
+ fired: hookFired,
56
+ state: hookConfigured && !hookFired ? 'configured-not-fired' : hookFired ? 'observed' : 'absent',
57
+ },
58
+ writePath: perHost,
59
+ ci: {
60
+ workflowPresent,
61
+ requiredStatusConfigured: required,
62
+ state: ciState,
63
+ },
64
+ githubPlan: {
65
+ plan: github.plan ?? (canRequire ? 'unknown' : 'free'),
66
+ canRequire,
67
+ reason: canRequire ? null : 'github-free-cannot-require',
68
+ },
69
+ hookGreenIsNotTreeGreen: true,
70
+ scriptedEditsBypassPreToolUse: true,
71
+ note:
72
+ 'Do not reverse-engineer node_modules/arkgate/dist. This file is the honesty surface for writePath and CI.',
73
+ };
74
+ }
75
+
76
+ export function writeCiMergeBoundary(root, input = {}) {
77
+ const payload = buildCiMergeBoundary(input);
78
+ const dir = path.join(root, '.ark');
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ const dest = path.join(dir, 'ci-merge-boundary.json');
81
+ const next = `${JSON.stringify(payload, null, 2)}\n`;
82
+ try {
83
+ if (fs.existsSync(dest) && fs.readFileSync(dest, 'utf8') === next) return payload;
84
+ } catch {
85
+ /* rewrite */
86
+ }
87
+ fs.writeFileSync(dest, next);
88
+ return payload;
89
+ }
@@ -154,6 +154,8 @@ export const ARK_CONFIG_SCHEMA = {
154
154
  pure: { type: 'boolean' },
155
155
  mayImportInfrastructure: { type: 'boolean' },
156
156
  optional: { type: 'boolean' },
157
+ reserved: { type: 'boolean' },
158
+ allowEmpty: { type: 'boolean' },
157
159
  },
158
160
  },
159
161
  rule: {
@@ -32,7 +32,7 @@ function entry(ruleId, category, title, why, fix, extras) {
32
32
  */
33
33
  export const DIAGNOSTIC_CATALOG = Object.freeze([
34
34
  // ── layer / graph ────────────────────────────────────────────────────────
35
- entry('LAYER_IMPORT_VIOLATION', 'layer', 'Layer import not allowed', 'A module import (or re-export) crosses a layer edge that ark.config.json does not allow. The architecture contract forbids that dependency direction so outer infrastructure cannot leak into pure or inner layers.', 'Define a port in the source layer, inject the outer-layer implementation, or move/share the type with `import type` when the edge is type-only then preflight again. Do not weaken the layer rule without a hash-bound policy acknowledgement.'),
35
+ entry('LAYER_IMPORT_VIOLATION', 'layer', 'Layer import not allowed', 'A module import (or re-export) crosses a layer edge that ark.config.json does not allow. The architecture contract forbids that dependency direction so outer infrastructure cannot leak into pure or inner layers.', 'Branch by import kind: constants/types/pure → adopt into DomainModel or SharedKernel (do not invent a port); kernel/events/bootstrap from Persistence inject a port or move the map to SharedTypes (Persistence must not emit); define a port only when the target is a real use-case. Type-only edges use `import type`. Then preflight again. Do not weaken the layer rule without a hash-bound policy acknowledgement.'),
36
36
  entry('LAYER_INTENT_REFERENCE_VIOLATION', 'layer', 'Intent referenced across a blocked layer edge', 'A string intent (or intent-like reference) names a layer that the file’s layer may not reach under the contract rules — the same plane as import edges, for event/intent coupling.', 'Reference that intent from a layer allowed to know about it (usually an adapter or application layer), or relocate the reference — then preflight again.'),
37
37
  entry('LAYER_REFERENCE_VIOLATION', 'layer', 'Layer reference blocked (snippet / AI gate)', 'Snippet analysis found an intent or string reference that would couple layers in a direction the architecture profile forbids.', 'Move the reference to an allowed layer or introduce a port/event boundary, then re-run the snippet gate.'),
38
38
  entry('CIRCULAR_DEPENDENCY', 'layer', 'Dependency cycle', 'Two or more modules import each other in a loop. Cycles make ownership unclear and break stable layer direction.', 'Extract the shared dependency into a third module, invert one edge behind a port, or merge units that are truly one — then preflight again.'),
@@ -55,7 +55,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
55
55
  entry('ARKRULE_STRUCTURE', 'arkrules', 'ArkRule structure sensor failed', 'An opt-in ArkRules structure sensor (private state, factory shape, event publish, …) failed on a governed file for a declared arkruleId.', 'Restore the declared structure for the ArkRule (see arkruleSource), then preflight again. Do not demote the rule without a hash-bound policy acknowledgement.'),
56
56
  entry('ARKRULE_INVARIANT', 'arkrules', 'ArkRule invariant failed', 'Reserved / remediation-recognized code for invariant-plane failures bound to an ArkRule id (coverage path also emits INVARIANT_UNCOVERED).', 'Fix the invariant for the ArkRule declared in arkrules/<Layer>.json, then preflight again. Do not demote without acknowledgement.'),
57
57
  entry('ARKRULE_SCOPE_EMPTY', 'arkrules', 'ArkRule appliesTo matched zero files', 'An ArkRule’s appliesTo globs matched no governed files — the rule cannot observe what it claims to protect.', 'Fix appliesTo globs so they match governed files, or remove the rule. Enforced empty scope fails; advisory empty scope warns.', { oftenAdvisory: true }),
58
- entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Missing test globs report partial — never fake green.'),
58
+ entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial). Kind is never-had-tests (adopt residual) vs tests-disappeared (suite exists).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Treat never-had-tests as adopt residual; treat tests-disappeared as a regression. Missing test globs report partial — never fake green.'),
59
59
  // ── atomic preflight / change set ────────────────────────────────────────
60
60
  entry('INVALID_CHANGE_PATH', 'preflight', 'Unsafe change path', 'A change set entry is not a safe, non-empty project-relative path (absolute, escape, empty, or NUL).', 'Use canonical project-relative paths only in the atomic change set, then preflight again.'),
61
61
  entry('DUPLICATE_CHANGE_PATH', 'preflight', 'Duplicate path in change set', 'The atomic change set lists more than one operation for the same path.', 'Collapse to one create/update/delete per path, then preflight again.'),
@@ -69,7 +69,8 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
69
69
  entry('ATOMIC_PREFLIGHT_UNAVAILABLE', 'preflight', 'Atomic preflight unavailable', 'The host/MCP path could not run the atomic preflight engine (missing facts, incomplete setup, or unsupported mode).', 'Use resolved-candidate facts / ark_prepare_change with a complete batch, or fall back to ark-check on disk. Do not treat missing preflight as green.'),
70
70
  entry('DESIGN_SMELL_REGRESSION', 'preflight', 'Design smell regression on base-relative ratchet', 'Compared to the base ref, the candidate introduces or worsens a blocking design-smell class (e.g. domain-logic-in-ui) under --fail-on-new-smells.', 'Revert the regression or redesign so the smell does not worsen versus base, then re-run with the same base ref.'),
71
71
  // ── analysis completeness / host ─────────────────────────────────────────
72
- entry('ANALYSIS_PARSE_INCOMPLETE', 'analysis', 'Parse incomplete', 'Governed source could not be fully parsed; analysis is partial and must not paint green.', 'Fix syntax/parse errors in governed files (or restore a usable TypeScript host), then re-run. Partial never means pass.'),
72
+ entry('ANALYSIS_PARSE_INCOMPLETE', 'analysis', 'Parse incomplete', 'Governed source could not be fully parsed; evidence includes the TypeScript diagnostic (line + message). Incremental mid-edit parse is normal for agents. Contract exclude paths skip the write hook.', 'Finish the source or fix the reported syntax error, then re-run `npx arkgate-check`. The write hook does not deny solely on mid-edit parse. Partial never means pass.'),
73
+ entry('LEXICAL_EVIDENCE_INCOMPLETE', 'analysis', 'Lexical evidence incomplete', 'Single-file validation cannot prove project module resolution. The write hook is already the verdict.', 'Re-run `npx arkgate-check --root . --config ark.config.json`, or treat the hook deny as final. Do not call ark_prepare_change from a hook deny.'),
73
74
  entry('ANALYSIS_HOST_UNAVAILABLE', 'analysis', 'Analysis host unavailable', 'No usable TypeScript / analysis host was available for this invocation.', 'Install a supported TypeScript version visible to the project, then re-run. Unavailable analysis is fail-closed.'),
74
75
  entry('ADAPTER_NOT_ALLOWED_FOR_PORT', 'adapter', 'Adapter not allowed for port', 'Runtime/port wiring selected an adapter implementation that the architecture profile does not allow for that port.', 'Bind an allowed adapter for the port, or adjust the profile with an explicit policy decision — then re-run.'),
75
76
  // ── AI snippet gate policy surface ───────────────────────────────────────
@@ -88,7 +89,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
88
89
  entry('CONFIG_INVALID_FORBIDDEN_GLOBALS', 'config', 'Invalid forbiddenGlobals', 'A layer’s forbiddenGlobals is not an array of strings; the entry is ignored.', 'Use an array of strings (e.g. ["fetch", "Date.now"]).', { oftenAdvisory: true }),
89
90
  entry('CONFIG_LAYER_WITHOUT_PATTERNS', 'config', 'Layer without patterns', 'A named layer has no file patterns and will never classify files.', 'Add patterns globs that match the layer’s source tree.', { oftenAdvisory: true }),
90
91
  entry('CONFIG_INVALID_LAYER_PATTERN', 'config', 'Invalid layer pattern', 'A layer pattern is not a valid glob / failed to compile.', 'Fix the pattern syntax for that layer.', { oftenAdvisory: true }),
91
- entry('CONFIG_LAYER_PATTERN_NO_MATCHES', 'config', 'Layer pattern matched no files', 'A layer pattern matched zero included files (often a typo or include mismatch).', 'Adjust the pattern or include roots so governed files match.', { oftenAdvisory: true }),
92
+ entry('CONFIG_LAYER_PATTERN_NO_MATCHES', 'config', 'Layer pattern matched no files', 'A layer pattern matched zero included files (often a typo or include mismatch). Reserved/allowEmpty houses do not emit this.', 'Adjust the pattern or include roots, or mark the layer reserved/allowEmpty if the glob is a future house.', { oftenAdvisory: true }),
92
93
  entry('CONFIG_DUPLICATE_LAYER', 'config', 'Duplicate layer name', 'The same layer name appears more than once in configuration.', 'Rename or merge duplicate layer entries.', { oftenAdvisory: true }),
93
94
  entry('CONFIG_RULE_UNKNOWN_FROM_LAYER', 'config', 'Rule unknown from layer', 'A dependency rule references a source layer name that is not declared.', 'Fix the rule’s from field to a declared layer name.', { oftenAdvisory: true }),
94
95
  entry('CONFIG_RULE_UNKNOWN_TO_LAYER', 'config', 'Rule unknown to layer', 'A dependency rule references a target layer name that is not declared.', 'Fix the rule’s to field to a declared layer name.', { oftenAdvisory: true }),
@@ -9,6 +9,16 @@ import { mergePostGreenTopActions } from './post-green-path.mjs';
9
9
 
10
10
  export function collectDoctorNextActions(ctx) {
11
11
  const actions = [];
12
+ const gatesInstalled = Array.isArray(ctx.gatesMissing) && ctx.gatesMissing.length === 0;
13
+ const planAEmpty = !ctx.activeCount;
14
+ const enforceEmptyPlan =
15
+ ctx.operatingMode === 'enforce' && planAEmpty && gatesInstalled;
16
+ if (enforceEmptyPlan) {
17
+ actions.push(
18
+ ctx.postGreenPath?.action ||
19
+ '/ark-explore shape-focus → /ark-autopilot (one B pilot) # ENFORCE + empty plan A → Shape, not reinstall gates'
20
+ );
21
+ }
12
22
  if (!ctx.analysisComplete) actions.push('restore complete analysis, then rerun ark-check --doctor');
13
23
  if (ctx.designSmells.length > 0 && ctx.postGreenPath) actions.push(ctx.postGreenPath.action);
14
24
  if (ctx.coverageHonesty.greenIsNotEnforcement && ctx.coverageHonesty.worseThanNoGate) {
@@ -31,8 +41,8 @@ export function collectDoctorNextActions(ctx) {
31
41
  `resolve the non-baselined violations — see the classified plan (${arkCommand(ctx.root, 'ark-check', '--plan')}), then /ark-autopilot`
32
42
  );
33
43
  }
34
- if (ctx.writePath?.gap?.fix) actions.push(ctx.writePath.gap.fix);
35
- if (ctx.gatesMissing.length > 0) {
44
+ if (ctx.writePath?.gap?.fix && !gatesInstalled) actions.push(ctx.writePath.gap.fix);
45
+ if (!gatesInstalled && ctx.gatesMissing.length > 0) {
36
46
  actions.push(`install gates (${arkCommand(ctx.root, 'ark-check', '--install-agent-gates')})`);
37
47
  }
38
48
  const humanSkillGaps = skillGapsForActiveHost(ctx.skillGaps);
@@ -45,10 +55,12 @@ export function collectDoctorNextActions(ctx) {
45
55
  if (legacyCodex) {
46
56
  actions.push('install Codex SKILL.md catalog (--install-agent-gates --skills-only --tools codex --force)');
47
57
  }
48
- if (remMiss + remStale > 0) {
49
- actions.push('refresh /ark-* skills (--install-agent-gates --skills-only --force)');
58
+ if (remMiss > 0) {
59
+ actions.push('install missing /ark-* skills (--install-agent-gates --skills-only --force)');
60
+ } else if (remStale > 0) {
61
+ actions.push('refresh stale /ark-* skills (--install-agent-gates --skills-only --force) — gates are installed, catalog is stale');
50
62
  }
51
- if (ctx.codexHomeGap && ctx.codexConcernActive) {
63
+ if (ctx.codexHomeGap && ctx.codexConcernActive && ctx.codexHomeGap.preferProject !== true) {
52
64
  actions.push(
53
65
  ctx.codexHomeGap.catalogMetadataInvalid
54
66
  ? 'repair invalid Codex home catalog metadata after verifying the newest installed version'
@@ -75,6 +75,7 @@ import {
75
75
  buildDeepModuleCoachAdvisory,
76
76
  printDeepModuleCoachSection,
77
77
  } from './deep-module-coach.mjs';
78
+ import { writeCiMergeBoundary } from './ci-merge-boundary.mjs';
78
79
 
79
80
  const color = {
80
81
  green: (s) => `\x1b[32m${s}\x1b[0m`,
@@ -535,6 +536,15 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
535
536
  const adoption = collectAdoptionGaps(root, config, cov);
536
537
  // Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
537
538
  const writePath = adoption.writePath ?? detectWritePathCapabilities(root);
539
+ let ciMergeBoundary = null;
540
+ try {
541
+ ciMergeBoundary = writeCiMergeBoundary(root, {
542
+ writePath,
543
+ github: adoption.deployPath?.github ?? writePath.enforcementState?.ciMerge ?? {},
544
+ });
545
+ } catch {
546
+ ciMergeBoundary = null;
547
+ }
538
548
  const baseline = readBaseline(root, '.ark-baseline.json');
539
549
  const occurrenceKeys = baselineOccurrenceKeys(violations);
540
550
  const currentKeys = new Set(occurrenceKeys);
@@ -682,6 +692,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
682
692
  (options.writeJson ?? console.log)(
683
693
  JSON.stringify(
684
694
  {
695
+ schemaVersion: '1.0',
696
+ envelope: 'doctor',
685
697
  ok: analysisComplete && (options.designDelta?.valid ?? true),
686
698
  doctor: {
687
699
  completeness,
@@ -767,6 +779,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
767
779
  skillGaps,
768
780
  ...(agentHomeGaps.length > 0 ? { agentHomeGaps } : {}),
769
781
  staleRunnerFiles: staleRunners,
782
+ ciMergeBoundary,
770
783
  writePath: {
771
784
  activeHost: writePath.activeHost,
772
785
  support: writePath.support,
@@ -897,6 +910,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
897
910
  safetyHasEntries,
898
911
  showNewHere,
899
912
  designFitness,
913
+ operatingMode,
900
914
  });
901
915
  console.log('');
902
916
  if (isDoctorHealthyNothingToDo(designFitness, uniqueActions)) {
@@ -523,6 +523,8 @@ export function buildProductHonesty(input = {}) {
523
523
  primaryMessage,
524
524
  primaryNextAction,
525
525
  headline,
526
+ hookGreenIsNotTreeGreen: true,
527
+ scriptedEditsBypassPreToolUse: true,
526
528
  };
527
529
  }
528
530
 
@@ -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,
@@ -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);
@@ -725,7 +730,7 @@ export function buildPostUpgradeChecks(root, options = {}) {
725
730
  neverGateInput: true,
726
731
  checks,
727
732
  mcpNote:
728
- 'If you used Ark MCP this session: restart/retarget MCP after package bump so process arkgateVersion matches project install; always pass project.expectedRoot + expectedProjectId (WI01). Prefer project-local CLI until identity matched and versions align.',
733
+ 'If you used Ark MCP this session: restart/retarget after the package bump. A stale process is non-authoritative and project tools return PROCESS_PACKAGE_STALE; always pass project.expectedRoot + expectedProjectId. Prefer project-local CLI until identity and package versions align.',
729
734
  };
730
735
  }
731
736
 
@@ -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/**',
@@ -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
  ],