arkgate 4.4.0 → 4.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +71 -2
  2. package/README.md +7 -4
  3. package/bin/ark-check-runtime.mjs +38 -13
  4. package/bin/ark-layer-match.mjs +25 -12
  5. package/bin/lib/adapter-contract.mjs +5 -5
  6. package/bin/lib/analysis-engine.mjs +5 -5
  7. package/bin/lib/ci-and-commands.mjs +5 -0
  8. package/bin/lib/deep-module-coach.mjs +177 -0
  9. package/bin/lib/deepening-coach.mjs +177 -0
  10. package/bin/lib/doctor-plan.mjs +14 -0
  11. package/bin/lib/html-report-advisories.mjs +33 -0
  12. package/bin/lib/html-report-depth.mjs +9 -0
  13. package/bin/lib/html-report.mjs +8 -1
  14. package/bin/lib/improvement-compass-map.mjs +507 -0
  15. package/bin/lib/improvement-compass-types.mjs +85 -0
  16. package/bin/lib/improvement-compass.mjs +10 -561
  17. package/bin/lib/managed-upgrade-honesty.mjs +201 -0
  18. package/bin/lib/managed-upgrade.mjs +54 -4
  19. package/bin/lib/remediation.mjs +5 -5
  20. package/bin/lib/status-command.mjs +127 -2
  21. package/bin/lib/status-manifest.mjs +163 -14
  22. package/bin/lib/upgrade-whats-new.mjs +110 -0
  23. package/dist/eslint/index.cjs +2 -2
  24. package/dist/eslint/index.js +2 -2
  25. package/dist/index.cjs +28 -28
  26. package/dist/index.d.ts +126 -21
  27. package/dist/index.js +28 -28
  28. package/docs/README.md +5 -5
  29. package/docs/agent-guide.md +50 -8
  30. package/docs/brownfield-adoption.md +12 -0
  31. package/docs/develop.md +3 -1
  32. package/docs/package-surface.md +8 -6
  33. package/docs/product-voice.md +25 -1
  34. package/docs/use.md +33 -0
  35. package/package.json +1 -1
  36. package/schemas/ark.status-manifest.schema.json +28 -1
  37. package/server.json +2 -2
  38. package/templates/agent-skills/README.md +2 -2
  39. package/templates/agent-skills/ark-adopt/SKILL.md +13 -0
  40. package/templates/agent-skills/ark-explore/SKILL.md +21 -0
  41. package/templates/agent-skills/ark-fix/SKILL.md +7 -0
  42. package/templates/agent-skills/ark-loop/SKILL.md +7 -0
  43. package/templates/agent-skills/ark-place/SKILL.md +7 -0
  44. package/templates/agent-skills/ark-think/SKILL.md +7 -0
  45. package/templates/agent-skills/ark-upgrade/SKILL.md +14 -0
  46. package/templates/skills/ark-adopt.md +13 -0
  47. package/templates/skills/ark-explore.md +21 -0
  48. package/templates/skills/ark-fix.md +7 -0
  49. package/templates/skills/ark-loop.md +7 -0
  50. package/templates/skills/ark-place.md +7 -0
  51. package/templates/skills/ark-think.md +7 -0
  52. package/templates/skills/ark-upgrade.md +14 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * DF05 — managed-upgrade self-service honesty (one residual pilot).
3
+ *
4
+ * Self-service criterion (must stay answerable from package surfaces without a maintainer):
5
+ * After a managed upgrade (or equivalent), can a consumer learn from package surfaces whether
6
+ * the write-path is still honestly labeled active/advisory and whether customized install
7
+ * content was preserved — without asking a maintainer?
8
+ *
9
+ * This module projects that answer onto `ark upgrade` JSON/human output:
10
+ * - write-path activation labels per selected host (hard | advisory | unavailable)
11
+ * - customized (and conflicted) content-identity preserve proof
12
+ *
13
+ * Soft hosts never claim hard. Upgrade never invents hardWriteActive from disk alone —
14
+ * hard requires runtime evidence elsewhere (hooks/doctor/status); upgrade labels fail-closed.
15
+ * Always notAScore; never a gate input.
16
+ */
17
+ import { getHostSupportProfile } from './host-support-matrix.mjs';
18
+ import { classifyStatusWritePath, defaultHonestLabel } from './status-manifest.mjs';
19
+
20
+ /**
21
+ * @param {string} host
22
+ * @param {{ hardWriteActive?: boolean }} [evidence]
23
+ * @returns {{
24
+ * host: string,
25
+ * writePath: 'hard'|'advisory'|'unavailable',
26
+ * softWriteHost: boolean,
27
+ * hardWriteSupported: boolean,
28
+ * hardWriteActive: boolean,
29
+ * label: string,
30
+ * }}
31
+ */
32
+ export function projectHostWritePathActivation(host, evidence = {}) {
33
+ const normalized = typeof host === 'string' ? host.trim().toLowerCase() : '';
34
+ if (!normalized) {
35
+ return {
36
+ host: 'unknown',
37
+ writePath: 'unavailable',
38
+ softWriteHost: false,
39
+ hardWriteSupported: false,
40
+ hardWriteActive: false,
41
+ label: defaultHonestLabel('unavailable', null),
42
+ };
43
+ }
44
+
45
+ const profile = getHostSupportProfile(normalized);
46
+ if (!profile) {
47
+ return {
48
+ host: normalized,
49
+ writePath: 'unavailable',
50
+ softWriteHost: false,
51
+ hardWriteSupported: false,
52
+ hardWriteActive: false,
53
+ label: defaultHonestLabel('unavailable', normalized),
54
+ };
55
+ }
56
+
57
+ const hardWriteSupported = profile.capabilities?.['hard-write'] === true;
58
+ const softWriteHost = !hardWriteSupported;
59
+ // Fail-closed: soft never hard; hard only when caller supplies proven active evidence.
60
+ const hardWriteActive =
61
+ !softWriteHost && hardWriteSupported && evidence.hardWriteActive === true;
62
+ const writePath = classifyStatusWritePath({
63
+ softWriteHost,
64
+ hardWriteActive,
65
+ activeHost: normalized,
66
+ });
67
+ return {
68
+ host: normalized,
69
+ writePath,
70
+ softWriteHost,
71
+ hardWriteSupported,
72
+ hardWriteActive,
73
+ label: defaultHonestLabel(writePath, normalized),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Project self-service honesty facts from a managed upgrade plan.
79
+ *
80
+ * @param {{
81
+ * hosts?: string[],
82
+ * assets?: Array<{ path?: string, state?: string, willApply?: boolean, blocked?: boolean }>,
83
+ * summary?: { customizedPreserved?: number, blocked?: number, states?: Record<string, number> },
84
+ * }} plan
85
+ * @param {{
86
+ * hardWriteActiveByHost?: Record<string, boolean>,
87
+ * }} [options]
88
+ * @returns {{
89
+ * schemaVersion: '1.0',
90
+ * notAScore: true,
91
+ * criterionId: 'df05-upgrade-activation-preserve',
92
+ * customizedPreserved: number,
93
+ * customizedPaths: string[],
94
+ * conflictedPaths: string[],
95
+ * customizedContentPreserved: boolean,
96
+ * writePathActivation: ReturnType<typeof projectHostWritePathActivation>[],
97
+ * writePathHonestlyLabeled: boolean,
98
+ * answers: {
99
+ * writePathActivationLabeled: boolean,
100
+ * customizedContentPreserved: boolean,
101
+ * },
102
+ * }}
103
+ */
104
+ export function projectManagedUpgradeSelfServiceHonesty(plan, options = {}) {
105
+ const assets = Array.isArray(plan?.assets) ? plan.assets : [];
106
+ const hosts = Array.isArray(plan?.hosts) ? plan.hosts : [];
107
+ const hardByHost =
108
+ options.hardWriteActiveByHost && typeof options.hardWriteActiveByHost === 'object'
109
+ ? options.hardWriteActiveByHost
110
+ : {};
111
+
112
+ const customizedPaths = assets
113
+ .filter((asset) => asset?.state === 'customized' && typeof asset.path === 'string')
114
+ .map((asset) => asset.path)
115
+ .sort();
116
+ const conflictedPaths = assets
117
+ .filter((asset) => asset?.state === 'conflicted' && typeof asset.path === 'string')
118
+ .map((asset) => asset.path)
119
+ .sort();
120
+
121
+ // Preserve contract: customized assets must never be scheduled writes without consent.
122
+ const customizedContentPreserved = assets
123
+ .filter((asset) => asset?.state === 'customized' || asset?.state === 'conflicted')
124
+ .every((asset) => asset.willApply !== true);
125
+
126
+ const summaryCount =
127
+ typeof plan?.summary?.customizedPreserved === 'number'
128
+ ? plan.summary.customizedPreserved
129
+ : customizedPaths.length;
130
+
131
+ const writePathActivation = hosts.map((host) => {
132
+ const key = typeof host === 'string' ? host.trim().toLowerCase() : '';
133
+ return projectHostWritePathActivation(host, {
134
+ hardWriteActive: hardByHost[key] === true,
135
+ });
136
+ });
137
+
138
+ // Soft hosts never labeled hard; hard only when evidence supplied.
139
+ const writePathHonestlyLabeled = writePathActivation.every((entry) => {
140
+ if (entry.softWriteHost && entry.writePath === 'hard') return false;
141
+ if (entry.softWriteHost && entry.hardWriteActive) return false;
142
+ if (!entry.hardWriteSupported && entry.writePath === 'hard') return false;
143
+ if (entry.writePath === 'hard' && !entry.hardWriteActive) return false;
144
+ return true;
145
+ });
146
+
147
+ const answers = {
148
+ // Empty host list is not "labeled activation" — only claim labeled when hosts were projected.
149
+ writePathActivationLabeled:
150
+ writePathActivation.length > 0 && writePathHonestlyLabeled,
151
+ customizedContentPreserved,
152
+ };
153
+
154
+ return {
155
+ schemaVersion: '1.0',
156
+ notAScore: true,
157
+ criterionId: 'df05-upgrade-activation-preserve',
158
+ customizedPreserved: summaryCount,
159
+ customizedPaths,
160
+ conflictedPaths,
161
+ customizedContentPreserved,
162
+ writePathActivation,
163
+ writePathHonestlyLabeled,
164
+ answers,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Human one-liner block for upgrade preview/apply (stdout).
170
+ * @param {ReturnType<typeof projectManagedUpgradeSelfServiceHonesty>} honesty
171
+ */
172
+ export function formatManagedUpgradeSelfServiceHonesty(honesty) {
173
+ if (!honesty) return [];
174
+ const lines = ['Self-service honesty (no maintainer required):'];
175
+ if (honesty.writePathActivation.length === 0) {
176
+ lines.push(' Write-path: shared/gates only (no host selected) — activation unavailable.');
177
+ } else {
178
+ for (const entry of honesty.writePathActivation) {
179
+ const soft = entry.softWriteHost ? 'soft host' : 'hard-capable';
180
+ const active = entry.hardWriteActive ? 'active' : 'not proven this invocation';
181
+ lines.push(
182
+ ` Write-path ${entry.host}: ${entry.writePath} (${soft}; hard ${active}).`
183
+ );
184
+ }
185
+ }
186
+ if (honesty.customizedPaths.length > 0) {
187
+ lines.push(
188
+ ` Customized preserved: ${honesty.customizedPreserved} (${honesty.customizedPaths.join(', ')}).`
189
+ );
190
+ } else {
191
+ lines.push(
192
+ ` Customized preserved: ${honesty.customizedPreserved} (no customized managed assets).`
193
+ );
194
+ }
195
+ if (honesty.conflictedPaths.length > 0) {
196
+ lines.push(
197
+ ` Conflicted (consent required): ${honesty.conflictedPaths.join(', ')}.`
198
+ );
199
+ }
200
+ return lines;
201
+ }
@@ -4,6 +4,14 @@ import path from 'node:path';
4
4
 
5
5
  import { codexPrimaryTable, upsertCodexMcpTable } from './codex-home.mjs';
6
6
  import { buildManagedAssetCatalog } from './install-migrate.mjs';
7
+ import {
8
+ formatManagedUpgradeSelfServiceHonesty,
9
+ projectManagedUpgradeSelfServiceHonesty,
10
+ } from './managed-upgrade-honesty.mjs';
11
+ import {
12
+ buildUpgradeWhatsNewSuggestions,
13
+ formatUpgradeWhatsNewSuggestions,
14
+ } from './upgrade-whats-new.mjs';
7
15
  import {
8
16
  KNOWN_TOOLS,
9
17
  arkPackageVersion,
@@ -12,6 +20,17 @@ import {
12
20
  skillContentIdentity,
13
21
  } from './skill-install.mjs';
14
22
 
23
+ export {
24
+ formatManagedUpgradeSelfServiceHonesty,
25
+ projectHostWritePathActivation,
26
+ projectManagedUpgradeSelfServiceHonesty,
27
+ } from './managed-upgrade-honesty.mjs';
28
+ export {
29
+ buildUpgradeWhatsNewSuggestions,
30
+ formatUpgradeWhatsNewSuggestions,
31
+ UPGRADE_WHATS_NEW_SCHEMA_VERSION,
32
+ } from './upgrade-whats-new.mjs';
33
+
15
34
  export const MANAGED_MANIFEST_PATH = 'ark.managed.json';
16
35
  const MANIFEST_VERSION = '1.0';
17
36
  const AFTER_CONTENT = Symbol('managed-after-content');
@@ -534,7 +553,10 @@ export function planManagedUpgrade(root, options = {}) {
534
553
  }
535
554
 
536
555
  function publicPlan(plan, overrides = {}) {
537
- return {
556
+ const assets = plan.assets.map(
557
+ ({ containerBeforeHash: _container, [AFTER_CONTENT]: _content, ...asset }) => asset
558
+ );
559
+ const base = {
538
560
  schemaVersion: plan.schemaVersion,
539
561
  root: plan.root,
540
562
  readOnly: overrides.readOnly ?? plan.readOnly,
@@ -544,11 +566,25 @@ function publicPlan(plan, overrides = {}) {
544
566
  profile: plan.profile,
545
567
  hosts: plan.hosts,
546
568
  acceptConflicts: plan.acceptConflicts,
547
- assets: plan.assets.map(
548
- ({ containerBeforeHash: _container, [AFTER_CONTENT]: _content, ...asset }) => asset
549
- ),
569
+ assets,
550
570
  summary: plan.summary,
571
+ };
572
+ // DF05: self-service honesty (write-path labels + customized preserve) on every public plan.
573
+ // Not part of planDigest — advisory projection only; never invents hard write on soft hosts.
574
+ const selfService = projectManagedUpgradeSelfServiceHonesty({
575
+ hosts: base.hosts,
576
+ assets,
577
+ summary: base.summary,
578
+ });
579
+ // Suggested improvements / what’s new — product capabilities to try after install/upgrade.
580
+ // Always notAScore; never a gate input; not part of planDigest.
581
+ const whatsNew = buildUpgradeWhatsNewSuggestions();
582
+ return {
583
+ ...base,
551
584
  ...overrides,
585
+ // DF05 projection always present unless an override supplies a replacement.
586
+ selfService: overrides.selfService ?? selfService,
587
+ whatsNew: overrides.whatsNew ?? whatsNew,
552
588
  };
553
589
  }
554
590
 
@@ -738,6 +774,20 @@ export function renderManagedUpgrade(plan, options = {}) {
738
774
  `Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
739
775
  `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
740
776
  );
777
+ const honesty =
778
+ plan.selfService ??
779
+ projectManagedUpgradeSelfServiceHonesty({
780
+ hosts: plan.hosts,
781
+ assets: plan.assets,
782
+ summary: plan.summary,
783
+ });
784
+ for (const line of formatManagedUpgradeSelfServiceHonesty(honesty)) {
785
+ console.log(line);
786
+ }
787
+ const whatsNew = plan.whatsNew ?? buildUpgradeWhatsNewSuggestions();
788
+ for (const line of formatUpgradeWhatsNewSuggestions(whatsNew)) {
789
+ console.log(line);
790
+ }
741
791
  if (plan.applied) {
742
792
  console.log(
743
793
  `Applied ${wouldWrite} content write(s)` +
@@ -49,15 +49,15 @@ export function deterministicNextAction(violation) {
49
49
  return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
50
50
  }
51
51
  if (violation.peerIsolation) {
52
- return 'Extract the shared dependency to a shared layer, then preflight again.';
52
+ return 'Extract the shared dependency to a shared layer, test at the public interface, then preflight again.';
53
53
  }
54
- return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, then preflight again.`;
54
+ return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, test at the public interface, then preflight again.`;
55
55
  case 'FORBIDDEN_GLOBAL':
56
- return `Inject ${violation.target ?? 'the capability'} through a port, then preflight again.`;
56
+ return `Inject ${violation.target ?? 'the capability'} through a port, test at the public interface, then preflight again.`;
57
57
  case 'CAPABILITY_VIOLATION':
58
- return `Define a ${String(violation.capability ?? 'capability')} port in ${violation.fromLayer ?? 'the walled layer'}, bind the implementation outside it, then preflight again.`;
58
+ return `Define a ${String(violation.capability ?? 'capability')} port in ${violation.fromLayer ?? 'the walled layer'}, bind the implementation outside it, test at the public interface, then preflight again.`;
59
59
  case 'CIRCULAR_DEPENDENCY':
60
- return 'Extract the shared dependency into a third module, then preflight again.';
60
+ return 'Extract the shared dependency into a third module, test at the public interface, then preflight again.';
61
61
  case 'RAW_EVENT_PUBLISH':
62
62
  return 'Publish through a registered intent creator, then run Ark again.';
63
63
  case 'PUBLISH_MISSING_SOURCE':
@@ -1,15 +1,21 @@
1
1
  /**
2
- * ACS03 — gather session/project evidence for `ark status` / MCP `ark_status`.
2
+ * ACS03 + DF02 — gather session/project evidence for `ark status` / MCP `ark_status`.
3
3
  *
4
4
  * Fail-closed and CI-safe: never prompts (no readline), never invents hard write,
5
5
  * never invents a numeric score. Pure assembly lives in Domain statusManifest.
6
+ * Improvement compass carries explicit honesty mode (full|subset|unavailable).
6
7
  */
7
8
  import { createHash } from 'node:crypto';
8
9
  import fs from 'node:fs';
9
10
  import path from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
11
12
 
12
- import { buildStatusManifest } from './status-manifest.mjs';
13
+ import {
14
+ buildStatusManifest,
15
+ normalizeStatusImprovementCompass,
16
+ projectStatusImprovementCompass,
17
+ unavailableStatusImprovementCompass,
18
+ } from './status-manifest.mjs';
13
19
  import { createProjectId } from './project-identity.mjs';
14
20
  import { resolveEffectiveProjectRoot } from './project-root.mjs';
15
21
  import { detectWritePathCapabilities } from './write-path-detect.mjs';
@@ -130,6 +136,94 @@ export function lastCheckFactsFromSnapshot(latest, baseline) {
130
136
  };
131
137
  }
132
138
 
139
+ /**
140
+ * Project status improvementCompass honesty from a report/session snapshot (DF02).
141
+ * Prefer stored thin slice; never invent green residual when facts are missing.
142
+ *
143
+ * @param {object|null|undefined} latest
144
+ * @param {{ contractHash?: string|null }} [opts]
145
+ * @returns {import('./status-manifest.mjs').StatusImprovementCompassSlice}
146
+ */
147
+ export function statusCompassFromSnapshot(latest, opts = {}) {
148
+ const contractHash =
149
+ typeof opts.contractHash === 'string' && opts.contractHash.length > 0
150
+ ? opts.contractHash
151
+ : null;
152
+
153
+ if (!latest || typeof latest !== 'object') {
154
+ return unavailableStatusImprovementCompass({
155
+ reasonCode: 'NO_SESSION_SNAPSHOT',
156
+ reason:
157
+ 'No session report snapshot yet — run ark-check --doctor or --report for residual lenses. Status never invents green.',
158
+ contractHash,
159
+ });
160
+ }
161
+
162
+ // Prefer explicit thin status slice on the snapshot (report path stores mode+residual).
163
+ if (latest.improvementCompass && typeof latest.improvementCompass === 'object') {
164
+ const normalized = normalizeStatusImprovementCompass({
165
+ ...latest.improvementCompass,
166
+ ...(contractHash && !latest.improvementCompass.contractHash
167
+ ? { contractHash }
168
+ : {}),
169
+ factsSource: latest.improvementCompass.factsSource || 'report-snapshot',
170
+ });
171
+ if (normalized) return normalized;
172
+ }
173
+
174
+ // Doctor-equivalent residual ids stored without honesty wrapper → full if complete flag set.
175
+ if (
176
+ latest.doctorImprovementCompass &&
177
+ typeof latest.doctorImprovementCompass === 'object' &&
178
+ latest.doctorImprovementCompass.notAScore === true &&
179
+ Array.isArray(latest.doctorImprovementCompass.topResidual)
180
+ ) {
181
+ const complete = latest.compassFactsComplete === true || latest.completeness === 'complete';
182
+ return projectStatusImprovementCompass({
183
+ mode: complete ? 'full' : 'subset',
184
+ topResidual: latest.doctorImprovementCompass.topResidual,
185
+ reasonCode: complete ? undefined : 'FACTS_PARTIAL',
186
+ reason: complete
187
+ ? undefined
188
+ : 'Session snapshot residual is partial — re-run doctor/report for full compass.',
189
+ factsSource: 'report-snapshot',
190
+ contractHash,
191
+ });
192
+ }
193
+
194
+ return unavailableStatusImprovementCompass({
195
+ reasonCode: 'NO_SESSION_SNAPSHOT',
196
+ reason:
197
+ 'Session snapshot has no improvement compass facts — run ark-check --doctor or --report. Status never invents green.',
198
+ contractHash,
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Build a storeable thin status compass from a full doctor ImprovementCompass (DF02).
204
+ * Used by report snapshot so status residual ⊆ doctor residual for the same tree.
205
+ *
206
+ * @param {{ notAScore?: boolean, topResidual?: string[] }|null|undefined} doctorCompass
207
+ * @param {{ mode?: 'full'|'subset', contractHash?: string|null, reasonCode?: string, reason?: string }} [opts]
208
+ */
209
+ export function thinStatusCompassFromDoctor(doctorCompass, opts = {}) {
210
+ if (!doctorCompass || doctorCompass.notAScore !== true) {
211
+ return unavailableStatusImprovementCompass({
212
+ reasonCode: 'FACTS_UNAVAILABLE',
213
+ contractHash: opts.contractHash,
214
+ });
215
+ }
216
+ const mode = opts.mode === 'subset' ? 'subset' : 'full';
217
+ return projectStatusImprovementCompass({
218
+ mode,
219
+ topResidual: Array.isArray(doctorCompass.topResidual) ? doctorCompass.topResidual : [],
220
+ reasonCode: opts.reasonCode,
221
+ reason: opts.reason,
222
+ factsSource: 'report-snapshot',
223
+ contractHash: opts.contractHash,
224
+ });
225
+ }
226
+
133
227
  /**
134
228
  * Collect status facts from disk (no prompts).
135
229
  * @param {{
@@ -140,6 +234,8 @@ export function lastCheckFactsFromSnapshot(latest, baseline) {
140
234
  * host?: string,
141
235
  * arkgateVersion?: string,
142
236
  * env?: NodeJS.ProcessEnv,
237
+ * improvementCompass?: object | null,
238
+ * contractHash?: string | null,
143
239
  * }} [options]
144
240
  */
145
241
  export function collectStatusFacts(options = {}) {
@@ -242,6 +338,22 @@ export function collectStatusFacts(options = {}) {
242
338
 
243
339
  const arkruleFrozenFallback = countArkruleFrozenKeys(baseline);
244
340
 
341
+ // DF02 — always project compass with honesty mode (never invent green residual).
342
+ // Prefer explicit override (tests/MCP inject doctor-facts); else report snapshot.
343
+ let improvementCompass = null;
344
+ if (options.improvementCompass != null) {
345
+ improvementCompass = normalizeStatusImprovementCompass(options.improvementCompass);
346
+ }
347
+ if (!improvementCompass) {
348
+ const contractHash =
349
+ typeof options.contractHash === 'string' && options.contractHash.length > 0
350
+ ? options.contractHash
351
+ : configExists && config
352
+ ? sha256Hex(JSON.stringify(config))
353
+ : null;
354
+ improvementCompass = statusCompassFromSnapshot(latest, { contractHash });
355
+ }
356
+
245
357
  return {
246
358
  arkgateVersion: options.arkgateVersion || packageVersion(),
247
359
  resolvedRoot: realpathOrResolve(resolvedRoot),
@@ -269,6 +381,7 @@ export function collectStatusFacts(options = {}) {
269
381
  : arkRulesLoaded
270
382
  ? 0
271
383
  : null,
384
+ improvementCompass,
272
385
  };
273
386
  }
274
387
 
@@ -346,6 +459,18 @@ export function runStatusCommand(args = {}) {
346
459
  ? ` · frozen=${manifest.rules.frozenResidual}`
347
460
  : '')
348
461
  );
462
+ const ic = manifest.improvementCompass;
463
+ if (ic) {
464
+ const residual =
465
+ Array.isArray(ic.topResidual) && ic.topResidual.length > 0
466
+ ? ic.topResidual.join(', ')
467
+ : '(none)';
468
+ write(
469
+ ` compass: mode=${ic.mode}` +
470
+ (ic.mode === 'unavailable' ? '' : ` · residual=${residual}`) +
471
+ ' · not a score'
472
+ );
473
+ }
349
474
  write(` next: [${manifest.nextAction.id}] ${manifest.nextAction.summary}`);
350
475
  }
351
476