arkgate 3.8.1 → 3.8.3

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.
@@ -6,8 +6,10 @@ import { codexPrimaryTable, upsertCodexMcpTable } from './codex-home.mjs';
6
6
  import { buildManagedAssetCatalog } from './install-migrate.mjs';
7
7
  import {
8
8
  KNOWN_TOOLS,
9
+ arkPackageVersion,
9
10
  detectActiveAgentHost,
10
11
  normalizeToolsList,
12
+ skillContentIdentity,
11
13
  } from './skill-install.mjs';
12
14
 
13
15
  export const MANAGED_MANIFEST_PATH = 'ark.managed.json';
@@ -36,21 +38,17 @@ function hash(content) {
36
38
  return `sha256:${createHash('sha256').update(content).digest('hex')}`;
37
39
  }
38
40
 
39
- function normalizedIdentityContent(content, kind) {
40
- let text = String(content).replace(/\r\n/g, '\n');
41
- if (kind !== 'skill') return text;
42
- const lines = text.split('\n');
43
- if (lines[0] !== '---') return text;
44
- const end = lines.indexOf('---', 1);
45
- if (end < 0) return text;
46
- for (let index = 1; index < end; index += 1) {
47
- if (/^arkVersion:/.test(lines[index])) lines[index] = 'arkVersion:<managed>';
48
- }
49
- return lines.join('\n');
41
+ function normalizedIdentityContent(content) {
42
+ return String(content).replace(/\r\n/g, '\n');
50
43
  }
51
44
 
45
+ /**
46
+ * Content identity for managed assets. Skill kind delegates to skill-install so
47
+ * doctor stale detection and upgrade classify never drift (single hasher).
48
+ */
52
49
  export function managedContentIdentity(content, kind = 'gate') {
53
- return hash(Buffer.from(normalizedIdentityContent(content, kind)));
50
+ if (kind === 'skill') return skillContentIdentity(content);
51
+ return hash(Buffer.from(normalizedIdentityContent(content)));
54
52
  }
55
53
 
56
54
  function isSafeRelativePath(relativePath) {
@@ -271,12 +269,22 @@ function serializeManifest(value) {
271
269
  function summaryFor(assets, manifestChanged) {
272
270
  const states = {};
273
271
  for (const asset of assets) states[asset.state] = (states[asset.state] ?? 0) + 1;
274
- const fileChanges = assets.filter((asset) => asset.willApply).length;
272
+ const applying = assets.filter((asset) => asset.willApply);
273
+ // Content writes (stale/missing/conflicted accepted) — not version-stamp metadata-only.
274
+ const wouldWrite = applying.filter((asset) => asset.action !== 'refresh-metadata').length;
275
+ const metadataRefresh = applying.filter((asset) => asset.action === 'refresh-metadata').length;
276
+ const customizedPreserved = assets.filter((asset) => asset.state === 'customized').length;
277
+ const fileChanges = applying.length;
275
278
  return {
276
279
  total: assets.length,
280
+ managedAssets: assets.length,
277
281
  states,
282
+ wouldWrite,
283
+ metadataRefresh,
284
+ customizedPreserved,
278
285
  fileChanges,
279
286
  manifestChanged,
287
+ // Full apply count still includes optional stamp refresh + manifest bookkeeping.
280
288
  changed: fileChanges + (manifestChanged ? 1 : 0),
281
289
  blocked: assets.filter((asset) => asset.blocked).length,
282
290
  };
@@ -505,7 +513,20 @@ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
505
513
  const resolvedRoot = path.resolve(root);
506
514
  if (resolvedRoot !== plan.root) throw new Error('managed upgrade plan root mismatch');
507
515
  if (plan.summary.blocked > 0) return publicPlan(plan, { blocked: true });
516
+ const wouldWrite = plan.summary.wouldWrite ?? 0;
517
+ const metadataRefresh = plan.summary.metadataRefresh ?? 0;
518
+ // Content already matches: unbound --apply is a no-op (exit success), not a digest error.
519
+ // Optional stamp-only refresh still requires the preview's exact --plan-digest.
508
520
  if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {
521
+ if (wouldWrite === 0 && (plan.summary.blocked ?? 0) === 0 && !expectedPlanDigest) {
522
+ return publicPlan(plan, {
523
+ readOnly: true,
524
+ applied: false,
525
+ blocked: false,
526
+ nothingToApply: true,
527
+ optionalStampRefresh: metadataRefresh,
528
+ });
529
+ }
509
530
  throw new Error('managed upgrade plan digest mismatch; run a new preview and use its exact nextCommand');
510
531
  }
511
532
 
@@ -616,7 +637,55 @@ export function renderManagedUpgrade(plan, options = {}) {
616
637
  const consent = asset.requiresConsent ? ' (consent required)' : '';
617
638
  console.log(` ${asset.state.padEnd(10)} ${asset.path}${consent}`);
618
639
  }
619
- const label = plan.applied ? 'Applied changes' : 'Planned changes';
620
- console.log(`${label}: ${plan.summary.changed}; blocked conflicts/deletions: ${plan.summary.blocked}.`);
621
- if (!plan.applied) console.log(options.next ?? 'Apply the exact preview with: ark upgrade --apply --no-install');
640
+ const summary = plan.summary;
641
+ const managedAssets = summary.managedAssets ?? summary.total ?? plan.assets.length;
642
+ const wouldWrite = summary.wouldWrite ?? 0;
643
+ const metadataRefresh = summary.metadataRefresh ?? 0;
644
+ const customizedPreserved = summary.customizedPreserved ?? summary.states?.customized ?? 0;
645
+ const blocked = summary.blocked ?? 0;
646
+ console.log(
647
+ `Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
648
+ `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}` +
649
+ (metadataRefresh > 0 ? `; optional stamp refresh: ${metadataRefresh}` : '') +
650
+ '.'
651
+ );
652
+ if (plan.applied) {
653
+ // Distinguish content writes from optional stamp/metadata bookkeeping.
654
+ if (wouldWrite === 0 && metadataRefresh > 0) {
655
+ console.log(
656
+ `Refreshed ${metadataRefresh} version stamp(s)` +
657
+ (summary.manifestChanged ? ' and managed manifest' : '') +
658
+ ' (no content body changes).'
659
+ );
660
+ } else {
661
+ console.log(
662
+ `Applied ${wouldWrite} content write(s)` +
663
+ (metadataRefresh > 0 ? `, ${metadataRefresh} stamp refresh(es)` : '') +
664
+ (summary.manifestChanged ? ', managed manifest' : '') +
665
+ '.'
666
+ );
667
+ }
668
+ return;
669
+ }
670
+ // Content already matches package templates — do not urge --apply as the primary next step.
671
+ if (wouldWrite === 0 && blocked === 0) {
672
+ const ver = options.packageVersion ?? arkPackageVersion();
673
+ const verLabel = ver ? `arkgate@${ver}` : 'the installed arkgate package';
674
+ console.log(
675
+ `Nothing to apply — managed content matches ${verLabel} (${customizedPreserved} customized preserved).`
676
+ );
677
+ if (metadataRefresh > 0) {
678
+ console.log(
679
+ `Optional: ${metadataRefresh} skill stamp(s) lag package version while content is already current.`
680
+ );
681
+ const stampCmd = options.optionalStampApply ?? options.next;
682
+ if (stampCmd) {
683
+ console.log(`Optional stamp-only apply (not required): ${stampCmd}`);
684
+ }
685
+ }
686
+ return;
687
+ }
688
+ console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);
689
+ if (options.next) console.log(options.next);
690
+ else console.log('Apply the exact preview with: ark upgrade --apply --no-install');
622
691
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Tool detection, skill templates, stamping, and skill freshness gaps.
3
3
  */
4
+ import { createHash } from 'node:crypto';
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { arkCommand } from '../ark-shared.mjs';
@@ -206,7 +207,12 @@ export function installedSkillVersion(filePath) {
206
207
  } catch {
207
208
  return null;
208
209
  }
209
- const match = content.match(/^arkVersion:\s*(.+)$/m);
210
+ return skillVersionFromContent(content);
211
+ }
212
+
213
+ function skillVersionFromContent(content) {
214
+ if (content == null) return null;
215
+ const match = String(content).match(/^arkVersion:\s*(.+)$/m);
210
216
  return match ? match[1].trim() : null;
211
217
  }
212
218
 
@@ -225,6 +231,63 @@ export function isVersionOlder(a, b) {
225
231
  return false;
226
232
  }
227
233
 
234
+ /**
235
+ * Content identity for managed skills — arkVersion stamp is normalized so a lagging
236
+ * header alone never diverges from the package template (matches managed-upgrade).
237
+ * @param {string|null|undefined} content
238
+ * @returns {string|null}
239
+ */
240
+ export function skillContentIdentity(content) {
241
+ if (content == null) return null;
242
+ let text = String(content).replace(/\r\n/g, '\n');
243
+ const lines = text.split('\n');
244
+ if (lines[0] === '---') {
245
+ const end = lines.indexOf('---', 1);
246
+ if (end >= 0) {
247
+ for (let index = 1; index < end; index += 1) {
248
+ if (/^arkVersion:/.test(lines[index])) lines[index] = 'arkVersion:<managed>';
249
+ }
250
+ text = lines.join('\n');
251
+ }
252
+ }
253
+ return `sha256:${createHash('sha256').update(text).digest('hex')}`;
254
+ }
255
+
256
+ /**
257
+ * True when installed skill body matches the package template for that skill
258
+ * (version stamp ignored). Used so doctor "stale" aligns with managed upgrade.
259
+ *
260
+ * Templates ship without arkVersion; installs are stamped. Identity normalizes
261
+ * the stamp value, so compare against both the raw template and a stamped copy.
262
+ * @param {string} installedContent
263
+ * @param {string|undefined|null} templateContent
264
+ */
265
+ export function skillContentMatchesTemplate(installedContent, templateContent) {
266
+ if (templateContent == null || installedContent == null) return false;
267
+ const installedId = skillContentIdentity(installedContent);
268
+ if (installedId === skillContentIdentity(templateContent)) return true;
269
+ // Installed skills are stamped; templates are not — stamp with a dummy version
270
+ // so arkVersion:<managed> lines align under skillContentIdentity.
271
+ return installedId === skillContentIdentity(stampSkill(templateContent, '0.0.0'));
272
+ }
273
+
274
+ /** @returns {Record<string, string>} skill name → template body from package */
275
+ export function skillTemplateBodies() {
276
+ return Object.fromEntries(skillTemplates());
277
+ }
278
+
279
+ /**
280
+ * Count a present skill as stale only when content differs from the package
281
+ * template AND the arkVersion stamp is missing or older than the package.
282
+ * Content identity match → never stale (even if header lags).
283
+ */
284
+ function isInstalledSkillStale(installedContent, templateContent, packageVersion) {
285
+ if (!packageVersion) return false;
286
+ if (skillContentMatchesTemplate(installedContent, templateContent)) return false;
287
+ const installed = skillVersionFromContent(installedContent);
288
+ return installed === null || isVersionOlder(installed, packageVersion);
289
+ }
290
+
228
291
  export function skillTemplates() {
229
292
  const dir = path.join(__packageRoot, 'templates', 'skills');
230
293
  // A missing/mispackaged templates dir would otherwise install zero skills with
@@ -263,23 +326,31 @@ export function skillTemplateNames() {
263
326
 
264
327
  /**
265
328
  * Count present / stale / legacy-only skill files for one catalog root.
329
+ * "stale" means content behind the package template (identity mismatch) with a
330
+ * missing/older arkVersion stamp — not merely a lagging version header when the
331
+ * body still matches the template (aligned with managed-upgrade classify).
266
332
  * @param {string[]} skillNames
267
333
  * @param {(name: string) => string} skillFile path builder
268
334
  * @param {string|null} packageVersion
269
- * @param {{ legacyFile?: (name: string) => string }} [opts]
335
+ * @param {{ legacyFile?: (name: string) => string, templateBodies?: Record<string, string> }} [opts]
270
336
  */
271
337
  export function assessSkillCatalogParity(skillNames, skillFile, packageVersion, opts = {}) {
272
338
  const expectedCount = skillNames.length;
339
+ const templates = opts.templateBodies ?? skillTemplateBodies();
273
340
  const present = [];
274
341
  let stale = 0;
275
342
  for (const name of skillNames) {
276
343
  const file = skillFile(name);
277
344
  if (!fs.existsSync(file)) continue;
278
- present.push(name);
279
- if (packageVersion) {
280
- const installed = installedSkillVersion(file);
281
- if (installed === null || isVersionOlder(installed, packageVersion)) stale += 1;
345
+ let content;
346
+ try {
347
+ content = fs.readFileSync(file, 'utf8');
348
+ } catch {
349
+ // Unreadable path is not a usable install — count as missing (matches detectSkillGaps).
350
+ continue;
282
351
  }
352
+ present.push(name);
353
+ if (isInstalledSkillStale(content, templates[name], packageVersion)) stale += 1;
283
354
  }
284
355
  let legacyCount = 0;
285
356
  if (typeof opts.legacyFile === 'function') {
@@ -291,6 +362,7 @@ export function assessSkillCatalogParity(skillNames, skillFile, packageVersion,
291
362
  const missing = expectedCount - presentCount;
292
363
  const legacyPromptsOnly = presentCount === 0 && legacyCount > 0;
293
364
  const hasLegacyPrompts = legacyCount > 0;
365
+ // Legacy prompts beside a complete modern catalog are not catalog debt.
294
366
  const ok = missing === 0 && stale === 0 && !legacyPromptsOnly;
295
367
  return {
296
368
  ok,
@@ -302,6 +374,7 @@ export function assessSkillCatalogParity(skillNames, skillFile, packageVersion,
302
374
  legacyPromptsOnly,
303
375
  hasLegacyPrompts,
304
376
  legacyCount,
377
+ catalogComplete: missing === 0 && stale === 0 && presentCount === expectedCount,
305
378
  };
306
379
  }
307
380
 
@@ -490,6 +563,7 @@ export function detectSkillGaps(root) {
490
563
  if (fs.existsSync(path.join(root, 'templates', 'skills'))) return [];
491
564
  const skillNames = skillTemplateNames();
492
565
  if (skillNames.length === 0) return [];
566
+ const templates = skillTemplateBodies();
493
567
  const detected = [];
494
568
  if (fs.existsSync(path.join(root, '.claude'))) detected.push('claude');
495
569
  if (fs.existsSync(path.join(root, '.cursor'))) detected.push('cursor');
@@ -510,16 +584,21 @@ export function detectSkillGaps(root) {
510
584
  const file = path.join(root, target(name));
511
585
  if (!fs.existsSync(file)) {
512
586
  missing += 1;
513
- } else if (version) {
514
- // An installed skill with no stamp predates stamping (older Ark), or one
515
- // stamped behind the current version is left over from an older install.
516
- // Either way the shipped skill has moved on — offer a --force refresh.
517
- const installed = installedSkillVersion(file);
518
- if (installed === null || isVersionOlder(installed, version)) stale += 1;
587
+ continue;
588
+ }
589
+ let content;
590
+ try {
591
+ content = fs.readFileSync(file, 'utf8');
592
+ } catch {
593
+ missing += 1;
594
+ continue;
519
595
  }
596
+ // Content identity match with package template → not stale (version header may lag).
597
+ if (isInstalledSkillStale(content, templates[name], version)) stale += 1;
520
598
  }
521
599
  let legacyPromptsOnly = false;
522
600
  let hasLegacyPrompts = false;
601
+ let legacyAdvisory = false;
523
602
  if (tool === 'codex') {
524
603
  const legacyCount = skillNames.filter((name) =>
525
604
  fs.existsSync(path.join(root, '.codex', 'prompts', `${name}.md`))
@@ -527,14 +606,20 @@ export function detectSkillGaps(root) {
527
606
  hasLegacyPrompts = legacyCount > 0;
528
607
  // Flat prompts without any SKILL.md catalog entries are not loadable.
529
608
  legacyPromptsOnly = hasLegacyPrompts && missing === skillNames.length;
609
+ // Modern catalog complete + leftover flat prompts → advisory only (safe delete).
610
+ legacyAdvisory =
611
+ hasLegacyPrompts && !legacyPromptsOnly && missing === 0 && stale === 0;
530
612
  }
531
- if (missing > 0 || stale > 0 || legacyPromptsOnly) {
613
+ if (missing > 0 || stale > 0 || legacyPromptsOnly || legacyAdvisory) {
532
614
  gaps.push({
533
615
  tool,
534
616
  missing,
535
617
  stale,
536
618
  ...(legacyPromptsOnly ? { legacyPromptsOnly: true } : {}),
537
619
  ...(hasLegacyPrompts ? { hasLegacyPrompts: true } : {}),
620
+ ...(legacyAdvisory
621
+ ? { legacyAdvisory: true, catalogComplete: true }
622
+ : {}),
538
623
  });
539
624
  }
540
625
  }
@@ -550,8 +635,14 @@ export function printSkillAndCodexGapHints(root, opts) {
550
635
  const { skillGaps, codexHomeGap, codexRepoSkillGap, codexSessionActive, color } = opts;
551
636
  if (skillGaps?.length > 0) {
552
637
  const legacyCodex = skillGaps.some((gap) => gap.tool === 'codex' && gap.legacyPromptsOnly);
638
+ const legacyAdvisory = skillGaps.some(
639
+ (gap) => gap.tool === 'codex' && gap.legacyAdvisory && gap.catalogComplete
640
+ );
553
641
  // Report Codex legacy separately; never suppress missing/stale for other hosts.
554
- const remaining = skillGaps.filter((gap) => !(gap.tool === 'codex' && gap.legacyPromptsOnly));
642
+ const remaining = skillGaps.filter(
643
+ (gap) =>
644
+ !(gap.tool === 'codex' && (gap.legacyPromptsOnly || gap.legacyAdvisory))
645
+ );
555
646
  const missingTotal = remaining.reduce((sum, gap) => sum + gap.missing, 0);
556
647
  const staleTotal = remaining.reduce((sum, gap) => sum + gap.stale, 0);
557
648
  const tools = remaining.map((gap) => gap.tool).join(', ');
@@ -563,6 +654,13 @@ export function printSkillAndCodexGapHints(root, opts) {
563
654
  )
564
655
  );
565
656
  }
657
+ if (legacyAdvisory) {
658
+ console.log(
659
+ color.dim(
660
+ 'Codex .agents/skills catalog is complete; leftover .codex/prompts/ark-*.md are not loadable and safe to delete (not required).'
661
+ )
662
+ );
663
+ }
566
664
  if (missingTotal > 0) {
567
665
  console.log(
568
666
  color.dim(
@@ -574,7 +672,7 @@ export function printSkillAndCodexGapHints(root, opts) {
574
672
  if (staleTotal > 0) {
575
673
  console.log(
576
674
  color.dim(
577
- `${staleTotal} /ark-* skill(s) outdated for ${tools} (this Ark ships newer versions). ` +
675
+ `${staleTotal} /ark-* skill(s) content behind this Ark package for ${tools}. ` +
578
676
  `Refresh: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`
579
677
  )
580
678
  );
@@ -584,7 +682,7 @@ export function printSkillAndCodexGapHints(root, opts) {
584
682
  const parts = [];
585
683
  if (codexHomeGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
586
684
  if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
587
- if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} outdated`);
685
+ if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} content-behind-package`);
588
686
  const deferred = !codexSessionActive;
589
687
  const deferredNote = deferred
590
688
  ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
@@ -600,7 +698,7 @@ export function printSkillAndCodexGapHints(root, opts) {
600
698
  const parts = [];
601
699
  if (codexRepoSkillGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
602
700
  if (codexRepoSkillGap.missing > 0) parts.push(`${codexRepoSkillGap.missing} missing`);
603
- if (codexRepoSkillGap.stale > 0) parts.push(`${codexRepoSkillGap.stale} outdated`);
701
+ if (codexRepoSkillGap.stale > 0) parts.push(`${codexRepoSkillGap.stale} content-behind-package`);
604
702
  console.log(
605
703
  color.yellow(
606
704
  `Codex repo skill catalog (.agents/skills) needs refresh (${parts.join(', ')}). ` +
@@ -70,12 +70,14 @@ function setupBudget(changes) {
70
70
  (total, item) => total + (item.afterBase64 ? Buffer.from(item.afterBase64, 'base64').length : 0),
71
71
  0
72
72
  );
73
+ // Compact start includes shared MCP + one host registration + CI + AGENTS + config.
74
+ // Budget raised from 5→8 so .mcp.json always fits (field: grok compact hit the old ceiling).
73
75
  return {
74
76
  files: generatedChanges.length,
75
77
  bytes,
76
- maxFiles: 5,
77
- maxBytes: 25 * 1024,
78
- ok: generatedChanges.length <= 5 && bytes < 25 * 1024,
78
+ maxFiles: 8,
79
+ maxBytes: 32 * 1024,
80
+ ok: generatedChanges.length <= 8 && bytes < 32 * 1024,
79
81
  };
80
82
  }
81
83
 
@@ -84,7 +86,8 @@ function commands(root, args, helpers) {
84
86
  return [`ark start --root ${root} --tools ${args.removeHost} --apply`];
85
87
  }
86
88
  const result = [];
87
- if (args.installExplicit && args.install && fs.existsSync(path.join(root, 'package.json'))) {
89
+ // Default install=true: surface the package pin/install command unless --no-install.
90
+ if (args.install !== false && fs.existsSync(path.join(root, 'package.json'))) {
88
91
  const [command, commandArgs] = helpers.packageInstallArgv(root, `^${helpers.cliVersion()}`);
89
92
  result.push(`${command} ${commandArgs.join(' ')}`);
90
93
  }
@@ -216,8 +219,9 @@ export async function planStart(args, helpers) {
216
219
  if (args.yes) childArgs.push('--yes');
217
220
  if (args.force) childArgs.push('--force');
218
221
  if (!args.strict) childArgs.push('--no-strict');
219
- if (args.installExplicit && args.install) childArgs.push('--install');
220
- if (args.installExplicit && !args.install) childArgs.push('--no-install');
222
+ // Propagate install intent into the shadow plan so package.json pin is in the diff by default.
223
+ if (args.install === false) childArgs.push('--no-install');
224
+ else childArgs.push('--install');
221
225
  if (args.tools) childArgs.push('--tools', args.tools);
222
226
  if (args.requireWriteHook) childArgs.push('--require-write-hook', args.requireWriteHook);
223
227
  const planned = spawnSync(process.execPath, [helpers.cliPath, ...childArgs], {
@@ -55,23 +55,50 @@ function verify(root, json, arkCheck, runArkCheck) {
55
55
  export function runUpgradeCommand(args, dependencies) {
56
56
  const root = args.root;
57
57
  if (args.apply && args.install) {
58
- const [command, commandArgs] = dependencies.packageInstallArgv(root);
59
- if (!args.json) console.log(`Updating ArkGate: ${command} ${commandArgs.join(' ')}`);
60
- const install = spawnSync(command, commandArgs, {
61
- cwd: root,
62
- stdio: args.json ? ['ignore', 'pipe', 'pipe'] : 'inherit',
63
- encoding: 'utf8',
64
- });
65
- const exitCode = install.status ?? 1;
66
- if (exitCode !== 0) {
67
- if (args.json && install.stderr) console.error(install.stderr.trim());
58
+ const skip =
59
+ typeof dependencies.shouldSkipArkgateInstall === 'function'
60
+ ? dependencies.shouldSkipArkgateInstall(root, dependencies.cliVersion)
61
+ : { skip: false };
62
+ if (skip.skip) {
63
+ if (!args.json) {
64
+ console.log(
65
+ `Package already at arkgate@${skip.installedVersion}; skipping install and recomputing managed preview.`
66
+ );
67
+ }
68
+ } else {
69
+ const [command, commandArgs] = dependencies.packageInstallArgv(root);
70
+ if (!args.json) console.log(`Updating ArkGate: ${command} ${commandArgs.join(' ')}`);
71
+ const install = spawnSync(command, commandArgs, {
72
+ cwd: root,
73
+ stdio: args.json ? ['ignore', 'pipe', 'pipe'] : 'inherit',
74
+ encoding: 'utf8',
75
+ });
76
+ const exitCode = install.status ?? 1;
77
+ if (exitCode !== 0) {
78
+ if (args.json && install.stderr) console.error(install.stderr.trim());
79
+ const recovery = `${command} ${commandArgs.join(' ')}`;
80
+ console.error(
81
+ `Package update failed (exit ${exitCode}). Fix the install and re-run:\n` +
82
+ ` ${recovery}\n` +
83
+ `Then: ark upgrade --no-install --root ${JSON.stringify(root)}` +
84
+ (args.tools ? ` --tools ${args.tools}` : '') +
85
+ (!args.strict ? ' --no-strict' : '') +
86
+ (args.json ? ' --json' : '')
87
+ );
88
+ return exitCode;
89
+ }
90
+ }
91
+ // Re-enter via installed CLI so the managed plan uses the newly installed package bytes.
92
+ let cli;
93
+ try {
94
+ cli = installedCli(root);
95
+ } catch {
68
96
  console.error(
69
- `Package update failed (exit ${exitCode}). Fix the install and re-run, or use ` +
70
- '`ark upgrade --no-install` against the installed version.'
97
+ 'arkgate is not installed in this project after the package step. Install it, then re-run with --no-install.'
71
98
  );
72
- return exitCode;
99
+ return 1;
73
100
  }
74
- return spawnSync(process.execPath, [installedCli(root), ...previewArgs(args)], {
101
+ return spawnSync(process.execPath, [cli, ...previewArgs(args)], {
75
102
  cwd: root,
76
103
  stdio: 'inherit',
77
104
  encoding: 'utf8',
@@ -83,19 +110,42 @@ export function runUpgradeCommand(args, dependencies) {
83
110
  acceptConflicts: args.acceptConflicts,
84
111
  });
85
112
  if (!args.apply) {
113
+ const wouldWrite = plan.summary?.wouldWrite ?? 0;
114
+ const blocked = plan.summary?.blocked ?? 0;
115
+ const needsApply = wouldWrite > 0 || blocked > 0;
86
116
  const command = nextCommand(args, plan.planDigest);
87
- if (args.json) console.log(managedUpgradeJson(plan, { nextCommand: command }));
88
- else {
117
+ if (args.json) {
118
+ // Always expose nextCommand for digest-bound apply (metadata/manifest optional);
119
+ // nothingToApply flags when content writes are zero so UIs do not urge apply.
120
+ console.log(
121
+ managedUpgradeJson(plan, {
122
+ nextCommand: command,
123
+ ...(needsApply ? {} : { nothingToApply: true }),
124
+ })
125
+ );
126
+ } else {
127
+ const metadataRefresh = plan.summary?.metadataRefresh ?? 0;
89
128
  renderManagedUpgrade(plan, {
90
- next: args.install
91
- ? `Update the package and recompute this preview with: ${command}`
92
- : `Apply the exact preview with: ${command}`,
129
+ next: needsApply
130
+ ? args.install
131
+ ? `Update the package and recompute this preview with: ${command}`
132
+ : `Apply the exact preview with: ${command}`
133
+ : undefined,
134
+ // Human path: optional digest-bound stamp refresh without urging content apply.
135
+ ...( !needsApply && metadataRefresh > 0 ? { optionalStampApply: command } : {}),
93
136
  });
94
137
  }
95
138
  return 0;
96
139
  }
97
140
 
98
- const applied = applyManagedUpgrade(root, plan, args.planDigest);
141
+ let applied;
142
+ try {
143
+ applied = applyManagedUpgrade(root, plan, args.planDigest);
144
+ } catch (error) {
145
+ const message = error instanceof Error ? error.message : String(error);
146
+ console.error(message);
147
+ return 2;
148
+ }
99
149
  if (applied.blocked) {
100
150
  if (args.json) console.log(JSON.stringify(applied, null, 2));
101
151
  else renderManagedUpgrade(applied, {
@@ -103,6 +153,14 @@ export function runUpgradeCommand(args, dependencies) {
103
153
  });
104
154
  return 1;
105
155
  }
156
+ if (applied.nothingToApply && !applied.applied) {
157
+ if (args.json) console.log(JSON.stringify(applied, null, 2));
158
+ else {
159
+ renderManagedUpgrade(applied);
160
+ console.log('No managed content writes pending (optional stamp refresh needs --plan-digest).');
161
+ }
162
+ return 0;
163
+ }
106
164
  const verification = args.strict
107
165
  ? { mode: 'strict-merge', ...verify(root, args.json, dependencies.arkCheck, dependencies.runArkCheck) }
108
166
  : { mode: 'skipped', exitCode: 0 };
@@ -15,6 +15,43 @@ function installToolsForHost(activeHost) {
15
15
  : activeHost;
16
16
  }
17
17
 
18
+ /**
19
+ * Human/JSON note when doctor runs outside an agent host: inventory on disk vs
20
+ * this-invocation hardness (Z10 — never claim hard from assets alone).
21
+ * @param {{ hosts?: Record<string, unknown>, capabilities?: Record<string, boolean> }|null|undefined} inventory
22
+ * @param {Record<string, boolean>} capabilities active-host projection
23
+ * @returns {string}
24
+ */
25
+ export function buildUnknownHostSessionNote(inventory, capabilities = {}) {
26
+ const hosts = inventory?.hosts && typeof inventory.hosts === 'object' ? inventory.hosts : {};
27
+ const onDiskHosts = Object.entries(hosts)
28
+ .filter(([, record]) => {
29
+ if (!record || typeof record !== 'object') return false;
30
+ const caps = /** @type {{ [k: string]: boolean }} */ (record).capabilities ?? {};
31
+ const configured = Boolean(/** @type {{ configured?: boolean }} */ (record).configured);
32
+ return (
33
+ configured ||
34
+ Boolean(caps['hard-write']) ||
35
+ Boolean(caps['advisory-write']) ||
36
+ Boolean(caps['repair-payload'])
37
+ );
38
+ })
39
+ .map(([name]) => name)
40
+ .sort();
41
+ const parts = [];
42
+ if (onDiskHosts.length > 0) {
43
+ parts.push(`On-disk hosts with write-path assets: ${onDiskHosts.join(', ')}`);
44
+ }
45
+ if (inventory?.capabilities?.['merge-gate'] || capabilities['merge-gate']) {
46
+ parts.push('CI merge gate configured on disk');
47
+ }
48
+ parts.push(
49
+ 'This invocation has no active hard-write guarantee (activeHost unknown); ' +
50
+ 'required-status remains unverified without provider evidence'
51
+ );
52
+ return `${parts.join('. ')}.`;
53
+ }
54
+
18
55
  export function detectWritePathCapabilities(root, explicitHost, attempt) {
19
56
  const model = buildWritePathCapabilityModel(root, explicitHost, attempt);
20
57
  const { activeHost, support, capabilities, capabilityEvidence, enforcementLadder, enforcementState, inventory } = model;
@@ -92,6 +129,12 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
92
129
  };
93
130
  }
94
131
 
132
+ /** @type {string|null} */
133
+ const sessionNote =
134
+ activeHost === 'unknown'
135
+ ? buildUnknownHostSessionNote(inventory, capabilities)
136
+ : null;
137
+
95
138
  return {
96
139
  activeHost,
97
140
  support,
@@ -101,6 +144,8 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
101
144
  enforcementLadder,
102
145
  enforcementState,
103
146
  inventory,
147
+ // Configured inventory (on-disk hosts) vs this-invocation projection (activeHost).
148
+ ...(sessionNote ? { sessionNote } : {}),
104
149
  // Compatibility projection for existing doctor/API consumers.
105
150
  mode,
106
151
  prepareWrite: advisoryWrite,