@ecoma-io/archkeep 0.25.0 → 0.27.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 (121) hide show
  1. package/cli.mjs +167 -506
  2. package/commands.mjs +7 -1
  3. package/package.json +1 -1
  4. package/src/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +34 -0
  6. package/src/canonical.mjs +2 -1
  7. package/src/commands/adr-for-workspace.mjs +63 -0
  8. package/src/commands/analyze-capability.mjs +13 -0
  9. package/src/commands/change-intent.mjs +11 -0
  10. package/src/commands/change.mjs +251 -35
  11. package/src/commands/check-capability.mjs +13 -0
  12. package/src/commands/check.mjs +11 -3
  13. package/src/commands/compare-capability.mjs +20 -0
  14. package/src/commands/completeness.mjs +12 -0
  15. package/src/commands/context-command.mjs +31 -0
  16. package/src/commands/coverage-verdict.mjs +15 -2
  17. package/src/commands/debt.mjs +30 -0
  18. package/src/commands/decisions.mjs +59 -10
  19. package/src/commands/delta.mjs +266 -50
  20. package/src/commands/diff.mjs +45 -0
  21. package/src/commands/discover.mjs +37 -0
  22. package/src/commands/drift.mjs +45 -0
  23. package/src/commands/evolution.mjs +32 -2
  24. package/src/commands/explain-capability.mjs +11 -0
  25. package/src/commands/explain.mjs +24 -0
  26. package/src/commands/fitness.mjs +119 -10
  27. package/src/commands/govern-capability.mjs +16 -0
  28. package/src/commands/graph.mjs +39 -0
  29. package/src/commands/health.mjs +34 -0
  30. package/src/commands/history.mjs +85 -2
  31. package/src/commands/impact.mjs +23 -0
  32. package/src/commands/inspect-capability.mjs +16 -0
  33. package/src/commands/plan-context-command.mjs +35 -0
  34. package/src/commands/policy.mjs +42 -4
  35. package/src/commands/provenance-command.mjs +15 -24
  36. package/src/commands/report.mjs +38 -0
  37. package/src/commands/rules-capability.mjs +16 -0
  38. package/src/commands/rules.mjs +3 -1
  39. package/src/commands/scenario.mjs +23 -0
  40. package/src/commands/trajectory.mjs +35 -0
  41. package/src/commands/waivers.mjs +30 -0
  42. package/src/corpus/goldens/.gitkeep +0 -0
  43. package/src/corpus/goldens/adr.json +62 -0
  44. package/src/corpus/goldens/adr.text +6 -0
  45. package/src/corpus/goldens/change.json +99 -0
  46. package/src/corpus/goldens/change.text +6 -0
  47. package/src/corpus/goldens/check.json +90 -0
  48. package/src/corpus/goldens/check.sarif +479 -0
  49. package/src/corpus/goldens/check.text +9 -0
  50. package/src/corpus/goldens/context.json +47 -0
  51. package/src/corpus/goldens/context.text +6 -0
  52. package/src/corpus/goldens/debt.json +59 -0
  53. package/src/corpus/goldens/debt.text +7 -0
  54. package/src/corpus/goldens/decisions.json +74 -0
  55. package/src/corpus/goldens/decisions.text +7 -0
  56. package/src/corpus/goldens/delta.json +94 -0
  57. package/src/corpus/goldens/delta.sarif +472 -0
  58. package/src/corpus/goldens/delta.text +5 -0
  59. package/src/corpus/goldens/diff.json +58 -0
  60. package/src/corpus/goldens/diff.text +4 -0
  61. package/src/corpus/goldens/discover.json +77 -0
  62. package/src/corpus/goldens/discover.text +10 -0
  63. package/src/corpus/goldens/drift.json +43 -0
  64. package/src/corpus/goldens/drift.text +3 -0
  65. package/src/corpus/goldens/evolution.json +330 -0
  66. package/src/corpus/goldens/evolution.text +32 -0
  67. package/src/corpus/goldens/explain.json +60 -0
  68. package/src/corpus/goldens/explain.text +7 -0
  69. package/src/corpus/goldens/fitness.json +45 -0
  70. package/src/corpus/goldens/fitness.text +3 -0
  71. package/src/corpus/goldens/graph.json +78 -0
  72. package/src/corpus/goldens/graph.text +10 -0
  73. package/src/corpus/goldens/health.json +72 -0
  74. package/src/corpus/goldens/health.text +10 -0
  75. package/src/corpus/goldens/help.text +104 -0
  76. package/src/corpus/goldens/history.json +43 -0
  77. package/src/corpus/goldens/history.text +4 -0
  78. package/src/corpus/goldens/impact.json +327 -0
  79. package/src/corpus/goldens/impact.text +10 -0
  80. package/src/corpus/goldens/provenance.json +453 -0
  81. package/src/corpus/goldens/provenance.text +14 -0
  82. package/src/corpus/goldens/reconcile.json +168 -0
  83. package/src/corpus/goldens/reconcile.text +3 -0
  84. package/src/corpus/goldens/report.json +168 -0
  85. package/src/corpus/goldens/report.text +42 -0
  86. package/src/corpus/goldens/rules verify.json +0 -0
  87. package/src/corpus/goldens/rules verify.text +0 -0
  88. package/src/corpus/goldens/scenario.json +441 -0
  89. package/src/corpus/goldens/scenario.text +28 -0
  90. package/src/corpus/goldens/trajectory.json +83 -0
  91. package/src/corpus/goldens/trajectory.text +10 -0
  92. package/src/corpus/goldens/usage-error.text +105 -0
  93. package/src/corpus/goldens/waivers.json +40 -0
  94. package/src/corpus/goldens/waivers.text +1 -0
  95. package/src/corpus/goldens-lsp/initialize.result.json +15 -0
  96. package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
  97. package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
  98. package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
  99. package/src/corpus/goldens-lsp/publish-violation.json +22 -0
  100. package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
  101. package/src/go-work.mjs +7 -23
  102. package/src/governance/debt-ledger.mjs +5 -2
  103. package/src/governance/evolution-event.mjs +14 -0
  104. package/src/governance/evolution-store.mjs +59 -31
  105. package/src/governance/provenance-record.mjs +0 -152
  106. package/src/governance/verdict.mjs +26 -3
  107. package/src/intent/intent-manifest.json +14 -14
  108. package/src/lsp/server.mjs +5 -8
  109. package/src/lsp/workspace-index.mjs +55 -220
  110. package/src/options.mjs +10 -0
  111. package/src/providers/native/discover.mjs +13 -12
  112. package/src/providers/native/model.mjs +8 -3
  113. package/src/providers/nx-static.mjs +231 -0
  114. package/src/report/json.mjs +3 -2
  115. package/src/report/sarif.mjs +8 -3
  116. package/src/rules/edge-constraints.mjs +8 -0
  117. package/src/rules/index.mjs +24 -10
  118. package/src/rules/messages.mjs +64 -14
  119. package/src/rules/specifiers.mjs +21 -20
  120. package/src/tsconfig-paths.mjs +7 -14
  121. package/src/verdict.mjs +127 -16
package/cli.mjs CHANGED
@@ -55,8 +55,10 @@
55
55
  * classifies as introduced, or a change-intent reconciliation that found
56
56
  * undeclared material changes, unfulfilled declarations, or a failed
57
57
  * declared constraint. `check`, `fitness`, `delta` and `change` are the
58
- * verbs whose verdicts carry this code every other verb in this table
59
- * only ever reads.
58
+ * verbs whose verdicts carry this code, plus `rules verify` the
59
+ * artifact-integrity fold, a bounded verification surface, not a fifth
60
+ * architecture carrier (`docs/reference/exit-codes.md` owns the
61
+ * roster). Every other verb only ever reads.
60
62
  * 2 usage error — unknown command, unknown flag, missing argument, path
61
63
  * outside the tree
62
64
  * 3 no verdict — no workspace, malformed config, the graph provider or git
@@ -92,7 +94,7 @@
92
94
  * only once a later command needs something this table cannot express.
93
95
  */
94
96
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
95
- import { dirname, isAbsolute, join, resolve } from "node:path";
97
+ import { isAbsolute, join, resolve } from "node:path";
96
98
  import { createRequire } from "node:module";
97
99
 
98
100
  const require = createRequire(import.meta.url);
@@ -101,59 +103,70 @@ const { name: TOOL_NAME, version: TOOL_VERSION } = require("./package.json");
101
103
 
102
104
  import { containmentViolation } from "./src/containment.mjs";
103
105
  import { UsageError } from "./src/errors.mjs";
104
- import { check, sortViolations } from "./src/commands/check.mjs";
105
- import { resolveDescribedPolicy, resolvePolicy } from "./src/commands/policy.mjs";
106
+ // Verb imports route through the capability facades: each
107
+ // `<word>-capability.mjs` module is its word's explicit verb roster — pure
108
+ // re-exports, zero judgment. The vocabulary's roster lives in
109
+ // docs/concepts/architecture.md's "The 24 commands".
110
+ // Non-verb helper modules keep their direct imports.
106
111
  import {
107
- DEFAULT_OPTIONS,
108
- WORKSPACE_MARKERS,
109
- markersAt,
110
- resolveCommandContext,
111
- } from "./src/commands/context.mjs";
112
- import { contextCommand } from "./src/commands/context-command.mjs";
113
- import { planContextCommand } from "./src/commands/plan-context-command.mjs";
114
- import { adrCommand } from "./src/commands/adr.mjs";
115
- import { decisionsCommand } from "./src/commands/decisions.mjs";
116
- import { diffCommand } from "./src/commands/diff.mjs";
117
- import { captureDelta, deltaCommand } from "./src/commands/delta.mjs";
118
- import { discoverCommand, proposalToIntent } from "./src/commands/discover.mjs";
119
- import { driftCommand } from "./src/commands/drift.mjs";
120
- import { fitnessCommand } from "./src/commands/fitness.mjs";
121
- import { reconcileCommand } from "./src/commands/reconcile.mjs";
122
- import { changeCommand } from "./src/commands/change.mjs";
123
- import { computePolicyFingerprint, graphCommand } from "./src/commands/graph.mjs";
124
- import { historyCommand } from "./src/commands/history.mjs";
125
- import { trajectoryCommand } from "./src/commands/trajectory.mjs";
126
- import { evolutionCommand } from "./src/commands/evolution.mjs";
127
- import { healthCommand } from "./src/commands/health.mjs";
128
- import { reportCommand } from "./src/commands/report.mjs";
129
- import { debtCommand } from "./src/commands/debt.mjs";
130
- import { explainCommand } from "./src/commands/explain.mjs";
131
- import { impactCommand } from "./src/commands/impact.mjs";
132
- import { scenarioCommand } from "./src/commands/scenario.mjs";
133
- import { provenanceCommand } from "./src/commands/provenance-command.mjs";
112
+ discoverCommand,
113
+ intentJsonFromProposal,
114
+ intentWriteRefusal,
115
+ } from "./src/commands/analyze-capability.mjs";
116
+ import { check, fitness, scenario, sortViolations } from "./src/commands/check-capability.mjs";
117
+ import {
118
+ captureBaseline,
119
+ change,
120
+ changeOutputRefusal,
121
+ delta,
122
+ diff,
123
+ drift,
124
+ evolutionCommand,
125
+ history,
126
+ historyOutputRefusal,
127
+ reconcileCommand,
128
+ trajectoryCommand,
129
+ trajectoryOutputRefusal,
130
+ } from "./src/commands/compare-capability.mjs";
131
+ import { explain } from "./src/commands/explain-capability.mjs";
132
+ import { context, graph, health, impact, planContext } from "./src/commands/inspect-capability.mjs";
133
+ import {
134
+ debt,
135
+ decisions,
136
+ provenanceCommand,
137
+ report,
138
+ waivers,
139
+ } from "./src/commands/govern-capability.mjs";
134
140
  import {
135
141
  rulesAddCommand,
136
142
  rulesInfoCommand,
137
143
  rulesListCommand,
138
144
  rulesVerifyCommand,
139
- } from "./src/commands/rules.mjs";
140
- import { waiversCommand } from "./src/commands/waivers.mjs";
141
- import { INTENT_FILE, loadIntent } from "./src/architecture-intent/model.mjs";
145
+ } from "./src/commands/rules-capability.mjs";
146
+ import { nativePolicyOptions } from "./src/commands/policy.mjs";
147
+ import {
148
+ DEFAULT_OPTIONS,
149
+ WORKSPACE_MARKERS,
150
+ markersAt,
151
+ resolveCommandContext,
152
+ } from "./src/commands/context.mjs";
153
+ import { adrForWorkspace } from "./src/commands/adr-for-workspace.mjs";
154
+ import { INTENT_FILE } from "./src/architecture-intent/model.mjs";
142
155
  import { isProgramEntry } from "./src/entry-point.mjs";
143
- import { readPluginOptions } from "./src/options.mjs";
144
- import { EXIT, verdictFor } from "./src/verdict.mjs";
156
+ import { ARCHKEEP_MODEL_FILE, readPluginOptions } from "./src/options.mjs";
157
+ import { EXIT } from "./src/verdict.mjs";
145
158
 
146
- import { ARCHKEEP_MODEL_FILE, loadNativeModel } from "./src/providers/native/model.mjs";
147
- import { findWorkspaceRoot, listTrackedFiles } from "./src/workspace.mjs";
159
+ import { findWorkspaceRoot } from "./src/workspace.mjs";
148
160
 
149
161
  /**
150
162
  * Workspace-relative read from `root`, the same default `createWorkspace`
151
163
  * builds when no reader is injected (`./src/workspace.mjs`) — duplicated
152
164
  * rather than imported for the reason `./src/commands/context.mjs` carries its
153
165
  * own copy of the same helper: `optionsForUsage` below needs one BEFORE any
154
- * `Workspace` exists, to hand `loadNativeModel` a reader for `archkeep.json`
155
- * itself. `check` no longer needs a copy of its own — `resolveCommandContext`
156
- * owns that read now — which is why this is the only one left in this file.
166
+ * `Workspace` exists, to hand `nativePolicyOptions` a reader for
167
+ * `archkeep.json` itself. `check` no longer needs a copy of its own —
168
+ * `resolveCommandContext` owns that read now — which is why this is the only
169
+ * one left in this file.
157
170
  *
158
171
  * @param {string} root
159
172
  * @returns {(path: string) => string|null}
@@ -338,20 +351,7 @@ function optionsForUsage(cwd) {
338
351
  if (root === null) return DEFAULT_OPTIONS;
339
352
  const { hasNx, hasNative } = markersAt(root);
340
353
  if (hasNative && !hasNx) {
341
- const model = loadNativeModel(root, { readFile: readWorkspaceRoot(root) });
342
- // An inline policy object has no filename to print — `${boundaryConfig}`
343
- // below would otherwise coerce it to the literal text "[object Object]",
344
- // which reads as a real (and wrong) filename rather than as the "there
345
- // is no file" it actually means. `inline: true` is what tells `usage()`
346
- // to print the paragraph that says so, instead of the one describing a
347
- // named file.
348
- return typeof model.boundaryConfig === "string"
349
- ? { boundaryConfig: model.boundaryConfig, tsConfig: model.tsConfig }
350
- : {
351
- boundaryConfig: "an inline policy in archkeep.json",
352
- tsConfig: model.tsConfig,
353
- inline: true,
354
- };
354
+ return nativePolicyOptions(root, { readFile: readWorkspaceRoot(root) });
355
355
  }
356
356
  return readPluginOptions(root);
357
357
  } catch {
@@ -771,9 +771,9 @@ async function runCheck(options, { cwd, env }) {
771
771
  (result.intentFindings > 0
772
772
  ? `, ${result.intentFindings} architecture-intent finding${result.intentFindings === 1 ? "" : "s"}`
773
773
  : "") +
774
- // Fitness drives the exit code exactly like every count above it
775
- // (`verdictFor`) — omitting it here is what let a fitness-only
776
- // failure log "0 violations …" beside a non-zero exit.
774
+ // Fitness drives the exit code exactly like every count above it
775
+ // omitting it here is what let a fitness-only failure log "0
776
+ // violations …" beside a non-zero exit.
777
777
  (result.fitnessFail > 0
778
778
  ? `, ${result.fitnessFail} fitness function${result.fitnessFail === 1 ? "" : "s"} failed`
779
779
  : "") +
@@ -781,9 +781,9 @@ async function runCheck(options, { cwd, env }) {
781
781
  ? `, ${result.fitnessUnknown} fitness function${result.fitnessUnknown === 1 ? "" : "s"} undetermined`
782
782
  : "") +
783
783
  // Custom rules drive the exit code exactly like every count above
784
- // them (`verdictFor`), so they are named here for the same reason
785
- // fitness is: a custom-rule-only failure would otherwise log
786
- // "0 violations …" beside a non-zero exit.
784
+ // them, so they are named here for the same reason fitness is: a
785
+ // custom-rule-only failure would otherwise log "0 violations …"
786
+ // beside a non-zero exit.
787
787
  (result.customRuleFail > 0
788
788
  ? `, ${result.customRuleFail} custom rule${result.customRuleFail === 1 ? "" : "s"} failed`
789
789
  : "") +
@@ -799,7 +799,11 @@ async function runCheck(options, { cwd, env }) {
799
799
  env.out(result.report);
800
800
  }
801
801
 
802
- return verdictFor(result).exitCode;
802
+ // `check`'s own verdict rides its return (`./src/commands/check.mjs`): the
803
+ // command computes the one exit authority, and the process returns it
804
+ // unmodified — a second fold here would be a second chance for the printed
805
+ // verdict and the process exit to disagree.
806
+ return result.exitCode;
803
807
  }
804
808
 
805
809
  /**
@@ -818,32 +822,7 @@ async function runGraph(options, { cwd, env }) {
818
822
 
819
823
  let result;
820
824
  try {
821
- const commandContext = resolveCommandContext(
822
- { cwd },
823
- { readGraph: env.readGraph, listFiles: env.listFiles },
824
- );
825
-
826
- // Load the boundary config so the snapshot carries a policy fingerprint
827
- // that `diff` can use to warn when the policy changed between runs. Without
828
- // a config, the snapshot carries no policy identity — the consumer did not
829
- // provide one (`./src/commands/graph.mjs` makes that field conditional). A
830
- // profile-selected workspace's `boundaryConfig` names a profile rather than
831
- // a file, resolved the same way `check` resolves it (`resolvePolicy`), so
832
- // the fingerprint moves with a profile edit the same way it already does
833
- // with a file or inline-object edit.
834
- //
835
- // `graph` describes the project graph, not the boundary law — it reads no
836
- // constraint row and judges nothing against one — so a workspace that has
837
- // not written a law yet must not be refused here. Every arm of that
838
- // decision — what is skipped is the load of a file that is NOT THERE, the
839
- // `boundaryConfigDeclared` bit that keeps the guard to the un-overridden
840
- // default, and why a law someone named and then deleted stays loud — lives
841
- // in `resolveDescribedPolicy` (`./src/commands/policy.mjs`) rather than
842
- // here, so the descriptive commands and the MCP face that serves them
843
- // cannot disagree about what "no law declared" means.
844
- const { config } = await resolveDescribedPolicy(options, commandContext, cwd);
845
-
846
- result = graphCommand(commandContext, { config });
825
+ result = await graph(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
847
826
  } catch (error) {
848
827
  const usageError = error instanceof UsageError;
849
828
  env.err(String(error?.message ?? error));
@@ -890,26 +869,9 @@ async function runDiff(options, { cwd, env }) {
890
869
  return EXIT.usage;
891
870
  }
892
871
 
893
- const baselinePath = isAbsolute(options.paths[0])
894
- ? options.paths[0]
895
- : resolve(cwd, options.paths[0]);
896
-
897
872
  let result;
898
873
  try {
899
- const commandContext = resolveCommandContext(
900
- { cwd },
901
- { readGraph: env.readGraph, listFiles: env.listFiles },
902
- );
903
-
904
- // Load the boundary config when --config is given or when the workspace
905
- // declares one, so rule-impact analysis is computed. Without a config,
906
- // the diff reports only structural changes — same as before. A
907
- // profile-selected workspace resolves the same way `check` does
908
- // (`resolvePolicy`), so a policy edit under an unchanged profile NAME is
909
- // still visible as a fingerprint change here.
910
- const { config } = await resolvePolicy(options, commandContext, cwd);
911
-
912
- result = diffCommand(baselinePath, commandContext, { config });
874
+ result = await diff(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
913
875
  } catch (error) {
914
876
  const usageError = error instanceof UsageError;
915
877
  env.err(String(error?.message ?? error));
@@ -974,18 +936,12 @@ async function runDelta(options, { cwd, env }) {
974
936
 
975
937
  let result;
976
938
  try {
977
- const commandContext = resolveCommandContext(
978
- { cwd },
979
- { readGraph: env.readGraph, listFiles: env.listFiles },
980
- );
981
-
982
- // Both modes need the boundary law: capture fingerprints it, compare
983
- // re-judges both sides under it — the same ladder every judging command
984
- // resolves through (`resolvePolicy`).
985
- const { config } = await resolvePolicy(options, commandContext, cwd);
986
-
987
939
  if (options.capture) {
988
- const { text } = captureDelta(commandContext, { config });
940
+ const { text } = await captureBaseline(options, {
941
+ cwd,
942
+ readGraph: env.readGraph,
943
+ listFiles: env.listFiles,
944
+ });
989
945
  if (options.output) {
990
946
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring
991
947
  if (!writeOutputReport(options.output, text, env, cwd, options.config)) return EXIT.error;
@@ -997,13 +953,7 @@ async function runDelta(options, { cwd, env }) {
997
953
  return EXIT.ok;
998
954
  }
999
955
 
1000
- const baselinePath = isAbsolute(options.paths[0])
1001
- ? resolve(options.paths[0])
1002
- : resolve(cwd, options.paths[0]);
1003
- result = await deltaCommand(baselinePath, commandContext, {
1004
- config,
1005
- eventOut: options.eventOut,
1006
- });
956
+ result = await delta(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1007
957
  } catch (error) {
1008
958
  const usageError = error instanceof UsageError;
1009
959
  env.err(String(error?.message ?? error));
@@ -1053,13 +1003,10 @@ async function runDelta(options, { cwd, env }) {
1053
1003
  );
1054
1004
  }
1055
1005
 
1056
- // The exit fold `deltaCommand` computed: a non-waived introduced violation
1057
- // is a finding, an unclassifiable item is a no-verdict, anything else is
1058
- // clean mapped here the same way `fitness`'s status is.
1059
- return (
1060
- { ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
1061
- EXIT.error
1062
- );
1006
+ // The exit `deltaCommand` computed: a non-waived introduced violation is a
1007
+ // finding, an unclassifiable item is a no-verdict, anything else is clean —
1008
+ // the command's return carries the pair, the process returns it as is.
1009
+ return result.exitCode;
1063
1010
  }
1064
1011
 
1065
1012
  /**
@@ -1081,36 +1028,7 @@ async function runDrift(options, { cwd, env }) {
1081
1028
 
1082
1029
  let result;
1083
1030
  try {
1084
- const commandContext = resolveCommandContext(
1085
- { cwd },
1086
- { readGraph: env.readGraph, listFiles: env.listFiles },
1087
- );
1088
- // The loaded policy — profile-aware the same way `check` is
1089
- // (`resolvePolicy`), `null` when the workspace declares none. Drift reads
1090
- // the intent's rows, and the fitness half of a row's `decisionRef`
1091
- // resolves against the ids THIS policy declares (F04), so the same policy
1092
- // that made the boundary law answerable to the model must answer here.
1093
- // `drift` has no `--config` (`DRIFT_FLAG_HELP`), so `config` is always the
1094
- // workspace's own default — resolvePolicy reads `options.config` as the
1095
- // override, hence `null` here, which selects the workspace's configured
1096
- // boundary law (or a profile, when one is registered).
1097
- //
1098
- // The failure is DEFERRED rather than thrown here. `drift`'s only reader of
1099
- // this policy is the non-verdict decisionRef axis, and only for rows that
1100
- // carry one, so a workspace with an intent and no boundary config was
1101
- // exiting 3 over a law drift would never have opened — a fifth refusal
1102
- // neither `docs/usage/drift.md` nor `reconcile`, which makes the same four,
1103
- // ever had. `driftCommand` rethrows it, unchanged, at the one site that
1104
- // reads the policy, so every workspace whose intent cites anything keeps the
1105
- // exact exit-3 it had.
1106
- let config = null;
1107
- let configError = null;
1108
- try {
1109
- ({ config } = await resolvePolicy({ ...options, config: null }, commandContext, cwd));
1110
- } catch (error) {
1111
- configError = /** @type {Error} */ (error);
1112
- }
1113
- result = await driftCommand(commandContext, { config, configError });
1031
+ result = await drift(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1114
1032
  } catch (error) {
1115
1033
  const usageError = error instanceof UsageError;
1116
1034
  env.err(String(error?.message ?? error));
@@ -1154,7 +1072,9 @@ async function runDrift(options, { cwd, env }) {
1154
1072
  * Provenance reads no graph and judges nothing — it describes where the run's
1155
1073
  * facts came from and which governance rows carry an origin. It is
1156
1074
  * fail-closed the way every descriptive command is: a malformed intent or
1157
- * boundary config throws out of `provenanceCommand` → exit 3, so "rows
1075
+ * boundary config throws out of `provenanceCommand` → exit 3, and so does
1076
+ * a law the shared ladder refuses (`./src/commands/policy.mjs` — e.g. a
1077
+ * `coverage` key in a native workspace's `boundaryConfig` file), so "rows
1158
1078
  * unlisted" never reads as "rows attested".
1159
1079
  *
1160
1080
  * @param {{format: string, output: string|null, paths: string[]}} options
@@ -1293,55 +1213,15 @@ async function runChange(options, { cwd, env }) {
1293
1213
  return EXIT.usage;
1294
1214
  }
1295
1215
 
1296
- const baselinePath = isAbsolute(options.paths[0])
1297
- ? options.paths[0]
1298
- : resolve(cwd, options.paths[0]);
1299
- const intentPath = isAbsolute(options.intent) ? options.intent : resolve(cwd, options.intent);
1300
-
1301
- // A self-footgun guard, the same shape `history`'s holds: writing the
1302
- // reconciliation report over the very manifest this run just read would
1303
- // destroy the declaration it verified, with the loss surfacing only later —
1304
- // the first time someone tries to re-run the verification.
1305
- if (options.output) {
1306
- const outputAbs = isAbsolute(options.output)
1307
- ? resolve(options.output)
1308
- : resolve(cwd, options.output);
1309
- if (outputAbs === intentPath) {
1310
- env.err(
1311
- `archkeep: --output '${options.output}' resolves to the change-intent manifest itself — ` +
1312
- `overwriting the declaration with its own reconciliation report would destroy it. ` +
1313
- `Write the report somewhere else.`,
1314
- );
1315
- return EXIT.usage;
1316
- }
1216
+ const refusal = changeOutputRefusal(options, cwd);
1217
+ if (refusal) {
1218
+ env.err(refusal);
1219
+ return EXIT.usage;
1317
1220
  }
1318
1221
 
1319
1222
  let result;
1320
1223
  try {
1321
- const commandContext = resolveCommandContext(
1322
- { cwd },
1323
- { readGraph: env.readGraph, listFiles: env.listFiles },
1324
- );
1325
-
1326
- // Declared constraints are judged under whichever law THIS run resolves,
1327
- // and the envelope records that law's fingerprint beside the baseline's —
1328
- // the same loading every judging command does (`resolvePolicy`),
1329
- // profile-aware the same way `check` is.
1330
- const { config } = await resolvePolicy(options, commandContext, cwd);
1331
-
1332
- // `--event-out` names the reconcile event store directory, resolved from
1333
- // cwd like the other path flags; `undefined` when absent, so a run
1334
- // without the flag writes no event and stays byte-identical.
1335
- const eventOut =
1336
- typeof options.eventOut === "string" && options.eventOut !== ""
1337
- ? isAbsolute(options.eventOut)
1338
- ? options.eventOut
1339
- : resolve(cwd, options.eventOut)
1340
- : undefined;
1341
- result = await changeCommand(baselinePath, intentPath, commandContext, {
1342
- config,
1343
- ...(eventOut === undefined ? {} : { eventOut }),
1344
- });
1224
+ result = await change(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1345
1225
  } catch (error) {
1346
1226
  const usageError = error instanceof UsageError;
1347
1227
  env.err(String(error?.message ?? error));
@@ -1371,12 +1251,9 @@ async function runChange(options, { cwd, env }) {
1371
1251
  env.out(report);
1372
1252
  }
1373
1253
 
1374
- // The verdict fold `changeCommand` computed, mapped here the way `delta`'s
1375
- // and `fitness`' are.
1376
- return (
1377
- { ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
1378
- EXIT.error
1379
- );
1254
+ // The verdict fold `changeCommand` computed, returned as the command
1255
+ // carries it.
1256
+ return result.exitCode;
1380
1257
  }
1381
1258
 
1382
1259
  /**
@@ -1400,25 +1277,7 @@ async function runWaivers(options, { cwd, env }) {
1400
1277
 
1401
1278
  let result;
1402
1279
  try {
1403
- const commandContext = resolveCommandContext(
1404
- { cwd },
1405
- { readGraph: env.readGraph, listFiles: env.listFiles },
1406
- );
1407
-
1408
- // The waivers surface is part of the run's boundary law, so the law is
1409
- // loaded the same way `check` loads it (`resolvePolicy`) and `--config`
1410
- // wins the same way — resolved against the working directory, never
1411
- // against this tool's own location, and a `profiles` registry resolves
1412
- // `--config`/`boundaryConfig` as a profile NAME the same way `check`
1413
- // does. A malformed law throws here, exit 3, exactly as in `check`.
1414
- const { config, source } = await resolvePolicy(options, commandContext, cwd);
1415
-
1416
- // `source` rides along for one job: `waiversCommand` subtracts the law's
1417
- // own file from the unowned-file set the `coverage.unowned` acceptances
1418
- // are matched against, exactly as `check` does — the law is not source
1419
- // judged by the law (`../src/commands/context.mjs`'s
1420
- // `unownedGapWithoutRunConfiguration`).
1421
- result = await waiversCommand(commandContext, config, { policySource: source });
1280
+ result = await waivers(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1422
1281
  } catch (error) {
1423
1282
  const usageError = error instanceof UsageError;
1424
1283
  env.err(String(error?.message ?? error));
@@ -1471,8 +1330,9 @@ async function runWaivers(options, { cwd, env }) {
1471
1330
  * sits at the tail of this function, and `../src/commands/fitness.mjs` states
1472
1331
  * the posture — a failing fitness function is a finding, not a print job
1473
1332
  * (D-09). `check` folds the same `fail` into its own exit 1 by presence, so the
1474
- * two faces agree; `check` and `fitness` are the only verbs whose verdict
1475
- * carries that code.
1333
+ * two faces agree; the verbs whose verdict carries that code are `check`,
1334
+ * `fitness`, `delta` and `change`, plus the `rules verify` artifact-integrity
1335
+ * fold (`docs/reference/exit-codes.md` owns the roster).
1476
1336
  *
1477
1337
  * @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
1478
1338
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
@@ -1486,24 +1346,7 @@ async function runFitness(options, { cwd, env }) {
1486
1346
 
1487
1347
  let result;
1488
1348
  try {
1489
- const commandContext = resolveCommandContext(
1490
- { cwd },
1491
- { readGraph: env.readGraph, listFiles: env.listFiles },
1492
- );
1493
-
1494
- // Fitness is part of the run's boundary law, so the law is loaded the same
1495
- // way `check` loads it (`resolvePolicy`) and `--config` wins the same
1496
- // way — resolved against the working directory, never against this
1497
- // tool's own location, profile-aware the same way `check` is. A malformed
1498
- // law throws here, exit 3, exactly as in `check`. A profile's `block` may
1499
- // carry a `fitness` key (`docs/concepts/profiles.md` now names four
1500
- // block keys, fitness among them), so a profile-selected workspace folds
1501
- // the declared functions the same way a file-selected one does — a
1502
- // profile that declares none reaches `fitnessCommand`'s own "declares no
1503
- // fitness functions" refusal below rather than a config-loading failure.
1504
- const { config } = await resolvePolicy(options, commandContext, cwd);
1505
-
1506
- result = await fitnessCommand(commandContext, { config });
1349
+ result = await fitness(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1507
1350
  } catch (error) {
1508
1351
  const usageError = error instanceof UsageError;
1509
1352
  env.err(String(error?.message ?? error));
@@ -1532,13 +1375,10 @@ async function runFitness(options, { cwd, env }) {
1532
1375
  }
1533
1376
 
1534
1377
  // `fitness` is a verdict, not a print job (D-09): `fail` exits 1, `unknown`
1535
- // exits 3, and a run that completed with everything `pass` (or not
1536
- // applicable) exits 0. The command's own status carries the pair, and the
1537
- // JSON envelope asserts it; this mapping is the one process-level exit.
1538
- return (
1539
- { ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
1540
- EXIT.error
1541
- );
1378
+ // exits 3, a run whose every function is `pass` (or not applicable) exits 0.
1379
+ // The command's return carries the pair the envelope asserts; this return
1380
+ // is that verdict, unmodified.
1381
+ return result.exitCode;
1542
1382
  }
1543
1383
 
1544
1384
  /**
@@ -1564,17 +1404,11 @@ async function runImpact(options, { cwd, env }) {
1564
1404
 
1565
1405
  let result;
1566
1406
  try {
1567
- const commandContext = resolveCommandContext(
1568
- { cwd },
1569
- { readGraph: env.readGraph, listFiles: env.listFiles },
1570
- );
1571
-
1572
- // Load the boundary config when --config is given or when the workspace
1573
- // declares one, so constraint-impact analysis is computed — profile-aware
1574
- // the same way `check` is (`resolvePolicy`).
1575
- const { config } = await resolvePolicy(options, commandContext, cwd);
1576
-
1577
- result = impactCommand(projectName, commandContext, config);
1407
+ result = await impact(projectName, options, {
1408
+ cwd,
1409
+ readGraph: env.readGraph,
1410
+ listFiles: env.listFiles,
1411
+ });
1578
1412
  } catch (error) {
1579
1413
  const usageError = error instanceof UsageError;
1580
1414
  env.err(String(error?.message ?? error));
@@ -1647,14 +1481,11 @@ async function runScenario(options, { cwd, env }) {
1647
1481
 
1648
1482
  let result;
1649
1483
  try {
1650
- const commandContext = resolveCommandContext(
1651
- { cwd },
1652
- { readGraph: env.readGraph, listFiles: env.listFiles },
1653
- );
1654
-
1655
- const { config } = await resolvePolicy(options, commandContext, cwd);
1656
-
1657
- result = scenarioCommand(projectName, scenarioJson, commandContext, config);
1484
+ result = await scenario(projectName, scenarioJson, options, {
1485
+ cwd,
1486
+ readGraph: env.readGraph,
1487
+ listFiles: env.listFiles,
1488
+ });
1658
1489
  } catch (error) {
1659
1490
  const usageError = error instanceof UsageError;
1660
1491
  env.err(String(error?.message ?? error));
@@ -1702,22 +1533,9 @@ async function runExplain(options, { cwd, env }) {
1702
1533
  return EXIT.usage;
1703
1534
  }
1704
1535
 
1705
- const site = options.paths[0];
1706
-
1707
1536
  let result;
1708
1537
  try {
1709
- const commandContext = resolveCommandContext(
1710
- { cwd },
1711
- { readGraph: env.readGraph, listFiles: env.listFiles },
1712
- );
1713
-
1714
- // The config's location is a separate fact from the workspace root.
1715
- // Same loading logic as `check` (`resolvePolicy`) — a `--config`
1716
- // overrides the workspace's own `boundaryConfig`, profile-aware the same
1717
- // way `check` is.
1718
- const { config } = await resolvePolicy(options, commandContext, cwd);
1719
-
1720
- result = explainCommand(site, commandContext, config);
1538
+ result = await explain(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1721
1539
  } catch (error) {
1722
1540
  const usageError = error instanceof UsageError;
1723
1541
  env.err(String(error?.message ?? error));
@@ -1768,36 +1586,11 @@ async function runContextCommand(options, { cwd, env }) {
1768
1586
  return EXIT.usage;
1769
1587
  }
1770
1588
 
1771
- const projectName = options.paths[0];
1772
- const scopePaths = options.plan ? options.paths.slice(1) : [];
1773
-
1774
1589
  let result;
1775
1590
  try {
1776
- // The command context is resolved over the WHOLE workspace. Scoping by
1777
- // path is the plan command's decision (which projects the change touches),
1778
- // not the preamble's: the rule verdict and the architecture snapshot must
1779
- // be over the whole tree, and only reporting is narrowed. Passing no paths
1780
- // here keeps the non-plan `context` path byte-for-byte identical to before.
1781
- const commandContext = resolveCommandContext(
1782
- { cwd },
1783
- { readGraph: env.readGraph, listFiles: env.listFiles },
1784
- );
1785
-
1786
- // The config's location is a separate fact from the workspace root.
1787
- // Same loading logic as `check` and `explain` (`resolvePolicy`) — a
1788
- // `--config` overrides the workspace's own `boundaryConfig`,
1789
- // profile-aware the same way `check` is.
1790
- const { config } = await resolvePolicy(options, commandContext, cwd);
1791
-
1792
- const historyDir = options.historyDir
1793
- ? isAbsolute(options.historyDir)
1794
- ? options.historyDir
1795
- : resolve(cwd, options.historyDir)
1796
- : null;
1797
-
1798
1591
  result = options.plan
1799
- ? await planContextCommand(projectName, scopePaths, commandContext, config, historyDir)
1800
- : contextCommand(projectName, commandContext, config);
1592
+ ? await planContext(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles })
1593
+ : await context(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1801
1594
  } catch (error) {
1802
1595
  const usageError = error instanceof UsageError;
1803
1596
  env.err(String(error?.message ?? error));
@@ -1824,22 +1617,26 @@ async function runContextCommand(options, { cwd, env }) {
1824
1617
  * `adr`'s `run`: reads the ADR registry at the workspace root and renders it.
1825
1618
  *
1826
1619
  * The registry lives in `docs/adr/` in the tree being described, not in this
1827
- * package's own tree, so the root comes from the current working directory
1828
- * the same walking `resolveCommandContext` does, but without the whole
1829
- * project-graph preamble. `adr` never exits 1: a description of what is
1830
- * recorded is never a finding. An unreadable registry (a malformed record, an
1831
- * unreadable file, a bad filename) throws exit 3; an id the user asked
1832
- * about that the registry does not know exit 3, the invariant.
1833
- *
1834
- * The tracked-file list is read the same way every other command reads it —
1835
- * `env.listFiles ?? listTrackedFiles`, so a test can inject a fake the same
1836
- * way `runCheck` and its siblings do — and handed to `adrCommand` so the
1837
- * registry resolves only git-tracked records (`src/governance/adr-registry.mjs`'s
1620
+ * package's own tree, and the preamble it needs the root walked up from
1621
+ * `cwd`, the tracked file list is the one `adrForWorkspace`
1622
+ * (`./src/commands/adr-for-workspace.mjs`) composes, the same composition
1623
+ * the MCP history adapter runs. This driver decides none of it: it words the
1624
+ * no-workspace refusal (`adrForWorkspace` returns the `null` and holds no
1625
+ * message of its own) and maps the command's throws. `adr` never exits 1: a
1626
+ * description of what is recorded is never a finding. An unreadable registry
1627
+ * (a malformed record, an unreadable file, a bad filename) throws exit 3;
1628
+ * an id the user asked about that the registry does not know → exit 3, the
1629
+ * invariant.
1630
+ *
1631
+ * The tracked-file list is injected the way every other command's is —
1632
+ * `env.listFiles`, so a test can drive the read over a fixture tree with no
1633
+ * git — and reaches `adrCommand` through the driver, so the registry
1634
+ * resolves only git-tracked records (`src/governance/adr-registry.mjs`'s
1838
1635
  * header). A `git ls-files` failure here throws the same as any other
1839
1636
  * unreadable registry, mapped to exit 3 below.
1840
1637
  *
1841
1638
  * @param {{format: string, output: string|null, paths: string[]}} options
1842
- * @param {{cwd: string, env: {out: Function, err: Function, listFiles?: typeof listTrackedFiles}}} runContext
1639
+ * @param {{cwd: string, env: {out: Function, err: Function, listFiles?: Function}}} runContext
1843
1640
  * @returns {Promise<number>}
1844
1641
  */
1845
1642
  async function runAdr(options, { cwd, env }) {
@@ -1851,23 +1648,20 @@ async function runAdr(options, { cwd, env }) {
1851
1648
  return EXIT.usage;
1852
1649
  }
1853
1650
 
1854
- const root = resolveWorkspaceRootForUsage(cwd);
1855
- if (root === null) {
1856
- env.err(
1857
- `archkeep: adr needs a workspace root — no nx.json, archkeep.json, or ` +
1858
- `.moon/workspace.yml marker found walking up from ${cwd}`,
1859
- );
1860
- return EXIT.error;
1861
- }
1862
-
1863
1651
  let result;
1864
1652
  try {
1865
- const tracked = (env.listFiles ?? listTrackedFiles)(root);
1866
- result = adrCommand(root, { id: options.paths[0] }, { tracked });
1653
+ result = adrForWorkspace({ cwd }, { id: options.paths[0] }, { listFiles: env.listFiles });
1867
1654
  } catch (error) {
1868
1655
  env.err(String(error?.message ?? error));
1869
1656
  return EXIT.error;
1870
1657
  }
1658
+ if (result === null) {
1659
+ env.err(
1660
+ `archkeep: adr needs a workspace root — no nx.json, archkeep.json, or ` +
1661
+ `.moon/workspace.yml marker found walking up from ${cwd}`,
1662
+ );
1663
+ return EXIT.error;
1664
+ }
1871
1665
 
1872
1666
  const report = options.format === "json" ? result.report.json : result.report.text;
1873
1667
 
@@ -1913,20 +1707,7 @@ async function runDecisions(options, { cwd, env }) {
1913
1707
 
1914
1708
  let result;
1915
1709
  try {
1916
- const commandContext = resolveCommandContext(
1917
- { cwd },
1918
- { readGraph: env.readGraph, listFiles: env.listFiles },
1919
- );
1920
-
1921
- // ONE law for the chain, resolved exactly like `report` — the Fitness leg
1922
- // reads this law's declared gates, so a `--config` override must reach it.
1923
- const { config } = await resolvePolicy(options, commandContext, cwd);
1924
-
1925
- const intent = commandContext.tracked.includes(INTENT_FILE)
1926
- ? await loadIntent(commandContext.root, { tracked: commandContext.tracked })
1927
- : null;
1928
-
1929
- result = decisionsCommand(options.paths[0], commandContext, config, { intent });
1710
+ result = await decisions(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1930
1711
  } catch (error) {
1931
1712
  const usageError = error instanceof UsageError;
1932
1713
  env.err(String(error?.message ?? error));
@@ -2014,7 +1795,12 @@ async function runRules(options, { cwd, env }) {
2014
1795
  env.out(report);
2015
1796
  }
2016
1797
 
2017
- // Exit codes: 0 for ok, 1 for findings (verify only), 3 for no-verdict
1798
+ // Exit codes: `verify`'s return carries the pair its status asserts (0 ok,
1799
+ // 1 findings, 3 could-not-look). The descriptive subcommands (`list`,
1800
+ // `info`, `add`) expose no exit field — their statuses are `ok` or a
1801
+ // no-verdict class only, never findings — so this ladder keeps their exits
1802
+ // exactly as they were.
1803
+ if (subcommand === "verify") return result.exitCode;
2018
1804
  if (result.status === "ok") return EXIT.ok;
2019
1805
  if (result.status === "findings") return EXIT.violations;
2020
1806
  return EXIT.error;
@@ -2040,56 +1826,15 @@ async function runHistory(options, { cwd, env }) {
2040
1826
  return EXIT.usage;
2041
1827
  }
2042
1828
 
2043
- const dir = isAbsolute(options.paths[0])
2044
- ? resolve(options.paths[0])
2045
- : resolve(cwd, options.paths[0]);
2046
-
2047
- // A self-footgun guard: writing the history report back into the very
2048
- // directory `history` reads would poison every later run (the report envelope
2049
- // is a `history` envelope, which `parseBaseline` refuses as a non-`graph`
2050
- // snapshot). Refuse loudly instead of eventually failing on a poisoned dir.
2051
- if (options.output) {
2052
- // `resolve()` on the absolute branch too — not just the raw path — the
2053
- // same normalization `writeOutputReport` applies, so an absolute
2054
- // `--output` carrying a `..` segment that resolves INTO the history
2055
- // directory cannot slip past this guard unnormalized.
2056
- const outputAbs = isAbsolute(options.output)
2057
- ? resolve(options.output)
2058
- : resolve(cwd, options.output);
2059
- if (dirname(outputAbs) === dir) {
2060
- env.err(
2061
- `archkeep: --output '${options.output}' is inside the history directory '${dir}' — ` +
2062
- `writing the report there would be read back as a snapshot on the next run. ` +
2063
- `Write it somewhere else.`,
2064
- );
2065
- return EXIT.usage;
2066
- }
1829
+ const refusal = historyOutputRefusal(options, cwd);
1830
+ if (refusal) {
1831
+ env.err(refusal);
1832
+ return EXIT.usage;
2067
1833
  }
2068
1834
 
2069
1835
  let result;
2070
1836
  try {
2071
- const commandContext = resolveCommandContext(
2072
- { cwd },
2073
- { readGraph: env.readGraph, listFiles: env.listFiles },
2074
- );
2075
-
2076
- // The boundary law's fingerprint when the workspace declares one, so a
2077
- // captured snapshot records the policy it was taken under — the same
2078
- // config loading `graph` uses (`resolvePolicy`, profile-aware the same
2079
- // way `check` is), kept in one place so a capture and a standalone
2080
- // `graph` never disagree about the current policy.
2081
- let fingerprint = null;
2082
- if (options.capture) {
2083
- const { config } = await resolvePolicy(options, commandContext, cwd);
2084
- if (config) {
2085
- fingerprint = computePolicyFingerprint(config);
2086
- }
2087
- }
2088
-
2089
- result = historyCommand(dir, commandContext, {
2090
- capture: options.capture,
2091
- policyFingerprint: fingerprint,
2092
- });
1837
+ result = await history(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
2093
1838
  } catch (error) {
2094
1839
  const usageError = error instanceof UsageError;
2095
1840
  env.err(String(error?.message ?? error));
@@ -2139,22 +1884,10 @@ async function runTrajectory(options, { cwd, env }) {
2139
1884
  ? resolve(options.paths[0])
2140
1885
  : resolve(cwd, options.paths[0]);
2141
1886
 
2142
- // The same self-footgun guard `runHistory` applies: a report written into
2143
- // the directory being read would be read back as a snapshot on the next run
2144
- // (the envelope is not a `graph` snapshot, which `parseBaseline` refuses)
2145
- // poison the record loudly refused rather than quietly planted.
2146
- if (options.output) {
2147
- const outputAbs = isAbsolute(options.output)
2148
- ? resolve(options.output)
2149
- : resolve(cwd, options.output);
2150
- if (dirname(outputAbs) === dir) {
2151
- env.err(
2152
- `archkeep: --output '${options.output}' is inside the history directory '${dir}' — ` +
2153
- `writing the report there would be read back as a snapshot on the next run. ` +
2154
- `Write it somewhere else.`,
2155
- );
2156
- return EXIT.usage;
2157
- }
1887
+ const refusal = trajectoryOutputRefusal(options, cwd);
1888
+ if (refusal) {
1889
+ env.err(refusal);
1890
+ return EXIT.usage;
2158
1891
  }
2159
1892
 
2160
1893
  let result;
@@ -2293,25 +2026,9 @@ async function runDebt(options, { cwd, env }) {
2293
2026
  return EXIT.usage;
2294
2027
  }
2295
2028
 
2296
- const dir = isAbsolute(options.paths[0]) ? options.paths[0] : resolve(cwd, options.paths[0]);
2297
-
2298
2029
  let result;
2299
2030
  try {
2300
- const commandContext = resolveCommandContext(
2301
- { cwd },
2302
- { readGraph: env.readGraph, listFiles: env.listFiles },
2303
- );
2304
-
2305
- // The boundary law the ledger ages waivers against — resolved the same way
2306
- // `graph` and `diff` resolve it (`resolvePolicy`), so a `debt` run and a
2307
- // `check` run never disagree about the current suppressions, and a
2308
- // profile-selected workspace resolves the same way `check` does.
2309
- const { config } = await resolvePolicy(options, commandContext, cwd);
2310
-
2311
- result = await debtCommand(dir, commandContext, {
2312
- config,
2313
- events: options.events,
2314
- });
2031
+ result = await debt(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
2315
2032
  } catch (error) {
2316
2033
  const usageError = error instanceof UsageError;
2317
2034
  env.err(String(error?.message ?? error));
@@ -2402,22 +2119,17 @@ async function runDiscover(options, { cwd, env }) {
2402
2119
  }
2403
2120
 
2404
2121
  if (options.writeIntent) {
2405
- // The one write that can turn a proposal into the law `check` gates on,
2406
- // so it refuses to replace: a file already at the target is a law (or a
2407
- // candidate someone holds), and silently overwriting it with a proposal
2408
- // is the adoption this command must never perform by itself. Move or
2409
- // delete the file first a step a human reviews.
2410
- if (existsSync(options.writeIntent)) {
2411
- env.err(
2412
- `archkeep: ${options.writeIntent} already exists, and a proposal must never ` +
2413
- `silently replace what is there. Move or delete the file first, then run this again.`,
2414
- );
2122
+ // The one write that can turn a proposal into the law `check` gates on
2123
+ // the refusal DECISION is discover's own (`intentWriteRefusal`, beside the
2124
+ // proposal it protects, the way `historyOutputRefusal` sits with history);
2125
+ // this driver keeps the mechanics only.
2126
+ const intentRefusal = intentWriteRefusal(options.writeIntent, { exists: existsSync });
2127
+ if (intentRefusal) {
2128
+ env.err(intentRefusal);
2415
2129
  return EXIT.error;
2416
2130
  }
2417
2131
  try {
2418
- const intentJson = JSON.stringify(proposalToIntent(result.proposal), null, 2) + "\n";
2419
- // `wx` refuses the file materializing between the check above and this
2420
- // write, so the refusal above cannot be raced past.
2132
+ const intentJson = intentJsonFromProposal(result.proposal);
2421
2133
  writeFileSync(options.writeIntent, intentJson, { encoding: "utf-8", flag: "wx" });
2422
2134
  env.err(`archkeep: proposed architecture written to ${options.writeIntent}`);
2423
2135
  env.err(
@@ -2434,8 +2146,6 @@ async function runDiscover(options, { cwd, env }) {
2434
2146
  }
2435
2147
 
2436
2148
  /**
2437
- * `health`'s `run`: resolves the command context, drives `healthCommand`,
2438
- * writes the report where it belongs, and returns the process's exit code.
2439
2149
  * `health`'s `run`: resolves the command context, drives `healthCommand`,
2440
2150
  * writes the report where it belongs, and returns the process's exit code.
2441
2151
  *
@@ -2456,31 +2166,9 @@ async function runHealth(options, { cwd, env }) {
2456
2166
  return EXIT.usage;
2457
2167
  }
2458
2168
 
2459
- const trendDir =
2460
- options.paths.length === 1
2461
- ? isAbsolute(options.paths[0])
2462
- ? options.paths[0]
2463
- : resolve(cwd, options.paths[0])
2464
- : null;
2465
-
2466
2169
  let result;
2467
2170
  try {
2468
- const commandContext = resolveCommandContext(
2469
- { cwd },
2470
- { readGraph: env.readGraph, listFiles: env.listFiles },
2471
- );
2472
-
2473
- // The boundary law and the intent, the same loading every command does
2474
- // (`resolvePolicy`) — a `--config` overrides the workspace's own
2475
- // `boundaryConfig`, profile-aware the same way `check` is, and the
2476
- // intent is the tracked root `architecture-intent.json` (or absent).
2477
- const { config } = await resolvePolicy(options, commandContext, cwd);
2478
-
2479
- const intent = commandContext.tracked.includes(INTENT_FILE)
2480
- ? await loadIntent(commandContext.root, { tracked: commandContext.tracked })
2481
- : null;
2482
-
2483
- result = healthCommand(commandContext, { config, intent, trendDir });
2171
+ result = await health(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
2484
2172
  } catch (error) {
2485
2173
  const usageError = error instanceof UsageError;
2486
2174
  env.err(String(error?.message ?? error));
@@ -2528,47 +2216,20 @@ async function runReport(options, { cwd, env }) {
2528
2216
  return EXIT.usage;
2529
2217
  }
2530
2218
 
2531
- const trendDir =
2532
- options.paths.length === 1
2533
- ? isAbsolute(options.paths[0])
2534
- ? options.paths[0]
2535
- : resolve(cwd, options.paths[0])
2536
- : null;
2537
-
2538
2219
  let result;
2539
2220
  try {
2540
- const commandContext = resolveCommandContext(
2541
- { cwd },
2542
- { readGraph: env.readGraph, listFiles: env.listFiles },
2543
- );
2544
-
2545
- // ONE law for the whole document — resolved exactly the way `check` and
2546
- // `health` resolve theirs, and handed to every surface the report
2547
- // composes, so no two sections can cite different laws.
2548
- const { config, source } = await resolvePolicy(options, commandContext, cwd);
2549
-
2550
- const intent = commandContext.tracked.includes(INTENT_FILE)
2551
- ? await loadIntent(commandContext.root, { tracked: commandContext.tracked })
2552
- : null;
2553
-
2554
- result = await reportCommand(commandContext, {
2555
- config,
2556
- intent,
2557
- trendDir,
2558
- policySource: source,
2559
- });
2221
+ result = await report(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
2560
2222
  } catch (error) {
2561
2223
  const usageError = error instanceof UsageError;
2562
2224
  env.err(String(error?.message ?? error));
2563
2225
  return usageError ? EXIT.usage : EXIT.error;
2564
2226
  }
2565
-
2566
- const report = options.format === "json" ? result.report.json : result.report.text;
2227
+ const rendered = options.format === "json" ? result.report.json : result.report.text;
2567
2228
 
2568
2229
  if (options.output) {
2569
2230
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
2570
2231
  // the mechanism and the threat it closes.
2571
- const reportText = report.endsWith("\n") ? report : `${report}\n`;
2232
+ const reportText = rendered.endsWith("\n") ? rendered : `${rendered}\n`;
2572
2233
  if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
2573
2234
  // The confirmation names the no-verdict case, so a reader who only
2574
2235
  // glances at stderr cannot mistake a written document for an established
@@ -2581,7 +2242,7 @@ async function runReport(options, { cwd, env }) {
2581
2242
  : `NO VERDICT — ${gaps} surface${gaps === 1 ? "" : "s"} could not be inspected`;
2582
2243
  env.err(`archkeep: report complete (${verdict}) → ${options.output}`);
2583
2244
  } else {
2584
- env.out(report);
2245
+ env.out(rendered);
2585
2246
  }
2586
2247
 
2587
2248
  // Descriptive: 0 when every surface reached a verdict, 3 when any evidence