arkgate 4.3.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +81 -10
  2. package/README.md +11 -7
  3. package/bin/ark-check-runtime.mjs +50 -14
  4. package/bin/ark-layer-match.mjs +25 -12
  5. package/bin/lib/agent-projection.mjs +1 -1
  6. package/bin/lib/analysis-engine.mjs +5 -5
  7. package/bin/lib/ci-and-commands.mjs +11 -5
  8. package/bin/lib/doctor-plan.mjs +25 -0
  9. package/bin/lib/html-report-advisories.mjs +33 -0
  10. package/bin/lib/html-report-depth.mjs +24 -0
  11. package/bin/lib/html-report.mjs +8 -1
  12. package/bin/lib/improvement-compass-doctor.mjs +106 -0
  13. package/bin/lib/improvement-compass-map.mjs +507 -0
  14. package/bin/lib/improvement-compass-types.mjs +85 -0
  15. package/bin/lib/improvement-compass.mjs +79 -0
  16. package/bin/lib/managed-upgrade-honesty.mjs +201 -0
  17. package/bin/lib/managed-upgrade.mjs +37 -4
  18. package/bin/lib/status-command.mjs +127 -2
  19. package/bin/lib/status-manifest.mjs +187 -1
  20. package/dist/eslint/index.cjs +2 -2
  21. package/dist/eslint/index.js +2 -2
  22. package/dist/index.cjs +35 -35
  23. package/dist/index.d.ts +267 -5
  24. package/dist/index.js +35 -35
  25. package/docs/README.md +7 -6
  26. package/docs/agent-guide.md +50 -7
  27. package/docs/develop.md +9 -4
  28. package/docs/diagnostics.md +1 -1
  29. package/docs/package-surface.md +38 -34
  30. package/docs/product-voice.md +30 -1
  31. package/docs/use.md +67 -1
  32. package/package.json +1 -1
  33. package/schemas/ark.status-manifest.schema.json +53 -0
  34. package/server.json +2 -2
  35. package/templates/agent-skills/README.md +2 -2
  36. package/templates/agent-skills/ark-adopt/SKILL.md +20 -0
  37. package/templates/agent-skills/ark-architect/SKILL.md +21 -1
  38. package/templates/agent-skills/ark-autopilot/SKILL.md +25 -5
  39. package/templates/agent-skills/ark-contract/SKILL.md +20 -0
  40. package/templates/agent-skills/ark-coverage/SKILL.md +20 -0
  41. package/templates/agent-skills/ark-explain/SKILL.md +20 -0
  42. package/templates/agent-skills/ark-explore/SKILL.md +23 -3
  43. package/templates/agent-skills/ark-fix/SKILL.md +22 -2
  44. package/templates/agent-skills/ark-loop/SKILL.md +22 -2
  45. package/templates/agent-skills/ark-place/SKILL.md +20 -0
  46. package/templates/agent-skills/ark-runtime/SKILL.md +7 -0
  47. package/templates/agent-skills/ark-think/SKILL.md +20 -0
  48. package/templates/agent-skills/ark-upgrade/SKILL.md +20 -0
  49. package/templates/skills/ark-adopt.md +20 -0
  50. package/templates/skills/ark-architect.md +21 -1
  51. package/templates/skills/ark-autopilot.md +25 -5
  52. package/templates/skills/ark-contract.md +20 -0
  53. package/templates/skills/ark-coverage.md +20 -0
  54. package/templates/skills/ark-explain.md +20 -0
  55. package/templates/skills/ark-explore.md +23 -3
  56. package/templates/skills/ark-fix.md +22 -2
  57. package/templates/skills/ark-loop.md +22 -2
  58. package/templates/skills/ark-place.md +20 -0
  59. package/templates/skills/ark-runtime.md +7 -0
  60. package/templates/skills/ark-think.md +20 -0
  61. package/templates/skills/ark-upgrade.md +20 -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,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.
@@ -278,7 +297,7 @@ export function buildStatusManifest(facts) {
278
297
  ...(binding.code ? { code: binding.code } : {}),
279
298
  ...(binding.message ? { message: binding.message } : {}),
280
299
  };
281
- return {
300
+ const status = {
282
301
  schemaVersion: ARK_STATUS_MANIFEST_SCHEMA_VERSION,
283
302
  arkgateVersion: typeof facts.arkgateVersion === 'string' && facts.arkgateVersion.length > 0
284
303
  ? facts.arkgateVersion
@@ -289,6 +308,153 @@ export function buildStatusManifest(facts) {
289
308
  rules,
290
309
  nextAction: resolveStatusNextAction(facts, binding, activation, lastCheck, rules),
291
310
  };
311
+ const compass = normalizeStatusImprovementCompass(facts.improvementCompass);
312
+ if (compass)
313
+ status.improvementCompass = compass;
314
+ return status;
315
+ }
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) {
412
+ if (value == null || typeof value !== 'object')
413
+ return null;
414
+ const record = value;
415
+ if (record.notAScore !== true)
416
+ return null;
417
+ if (record.schemaVersion !== '1.0')
418
+ return null;
419
+ // Score-like fields never allowed on status compass.
420
+ if ('score' in record || 'valid' in record || 'goal' in record)
421
+ return null;
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;
292
458
  }
293
459
  function numberOrNull(value) {
294
460
  if (value == null)
@@ -390,5 +556,25 @@ export const ARK_STATUS_MANIFEST_SCHEMA = {
390
556
  summary: { type: 'string', minLength: 1 },
391
557
  },
392
558
  },
559
+ improvementCompass: {
560
+ type: 'object',
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.',
562
+ additionalProperties: false,
563
+ required: ['schemaVersion', 'notAScore', 'mode', 'topResidual'],
564
+ properties: {
565
+ schemaVersion: { const: '1.0' },
566
+ notAScore: { const: true },
567
+ mode: { enum: ['full', 'subset', 'unavailable'] },
568
+ topResidual: {
569
+ type: 'array',
570
+ items: { type: 'string', minLength: 1 },
571
+ maxItems: 15,
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 },
577
+ },
578
+ },
393
579
  },
394
580
  };