arkgate 4.8.16 → 4.8.18

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 (46) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +10 -7
  3. package/bin/ark-check-runtime.mjs +5 -1
  4. package/bin/ark-mcp-runtime.mjs +14 -7
  5. package/bin/ark-shared.mjs +10 -4
  6. package/bin/ark.mjs +6 -0
  7. package/bin/lib/adr-path.mjs +116 -0
  8. package/bin/lib/adr-presence.mjs +3 -2
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/doctor-human.mjs +2 -2
  12. package/bin/lib/doctor-next-actions.mjs +117 -17
  13. package/bin/lib/first-run-help.mjs +6 -2
  14. package/bin/lib/html-report.mjs +1 -1
  15. package/bin/lib/policy-delta-io.mjs +19 -10
  16. package/bin/lib/presets.mjs +15 -21
  17. package/bin/lib/skill-install.mjs +42 -4
  18. package/bin/lib/start-preview.mjs +75 -3
  19. package/dist/{diagnosticCatalog-KWvGLI1U.d.ts → diagnosticCatalog-BNxKdcN4.d.ts} +7 -1
  20. package/dist/index.cjs +9 -9
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +11 -11
  23. package/dist/nestjs/index.cjs +1 -1
  24. package/dist/nestjs/index.js +1 -1
  25. package/dist/runtime/index.cjs +6 -6
  26. package/dist/runtime/index.d.ts +1 -1
  27. package/dist/runtime/index.js +6 -6
  28. package/docs/README.md +2 -2
  29. package/docs/agent-guide.md +9 -2
  30. package/docs/ai-gates.md +4 -0
  31. package/docs/configuration.md +6 -3
  32. package/docs/enthusiast/how-to-agent-gates.md +3 -1
  33. package/docs/enthusiast/how-to-pick-shape.md +1 -1
  34. package/docs/package-surface.md +1 -1
  35. package/docs/use.md +9 -4
  36. package/package.json +1 -1
  37. package/server.json +2 -2
  38. package/templates/agent-skills/README.md +1 -1
  39. package/templates/agent-skills/ark-adopt/SKILL.md +8 -5
  40. package/templates/agent-skills/ark-explain/SKILL.md +3 -0
  41. package/templates/agent-skills/ark-explore/SKILL.md +3 -1
  42. package/templates/agent-skills/ark-upgrade/SKILL.md +1 -0
  43. package/templates/skills/ark-adopt.md +8 -5
  44. package/templates/skills/ark-explain.md +3 -0
  45. package/templates/skills/ark-explore.md +3 -1
  46. package/templates/skills/ark-upgrade.md +1 -0
@@ -11,7 +11,7 @@ import { printDoctorAdvisories, printCompactExtraDoctorLines } from './doctor-ad
11
11
  import { designDeltaDoctorLines } from './design-delta.mjs';
12
12
  import { enforcementDoctorLines } from './enforcement-state.mjs';
13
13
  import { analysisIncompleteStatement } from './analysis-completeness.mjs';
14
- import { skillGapsForActiveHost, detectCodexHomeGap, codexConcernIsActive } from './agent-gates.mjs';
14
+ import { skillGapsForActiveHost, detectCodexHomeGap, codexConcernIsActive, skillGapToolLabel } from './agent-gates.mjs';
15
15
  import { agentHomeConcernIsActive } from './agent-homes.mjs';
16
16
  import { layerGuidanceLine } from './layer-description.mjs';
17
17
  import {
@@ -467,7 +467,7 @@ export function printDoctorDetailsHuman(view) {
467
467
  if (remMiss + remStale > 0) {
468
468
  line(
469
469
  warn,
470
- `${remMiss} missing / ${remStale} content-behind-package /ark-* skill(s) for ${remainingGaps.map((g) => g.tool).join(', ')}`
470
+ `${remMiss} missing / ${remStale} content-behind-package /ark-* skill(s) for ${remainingGaps.map((g) => skillGapToolLabel(g.tool)).join(', ')}`
471
471
  );
472
472
  }
473
473
  const codexHomeGap = detectCodexHomeGap(root);
@@ -2,7 +2,7 @@
2
2
  * Rank doctor next actions from already-computed facts (no I/O).
3
3
  * Lets the human printer show light + #1 before honesty/compass sections.
4
4
  */
5
- import { arkCommand } from '../ark-shared.mjs';
5
+ import { arkCommand, globToRegExp } from '../ark-shared.mjs';
6
6
  import { skillGapsForActiveHost } from './agent-gates.mjs';
7
7
  import { agentHomeConcernIsActive, agentHomeRefreshCommand } from './agent-homes.mjs';
8
8
  import { mergePostGreenTopActions } from './post-green-path.mjs';
@@ -17,6 +17,99 @@ function missingGateFiles(ctx) {
17
17
  return list;
18
18
  }
19
19
 
20
+ /** Ignore a handful of expected Next API dual-matches. */
21
+ const DUAL_MATCH_REPAIR_FLOOR = 20;
22
+ /** Absolute pile-up even when the tree is huge. */
23
+ const DUAL_MATCH_REPAIR_ABSOLUTE = 50;
24
+ /** Share of in-scope files that match two+ layers. */
25
+ const DUAL_MATCH_REPAIR_SHARE = 0.1;
26
+
27
+ function isWildcardPattern(pattern) {
28
+ return /[*?]/.test(String(pattern ?? ''));
29
+ }
30
+
31
+ function posixRel(file) {
32
+ return String(file ?? '').split(/\\/).join('/');
33
+ }
34
+
35
+ function layerPatterns(cov, layerName) {
36
+ const rows = Array.isArray(cov?.layers) ? cov.layers : [];
37
+ const row = rows.find((layer) => layer?.name === layerName);
38
+ return Array.isArray(row?.patterns) ? row.patterns : [];
39
+ }
40
+
41
+ function fileMatchesWildcardPatterns(file, patterns) {
42
+ const rel = posixRel(file);
43
+ return patterns.some((pattern) => isWildcardPattern(pattern) && globToRegExp(pattern).test(rel));
44
+ }
45
+
46
+ /**
47
+ * Domain overlap is a glob leak when Domain matched the sample via a wildcard.
48
+ * Exact-file Domain listings under another layer glob (mother generated CLI)
49
+ * are intentional dual-lists, not a repair. No layer patterns → keep the
50
+ * Domain-sample shortcut so older #269 fixtures still fire.
51
+ */
52
+ function domainSampleIsGlobLeak(row, cov) {
53
+ if (!(row?.layers ?? []).includes('DomainModel')) return false;
54
+ const patterns = layerPatterns(cov, 'DomainModel');
55
+ if (patterns.length === 0) return true;
56
+ return fileMatchesWildcardPatterns(row.file, patterns);
57
+ }
58
+
59
+ /** Overlapping roots from matching Domain wildcards, else sample path prefixes. */
60
+ export function overlappingRootsFromCoverage(cov) {
61
+ const samples = Array.isArray(cov?.dualMembership?.samples) ? cov.dualMembership.samples : [];
62
+ const domainPatterns = layerPatterns(cov, 'DomainModel');
63
+ const fromPatterns = [];
64
+ for (const row of samples) {
65
+ const rel = posixRel(row?.file);
66
+ for (const pattern of domainPatterns) {
67
+ if (isWildcardPattern(pattern) && globToRegExp(pattern).test(rel)) {
68
+ fromPatterns.push(pattern);
69
+ }
70
+ }
71
+ }
72
+ if (fromPatterns.length > 0) {
73
+ return [...new Set(fromPatterns)].slice(0, 2);
74
+ }
75
+ const derived = [];
76
+ for (const row of samples) {
77
+ const first = posixRel(row?.file).split('/').filter(Boolean)[0];
78
+ if (first) derived.push(`${first}/**`);
79
+ }
80
+ return [...new Set(derived)].slice(0, 2);
81
+ }
82
+
83
+ /**
84
+ * Dual-match is a lying layer map when it is large — especially Domain
85
+ * overlapping Presentation/Application after over-broad monorepo start globs.
86
+ * Intentional file+glob dual-lists (exact Domain files under Tooling `bin/**`)
87
+ * do not steal doctor #1.
88
+ */
89
+ export function dualMatchNeedsGlobRepair(cov) {
90
+ const count = Number(cov?.dualMembership?.count) || 0;
91
+ if (count < DUAL_MATCH_REPAIR_FLOOR) return false;
92
+ const total = Number(cov?.totalFiles ?? cov?.governed?.totalFiles) || 0;
93
+ const samples = Array.isArray(cov?.dualMembership?.samples) ? cov.dualMembership.samples : [];
94
+ const domainOverlap = samples.some((row) => domainSampleIsGlobLeak(row, cov));
95
+ if (domainOverlap) return true;
96
+ if (total > 0 && count / total >= DUAL_MATCH_REPAIR_SHARE) return true;
97
+ return count >= DUAL_MATCH_REPAIR_ABSOLUTE;
98
+ }
99
+
100
+ export function overlappingGlobNextAction(cov) {
101
+ const count = Number(cov?.dualMembership?.count) || 0;
102
+ const sample = Array.isArray(cov?.dualMembership?.samples) ? cov.dualMembership.samples[0] : null;
103
+ const layers = Array.isArray(sample?.layers) ? sample.layers.filter(Boolean) : [];
104
+ const example =
105
+ sample?.file && layers.length > 1
106
+ ? `${sample.file} matches ${layers.join(' + ')}`
107
+ : 'the same files match two layer globs';
108
+ const roots = overlappingRootsFromCoverage(cov);
109
+ const rootPhrase = roots.length > 0 ? roots.join(', ') : 'the overlapping globs';
110
+ return `Fix overlapping layer globs — ${count} files match more than one layer (e.g. ${example}). Narrow overlapping roots like ${rootPhrase}. Then /ark-adopt`;
111
+ }
112
+
20
113
  export function collectDoctorNextActions(ctx) {
21
114
  const actions = [];
22
115
  const missingFiles = missingGateFiles(ctx);
@@ -37,9 +130,26 @@ export function collectDoctorNextActions(ctx) {
37
130
  if (ctx.layerOwners?.required && ctx.layerOwners.nextAction) {
38
131
  actions.push(ctx.layerOwners.nextAction);
39
132
  }
133
+ const humanSkillGaps = skillGapsForActiveHost(ctx.skillGaps ?? []);
134
+ const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
135
+ const remainingGaps = humanSkillGaps.filter(
136
+ (g) => !(g.tool === 'codex' && (g.legacyPromptsOnly || g.legacyAdvisory))
137
+ );
138
+ const remMiss = remainingGaps.reduce((s, g) => s + g.missing, 0);
139
+ const remStale = remainingGaps.reduce((s, g) => s + g.stale, 0);
140
+ if (legacyCodex) {
141
+ actions.push('install Codex SKILL.md catalog (--install-agent-gates --skills-only --tools codex --force)');
142
+ }
143
+ if (remMiss > 0) {
144
+ actions.push('install missing /ark-* skills (--install-agent-gates --skills-only --force)');
145
+ } else if (remStale > 0) {
146
+ actions.push('refresh stale /ark-* skills (--install-agent-gates --skills-only --force) — gates are installed, catalog is stale');
147
+ }
40
148
  const enforceEmptyPlan =
41
149
  ctx.operatingMode === 'enforce' && planAEmpty && gatesInstalled && !notAdopted;
42
- if (enforceEmptyPlan) {
150
+ // Stale/missing doors outrank the Shape nudge — a colleague on an old catalog
151
+ // must see skills-only refresh as #1, not leftover-design explore.
152
+ if (enforceEmptyPlan && remMiss === 0 && remStale === 0 && !legacyCodex) {
43
153
  actions.push(
44
154
  ctx.postGreenPath?.action ||
45
155
  '/ark-explore, then one small refactor with /ark-autopilot and your OK'
@@ -70,6 +180,8 @@ export function collectDoctorNextActions(ctx) {
70
180
  if (ctx.coverageHonesty.greenIsNotEnforcement && ctx.coverageHonesty.worseThanNoGate) {
71
181
  actions.push('raise governed coverage above a minority slice before treating green as enforcement');
72
182
  }
183
+ const overlapAction = dualMatchNeedsGlobRepair(ctx.cov) ? overlappingGlobNextAction(ctx.cov) : null;
184
+ if (overlapAction) actions.push(overlapAction);
73
185
  if (ctx.cov.suggestions.length > 0) actions.push('classify the ungoverned directories (/ark-adopt)');
74
186
  if (ctx.packageVersionTruth?.dualTruth) {
75
187
  actions.push(
@@ -100,21 +212,6 @@ export function collectDoctorNextActions(ctx) {
100
212
  : 'Remove the skippable if:, or write .ark/adoption-stance.json with stance: advisory-only')
101
213
  );
102
214
  }
103
- const humanSkillGaps = skillGapsForActiveHost(ctx.skillGaps);
104
- const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
105
- const remainingGaps = humanSkillGaps.filter(
106
- (g) => !(g.tool === 'codex' && (g.legacyPromptsOnly || g.legacyAdvisory))
107
- );
108
- const remMiss = remainingGaps.reduce((s, g) => s + g.missing, 0);
109
- const remStale = remainingGaps.reduce((s, g) => s + g.stale, 0);
110
- if (legacyCodex) {
111
- actions.push('install Codex SKILL.md catalog (--install-agent-gates --skills-only --tools codex --force)');
112
- }
113
- if (remMiss > 0) {
114
- actions.push('install missing /ark-* skills (--install-agent-gates --skills-only --force)');
115
- } else if (remStale > 0) {
116
- actions.push('refresh stale /ark-* skills (--install-agent-gates --skills-only --force) — gates are installed, catalog is stale');
117
- }
118
215
  if (ctx.codexHomeGap && ctx.codexConcernActive && ctx.codexHomeGap.duplicateHome) {
119
216
  actions.push(
120
217
  'remove duplicate Codex home /ark-* skills (project .agents/skills is enough): --install-agent-gates --skills-only --prune-home-duplicates'
@@ -162,6 +259,9 @@ export function collectDoctorNextActions(ctx) {
162
259
  if (ctx.designFitness.designWeak && unique.length === 0 && ctx.postGreenPath) {
163
260
  unique.push(ctx.postGreenPath.action);
164
261
  }
262
+ if (overlapAction) {
263
+ return [overlapAction, ...unique.filter((a) => a !== overlapAction)];
264
+ }
165
265
  if (notAdopted) {
166
266
  const next = ctx.notAdoptedNextAction || NOT_ADOPTED_NEXT_ACTION;
167
267
  return [next, ...unique.filter((a) => a !== next)];
@@ -12,6 +12,7 @@ ${NORTH_STAR_ONE_LINE}
12
12
  arkgate start preview (no writes)
13
13
  arkgate start --apply write host + CI setup
14
14
  (refuses weak coverage/shape; lock with --archetype/--preset/--force)
15
+ (refuses a plan too big for compact start; next: arkgate-check --init)
15
16
  arkgate-check --doctor status — one next step
16
17
  (if missing: npx --package=arkgate arkgate-check --doctor)
17
18
 
@@ -70,6 +71,7 @@ Options:
70
71
  --yes Non-interactive defaults: create config if needed, install gate templates, run strict check.
71
72
  (Also the implicit default when stdin/stdout are not a TTY — agents never hang on prompts.)
72
73
  --force Allow generated files to overwrite existing files.
74
+ Unlocks weak coverage/shape on start --apply. Does not unlock a plan too big for compact start.
73
75
  --no-strict Skip the final strict ark-check run.
74
76
  --install Pin and install arkgate as a project devDependency (default for start).
75
77
  --no-install Skip adding/installing arkgate as a project devDependency (start/upgrade).
@@ -271,7 +273,8 @@ export function checkUsageAll() {
271
273
  'This merge profile never depends on an editor/agent hook.',
272
274
  'When a Git merge base is available, --strict-merge classifies the ark.config.json',
273
275
  'transition. Weakening or judgment-required findings fail unless --policy-ack names',
274
- 'every finding and is bound to both policy hashes. Use --policy-base/--policy-base-ref',
276
+ 'every finding, is bound to both policy hashes, and adrPath points at a short note',
277
+ 'under docs/adr/ or docs/decisions/. Use --policy-base/--policy-base-ref',
275
278
  'for an explicit comparison; ARK_POLICY_BASE_REF is the CI environment equivalent.',
276
279
  'The same merge profile blocks new UI business-rule files (domain-logic-in-ui) created',
277
280
  'versus that base; leftover design on existing files stays green. Missing base skips',
@@ -302,7 +305,8 @@ export function checkUsageAll() {
302
305
  'rule file. Existing files are never overwritten without --force, so re-running',
303
306
  'after an update only adds what is missing. --skills-only restricts the write to',
304
307
  'just the /ark-* skills (safe to --force-refresh — it leaves a customized AGENTS.md,',
305
- 'settings, and CI workflow untouched).',
308
+ 'settings, and CI workflow untouched). Doctor / ark-check warn when an already-installed',
309
+ 'catalog is behind this package; that next action is --skills-only --force, not re-adopt.',
306
310
  'Pass --tools to pick which tool configs to write; otherwise they are auto-detected',
307
311
  'from their config directories (.claude/, .cursor/, .codex/, .grok/, .windsurf/,',
308
312
  '.clinerules/, .kiro/, .roo/, .continue/, .gemini/; copilot is explicit-only).',
@@ -905,7 +905,7 @@ export function renderHtmlReport({
905
905
  );
906
906
  } else {
907
907
  skillsParts.push(
908
- `<div class="pill warn">${actionableSkillGaps.length} skill gap(s) — run ark upgrade / --install-agent-gates</div>`
908
+ `<div class="pill warn">${actionableSkillGaps.length} skill gap(s) — refresh skills only: ark-check --install-agent-gates --skills-only --force</div>`
909
909
  );
910
910
  }
911
911
  if (legacyAdvisoryOnly) {
@@ -9,6 +9,7 @@ import {
9
9
  loadInvariantCoverageInputs,
10
10
  } from './invariant-coverage-io.mjs';
11
11
  import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
12
+ import { attachPolicyAdrNote } from './adr-path.mjs';
12
13
 
13
14
  function readJsonFile(filePath, label) {
14
15
  if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
@@ -196,14 +197,22 @@ export function analyzePolicyTransition({
196
197
  candidateInvariantCoverage = evaluated.coverage;
197
198
  }
198
199
 
199
- return analyzePolicyDelta({
200
- baseConfig: base.config,
201
- candidateConfig,
202
- acknowledgement: readPolicyAcknowledgement(root, acknowledgementPath),
203
- baseSource: base.source,
204
- candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
205
- ...(baseArkRules ? { baseArkRules } : {}),
206
- candidateArkRules,
207
- ...(candidateInvariantCoverage ? { candidateInvariantCoverage } : {}),
208
- });
200
+ const acknowledgement = readPolicyAcknowledgement(root, acknowledgementPath);
201
+ return attachPolicyAdrNote(
202
+ analyzePolicyDelta({
203
+ baseConfig: base.config,
204
+ candidateConfig,
205
+ acknowledgement,
206
+ baseSource: base.source,
207
+ candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
208
+ ...(baseArkRules ? { baseArkRules } : {}),
209
+ candidateArkRules,
210
+ ...(candidateInvariantCoverage ? { candidateInvariantCoverage } : {}),
211
+ }),
212
+ {
213
+ root,
214
+ acknowledgement,
215
+ failClosed: Boolean(strictMerge || acknowledgementPath),
216
+ }
217
+ );
209
218
  }
@@ -629,27 +629,21 @@ export const ARCHITECTURE_PRESETS = {
629
629
  }
630
630
  // Turborepo: apps/ + packages/; Nx enterprise: apps/ + libs/ (+ packages/).
631
631
  if (include.length === 0) include = ['packages', 'apps', 'libs'];
632
- // Established workspaces often have flat package source roots rather than a
633
- // directory named domain/application. The package manifest supplies a real,
634
- // reviewable role signal: a published library is a domain surface; an app or
635
- // CLI package coordinates work. This is deliberately limited to package roots
636
- // Ark already discovered, never a catch-all **/src/** fallback.
637
- const packagePatterns = (roles) => {
638
- if (!root) return [];
639
- return units
640
- .filter((unit) => roles.includes(unit.role))
641
- .flatMap((unit) => unit.sourceRoots.map((sourceRoot) => {
642
- const base = unit.root === '.'
643
- ? sourceRoot
644
- : sourceRoot === '.'
645
- ? unit.root
646
- : `${unit.root}/${sourceRoot}`;
647
- return base === '.' ? null : `${base}/**`;
648
- }))
649
- .filter(Boolean);
650
- };
651
- const librarySourcePatterns = packagePatterns(['library']);
652
- const applicationSourcePatterns = packagePatterns(['application', 'cli']);
632
+ // Library Domain bags stay under packages/ or libs/ never api/** or client/**.
633
+ const roleGlobs = (roles, domainScoped) =>
634
+ !root
635
+ ? []
636
+ : units
637
+ .filter((unit) => roles.includes(unit.role) && !(domainScoped && unit.root === '.'))
638
+ .flatMap((unit) =>
639
+ (unit.sourceRoots ?? []).map((sourceRoot) => {
640
+ const base = unit.root === '.' ? sourceRoot : sourceRoot === '.' ? unit.root : `${unit.root}/${sourceRoot}`;
641
+ return base === '.' ? null : `${base}/**`;
642
+ })
643
+ )
644
+ .filter((pattern) => pattern && (!domainScoped || /^(packages|libs)\//.test(pattern)));
645
+ const librarySourcePatterns = roleGlobs(['library'], true);
646
+ const applicationSourcePatterns = roleGlobs(['application', 'cli'], false);
653
647
  return presetWithOverlays(
654
648
  {
655
649
  include,
@@ -261,8 +261,22 @@ export const SKILL_TOOL_TARGETS = {
261
261
  windsurf: (name) => `.windsurf/workflows/${name}.md`,
262
262
  cline: (name) => `.clinerules/workflows/${name}.md`,
263
263
  copilot: (name) => `.github/prompts/${name}.prompt.md`,
264
+ // Catalog-only gap: `.agents/skills` exists without a host marker that reads it.
265
+ agents: (name) => canonicalSkillPath(name),
264
266
  };
265
267
 
268
+ /** Synthetic detectSkillGaps tool when only the canonical catalog is present. */
269
+ export const SKILL_CANONICAL_TOOL = 'agents';
270
+
271
+ export function skillGapToolLabel(tool) {
272
+ return tool === SKILL_CANONICAL_TOOL ? SKILL_CANONICAL_DIR : String(tool ?? '');
273
+ }
274
+
275
+ export function skillTargetIsCanonical(tool) {
276
+ const target = SKILL_TOOL_TARGETS[tool];
277
+ return typeof target === 'function' && target('ark-place') === canonicalSkillPath('ark-place');
278
+ }
279
+
266
280
  /** Hosts whose catalog is the project `.agents/skills` tree (write once). */
267
281
  export function usesCanonicalSkillCatalog(tool) {
268
282
  return (
@@ -1086,10 +1100,32 @@ export function detectSkillGaps(root) {
1086
1100
  if (fs.existsSync(path.join(root, '.cursor'))) detected.push('cursor');
1087
1101
  if (fs.existsSync(path.join(root, '.codex'))) detected.push('codex');
1088
1102
  if (fs.existsSync(path.join(root, '.grok'))) detected.push('grok');
1103
+ // Same markers as resolveTools — Antigravity/OpenCode read or link the catalog.
1104
+ if (
1105
+ fs.existsSync(path.join(root, '.agents', 'hooks.json')) ||
1106
+ fs.existsSync(path.join(root, '.agents', 'mcp_config.json'))
1107
+ ) {
1108
+ detected.push('antigravity');
1109
+ }
1110
+ if (
1111
+ fs.existsSync(path.join(root, 'opencode.json')) ||
1112
+ fs.existsSync(path.join(root, 'opencode.jsonc')) ||
1113
+ fs.existsSync(path.join(root, '.opencode'))
1114
+ ) {
1115
+ detected.push('opencode');
1116
+ }
1089
1117
  if (fs.existsSync(path.join(root, '.windsurf'))) detected.push('windsurf');
1090
1118
  if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
1091
1119
  detected.push('cline');
1092
1120
  }
1121
+ // One-catalog trees may have `.agents/skills` and no host marker dir.
1122
+ // Without this, a colleague who `npm install`s a new pin keeps stale doors silently.
1123
+ if (
1124
+ fs.existsSync(path.join(root, SKILL_CANONICAL_DIR)) &&
1125
+ !detected.some((tool) => skillTargetIsCanonical(tool))
1126
+ ) {
1127
+ detected.push(SKILL_CANONICAL_TOOL);
1128
+ }
1093
1129
  const version = arkPackageVersion();
1094
1130
  const gaps = [];
1095
1131
  for (const tool of detected) {
@@ -1150,7 +1186,9 @@ export function detectSkillGaps(root) {
1150
1186
  export function skillGapsForActiveHost(skillGaps, env = process.env) {
1151
1187
  const activeHost = detectActiveAgentHost(env);
1152
1188
  if (!activeHost) return skillGaps ?? [];
1153
- return (skillGaps ?? []).filter((gap) => gap.tool === activeHost);
1189
+ return (skillGaps ?? []).filter(
1190
+ (gap) => gap.tool === activeHost || gap.tool === SKILL_CANONICAL_TOOL
1191
+ );
1154
1192
  }
1155
1193
 
1156
1194
  /**
@@ -1174,7 +1212,7 @@ export function printSkillAndCodexGapHints(root, opts) {
1174
1212
  );
1175
1213
  const missingTotal = remaining.reduce((sum, gap) => sum + gap.missing, 0);
1176
1214
  const staleTotal = remaining.reduce((sum, gap) => sum + gap.stale, 0);
1177
- const tools = remaining.map((gap) => gap.tool).join(', ');
1215
+ const tools = remaining.map((gap) => skillGapToolLabel(gap.tool)).join(', ');
1178
1216
  if (legacyCodex) {
1179
1217
  console.log(
1180
1218
  color.yellow(
@@ -1200,9 +1238,9 @@ export function printSkillAndCodexGapHints(root, opts) {
1200
1238
  }
1201
1239
  if (staleTotal > 0) {
1202
1240
  console.log(
1203
- color.dim(
1241
+ color.yellow(
1204
1242
  `${staleTotal} /ark-* skill(s) content behind this Ark package for ${tools}. ` +
1205
- `Refresh: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`
1243
+ `Refresh skills only (do not re-adopt): ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`
1206
1244
  )
1207
1245
  );
1208
1246
  }
@@ -122,6 +122,73 @@ export function setupBudget(changes) {
122
122
  };
123
123
  }
124
124
 
125
+ /** One next write when compact start is the wrong size. `--force` does not unlock. */
126
+ export const START_SETUP_BUDGET_NEXT = 'arkgate-check --init';
127
+
128
+ function formatSetupKb(bytes) {
129
+ return `${Math.max(1, Math.round(Number(bytes) / 1024))} KB`;
130
+ }
131
+
132
+ /**
133
+ * Compact-start size lock. `--force` unlocks coverage/shape only — never this.
134
+ * @param {object|null|undefined} setupBudget
135
+ * @param {{ force?: boolean }} [options]
136
+ */
137
+ export function evaluateStartSetupBudgetGate(setupBudget, { force = false } = {}) {
138
+ if (!setupBudget || setupBudget.ok) {
139
+ return { ok: true, forceIgnored: Boolean(force) };
140
+ }
141
+ const reasons = [];
142
+ const maxFiles = setupBudget.maxFiles ?? 8;
143
+ const maxBytes = setupBudget.maxBytes ?? 32 * 1024;
144
+ const gateFiles =
145
+ typeof setupBudget.gateFiles === 'number' ? setupBudget.gateFiles : setupBudget.files;
146
+ if (typeof gateFiles === 'number' && gateFiles > maxFiles) {
147
+ reasons.push(`${gateFiles} gate files (max ${maxFiles})`);
148
+ }
149
+ if (typeof setupBudget.bytes === 'number' && setupBudget.bytes >= maxBytes) {
150
+ reasons.push(`${formatSetupKb(setupBudget.bytes)} (max ${formatSetupKb(maxBytes)})`);
151
+ }
152
+ if (reasons.length === 0) {
153
+ reasons.push('the planned write is larger than compact start allows');
154
+ }
155
+ return {
156
+ ok: false,
157
+ reasons,
158
+ nextAction: START_SETUP_BUDGET_NEXT,
159
+ forceBypasses: false,
160
+ setupBudget,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Human refuse / preview warning. Avoids the first-run phrase "Compact setup budget".
166
+ * @param {object|null|undefined} setupBudget
167
+ * @param {{ applying?: boolean }} [options]
168
+ */
169
+ export function formatStartSetupBudgetRefuse(setupBudget, { applying = true } = {}) {
170
+ const gate = evaluateStartSetupBudgetGate(setupBudget);
171
+ if (gate.ok) return '';
172
+ const facts = gate.reasons.map((reason) => ` • ${reason}`).join('\n');
173
+ const next = `--force does not unlock this. Next: ${gate.nextAction}`;
174
+ if (applying) {
175
+ return `Refusing ark start --apply: this plan is too big for compact start.\n${facts}\n${next}`;
176
+ }
177
+ return `This plan is too big for compact start.\n${facts}\nApply will refuse. ${next}`;
178
+ }
179
+
180
+ /** Apply-path emit: JSON envelope or human refuse. Call before any “writing…” copy. */
181
+ export function emitStartSetupBudgetRefuse(preview, json) {
182
+ const gate = evaluateStartSetupBudgetGate(preview.setupBudget);
183
+ if (json) {
184
+ console.log(
185
+ JSON.stringify({ ok: false, error: 'start-setup-budget-gate', ...gate, preview }, null, 2)
186
+ );
187
+ return;
188
+ }
189
+ console.error(formatStartSetupBudgetRefuse(preview.setupBudget, { applying: true }));
190
+ }
191
+
125
192
  function commands(root, args, helpers) {
126
193
  if (args.removeHost) {
127
194
  return [`ark start --root ${root} --tools ${args.removeHost} --apply`];
@@ -160,6 +227,7 @@ export function formatStartPackageInstallFailure({ exitStatus, installCommand })
160
227
  */
161
228
  export function renderStartPreview(preview, options = {}) {
162
229
  const applying = options.applying === true;
230
+ const budgetOk = preview.setupBudget?.ok !== false;
163
231
  if (applying) {
164
232
  console.log(
165
233
  preview.changes.length === 0
@@ -170,7 +238,11 @@ export function renderStartPreview(preview, options = {}) {
170
238
  console.log('Ark start preview — no files were changed.');
171
239
  }
172
240
  if (!applying) {
173
- console.log('Apply this plan with: arkgate start --apply');
241
+ if (budgetOk) {
242
+ console.log('Apply this plan with: arkgate start --apply');
243
+ } else {
244
+ console.log(formatStartSetupBudgetRefuse(preview.setupBudget, { applying: false }));
245
+ }
174
246
  }
175
247
  if (preview.analysis) {
176
248
  console.log(`Your project looks like: ${preview.analysis.label}.`);
@@ -208,8 +280,8 @@ export function renderStartPreview(preview, options = {}) {
208
280
  }
209
281
 
210
282
  export function applyStartPreview(root, preview) {
211
- if (!preview.setupBudget?.ok) {
212
- throw new Error('Refusing to apply a start plan that exceeds the compact setup budget.');
283
+ if (!evaluateStartSetupBudgetGate(preview.setupBudget).ok) {
284
+ throw new Error(formatStartSetupBudgetRefuse(preview.setupBudget, { applying: true }));
213
285
  }
214
286
  for (const change of preview.changes) {
215
287
  const target = path.join(root, change.path);
@@ -392,7 +392,7 @@ declare function createAdapterResult(input: {
392
392
  }): CurrentAdapterResult;
393
393
 
394
394
  /** ArkGate library version — single source of truth. */
395
- declare const version = "4.8.16";
395
+ declare const version = "4.8.18";
396
396
 
397
397
  /**
398
398
  * AI Code Gate (basic).
@@ -1971,6 +1971,12 @@ type PolicyDeltaAcknowledgement = {
1971
1971
  candidatePolicyHash: string;
1972
1972
  findingIds: readonly string[];
1973
1973
  reason: string;
1974
+ /**
1975
+ * Relative decision-note path under a conventional home (`docs/adr/`,
1976
+ * `docs/decisions/`, or the AP01 single-file homes). Tooling checks the file.
1977
+ * Hash matching does not require it.
1978
+ */
1979
+ adrPath?: string;
1974
1980
  };
1975
1981
  type ClassifyArkPolicyDeltaOptions = {
1976
1982
  baseArkRules?: EffectiveArkRules;