@ecoma-io/archkeep 0.25.0 → 0.26.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 (120) hide show
  1. package/cli.mjs +166 -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 +247 -34
  11. package/src/commands/check-capability.mjs +13 -0
  12. package/src/commands/check.mjs +13 -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 +263 -49
  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 +22 -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 +98 -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 +328 -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 +8 -23
  102. package/src/governance/evolution-event.mjs +15 -0
  103. package/src/governance/evolution-store.mjs +59 -31
  104. package/src/governance/provenance-record.mjs +0 -152
  105. package/src/governance/verdict.mjs +26 -3
  106. package/src/intent/intent-manifest.json +14 -14
  107. package/src/lsp/server.mjs +5 -8
  108. package/src/lsp/workspace-index.mjs +55 -220
  109. package/src/options.mjs +10 -0
  110. package/src/providers/native/discover.mjs +13 -12
  111. package/src/providers/native/model.mjs +8 -3
  112. package/src/providers/nx-static.mjs +231 -0
  113. package/src/report/json.mjs +3 -2
  114. package/src/report/sarif.mjs +8 -3
  115. package/src/rules/edge-constraints.mjs +9 -0
  116. package/src/rules/index.mjs +26 -10
  117. package/src/rules/messages.mjs +64 -14
  118. package/src/rules/specifiers.mjs +21 -20
  119. package/src/tsconfig-paths.mjs +8 -14
  120. 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 (PD-8; `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,69 @@ 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 (PD-18, docs/architecture/refactor/DECISIONS.md).
109
+ // Non-verb helper modules keep their direct imports.
106
110
  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";
111
+ discoverCommand,
112
+ intentJsonFromProposal,
113
+ intentWriteRefusal,
114
+ } from "./src/commands/analyze-capability.mjs";
115
+ import { check, fitness, scenario, sortViolations } from "./src/commands/check-capability.mjs";
116
+ import {
117
+ captureBaseline,
118
+ change,
119
+ changeOutputRefusal,
120
+ delta,
121
+ diff,
122
+ drift,
123
+ evolutionCommand,
124
+ history,
125
+ historyOutputRefusal,
126
+ reconcileCommand,
127
+ trajectoryCommand,
128
+ trajectoryOutputRefusal,
129
+ } from "./src/commands/compare-capability.mjs";
130
+ import { explain } from "./src/commands/explain-capability.mjs";
131
+ import { context, graph, health, impact, planContext } from "./src/commands/inspect-capability.mjs";
132
+ import {
133
+ debt,
134
+ decisions,
135
+ provenanceCommand,
136
+ report,
137
+ waivers,
138
+ } from "./src/commands/govern-capability.mjs";
134
139
  import {
135
140
  rulesAddCommand,
136
141
  rulesInfoCommand,
137
142
  rulesListCommand,
138
143
  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";
144
+ } from "./src/commands/rules-capability.mjs";
145
+ import { nativePolicyOptions } from "./src/commands/policy.mjs";
146
+ import {
147
+ DEFAULT_OPTIONS,
148
+ WORKSPACE_MARKERS,
149
+ markersAt,
150
+ resolveCommandContext,
151
+ } from "./src/commands/context.mjs";
152
+ import { adrForWorkspace } from "./src/commands/adr-for-workspace.mjs";
153
+ import { INTENT_FILE } from "./src/architecture-intent/model.mjs";
142
154
  import { isProgramEntry } from "./src/entry-point.mjs";
143
- import { readPluginOptions } from "./src/options.mjs";
144
- import { EXIT, verdictFor } from "./src/verdict.mjs";
155
+ import { ARCHKEEP_MODEL_FILE, readPluginOptions } from "./src/options.mjs";
156
+ import { EXIT } from "./src/verdict.mjs";
145
157
 
146
- import { ARCHKEEP_MODEL_FILE, loadNativeModel } from "./src/providers/native/model.mjs";
147
- import { findWorkspaceRoot, listTrackedFiles } from "./src/workspace.mjs";
158
+ import { findWorkspaceRoot } from "./src/workspace.mjs";
148
159
 
149
160
  /**
150
161
  * Workspace-relative read from `root`, the same default `createWorkspace`
151
162
  * builds when no reader is injected (`./src/workspace.mjs`) — duplicated
152
163
  * rather than imported for the reason `./src/commands/context.mjs` carries its
153
164
  * 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.
165
+ * `Workspace` exists, to hand `nativePolicyOptions` a reader for
166
+ * `archkeep.json` itself. `check` no longer needs a copy of its own —
167
+ * `resolveCommandContext` owns that read now — which is why this is the only
168
+ * one left in this file.
157
169
  *
158
170
  * @param {string} root
159
171
  * @returns {(path: string) => string|null}
@@ -338,20 +350,7 @@ function optionsForUsage(cwd) {
338
350
  if (root === null) return DEFAULT_OPTIONS;
339
351
  const { hasNx, hasNative } = markersAt(root);
340
352
  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
- };
353
+ return nativePolicyOptions(root, { readFile: readWorkspaceRoot(root) });
355
354
  }
356
355
  return readPluginOptions(root);
357
356
  } catch {
@@ -771,9 +770,9 @@ async function runCheck(options, { cwd, env }) {
771
770
  (result.intentFindings > 0
772
771
  ? `, ${result.intentFindings} architecture-intent finding${result.intentFindings === 1 ? "" : "s"}`
773
772
  : "") +
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.
773
+ // Fitness drives the exit code exactly like every count above it
774
+ // omitting it here is what let a fitness-only failure log "0
775
+ // violations …" beside a non-zero exit.
777
776
  (result.fitnessFail > 0
778
777
  ? `, ${result.fitnessFail} fitness function${result.fitnessFail === 1 ? "" : "s"} failed`
779
778
  : "") +
@@ -781,9 +780,9 @@ async function runCheck(options, { cwd, env }) {
781
780
  ? `, ${result.fitnessUnknown} fitness function${result.fitnessUnknown === 1 ? "" : "s"} undetermined`
782
781
  : "") +
783
782
  // 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.
783
+ // them, so they are named here for the same reason fitness is: a
784
+ // custom-rule-only failure would otherwise log "0 violations …"
785
+ // beside a non-zero exit.
787
786
  (result.customRuleFail > 0
788
787
  ? `, ${result.customRuleFail} custom rule${result.customRuleFail === 1 ? "" : "s"} failed`
789
788
  : "") +
@@ -799,7 +798,11 @@ async function runCheck(options, { cwd, env }) {
799
798
  env.out(result.report);
800
799
  }
801
800
 
802
- return verdictFor(result).exitCode;
801
+ // `check`'s own verdict rides its return (`./src/commands/check.mjs`): the
802
+ // command computes the one exit authority, and the process returns it
803
+ // unmodified — a second fold here would be a second chance for the printed
804
+ // verdict and the process exit to disagree.
805
+ return result.exitCode;
803
806
  }
804
807
 
805
808
  /**
@@ -818,32 +821,7 @@ async function runGraph(options, { cwd, env }) {
818
821
 
819
822
  let result;
820
823
  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 });
824
+ result = await graph(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
847
825
  } catch (error) {
848
826
  const usageError = error instanceof UsageError;
849
827
  env.err(String(error?.message ?? error));
@@ -890,26 +868,9 @@ async function runDiff(options, { cwd, env }) {
890
868
  return EXIT.usage;
891
869
  }
892
870
 
893
- const baselinePath = isAbsolute(options.paths[0])
894
- ? options.paths[0]
895
- : resolve(cwd, options.paths[0]);
896
-
897
871
  let result;
898
872
  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 });
873
+ result = await diff(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
913
874
  } catch (error) {
914
875
  const usageError = error instanceof UsageError;
915
876
  env.err(String(error?.message ?? error));
@@ -974,18 +935,12 @@ async function runDelta(options, { cwd, env }) {
974
935
 
975
936
  let result;
976
937
  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
938
  if (options.capture) {
988
- const { text } = captureDelta(commandContext, { config });
939
+ const { text } = await captureBaseline(options, {
940
+ cwd,
941
+ readGraph: env.readGraph,
942
+ listFiles: env.listFiles,
943
+ });
989
944
  if (options.output) {
990
945
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring
991
946
  if (!writeOutputReport(options.output, text, env, cwd, options.config)) return EXIT.error;
@@ -997,13 +952,7 @@ async function runDelta(options, { cwd, env }) {
997
952
  return EXIT.ok;
998
953
  }
999
954
 
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
- });
955
+ result = await delta(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1007
956
  } catch (error) {
1008
957
  const usageError = error instanceof UsageError;
1009
958
  env.err(String(error?.message ?? error));
@@ -1053,13 +1002,10 @@ async function runDelta(options, { cwd, env }) {
1053
1002
  );
1054
1003
  }
1055
1004
 
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
- );
1005
+ // The exit `deltaCommand` computed: a non-waived introduced violation is a
1006
+ // finding, an unclassifiable item is a no-verdict, anything else is clean —
1007
+ // the command's return carries the pair, the process returns it as is.
1008
+ return result.exitCode;
1063
1009
  }
1064
1010
 
1065
1011
  /**
@@ -1081,36 +1027,7 @@ async function runDrift(options, { cwd, env }) {
1081
1027
 
1082
1028
  let result;
1083
1029
  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 });
1030
+ result = await drift(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1114
1031
  } catch (error) {
1115
1032
  const usageError = error instanceof UsageError;
1116
1033
  env.err(String(error?.message ?? error));
@@ -1154,7 +1071,9 @@ async function runDrift(options, { cwd, env }) {
1154
1071
  * Provenance reads no graph and judges nothing — it describes where the run's
1155
1072
  * facts came from and which governance rows carry an origin. It is
1156
1073
  * fail-closed the way every descriptive command is: a malformed intent or
1157
- * boundary config throws out of `provenanceCommand` → exit 3, so "rows
1074
+ * boundary config throws out of `provenanceCommand` → exit 3, and so does
1075
+ * a law the shared ladder refuses (`./src/commands/policy.mjs` — e.g. a
1076
+ * `coverage` key in a native workspace's `boundaryConfig` file), so "rows
1158
1077
  * unlisted" never reads as "rows attested".
1159
1078
  *
1160
1079
  * @param {{format: string, output: string|null, paths: string[]}} options
@@ -1293,55 +1212,15 @@ async function runChange(options, { cwd, env }) {
1293
1212
  return EXIT.usage;
1294
1213
  }
1295
1214
 
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
- }
1215
+ const refusal = changeOutputRefusal(options, cwd);
1216
+ if (refusal) {
1217
+ env.err(refusal);
1218
+ return EXIT.usage;
1317
1219
  }
1318
1220
 
1319
1221
  let result;
1320
1222
  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
- });
1223
+ result = await change(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1345
1224
  } catch (error) {
1346
1225
  const usageError = error instanceof UsageError;
1347
1226
  env.err(String(error?.message ?? error));
@@ -1371,12 +1250,9 @@ async function runChange(options, { cwd, env }) {
1371
1250
  env.out(report);
1372
1251
  }
1373
1252
 
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
- );
1253
+ // The verdict fold `changeCommand` computed, returned as the command
1254
+ // carries it.
1255
+ return result.exitCode;
1380
1256
  }
1381
1257
 
1382
1258
  /**
@@ -1400,25 +1276,7 @@ async function runWaivers(options, { cwd, env }) {
1400
1276
 
1401
1277
  let result;
1402
1278
  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 });
1279
+ result = await waivers(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1422
1280
  } catch (error) {
1423
1281
  const usageError = error instanceof UsageError;
1424
1282
  env.err(String(error?.message ?? error));
@@ -1471,8 +1329,9 @@ async function runWaivers(options, { cwd, env }) {
1471
1329
  * sits at the tail of this function, and `../src/commands/fitness.mjs` states
1472
1330
  * the posture — a failing fitness function is a finding, not a print job
1473
1331
  * (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.
1332
+ * two faces agree; the verbs whose verdict carries that code are `check`,
1333
+ * `fitness`, `delta` and `change`, plus the `rules verify` artifact-integrity
1334
+ * fold (PD-8; `docs/reference/exit-codes.md` owns the roster).
1476
1335
  *
1477
1336
  * @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
1478
1337
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
@@ -1486,24 +1345,7 @@ async function runFitness(options, { cwd, env }) {
1486
1345
 
1487
1346
  let result;
1488
1347
  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 });
1348
+ result = await fitness(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1507
1349
  } catch (error) {
1508
1350
  const usageError = error instanceof UsageError;
1509
1351
  env.err(String(error?.message ?? error));
@@ -1532,13 +1374,10 @@ async function runFitness(options, { cwd, env }) {
1532
1374
  }
1533
1375
 
1534
1376
  // `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
- );
1377
+ // exits 3, a run whose every function is `pass` (or not applicable) exits 0.
1378
+ // The command's return carries the pair the envelope asserts; this return
1379
+ // is that verdict, unmodified.
1380
+ return result.exitCode;
1542
1381
  }
1543
1382
 
1544
1383
  /**
@@ -1564,17 +1403,11 @@ async function runImpact(options, { cwd, env }) {
1564
1403
 
1565
1404
  let result;
1566
1405
  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);
1406
+ result = await impact(projectName, options, {
1407
+ cwd,
1408
+ readGraph: env.readGraph,
1409
+ listFiles: env.listFiles,
1410
+ });
1578
1411
  } catch (error) {
1579
1412
  const usageError = error instanceof UsageError;
1580
1413
  env.err(String(error?.message ?? error));
@@ -1647,14 +1480,11 @@ async function runScenario(options, { cwd, env }) {
1647
1480
 
1648
1481
  let result;
1649
1482
  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);
1483
+ result = await scenario(projectName, scenarioJson, options, {
1484
+ cwd,
1485
+ readGraph: env.readGraph,
1486
+ listFiles: env.listFiles,
1487
+ });
1658
1488
  } catch (error) {
1659
1489
  const usageError = error instanceof UsageError;
1660
1490
  env.err(String(error?.message ?? error));
@@ -1702,22 +1532,9 @@ async function runExplain(options, { cwd, env }) {
1702
1532
  return EXIT.usage;
1703
1533
  }
1704
1534
 
1705
- const site = options.paths[0];
1706
-
1707
1535
  let result;
1708
1536
  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);
1537
+ result = await explain(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1721
1538
  } catch (error) {
1722
1539
  const usageError = error instanceof UsageError;
1723
1540
  env.err(String(error?.message ?? error));
@@ -1768,36 +1585,11 @@ async function runContextCommand(options, { cwd, env }) {
1768
1585
  return EXIT.usage;
1769
1586
  }
1770
1587
 
1771
- const projectName = options.paths[0];
1772
- const scopePaths = options.plan ? options.paths.slice(1) : [];
1773
-
1774
1588
  let result;
1775
1589
  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
1590
  result = options.plan
1799
- ? await planContextCommand(projectName, scopePaths, commandContext, config, historyDir)
1800
- : contextCommand(projectName, commandContext, config);
1591
+ ? await planContext(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles })
1592
+ : await context(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1801
1593
  } catch (error) {
1802
1594
  const usageError = error instanceof UsageError;
1803
1595
  env.err(String(error?.message ?? error));
@@ -1824,22 +1616,26 @@ async function runContextCommand(options, { cwd, env }) {
1824
1616
  * `adr`'s `run`: reads the ADR registry at the workspace root and renders it.
1825
1617
  *
1826
1618
  * 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
1619
+ * package's own tree, and the preamble it needs the root walked up from
1620
+ * `cwd`, the tracked file list is the one `adrForWorkspace`
1621
+ * (`./src/commands/adr-for-workspace.mjs`) composes, the same composition
1622
+ * the MCP history adapter runs. This driver decides none of it: it words the
1623
+ * no-workspace refusal (`adrForWorkspace` returns the `null` and holds no
1624
+ * message of its own) and maps the command's throws. `adr` never exits 1: a
1625
+ * description of what is recorded is never a finding. An unreadable registry
1626
+ * (a malformed record, an unreadable file, a bad filename) throws exit 3;
1627
+ * an id the user asked about that the registry does not know → exit 3, the
1628
+ * invariant.
1629
+ *
1630
+ * The tracked-file list is injected the way every other command's is —
1631
+ * `env.listFiles`, so a test can drive the read over a fixture tree with no
1632
+ * git — and reaches `adrCommand` through the driver, so the registry
1633
+ * resolves only git-tracked records (`src/governance/adr-registry.mjs`'s
1838
1634
  * header). A `git ls-files` failure here throws the same as any other
1839
1635
  * unreadable registry, mapped to exit 3 below.
1840
1636
  *
1841
1637
  * @param {{format: string, output: string|null, paths: string[]}} options
1842
- * @param {{cwd: string, env: {out: Function, err: Function, listFiles?: typeof listTrackedFiles}}} runContext
1638
+ * @param {{cwd: string, env: {out: Function, err: Function, listFiles?: Function}}} runContext
1843
1639
  * @returns {Promise<number>}
1844
1640
  */
1845
1641
  async function runAdr(options, { cwd, env }) {
@@ -1851,23 +1647,20 @@ async function runAdr(options, { cwd, env }) {
1851
1647
  return EXIT.usage;
1852
1648
  }
1853
1649
 
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
1650
  let result;
1864
1651
  try {
1865
- const tracked = (env.listFiles ?? listTrackedFiles)(root);
1866
- result = adrCommand(root, { id: options.paths[0] }, { tracked });
1652
+ result = adrForWorkspace({ cwd }, { id: options.paths[0] }, { listFiles: env.listFiles });
1867
1653
  } catch (error) {
1868
1654
  env.err(String(error?.message ?? error));
1869
1655
  return EXIT.error;
1870
1656
  }
1657
+ if (result === null) {
1658
+ env.err(
1659
+ `archkeep: adr needs a workspace root — no nx.json, archkeep.json, or ` +
1660
+ `.moon/workspace.yml marker found walking up from ${cwd}`,
1661
+ );
1662
+ return EXIT.error;
1663
+ }
1871
1664
 
1872
1665
  const report = options.format === "json" ? result.report.json : result.report.text;
1873
1666
 
@@ -1913,20 +1706,7 @@ async function runDecisions(options, { cwd, env }) {
1913
1706
 
1914
1707
  let result;
1915
1708
  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 });
1709
+ result = await decisions(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
1930
1710
  } catch (error) {
1931
1711
  const usageError = error instanceof UsageError;
1932
1712
  env.err(String(error?.message ?? error));
@@ -2014,7 +1794,12 @@ async function runRules(options, { cwd, env }) {
2014
1794
  env.out(report);
2015
1795
  }
2016
1796
 
2017
- // Exit codes: 0 for ok, 1 for findings (verify only), 3 for no-verdict
1797
+ // Exit codes: `verify`'s return carries the pair its status asserts (0 ok,
1798
+ // 1 findings, 3 could-not-look). The descriptive subcommands (`list`,
1799
+ // `info`, `add`) expose no exit field — their statuses are `ok` or a
1800
+ // no-verdict class only, never findings — so this ladder keeps their exits
1801
+ // exactly as they were.
1802
+ if (subcommand === "verify") return result.exitCode;
2018
1803
  if (result.status === "ok") return EXIT.ok;
2019
1804
  if (result.status === "findings") return EXIT.violations;
2020
1805
  return EXIT.error;
@@ -2040,56 +1825,15 @@ async function runHistory(options, { cwd, env }) {
2040
1825
  return EXIT.usage;
2041
1826
  }
2042
1827
 
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
- }
1828
+ const refusal = historyOutputRefusal(options, cwd);
1829
+ if (refusal) {
1830
+ env.err(refusal);
1831
+ return EXIT.usage;
2067
1832
  }
2068
1833
 
2069
1834
  let result;
2070
1835
  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
- });
1836
+ result = await history(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
2093
1837
  } catch (error) {
2094
1838
  const usageError = error instanceof UsageError;
2095
1839
  env.err(String(error?.message ?? error));
@@ -2139,22 +1883,10 @@ async function runTrajectory(options, { cwd, env }) {
2139
1883
  ? resolve(options.paths[0])
2140
1884
  : resolve(cwd, options.paths[0]);
2141
1885
 
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
- }
1886
+ const refusal = trajectoryOutputRefusal(options, cwd);
1887
+ if (refusal) {
1888
+ env.err(refusal);
1889
+ return EXIT.usage;
2158
1890
  }
2159
1891
 
2160
1892
  let result;
@@ -2293,25 +2025,9 @@ async function runDebt(options, { cwd, env }) {
2293
2025
  return EXIT.usage;
2294
2026
  }
2295
2027
 
2296
- const dir = isAbsolute(options.paths[0]) ? options.paths[0] : resolve(cwd, options.paths[0]);
2297
-
2298
2028
  let result;
2299
2029
  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
- });
2030
+ result = await debt(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
2315
2031
  } catch (error) {
2316
2032
  const usageError = error instanceof UsageError;
2317
2033
  env.err(String(error?.message ?? error));
@@ -2402,22 +2118,17 @@ async function runDiscover(options, { cwd, env }) {
2402
2118
  }
2403
2119
 
2404
2120
  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
- );
2121
+ // The one write that can turn a proposal into the law `check` gates on
2122
+ // the refusal DECISION is discover's own (`intentWriteRefusal`, beside the
2123
+ // proposal it protects, the way `historyOutputRefusal` sits with history);
2124
+ // this driver keeps the mechanics only.
2125
+ const intentRefusal = intentWriteRefusal(options.writeIntent, { exists: existsSync });
2126
+ if (intentRefusal) {
2127
+ env.err(intentRefusal);
2415
2128
  return EXIT.error;
2416
2129
  }
2417
2130
  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.
2131
+ const intentJson = intentJsonFromProposal(result.proposal);
2421
2132
  writeFileSync(options.writeIntent, intentJson, { encoding: "utf-8", flag: "wx" });
2422
2133
  env.err(`archkeep: proposed architecture written to ${options.writeIntent}`);
2423
2134
  env.err(
@@ -2434,8 +2145,6 @@ async function runDiscover(options, { cwd, env }) {
2434
2145
  }
2435
2146
 
2436
2147
  /**
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
2148
  * `health`'s `run`: resolves the command context, drives `healthCommand`,
2440
2149
  * writes the report where it belongs, and returns the process's exit code.
2441
2150
  *
@@ -2456,31 +2165,9 @@ async function runHealth(options, { cwd, env }) {
2456
2165
  return EXIT.usage;
2457
2166
  }
2458
2167
 
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
2168
  let result;
2467
2169
  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 });
2170
+ result = await health(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
2484
2171
  } catch (error) {
2485
2172
  const usageError = error instanceof UsageError;
2486
2173
  env.err(String(error?.message ?? error));
@@ -2528,47 +2215,20 @@ async function runReport(options, { cwd, env }) {
2528
2215
  return EXIT.usage;
2529
2216
  }
2530
2217
 
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
2218
  let result;
2539
2219
  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
- });
2220
+ result = await report(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
2560
2221
  } catch (error) {
2561
2222
  const usageError = error instanceof UsageError;
2562
2223
  env.err(String(error?.message ?? error));
2563
2224
  return usageError ? EXIT.usage : EXIT.error;
2564
2225
  }
2565
-
2566
- const report = options.format === "json" ? result.report.json : result.report.text;
2226
+ const rendered = options.format === "json" ? result.report.json : result.report.text;
2567
2227
 
2568
2228
  if (options.output) {
2569
2229
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
2570
2230
  // the mechanism and the threat it closes.
2571
- const reportText = report.endsWith("\n") ? report : `${report}\n`;
2231
+ const reportText = rendered.endsWith("\n") ? rendered : `${rendered}\n`;
2572
2232
  if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
2573
2233
  // The confirmation names the no-verdict case, so a reader who only
2574
2234
  // glances at stderr cannot mistake a written document for an established
@@ -2581,7 +2241,7 @@ async function runReport(options, { cwd, env }) {
2581
2241
  : `NO VERDICT — ${gaps} surface${gaps === 1 ? "" : "s"} could not be inspected`;
2582
2242
  env.err(`archkeep: report complete (${verdict}) → ${options.output}`);
2583
2243
  } else {
2584
- env.out(report);
2244
+ env.out(rendered);
2585
2245
  }
2586
2246
 
2587
2247
  // Descriptive: 0 when every surface reached a verdict, 3 when any evidence