arkgate 4.8.17 → 4.8.19

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 (50) hide show
  1. package/CHANGELOG.md +109 -2
  2. package/README.md +12 -10
  3. package/bin/ark-mcp-runtime.mjs +3 -3
  4. package/bin/ark-shared.mjs +197 -20
  5. package/bin/ark.mjs +24 -18
  6. package/bin/lib/agent-gates.mjs +2 -0
  7. package/bin/lib/ci-and-commands.mjs +2 -2
  8. package/bin/lib/doctor-human.mjs +4 -2
  9. package/bin/lib/doctor-next-actions.mjs +152 -17
  10. package/bin/lib/doctor-plan.mjs +6 -8
  11. package/bin/lib/first-run-help.mjs +4 -1
  12. package/bin/lib/gate-files.mjs +22 -1
  13. package/bin/lib/host-support-matrix.mjs +1 -1
  14. package/bin/lib/html-report-depth.mjs +1 -1
  15. package/bin/lib/html-report.mjs +1 -1
  16. package/bin/lib/install-migrate.mjs +3 -4
  17. package/bin/lib/managed-upgrade.mjs +30 -5
  18. package/bin/lib/mcp-adoption.mjs +4 -1
  19. package/bin/lib/presets.mjs +15 -21
  20. package/bin/lib/scan-files.mjs +3 -26
  21. package/bin/lib/skill-install.mjs +45 -6
  22. package/bin/lib/start-preview.mjs +135 -8
  23. package/bin/lib/write-path-detect.mjs +3 -2
  24. package/dist/{diagnosticCatalog-DVABouBV.d.ts → diagnosticCatalog-D2v0ASN2.d.ts} +1 -1
  25. package/dist/index.cjs +1 -1
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.js +1 -1
  28. package/dist/nestjs/index.cjs +1 -1
  29. package/dist/nestjs/index.js +1 -1
  30. package/dist/runtime/index.cjs +1 -1
  31. package/dist/runtime/index.d.ts +1 -1
  32. package/dist/runtime/index.js +1 -1
  33. package/docs/README.md +2 -2
  34. package/docs/agent-guide.md +11 -2
  35. package/docs/ai-gates.md +11 -3
  36. package/docs/develop.md +1 -2
  37. package/docs/enthusiast/how-to-pick-shape.md +1 -1
  38. package/docs/use.md +10 -6
  39. package/package.json +1 -1
  40. package/server.json +2 -2
  41. package/templates/agent-skills/README.md +1 -1
  42. package/templates/agent-skills/ark-adopt/SKILL.md +4 -3
  43. package/templates/agent-skills/ark-explain/SKILL.md +3 -0
  44. package/templates/agent-skills/ark-place/SKILL.md +2 -0
  45. package/templates/agent-skills/ark-upgrade/SKILL.md +1 -0
  46. package/templates/hooks/opencode-ark-write-gate.mjs +2 -2
  47. package/templates/skills/ark-adopt.md +4 -3
  48. package/templates/skills/ark-explain.md +3 -0
  49. package/templates/skills/ark-place.md +2 -0
  50. package/templates/skills/ark-upgrade.md +1 -0
@@ -201,7 +201,7 @@ const AGENT_CONTRACT = {
201
201
  ],
202
202
  // Cursor-only guidance: the write-time validate_code tool is available in
203
203
  // Cursor's runtime but has no equivalent in a plain AGENTS.md read.
204
- cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
204
+ cursorValidateStep: `The write gate is the host pre-hook when \`.cursor/hooks.json\` is installed, trusted, and \`failClosed\`. Call \`ark_prepare_write\` / \`validate_code\` only as fallback if that hook is missing or fail-open.`,
205
205
  };
206
206
 
207
207
  /**
@@ -413,7 +413,7 @@ Contener · Guiar · Ordenar — contain the write, guide the next step, order l
413
413
  1. Status anytime: \`${doctorCmd}\` — what's wrong and what to do first.
414
414
  2. Read the **Improvement compass** section (not a score). Name residual lenses in plain language when present (SoC, DIP, domain, …). Out-of-scope lenses (performance, app security tooling, full resilience) stay honest — do not invent Ark enforcement for them.
415
415
  3. CLI-first: if the local CLI already resolved this project root, do not wait on MCP “still connecting”. Identity handshake is optional in that case. When you do trust MCP evidence: call \`ark_identity\` with \`project.expectedRoot\` set to this project's exact absolute root, then reuse that root plus the returned \`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only with that matching id. Missing tool, non-\`matched\` binding, or wrong root means this is not proven to be the right project: restart the host and use the local CLI meanwhile.
416
- 4. Day to day: call \`ark_manifest\` with the same project expectation (or skip if CLI already bound the root); place new files with \`ark_place\` (filePath required — fail-closed); validate after edits; run \`${checkCmd}\`. The \`ark://manifest\` resource is compatibility-only and always unverified. On a deny, fix the illegal import — do not weaken \`ark.config.json\`.
416
+ 4. Day to day: call \`ark_manifest\` with the same project expectation (or skip if CLI already bound the root); place new files with \`ark_place\` (filePath required — fail-closed); run \`${checkCmd}\`. The host pre-hook is the write gate when installed; MCP \`ark_prepare_write\` is fallback if that hook is missing or fail-open. The \`ark://manifest\` resource is compatibility-only and always unverified. On a deny, fix the illegal import — do not weaken \`ark.config.json\`.
417
417
  5. If MCP is unavailable: inspect \`ark.config.json\` and run \`${checkCmd}\`. The host-readable projection is AGENTS.md and CLAUDE.md (same schema).
418
418
 
419
419
  **Single door when residual remains:**
@@ -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 {
@@ -147,6 +147,8 @@ export function printDoctorCompactHuman(view) {
147
147
  if (skippableCi) line(warn, skippableCi);
148
148
  else if (writePathHonesty?.nativeFailClosed === false) {
149
149
  line(warn, writePathHonesty.message || 'Write hook is fail-open — if the checker cannot run, the write still lands.');
150
+ } else if (listedMissing.length === 0 && writePath.gap?.id === 'write-path-mcp-only') {
151
+ line(warn, writePath.gap.message || 'Fell back to MCP prepare; required CI is the merge boundary.');
150
152
  } else if (listedMissing.length === 0 && (writePath.gap || writePathHonesty?.softWriteHost)) {
151
153
  line(warn, 'Local writes are advisory; required CI is the merge boundary.');
152
154
  }
@@ -467,7 +469,7 @@ export function printDoctorDetailsHuman(view) {
467
469
  if (remMiss + remStale > 0) {
468
470
  line(
469
471
  warn,
470
- `${remMiss} missing / ${remStale} content-behind-package /ark-* skill(s) for ${remainingGaps.map((g) => g.tool).join(', ')}`
472
+ `${remMiss} missing / ${remStale} content-behind-package /ark-* skill(s) for ${remainingGaps.map((g) => skillGapToolLabel(g.tool)).join(', ')}`
471
473
  );
472
474
  }
473
475
  const codexHomeGap = detectCodexHomeGap(root);
@@ -2,13 +2,43 @@
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';
9
9
  import { ADOPTED_NOT, NOT_ADOPTED_NEXT_ACTION } from './adoption-stance.mjs';
10
10
  import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
11
11
 
12
+ /** Package pin may exist; local resolve still failed. Lead with install, not CI. */
13
+ export const PACKAGE_UNRESOLVED_NEXT_ACTION =
14
+ 'Install arkgate in this project first (npx --package=arkgate, or pnpm add -D arkgate -w / yarn add -D arkgate -W at a workspace root), then re-run --doctor';
15
+
16
+ export function packageUnresolvedNextAction(ctx) {
17
+ if (ctx?.packageInstalled !== false || ctx?.selfHost === true) return null;
18
+ const code = ctx?.packageVersionTruth?.code;
19
+ if (code === 'PACKAGE_PIN_SELF_HOST' || code === 'PACKAGE_PIN_ABSENT') return null;
20
+ // Failed start --apply: pin is in package.json, node_modules did not resolve.
21
+ if (code === 'PACKAGE_PIN_MATCHES' || code === 'PACKAGE_PIN_BEHIND_CLI' || ctx?.packagePinned === true) {
22
+ return PACKAGE_UNRESOLVED_NEXT_ACTION;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ /** Compact doctor JSON #1 — same rank as collectDoctorNextActions when package is missing. */
28
+ export function preferredDoctorPrimaryNextAction({
29
+ adopted,
30
+ packageInstalled,
31
+ selfHost,
32
+ packageVersionTruth,
33
+ postGreenPath,
34
+ dualTruthNext,
35
+ } = {}) {
36
+ return (
37
+ packageUnresolvedNextAction({ packageInstalled, selfHost, packageVersionTruth }) ||
38
+ (adopted === 'not-adopted' ? NOT_ADOPTED_NEXT_ACTION : postGreenPath?.action ?? dualTruthNext ?? null)
39
+ );
40
+ }
41
+
12
42
  function missingGateFiles(ctx) {
13
43
  const list = Array.isArray(ctx.gatesMissing) ? ctx.gatesMissing : [];
14
44
  if (ctx.ciNotFailClosed) {
@@ -17,6 +47,99 @@ function missingGateFiles(ctx) {
17
47
  return list;
18
48
  }
19
49
 
50
+ /** Ignore a handful of expected Next API dual-matches. */
51
+ const DUAL_MATCH_REPAIR_FLOOR = 20;
52
+ /** Absolute pile-up even when the tree is huge. */
53
+ const DUAL_MATCH_REPAIR_ABSOLUTE = 50;
54
+ /** Share of in-scope files that match two+ layers. */
55
+ const DUAL_MATCH_REPAIR_SHARE = 0.1;
56
+
57
+ function isWildcardPattern(pattern) {
58
+ return /[*?]/.test(String(pattern ?? ''));
59
+ }
60
+
61
+ function posixRel(file) {
62
+ return String(file ?? '').split(/\\/).join('/');
63
+ }
64
+
65
+ function layerPatterns(cov, layerName) {
66
+ const rows = Array.isArray(cov?.layers) ? cov.layers : [];
67
+ const row = rows.find((layer) => layer?.name === layerName);
68
+ return Array.isArray(row?.patterns) ? row.patterns : [];
69
+ }
70
+
71
+ function fileMatchesWildcardPatterns(file, patterns) {
72
+ const rel = posixRel(file);
73
+ return patterns.some((pattern) => isWildcardPattern(pattern) && globToRegExp(pattern).test(rel));
74
+ }
75
+
76
+ /**
77
+ * Domain overlap is a glob leak when Domain matched the sample via a wildcard.
78
+ * Exact-file Domain listings under another layer glob (mother generated CLI)
79
+ * are intentional dual-lists, not a repair. No layer patterns → keep the
80
+ * Domain-sample shortcut so older #269 fixtures still fire.
81
+ */
82
+ function domainSampleIsGlobLeak(row, cov) {
83
+ if (!(row?.layers ?? []).includes('DomainModel')) return false;
84
+ const patterns = layerPatterns(cov, 'DomainModel');
85
+ if (patterns.length === 0) return true;
86
+ return fileMatchesWildcardPatterns(row.file, patterns);
87
+ }
88
+
89
+ /** Overlapping roots from matching Domain wildcards, else sample path prefixes. */
90
+ export function overlappingRootsFromCoverage(cov) {
91
+ const samples = Array.isArray(cov?.dualMembership?.samples) ? cov.dualMembership.samples : [];
92
+ const domainPatterns = layerPatterns(cov, 'DomainModel');
93
+ const fromPatterns = [];
94
+ for (const row of samples) {
95
+ const rel = posixRel(row?.file);
96
+ for (const pattern of domainPatterns) {
97
+ if (isWildcardPattern(pattern) && globToRegExp(pattern).test(rel)) {
98
+ fromPatterns.push(pattern);
99
+ }
100
+ }
101
+ }
102
+ if (fromPatterns.length > 0) {
103
+ return [...new Set(fromPatterns)].slice(0, 2);
104
+ }
105
+ const derived = [];
106
+ for (const row of samples) {
107
+ const first = posixRel(row?.file).split('/').filter(Boolean)[0];
108
+ if (first) derived.push(`${first}/**`);
109
+ }
110
+ return [...new Set(derived)].slice(0, 2);
111
+ }
112
+
113
+ /**
114
+ * Dual-match is a lying layer map when it is large — especially Domain
115
+ * overlapping Presentation/Application after over-broad monorepo start globs.
116
+ * Intentional file+glob dual-lists (exact Domain files under Tooling `bin/**`)
117
+ * do not steal doctor #1.
118
+ */
119
+ export function dualMatchNeedsGlobRepair(cov) {
120
+ const count = Number(cov?.dualMembership?.count) || 0;
121
+ if (count < DUAL_MATCH_REPAIR_FLOOR) return false;
122
+ const total = Number(cov?.totalFiles ?? cov?.governed?.totalFiles) || 0;
123
+ const samples = Array.isArray(cov?.dualMembership?.samples) ? cov.dualMembership.samples : [];
124
+ const domainOverlap = samples.some((row) => domainSampleIsGlobLeak(row, cov));
125
+ if (domainOverlap) return true;
126
+ if (total > 0 && count / total >= DUAL_MATCH_REPAIR_SHARE) return true;
127
+ return count >= DUAL_MATCH_REPAIR_ABSOLUTE;
128
+ }
129
+
130
+ export function overlappingGlobNextAction(cov) {
131
+ const count = Number(cov?.dualMembership?.count) || 0;
132
+ const sample = Array.isArray(cov?.dualMembership?.samples) ? cov.dualMembership.samples[0] : null;
133
+ const layers = Array.isArray(sample?.layers) ? sample.layers.filter(Boolean) : [];
134
+ const example =
135
+ sample?.file && layers.length > 1
136
+ ? `${sample.file} matches ${layers.join(' + ')}`
137
+ : 'the same files match two layer globs';
138
+ const roots = overlappingRootsFromCoverage(cov);
139
+ const rootPhrase = roots.length > 0 ? roots.join(', ') : 'the overlapping globs';
140
+ return `Fix overlapping layer globs — ${count} files match more than one layer (e.g. ${example}). Narrow overlapping roots like ${rootPhrase}. Then /ark-adopt`;
141
+ }
142
+
20
143
  export function collectDoctorNextActions(ctx) {
21
144
  const actions = [];
22
145
  const missingFiles = missingGateFiles(ctx);
@@ -26,6 +149,8 @@ export function collectDoctorNextActions(ctx) {
26
149
  if (notAdopted || ctx.adopted === ADOPTED_NOT || ctx.adopted == null) {
27
150
  actions.push(ctx.notAdoptedNextAction || NOT_ADOPTED_NEXT_ACTION);
28
151
  }
152
+ const resolvePkg = packageUnresolvedNextAction(ctx);
153
+ if (resolvePkg) actions.push(resolvePkg);
29
154
  const nudge = ctx.stewardNudge;
30
155
  if (
31
156
  nudge &&
@@ -37,9 +162,26 @@ export function collectDoctorNextActions(ctx) {
37
162
  if (ctx.layerOwners?.required && ctx.layerOwners.nextAction) {
38
163
  actions.push(ctx.layerOwners.nextAction);
39
164
  }
165
+ const humanSkillGaps = skillGapsForActiveHost(ctx.skillGaps ?? []);
166
+ const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
167
+ const remainingGaps = humanSkillGaps.filter(
168
+ (g) => !(g.tool === 'codex' && (g.legacyPromptsOnly || g.legacyAdvisory))
169
+ );
170
+ const remMiss = remainingGaps.reduce((s, g) => s + g.missing, 0);
171
+ const remStale = remainingGaps.reduce((s, g) => s + g.stale, 0);
172
+ if (legacyCodex) {
173
+ actions.push('install Codex SKILL.md catalog (--install-agent-gates --skills-only --tools codex --force)');
174
+ }
175
+ if (remMiss > 0) {
176
+ actions.push('install missing /ark-* skills (--install-agent-gates --skills-only --force)');
177
+ } else if (remStale > 0) {
178
+ actions.push('refresh stale /ark-* skills (--install-agent-gates --skills-only --force) — gates are installed, catalog is stale');
179
+ }
40
180
  const enforceEmptyPlan =
41
181
  ctx.operatingMode === 'enforce' && planAEmpty && gatesInstalled && !notAdopted;
42
- if (enforceEmptyPlan) {
182
+ // Stale/missing doors outrank the Shape nudge — a colleague on an old catalog
183
+ // must see skills-only refresh as #1, not leftover-design explore.
184
+ if (enforceEmptyPlan && remMiss === 0 && remStale === 0 && !legacyCodex) {
43
185
  actions.push(
44
186
  ctx.postGreenPath?.action ||
45
187
  '/ark-explore, then one small refactor with /ark-autopilot and your OK'
@@ -70,6 +212,8 @@ export function collectDoctorNextActions(ctx) {
70
212
  if (ctx.coverageHonesty.greenIsNotEnforcement && ctx.coverageHonesty.worseThanNoGate) {
71
213
  actions.push('raise governed coverage above a minority slice before treating green as enforcement');
72
214
  }
215
+ const overlapAction = dualMatchNeedsGlobRepair(ctx.cov) ? overlappingGlobNextAction(ctx.cov) : null;
216
+ if (overlapAction) actions.push(overlapAction);
73
217
  if (ctx.cov.suggestions.length > 0) actions.push('classify the ungoverned directories (/ark-adopt)');
74
218
  if (ctx.packageVersionTruth?.dualTruth) {
75
219
  actions.push(
@@ -100,21 +244,6 @@ export function collectDoctorNextActions(ctx) {
100
244
  : 'Remove the skippable if:, or write .ark/adoption-stance.json with stance: advisory-only')
101
245
  );
102
246
  }
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
247
  if (ctx.codexHomeGap && ctx.codexConcernActive && ctx.codexHomeGap.duplicateHome) {
119
248
  actions.push(
120
249
  'remove duplicate Codex home /ark-* skills (project .agents/skills is enough): --install-agent-gates --skills-only --prune-home-duplicates'
@@ -162,6 +291,12 @@ export function collectDoctorNextActions(ctx) {
162
291
  if (ctx.designFitness.designWeak && unique.length === 0 && ctx.postGreenPath) {
163
292
  unique.push(ctx.postGreenPath.action);
164
293
  }
294
+ if (resolvePkg) {
295
+ return [resolvePkg, ...unique.filter((a) => a !== resolvePkg)];
296
+ }
297
+ if (overlapAction) {
298
+ return [overlapAction, ...unique.filter((a) => a !== overlapAction)];
299
+ }
165
300
  if (notAdopted) {
166
301
  const next = ctx.notAdoptedNextAction || NOT_ADOPTED_NEXT_ACTION;
167
302
  return [next, ...unique.filter((a) => a !== next)];
@@ -13,7 +13,7 @@ import * as arkShared from '../ark-shared.mjs';
13
13
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
14
14
  import { describePackageVersionDualTruth } from './field-install.mjs';
15
15
  import { detectAgentHomeGaps } from './agent-homes.mjs';
16
- import { collectDoctorNextActions } from './doctor-next-actions.mjs';
16
+ import { collectDoctorNextActions, preferredDoctorPrimaryNextAction } from './doctor-next-actions.mjs';
17
17
  import { printDoctorCompactHuman, printDoctorDetailsHuman } from './doctor-human.mjs';
18
18
  import { collectLayerOwnerResidual, layerGuidanceLine, placementDescriptionFields } from './layer-description.mjs';
19
19
  import { collectAdrPresenceResidual, printAdrPresenceHint } from './adr-presence.mjs';
@@ -653,6 +653,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
653
653
  const statusTransitionCatalog = collectStatusTransitionCatalogResidual({ root, config, files, statesTransitions });
654
654
  const noDomainFrontend = collectNoDomainFrontendResidual({ config, coverage: cov, designSmells });
655
655
  const { invariantCoverageRoots, invariantTestsPath } = collectInvariantCoverageResiduals({ adopted: isAdopted(adopted) || options.requireGates === true, coverage: config?.coverage, config, root });
656
+ const selfHost = packageVersionTruth?.selfHost === true || packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST';
656
657
  const { coverageHonesty, baselineHonesty, writePathHonesty, productHonesty } =
657
658
  computeDoctorEnforcementHonesty({
658
659
  governedPercent: cov.governed.percent,
@@ -681,13 +682,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
681
682
  : rulesUnderContract?.active === true || arkRun?.active === true || doctorAdvisories.arkOrder?.active === true
682
683
  ? { active: true, extraMergeTeeth: arkRun?.extraMergeTeeth === true || doctorAdvisories.arkOrder?.extraMergeTeeth === true }
683
684
  : null,
684
- primaryNextAction:
685
- adopted === 'not-adopted' ? NOT_ADOPTED_NEXT_ACTION : postGreenPath?.action ?? dualTruthNext,
685
+ primaryNextAction: preferredDoctorPrimaryNextAction({ adopted, packageInstalled, selfHost, packageVersionTruth, postGreenPath, dualTruthNext }),
686
686
  operatingMode,
687
687
  packageInstalled,
688
- selfHost:
689
- packageVersionTruth?.selfHost === true ||
690
- packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
688
+ selfHost,
691
689
  nativeFailClosed: writePath.nativeFailClosed,
692
690
  nativeFailClosedPolicy: writePath.nativeFailClosedPolicy,
693
691
  adopted,
@@ -752,7 +750,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
752
750
  adoptionStance: adopted,
753
751
  ...(adopted === 'not-adopted'
754
752
  ? {
755
- primaryNextAction: NOT_ADOPTED_NEXT_ACTION,
753
+ primaryNextAction: productHonesty.primaryNextAction || NOT_ADOPTED_NEXT_ACTION,
756
754
  ...(postGreenPath ? DESIGN_WEAK_HONESTY_FLAGS : {}),
757
755
  }
758
756
  : postGreenPath
@@ -908,7 +906,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
908
906
  coverageHonesty,
909
907
  cov,
910
908
  packageVersionTruth,
911
- dualTruthNext,
909
+ dualTruthNext, packageInstalled, selfHost,
912
910
  activeCount,
913
911
  writePath,
914
912
  gatesMissing,
@@ -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).
@@ -303,7 +305,8 @@ export function checkUsageAll() {
303
305
  'rule file. Existing files are never overwritten without --force, so re-running',
304
306
  'after an update only adds what is missing. --skills-only restricts the write to',
305
307
  'just the /ark-* skills (safe to --force-refresh — it leaves a customized AGENTS.md,',
306
- '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.',
307
310
  'Pass --tools to pick which tool configs to write; otherwise they are auto-detected',
308
311
  'from their config directories (.claude/, .cursor/, .codex/, .grok/, .windsurf/,',
309
312
  '.clinerules/, .kiro/, .roo/, .continue/, .gemini/; copilot is explicit-only).',
@@ -152,9 +152,30 @@ export function isCompactRouterAgentsContent(text) {
152
152
  return typeof text === 'string' && COMPACT_ROUTER.test(text);
153
153
  }
154
154
 
155
+ function hasCursorFailClosedHook(root) {
156
+ try {
157
+ const parsed = readJson(path.join(root, '.cursor', 'hooks.json'));
158
+ const entries = Array.isArray(parsed?.hooks?.preToolUse) ? parsed.hooks.preToolUse : [];
159
+ return entries.some(
160
+ (entry) =>
161
+ entry &&
162
+ typeof entry === 'object' &&
163
+ entry.failClosed === true &&
164
+ typeof entry.command === 'string' &&
165
+ /(?:^|\s)--hook(?:\s|$)/.test(entry.command)
166
+ );
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ function hasCursorCompactRegistration(root) {
173
+ return hasArkMcpRegistration(root, '.cursor/mcp.json') && hasCursorFailClosedHook(root);
174
+ }
175
+
155
176
  function hasCompactHostRegistration(root, host) {
156
177
  if (host === 'none') return hasArkMcpRegistration(root);
157
- if (host === 'cursor') return hasArkMcpRegistration(root, '.cursor/mcp.json');
178
+ if (host === 'cursor') return hasCursorCompactRegistration(root);
158
179
  if (host === 'codex') return hasCodexCompactRegistration(root);
159
180
  const files = COMPACT_HOST_FILES[host];
160
181
  return Boolean(files) && files.every((relativePath) => fs.existsSync(path.join(root, relativePath)));
@@ -197,7 +197,7 @@ Codex hard write covers only a complete local \`apply_patch\`; Cursor covers onl
197
197
  installed + trusted, while shell/direct
198
198
  filesystem writes, hosted or specialized opt-out paths, and human edits still rely on CI.
199
199
 
200
- This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair **envelopes** may be emitted without reinjection being guaranteed; silent auto-apply never happens. Run \`arkgate-check --doctor\` (or \`ark-check --doctor\`) for the evidence actually detected in the current repository.`;
200
+ This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. **Write-gate order:** install and trust the host pre-hook first. MCP prepare is fallback when that path is missing or fail-open. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair **envelopes** may be emitted without reinjection being guaranteed; silent auto-apply never happens. Run \`arkgate-check --doctor\` (or \`ark-check --doctor\`) for the evidence actually detected in the current repository.`;
201
201
  }
202
202
 
203
203
  /**
@@ -224,7 +224,7 @@ export function writePathModeHint(mode) {
224
224
  case 'reject-only':
225
225
  return 'Hard write boundary without repair payload; edits can be blocked without guided re-entry.';
226
226
  case 'mcp-only':
227
- return 'Advisory MCP only — prepare-write/autoPatch available, no hard PreToolUse for this host.';
227
+ return 'Fell back to MCP prepare — advisory prepare-write/autoPatch; no hard PreToolUse for this host.';
228
228
  case 'none':
229
229
  return 'No hard write boundary or advisory MCP for the active host (or host is unknown in this process).';
230
230
  default:
@@ -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) {
@@ -200,10 +200,9 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
200
200
  })
201
201
  );
202
202
  if (selectedTools.has('cursor')) {
203
- add('.cursor/mcp.json', mcpJson(root));
204
- // whole-file for managed upgrade/manifest (json-merge is install-time only via
205
- // mergeCursorArkHook below — same pattern as Antigravity hooks).
203
+ // Write-gate order: pre-hook first, MCP prepare as fallback + identity.
206
204
  add('.cursor/hooks.json', cursorHooks(root));
205
+ add('.cursor/mcp.json', mcpJson(root));
207
206
  if (!compact) add('.cursor/rules/ark.mdc', cursorRule(root));
208
207
  }
209
208
  if (selectedTools.has('claude')) add('.claude/settings.json', claudeSettings(root));
@@ -213,8 +212,8 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
213
212
  if (!compact) add('docs/ark-codex-config.toml', codexTomlSnippet(root));
214
213
  }
215
214
  if (selectedTools.has('grok')) {
216
- add('.grok/config.toml', grokProjectConfig(root));
217
215
  add('.grok/hooks/ark-write-gate.json', grokHooks(root));
216
+ add('.grok/config.toml', grokProjectConfig(root));
218
217
  }
219
218
  if (selectedTools.has('antigravity')) {
220
219
  add('.agents/hooks.json', antigravityHooks(root));
@@ -17,6 +17,7 @@ import {
17
17
  arkPackageVersion,
18
18
  detectActiveAgentHost,
19
19
  normalizeToolsList,
20
+ planSkillInstall,
20
21
  skillContentIdentity,
21
22
  } from './skill-install.mjs';
22
23
 
@@ -396,8 +397,11 @@ function summaryFor(assets, manifestChanged) {
396
397
  for (const asset of assets) states[asset.state] = (states[asset.state] ?? 0) + 1;
397
398
  const applying = assets.filter((asset) => asset.willApply);
398
399
  const wouldWrite = applying.length;
399
- // Public-summary compatibility: stamp-only writes are no longer scheduled.
400
- const metadataRefresh = 0;
400
+ // Same-body stamp drift (arkVersion / description prefix) stays state=current
401
+ // and is scheduled via planSkillInstall stamp-refresh — not a second pipeline.
402
+ const metadataRefresh = applying.filter(
403
+ (asset) => asset.state === 'current' && asset.reason === 'stamp-refresh'
404
+ ).length;
401
405
  const customizedPreserved = assets.filter((asset) => asset.state === 'customized').length;
402
406
  const fileChanges = applying.length;
403
407
  return {
@@ -477,9 +481,23 @@ export function planManagedUpgrade(root, options = {}) {
477
481
  refreshSkills &&
478
482
  catalogAsset.kind === 'skill' &&
479
483
  classified.state === 'customized';
484
+ // HS01 / #284: identity-current skill whose arkVersion / description prefix
485
+ // lagged the package — reuse planSkillInstall stamp-refresh, do not reclassify.
486
+ const stampRefresh =
487
+ !unparsedScope &&
488
+ catalogAsset.kind === 'skill' &&
489
+ classified.state === 'current' &&
490
+ currentScoped != null &&
491
+ planSkillInstall({
492
+ existingContent: currentScoped,
493
+ targetContent: desiredScoped,
494
+ packageVersion: catalog.version ?? arkPackageVersion(),
495
+ scope: 'repo',
496
+ }).reason === 'stamp-refresh';
480
497
  const canApply =
481
498
  classified.state === 'stale' ||
482
499
  skillRefresh ||
500
+ stampRefresh ||
483
501
  (classified.state === 'missing' && (!recorded || accepted)) ||
484
502
  (classified.state === 'conflicted' && accepted);
485
503
  const blocked = classified.requiresConsent && !accepted && !skillRefresh;
@@ -490,7 +508,11 @@ export function planManagedUpgrade(root, options = {}) {
490
508
  kind: catalogAsset.kind,
491
509
  scope: catalogAsset.scope,
492
510
  ...classified,
493
- ...(unparsedScope ? { reason: 'unparsed managed TOML scope preserved' } : {}),
511
+ ...(unparsedScope
512
+ ? { reason: 'unparsed managed TOML scope preserved' }
513
+ : stampRefresh
514
+ ? { reason: 'stamp-refresh' }
515
+ : {}),
494
516
  action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
495
517
  willApply: canApply,
496
518
  blocked,
@@ -970,8 +992,11 @@ export function renderManagedUpgrade(plan, options = {}) {
970
992
  const customizedPreserved = summary.customizedPreserved ?? summary.states?.customized ?? 0;
971
993
  const blocked = summary.blocked ?? 0;
972
994
  console.log(
973
- `Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
974
- `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
995
+ `Managed assets: ${managedAssets}; would write: ${wouldWrite}` +
996
+ (summary.metadataRefresh
997
+ ? `; metadata refresh: ${summary.metadataRefresh}`
998
+ : '') +
999
+ `; customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
975
1000
  );
976
1001
  const honesty =
977
1002
  plan.selfService ??
@@ -191,7 +191,10 @@ export function collectAdoptionGaps(root, config, coverage) {
191
191
  host: 'cursor',
192
192
  dir: '.cursor',
193
193
  skill: (n) => path.join(root, '.cursor', 'commands', `${n}.md`),
194
- extras: [['.cursor/mcp.json', 'MCP config']],
194
+ extras: [
195
+ ['.cursor/hooks.json', 'write-gate hook'],
196
+ ['.cursor/mcp.json', 'MCP config (fallback)'],
197
+ ],
195
198
  toolsFlag: 'cursor',
196
199
  },
197
200
  {
@@ -10,6 +10,7 @@ import {
10
10
  DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
11
11
  discoverRepoUnits,
12
12
  DEFAULT_INTENT_PREFIXES,
13
+ monorepoRoleSourceGlobs,
13
14
  resolveIncludeRoots,
14
15
  } from '../ark-shared.mjs';
15
16
  import { withArkConfigMetadata } from './config-contract.mjs';
@@ -629,27 +630,19 @@ export const ARCHITECTURE_PRESETS = {
629
630
  }
630
631
  // Turborepo: apps/ + packages/; Nx enterprise: apps/ + libs/ (+ packages/).
631
632
  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']);
633
+ // Library Domain bags stay under packages/ or libs/ — never api/** or client/**.
634
+ const librarySourcePatterns = monorepoRoleSourceGlobs(units, ['library'], {
635
+ root,
636
+ domainScoped: true,
637
+ });
638
+ const applicationSourcePatterns = monorepoRoleSourceGlobs(units, ['application', 'cli'], {
639
+ root,
640
+ uiMode: 'not-ui',
641
+ });
642
+ const uiApplicationSourcePatterns = monorepoRoleSourceGlobs(units, ['application', 'cli'], {
643
+ root,
644
+ uiMode: 'ui',
645
+ });
653
646
  return presetWithOverlays(
654
647
  {
655
648
  include,
@@ -695,6 +688,7 @@ export const ARCHITECTURE_PRESETS = {
695
688
  '**/http/**',
696
689
  '**/routes/**',
697
690
  '**/hooks/**',
691
+ ...uiApplicationSourcePatterns,
698
692
  // No bare **/lib/** — NEW-ADOPT-LIB-AS-PRESENTATION / NEW-APP-VACUUM-LIB.
699
693
  ],
700
694
  optional: true,
@@ -4,7 +4,9 @@
4
4
  */
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { isScanExcludedRelative } from '../ark-shared.mjs';
7
+ import { isScanExcludedRelative, isSkippedSourceDir } from '../ark-shared.mjs';
8
+
9
+ export { isSkippedSourceDir };
8
10
 
9
11
  export const SOURCE_FILE_NAME = /\.[cm]?[tj]sx?$/;
10
12
 
@@ -18,31 +20,6 @@ export function isGovernableSourceFile(name) {
18
20
  return SOURCE_FILE_NAME.test(name) && !name.endsWith('.d.ts') && !TEST_FILE_NAME.test(name);
19
21
  }
20
22
 
21
- export function isSkippedSourceDir(name) {
22
- return (
23
- name === 'node_modules' ||
24
- name === 'dist' ||
25
- name === 'coverage' ||
26
- name === 'bench' ||
27
- name === 'benches' ||
28
- name === 'benchmark' ||
29
- name === 'benchmarks' ||
30
- name === 'docs' ||
31
- name === 'documentation' ||
32
- name === 'example' ||
33
- name === 'examples' ||
34
- name === 'fixture' ||
35
- name === 'fixtures' ||
36
- name === 'playground' ||
37
- name === '__tests__' ||
38
- name === '__mocks__' ||
39
- name === 'e2e' ||
40
- // Top-level style Nest/Jest folders (not "testing" helpers inside src)
41
- name === 'test' ||
42
- name === 'tests'
43
- );
44
- }
45
-
46
23
  function isInsideRoot(root, target) {
47
24
  const rel = path.relative(root, target);
48
25
  return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));