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
@@ -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 (
@@ -472,8 +486,9 @@ export function skillContentMatchesTemplate(installedContent, templateContent) {
472
486
  * Repo catalogs belong to that repo's installed package, so an explicit --force
473
487
  * may move them in either direction. Codex home is shared by every repo on the
474
488
  * machine: a package older than the installed home stamp must never win, even
475
- * under --force. In both scopes a version-stamp-only difference is a no-op; the
476
- * skill body is the capability contract.
489
+ * under --force. In both scopes a version-stamp-only difference is
490
+ * `stamp-refresh` (write without --force) so the picker shows this package;
491
+ * the skill body is the capability contract.
477
492
  *
478
493
  * @param {{
479
494
  * existingContent?: string|null,
@@ -1086,10 +1101,32 @@ export function detectSkillGaps(root) {
1086
1101
  if (fs.existsSync(path.join(root, '.cursor'))) detected.push('cursor');
1087
1102
  if (fs.existsSync(path.join(root, '.codex'))) detected.push('codex');
1088
1103
  if (fs.existsSync(path.join(root, '.grok'))) detected.push('grok');
1104
+ // Same markers as resolveTools — Antigravity/OpenCode read or link the catalog.
1105
+ if (
1106
+ fs.existsSync(path.join(root, '.agents', 'hooks.json')) ||
1107
+ fs.existsSync(path.join(root, '.agents', 'mcp_config.json'))
1108
+ ) {
1109
+ detected.push('antigravity');
1110
+ }
1111
+ if (
1112
+ fs.existsSync(path.join(root, 'opencode.json')) ||
1113
+ fs.existsSync(path.join(root, 'opencode.jsonc')) ||
1114
+ fs.existsSync(path.join(root, '.opencode'))
1115
+ ) {
1116
+ detected.push('opencode');
1117
+ }
1089
1118
  if (fs.existsSync(path.join(root, '.windsurf'))) detected.push('windsurf');
1090
1119
  if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
1091
1120
  detected.push('cline');
1092
1121
  }
1122
+ // One-catalog trees may have `.agents/skills` and no host marker dir.
1123
+ // Without this, a colleague who `npm install`s a new pin keeps stale doors silently.
1124
+ if (
1125
+ fs.existsSync(path.join(root, SKILL_CANONICAL_DIR)) &&
1126
+ !detected.some((tool) => skillTargetIsCanonical(tool))
1127
+ ) {
1128
+ detected.push(SKILL_CANONICAL_TOOL);
1129
+ }
1093
1130
  const version = arkPackageVersion();
1094
1131
  const gaps = [];
1095
1132
  for (const tool of detected) {
@@ -1150,7 +1187,9 @@ export function detectSkillGaps(root) {
1150
1187
  export function skillGapsForActiveHost(skillGaps, env = process.env) {
1151
1188
  const activeHost = detectActiveAgentHost(env);
1152
1189
  if (!activeHost) return skillGaps ?? [];
1153
- return (skillGaps ?? []).filter((gap) => gap.tool === activeHost);
1190
+ return (skillGaps ?? []).filter(
1191
+ (gap) => gap.tool === activeHost || gap.tool === SKILL_CANONICAL_TOOL
1192
+ );
1154
1193
  }
1155
1194
 
1156
1195
  /**
@@ -1174,7 +1213,7 @@ export function printSkillAndCodexGapHints(root, opts) {
1174
1213
  );
1175
1214
  const missingTotal = remaining.reduce((sum, gap) => sum + gap.missing, 0);
1176
1215
  const staleTotal = remaining.reduce((sum, gap) => sum + gap.stale, 0);
1177
- const tools = remaining.map((gap) => gap.tool).join(', ');
1216
+ const tools = remaining.map((gap) => skillGapToolLabel(gap.tool)).join(', ');
1178
1217
  if (legacyCodex) {
1179
1218
  console.log(
1180
1219
  color.yellow(
@@ -1200,9 +1239,9 @@ export function printSkillAndCodexGapHints(root, opts) {
1200
1239
  }
1201
1240
  if (staleTotal > 0) {
1202
1241
  console.log(
1203
- color.dim(
1242
+ color.yellow(
1204
1243
  `${staleTotal} /ark-* skill(s) content behind this Ark package for ${tools}. ` +
1205
- `Refresh: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`
1244
+ `Refresh skills only (do not re-adopt): ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`
1206
1245
  )
1207
1246
  );
1208
1247
  }
@@ -40,8 +40,8 @@ const COMPACT_HOST_TEMPLATES = {
40
40
  ['.mcp.json', mcpJson(root)],
41
41
  ],
42
42
  cursor: (root) => [
43
- ['.cursor/mcp.json', mcpJson(root)],
44
43
  ['.cursor/hooks.json', cursorHooks(root)],
44
+ ['.cursor/mcp.json', mcpJson(root)],
45
45
  ],
46
46
  codex: (root) => [
47
47
  ['.codex/hooks.json', codexHooks(root)],
@@ -83,6 +83,17 @@ function digest(content) {
83
83
  return `sha256:${crypto.createHash('sha256').update(content ?? Buffer.alloc(0)).digest('hex')}`;
84
84
  }
85
85
 
86
+ function writeGateOrderGuarantee(tools) {
87
+ const host = typeof tools === 'string' ? tools.trim().toLowerCase() : '';
88
+ if (host === 'opencode') {
89
+ return 'Write gate: MCP prepare only (this host has no hard pre-hook). Required CI is the merge line.';
90
+ }
91
+ if (['claude', 'grok', 'antigravity', 'cursor', 'codex'].includes(host)) {
92
+ return 'Write gate: host pre-hook first; MCP prepare is fallback if that hook is missing or fail-open.';
93
+ }
94
+ return 'Write gate: host pre-hook first when the host has one; MCP prepare is fallback. Required CI is the merge line.';
95
+ }
96
+
86
97
  function change(pathname, before, after) {
87
98
  return {
88
99
  path: pathname,
@@ -122,6 +133,73 @@ export function setupBudget(changes) {
122
133
  };
123
134
  }
124
135
 
136
+ /** One next write when compact start is the wrong size. `--force` does not unlock. */
137
+ export const START_SETUP_BUDGET_NEXT = 'arkgate-check --init';
138
+
139
+ function formatSetupKb(bytes) {
140
+ return `${Math.max(1, Math.round(Number(bytes) / 1024))} KB`;
141
+ }
142
+
143
+ /**
144
+ * Compact-start size lock. `--force` unlocks coverage/shape only — never this.
145
+ * @param {object|null|undefined} setupBudget
146
+ * @param {{ force?: boolean }} [options]
147
+ */
148
+ export function evaluateStartSetupBudgetGate(setupBudget, { force = false } = {}) {
149
+ if (!setupBudget || setupBudget.ok) {
150
+ return { ok: true, forceIgnored: Boolean(force) };
151
+ }
152
+ const reasons = [];
153
+ const maxFiles = setupBudget.maxFiles ?? 8;
154
+ const maxBytes = setupBudget.maxBytes ?? 32 * 1024;
155
+ const gateFiles =
156
+ typeof setupBudget.gateFiles === 'number' ? setupBudget.gateFiles : setupBudget.files;
157
+ if (typeof gateFiles === 'number' && gateFiles > maxFiles) {
158
+ reasons.push(`${gateFiles} gate files (max ${maxFiles})`);
159
+ }
160
+ if (typeof setupBudget.bytes === 'number' && setupBudget.bytes >= maxBytes) {
161
+ reasons.push(`${formatSetupKb(setupBudget.bytes)} (max ${formatSetupKb(maxBytes)})`);
162
+ }
163
+ if (reasons.length === 0) {
164
+ reasons.push('the planned write is larger than compact start allows');
165
+ }
166
+ return {
167
+ ok: false,
168
+ reasons,
169
+ nextAction: START_SETUP_BUDGET_NEXT,
170
+ forceBypasses: false,
171
+ setupBudget,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Human refuse / preview warning. Avoids the first-run phrase "Compact setup budget".
177
+ * @param {object|null|undefined} setupBudget
178
+ * @param {{ applying?: boolean }} [options]
179
+ */
180
+ export function formatStartSetupBudgetRefuse(setupBudget, { applying = true } = {}) {
181
+ const gate = evaluateStartSetupBudgetGate(setupBudget);
182
+ if (gate.ok) return '';
183
+ const facts = gate.reasons.map((reason) => ` • ${reason}`).join('\n');
184
+ const next = `--force does not unlock this. Next: ${gate.nextAction}`;
185
+ if (applying) {
186
+ return `Refusing ark start --apply: this plan is too big for compact start.\n${facts}\n${next}`;
187
+ }
188
+ return `This plan is too big for compact start.\n${facts}\nApply will refuse. ${next}`;
189
+ }
190
+
191
+ /** Apply-path emit: JSON envelope or human refuse. Call before any “writing…” copy. */
192
+ export function emitStartSetupBudgetRefuse(preview, json) {
193
+ const gate = evaluateStartSetupBudgetGate(preview.setupBudget);
194
+ if (json) {
195
+ console.log(
196
+ JSON.stringify({ ok: false, error: 'start-setup-budget-gate', ...gate, preview }, null, 2)
197
+ );
198
+ return;
199
+ }
200
+ console.error(formatStartSetupBudgetRefuse(preview.setupBudget, { applying: true }));
201
+ }
202
+
125
203
  function commands(root, args, helpers) {
126
204
  if (args.removeHost) {
127
205
  return [`ark start --root ${root} --tools ${args.removeHost} --apply`];
@@ -140,18 +218,57 @@ function commands(root, args, helpers) {
140
218
  return result;
141
219
  }
142
220
 
221
+ /**
222
+ * Plain one-liner for pnpm minimumReleaseAge / ERR_PNPM_NO_MATURE_MATCHING_VERSION.
223
+ * Host dumps stay out of the recovery — stranger needs one next step.
224
+ */
225
+ export function explainPnpmMaturityBlock(hostOutput) {
226
+ const text = String(hostOutput ?? '');
227
+ if (!/ERR_PNPM_NO_MATURE_MATCHING_VERSION|minimumReleaseAge/i.test(text)) return null;
228
+ const days = text.match(/(\d+)\s*days?/i)?.[1];
229
+ const window = days ? `${days} days` : 'a cooling-off window';
230
+ return `This repo blocks packages younger than ${window} — pin an older release or exclude arkgate temporarily.`;
231
+ }
232
+
233
+ /** Pipe the package manager so recovery can hide maturity internals. */
234
+ export function runStartPackageInstall(command, commandArgs, cwd) {
235
+ const result = spawnSync(command, commandArgs, {
236
+ cwd,
237
+ stdio: ['ignore', 'pipe', 'pipe'],
238
+ encoding: 'utf8',
239
+ });
240
+ return {
241
+ status: result.status ?? 1,
242
+ stdout: result.stdout ?? '',
243
+ stderr: result.stderr ?? '',
244
+ };
245
+ }
246
+
143
247
  /**
144
248
  * Red next-step when post-apply package install fails.
145
249
  * Host files may already be written; local bins may be missing.
146
250
  */
147
- export function formatStartPackageInstallFailure({ exitStatus, installCommand }) {
251
+ export function formatStartPackageInstallFailure({ exitStatus, installCommand, hostOutput }) {
148
252
  const doctor = arkPackageRecoveryCommand('arkgate-check', '--doctor');
149
- return [
253
+ const maturity = explainPnpmMaturityBlock(hostOutput);
254
+ const lines = [];
255
+ if (maturity) {
256
+ lines.push(maturity);
257
+ } else if (hostOutput && String(hostOutput).trim()) {
258
+ const tail = String(hostOutput)
259
+ .split('\n')
260
+ .map((line) => line.trimEnd())
261
+ .filter((line) => line.trim())
262
+ .slice(-6);
263
+ if (tail.length) lines.push(...tail);
264
+ }
265
+ lines.push(
150
266
  `Package install failed (exit ${exitStatus}). Setup files are written; the local command is not installed yet.`,
151
267
  ` ${installCommand}`,
152
268
  ` ${doctor}`,
153
- '`arkgate-check` is a command in the arkgate package — not its own npm package.',
154
- ].join('\n');
269
+ '`arkgate-check` is a command in the arkgate package — not its own npm package.'
270
+ );
271
+ return lines.join('\n');
155
272
  }
156
273
 
157
274
  /**
@@ -160,6 +277,7 @@ export function formatStartPackageInstallFailure({ exitStatus, installCommand })
160
277
  */
161
278
  export function renderStartPreview(preview, options = {}) {
162
279
  const applying = options.applying === true;
280
+ const budgetOk = preview.setupBudget?.ok !== false;
163
281
  if (applying) {
164
282
  console.log(
165
283
  preview.changes.length === 0
@@ -170,7 +288,11 @@ export function renderStartPreview(preview, options = {}) {
170
288
  console.log('Ark start preview — no files were changed.');
171
289
  }
172
290
  if (!applying) {
173
- console.log('Apply this plan with: arkgate start --apply');
291
+ if (budgetOk) {
292
+ console.log('Apply this plan with: arkgate start --apply');
293
+ } else {
294
+ console.log(formatStartSetupBudgetRefuse(preview.setupBudget, { applying: false }));
295
+ }
174
296
  }
175
297
  if (preview.analysis) {
176
298
  console.log(`Your project looks like: ${preview.analysis.label}.`);
@@ -191,6 +313,10 @@ export function renderStartPreview(preview, options = {}) {
191
313
  if (preview.runtimeActivation) {
192
314
  console.log('Host: Codex is configured but not verified yet. Restart the host, then confirm this project.');
193
315
  }
316
+ const writeGateOrder = (preview.hostGuarantees || []).find((line) =>
317
+ String(line).startsWith('Write gate:')
318
+ );
319
+ if (writeGateOrder) console.log(writeGateOrder);
194
320
  if (preview.unresolvedDecisions.length > 0) {
195
321
  console.log('Unresolved decisions:');
196
322
  for (const decision of preview.unresolvedDecisions) console.log(` ${decision}`);
@@ -208,8 +334,8 @@ export function renderStartPreview(preview, options = {}) {
208
334
  }
209
335
 
210
336
  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.');
337
+ if (!evaluateStartSetupBudgetGate(preview.setupBudget).ok) {
338
+ throw new Error(formatStartSetupBudgetRefuse(preview.setupBudget, { applying: true }));
213
339
  }
214
340
  for (const change of preview.changes) {
215
341
  const target = path.join(root, change.path);
@@ -431,6 +557,7 @@ export async function planStart(args, helpers) {
431
557
  commands: commands(root, args, helpers),
432
558
  hostGuarantees: [
433
559
  args.requireWriteHook ? `Hard-write hook verified for ${args.requireWriteHook}` : 'shared CI merge gate will be installed',
560
+ writeGateOrderGuarantee(args.tools),
434
561
  'preview phase performs no writes in the target project',
435
562
  'apply writes the exact bytes identified by each afterHash',
436
563
  ...(codexSelected
@@ -141,8 +141,9 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
141
141
  ? 'OpenCode local write is advisory (MCP + optional experimental plugin — not a hard boundary; ' +
142
142
  'not equivalent to Claude/Grok/Antigravity PreToolUse hard-write). ' +
143
143
  'The hard merge backstop is a required GitHub status context running --strict-merge.'
144
- : `Active host ${activeHost} has advisory prepare-write/autoPatch tools, ` +
145
- 'but no hard write boundary; CI can report failure, while merge blocking requires provider policy.';
144
+ : `Fell back to MCP prepare: active host ${activeHost} has no hard pre-hook. ` +
145
+ 'Advisory prepare-write/autoPatch is the local write surface. ' +
146
+ 'Next: install and trust the host write hook, or keep required CI as the merge line.';
146
147
  gap = {
147
148
  id: 'write-path-mcp-only',
148
149
  severity: 'info',
@@ -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.17";
395
+ declare const version = "4.8.19";
396
396
 
397
397
  /**
398
398
  * AI Code Gate (basic).