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,177 @@
1
+ /**
2
+ * Deep-module coach advisory (Tooling) — hot paths + deepening candidates.
3
+ *
4
+ * Advisory only (`notAScore`). Never feeds valid / strict-merge / goal.met /
5
+ * completeness green. Hot paths use a bounded git log heuristic; incomplete or
6
+ * missing history → status `unavailable`, empty paths (never invent).
7
+ * Deepening candidates come only from existing smells / cohesion / compass /
8
+ * pilot evidence via Domain pure projection.
9
+ */
10
+ import { spawnSync } from 'node:child_process';
11
+ import {
12
+ buildDeepeningCandidates,
13
+ ARK_DEEPENING_COACH_SCHEMA_VERSION,
14
+ DEEPENING_CANDIDATE_CAP,
15
+ } from './deepening-coach.mjs';
16
+
17
+ export { buildDeepeningCandidates, ARK_DEEPENING_COACH_SCHEMA_VERSION, DEEPENING_CANDIDATE_CAP };
18
+
19
+ /** Recent-window commit cap for hot-path heuristic (budget). */
20
+ export const HOT_PATH_COMMIT_LIMIT = 200;
21
+ /** Max listed hot paths. */
22
+ export const HOT_PATH_LIST_CAP = 8;
23
+ /** Minimum change hits before a path is “elevated”. */
24
+ export const HOT_PATH_MIN_HITS = 3;
25
+
26
+ /**
27
+ * Best-effort recent-churn paths from git history.
28
+ * @param {string} root
29
+ * @param {{ runGit?: Function, commitLimit?: number, minHits?: number, listCap?: number }} [opts]
30
+ */
31
+ export function computeHotPathAdvisory(root, opts = {}) {
32
+ const commitLimit = Number(opts.commitLimit) > 0 ? Number(opts.commitLimit) : HOT_PATH_COMMIT_LIMIT;
33
+ const minHits = Number(opts.minHits) > 0 ? Number(opts.minHits) : HOT_PATH_MIN_HITS;
34
+ const listCap = Number(opts.listCap) > 0 ? Number(opts.listCap) : HOT_PATH_LIST_CAP;
35
+ const runGit =
36
+ typeof opts.runGit === 'function'
37
+ ? opts.runGit
38
+ : (args) =>
39
+ spawnSync('git', ['-C', root, ...args], {
40
+ encoding: 'utf8',
41
+ maxBuffer: 8 * 1024 * 1024,
42
+ stdio: ['ignore', 'pipe', 'pipe'],
43
+ });
44
+
45
+ const emptyUnavailable = (reason) => ({
46
+ available: false,
47
+ status: 'unavailable',
48
+ reason,
49
+ paths: [],
50
+ notAScore: true,
51
+ });
52
+
53
+ try {
54
+ const head = runGit(['rev-parse', '--verify', 'HEAD']);
55
+ if (!head || head.status !== 0) {
56
+ return emptyUnavailable('git history incomplete or missing (no HEAD)');
57
+ }
58
+
59
+ const log = runGit([
60
+ 'log',
61
+ '-n',
62
+ String(commitLimit),
63
+ '--name-only',
64
+ '--pretty=format:',
65
+ '--diff-filter=AMR',
66
+ ]);
67
+ if (!log || log.status !== 0) {
68
+ return emptyUnavailable('git log unavailable for hot-path heuristic');
69
+ }
70
+
71
+ const counts = new Map();
72
+ const text = typeof log.stdout === 'string' ? log.stdout : '';
73
+ for (const line of text.split('\n')) {
74
+ const raw = line.trim().replace(/\\/g, '/');
75
+ if (!raw || raw.startsWith('.git/')) continue;
76
+ // Prefer product source; still allow other paths if they dominate.
77
+ counts.set(raw, (counts.get(raw) || 0) + 1);
78
+ }
79
+
80
+ if (counts.size === 0) {
81
+ return {
82
+ available: true,
83
+ status: 'ok',
84
+ reason: null,
85
+ paths: [],
86
+ notAScore: true,
87
+ window: { commitLimit, minHits },
88
+ };
89
+ }
90
+
91
+ const ranked = [...counts.entries()]
92
+ .filter(([, n]) => n >= minHits)
93
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
94
+ .slice(0, listCap)
95
+ .map(([path, changeCount]) => ({ path, changeCount }));
96
+
97
+ return {
98
+ available: true,
99
+ status: 'ok',
100
+ reason: null,
101
+ paths: ranked,
102
+ notAScore: true,
103
+ window: { commitLimit, minHits },
104
+ };
105
+ } catch {
106
+ return emptyUnavailable('git history incomplete or missing');
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Full deep-module coach advisory for doctor / report.
112
+ * @param {string} root
113
+ * @param {{
114
+ * designSmells?: object[],
115
+ * physicalCohesion?: object | null,
116
+ * improvementCompass?: object | null,
117
+ * pilotLoop?: object | null,
118
+ * runGit?: Function,
119
+ * }} [input]
120
+ */
121
+ export function buildDeepModuleCoachAdvisory(root, input = {}) {
122
+ const deepening = buildDeepeningCandidates({
123
+ designSmells: input.designSmells,
124
+ physicalCohesion: input.physicalCohesion,
125
+ improvementCompass: input.improvementCompass,
126
+ pilotLoop: input.pilotLoop,
127
+ });
128
+ const hotPaths = computeHotPathAdvisory(root, {
129
+ runGit: input.runGit,
130
+ });
131
+
132
+ return {
133
+ schemaVersion: ARK_DEEPENING_COACH_SCHEMA_VERSION,
134
+ notAScore: true,
135
+ hotPaths,
136
+ deepeningCandidates: deepening.candidates,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Human doctor section (never a score bar). Always one short block when coach is present.
142
+ * @param {ReturnType<typeof buildDeepModuleCoachAdvisory>} coach
143
+ * @param {{ line: Function, warn: string, ok: string, color: { bold: Function, dim: Function } }} io
144
+ */
145
+ export function printDeepModuleCoachSection(coach, io) {
146
+ if (!coach || coach.notAScore !== true) return;
147
+ const { line, warn, ok, color } = io;
148
+ const hot = coach.hotPaths || { status: 'unavailable', paths: [], reason: 'missing' };
149
+ const candidates = Array.isArray(coach.deepeningCandidates) ? coach.deepeningCandidates : [];
150
+ const paths = Array.isArray(hot.paths) ? hot.paths : [];
151
+
152
+ console.log('');
153
+ console.log(color.bold('Deep-module coach (advisory — not a score)'));
154
+
155
+ if (hot.status === 'unavailable') {
156
+ line(ok, color.dim(`Hot paths: unavailable — ${hot.reason || 'no git history'}; never invented.`));
157
+ } else if (paths.length > 0) {
158
+ line(warn, `Hot paths (recent churn heuristic, top ${paths.length}):`);
159
+ for (const row of paths.slice(0, HOT_PATH_LIST_CAP)) {
160
+ line(' ', color.dim(`${row.path} · ${row.changeCount} recent change(s)`));
161
+ }
162
+ } else {
163
+ line(ok, color.dim('Hot paths: none above churn threshold (advisory only).'));
164
+ }
165
+
166
+ if (candidates.length === 0) {
167
+ line(ok, color.dim('Deepening candidates: none from existing evidence.'));
168
+ } else {
169
+ line(warn, `Deepening candidates (${candidates.length}, from existing residual only):`);
170
+ for (const c of candidates.slice(0, DEEPENING_CANDIDATE_CAP)) {
171
+ line(' ', `${c.target} — ${c.friction}`);
172
+ if (c.intent) line(' ', color.dim(`intent: ${c.intent}`));
173
+ if (c.benefit) line(' ', color.dim(`benefit: ${c.benefit}`));
174
+ }
175
+ }
176
+ line(ok, color.dim('Never changes gate verdicts. Prefer deep modules; test at the public interface.'));
177
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/deepeningCoach.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/deepening-coach.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const ARK_DEEPENING_COACH_SCHEMA_VERSION = '1.0';
12
+ /** Cap on listed deepening candidates (agent-legible; not a ranking score). */
13
+ export const DEEPENING_CANDIDATE_CAP = 5;
14
+ function asString(value) {
15
+ return typeof value === 'string' ? value.trim() : '';
16
+ }
17
+ function firstEvidencePath(evidence) {
18
+ if (!Array.isArray(evidence))
19
+ return '';
20
+ for (const row of evidence) {
21
+ if (typeof row === 'string' && row.trim())
22
+ return row.trim().replace(/\\/g, '/');
23
+ if (row && typeof row === 'object') {
24
+ const path = asString(row.path)
25
+ || asString(row.file);
26
+ if (path)
27
+ return path.replace(/\\/g, '/');
28
+ }
29
+ }
30
+ return '';
31
+ }
32
+ function evidenceList(source, refs, detail) {
33
+ const out = [];
34
+ for (const ref of refs) {
35
+ const r = asString(ref);
36
+ if (!r)
37
+ continue;
38
+ out.push(detail ? { source, ref: r, detail } : { source, ref: r });
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * Project deepening candidates from existing doctor-side evidence only.
44
+ * Empty input / no residual → empty `candidates` (honesty: no fake list).
45
+ */
46
+ export function buildDeepeningCandidates(input = {}) {
47
+ const candidates = [];
48
+ const seen = new Set();
49
+ const push = (candidate) => {
50
+ if (candidates.length >= DEEPENING_CANDIDATE_CAP)
51
+ return;
52
+ const key = `${candidate.target}|${candidate.friction}`.toLowerCase();
53
+ if (seen.has(key))
54
+ return;
55
+ if (!asString(candidate.target) || !asString(candidate.friction))
56
+ return;
57
+ seen.add(key);
58
+ candidates.push(candidate);
59
+ };
60
+ const pilot = input.pilotLoop?.active === true && input.pilotLoop.nextPilot
61
+ ? input.pilotLoop.nextPilot
62
+ : null;
63
+ if (pilot) {
64
+ // Require a real pilot identity — empty shells are not evidence.
65
+ const target = asString(pilot.pilotTarget) || asString(pilot.pilot) || asString(pilot.smellId);
66
+ if (target) {
67
+ push({
68
+ target,
69
+ friction: asString(pilot.smellId)
70
+ ? `Shape pilot residual (${pilot.smellId}) — one extraction at a time`
71
+ : 'Shape pilot residual — one extraction at a time',
72
+ intent: asString(pilot.move) ||
73
+ 'Deepen the public seam; hide implementation behind a small interface',
74
+ benefit: asString(pilot.successSignal) ||
75
+ 'Locality: change and tests concentrate at the public interface',
76
+ evidence: evidenceList('pilotLoop', [target], 'nextPilot'),
77
+ });
78
+ }
79
+ }
80
+ const smells = Array.isArray(input.designSmells) ? input.designSmells : [];
81
+ for (const smell of smells) {
82
+ if (!smell || typeof smell !== 'object')
83
+ continue;
84
+ const id = asString(smell.id);
85
+ const path = firstEvidencePath(smell.evidence);
86
+ // Require non-empty smell id or evidence path — whitespace shells are not evidence.
87
+ if (!id && !path)
88
+ continue;
89
+ const target = path || id;
90
+ const friction = asString(smell.outcome) || asString(smell.message) || `Design residual (${id || target})`;
91
+ push({
92
+ target,
93
+ friction,
94
+ intent: asString(smell.fix) ||
95
+ 'Prefer a deep module at a named seam; apply the deletion test before pass-through extracts',
96
+ benefit: 'Leverage: callers learn a smaller interface; locality of change improves',
97
+ evidence: evidenceList('designSmells', path ? [path, id].filter(Boolean) : [id], id || undefined),
98
+ });
99
+ }
100
+ const pc = input.physicalCohesion;
101
+ const reshape = pc?.reshapePilot?.nextPilot;
102
+ if (reshape) {
103
+ const target = asString(reshape.pilotTarget) || asString(reshape.pilot);
104
+ if (target) {
105
+ push({
106
+ target,
107
+ friction: 'Physical cohesion residual — concept concentration across anchors',
108
+ intent: asString(reshape.move) ||
109
+ 'One reshape pilot toward locality; never mechanical-safe multi-file batch',
110
+ benefit: asString(reshape.successSignal) ||
111
+ 'Related behavior co-located; public seams stay small',
112
+ evidence: evidenceList('physicalCohesion', [target], 'reshapePilot'),
113
+ });
114
+ }
115
+ }
116
+ const findings = Array.isArray(pc?.findings) ? pc.findings : [];
117
+ for (const finding of findings) {
118
+ if (!finding || typeof finding !== 'object')
119
+ continue;
120
+ const concept = asString(finding.concept);
121
+ const anchors = Array.isArray(finding.anchors)
122
+ ? finding.anchors.map((a) => asString(a)).filter(Boolean)
123
+ : [];
124
+ const target = anchors[0] || concept;
125
+ if (!target)
126
+ continue;
127
+ push({
128
+ target,
129
+ friction: asString(finding.message) ||
130
+ (concept
131
+ ? `Concept "${concept}" concentrated across physical anchors`
132
+ : 'Physical cohesion residual'),
133
+ intent: 'Deepen by colocating behavior behind one public interface per concern',
134
+ benefit: 'Locality of change; fewer cross-anchor edits for one concept',
135
+ evidence: evidenceList('physicalCohesion', anchors.length > 0 ? anchors.slice(0, 3) : [target], concept || undefined),
136
+ });
137
+ }
138
+ const compass = input.improvementCompass;
139
+ if (compass && compass.notAScore === true) {
140
+ const residualIds = Array.isArray(compass.topResidual)
141
+ ? compass.topResidual.map((id) => asString(id)).filter(Boolean)
142
+ : [];
143
+ const lensById = new Map();
144
+ if (Array.isArray(compass.lenses)) {
145
+ for (const lens of compass.lenses) {
146
+ if (lens && typeof lens === 'object' && asString(lens.id)) {
147
+ lensById.set(asString(lens.id), { summary: asString(lens.summary) || undefined });
148
+ }
149
+ }
150
+ }
151
+ for (const id of residualIds) {
152
+ // Each residual lens id is independent evidence. Cap + target|friction dedupe may
153
+ // still list both a smell card and lens:<id> — no cross-source suppression.
154
+ const summary = lensById.get(id)?.summary;
155
+ push({
156
+ target: `lens:${id}`,
157
+ friction: summary
158
+ ? `Residual lens ${id}: ${summary}`
159
+ : `Residual architecture lens ${id} (not a score)`,
160
+ intent: 'Process judgment: deepen modules / name seams that clear this lens residual',
161
+ benefit: 'Clear residual without inventing a depth score or gate fail',
162
+ evidence: evidenceList('improvementCompass', [id], 'topResidual'),
163
+ });
164
+ }
165
+ }
166
+ return {
167
+ schemaVersion: ARK_DEEPENING_COACH_SCHEMA_VERSION,
168
+ notAScore: true,
169
+ candidates,
170
+ };
171
+ }
172
+ /**
173
+ * True when the pure projection would list zero candidates (honesty helper for tests).
174
+ */
175
+ export function hasNoDeepeningEvidence(input) {
176
+ return buildDeepeningCandidates(input).candidates.length === 0;
177
+ }
@@ -66,6 +66,10 @@ import {
66
66
  buildDoctorImprovementCompass,
67
67
  printImprovementCompassSection,
68
68
  } from './improvement-compass-doctor.mjs';
69
+ import {
70
+ buildDeepModuleCoachAdvisory,
71
+ printDeepModuleCoachSection,
72
+ } from './deep-module-coach.mjs';
69
73
 
70
74
  const color = {
71
75
  green: (s) => `\x1b[32m${s}\x1b[0m`,
@@ -660,6 +664,13 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
660
664
  goldenPatternPresent: goldenPattern.present === true,
661
665
  arkRulesLoaded: rulesUnderContract?.active === true,
662
666
  });
667
+ // Deep-module coach: hot paths + deepening candidates — advisory only (notAScore).
668
+ const deepModuleCoach = buildDeepModuleCoachAdvisory(root, {
669
+ designSmells,
670
+ physicalCohesion: doctorAdvisories.physicalCohesion,
671
+ improvementCompass,
672
+ pilotLoop,
673
+ });
663
674
 
664
675
  if (asJson) {
665
676
  (options.writeJson ?? console.log)(
@@ -677,6 +688,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
677
688
  designSmells,
678
689
  // Improvement compass (lenses; notAScore; never a gate input).
679
690
  improvementCompass,
691
+ // Deep-module coach (hot paths + deepening; notAScore; never a gate input).
692
+ deepModuleCoach,
680
693
  ...(options.designDelta ? { designDelta: options.designDelta } : {}),
681
694
  // Q01: primary next action when Shape residual dominates (null if not design-weak).
682
695
  postGreenPath,
@@ -870,6 +883,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
870
883
  }
871
884
 
872
885
  printImprovementCompassSection(improvementCompass, { line, warn, ok, color });
886
+ printDeepModuleCoachSection(deepModuleCoach, { line, warn, ok, color });
873
887
 
874
888
  console.log('');
875
889
  console.log(color.bold('Design fitness'));
@@ -279,11 +279,44 @@ function improvementCompassHtml(compass) {
279
279
  </section>`;
280
280
  }
281
281
 
282
+ /** Deep-module coach — hot paths + deepening candidates; never a gate input. */
283
+ function deepModuleCoachHtml(coach) {
284
+ if (!coach || coach.notAScore !== true) return '';
285
+ const hot = coach.hotPaths;
286
+ const candidates = Array.isArray(coach.deepeningCandidates) ? coach.deepeningCandidates : [];
287
+ let hotBlock = '';
288
+ if (hot?.status === 'unavailable') {
289
+ hotBlock = `<p class="muted">Hot paths: unavailable — ${esc(hot.reason || 'no git history')}; never invented.</p>`;
290
+ } else if (hot?.available === true && Array.isArray(hot.paths) && hot.paths.length > 0) {
291
+ hotBlock = `<p><span class="tag warn">hot paths</span> ${hot.paths
292
+ .slice(0, 8)
293
+ .map((p) => `<code>${esc(p.path)}</code> (${esc(String(p.changeCount))})`)
294
+ .join(' · ')}</p>`;
295
+ } else {
296
+ hotBlock = '<p class="muted">Hot paths: none above churn threshold (advisory).</p>';
297
+ }
298
+ const deepenBlock =
299
+ candidates.length === 0
300
+ ? '<p class="muted">Deepening candidates: none from existing evidence (not a score).</p>'
301
+ : `<p><span class="tag warn">deepening</span> ${candidates
302
+ .slice(0, 5)
303
+ .map((c) => `<code>${esc(c.target)}</code> — ${esc(c.friction)}`)
304
+ .join('<br/>')}</p>`;
305
+ return `
306
+ <section class="section card" data-advisory="deepModuleCoach">
307
+ <h2>Deep-module coach <span class="muted">(advisory — never changes the verdict)</span></h2>
308
+ ${hotBlock}
309
+ ${deepenBlock}
310
+ <p class="muted">Prefer deep modules; name seams; test at the public interface. Always <code>notAScore</code>.</p>
311
+ </section>`;
312
+ }
313
+
282
314
  export function renderAdvisorySections(advisories, escape) {
283
315
  if (!advisories || typeof advisories !== 'object') return '';
284
316
  if (typeof escape === 'function') esc = escape;
285
317
  return [
286
318
  improvementCompassHtml(advisories.improvementCompass),
319
+ deepModuleCoachHtml(advisories.deepModuleCoach),
287
320
  contractHealthHtml(advisories.contractHealth),
288
321
  ambientStateHtml(advisories.ambientState),
289
322
  physicalCohesionHtml(advisories.physicalCohesion),
@@ -22,6 +22,7 @@ import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
22
22
  import { readBaseline, baselineOccurrenceKeys } from './violations.mjs';
23
23
  import { describePackageVersionDualTruth } from './field-install.mjs';
24
24
  import { buildDoctorImprovementCompass } from './improvement-compass-doctor.mjs';
25
+ import { buildDeepModuleCoachAdvisory } from './deep-module-coach.mjs';
25
26
  import { computePhysicalCohesion } from './physical-cohesion.mjs';
26
27
 
27
28
  function esc(value) {
@@ -178,6 +179,13 @@ export function buildReportDepthPayload(
178
179
  goldenPatternPresent: goldenPattern.present === true,
179
180
  arkRulesLoaded: rulesUnderContract?.active === true,
180
181
  });
182
+ // Deep-module coach — same advisory as doctor; never a gate input.
183
+ const deepModuleCoach = buildDeepModuleCoachAdvisory(root, {
184
+ designSmells,
185
+ physicalCohesion,
186
+ improvementCompass,
187
+ pilotLoop,
188
+ });
181
189
  return {
182
190
  adoption,
183
191
  designDepth: {
@@ -190,6 +198,7 @@ export function buildReportDepthPayload(
190
198
  productHonesty,
191
199
  mergePlanes: rulesUnderContract?.mergePlanes ?? null,
192
200
  improvementCompass,
201
+ deepModuleCoach,
193
202
  },
194
203
  };
195
204
  }
@@ -143,6 +143,8 @@ export function buildReportSnapshot({
143
143
  enforcement,
144
144
  score,
145
145
  mode,
146
+ /** DF02 — thin status compass slice (mode + residual ids, notAScore). */
147
+ improvementCompass = null,
146
148
  }) {
147
149
  const layers = Array.isArray(config?.layers) ? config.layers : [];
148
150
  const rules = Array.isArray(config?.rules) ? config.rules : [];
@@ -151,7 +153,7 @@ export function buildReportSnapshot({
151
153
  for (const [name, n] of fileCountByLayer) counts[name] = n;
152
154
  }
153
155
  const gatesOn = (enforcement || []).filter((e) => e.on).length;
154
- return {
156
+ const snapshot = {
155
157
  version: 1,
156
158
  kind: 'ark-architecture-snapshot',
157
159
  generatedAt: new Date().toISOString(),
@@ -186,6 +188,11 @@ export function buildReportSnapshot({
186
188
  gatesTotal: (enforcement || []).length,
187
189
  layerFiles: counts,
188
190
  };
191
+ // DF02 — store thin status compass so `ark status` can project residual honestly.
192
+ if (improvementCompass && typeof improvementCompass === 'object') {
193
+ snapshot.improvementCompass = improvementCompass;
194
+ }
195
+ return snapshot;
189
196
  }
190
197
 
191
198
  export function readJsonSafe(file) {