arkgate 4.0.0 → 4.1.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 (57) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +244 -25
  4. package/bin/ark-check.mjs +10 -1
  5. package/bin/ark-layer-match.mjs +80 -5
  6. package/bin/ark-shared.mjs +170 -9
  7. package/bin/ark.mjs +52 -5
  8. package/bin/lib/adapter-contract.mjs +7 -1
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/arkrules-sensors.mjs +63 -22
  12. package/bin/lib/ci-and-commands.mjs +148 -8
  13. package/bin/lib/core-ratchet.mjs +9 -4
  14. package/bin/lib/doctor-advisories.mjs +8 -1
  15. package/bin/lib/doctor-plan.mjs +277 -59
  16. package/bin/lib/enforcement-honesty.mjs +351 -26
  17. package/bin/lib/enforcement-state.mjs +1 -1
  18. package/bin/lib/field-install.mjs +35 -2
  19. package/bin/lib/graph-blind.mjs +1 -1
  20. package/bin/lib/html-report-advisories.mjs +8 -25
  21. package/bin/lib/html-report-depth.mjs +167 -3
  22. package/bin/lib/html-report.mjs +12 -5
  23. package/bin/lib/install-migrate.mjs +109 -6
  24. package/bin/lib/managed-upgrade.mjs +100 -1
  25. package/bin/lib/presets.mjs +314 -46
  26. package/bin/lib/project-root.mjs +268 -0
  27. package/bin/lib/remediation.mjs +12 -11
  28. package/bin/lib/rules-inventory.mjs +71 -29
  29. package/bin/lib/rules-under-contract.mjs +389 -5
  30. package/bin/lib/start-preview.mjs +48 -14
  31. package/bin/lib/suggestions.mjs +118 -3
  32. package/bin/lib/unavailable-analysis.mjs +2 -0
  33. package/bin/lib/upgrade-command.mjs +325 -14
  34. package/bin/lib/write-path-capabilities.mjs +38 -9
  35. package/dist/eslint/index.cjs +2 -2
  36. package/dist/eslint/index.d.ts +27 -2
  37. package/dist/eslint/index.js +2 -2
  38. package/dist/index.cjs +16 -14
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +16 -14
  41. package/docs/README.md +3 -2
  42. package/docs/ai-gates.md +15 -11
  43. package/docs/brownfield-adoption.md +38 -0
  44. package/docs/configuration.md +59 -7
  45. package/docs/package-surface.md +3 -2
  46. package/docs/product-voice.md +10 -1
  47. package/docs/typescript-support.md +9 -5
  48. package/docs/use.md +7 -5
  49. package/package.json +3 -1
  50. package/server.json +3 -3
  51. package/templates/architecture-playbook.json +3 -0
  52. package/templates/layers/shared-types.starter.json +29 -0
  53. package/templates/skills/ark-adopt.md +2 -0
  54. package/templates/skills/ark-explain.md +23 -5
  55. package/templates/skills/ark-explore.md +21 -1
  56. package/templates/skills/ark-fix.md +16 -5
  57. package/templates/skills/ark-upgrade.md +57 -11
@@ -9,9 +9,16 @@ import {
9
9
  resolveOperatingMode,
10
10
  shouldShowNewHereNudge,
11
11
  } from '../ark-shared.mjs';
12
+ import * as arkShared from '../ark-shared.mjs';
12
13
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
13
14
  import { describePackageVersionDualTruth } from './field-install.mjs';
14
15
  export { summarizeRulesUnderContract };
16
+
17
+ /** Optional S3 dual-match classifier when ark-shared exports it (soft dep for S5 landing). */
18
+ const matchingLayersForRelativePath =
19
+ typeof arkShared.matchingLayersForRelativePath === 'function'
20
+ ? arkShared.matchingLayersForRelativePath
21
+ : null;
15
22
  import {
16
23
  collectAdoptionGaps,
17
24
  detectSkillGaps,
@@ -70,12 +77,33 @@ export function computeCoverage(root, config, files, rules) {
70
77
  const layers = config.layers ?? [];
71
78
  const counts = new Map(layers.map((layer) => [layer.name, 0]));
72
79
  const unclassified = [];
80
+ const dualMembership = [];
73
81
  for (const file of files) {
82
+ const rel = normalize(path.relative(root, file));
74
83
  const layer = layerForFile(root, file, layers);
75
84
  if (layer && counts.has(layer)) counts.set(layer, counts.get(layer) + 1);
76
- else unclassified.push(normalize(path.relative(root, file)));
85
+ else unclassified.push(rel);
86
+
87
+ // P0A-DUAL-MATCH: surface files that match 2+ layers (winner still single-valued).
88
+ // Soft: classifier is S3; S5 landing must not hard-require it.
89
+ if (matchingLayersForRelativePath) {
90
+ try {
91
+ const hits = matchingLayersForRelativePath(rel, layers);
92
+ if (hits.length > 1) {
93
+ dualMembership.push({
94
+ file: rel,
95
+ layers: hits.map((h) => h.layer),
96
+ winner: layer ?? hits[0]?.layer ?? null,
97
+ scores: Object.fromEntries(hits.map((h) => [h.layer, h.score])),
98
+ });
99
+ }
100
+ } catch {
101
+ /* pure classifier only — ignore if unavailable */
102
+ }
103
+ }
77
104
  }
78
105
  unclassified.sort();
106
+ dualMembership.sort((a, b) => a.file.localeCompare(b.file));
79
107
  const layerRows = layers.map((layer) => ({
80
108
  name: layer.name,
81
109
  patterns: layer.patterns ?? [],
@@ -101,6 +129,15 @@ export function computeCoverage(root, config, files, rules) {
101
129
  suggestions: buildUnclassifiedSuggestions(unclassified),
102
130
  emptyLayers,
103
131
  layersWithoutRules,
132
+ dualMembership: {
133
+ count: dualMembership.length,
134
+ // Cap samples so doctor/JSON stay bounded on large trees.
135
+ samples: dualMembership.slice(0, 25),
136
+ note:
137
+ dualMembership.length > 0
138
+ ? `${dualMembership.length} file(s) match multiple layers; classification uses highest path-anchored specificity (winner listed). Review overlapping globs if the winner looks wrong.`
139
+ : null,
140
+ },
104
141
  };
105
142
  }
106
143
 
@@ -162,6 +199,20 @@ export function runCoverage(root, config, files, rules, asJson) {
162
199
  console.log('');
163
200
  console.log(`Layers with no rule edge (can import anything): ${layersWithoutRules.join(', ')}`);
164
201
  }
202
+ if (cov.dualMembership?.count > 0) {
203
+ console.log('');
204
+ console.log(
205
+ `Dual-match: ${cov.dualMembership.count} file(s) match multiple layers (winner by path-anchored specificity).`
206
+ );
207
+ for (const sample of (cov.dualMembership.samples ?? []).slice(0, 5)) {
208
+ console.log(
209
+ ` ${sample.file} → ${sample.winner} (also: ${sample.layers.filter((l) => l !== sample.winner).join(', ')})`
210
+ );
211
+ }
212
+ if (cov.dualMembership.count > 5) {
213
+ console.log(` … +${cov.dualMembership.count - 5} more (see --coverage --json dualMembership)`);
214
+ }
215
+ }
165
216
  }
166
217
 
167
218
  // --doctor: one consolidated health view — coverage, violations, gates, skills, baseline,
@@ -210,9 +261,15 @@ export function buildRemediationPlan(
210
261
  ...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
211
262
  };
212
263
  });
213
- // Order: auto-applicable first (quick, safe wins), then human decisions, then deferred.
264
+ // Order: value edges first (runtime coupling), then type-only placement debt as a group,
265
+ // and within each bucket: mechanical-safe → judgment → deferred (NEW-TYPEONLY-VOLUME).
214
266
  const rank = { 'mechanical-safe': 0, judgment: 1, deferred: 2 };
215
- steps.sort((a, b) => rank[a.class] - rank[b.class]);
267
+ steps.sort((a, b) => {
268
+ const aType = a.typeOnly || a.namedBindingsTypeOnly ? 1 : 0;
269
+ const bType = b.typeOnly || b.namedBindingsTypeOnly ? 1 : 0;
270
+ if (aType !== bType) return aType - bType;
271
+ return rank[a.class] - rank[b.class];
272
+ });
216
273
  const countOf = (cls) => steps.filter((s) => s.class === cls).length;
217
274
  const counts = {
218
275
  mechanicalSafe: countOf('mechanical-safe'),
@@ -233,9 +290,17 @@ export function buildRemediationPlan(
233
290
  designSmells = designSmells ?? [];
234
291
  const patternBets =
235
292
  options.patternBets ?? buildPatternBetsFromSmells(designSmells);
236
- const edgesMet = activeViolations.length === 0 && !notHonestlyEnforced;
293
+ // P1-type: goal.met uses blocking (value) edges only; type-only placement debt still listed.
294
+ const blockingCount =
295
+ typeof options.blockingViolationCount === 'number'
296
+ ? options.blockingViolationCount
297
+ : activeViolations.filter((v) => v.failsStrict !== false).length;
298
+ const typeOnlyCount = activeViolations.filter(
299
+ (v) => v.typeOnly || v.namedBindingsTypeOnly
300
+ ).length;
301
+ const edgesMet = blockingCount === 0 && !notHonestlyEnforced;
237
302
  const designWeak = isDesignWeak(designSmells, {
238
- activeViolations: activeViolations.length,
303
+ activeViolations: blockingCount,
239
304
  governedPercent,
240
305
  totalFiles,
241
306
  });
@@ -252,19 +317,38 @@ export function buildRemediationPlan(
252
317
  });
253
318
 
254
319
  let statement =
255
- activeViolations.length > 0
256
- ? `Resolve ${activeViolations.length} architecture violation(s) without weakening the contract.`
257
- : emptyScope
258
- ? '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.'
259
- : governedLow
260
- ? `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.`
261
- : 'No active violationsthe architecture already meets its contract.';
320
+ blockingCount > 0
321
+ ? `Resolve ${blockingCount} architecture violation(s) without weakening the contract.` +
322
+ (typeOnlyCount > 0
323
+ ? ` (${typeOnlyCount} type-only placement debt also reportedSharedTypes / owning layer.)`
324
+ : '')
325
+ : typeOnlyCount > 0
326
+ ? `No blocking value edges ${typeOnlyCount} type-only placement debt remain (prefer SharedTypes / owning layer; not runtime coupling).`
327
+ : emptyScope
328
+ ? '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.'
329
+ : governedLow
330
+ ? `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.`
331
+ : 'No active violations — the architecture already meets its contract.';
262
332
  if (designWeak) {
263
333
  statement =
264
334
  'No active edge violations — contract edges are clean, but design smells remain (ENFORCE · design-weak). Shape residual is plan B only; not healthy finished.';
265
335
  }
266
336
  if (completeness !== ANALYSIS_COMPLETENESS.complete) statement = analysisIncompleteStatement(completeness);
267
337
 
338
+ // NEW-TYPEONLY-VOLUME: group type-only steps for plan messaging (value first).
339
+ const typeOnlySteps = steps.filter((s) => s.typeOnly || s.namedBindingsTypeOnly);
340
+ const valueSteps = steps.filter((s) => !(s.typeOnly || s.namedBindingsTypeOnly));
341
+ const typeOnlyGroup =
342
+ typeOnlySteps.length > 0
343
+ ? {
344
+ count: typeOnlySteps.length,
345
+ valueCount: valueSteps.length,
346
+ guidance:
347
+ 'Type-only placement debt is non-blocking. Prefer a SharedTypes (or owning) layer both sides may import; fix value runtime coupling first. See templates/layers/shared-types.starter.json.',
348
+ stepIds: typeOnlySteps.map((s) => s.id).slice(0, 40),
349
+ }
350
+ : null;
351
+
268
352
  return {
269
353
  version: '1',
270
354
  completeness,
@@ -289,9 +373,13 @@ export function buildRemediationPlan(
289
373
  needsDecision: counts.judgment,
290
374
  deferred: counts.deferred,
291
375
  patternBetCount: patternBets.length,
376
+ ...(typeOnlyCount > 0
377
+ ? { typeOnlyPlacementDebt: typeOnlyCount, typeOnlyNonBlocking: true }
378
+ : {}),
292
379
  },
293
380
  counts,
294
381
  steps,
382
+ ...(typeOnlyGroup ? { typeOnlyGroup } : {}),
295
383
  // Additive: pattern evolution bets derived from design smells (never auto).
296
384
  patternBets,
297
385
  designSmells,
@@ -375,13 +463,26 @@ export function runPlan(
375
463
  `${color.yellow(`${plan.counts.judgment} need your decision`)} · ` +
376
464
  `${color.dim(`${plan.counts.deferred} deferred`)}`
377
465
  );
466
+ if (plan.typeOnlyGroup?.count) {
467
+ console.log('');
468
+ console.log(
469
+ color.dim(
470
+ `Type-only group: ${plan.typeOnlyGroup.count} placement-debt step(s) after ${plan.typeOnlyGroup.valueCount} value step(s) — non-blocking; SharedTypes starter optional.`
471
+ )
472
+ );
473
+ }
378
474
  console.log('');
379
475
  const tag = {
380
476
  'mechanical-safe': color.green('auto '),
381
477
  judgment: color.yellow('decide'),
382
478
  deferred: color.dim('defer '),
383
479
  };
480
+ let typeOnlyHeaderPrinted = false;
384
481
  for (const step of plan.steps) {
482
+ if ((step.typeOnly || step.namedBindingsTypeOnly) && !typeOnlyHeaderPrinted) {
483
+ console.log(color.dim(' --- type-only placement debt (non-blocking) ---'));
484
+ typeOnlyHeaderPrinted = true;
485
+ }
385
486
  const where = `${step.file}${step.line ? `:${step.line}` : ''}`;
386
487
  console.log(` [${tag[step.class]}] ${step.edge} ${color.dim(where)}`);
387
488
  console.log(color.dim(` ${step.rationale}`));
@@ -429,9 +530,15 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
429
530
  ? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
430
531
  : 0;
431
532
  const activeCount = violations.length - suppressed;
533
+ // productHonesty: blocking = failsStrict !== false only (type-only placement debt excluded).
534
+ const blockingActive = violations.filter((v, index) => {
535
+ if (v.failsStrict === false) return false;
536
+ if (!baseline.exists) return true;
537
+ return !baseline.keys.has(occurrenceKeys[index]);
538
+ }).length;
432
539
  const designSmells = detectDesignSmells(root, config, files, cov);
433
540
  const observedDesignFitness = summarizeDesignFitness(designSmells, {
434
- activeViolations: activeCount,
541
+ activeViolations: blockingActive,
435
542
  governedPercent: cov.governed.percent,
436
543
  totalFiles: cov.governed.totalFiles,
437
544
  });
@@ -453,18 +560,84 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
453
560
  designSmells,
454
561
  });
455
562
  const doctorAdvisories = computeDoctorAdvisories(root, config, cov, rules, files, options.ts, options.parseHealth);
456
- const { coverageHonesty, baselineHonesty, writePathHonesty } = computeDoctorEnforcementHonesty({
457
- governedPercent: cov.governed.percent,
563
+ // AR12 compute once for JSON + product honesty + human lines.
564
+ // P1M: classification gate on extraMergeTeeth (no teeth at empty graph).
565
+ const rulesUnderContract = summarizeRulesUnderContract(
566
+ root,
567
+ config,
568
+ options.facts ?? options.architectureFacts,
569
+ {
570
+ governedPercent: cov.governed?.percent ?? null,
571
+ populatedLayerCount: Array.isArray(cov.layers)
572
+ ? cov.layers.filter((row) => (row?.files ?? 0) > 0).length
573
+ : null,
574
+ classifiedFiles: cov.governed?.classifiedFiles ?? null,
575
+ }
576
+ );
577
+ // Single residual expression (nextPilot || extractionCard) — HTML report uses the same.
578
+ const residualPilot = pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
579
+ const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
580
+ const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
581
+ const totalFilesEarly = cov.governed.totalFiles || 0;
582
+ const operatingMode = resolveOperatingMode({
583
+ governedPercent: emptyScopeEarly ? 0 : cov.governed.percent,
584
+ // planMet uses blocking (failsStrict !== false) only — type-only placement debt alone
585
+ // must not force adapt via unmet plan (parity with merge/exit and productHonesty).
586
+ planMet:
587
+ analysisComplete &&
588
+ blockingActive === 0 &&
589
+ !emptyScopeEarly &&
590
+ cov.governed.percent >= 50,
591
+ mature: cov.governed.totalFiles >= 150,
458
592
  totalFiles: cov.governed.totalFiles,
459
- emptyScope: cov.emptyScope === true || cov.governed.totalFiles === 0,
460
- baselineExists: baseline.exists,
461
- frozenKeys: baseline.exists ? baseline.keys.size : 0,
462
- activeViolations: activeCount,
463
- suppressed,
464
- totalViolations: violations.length,
465
- activeHost: writePath.activeHost,
466
- hardWriteActive: writePath.capabilities?.['hard-write'] === true,
593
+ emptyLayers: cov.emptyLayers,
594
+ coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
595
+ presentationShare:
596
+ totalFilesEarly > 0 && presentationRowEarly
597
+ ? presentationRowEarly.files / totalFilesEarly
598
+ : null,
467
599
  });
600
+ // Evidence-backed hard only (never capabilities-from-hook-files alone).
601
+ const hardWriteActive = writePath.enforcementState?.localWrite?.hard === true;
602
+ const packageInstalled = writePath.enforcementState?.localWrite?.installed === true;
603
+ const dualTruthNext =
604
+ packageVersionTruth?.dualTruth === true
605
+ ? `Bump package.json arkgate pin to ${packageVersionTruth.cliVersion || 'this CLI'} (or re-run install without --no-install)`
606
+ : packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT'
607
+ ? 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
608
+ : null;
609
+ const { coverageHonesty, baselineHonesty, writePathHonesty, productHonesty } =
610
+ computeDoctorEnforcementHonesty({
611
+ governedPercent: cov.governed.percent,
612
+ totalFiles: cov.governed.totalFiles,
613
+ emptyScope: emptyScopeEarly,
614
+ baselineExists: baseline.exists,
615
+ frozenKeys: baseline.exists ? baseline.keys.size : 0,
616
+ activeViolations: activeCount,
617
+ activeBlockingViolations: blockingActive,
618
+ suppressed,
619
+ totalViolations: violations.length,
620
+ activeHost: writePath.activeHost,
621
+ hardWriteActive,
622
+ designWeak: designFitness.designWeak === true,
623
+ designWeakLabel: designFitness.label,
624
+ designSmellCount: designSmells.length,
625
+ designSmellsWithOpenEdges: designSmells.length > 0 && blockingActive > 0,
626
+ packageVersionTruth,
627
+ residualPilots: Boolean(residualPilot) && designFitness.designWeak === true,
628
+ pilotTarget: residualPilot?.pilotTarget ?? residualPilot?.pilot ?? null,
629
+ arkRulesMergeHonesty: rulesUnderContract?.mergePlanes
630
+ ? { active: rulesUnderContract.active === true, ...rulesUnderContract.mergePlanes }
631
+ : rulesUnderContract?.active === true
632
+ ? { active: true, extraMergeTeeth: false }
633
+ : null,
634
+ primaryNextAction: postGreenPath?.action ?? dualTruthNext,
635
+ operatingMode,
636
+ packageInstalled,
637
+ selfHost:
638
+ packageVersionTruth?.selfHost === true ||
639
+ packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
640
+ });
468
641
 
469
642
  if (asJson) {
470
643
  (options.writeJson ?? console.log)(
@@ -473,20 +646,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
473
646
  ok: analysisComplete && (options.designDelta?.valid ?? true),
474
647
  doctor: {
475
648
  completeness,
476
- operatingMode: resolveOperatingMode({
477
- governedPercent: cov.governed.percent,
478
- planMet: analysisComplete && activeCount === 0 && cov.governed.percent >= 50,
479
- mature: cov.governed.totalFiles >= 150,
480
- totalFiles: cov.governed.totalFiles,
481
- emptyLayers: cov.emptyLayers,
482
- coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
483
- presentationShare: (() => {
484
- const total = cov.governed.totalFiles || 0;
485
- if (total <= 0) return null;
486
- const p = cov.layers.find((r) => r.name === 'PresentationAdapters');
487
- return p ? p.files / total : null;
488
- })(),
489
- }),
649
+ operatingMode,
650
+ // Monorepo walk-up (NEW-MONOREPO-CWD-WALKUP): where ark.config.json was resolved.
651
+ configRoot: options.configRoot ?? root,
652
+ ...(options.configWalkedUp ? { configWalkedUp: true } : {}),
490
653
  // Path-correct ENFORCE can still be design-weak (P02).
491
654
  designFitness,
492
655
  designSmells,
@@ -495,24 +658,24 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
495
658
  postGreenPath,
496
659
  ...(postGreenPath
497
660
  ? { primaryNextAction: postGreenPath.action, ...DESIGN_WEAK_HONESTY_FLAGS }
498
- : {}),
661
+ : productHonesty.primaryNextAction
662
+ ? { primaryNextAction: productHonesty.primaryNextAction }
663
+ : {}),
499
664
  // Q03: advisory golden for new-code placement (absent = no claim).
500
665
  goldenPattern,
501
666
  // Y06: advisory pure-layer opt-in (null when not applicable).
502
667
  pureLayerOptIn,
503
668
  // Q04: one-pilot loop (extraction card → re-doctor).
504
669
  pilotLoop,
505
- // AR12 — Rules under contract (honest counts, not a score).
506
- // Pass architecture facts when available so coverage can scan real tests.
507
- rulesUnderContract: summarizeRulesUnderContract(
508
- root,
509
- config,
510
- options.facts ?? options.architectureFacts
511
- ),
512
670
  // Dual-truth: managed CLI vs package.json pin (not a gate fail).
513
671
  packageVersionTruth,
514
672
  // Advisories, never a verdict: W01/U05/X04/Y03 + graph-blind spots.
673
+ // (rulesUnderContract is also in advisories; re-assert after spread so mergePlanes wins.)
515
674
  ...doctorAdvisories,
675
+ // AR12 + P1-M mergePlanes (authoritative; after advisories spread).
676
+ rulesUnderContract,
677
+ // P0-B — single anti-false-green honesty surface (never a score).
678
+ productHonesty,
516
679
  governed: cov.governed,
517
680
  coverageHonesty,
518
681
  emptyLayers: cov.emptyLayers,
@@ -524,6 +687,28 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
524
687
  suppressed,
525
688
  value: summary.valueCount,
526
689
  typeOnly: summary.typeOnlyCount,
690
+ // DL-TYPEEDGE-POLICY-FIELD / P1-type: always emit when type-only findings
691
+ // exist or the type-edge policy surface is active (default: always on).
692
+ typeEdgePolicy: {
693
+ active: true,
694
+ valueBlocksMerge: true,
695
+ typeOnlyIsPlacementDebt: true,
696
+ typeOnlyCount: summary.typeOnlyCount,
697
+ ...(summary.typeOnlyCount > 0
698
+ ? {
699
+ volume:
700
+ summary.typeOnlyCount >= 20
701
+ ? 'high'
702
+ : summary.typeOnlyCount >= 5
703
+ ? 'moderate'
704
+ : 'low',
705
+ starter:
706
+ 'templates/layers/shared-types.starter.json — optional SharedTypes layer both sides may import.',
707
+ }
708
+ : {}),
709
+ guidance:
710
+ 'Type-only edges (`import type` / pure type modules) are placement debt — fix value runtime coupling first; place shared types in a SharedTypes / owning layer.',
711
+ },
527
712
  concentrated: summary.concentrated,
528
713
  dominant: summary.dominant,
529
714
  topEdges: summary.edges.slice(0, 5),
@@ -601,19 +786,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
601
786
  console.log(color.bold(`Ark doctor — ${path.basename(path.resolve(root)) || '.'}`));
602
787
  if (!analysisComplete) line(warn, analysisIncompleteStatement(completeness));
603
788
 
604
- const emptyScope = cov.governed.totalFiles === 0;
605
- const totalFiles = cov.governed.totalFiles || 0;
606
- const presentationRow = cov.layers.find((r) => r.name === 'PresentationAdapters');
607
- const mode = resolveOperatingMode({
608
- governedPercent: emptyScope ? 0 : cov.governed.percent,
609
- planMet: analysisComplete && activeCount === 0 && !emptyScope && cov.governed.percent >= 50,
610
- mature: cov.governed.totalFiles >= 150,
611
- totalFiles: cov.governed.totalFiles,
612
- emptyLayers: cov.emptyLayers,
613
- coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
614
- presentationShare:
615
- totalFiles > 0 && presentationRow ? presentationRow.files / totalFiles : null,
616
- });
789
+ const emptyScope = emptyScopeEarly;
790
+ const mode = operatingMode;
617
791
  console.log('');
618
792
  console.log(color.bold('Operating mode'));
619
793
  // Modes are detected states, not user-picked settings. Plain-language "what you do next".
@@ -651,6 +825,27 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
651
825
  );
652
826
  }
653
827
 
828
+ // P0-B — single honesty surface (never a score; never "all good" when residual remains).
829
+ if (productHonesty) {
830
+ console.log('');
831
+ console.log(color.bold('Product honesty'));
832
+ line(
833
+ productHonesty.unfinished ? warn : ok,
834
+ `${productHonesty.headline} — ${productHonesty.primaryMessage}`
835
+ );
836
+ if (productHonesty.unfinished && Array.isArray(productHonesty.reasonIds) && productHonesty.reasonIds.length > 0) {
837
+ line(' ', color.dim(`signals: ${productHonesty.reasonIds.join(', ')} (notAScore)`));
838
+ }
839
+ if (rulesUnderContract?.mergePlanes?.failMergeWhen) {
840
+ line(
841
+ ' ',
842
+ color.dim(
843
+ `merge planes: ${rulesUnderContract.mergePlanes.failMergeWhen} · ${rulesUnderContract.mergePlanes.dualPlaneStamp}`
844
+ )
845
+ );
846
+ }
847
+ }
848
+
654
849
  console.log('');
655
850
  console.log(color.bold('Design fitness'));
656
851
  if (designSmells.length === 0) {
@@ -738,13 +933,36 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
738
933
  }
739
934
  if (cov.emptyLayers.length > 0) line(warn, `Empty layers (pattern matches nothing): ${cov.emptyLayers.join(', ')}`);
740
935
  if (cov.layersWithoutRules.length > 0) line(warn, `Layers with no rule edge: ${cov.layersWithoutRules.join(', ')}`);
936
+ if (cov.dualMembership?.count > 0) {
937
+ line(
938
+ warn,
939
+ `Dual-match: ${cov.dualMembership.count} file(s) match multiple layers — ${cov.dualMembership.note ?? 'review overlapping globs'}`
940
+ );
941
+ }
741
942
  if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
742
943
 
743
944
  if (packageVersionTruth?.dualTruth) {
744
945
  console.log('');
745
946
  console.log(color.bold('Package pin (dual-truth)'));
746
947
  line(warn, packageVersionTruth.note);
747
- actions.push('bump package.json arkgate pin to match this CLI (or install without --no-install)');
948
+ actions.push(
949
+ dualTruthNext ||
950
+ 'bump package.json arkgate pin to match this CLI (or install without --no-install)'
951
+ );
952
+ } else if (packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT') {
953
+ console.log('');
954
+ console.log(color.bold('Package pin'));
955
+ line(warn, packageVersionTruth.note);
956
+ actions.push(
957
+ dualTruthNext ||
958
+ 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
959
+ );
960
+ }
961
+ if (options.configWalkedUp && options.configRoot) {
962
+ line(
963
+ ok,
964
+ `Config walk-up: using monorepo root ${options.configRoot} (ark.config.json not in cwd package)`
965
+ );
748
966
  }
749
967
 
750
968
  if (showNewHere) {