arkgate 2.5.0 → 2.6.1

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,510 @@
1
+ /**
2
+ * Coverage, plan, and doctor CLI surfaces (roadmap #11).
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {
7
+ arkCommand,
8
+ buildArchitectureRecommendation,
9
+ classifyRemediation,
10
+ layerForFile,
11
+ resolveOperatingMode,
12
+ shouldShowNewHereNudge,
13
+ } from '../ark-shared.mjs';
14
+ import {
15
+ collectAdoptionGaps,
16
+ detectSkillGaps,
17
+ missingGates,
18
+ staleRunnerGateFiles,
19
+ } from './agent-gates.mjs';
20
+ import {
21
+ baselineKey,
22
+ readBaseline,
23
+ summarizeViolations,
24
+ violationEdge,
25
+ } from './violations.mjs';
26
+ import { buildUnclassifiedSuggestions } from './suggestions.mjs';
27
+
28
+ const color = {
29
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
30
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
31
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
32
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
33
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
34
+ };
35
+
36
+ function normalize(value) {
37
+ return String(value).split(path.sep).join('/');
38
+ }
39
+
40
+
41
+
42
+ export function computeCoverage(root, config, files, rules) {
43
+ const layers = config.layers ?? [];
44
+ const counts = new Map(layers.map((layer) => [layer.name, 0]));
45
+ const unclassified = [];
46
+ for (const file of files) {
47
+ const layer = layerForFile(root, file, layers);
48
+ if (layer && counts.has(layer)) counts.set(layer, counts.get(layer) + 1);
49
+ else unclassified.push(normalize(path.relative(root, file)));
50
+ }
51
+ unclassified.sort();
52
+ const layerRows = layers.map((layer) => ({
53
+ name: layer.name,
54
+ patterns: layer.patterns ?? [],
55
+ files: counts.get(layer.name) ?? 0,
56
+ }));
57
+ // A layer whose patterns match zero files is dead config — it enforces nothing, usually a
58
+ // wrong glob (the #1 monorepo mistake). A layer with no rule edge can import anything.
59
+ const emptyLayers = layerRows.filter((row) => row.files === 0).map((row) => row.name);
60
+ const layersWithoutRules = layerRows
61
+ .map((row) => row.name)
62
+ .filter((name) => !rules.some((rule) => rule.from === name || rule.to === name));
63
+ const classifiedFiles = files.length - unclassified.length;
64
+ // Empty scope is NOT "100% governed" — that was a false-green for monorepos/mis-includes
65
+ // (0/0 → ENFORCE). Zero files means the contract is not checking anything yet.
66
+ const fraction = files.length > 0 ? classifiedFiles / files.length : 0;
67
+ return {
68
+ include: config.include ?? [],
69
+ totalFiles: files.length,
70
+ emptyScope: files.length === 0,
71
+ governed: { classifiedFiles, totalFiles: files.length, percent: Math.round(fraction * 100) },
72
+ layers: layerRows,
73
+ unclassified: { count: unclassified.length, files: unclassified },
74
+ suggestions: buildUnclassifiedSuggestions(unclassified),
75
+ emptyLayers,
76
+ layersWithoutRules,
77
+ };
78
+ }
79
+
80
+ export function runCoverage(root, config, files, rules, asJson) {
81
+ const cov = computeCoverage(root, config, files, rules);
82
+ if (asJson) {
83
+ console.log(JSON.stringify({ ok: true, coverage: cov }, null, 2));
84
+ return;
85
+ }
86
+ const { governed, layers: layerRows, suggestions, layersWithoutRules } = cov;
87
+ const classifiedFiles = governed.classifiedFiles;
88
+ const unclassified = cov.unclassified.files;
89
+
90
+ const nameWidth = Math.max(
91
+ 'Layer'.length,
92
+ '(unclassified)'.length,
93
+ ...layerRows.map((row) => row.name.length)
94
+ );
95
+ const pad = (value) => value.padEnd(nameWidth);
96
+ console.log(`Ark coverage (include: ${(config.include ?? []).join(', ') || '.'}):`);
97
+ console.log('');
98
+ console.log(` ${pad('Layer')} Files`);
99
+ for (const row of layerRows) {
100
+ const flag = row.files === 0 ? ' (pattern matches nothing)' : '';
101
+ console.log(` ${pad(row.name)} ${String(row.files).padStart(5)}${flag}`);
102
+ }
103
+ console.log(` ${pad('(unclassified)')} ${String(unclassified.length).padStart(5)}`);
104
+ console.log('');
105
+ console.log(
106
+ `${files.length} source file(s) in scope; ${unclassified.length} not matched by any layer.`
107
+ );
108
+ console.log(`Governed: ${governed.percent}% (${classifiedFiles}/${files.length} files).`);
109
+ if (files.length > 0 && governed.percent < 50) {
110
+ console.log('');
111
+ console.log(
112
+ `⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check here does NOT`
113
+ );
114
+ console.log(' mean the codebase is checked — the rest is ungoverned. Classify the directories');
115
+ console.log(' below to actually cover it.');
116
+ }
117
+ if (suggestions.length > 0) {
118
+ console.log('');
119
+ console.log('Ungoverned directories (proposed layer — from the 11-layer profile + presets):');
120
+ for (const s of suggestions) {
121
+ const count = `(${s.files})`.padStart(6);
122
+ if (s.unrecognized) {
123
+ console.log(` ${count} ${s.dir}/ — unrecognized, you classify`);
124
+ } else {
125
+ const alt = s.alternatives ? ` (or ${s.alternatives.join(' / ')})` : '';
126
+ console.log(` ${count} ${s.dir}/ → ${s.layer}${alt}`);
127
+ }
128
+ }
129
+ console.log('');
130
+ console.log('Apply these via /ark-contract (adds the layer patterns to ark.config.json).');
131
+ }
132
+ if (layersWithoutRules.length > 0) {
133
+ console.log('');
134
+ console.log(`Layers with no rule edge (can import anything): ${layersWithoutRules.join(', ')}`);
135
+ }
136
+ }
137
+
138
+ // --doctor: one consolidated health view — coverage, violations, gates, skills, baseline,
139
+ // and command runners — each with the exact command to fix it. Folds the data the other
140
+ // modes already produce so a team sees "what state is my Ark adoption in?" at a glance.
141
+ // Co-pilot Phase F — turn active violations into a classified, ordered remediation PLAN with an
142
+ // embedded GOAL. This is the `plan` primitive the future apply-loop (Phase H, `loop`) consumes
143
+ // and the autopilot (Phase I) drives toward the `goal`. Read-only: it changes no files.
144
+ export function buildRemediationPlan(root, activeViolations, governedPercent = null, totalFiles = null) {
145
+ // A plan with 0 violations but ~0% governed (or ZERO files in scope) is a FALSE green:
146
+ // nothing is actually being checked. Treat as "not done — classify / fix include first."
147
+ const governedLow = governedPercent != null && governedPercent < 50;
148
+ const emptyScope = totalFiles === 0;
149
+ const notHonestlyEnforced = governedLow || emptyScope;
150
+ const steps = activeViolations.map((v, index) => {
151
+ const verdict = classifyRemediation(v);
152
+ return {
153
+ id: `${v.ruleId}:${v.file}:${v.line ?? 0}:${index}`,
154
+ class: verdict.class,
155
+ confidence: verdict.confidence,
156
+ rationale: verdict.rationale,
157
+ ruleId: v.ruleId,
158
+ edge: violationEdge(v),
159
+ file: v.file,
160
+ ...(v.line ? { line: v.line } : {}),
161
+ ...(v.target ? { target: v.target } : {}),
162
+ ...(v.typeOnly ? { typeOnly: true } : {}),
163
+ ...(v.targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
164
+ ...(v.sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
165
+ ...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
166
+ };
167
+ });
168
+ // Order: auto-applicable first (quick, safe wins), then human decisions, then deferred.
169
+ const rank = { 'mechanical-safe': 0, judgment: 1, deferred: 2 };
170
+ steps.sort((a, b) => rank[a.class] - rank[b.class]);
171
+ const countOf = (cls) => steps.filter((s) => s.class === cls).length;
172
+ const counts = {
173
+ mechanicalSafe: countOf('mechanical-safe'),
174
+ judgment: countOf('judgment'),
175
+ deferred: countOf('deferred'),
176
+ };
177
+ return {
178
+ version: '1',
179
+ goal: {
180
+ statement:
181
+ activeViolations.length > 0
182
+ ? `Resolve ${activeViolations.length} architecture violation(s) without weakening the contract.`
183
+ : emptyScope
184
+ ? 'No source files matched the contract include paths — this "clean" result checks nothing. Fix include/layers (monorepo → apps/packages, or /ark-adopt) so Ark has real code to govern.'
185
+ : governedLow
186
+ ? `No violations — but Ark governs only ${governedPercent}% of your code, so this "clean" result checks almost nothing. Classify the rest (ark-check --coverage, then /ark-adopt) so it's actually enforced.`
187
+ : 'No active violations — the architecture already meets its contract.',
188
+ // The loop's termination signal (Phase H): nothing left to remediate AND the contract
189
+ // actually governs real code. Empty scope or low coverage is not "met".
190
+ met: activeViolations.length === 0 && !notHonestlyEnforced,
191
+ ...(governedPercent != null ? { governedPercent } : {}),
192
+ ...(totalFiles != null ? { totalFiles } : {}),
193
+ ...(emptyScope ? { emptyScope: true } : {}),
194
+ activeViolations: activeViolations.length,
195
+ autoApplicable: counts.mechanicalSafe,
196
+ needsDecision: counts.judgment,
197
+ deferred: counts.deferred,
198
+ },
199
+ counts,
200
+ steps,
201
+ };
202
+ }
203
+
204
+ // `--plan`: print the classified remediation plan. Dual-focus output — a one-line headline
205
+ // anyone can read, then the per-step detail a developer acts on. Read-only.
206
+ export function runPlan(root, activeViolations, asJson, governedPercent = null, totalFiles = null) {
207
+ const plan = buildRemediationPlan(root, activeViolations, governedPercent, totalFiles);
208
+ // Honesty: a zero-violation plan with almost nothing governed is NOT "ok".
209
+ const planOk = plan.goal.met === true;
210
+ if (asJson) {
211
+ console.log(JSON.stringify({ ok: planOk, plan }, null, 2));
212
+ return plan;
213
+ }
214
+ console.log(color.bold(`Ark plan — ${path.basename(path.resolve(root)) || '.'}`));
215
+ console.log('');
216
+ console.log(plan.goal.statement);
217
+ if (governedPercent != null) {
218
+ const pctLabel =
219
+ governedPercent < 50
220
+ ? color.yellow(`Governed: ${governedPercent}% of in-scope files`)
221
+ : color.dim(`Governed: ${governedPercent}% of in-scope files`);
222
+ console.log(pctLabel);
223
+ }
224
+ if (activeViolations.length === 0) return plan;
225
+ console.log('');
226
+ console.log(
227
+ ` ${color.green(`${plan.counts.mechanicalSafe} safe to auto-apply`)} · ` +
228
+ `${color.yellow(`${plan.counts.judgment} need your decision`)} · ` +
229
+ `${color.dim(`${plan.counts.deferred} deferred`)}`
230
+ );
231
+ console.log('');
232
+ const tag = {
233
+ 'mechanical-safe': color.green('auto '),
234
+ judgment: color.yellow('decide'),
235
+ deferred: color.dim('defer '),
236
+ };
237
+ for (const step of plan.steps) {
238
+ const where = `${step.file}${step.line ? `:${step.line}` : ''}`;
239
+ console.log(` [${tag[step.class]}] ${step.edge} ${color.dim(where)}`);
240
+ console.log(color.dim(` ${step.rationale}`));
241
+ }
242
+ console.log('');
243
+ console.log(
244
+ color.dim(
245
+ 'Plan only — no files changed. "auto" = an agent can safely apply it; "decide" = your call.'
246
+ )
247
+ );
248
+ return plan;
249
+ }
250
+
251
+ export function runDoctor(root, config, files, rules, violations, asJson, options = {}) {
252
+ const cov = computeCoverage(root, config, files, rules);
253
+ const summary = summarizeViolations(violations);
254
+ const configPath = options.configPath ?? path.join(root, 'ark.config.json');
255
+ const configMissing = options.configMissing ?? !fs.existsSync(configPath);
256
+ const showNewHere = shouldShowNewHereNudge(root, configPath, cov.governed.percent, configMissing);
257
+ let recommendation;
258
+ if (showNewHere) {
259
+ try {
260
+ recommendation = buildArchitectureRecommendation(root);
261
+ } catch {
262
+ recommendation = undefined;
263
+ }
264
+ }
265
+ const gatesMissing = missingGates(root);
266
+ const skillGaps = detectSkillGaps(root);
267
+ const staleRunners = staleRunnerGateFiles(root);
268
+ const adoption = collectAdoptionGaps(root, config, cov);
269
+ const baseline = readBaseline(root, '.ark-baseline.json');
270
+ const currentKeys = new Set(violations.map(baselineKey));
271
+ const suppressed = baseline.exists
272
+ ? violations.filter((v) => baseline.keys.has(baselineKey(v))).length
273
+ : 0;
274
+ const staleBaseline = baseline.exists
275
+ ? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
276
+ : 0;
277
+ const activeCount = violations.length - suppressed;
278
+ const missingSkills = skillGaps.reduce((sum, gap) => sum + gap.missing, 0);
279
+ const staleSkills = skillGaps.reduce((sum, gap) => sum + gap.stale, 0);
280
+
281
+ if (asJson) {
282
+ console.log(
283
+ JSON.stringify(
284
+ {
285
+ ok: true,
286
+ doctor: {
287
+ operatingMode: resolveOperatingMode({
288
+ governedPercent: cov.governed.percent,
289
+ planMet: activeCount === 0 && cov.governed.percent >= 50,
290
+ mature: cov.governed.totalFiles >= 150,
291
+ }),
292
+ governed: cov.governed,
293
+ emptyLayers: cov.emptyLayers,
294
+ layersWithoutRules: cov.layersWithoutRules,
295
+ ungovernedDirs: cov.suggestions.length,
296
+ violations: {
297
+ total: violations.length,
298
+ active: activeCount,
299
+ suppressed,
300
+ value: summary.valueCount,
301
+ typeOnly: summary.typeOnlyCount,
302
+ concentrated: summary.concentrated,
303
+ dominant: summary.dominant,
304
+ topEdges: summary.edges.slice(0, 5),
305
+ },
306
+ baseline: {
307
+ exists: baseline.exists,
308
+ frozen: baseline.exists ? baseline.keys.size : 0,
309
+ stale: staleBaseline,
310
+ policy: adoption.baseline,
311
+ },
312
+ gatesMissing,
313
+ skillGaps,
314
+ staleRunnerFiles: staleRunners,
315
+ adoption,
316
+ newHere: showNewHere
317
+ ? {
318
+ show: true,
319
+ archetype: recommendation?.archetype,
320
+ label: recommendation?.label,
321
+ preset: recommendation?.preset,
322
+ recommendCommand: arkCommand(root, 'ark-check', '--recommend'),
323
+ initCommand: recommendation?.archetype
324
+ ? arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)
325
+ : undefined,
326
+ }
327
+ : { show: false },
328
+ },
329
+ },
330
+ null,
331
+ 2
332
+ )
333
+ );
334
+ return;
335
+ }
336
+
337
+ const ok = color.green('✓');
338
+ const warn = color.yellow('!');
339
+ const bad = color.red('✗');
340
+ const actions = [];
341
+ const line = (mark, text) => console.log(` ${mark} ${text}`);
342
+
343
+ console.log(color.bold(`Ark doctor — ${path.basename(path.resolve(root)) || '.'}`));
344
+
345
+ const emptyScope = cov.governed.totalFiles === 0;
346
+ const mode = resolveOperatingMode({
347
+ governedPercent: emptyScope ? 0 : cov.governed.percent,
348
+ planMet:
349
+ activeCount === 0 && !emptyScope && cov.governed.percent >= 50,
350
+ mature: cov.governed.totalFiles >= 150,
351
+ });
352
+ console.log('');
353
+ console.log(color.bold('Operating mode'));
354
+ // Modes are detected states, not user-picked settings. Plain-language "what you do next".
355
+ const modeMark = mode === 'enforce' ? ok : mode === 'adapt' ? warn : warn;
356
+ const modeHelp = {
357
+ suggest:
358
+ 'Setup — Ark proposes a starting architecture shape. You do not pick this mode; it means the tree is thin or new. Next: accept the shape (ark start / ark init) and add real layers as you grow.',
359
+ adapt:
360
+ 'Align — contract and folders still disagree, or coverage is weak / debt is open. You do not pick this mode. Next: classify ungoverned dirs (/ark-contract, /ark-adopt), run the plan (/ark-autopilot or /ark-loop). Gates do not fully protect you yet.',
361
+ enforce:
362
+ 'Guard — contract governs enough real code and edges are clean enough for gates to protect you. You do not pick this mode; you arrived here. Next: keep CI/write gates on; only NEW violations should fail.',
363
+ };
364
+ line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
365
+ if (emptyScope) {
366
+ line(
367
+ bad,
368
+ 'Empty scope: include paths match 0 source files — a green check is meaningless until include/layers match the tree (monorepo → apps/packages, or /ark-adopt).'
369
+ );
370
+ }
371
+
372
+ console.log('');
373
+ console.log(color.bold('Coverage'));
374
+ const govMark =
375
+ emptyScope || cov.governed.percent < 50
376
+ ? bad
377
+ : cov.governed.percent >= 80
378
+ ? ok
379
+ : warn;
380
+ line(govMark, `Governed: ${cov.governed.percent}% (${cov.governed.classifiedFiles}/${cov.governed.totalFiles} files)`);
381
+ if (cov.suggestions.length > 0) {
382
+ line(warn, `${cov.suggestions.length} ungoverned director(y/ies) — proposals: ${arkCommand(root, 'ark-check', '--coverage')}`);
383
+ actions.push('classify the ungoverned directories (/ark-contract)');
384
+ }
385
+ if (cov.emptyLayers.length > 0) line(warn, `Empty layers (pattern matches nothing): ${cov.emptyLayers.join(', ')}`);
386
+ if (cov.layersWithoutRules.length > 0) line(warn, `Layers with no rule edge: ${cov.layersWithoutRules.join(', ')}`);
387
+ if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
388
+
389
+ if (showNewHere) {
390
+ console.log('');
391
+ console.log(color.bold('New here?'));
392
+ if (recommendation) {
393
+ line(warn, `Suggested application shape: ${recommendation.archetype} — ${recommendation.label} (preset ${recommendation.preset})`);
394
+ } else {
395
+ line(warn, 'Low governed coverage or fresh config — pick an application shape before adding code.');
396
+ }
397
+ line(ok, `See the plan: ${arkCommand(root, 'ark-check', '--recommend')}`);
398
+ if (recommendation?.archetype) {
399
+ line(ok, `Quick setup: ${arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)}`);
400
+ }
401
+ actions.unshift('run ark-check --recommend or /ark-architect to choose your application shape');
402
+ }
403
+
404
+ console.log('');
405
+ console.log(color.bold('Violations'));
406
+ if (violations.length === 0) {
407
+ // Avoid false confidence when the contract barely covers the tree.
408
+ if (emptyScope || cov.governed.percent < 50) {
409
+ line(
410
+ warn,
411
+ 'No active violations — coverage is still thin, so green is not yet honest enforcement'
412
+ );
413
+ } else {
414
+ line(ok, 'None — the code matches the contract');
415
+ }
416
+ } else {
417
+ const typeNote = summary.typeOnlyCount > 0 ? ` (${summary.valueCount} value · ${summary.typeOnlyCount} type-only)` : '';
418
+ const supNote = suppressed > 0 ? `, ${suppressed} frozen` : '';
419
+ line(
420
+ activeCount > 0 ? warn : ok,
421
+ `${violations.length} total${typeNote}${supNote}${activeCount > 0 ? ` — ${activeCount} NOT baselined` : ''}`
422
+ );
423
+ for (const edge of summary.edges.slice(0, 3)) line(' ', color.dim(`${edge.count} ${edge.edge}`));
424
+ if (summary.concentrated) {
425
+ line(warn, color.dim(`${Math.round(summary.dominantShare * 100)}% on one edge (${summary.dominant}) — likely a contract fix, not debt`));
426
+ }
427
+ if (activeCount > 0) {
428
+ actions.push(
429
+ `resolve the non-baselined violations — see the classified plan (${arkCommand(root, 'ark-check', '--plan')}), then /ark-fix`
430
+ );
431
+ }
432
+ }
433
+
434
+ console.log('');
435
+ console.log(color.bold('Gates & skills'));
436
+ if (gatesMissing.length === 0) line(ok, 'Gate files present (AGENTS.md, .mcp.json, CI, write gate)');
437
+ else {
438
+ line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
439
+ actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
440
+ }
441
+ if (missingSkills + staleSkills === 0) line(ok, '/ark-* skills current for detected tools');
442
+ else {
443
+ line(warn, `${missingSkills} missing / ${staleSkills} outdated /ark-* skill(s) for ${skillGaps.map((g) => g.tool).join(', ')}`);
444
+ actions.push('refresh /ark-* skills (--install-agent-gates --skills-only --force)');
445
+ }
446
+
447
+ console.log('');
448
+ console.log(color.bold('Baseline'));
449
+ if (!baseline.exists) {
450
+ line(violations.length > 0 ? warn : ok, violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline' : 'No baseline (nothing to freeze)');
451
+ } else {
452
+ // Baseline keys are line-agnostic, so N keys can suppress ≥N violations — label as keys
453
+ // to avoid an apparent mismatch with the "frozen" violation count above.
454
+ line(ok, `${baseline.keys.size} frozen key(s)`);
455
+ if (staleBaseline > 0) {
456
+ line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline`);
457
+ actions.push('tighten the baseline (--update-baseline)');
458
+ }
459
+ }
460
+
461
+ console.log('');
462
+ console.log(color.bold('Command runners'));
463
+ if (staleRunners.length === 0) line(ok, 'Emitted commands match the package manager');
464
+ else {
465
+ line(warn, `Stale runner in ${staleRunners.join(', ')}`);
466
+ actions.push(`migrate command runners (${arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands')})`);
467
+ }
468
+
469
+ // Adoption completeness (hosts, MCP health, codex home, core optionality, origin, baseline policy)
470
+ console.log('');
471
+ console.log(color.bold('Adoption (separate from fitness score)'));
472
+ if (adoption.gaps.length === 0 && !adoption.layerBalance) {
473
+ line(
474
+ ok,
475
+ 'Hosts, MCP argv, core optionality, origin report, baseline policy, and deploy-path lint/types look complete'
476
+ );
477
+ } else {
478
+ for (const gap of adoption.gaps) {
479
+ const mark = gap.severity === 'warn' ? warn : gap.severity === 'info' ? warn : bad;
480
+ line(mark, gap.message);
481
+ if (gap.fix) line(' ', color.dim(`Fix: ${gap.fix}`));
482
+ actions.push(gap.fix || gap.message);
483
+ }
484
+ if (adoption.layerBalance) {
485
+ line(warn, color.dim(adoption.layerBalance.educational));
486
+ }
487
+ }
488
+ if (adoption.baseline) {
489
+ line(
490
+ ' ',
491
+ color.dim(
492
+ `Baseline policy: ${adoption.baseline.signal}` +
493
+ (adoption.baseline.primaryPathUsesBaseline
494
+ ? ' · primary path uses --baseline'
495
+ : ' · primary path does not use --baseline')
496
+ )
497
+ );
498
+ }
499
+ if (adoption.originReport.present) {
500
+ line(ok, 'Origin architecture snapshot present (.ark/reports/origin.json)');
501
+ }
502
+
503
+ console.log('');
504
+ if (actions.length === 0) {
505
+ console.log(color.green('✔ Healthy — nothing to do.'));
506
+ } else {
507
+ console.log(color.bold(`Top actions (${actions.length}):`));
508
+ actions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
509
+ }
510
+ }