@ecoma-io/archkeep 0.14.0 → 0.15.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.
package/README.md CHANGED
@@ -191,7 +191,10 @@ a file it cannot edit ([overview.md](https://github.com/ecoma-io/archkeep/blob/m
191
191
  ([nx.md](https://github.com/ecoma-io/archkeep/blob/main/docs/integrations/nx.md) ·
192
192
  [moon.md](https://github.com/ecoma-io/archkeep/blob/main/docs/integrations/moon.md)).
193
193
  - **Agents** — Claude Code, Codex and opencode run the `arch-*` skills, which
194
- are host-independent ([supported-hosts.md](https://github.com/ecoma-io/archkeep/blob/main/docs/skills/supported-hosts.md)).
194
+ are host-independent ([supported-hosts.md](https://github.com/ecoma-io/archkeep/blob/main/docs/skills/supported-hosts.md));
195
+ agents whose host speaks MCP use `@ecoma-io/archkeep-mcp`, which runs this
196
+ package's own command layer in-process through the `./commands` subpath
197
+ ([mcp.md](https://github.com/ecoma-io/archkeep/blob/main/docs/integrations/mcp.md)).
195
198
 
196
199
  ## Install and quick start
197
200
 
@@ -219,8 +222,11 @@ Exit codes: 0 clean — and every selected file was analyzed; 1 findings;
219
222
 
220
223
  Ten minutes end to end, most of it spent deciding what your tags mean:
221
224
  [**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
222
- `history`, `drift`, `impact`, `explain`, `context` and the rest of the
223
- eighteen-command surface are in the [CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
225
+ Ten minutes end to end, most of it spent deciding what your tags mean:
226
+ [**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
227
+ `history`, `trajectory`, `evolution`, `drift`, `impact`, `explain`,
228
+ `context` and the rest of the 21-command surface are in the
229
+ [CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
224
230
 
225
231
  ## Documentation map
226
232
 
package/cli.mjs CHANGED
@@ -51,15 +51,19 @@
51
51
  * tree is dirty" from "you typed it wrong" from "the checker itself broke":
52
52
  * 0 no violations, and every selected file was analyzed
53
53
  * 1 findings — boundary violations, go.work drift, dead tsconfig path
54
- * aliases, or architecture-intent findings. `check` is the only command
55
- * that can produce this exit code every other verb this table might grow
54
+ * aliases, architecture-intent findings, a non-waived violation `delta`
55
+ * classifies as introduced, or a change-intent reconciliation that found
56
+ * undeclared material changes, unfulfilled declarations, or a failed
57
+ * declared constraint. `check`, `fitness`, `delta` and `change` are the
58
+ * verbs whose verdicts carry this code — every other verb in this table
56
59
  * only ever reads.
57
60
  * 2 usage error — unknown command, unknown flag, missing argument, path
58
61
  * outside the tree
59
62
  * 3 no verdict — no workspace, malformed config, the graph provider or git
60
63
  * failed, a selected file could not be analyzed, an architecture-intent
61
- * boundary matched no observed project, or a `boundarySuppressions` row
62
- * accepts nothing this run judged. Distinct from
64
+ * boundary matched no observed project, a `boundarySuppressions` row
65
+ * accepts nothing this run judged, or a change intent could not be
66
+ * verified against its declared base. Distinct from
63
67
  * 1 on purpose: a checker that could not look must never be mistaken for
64
68
  * one that looked and found nothing.
65
69
  *
@@ -93,7 +97,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
93
97
  import { containmentViolation } from "./src/containment.mjs";
94
98
  import { UsageError } from "./src/errors.mjs";
95
99
  import { check, sortViolations } from "./src/commands/check.mjs";
96
- import { hasProfiles, resolvePolicy } from "./src/commands/policy.mjs";
100
+ import { resolveDescribedPolicy, resolvePolicy } from "./src/commands/policy.mjs";
97
101
  import {
98
102
  DEFAULT_OPTIONS,
99
103
  WORKSPACE_MARKERS,
@@ -109,8 +113,11 @@ import { discoverCommand } from "./src/commands/discover.mjs";
109
113
  import { driftCommand } from "./src/commands/drift.mjs";
110
114
  import { fitnessCommand } from "./src/commands/fitness.mjs";
111
115
  import { reconcileCommand } from "./src/commands/reconcile.mjs";
116
+ import { changeCommand } from "./src/commands/change.mjs";
112
117
  import { computePolicyFingerprint, graphCommand } from "./src/commands/graph.mjs";
113
118
  import { historyCommand } from "./src/commands/history.mjs";
119
+ import { trajectoryCommand } from "./src/commands/trajectory.mjs";
120
+ import { evolutionCommand } from "./src/commands/evolution.mjs";
114
121
  import { healthCommand } from "./src/commands/health.mjs";
115
122
  import { reportCommand } from "./src/commands/report.mjs";
116
123
  import { debtCommand } from "./src/commands/debt.mjs";
@@ -170,6 +177,16 @@ const CHECK_FORMATS = Object.freeze(["text", "sarif", "json"]);
170
177
  */
171
178
  const DESCRIBABLE_FORMATS = Object.freeze(["text", "json"]);
172
179
 
180
+ /**
181
+ * Every format `delta --format` accepts. SARIF joins the two descriptive
182
+ * formats because `delta`'s compare mode is a gate that produces findings —
183
+ * the introduced bucket is exactly what a code-scanning upload annotates at
184
+ * head sites — while every other descriptive-family verb stays on
185
+ * `DESCRIBABLE_FORMATS`: none of them produces findings, and SARIF's
186
+ * `results[]` is a findings container.
187
+ */
188
+ const DELTA_FORMATS = Object.freeze(["text", "sarif", "json"]);
189
+
173
190
  /**
174
191
  * Column `usage()`'s Options block aligns flag descriptions to. Matches the
175
192
  * hand-written text this table-driven rendering replaced, so deriving the
@@ -804,54 +821,14 @@ async function runGraph(options, { cwd, env }) {
804
821
  //
805
822
  // `graph` describes the project graph, not the boundary law — it reads no
806
823
  // constraint row and judges nothing against one — so a workspace that has
807
- // not written a law yet must not be refused here. It was, with exit 3: the
808
- // workspace-default `boundaryConfig` is never absent on the Nx and Moon
809
- // paths (`readPluginOptions` falls back to `DEFAULT_OPTIONS`, and Moon
810
- // takes the same default by convention), so that arm of `resolvePolicy`
811
- // fired unconditionally and a missing file became the command's exit code.
812
- // `discover`, the other descriptive verb over the same graph, answered
813
- // fine on the identical tree — and `graph` is what a workspace runs to see
814
- // what Archkeep found, which is what it needs in order to WRITE a first
815
- // policy.
816
- //
817
- // What is skipped is the load of a file that is NOT THERE. A boundary
818
- // config that exists and will not load still fails the run, because an
819
- // absent law and a broken one must not report alike; a `--config`, a
820
- // profile, and an inline `archkeep.json` policy are explicit declarations
821
- // and stay loud. Every command that JUDGES against the law keeps loading
822
- // it unconditionally — making it optional for those would turn a missing
823
- // file into a silent no-law run.
824
- //
825
- // `boundaryConfigDeclared` is what keeps this guard to the un-overridden
826
- // default, and it is load-bearing rather than belt-and-braces. The name
827
- // alone cannot answer it: `commandContext.options.boundaryConfig` is a
828
- // string BOTH when it came from `./src/options.mjs`'s `DEFAULT_OPTIONS`
829
- // and when the consumer WROTE it into `nx.json`'s plugin options or
830
- // `archkeep.json`, and a workspace is free to declare the convention
831
- // filename itself, so comparing against the default would still read a
832
- // deliberate declaration as an assumption. Without the bit, measured on a
833
- // committed native tree whose `archkeep.json` declares `boundaryConfig:
834
- // "policy-we-declared.mjs"` and does not contain that file: `graph` exited
835
- // 0 with a snapshot carrying no `policy` field — byte-identical to a
836
- // workspace that never had a law — where the same tree with that file
837
- // present but unparseable exited 3. A law someone named and then renamed
838
- // or deleted is exactly the case that must stay loud, so the provenance
839
- // survives the options layer instead (`./src/options.mjs`'s
840
- // `resolveOptions`, `./src/providers/native/model.mjs`'s
841
- // `normalizeNativeModel`, and `./src/commands/context.mjs`'s three
842
- // branches carry it; Moon answers `false` because it has no table to
843
- // declare one in).
844
- const workspaceDefault =
845
- !options.config &&
846
- !hasProfiles(commandContext.options) &&
847
- commandContext.options.boundaryConfigDeclared === false &&
848
- typeof commandContext.options.boundaryConfig === "string"
849
- ? resolve(commandContext.root, commandContext.options.boundaryConfig)
850
- : null;
851
- const { config } =
852
- workspaceDefault !== null && !existsSync(workspaceDefault)
853
- ? { config: null }
854
- : await resolvePolicy(options, commandContext, cwd);
824
+ // not written a law yet must not be refused here. Every arm of that
825
+ // decision what is skipped is the load of a file that is NOT THERE, the
826
+ // `boundaryConfigDeclared` bit that keeps the guard to the un-overridden
827
+ // default, and why a law someone named and then deleted stays loud — lives
828
+ // in `resolveDescribedPolicy` (`./src/commands/policy.mjs`) rather than
829
+ // here, so the descriptive commands and the MCP face that serves them
830
+ // cannot disagree about what "no law declared" means.
831
+ const { config } = await resolveDescribedPolicy(options, commandContext, cwd);
855
832
 
856
833
  result = graphCommand(commandContext, { config });
857
834
  } catch (error) {
@@ -1001,14 +978,19 @@ async function runDelta(options, { cwd, env }) {
1001
978
  const baselinePath = isAbsolute(options.paths[0])
1002
979
  ? resolve(options.paths[0])
1003
980
  : resolve(cwd, options.paths[0]);
1004
- result = deltaCommand(baselinePath, commandContext, { config });
981
+ result = await deltaCommand(baselinePath, commandContext, { config });
1005
982
  } catch (error) {
1006
983
  const usageError = error instanceof UsageError;
1007
984
  env.err(String(error?.message ?? error));
1008
985
  return usageError ? EXIT.usage : EXIT.error;
1009
986
  }
1010
987
 
1011
- const report = options.format === "json" ? result.report.json : result.report.text;
988
+ const report =
989
+ options.format === "json"
990
+ ? result.report.json
991
+ : options.format === "sarif"
992
+ ? result.report.sarif
993
+ : result.report.text;
1012
994
 
1013
995
  if (options.output) {
1014
996
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
@@ -1212,6 +1194,108 @@ async function runReconcile(options, { cwd, env }) {
1212
1194
  return EXIT.ok;
1213
1195
  }
1214
1196
 
1197
+ /**
1198
+ * `change`'s `run`: resolves the command context and the boundary law, drives
1199
+ * `changeCommand` over the baseline evidence snapshot and the intent manifest,
1200
+ * writes the report where it belongs, and returns the process's exit code.
1201
+ *
1202
+ * The fourth verb whose verdict carries exit 1, beside `check`, `fitness` and
1203
+ * `delta`: an undeclared material change, an unfulfilled declaration, or a
1204
+ * failed declared constraint is a finding; an unproven base identity or an
1205
+ * undeterminable constraint is a no-verdict. The workspace-law axis the
1206
+ * envelope reports is informational — it never moves this exit code, because
1207
+ * `check` remains the authority on the law.
1208
+ *
1209
+ * @param {{format: string, output: string|null, config: string|null,
1210
+ * intent: string|null, paths: string[]}} options
1211
+ * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function,
1212
+ * listFiles?: Function}}} runContext
1213
+ * @returns {Promise<number>}
1214
+ */
1215
+ async function runChange(options, { cwd, env }) {
1216
+ if (options.paths.length !== 1) {
1217
+ env.err(
1218
+ `archkeep: change takes exactly one positional argument (the baseline evidence snapshot ` +
1219
+ `from 'delta --capture'); got ${options.paths.length}`,
1220
+ );
1221
+ return EXIT.usage;
1222
+ }
1223
+ if (!options.intent) {
1224
+ env.err(
1225
+ "archkeep: change needs '--intent <file>' naming the change-intent manifest — without a " +
1226
+ "declaration there is nothing to reconcile against",
1227
+ );
1228
+ return EXIT.usage;
1229
+ }
1230
+
1231
+ const baselinePath = isAbsolute(options.paths[0])
1232
+ ? options.paths[0]
1233
+ : resolve(cwd, options.paths[0]);
1234
+ const intentPath = isAbsolute(options.intent) ? options.intent : resolve(cwd, options.intent);
1235
+
1236
+ // A self-footgun guard, the same shape `history`'s holds: writing the
1237
+ // reconciliation report over the very manifest this run just read would
1238
+ // destroy the declaration it verified, with the loss surfacing only later —
1239
+ // the first time someone tries to re-run the verification.
1240
+ if (options.output) {
1241
+ const outputAbs = isAbsolute(options.output)
1242
+ ? resolve(options.output)
1243
+ : resolve(cwd, options.output);
1244
+ if (outputAbs === intentPath) {
1245
+ env.err(
1246
+ `archkeep: --output '${options.output}' resolves to the change-intent manifest itself — ` +
1247
+ `overwriting the declaration with its own reconciliation report would destroy it. ` +
1248
+ `Write the report somewhere else.`,
1249
+ );
1250
+ return EXIT.usage;
1251
+ }
1252
+ }
1253
+
1254
+ let result;
1255
+ try {
1256
+ const commandContext = resolveCommandContext(
1257
+ { cwd },
1258
+ { readGraph: env.readGraph, listFiles: env.listFiles },
1259
+ );
1260
+
1261
+ // Declared constraints are judged under whichever law THIS run resolves,
1262
+ // and the envelope records that law's fingerprint beside the baseline's —
1263
+ // the same loading every judging command does (`resolvePolicy`),
1264
+ // profile-aware the same way `check` is.
1265
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1266
+
1267
+ result = await changeCommand(baselinePath, intentPath, commandContext, { config });
1268
+ } catch (error) {
1269
+ const usageError = error instanceof UsageError;
1270
+ env.err(String(error?.message ?? error));
1271
+ return usageError ? EXIT.usage : EXIT.error;
1272
+ }
1273
+
1274
+ const report = options.format === "json" ? result.report.json : result.report.text;
1275
+
1276
+ if (options.output) {
1277
+ // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
1278
+ // the mechanism and the threat it closes.
1279
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1280
+ if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
1281
+ env.err(
1282
+ `archkeep: change ${result.changeIntent.reconciliation.verdict} ` +
1283
+ `(+${result.changeIntent.reconciliation.matched.length} matched, ` +
1284
+ `!${result.changeIntent.reconciliation.unexpected.length} undeclared, ` +
1285
+ `?${result.changeIntent.reconciliation.missingExpected.length} unfulfilled) → ${options.output}`,
1286
+ );
1287
+ } else {
1288
+ env.out(report);
1289
+ }
1290
+
1291
+ // The verdict fold `changeCommand` computed, mapped here the way `delta`'s
1292
+ // and `fitness`' are.
1293
+ return (
1294
+ { ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
1295
+ EXIT.error
1296
+ );
1297
+ }
1298
+
1215
1299
  /**
1216
1300
  * `waivers`' `run`: resolves the command context, drives `waiversCommand`,
1217
1301
  * writes the report where it belongs, and returns the process's exit code.
@@ -1715,6 +1799,157 @@ async function runHistory(options, { cwd, env }) {
1715
1799
  return EXIT.ok;
1716
1800
  }
1717
1801
 
1802
+ /**
1803
+ * `trajectory`'s run: resolves the command context, drives
1804
+ * `trajectoryCommand`, writes the report, and returns the exit code.
1805
+ *
1806
+ * The history directory is the single positional argument — the same
1807
+ * consumer-managed directory `history` and `debt` read. No boundary law is
1808
+ * loaded and no snapshot is captured: the fingerprints compared travel inside
1809
+ * the stored snapshots (`src/commands/trajectory.mjs`'s header owns both
1810
+ * halves of that posture).
1811
+ *
1812
+ * @param {{format: string, output: string|null, paths: string[]}} options
1813
+ * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1814
+ * @returns {Promise<number>}
1815
+ */
1816
+ async function runTrajectory(options, { cwd, env }) {
1817
+ if (options.paths.length !== 1) {
1818
+ env.err(
1819
+ `archkeep: trajectory takes exactly one positional argument (the history directory); ` +
1820
+ `got ${options.paths.length}`,
1821
+ );
1822
+ return EXIT.usage;
1823
+ }
1824
+
1825
+ const dir = isAbsolute(options.paths[0])
1826
+ ? resolve(options.paths[0])
1827
+ : resolve(cwd, options.paths[0]);
1828
+
1829
+ // The same self-footgun guard `runHistory` applies: a report written into
1830
+ // the directory being read would be read back as a snapshot on the next run
1831
+ // (the envelope is not a `graph` snapshot, which `parseBaseline` refuses) —
1832
+ // poison the record loudly refused rather than quietly planted.
1833
+ if (options.output) {
1834
+ const outputAbs = isAbsolute(options.output)
1835
+ ? resolve(options.output)
1836
+ : resolve(cwd, options.output);
1837
+ if (dirname(outputAbs) === dir) {
1838
+ env.err(
1839
+ `archkeep: --output '${options.output}' is inside the history directory '${dir}' — ` +
1840
+ `writing the report there would be read back as a snapshot on the next run. ` +
1841
+ `Write it somewhere else.`,
1842
+ );
1843
+ return EXIT.usage;
1844
+ }
1845
+ }
1846
+
1847
+ let result;
1848
+ try {
1849
+ const commandContext = resolveCommandContext(
1850
+ { cwd },
1851
+ { readGraph: env.readGraph, listFiles: env.listFiles },
1852
+ );
1853
+ result = trajectoryCommand(dir, commandContext);
1854
+ } catch (error) {
1855
+ const usageError = error instanceof UsageError;
1856
+ env.err(String(error?.message ?? error));
1857
+ return usageError ? EXIT.usage : EXIT.error;
1858
+ }
1859
+
1860
+ const report = options.format === "json" ? result.report.json : result.report.text;
1861
+
1862
+ if (options.output) {
1863
+ // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
1864
+ // the mechanism and the threat it closes.
1865
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1866
+ // No `--config` flag (`TRAJECTORY_FLAG_HELP`) — there is no override to pass.
1867
+ if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
1868
+ env.err(`archkeep: trajectory complete → ${options.output}`);
1869
+ } else {
1870
+ env.out(report);
1871
+ }
1872
+
1873
+ // Trajectory is descriptive: 0 when the aggregation completes, never 1.
1874
+ return EXIT.ok;
1875
+ }
1876
+
1877
+ /**
1878
+ * `evolution`'s `run`: resolves the workspace root the way `adr` does (a light
1879
+ * marker walk — this command describes OTHER trees, so it must not fail
1880
+ * because the caller's own working tree does not fully analyze right now),
1881
+ * drives `evolutionCommand`, writes the report, and returns the exit code.
1882
+ *
1883
+ * The range is `--base` plus optional `--head`; there are no positional
1884
+ * arguments. The Nx and git seams in `env` thread into EVERY analyzed
1885
+ * revision's context, the same way they thread into one tree elsewhere — in
1886
+ * production both are undefined and every revision is read for real.
1887
+ *
1888
+ * @param {{format: string, output: string|null, base: string|null, head: string|null,
1889
+ * paths: string[]}} options
1890
+ * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1891
+ * @returns {Promise<number>}
1892
+ */
1893
+ async function runEvolution(options, { cwd, env }) {
1894
+ if (!options.base) {
1895
+ env.err(
1896
+ `archkeep: evolution needs --base <rev> — a commit, branch, or tag the range starts at ` +
1897
+ `(--head defaults to HEAD).`,
1898
+ );
1899
+ return EXIT.usage;
1900
+ }
1901
+ if (options.paths.length > 0) {
1902
+ env.err(
1903
+ `archkeep: evolution takes no positional arguments; the range is --base plus --head. ` +
1904
+ `Got '${options.paths.join("', '")}'`,
1905
+ );
1906
+ return EXIT.usage;
1907
+ }
1908
+
1909
+ const root = findWorkspaceRoot(cwd, WORKSPACE_MARKERS);
1910
+ if (root === null) {
1911
+ env.err(
1912
+ `archkeep: evolution needs a workspace root — no nx.json, archkeep.json, or .moon marker found ` +
1913
+ `walking up from ${cwd}`,
1914
+ );
1915
+ return EXIT.error;
1916
+ }
1917
+
1918
+ let result;
1919
+ try {
1920
+ result = await evolutionCommand(
1921
+ root,
1922
+ { base: options.base, head: options.head },
1923
+ {
1924
+ readGraph: env.readGraph,
1925
+ listFiles: env.listFiles,
1926
+ },
1927
+ );
1928
+ } catch (error) {
1929
+ const usageError = error instanceof UsageError;
1930
+ env.err(String(error?.message ?? error));
1931
+ return usageError ? EXIT.usage : EXIT.error;
1932
+ }
1933
+
1934
+ const report = options.format === "json" ? result.report.json : result.report.text;
1935
+
1936
+ if (options.output) {
1937
+ // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
1938
+ // the mechanism and the threat it closes.
1939
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1940
+ // No `--config` flag exists here (`EVOLUTION_FLAG_HELP`) — there is no
1941
+ // override to pass, only the workspace's un-overridden default to guard.
1942
+ if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
1943
+ env.err(`archkeep: evolution complete → ${options.output}`);
1944
+ } else {
1945
+ env.out(report);
1946
+ }
1947
+
1948
+ // Descriptive: where the architecture changed is a fact about history,
1949
+ // never a finding.
1950
+ return EXIT.ok;
1951
+ }
1952
+
1718
1953
  /**
1719
1954
  * `debt`'s `run`: resolves the command context, drives `debtCommand`, writes
1720
1955
  * the ledger report, and returns the exit code.
@@ -2127,10 +2362,11 @@ const DELTA_FLAG_HELP = Object.freeze([
2127
2362
  Object.freeze({
2128
2363
  flag: "--format",
2129
2364
  key: "format",
2130
- arg: "text|json",
2365
+ arg: "text|sarif|json",
2131
2366
  describe: Object.freeze([
2132
- "Terminal report (default) or the versioned JSON envelope",
2133
- "docs/reference/json-output.md documents",
2367
+ "Terminal report (default), SARIF 2.1.0 of the introduced",
2368
+ "findings for GitHub code scanning, or the versioned JSON",
2369
+ "envelope docs/reference/json-output.md documents",
2134
2370
  ]),
2135
2371
  }),
2136
2372
  Object.freeze({
@@ -2156,6 +2392,54 @@ const DELTA_FLAG_HELP = Object.freeze([
2156
2392
  }),
2157
2393
  ]);
2158
2394
 
2395
+ /**
2396
+ * `change`'s flags: text or JSON envelope, optional file output, and the
2397
+ * required `--intent` naming the change-intent manifest. `--config` joins for
2398
+ * the same reason `delta`'s does: declared constraints are re-judged under
2399
+ * whichever law this run resolves.
2400
+ *
2401
+ * @type {readonly FlagHelp[]}
2402
+ */
2403
+ const CHANGE_FLAG_HELP = Object.freeze([
2404
+ Object.freeze({
2405
+ flag: "--intent",
2406
+ key: "intent",
2407
+ arg: "<file>",
2408
+ describe: Object.freeze([
2409
+ "The change-intent manifest declaring the material",
2410
+ "architectural consequences this change expects",
2411
+ "(required; see docs/usage/change.md)",
2412
+ ]),
2413
+ }),
2414
+ Object.freeze({
2415
+ flag: "--format",
2416
+ key: "format",
2417
+ arg: "text|json",
2418
+ describe: Object.freeze([
2419
+ "Terminal report (default) or the versioned JSON envelope",
2420
+ "docs/reference/json-output.md documents",
2421
+ ]),
2422
+ }),
2423
+ Object.freeze({
2424
+ flag: "--output",
2425
+ key: "output",
2426
+ arg: "<file>",
2427
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
2428
+ }),
2429
+ Object.freeze({
2430
+ flag: "--config",
2431
+ key: "config",
2432
+ arg: "<file>",
2433
+ describe: ({ boundaryConfig, inline }) =>
2434
+ Object.freeze([
2435
+ "Read the boundary law from here instead of",
2436
+ inline
2437
+ ? "the inline boundaryConfig in archkeep.json"
2438
+ : `<workspace root>/${boundaryConfig}`,
2439
+ ]),
2440
+ }),
2441
+ ]);
2442
+
2159
2443
  /**
2160
2444
  * `drift`'s flags: text or JSON envelope, optional file output. The intent is
2161
2445
  * always read from the tracked root `architecture-intent.json` — the same one
@@ -2372,6 +2656,51 @@ const HISTORY_FLAG_HELP = Object.freeze([
2372
2656
  }),
2373
2657
  ]);
2374
2658
 
2659
+ /**
2660
+ * `evolution`'s flags: text or JSON envelope, optional file output, and the
2661
+ * two revisions that bound the range. There is no `--config` — each analyzed
2662
+ * revision is judged under the law its own tree declares (`src/commands/evolution.mjs`),
2663
+ * and a law carried from outside would misattribute policy changes to
2664
+ * revisions that never made them.
2665
+ *
2666
+ * @type {readonly FlagHelp[]}
2667
+ */
2668
+ const EVOLUTION_FLAG_HELP = Object.freeze([
2669
+ Object.freeze({
2670
+ flag: "--format",
2671
+ key: "format",
2672
+ arg: "text|json",
2673
+ describe: Object.freeze([
2674
+ "Terminal report (default) or the versioned JSON envelope",
2675
+ "docs/reference/json-output.md documents",
2676
+ ]),
2677
+ }),
2678
+ Object.freeze({
2679
+ flag: "--output",
2680
+ key: "output",
2681
+ arg: "<file>",
2682
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
2683
+ }),
2684
+ Object.freeze({
2685
+ flag: "--base",
2686
+ key: "base",
2687
+ arg: "<rev>",
2688
+ describe: Object.freeze([
2689
+ "The baseline revision — a commit, branch, tag,",
2690
+ "or HEAD~n; the first revision analyzed",
2691
+ ]),
2692
+ }),
2693
+ Object.freeze({
2694
+ flag: "--head",
2695
+ key: "head",
2696
+ arg: "<rev>",
2697
+ describe: Object.freeze([
2698
+ "The tip revision (default HEAD); must be a",
2699
+ "linear descendant of --base with no merges between",
2700
+ ]),
2701
+ }),
2702
+ ]);
2703
+
2375
2704
  /**
2376
2705
  * `health`'s flags: text or JSON envelope, optional file output. The optional
2377
2706
  * positional argument is the snapshot directory for trends, the same directory
@@ -2448,6 +2777,35 @@ const REPORT_FLAG_HELP = Object.freeze([
2448
2777
  }),
2449
2778
  ]);
2450
2779
 
2780
+ /**
2781
+ * `trajectory`'s flags: text or JSON envelope and optional file output — and
2782
+ * deliberately no `--config` and no `--capture`. There is no current-law
2783
+ * input to override: the fingerprints being compared travel inside the
2784
+ * snapshots, so the law each observation is judged under is the one it was
2785
+ * captured under. And the command writes nothing into the history directory —
2786
+ * capture stays `history --capture`'s job, so there is exactly one way a
2787
+ * snapshot enters the record.
2788
+ *
2789
+ * @type {readonly FlagHelp[]}
2790
+ */
2791
+ const TRAJECTORY_FLAG_HELP = Object.freeze([
2792
+ Object.freeze({
2793
+ flag: "--format",
2794
+ key: "format",
2795
+ arg: "text|json",
2796
+ describe: Object.freeze([
2797
+ "Terminal report (default) or the versioned JSON envelope",
2798
+ "docs/reference/json-output.md documents",
2799
+ ]),
2800
+ }),
2801
+ Object.freeze({
2802
+ flag: "--output",
2803
+ key: "output",
2804
+ arg: "<file>",
2805
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
2806
+ }),
2807
+ ]);
2808
+
2451
2809
  /**
2452
2810
  * `debt`'s flags: text or JSON envelope, optional file output, and the same
2453
2811
  * `--config` the other descriptive commands take, so a ledger ages the
@@ -2679,10 +3037,20 @@ const COMMANDS = Object.freeze({
2679
3037
  flagHelp: DELTA_FLAG_HELP,
2680
3038
  flags: Object.freeze(Object.fromEntries(DELTA_FLAG_HELP.map((f) => [f.flag, f.key]))),
2681
3039
  defaults: Object.freeze({ format: "text", output: null, config: null, capture: false }),
2682
- formats: DESCRIBABLE_FORMATS,
3040
+ formats: DELTA_FORMATS,
2683
3041
  booleans: Object.freeze(["capture"]),
2684
3042
  run: runDelta,
2685
3043
  }),
3044
+ change: Object.freeze({
3045
+ name: "change",
3046
+ args: "<baseline> --intent <file>",
3047
+ summary: "Reconcile a declared change intent against the architectural delta",
3048
+ flagHelp: CHANGE_FLAG_HELP,
3049
+ flags: Object.freeze(Object.fromEntries(CHANGE_FLAG_HELP.map((f) => [f.flag, f.key]))),
3050
+ defaults: Object.freeze({ format: "text", output: null, config: null, intent: null }),
3051
+ formats: DESCRIBABLE_FORMATS,
3052
+ run: runChange,
3053
+ }),
2686
3054
  discover: Object.freeze({
2687
3055
  name: "discover",
2688
3056
  args: "[--propose]",
@@ -2746,6 +3114,26 @@ const COMMANDS = Object.freeze({
2746
3114
  booleans: Object.freeze(["capture"]),
2747
3115
  run: runHistory,
2748
3116
  }),
3117
+ trajectory: Object.freeze({
3118
+ name: "trajectory",
3119
+ args: "<dir>",
3120
+ summary: "Aggregate the deterministic drift trajectory across snapshots",
3121
+ flagHelp: TRAJECTORY_FLAG_HELP,
3122
+ flags: Object.freeze(Object.fromEntries(TRAJECTORY_FLAG_HELP.map((f) => [f.flag, f.key]))),
3123
+ defaults: Object.freeze({ format: "text", output: null }),
3124
+ formats: DESCRIBABLE_FORMATS,
3125
+ run: runTrajectory,
3126
+ }),
3127
+ evolution: Object.freeze({
3128
+ name: "evolution",
3129
+ args: "",
3130
+ summary: "Describe how the architecture evolved across a Git revision range",
3131
+ flagHelp: EVOLUTION_FLAG_HELP,
3132
+ flags: Object.freeze(Object.fromEntries(EVOLUTION_FLAG_HELP.map((f) => [f.flag, f.key]))),
3133
+ defaults: Object.freeze({ format: "text", output: null, base: null, head: null }),
3134
+ formats: DESCRIBABLE_FORMATS,
3135
+ run: runEvolution,
3136
+ }),
2749
3137
  health: Object.freeze({
2750
3138
  name: "health",
2751
3139
  args: "[<snapshot-dir>]",