arkgate 3.9.1 → 4.0.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 (78) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +16 -4
  3. package/bin/ark-check-runtime.mjs +75 -3
  4. package/bin/ark-mcp-runtime.mjs +94 -0
  5. package/bin/lib/adapter-contract.mjs +14 -1
  6. package/bin/lib/ambient-state.mjs +64 -8
  7. package/bin/lib/analysis-engine.mjs +8 -8
  8. package/bin/lib/architecture-scan.mjs +35 -2
  9. package/bin/lib/arkrule-file-hints.mjs +71 -0
  10. package/bin/lib/arkrules-contract.mjs +382 -0
  11. package/bin/lib/arkrules-sensors.mjs +411 -0
  12. package/bin/lib/config-contract.mjs +85 -6
  13. package/bin/lib/doctor-advisories.mjs +22 -5
  14. package/bin/lib/doctor-plan.mjs +68 -13
  15. package/bin/lib/effective-contract-load.mjs +116 -0
  16. package/bin/lib/enforcement-honesty.mjs +225 -0
  17. package/bin/lib/field-install.mjs +104 -0
  18. package/bin/lib/graph-blind.mjs +254 -0
  19. package/bin/lib/html-report-advisories.mjs +29 -3
  20. package/bin/lib/install-migrate.mjs +20 -2
  21. package/bin/lib/invariant-coverage-io.mjs +157 -0
  22. package/bin/lib/invariant-coverage.mjs +127 -0
  23. package/bin/lib/pilot-loop.mjs +19 -0
  24. package/bin/lib/policy-delta-io.mjs +33 -0
  25. package/bin/lib/post-green-path.mjs +22 -1
  26. package/bin/lib/presets.mjs +241 -1
  27. package/bin/lib/remediation.mjs +28 -0
  28. package/bin/lib/resolved-candidate-facts.mjs +14 -1
  29. package/bin/lib/rules-inventory.mjs +144 -0
  30. package/bin/lib/rules-under-contract.mjs +66 -0
  31. package/bin/lib/start-preview.mjs +24 -7
  32. package/bin/lib/upgrade-command.mjs +48 -2
  33. package/dist/{configTypes-DAPvBqK6.d.ts → configTypes-CC0FEXoF.d.ts} +16 -3
  34. package/dist/eslint/index.cjs +2 -2
  35. package/dist/eslint/index.d.ts +1 -1
  36. package/dist/eslint/index.js +2 -2
  37. package/dist/index.cjs +14 -7
  38. package/dist/index.d.ts +615 -20
  39. package/dist/index.js +13 -6
  40. package/docs/README.md +4 -3
  41. package/docs/agent-guide.md +7 -3
  42. package/docs/ai-gates.md +6 -1
  43. package/docs/brownfield-adoption.md +20 -0
  44. package/docs/configuration.md +37 -4
  45. package/docs/develop.md +8 -2
  46. package/docs/enthusiast/README.md +11 -0
  47. package/docs/package-surface.md +13 -11
  48. package/docs/product-voice.md +9 -2
  49. package/docs/use.md +9 -0
  50. package/package.json +4 -17
  51. package/schemas/ark.analysis-result.schema.json +9 -1
  52. package/schemas/ark.arkrules.schema.json +141 -0
  53. package/schemas/ark.config.schema.json +10 -2
  54. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  55. package/server.json +3 -3
  56. package/templates/arkrules/ApplicationOrchestration.json +14 -0
  57. package/templates/arkrules/DomainModel.json +32 -0
  58. package/templates/arkrules/PersistenceAdapters.json +14 -0
  59. package/templates/arkrules/PresentationAdapters.json +14 -0
  60. package/templates/skills/ark-adopt.md +28 -1
  61. package/templates/skills/ark-architect.md +23 -0
  62. package/templates/skills/ark-autopilot.md +27 -1
  63. package/templates/skills/ark-contract.md +27 -1
  64. package/templates/skills/ark-coverage.md +30 -0
  65. package/templates/skills/ark-explain.md +23 -0
  66. package/templates/skills/ark-explore.md +30 -2
  67. package/templates/skills/ark-fix.md +23 -0
  68. package/templates/skills/ark-loop.md +23 -0
  69. package/templates/skills/ark-place.md +30 -0
  70. package/templates/skills/ark-runtime.md +4 -0
  71. package/templates/skills/ark-think.md +24 -1
  72. package/templates/skills/ark-upgrade.md +23 -0
  73. package/compat/nestjs.cjs +0 -2
  74. package/compat/nestjs.d.ts +0 -2
  75. package/compat/nestjs.js +0 -1
  76. package/compat/runtime.cjs +0 -2
  77. package/compat/runtime.d.ts +0 -2
  78. package/compat/runtime.js +0 -1
@@ -9,6 +9,9 @@ import {
9
9
  resolveOperatingMode,
10
10
  shouldShowNewHereNudge,
11
11
  } from '../ark-shared.mjs';
12
+ import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
13
+ import { describePackageVersionDualTruth } from './field-install.mjs';
14
+ export { summarizeRulesUnderContract };
12
15
  import {
13
16
  collectAdoptionGaps,
14
17
  detectSkillGaps,
@@ -35,8 +38,12 @@ import {
35
38
  buildPostGreenNextAction,
36
39
  mergePostGreenTopActions,
37
40
  isDoctorHealthyNothingToDo,
41
+ DESIGN_WEAK_HONESTY_FLAGS,
38
42
  } from './post-green-path.mjs';
39
- import { doctorWritePathHonestyMessage } from './host-support-matrix.mjs';
43
+ import {
44
+ buildCoverageHonesty,
45
+ computeDoctorEnforcementHonesty,
46
+ } from './enforcement-honesty.mjs';
40
47
  import {
41
48
  computePureLayerOptInNudge,
42
49
  loadGoldenPattern,
@@ -129,10 +136,12 @@ export function runCoverage(root, config, files, rules, asJson) {
129
136
  if (files.length > 0 && governed.percent < 50) {
130
137
  console.log('');
131
138
  console.log(
132
- `⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check here does NOT`
139
+ `⚠ Ark governs a MINORITY of your code (${governed.percent}%). A green check on ~${governed.percent}%`
140
+ );
141
+ console.log(
142
+ ' is worse than no gate — it looks safe while most code is ungoverned. Classify the'
133
143
  );
134
- console.log(' mean the codebase is checked the rest is ungoverned. Classify the directories');
135
- console.log(' below to actually cover it.');
144
+ console.log(' directories below before treating green as enforcement.');
136
145
  }
137
146
  if (suggestions.length > 0) {
138
147
  console.log('');
@@ -236,6 +245,11 @@ export function buildRemediationPlan(
236
245
  patternBets,
237
246
  designSmells,
238
247
  });
248
+ const coverageHonesty = buildCoverageHonesty({
249
+ percent: governedPercent,
250
+ totalFiles,
251
+ emptyScope,
252
+ });
239
253
 
240
254
  let statement =
241
255
  activeViolations.length > 0
@@ -264,6 +278,7 @@ export function buildRemediationPlan(
264
278
  ? {
265
279
  designWeakLabel:
266
280
  'ENFORCE · design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
281
+ ...DESIGN_WEAK_HONESTY_FLAGS,
267
282
  }
268
283
  : {}),
269
284
  ...(governedPercent != null ? { governedPercent } : {}),
@@ -282,6 +297,7 @@ export function buildRemediationPlan(
282
297
  designSmells,
283
298
  // Q04: one-pilot loop step (extraction card); never mechanical-safe.
284
299
  pilotLoop,
300
+ coverageHonesty,
285
301
  };
286
302
  }
287
303
 
@@ -397,6 +413,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
397
413
  }
398
414
  const gatesMissing = missingGates(root);
399
415
  const skillGaps = detectSkillGaps(root);
416
+ // Dual-truth: CLI version vs package.json pin (field residual after upgrade --no-install).
417
+ const packageVersionTruth = describePackageVersionDualTruth(root);
400
418
  const staleRunners = staleRunnerGateFiles(root);
401
419
  const adoption = collectAdoptionGaps(root, config, cov);
402
420
  // Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
@@ -435,6 +453,18 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
435
453
  designSmells,
436
454
  });
437
455
  const doctorAdvisories = computeDoctorAdvisories(root, config, cov, rules, files, options.ts, options.parseHealth);
456
+ const { coverageHonesty, baselineHonesty, writePathHonesty } = computeDoctorEnforcementHonesty({
457
+ governedPercent: cov.governed.percent,
458
+ 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,
467
+ });
438
468
 
439
469
  if (asJson) {
440
470
  (options.writeJson ?? console.log)(
@@ -464,10 +494,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
464
494
  // Q01: primary next action when Shape residual dominates (null if not design-weak).
465
495
  postGreenPath,
466
496
  ...(postGreenPath
467
- ? {
468
- primaryNextAction: postGreenPath.action,
469
- healthyFinishedForbidden: true,
470
- }
497
+ ? { primaryNextAction: postGreenPath.action, ...DESIGN_WEAK_HONESTY_FLAGS }
471
498
  : {}),
472
499
  // Q03: advisory golden for new-code placement (absent = no claim).
473
500
  goldenPattern,
@@ -475,10 +502,19 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
475
502
  pureLayerOptIn,
476
503
  // Q04: one-pilot loop (extraction card → re-doctor).
477
504
  pilotLoop,
478
- // Advisories, never a verdict: W01 contract health, U05 ambient state,
479
- // X04 physical cohesion/reshape pilot, Y03 parse health.
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
+ // Dual-truth: managed CLI vs package.json pin (not a gate fail).
513
+ packageVersionTruth,
514
+ // Advisories, never a verdict: W01/U05/X04/Y03 + graph-blind spots.
480
515
  ...doctorAdvisories,
481
516
  governed: cov.governed,
517
+ coverageHonesty,
482
518
  emptyLayers: cov.emptyLayers,
483
519
  layersWithoutRules: cov.layersWithoutRules,
484
520
  ungovernedDirs: cov.suggestions.length,
@@ -497,6 +533,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
497
533
  frozen: baseline.exists ? baseline.keys.size : 0,
498
534
  stale: analysisComplete ? staleBaseline : null,
499
535
  policy: adoption.baseline,
536
+ honesty: baselineHonesty,
500
537
  },
501
538
  gatesMissing,
502
539
  skillGaps,
@@ -517,6 +554,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
517
554
  hookRepair: writePath.hookRepair,
518
555
  mcpPresent: writePath.mcpPresent,
519
556
  evidence: writePath.evidence,
557
+ honesty: writePathHonesty,
520
558
  ...(writePath.sessionNote ? { sessionNote: writePath.sessionNote } : {}),
521
559
  ...(writePath.gap
522
560
  ? {
@@ -688,6 +726,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
688
726
  ? ok
689
727
  : warn;
690
728
  line(govMark, `Governed: ${cov.governed.percent}% (${cov.governed.classifiedFiles}/${cov.governed.totalFiles} files)`);
729
+ if (coverageHonesty.greenIsNotEnforcement) {
730
+ line(coverageHonesty.worseThanNoGate ? bad : warn, coverageHonesty.message);
731
+ if (coverageHonesty.worseThanNoGate) {
732
+ actions.push('raise governed coverage above a minority slice before treating green as enforcement');
733
+ }
734
+ }
691
735
  if (cov.suggestions.length > 0) {
692
736
  line(warn, `${cov.suggestions.length} ungoverned director(y/ies) — proposals: ${arkCommand(root, 'ark-check', '--coverage')}`);
693
737
  actions.push('classify the ungoverned directories (/ark-contract)');
@@ -696,6 +740,13 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
696
740
  if (cov.layersWithoutRules.length > 0) line(warn, `Layers with no rule edge: ${cov.layersWithoutRules.join(', ')}`);
697
741
  if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
698
742
 
743
+ if (packageVersionTruth?.dualTruth) {
744
+ console.log('');
745
+ console.log(color.bold('Package pin (dual-truth)'));
746
+ line(warn, packageVersionTruth.note);
747
+ actions.push('bump package.json arkgate pin to match this CLI (or install without --no-install)');
748
+ }
749
+
699
750
  if (showNewHere) {
700
751
  console.log('');
701
752
  console.log(color.bold('New here?'));
@@ -770,8 +821,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
770
821
  line(' ', `Active host: ${writePath.activeHost}`);
771
822
  line(' ', `Supported profile: ${writePath.supportSummary}`);
772
823
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
773
- const honestyLine = doctorWritePathHonestyMessage(writePath.activeHost, capabilities['hard-write']);
774
- if (honestyLine) line(warn, honestyLine);
824
+ if (writePathHonesty.message) line(warn, writePathHonesty.message);
775
825
  if (writePath.sessionNote) {
776
826
  line(warn, writePath.sessionNote);
777
827
  }
@@ -850,7 +900,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
850
900
  } else {
851
901
  // Baseline keys are line-agnostic, so N keys can suppress ≥N violations — label as keys
852
902
  // to avoid an apparent mismatch with the "frozen" violation count above.
853
- line(analysisComplete ? ok : warn, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
903
+ const baseMark = !analysisComplete || baselineHonesty.dirtyBaselineRisk ? warn : ok;
904
+ line(baseMark, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
905
+ if (analysisComplete && baselineHonesty.dirtyBaselineRisk) {
906
+ line(warn, baselineHonesty.message);
907
+ actions.push('review dirty baseline freezes — fix the contract before trusting green-via-freeze');
908
+ }
854
909
  if (analysisComplete && staleBaseline > 0) {
855
910
  line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline`);
856
911
  actions.push('tighten the baseline (--update-baseline)');
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tooling adapter: load Effective Contract from disk for a root config.
3
+ * Pure resolution lives in Domain (`resolveEffectiveContract`); this module owns I/O.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ emptyEffectiveArkRules,
9
+ buildEffectiveArkRules,
10
+ loadArkRulesContract,
11
+ } from './arkrules-contract.mjs';
12
+
13
+ /**
14
+ * @param {string} root
15
+ * @param {Record<string, unknown>} config loaded ark.config.json object
16
+ * @param {{ observeInput?: (abs: string, kind: string) => void }} [opts]
17
+ * @returns {{ arkRules: ReturnType<typeof emptyEffectiveArkRules>, warnings: Array<{path:string,message:string,severity:string}>, errors: Array<{path:string,message:string}> }}
18
+ */
19
+ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
20
+ const refs = config?.arkRules;
21
+ if (!refs || typeof refs !== 'object' || Object.keys(refs).length === 0) {
22
+ return { arkRules: emptyEffectiveArkRules(), warnings: [], errors: [] };
23
+ }
24
+
25
+ const layerNames = new Set(
26
+ Array.isArray(config.layers) ? config.layers.map((layer) => layer.name) : []
27
+ );
28
+ const errors = [];
29
+ const warnings = [];
30
+ const parts = [];
31
+ const referenced = new Set();
32
+
33
+ for (const layer of Object.keys(refs).sort()) {
34
+ const relRaw = refs[layer];
35
+ const pathKey = `$.arkRules[${JSON.stringify(layer)}]`;
36
+ if (typeof relRaw !== 'string' || relRaw.length === 0) {
37
+ errors.push({ path: pathKey, message: 'must be a non-empty relative path string' });
38
+ continue;
39
+ }
40
+ if (relRaw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(relRaw)) {
41
+ errors.push({
42
+ path: pathKey,
43
+ message: 'must be a project-relative path (absolute paths are not allowed)',
44
+ });
45
+ continue;
46
+ }
47
+ if (!layerNames.has(layer)) {
48
+ errors.push({
49
+ path: pathKey,
50
+ message: `layer ${JSON.stringify(layer)} is not declared in layers[]`,
51
+ });
52
+ continue;
53
+ }
54
+
55
+ const rel = relRaw.replace(/\\/g, '/').replace(/^\.\//, '');
56
+ referenced.add(rel);
57
+ const absolute = path.resolve(root, rel);
58
+ opts.observeInput?.(absolute, 'arkrules');
59
+ if (!fs.existsSync(absolute)) {
60
+ errors.push({
61
+ path: pathKey,
62
+ message: `referenced ArkRules file ${JSON.stringify(rel)} is missing`,
63
+ });
64
+ continue;
65
+ }
66
+ let content;
67
+ try {
68
+ content = fs.readFileSync(absolute, 'utf8');
69
+ } catch (error) {
70
+ errors.push({
71
+ path: pathKey,
72
+ message: `referenced ArkRules file ${JSON.stringify(rel)} could not be read: ${
73
+ error instanceof Error ? error.message : String(error)
74
+ }`,
75
+ });
76
+ continue;
77
+ }
78
+ try {
79
+ const loaded = loadArkRulesContract(JSON.parse(content), rel, layer);
80
+ parts.push({ layer, sourceFile: rel, file: loaded.config });
81
+ } catch (error) {
82
+ errors.push({
83
+ path: pathKey,
84
+ message:
85
+ error instanceof Error
86
+ ? error.message
87
+ : `referenced ArkRules file ${JSON.stringify(rel)} failed to load`,
88
+ });
89
+ }
90
+ }
91
+
92
+ // Drift: unreferenced files under arkrules/
93
+ const arkrulesDir = path.join(root, 'arkrules');
94
+ if (fs.existsSync(arkrulesDir) && fs.statSync(arkrulesDir).isDirectory()) {
95
+ for (const name of fs.readdirSync(arkrulesDir).sort()) {
96
+ if (!name.endsWith('.json')) continue;
97
+ const rel = `arkrules/${name}`;
98
+ if (!referenced.has(rel)) {
99
+ warnings.push({
100
+ path: rel,
101
+ message: `ArkRules file ${JSON.stringify(rel)} is not referenced by arkRules and will not be enforced`,
102
+ severity: 'advisory',
103
+ });
104
+ }
105
+ }
106
+ }
107
+
108
+ if (errors.length > 0) {
109
+ return { arkRules: emptyEffectiveArkRules(), warnings, errors };
110
+ }
111
+ return {
112
+ arkRules: buildEffectiveArkRules(parts),
113
+ warnings,
114
+ errors: [],
115
+ };
116
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Product honesty helpers — weak coverage, dirty baseline, soft write hosts.
3
+ *
4
+ * Pure / fail-closed: never invent hard write guarantees; never paint thin
5
+ * coverage or a dirty freeze as "done." Advisory labels only.
6
+ */
7
+ import {
8
+ doctorWritePathHonestyMessage,
9
+ HOST_SUPPORT_MATRIX,
10
+ HOST_SUPPORT_HOSTS,
11
+ } from './host-support-matrix.mjs';
12
+
13
+ /** Soft = matrix hard-write false; hard-capable = matrix hard-write true. Single source of truth. */
14
+ function hostWriteClassSets() {
15
+ const soft = new Set();
16
+ const hard = new Set();
17
+ for (const host of HOST_SUPPORT_HOSTS) {
18
+ const profile = HOST_SUPPORT_MATRIX[host];
19
+ if (profile?.capabilities?.['hard-write']) hard.add(host);
20
+ else soft.add(host);
21
+ }
22
+ return { soft, hard };
23
+ }
24
+
25
+ const { soft: SOFT_WRITE_HOSTS, hard: HARD_WRITE_HOSTS } = hostWriteClassSets();
26
+
27
+ /**
28
+ * Coverage honesty: green on a minority of the tree is worse than no gate.
29
+ * `greenIsNotEnforcement` stays true until the whole in-scope tree is governed (100%).
30
+ * @param {{ percent?: number|null, totalFiles?: number|null, emptyScope?: boolean }} input
31
+ */
32
+ export function buildCoverageHonesty(input = {}) {
33
+ const total = Number(input.totalFiles) || 0;
34
+ const empty = input.emptyScope === true || total === 0;
35
+ const percent = Number.isFinite(Number(input.percent)) ? Number(input.percent) : 0;
36
+
37
+ if (empty) {
38
+ return {
39
+ status: 'empty-scope',
40
+ advisory: true,
41
+ greenIsNotEnforcement: true,
42
+ worseThanNoGate: true,
43
+ wholeTreeGoverned: false,
44
+ governedPercent: 0,
45
+ // Always 0 when empty-scope so payload cannot contradict the message.
46
+ totalFiles: 0,
47
+ message:
48
+ 'Empty scope: a green check matches 0 files and is worse than no gate until include/layers cover real code.',
49
+ };
50
+ }
51
+ if (percent < 50) {
52
+ return {
53
+ status: 'weak',
54
+ advisory: true,
55
+ greenIsNotEnforcement: true,
56
+ worseThanNoGate: true,
57
+ wholeTreeGoverned: false,
58
+ governedPercent: percent,
59
+ totalFiles: total,
60
+ message: `Weak coverage (${percent}%): a green check on a minority of the tree is worse than no gate — most code is ungoverned.`,
61
+ };
62
+ }
63
+ if (percent < 80) {
64
+ return {
65
+ status: 'partial',
66
+ advisory: true,
67
+ greenIsNotEnforcement: true,
68
+ worseThanNoGate: false,
69
+ wholeTreeGoverned: false,
70
+ governedPercent: percent,
71
+ totalFiles: total,
72
+ message: `Partial coverage (${percent}%): green means edges on the governed slice only, not the whole tree.`,
73
+ };
74
+ }
75
+ const wholeTree = percent >= 100;
76
+ return {
77
+ status: 'strong',
78
+ advisory: true,
79
+ // Strong slice ≠ full-tree enforcement; boolean consumers must not oversell.
80
+ greenIsNotEnforcement: !wholeTree,
81
+ worseThanNoGate: false,
82
+ wholeTreeGoverned: wholeTree,
83
+ governedPercent: percent,
84
+ totalFiles: total,
85
+ message: wholeTree
86
+ ? `Governed 100% of in-scope files — green is meaningful for the full in-scope tree.`
87
+ : `Governed ${percent}% of in-scope files — green is meaningful only for that governed slice; ${100 - percent}% remains ungoverned.`,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Baseline honesty: a large freeze that zeros active violations can look safe
93
+ * while hiding false-positive debt.
94
+ * @param {{
95
+ * exists?: boolean,
96
+ * frozenKeys?: number,
97
+ * activeViolations?: number,
98
+ * suppressed?: number,
99
+ * totalViolations?: number,
100
+ * }} input
101
+ */
102
+ export function buildBaselineHonesty(input = {}) {
103
+ const exists = input.exists === true;
104
+ const frozen = Number(input.frozenKeys) || 0;
105
+ const active = Number(input.activeViolations) || 0;
106
+ const suppressed = Number(input.suppressed) || 0;
107
+ const total = Number(input.totalViolations) || 0;
108
+
109
+ if (!exists) {
110
+ return {
111
+ status: total > 0 ? 'missing-with-debt' : 'absent',
112
+ advisory: true,
113
+ dirtyBaselineRisk: false,
114
+ frozenKeys: 0,
115
+ activeViolations: active,
116
+ suppressed: 0,
117
+ message:
118
+ total > 0
119
+ ? 'No baseline while violations exist — freeze only real debt after the contract is honest.'
120
+ : 'No baseline (nothing to freeze).',
121
+ };
122
+ }
123
+
124
+ const suppressShare = total > 0 ? suppressed / total : 0;
125
+ // Dirty: green-via-freeze with material frozen debt (false-positive risk).
126
+ const dirty =
127
+ active === 0 &&
128
+ frozen > 0 &&
129
+ (frozen >= 10 || (frozen >= 5 && suppressed > 0) || (total >= 3 && suppressShare >= 0.5));
130
+
131
+ if (dirty) {
132
+ return {
133
+ status: 'dirty-freeze',
134
+ advisory: true,
135
+ dirtyBaselineRisk: true,
136
+ frozenKeys: frozen,
137
+ activeViolations: active,
138
+ suppressed,
139
+ message: `Baseline freezes ${frozen} key(s) while active violations are ${active} — green may hide false-positive debt. Prefer contract fixes over a dirty freeze.`,
140
+ };
141
+ }
142
+ if (frozen > 0) {
143
+ return {
144
+ status: 'active-freeze',
145
+ advisory: true,
146
+ dirtyBaselineRisk: false,
147
+ frozenKeys: frozen,
148
+ activeViolations: active,
149
+ suppressed,
150
+ message: `${frozen} frozen key(s); new distinct violations still fail. Ratchet down; do not reopen.`,
151
+ };
152
+ }
153
+ return {
154
+ status: 'empty-freeze',
155
+ advisory: true,
156
+ dirtyBaselineRisk: false,
157
+ frozenKeys: 0,
158
+ activeViolations: active,
159
+ suppressed,
160
+ message: 'Baseline present with 0 frozen keys — every violation is active (honest).',
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Write-path honesty for the active host (fail-closed).
166
+ * Soft hosts never claim hard local write; hard hosts without proof stay unverified.
167
+ * @param {string|null|undefined} activeHost
168
+ * @param {boolean} hardWriteActive
169
+ */
170
+ export function buildWritePathHonesty(activeHost, hardWriteActive = false) {
171
+ const host = typeof activeHost === 'string' ? activeHost.trim().toLowerCase() : '';
172
+ const softWriteHost = SOFT_WRITE_HOSTS.has(host);
173
+ const hardCapable = HARD_WRITE_HOSTS.has(host);
174
+ const message = doctorWritePathHonestyMessage(host, hardWriteActive);
175
+
176
+ return {
177
+ advisory: true,
178
+ activeHost: host || null,
179
+ softWriteHost,
180
+ hardWriteSupported: hardCapable,
181
+ hardWriteActive: Boolean(hardWriteActive) && hardCapable && !softWriteHost,
182
+ hardWriteUnverified: hardCapable && !hardWriteActive,
183
+ hardMergeBoundary: 'required-ci-status (arkgate-check --strict-merge)',
184
+ message,
185
+ // Explicit product rule for soft hosts.
186
+ ...(softWriteHost
187
+ ? {
188
+ note: 'Local write is advisory / best-effort — not a hard PreToolUse boundary. Required CI status is the hard merge boundary.',
189
+ }
190
+ : {}),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * One-shot doctor honesty bundle (coverage + baseline + write path).
196
+ * Keeps doctor-plan.mjs under its module budget.
197
+ */
198
+ export function computeDoctorEnforcementHonesty({
199
+ governedPercent,
200
+ totalFiles,
201
+ emptyScope,
202
+ baselineExists,
203
+ frozenKeys,
204
+ activeViolations,
205
+ suppressed,
206
+ totalViolations,
207
+ activeHost,
208
+ hardWriteActive,
209
+ } = {}) {
210
+ return {
211
+ coverageHonesty: buildCoverageHonesty({
212
+ percent: governedPercent,
213
+ totalFiles,
214
+ emptyScope,
215
+ }),
216
+ baselineHonesty: buildBaselineHonesty({
217
+ exists: baselineExists,
218
+ frozenKeys,
219
+ activeViolations,
220
+ suppressed,
221
+ totalViolations,
222
+ }),
223
+ writePathHonesty: buildWritePathHonesty(activeHost, hardWriteActive),
224
+ };
225
+ }
@@ -201,6 +201,110 @@ export function syncBaselineIntoCheckSurfaces(root, opts = {}) {
201
201
  return { changed, skipped };
202
202
  }
203
203
 
204
+ /**
205
+ * Read declared arkgate pin from consumer package.json (deps or devDeps).
206
+ * @param {string} root
207
+ * @returns {string|null}
208
+ */
209
+ export function readDeclaredArkgatePin(root) {
210
+ const pkgPath = path.join(root, 'package.json');
211
+ if (!fs.existsSync(pkgPath)) return null;
212
+ try {
213
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
214
+ const deps = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
215
+ const dev = pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {};
216
+ if (typeof deps.arkgate === 'string') return deps.arkgate;
217
+ if (typeof dev.arkgate === 'string') return dev.arkgate;
218
+ return null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Dual-truth: CLI/package-shipped version vs consumer package.json pin.
226
+ * Used by doctor + upgrade so agents never confuse managed-asset CLI with CI pin.
227
+ *
228
+ * @param {string} root
229
+ * @param {{ cliVersion?: string|null }} [opts]
230
+ * @returns {{
231
+ * dualTruth: boolean,
232
+ * cliVersion: string|null,
233
+ * declaredPin: string|null,
234
+ * code: 'PACKAGE_PIN_BEHIND_CLI' | 'PACKAGE_PIN_MATCHES' | 'PACKAGE_PIN_ABSENT' | 'CLI_VERSION_UNKNOWN',
235
+ * note: string
236
+ * }}
237
+ */
238
+ export function describePackageVersionDualTruth(root, opts = {}) {
239
+ const cliVersion =
240
+ typeof opts.cliVersion === 'string' && opts.cliVersion
241
+ ? opts.cliVersion
242
+ : arkPackageVersion();
243
+ const declaredPin = readDeclaredArkgatePin(root);
244
+ if (!cliVersion) {
245
+ return {
246
+ dualTruth: false,
247
+ cliVersion: null,
248
+ declaredPin,
249
+ code: 'CLI_VERSION_UNKNOWN',
250
+ note: 'Could not read shipped arkgate package version for this CLI.',
251
+ };
252
+ }
253
+ if (!declaredPin) {
254
+ return {
255
+ dualTruth: false,
256
+ cliVersion,
257
+ declaredPin: null,
258
+ code: 'PACKAGE_PIN_ABSENT',
259
+ note: 'No arkgate pin in package.json; CI/npx may not resolve this CLI version.',
260
+ };
261
+ }
262
+ // Normalize ^x.y.z / ~x.y.z / x.y.z for comparison of leading version token.
263
+ const pinCore = String(declaredPin).replace(/^[\^~>=<\s]+/, '').split(/\s+/)[0];
264
+ const matches =
265
+ pinCore === cliVersion ||
266
+ pinCore.startsWith(`${cliVersion}.`) ||
267
+ cliVersion.startsWith(pinCore.split('.').slice(0, 3).join('.'));
268
+ // Dual-truth when declared pin is clearly older major/minor than CLI, or different major.
269
+ const pinParts = pinCore.split('.').map((p) => Number.parseInt(p, 10));
270
+ const cliParts = cliVersion.split('.').map((p) => Number.parseInt(p, 10));
271
+ let behind = false;
272
+ if (
273
+ pinParts.length >= 1 &&
274
+ cliParts.length >= 1 &&
275
+ pinParts.every((n) => Number.isFinite(n)) &&
276
+ cliParts.every((n) => Number.isFinite(n))
277
+ ) {
278
+ for (let i = 0; i < 3; i += 1) {
279
+ const p = pinParts[i] ?? 0;
280
+ const c = cliParts[i] ?? 0;
281
+ if (p < c) {
282
+ behind = true;
283
+ break;
284
+ }
285
+ if (p > c) break;
286
+ }
287
+ } else if (!matches) {
288
+ behind = true;
289
+ }
290
+ if (behind) {
291
+ return {
292
+ dualTruth: true,
293
+ cliVersion,
294
+ declaredPin,
295
+ code: 'PACKAGE_PIN_BEHIND_CLI',
296
+ note: `Managed CLI is arkgate@${cliVersion} but package.json pins ${declaredPin}. Bump the pin or re-run install so CI resolves the same version (common after upgrade --no-install).`,
297
+ };
298
+ }
299
+ return {
300
+ dualTruth: false,
301
+ cliVersion,
302
+ declaredPin,
303
+ code: 'PACKAGE_PIN_MATCHES',
304
+ note: `package.json pin ${declaredPin} is aligned with CLI arkgate@${cliVersion}.`,
305
+ };
306
+ }
307
+
204
308
  /**
205
309
  * Pin `arkgate` in package.json devDependencies (no package manager network call).
206
310
  *