arkgate 4.4.0 → 4.5.0

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.
@@ -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,10 @@ 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';
7
11
  import {
8
12
  KNOWN_TOOLS,
9
13
  arkPackageVersion,
@@ -12,6 +16,12 @@ import {
12
16
  skillContentIdentity,
13
17
  } from './skill-install.mjs';
14
18
 
19
+ export {
20
+ formatManagedUpgradeSelfServiceHonesty,
21
+ projectHostWritePathActivation,
22
+ projectManagedUpgradeSelfServiceHonesty,
23
+ } from './managed-upgrade-honesty.mjs';
24
+
15
25
  export const MANAGED_MANIFEST_PATH = 'ark.managed.json';
16
26
  const MANIFEST_VERSION = '1.0';
17
27
  const AFTER_CONTENT = Symbol('managed-after-content');
@@ -534,7 +544,10 @@ export function planManagedUpgrade(root, options = {}) {
534
544
  }
535
545
 
536
546
  function publicPlan(plan, overrides = {}) {
537
- return {
547
+ const assets = plan.assets.map(
548
+ ({ containerBeforeHash: _container, [AFTER_CONTENT]: _content, ...asset }) => asset
549
+ );
550
+ const base = {
538
551
  schemaVersion: plan.schemaVersion,
539
552
  root: plan.root,
540
553
  readOnly: overrides.readOnly ?? plan.readOnly,
@@ -544,11 +557,21 @@ function publicPlan(plan, overrides = {}) {
544
557
  profile: plan.profile,
545
558
  hosts: plan.hosts,
546
559
  acceptConflicts: plan.acceptConflicts,
547
- assets: plan.assets.map(
548
- ({ containerBeforeHash: _container, [AFTER_CONTENT]: _content, ...asset }) => asset
549
- ),
560
+ assets,
550
561
  summary: plan.summary,
562
+ };
563
+ // DF05: self-service honesty (write-path labels + customized preserve) on every public plan.
564
+ // Not part of planDigest — advisory projection only; never invents hard write on soft hosts.
565
+ const selfService = projectManagedUpgradeSelfServiceHonesty({
566
+ hosts: base.hosts,
567
+ assets,
568
+ summary: base.summary,
569
+ });
570
+ return {
571
+ ...base,
551
572
  ...overrides,
573
+ // DF05 projection always present unless an override supplies a replacement.
574
+ selfService: overrides.selfService ?? selfService,
552
575
  };
553
576
  }
554
577
 
@@ -738,6 +761,16 @@ export function renderManagedUpgrade(plan, options = {}) {
738
761
  `Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
739
762
  `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
740
763
  );
764
+ const honesty =
765
+ plan.selfService ??
766
+ projectManagedUpgradeSelfServiceHonesty({
767
+ hosts: plan.hosts,
768
+ assets: plan.assets,
769
+ summary: plan.summary,
770
+ });
771
+ for (const line of formatManagedUpgradeSelfServiceHonesty(honesty)) {
772
+ console.log(line);
773
+ }
741
774
  if (plan.applied) {
742
775
  console.log(
743
776
  `Applied ${wouldWrite} content write(s)` +
@@ -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
 
@@ -10,6 +10,25 @@
10
10
 
11
11
  export const ARK_STATUS_MANIFEST_SCHEMA_VERSION = '1.0';
12
12
  export const ARK_STATUS_MANIFEST_SCHEMA_URL = 'https://unpkg.com/arkgate@4/schemas/ark.status-manifest.schema.json';
13
+ /**
14
+ * Honesty mode for status improvementCompass (DF02).
15
+ * - full: residual projected from doctor-equivalent facts (residual ⊆ doctor)
16
+ * - subset: incomplete facts; residual may omit doctor residual; never invent green
17
+ * - unavailable: no usable facts; empty residual + reason (never silent ok)
18
+ */
19
+ export const STATUS_COMPASS_MODES = ['full', 'subset', 'unavailable'];
20
+ /** Provenance for status compass residual (same-tree intent). */
21
+ export const STATUS_COMPASS_FACTS_SOURCES = [
22
+ 'doctor-facts',
23
+ 'report-snapshot',
24
+ 'none',
25
+ ];
26
+ /** Stable reason codes when mode is not full. */
27
+ export const STATUS_COMPASS_REASON_CODES = {
28
+ FACTS_UNAVAILABLE: 'FACTS_UNAVAILABLE',
29
+ FACTS_PARTIAL: 'FACTS_PARTIAL',
30
+ NO_SESSION_SNAPSHOT: 'NO_SESSION_SNAPSHOT',
31
+ };
13
32
  const PROJECT_ID_PATTERN = /^sha256:[a-f0-9]{64}$/;
14
33
  /**
15
34
  * Evaluate project binding for status without filesystem.
@@ -294,23 +313,148 @@ export function buildStatusManifest(facts) {
294
313
  status.improvementCompass = compass;
295
314
  return status;
296
315
  }
297
- function normalizeStatusImprovementCompass(value) {
316
+ const STATUS_COMPASS_MODE_SET = new Set(STATUS_COMPASS_MODES);
317
+ const STATUS_COMPASS_SOURCE_SET = new Set(STATUS_COMPASS_FACTS_SOURCES);
318
+ /**
319
+ * Project a thin status improvementCompass with explicit honesty mode (DF02).
320
+ *
321
+ * Rules:
322
+ * - always notAScore: true
323
+ * - mode full | subset | unavailable (invalid mode → unavailable)
324
+ * - unavailable: topResidual forced empty (never invent residual or silent green)
325
+ * - full/subset: residual ids from input only (never fabricate ok lenses)
326
+ * - never carries valid / goal.met / score fields
327
+ */
328
+ export function projectStatusImprovementCompass(input) {
329
+ const mode = STATUS_COMPASS_MODE_SET.has(input.mode)
330
+ ? input.mode
331
+ : 'unavailable';
332
+ const factsSource = input.factsSource != null && STATUS_COMPASS_SOURCE_SET.has(input.factsSource)
333
+ ? input.factsSource
334
+ : mode === 'unavailable'
335
+ ? 'none'
336
+ : undefined;
337
+ const contractHash = typeof input.contractHash === 'string' && input.contractHash.length > 0
338
+ ? input.contractHash
339
+ : undefined;
340
+ if (mode === 'unavailable') {
341
+ const out = {
342
+ schemaVersion: '1.0',
343
+ notAScore: true,
344
+ mode: 'unavailable',
345
+ topResidual: [],
346
+ reasonCode: typeof input.reasonCode === 'string' && input.reasonCode.length > 0
347
+ ? input.reasonCode
348
+ : STATUS_COMPASS_REASON_CODES.FACTS_UNAVAILABLE,
349
+ reason: typeof input.reason === 'string' && input.reason.length > 0
350
+ ? input.reason
351
+ : 'Improvement compass facts are unavailable — run ark-check --doctor for residual lenses. Status never invents green.',
352
+ factsSource: factsSource ?? 'none',
353
+ };
354
+ if (contractHash)
355
+ out.contractHash = contractHash;
356
+ return out;
357
+ }
358
+ const topResidual = Array.isArray(input.topResidual)
359
+ ? input.topResidual
360
+ .filter((id) => typeof id === 'string' && id.length > 0)
361
+ .slice(0, 15)
362
+ : [];
363
+ const out = {
364
+ schemaVersion: '1.0',
365
+ notAScore: true,
366
+ mode,
367
+ topResidual,
368
+ };
369
+ if (mode === 'subset') {
370
+ out.reasonCode =
371
+ typeof input.reasonCode === 'string' && input.reasonCode.length > 0
372
+ ? input.reasonCode
373
+ : STATUS_COMPASS_REASON_CODES.FACTS_PARTIAL;
374
+ out.reason =
375
+ typeof input.reason === 'string' && input.reason.length > 0
376
+ ? input.reason
377
+ : 'Status compass is a subset of doctor residual — incomplete session facts; run doctor for full.';
378
+ }
379
+ else if (typeof input.reasonCode === 'string' && input.reasonCode.length > 0) {
380
+ out.reasonCode = input.reasonCode;
381
+ }
382
+ if (typeof input.reason === 'string' && input.reason.length > 0 && mode === 'full') {
383
+ out.reason = input.reason;
384
+ }
385
+ if (factsSource)
386
+ out.factsSource = factsSource;
387
+ if (contractHash)
388
+ out.contractHash = contractHash;
389
+ return out;
390
+ }
391
+ /**
392
+ * Unavailable compass when Tooling has no doctor/report residual facts.
393
+ * Empty residual + mode label — never a green / ok claim.
394
+ */
395
+ export function unavailableStatusImprovementCompass(input = {}) {
396
+ return projectStatusImprovementCompass({
397
+ mode: 'unavailable',
398
+ topResidual: [],
399
+ reasonCode: input.reasonCode ?? STATUS_COMPASS_REASON_CODES.NO_SESSION_SNAPSHOT,
400
+ reason: input.reason ??
401
+ 'No session compass facts yet — run ark-check --doctor or --report for residual lenses. Status never invents green.',
402
+ factsSource: 'none',
403
+ contractHash: input.contractHash,
404
+ });
405
+ }
406
+ /**
407
+ * Normalize an incoming status compass slice (Tooling pass-through / snapshot).
408
+ * Rejects score-like shapes; coerces missing mode to subset (never silent full).
409
+ * Unavailable always clears residual.
410
+ */
411
+ export function normalizeStatusImprovementCompass(value) {
298
412
  if (value == null || typeof value !== 'object')
299
413
  return null;
300
- if (value.notAScore !== true)
414
+ const record = value;
415
+ if (record.notAScore !== true)
301
416
  return null;
302
- if (value.schemaVersion !== '1.0')
417
+ if (record.schemaVersion !== '1.0')
303
418
  return null;
304
- if (!Array.isArray(value.topResidual))
419
+ // Score-like fields never allowed on status compass.
420
+ if ('score' in record || 'valid' in record || 'goal' in record)
305
421
  return null;
306
- const topResidual = value.topResidual
307
- .filter((id) => typeof id === 'string' && id.length > 0)
308
- .slice(0, 15);
309
- return {
310
- schemaVersion: '1.0',
311
- notAScore: true,
312
- topResidual,
313
- };
422
+ let mode;
423
+ if (typeof record.mode === 'string' && STATUS_COMPASS_MODE_SET.has(record.mode)) {
424
+ mode = record.mode;
425
+ }
426
+ else if (Array.isArray(record.topResidual)) {
427
+ // Legacy thin slice without mode → subset honesty (never silent full).
428
+ mode = 'subset';
429
+ }
430
+ else {
431
+ mode = 'unavailable';
432
+ }
433
+ return projectStatusImprovementCompass({
434
+ mode,
435
+ topResidual: Array.isArray(record.topResidual)
436
+ ? record.topResidual
437
+ : [],
438
+ reasonCode: typeof record.reasonCode === 'string' ? record.reasonCode : null,
439
+ reason: typeof record.reason === 'string' ? record.reason : null,
440
+ factsSource: typeof record.factsSource === 'string' ? record.factsSource : null,
441
+ contractHash: typeof record.contractHash === 'string' ? record.contractHash : null,
442
+ });
443
+ }
444
+ /**
445
+ * Residual-id subset check for status ⊆ doctor parity fixtures (DF02).
446
+ * Returns true when every status residual id appears in doctor residual ids.
447
+ */
448
+ export function statusCompassResidualIsSubsetOfDoctor(statusResidual, doctorResidual) {
449
+ const status = Array.isArray(statusResidual) ? statusResidual : [];
450
+ const doctor = new Set(Array.isArray(doctorResidual) ? doctorResidual : []);
451
+ for (const id of status) {
452
+ if (typeof id !== 'string' || id.length === 0)
453
+ continue;
454
+ if (!doctor.has(id))
455
+ return false;
456
+ }
457
+ return true;
314
458
  }
315
459
  function numberOrNull(value) {
316
460
  if (value == null)
@@ -414,17 +558,22 @@ export const ARK_STATUS_MANIFEST_SCHEMA = {
414
558
  },
415
559
  improvementCompass: {
416
560
  type: 'object',
417
- description: 'Optional thin improvement-compass residual ids (notAScore). Never a gate input; full lenses on doctor JSON.',
561
+ description: 'Thin improvement-compass residual ids with honesty mode (notAScore). full | subset | unavailable. Never a gate input; full lenses on doctor JSON. When full, residual ids ⊆ doctor residual for the same facts. unavailable never invents green residual.',
418
562
  additionalProperties: false,
419
- required: ['schemaVersion', 'notAScore', 'topResidual'],
563
+ required: ['schemaVersion', 'notAScore', 'mode', 'topResidual'],
420
564
  properties: {
421
565
  schemaVersion: { const: '1.0' },
422
566
  notAScore: { const: true },
567
+ mode: { enum: ['full', 'subset', 'unavailable'] },
423
568
  topResidual: {
424
569
  type: 'array',
425
570
  items: { type: 'string', minLength: 1 },
426
571
  maxItems: 15,
427
572
  },
573
+ reasonCode: { type: 'string', minLength: 1 },
574
+ reason: { type: 'string', minLength: 1 },
575
+ factsSource: { enum: ['doctor-facts', 'report-snapshot', 'none'] },
576
+ contractHash: { type: 'string', minLength: 1 },
428
577
  },
429
578
  },
430
579
  },