@intentius/chant 0.1.13 → 0.1.15

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +18 -2
  3. package/src/cli/commands/build.ts +9 -1
  4. package/src/cli/commands/import-live.test.ts +126 -0
  5. package/src/cli/commands/import.ts +152 -2
  6. package/src/cli/commands/migrate.ts +2 -2
  7. package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +37 -37
  8. package/src/cli/handlers/{state.ts → lifecycle.ts} +148 -36
  9. package/src/cli/handlers/misc.ts +31 -2
  10. package/src/cli/handlers/run.test.ts +98 -0
  11. package/src/cli/handlers/run.ts +123 -0
  12. package/src/cli/main.test.ts +14 -0
  13. package/src/cli/main.ts +38 -12
  14. package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
  15. package/src/cli/mcp/op-tools.ts +2 -2
  16. package/src/cli/mcp/resource-handlers.ts +1 -1
  17. package/src/cli/mcp/server.test.ts +2 -2
  18. package/src/cli/mcp/server.ts +1 -1
  19. package/src/cli/registry.ts +21 -2
  20. package/src/codegen/fetch.test.ts +103 -2
  21. package/src/codegen/fetch.ts +62 -10
  22. package/src/config.ts +31 -0
  23. package/src/detectLexicon.test.ts +2 -2
  24. package/src/index.ts +2 -2
  25. package/src/lexicon-export.test.ts +92 -0
  26. package/src/lexicon.ts +88 -9
  27. package/src/lifecycle/change-set.test.ts +151 -0
  28. package/src/lifecycle/change-set.ts +172 -0
  29. package/src/{state → lifecycle}/git.test.ts +15 -15
  30. package/src/{state → lifecycle}/git.ts +14 -14
  31. package/src/{state → lifecycle}/index.ts +2 -0
  32. package/src/{state → lifecycle}/snapshot.test.ts +5 -5
  33. package/src/{state → lifecycle}/snapshot.ts +9 -9
  34. package/src/{state → lifecycle}/types.ts +1 -1
  35. package/src/op/activity-registry.test.ts +59 -0
  36. package/src/op/activity-registry.ts +91 -0
  37. package/src/op/builders.ts +3 -3
  38. package/src/op/index.ts +6 -1
  39. package/src/op/local-executor.test.ts +247 -0
  40. package/src/op/local-executor.ts +300 -0
  41. package/src/op/local-output.test.ts +54 -0
  42. package/src/op/local-output.ts +63 -0
  43. package/src/op/op.test.ts +4 -4
  44. package/src/op/types.ts +1 -1
  45. package/src/ownership.test.ts +109 -0
  46. package/src/ownership.ts +142 -0
  47. package/src/serializer.ts +19 -1
  48. package/src/toml-parse.ts +3 -3
  49. /package/src/{state → lifecycle}/digest.test.ts +0 -0
  50. /package/src/{state → lifecycle}/digest.ts +0 -0
  51. /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
  52. /package/src/{state → lifecycle}/live-diff.ts +0 -0
@@ -5,7 +5,7 @@ import type { BuildResult } from "../../build";
5
5
  import type { ParsedArgs } from "../registry";
6
6
 
7
7
  const buildMock = vi.fn();
8
- const fetchStateMock = vi.fn();
8
+ const fetchLifecycleMock = vi.fn();
9
9
  const readSnapshotMock = vi.fn();
10
10
  const readEnvironmentSnapshotsMock = vi.fn();
11
11
  const listSnapshotsMock = vi.fn();
@@ -13,20 +13,20 @@ const takeSnapshotMock = vi.fn();
13
13
  const loadChantConfigMock = vi.fn();
14
14
 
15
15
  vi.mock("../../build", () => ({ build: (...args: unknown[]) => buildMock(...args) }));
16
- vi.mock("../../state/git", () => ({
17
- fetchState: () => fetchStateMock(),
16
+ vi.mock("../../lifecycle/git", () => ({
17
+ fetchLifecycle: () => fetchLifecycleMock(),
18
18
  readSnapshot: (...args: unknown[]) => readSnapshotMock(...args),
19
19
  readEnvironmentSnapshots: (...args: unknown[]) => readEnvironmentSnapshotsMock(...args),
20
20
  listSnapshots: (...args: unknown[]) => listSnapshotsMock(...args),
21
21
  }));
22
- vi.mock("../../state/snapshot", () => ({
22
+ vi.mock("../../lifecycle/snapshot", () => ({
23
23
  takeSnapshot: (...args: unknown[]) => takeSnapshotMock(...args),
24
24
  }));
25
25
  vi.mock("../../config", () => ({
26
26
  loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
27
27
  }));
28
28
 
29
- const { runStateDiff, runStateSnapshot, runStateShow, runStateLog, runStateUnknown } = await import("./state");
29
+ const { runLifecycleDiff, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleUnknown } = await import("./lifecycle");
30
30
 
31
31
  function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
32
32
  return {
@@ -71,7 +71,7 @@ const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
71
71
  ...overrides,
72
72
  });
73
73
 
74
- describe("runStateDiff --live", () => {
74
+ describe("runLifecycleDiff --live", () => {
75
75
  let stdoutSpy: ReturnType<typeof vi.spyOn>;
76
76
  let stderrSpy: ReturnType<typeof vi.spyOn>;
77
77
  let stdoutBuf: string[];
@@ -83,13 +83,13 @@ describe("runStateDiff --live", () => {
83
83
  stdoutSpy = vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
84
84
  stderrSpy = vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
85
85
  buildMock.mockReset();
86
- fetchStateMock.mockReset();
86
+ fetchLifecycleMock.mockReset();
87
87
  readSnapshotMock.mockReset();
88
88
  });
89
89
 
90
90
  test("surfaces drift between previous snapshot and live state", async () => {
91
91
  buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
92
- fetchStateMock.mockResolvedValue(undefined);
92
+ fetchLifecycleMock.mockResolvedValue(undefined);
93
93
  readSnapshotMock.mockResolvedValue(JSON.stringify({
94
94
  lexicon: "aws",
95
95
  environment: "prod",
@@ -113,7 +113,7 @@ describe("runStateDiff --live", () => {
113
113
  serializers: plugins.map((p) => p.serializer),
114
114
  };
115
115
 
116
- const exit = await runStateDiff(ctx);
116
+ const exit = await runLifecycleDiff(ctx);
117
117
 
118
118
  expect(exit).toBe(0);
119
119
  const output = stdoutBuf.join("\n");
@@ -126,7 +126,7 @@ describe("runStateDiff --live", () => {
126
126
 
127
127
  test("warns and skips lexicons without describeResources", async () => {
128
128
  buildMock.mockResolvedValue(makeBuildResult({ k8s: ["pod"] }));
129
- fetchStateMock.mockResolvedValue(undefined);
129
+ fetchLifecycleMock.mockResolvedValue(undefined);
130
130
  readSnapshotMock.mockResolvedValue(null);
131
131
 
132
132
  const plugins: LexiconPlugin[] = [
@@ -139,7 +139,7 @@ describe("runStateDiff --live", () => {
139
139
  serializers: plugins.map((p) => p.serializer),
140
140
  };
141
141
 
142
- const exit = await runStateDiff(ctx);
142
+ const exit = await runLifecycleDiff(ctx);
143
143
 
144
144
  expect(exit).toBe(1);
145
145
  const stderr = stderrBuf.join("\n");
@@ -149,7 +149,7 @@ describe("runStateDiff --live", () => {
149
149
 
150
150
  test("--live with a listArtifacts-only plugin diffs artifacts (no resources path)", async () => {
151
151
  buildMock.mockResolvedValue(makeBuildResult({ helm: [] }));
152
- fetchStateMock.mockResolvedValue(undefined);
152
+ fetchLifecycleMock.mockResolvedValue(undefined);
153
153
  // Previous snapshot has no artifact entry for the new release → expect ARTIFACTS ADDED
154
154
  readSnapshotMock.mockResolvedValue(JSON.stringify({
155
155
  lexicon: "helm",
@@ -175,7 +175,7 @@ describe("runStateDiff --live", () => {
175
175
  serializers: plugins.map((p) => p.serializer),
176
176
  };
177
177
 
178
- const exit = await runStateDiff(ctx);
178
+ const exit = await runLifecycleDiff(ctx);
179
179
 
180
180
  expect(exit).toBe(0);
181
181
  const output = stdoutBuf.join("\n");
@@ -185,7 +185,7 @@ describe("runStateDiff --live", () => {
185
185
 
186
186
  test("legacy digest mode still works without --live", async () => {
187
187
  buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
188
- fetchStateMock.mockResolvedValue(undefined);
188
+ fetchLifecycleMock.mockResolvedValue(undefined);
189
189
  readSnapshotMock.mockResolvedValue(null);
190
190
 
191
191
  const plugins: LexiconPlugin[] = [createMockPlugin({ name: "aws" })];
@@ -196,7 +196,7 @@ describe("runStateDiff --live", () => {
196
196
  serializers: plugins.map((p) => p.serializer),
197
197
  };
198
198
 
199
- const exit = await runStateDiff(ctx);
199
+ const exit = await runLifecycleDiff(ctx);
200
200
 
201
201
  expect(exit).toBe(0);
202
202
  const output = stdoutBuf.join("\n");
@@ -206,7 +206,7 @@ describe("runStateDiff --live", () => {
206
206
  });
207
207
  });
208
208
 
209
- describe("runStateSnapshot", () => {
209
+ describe("runLifecycleSnapshot", () => {
210
210
  let stdoutBuf: string[];
211
211
  let stderrBuf: string[];
212
212
 
@@ -227,7 +227,7 @@ describe("runStateSnapshot", () => {
227
227
  plugins: [],
228
228
  serializers: [],
229
229
  };
230
- const exit = await runStateSnapshot(ctx);
230
+ const exit = await runLifecycleSnapshot(ctx);
231
231
  expect(exit).toBe(1);
232
232
  expect(stderrBuf.join("\n")).toContain("Environment is required");
233
233
  });
@@ -238,7 +238,7 @@ describe("runStateSnapshot", () => {
238
238
  plugins: [],
239
239
  serializers: [],
240
240
  };
241
- const exit = await runStateSnapshot(ctx);
241
+ const exit = await runLifecycleSnapshot(ctx);
242
242
  expect(exit).toBe(1);
243
243
  expect(stderrBuf.join("\n")).toContain('Unknown environment "unknown"');
244
244
  });
@@ -251,7 +251,7 @@ describe("runStateSnapshot", () => {
251
251
  plugins,
252
252
  serializers: plugins.map((p) => p.serializer),
253
253
  };
254
- const exit = await runStateSnapshot(ctx);
254
+ const exit = await runLifecycleSnapshot(ctx);
255
255
  expect(exit).toBe(1);
256
256
  expect(stderrBuf.join("\n")).toContain("No plugins implement describeResources");
257
257
  });
@@ -275,14 +275,14 @@ describe("runStateSnapshot", () => {
275
275
  plugins,
276
276
  serializers: plugins.map((p) => p.serializer),
277
277
  };
278
- const exit = await runStateSnapshot(ctx);
278
+ const exit = await runLifecycleSnapshot(ctx);
279
279
  expect(exit).toBe(0);
280
280
  expect(stderrBuf.join("\n")).toContain("Snapshot saved");
281
281
  expect(takeSnapshotMock).toHaveBeenCalledTimes(1);
282
282
  });
283
283
  });
284
284
 
285
- describe("runStateShow", () => {
285
+ describe("runLifecycleShow", () => {
286
286
  let stdoutBuf: string[];
287
287
  let stderrBuf: string[];
288
288
 
@@ -291,7 +291,7 @@ describe("runStateShow", () => {
291
291
  stderrBuf = [];
292
292
  vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
293
293
  vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
294
- fetchStateMock.mockReset();
294
+ fetchLifecycleMock.mockReset();
295
295
  readSnapshotMock.mockReset();
296
296
  readEnvironmentSnapshotsMock.mockReset();
297
297
  });
@@ -301,12 +301,12 @@ describe("runStateShow", () => {
301
301
  args: makeArgs({ command: "state", path: "show" }),
302
302
  plugins: [], serializers: [],
303
303
  };
304
- expect(await runStateShow(ctx)).toBe(1);
304
+ expect(await runLifecycleShow(ctx)).toBe(1);
305
305
  expect(stderrBuf.join("\n")).toContain("Environment is required");
306
306
  });
307
307
 
308
308
  test("specific lexicon: prints snapshot table when found", async () => {
309
- fetchStateMock.mockResolvedValue(undefined);
309
+ fetchLifecycleMock.mockResolvedValue(undefined);
310
310
  readSnapshotMock.mockResolvedValue(JSON.stringify({
311
311
  lexicon: "aws", environment: "prod", commit: "x", timestamp: "t",
312
312
  resources: { bucket: { type: "AWS::S3::Bucket", physicalId: "b-1", status: "OK" } },
@@ -315,25 +315,25 @@ describe("runStateShow", () => {
315
315
  args: makeArgs({ command: "state", path: "show", extraPositional: "prod", extraPositional2: "aws" }),
316
316
  plugins: [], serializers: [],
317
317
  };
318
- expect(await runStateShow(ctx)).toBe(0);
318
+ expect(await runLifecycleShow(ctx)).toBe(0);
319
319
  const out = stdoutBuf.join("\n");
320
320
  expect(out).toContain("bucket");
321
321
  expect(out).toContain("AWS::S3::Bucket");
322
322
  });
323
323
 
324
324
  test("specific lexicon: returns 1 when no snapshot found", async () => {
325
- fetchStateMock.mockResolvedValue(undefined);
325
+ fetchLifecycleMock.mockResolvedValue(undefined);
326
326
  readSnapshotMock.mockResolvedValue(null);
327
327
  const ctx = {
328
328
  args: makeArgs({ command: "state", path: "show", extraPositional: "prod", extraPositional2: "aws" }),
329
329
  plugins: [], serializers: [],
330
330
  };
331
- expect(await runStateShow(ctx)).toBe(1);
331
+ expect(await runLifecycleShow(ctx)).toBe(1);
332
332
  expect(stderrBuf.join("\n")).toContain("No snapshot found");
333
333
  });
334
334
 
335
335
  test("no lexicon: lists all lexicons in env", async () => {
336
- fetchStateMock.mockResolvedValue(undefined);
336
+ fetchLifecycleMock.mockResolvedValue(undefined);
337
337
  readEnvironmentSnapshotsMock.mockResolvedValue(new Map([
338
338
  ["aws", JSON.stringify({ lexicon: "aws", environment: "prod", commit: "x", timestamp: "t", resources: {} })],
339
339
  ["gcp", JSON.stringify({ lexicon: "gcp", environment: "prod", commit: "x", timestamp: "t", resources: {} })],
@@ -342,14 +342,14 @@ describe("runStateShow", () => {
342
342
  args: makeArgs({ command: "state", path: "show", extraPositional: "prod" }),
343
343
  plugins: [], serializers: [],
344
344
  };
345
- expect(await runStateShow(ctx)).toBe(0);
345
+ expect(await runLifecycleShow(ctx)).toBe(0);
346
346
  const out = stdoutBuf.join("\n");
347
347
  expect(out).toContain("prod/aws");
348
348
  expect(out).toContain("prod/gcp");
349
349
  });
350
350
  });
351
351
 
352
- describe("runStateLog", () => {
352
+ describe("runLifecycleLog", () => {
353
353
  let stdoutBuf: string[];
354
354
  let stderrBuf: string[];
355
355
 
@@ -358,23 +358,23 @@ describe("runStateLog", () => {
358
358
  stderrBuf = [];
359
359
  vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
360
360
  vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
361
- fetchStateMock.mockReset();
361
+ fetchLifecycleMock.mockReset();
362
362
  listSnapshotsMock.mockReset();
363
363
  });
364
364
 
365
365
  test("returns 1 with message when no entries exist", async () => {
366
- fetchStateMock.mockResolvedValue(undefined);
366
+ fetchLifecycleMock.mockResolvedValue(undefined);
367
367
  listSnapshotsMock.mockResolvedValue([]);
368
368
  const ctx = {
369
369
  args: makeArgs({ command: "state", path: "log" }),
370
370
  plugins: [], serializers: [],
371
371
  };
372
- expect(await runStateLog(ctx)).toBe(1);
372
+ expect(await runLifecycleLog(ctx)).toBe(1);
373
373
  expect(stderrBuf.join("\n")).toContain("No state snapshots");
374
374
  });
375
375
 
376
376
  test("prints commit / date / message rows for each entry", async () => {
377
- fetchStateMock.mockResolvedValue(undefined);
377
+ fetchLifecycleMock.mockResolvedValue(undefined);
378
378
  listSnapshotsMock.mockResolvedValue([
379
379
  { commit: "abcdef1234567890", date: "2026-05-01T00:00:00Z", message: "Snapshot prod" },
380
380
  { commit: "fedcba9876543210", date: "2026-05-02T00:00:00Z", message: "Snapshot staging" },
@@ -383,7 +383,7 @@ describe("runStateLog", () => {
383
383
  args: makeArgs({ command: "state", path: "log" }),
384
384
  plugins: [], serializers: [],
385
385
  };
386
- expect(await runStateLog(ctx)).toBe(0);
386
+ expect(await runLifecycleLog(ctx)).toBe(0);
387
387
  const out = stdoutBuf.join("\n");
388
388
  expect(out).toContain("abcdef1");
389
389
  expect(out).toContain("Snapshot prod");
@@ -391,7 +391,7 @@ describe("runStateLog", () => {
391
391
  });
392
392
  });
393
393
 
394
- describe("runStateUnknown", () => {
394
+ describe("runLifecycleUnknown", () => {
395
395
  test("returns 1 with subcommand list", async () => {
396
396
  const stderrBuf: string[] = [];
397
397
  vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
@@ -399,7 +399,7 @@ describe("runStateUnknown", () => {
399
399
  args: makeArgs({ command: "state", path: "garbage" }),
400
400
  plugins: [], serializers: [],
401
401
  };
402
- expect(await runStateUnknown(ctx)).toBe(1);
402
+ expect(await runLifecycleUnknown(ctx)).toBe(1);
403
403
  const stderr = stderrBuf.join("\n");
404
404
  expect(stderr).toContain("snapshot");
405
405
  expect(stderr).toContain("show");
@@ -1,27 +1,28 @@
1
1
  import { resolve } from "node:path";
2
2
  import { build } from "../../build";
3
- import { takeSnapshot } from "../../state/snapshot";
4
- import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchState, StaleStateBranchError } from "../../state/git";
5
- import { computeBuildDigest, diffDigests } from "../../state/digest";
6
- import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../state/live-diff";
3
+ import { takeSnapshot } from "../../lifecycle/snapshot";
4
+ import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, StaleLifecycleBranchError } from "../../lifecycle/git";
5
+ import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
6
+ import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../lifecycle/live-diff";
7
+ import { buildChangeSet, renderChangeSet, type ChangeSet } from "../../lifecycle/change-set";
7
8
  import { loadChantConfig } from "../../config";
8
9
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
9
10
  import type { CommandContext } from "../registry";
10
- import type { StateSnapshot } from "../../state/types";
11
+ import type { LifecycleSnapshot } from "../../lifecycle/types";
11
12
  import type { SerializerResult } from "../../serializer";
12
- import type { LexiconPlugin, ResourceMetadata, ArtifactMetadata } from "../../lexicon";
13
+ import type { ObservationLexicon, ResourceMetadata, ArtifactMetadata } from "../../lexicon";
13
14
  import type { BuildResult } from "../../build";
14
15
 
15
16
  /**
16
- * chant state snapshot <environment> [lexicon]
17
+ * chant lifecycle snapshot <environment> [lexicon]
17
18
  */
18
- export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
19
+ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number> {
19
20
  const { args, plugins } = ctx;
20
21
  const environment = args.extraPositional;
21
22
  const lexiconFilter = args.extraPositional2;
22
23
 
23
24
  if (!environment) {
24
- console.error(formatError({ message: "Environment is required: chant state snapshot <environment> [lexicon]" }));
25
+ console.error(formatError({ message: "Environment is required: chant lifecycle snapshot <environment> [lexicon]" }));
25
26
  return 1;
26
27
  }
27
28
 
@@ -62,10 +63,10 @@ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
62
63
  try {
63
64
  result = await takeSnapshot(environment, observingPlugins, buildResult);
64
65
  } catch (err) {
65
- if (err instanceof StaleStateBranchError) {
66
+ if (err instanceof StaleLifecycleBranchError) {
66
67
  console.error(formatError({
67
- message: `Another snapshot completed for chant/state after this run started (env: ${environment}).`,
68
- hint: `Pull and retry: \`git fetch origin ${"chant/state"}:${"chant/state"}\` && \`chant state snapshot ${environment}\`.`,
68
+ message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
69
+ hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
69
70
  }));
70
71
  return 1;
71
72
  }
@@ -83,26 +84,26 @@ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
83
84
  const counts = result.snapshots
84
85
  .map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
85
86
  .join(" ");
86
- console.error(formatSuccess(`Snapshot saved to chant/state (${counts})`));
87
+ console.error(formatSuccess(`Snapshot saved to chant/lifecycle (${counts})`));
87
88
  }
88
89
 
89
90
  return result.errors.length > 0 && result.snapshots.length === 0 ? 1 : 0;
90
91
  }
91
92
 
92
93
  /**
93
- * chant state show <environment> [lexicon]
94
+ * chant lifecycle show <environment> [lexicon]
94
95
  */
95
- export async function runStateShow(ctx: CommandContext): Promise<number> {
96
+ export async function runLifecycleShow(ctx: CommandContext): Promise<number> {
96
97
  const environment = ctx.args.extraPositional;
97
98
  const lexiconFilter = ctx.args.extraPositional2;
98
99
 
99
100
  if (!environment) {
100
- console.error(formatError({ message: "Environment is required: chant state show <environment> [lexicon]" }));
101
+ console.error(formatError({ message: "Environment is required: chant lifecycle show <environment> [lexicon]" }));
101
102
  return 1;
102
103
  }
103
104
 
104
105
  // Fetch from remote first
105
- await fetchState();
106
+ await fetchLifecycle();
106
107
 
107
108
  if (lexiconFilter) {
108
109
  const content = await readSnapshot(environment, lexiconFilter);
@@ -111,7 +112,7 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
111
112
  return 1;
112
113
  }
113
114
 
114
- const snapshot: StateSnapshot = JSON.parse(content);
115
+ const snapshot: LifecycleSnapshot = JSON.parse(content);
115
116
  printSnapshotTable(snapshot);
116
117
  } else {
117
118
  const snapshots = await readEnvironmentSnapshots(environment);
@@ -121,7 +122,7 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
121
122
  }
122
123
 
123
124
  for (const [lexicon, content] of snapshots) {
124
- const snapshot: StateSnapshot = JSON.parse(content);
125
+ const snapshot: LifecycleSnapshot = JSON.parse(content);
125
126
  console.log(`\n${formatBold(`${environment}/${lexicon}`)} — ${Object.keys(snapshot.resources).length} resources — ${snapshot.timestamp}`);
126
127
  printSnapshotTable(snapshot);
127
128
  }
@@ -131,15 +132,15 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
131
132
  }
132
133
 
133
134
  /**
134
- * chant state diff <environment> [lexicon]
135
+ * chant lifecycle diff <environment> [lexicon]
135
136
  */
136
- export async function runStateDiff(ctx: CommandContext): Promise<number> {
137
+ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
137
138
  const { args, plugins, serializers } = ctx;
138
139
  const environment = args.extraPositional;
139
140
  const lexiconFilter = args.extraPositional2;
140
141
 
141
142
  if (!environment) {
142
- console.error(formatError({ message: "Environment is required: chant state diff <environment> [lexicon]" }));
143
+ console.error(formatError({ message: "Environment is required: chant lifecycle diff <environment> [lexicon]" }));
143
144
  return 1;
144
145
  }
145
146
 
@@ -157,17 +158,17 @@ export async function runStateDiff(ctx: CommandContext): Promise<number> {
157
158
  }
158
159
 
159
160
  // Fetch and read previous snapshot
160
- await fetchState();
161
+ await fetchLifecycle();
161
162
 
162
163
  const lexicons = lexiconFilter
163
164
  ? [lexiconFilter]
164
165
  : Array.from(buildResult.manifest.lexicons);
165
166
 
166
167
  if (args.live) {
167
- return runStateDiffLive({ environment, lexicons, plugins, buildResult });
168
+ return runLifecycleDiffLive({ environment, lexicons, plugins, buildResult });
168
169
  }
169
170
 
170
- return runStateDiffDigest({ environment, lexicons, buildResult });
171
+ return runLifecycleDiffDigest({ environment, lexicons, buildResult });
171
172
  }
172
173
 
173
174
  interface DigestDiffArgs {
@@ -176,14 +177,14 @@ interface DigestDiffArgs {
176
177
  buildResult: BuildResult;
177
178
  }
178
179
 
179
- async function runStateDiffDigest(args: DigestDiffArgs): Promise<number> {
180
+ async function runLifecycleDiffDigest(args: DigestDiffArgs): Promise<number> {
180
181
  const currentDigest = computeBuildDigest(args.buildResult);
181
182
 
182
183
  for (const lexicon of args.lexicons) {
183
184
  const content = await readSnapshot(args.environment, lexicon);
184
185
  let previousDigest = undefined;
185
186
  if (content) {
186
- const snapshot: StateSnapshot = JSON.parse(content);
187
+ const snapshot: LifecycleSnapshot = JSON.parse(content);
187
188
  previousDigest = snapshot.digest;
188
189
  }
189
190
 
@@ -213,11 +214,11 @@ async function runStateDiffDigest(args: DigestDiffArgs): Promise<number> {
213
214
  interface LiveDiffArgs {
214
215
  environment: string;
215
216
  lexicons: string[];
216
- plugins: LexiconPlugin[];
217
+ plugins: ObservationLexicon[];
217
218
  buildResult: BuildResult;
218
219
  }
219
220
 
220
- async function runStateDiffLive(args: LiveDiffArgs): Promise<number> {
221
+ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<number> {
221
222
  let totalDrift = 0;
222
223
  let totalLexiconsChecked = 0;
223
224
 
@@ -256,7 +257,7 @@ async function runStateDiffLive(args: LiveDiffArgs): Promise<number> {
256
257
  : (rawOutput as SerializerResult).primary;
257
258
 
258
259
  // Read previous snapshot once; both flows pull what they need.
259
- let prevSnapshot: StateSnapshot | undefined;
260
+ let prevSnapshot: LifecycleSnapshot | undefined;
260
261
  const content = await readSnapshot(args.environment, lexiconName);
261
262
  if (content) prevSnapshot = JSON.parse(content);
262
263
 
@@ -403,12 +404,123 @@ function renderLiveArtifactDiff(lexiconName: string, environment: string, diff:
403
404
  }
404
405
 
405
406
  /**
406
- * chant state log [environment]
407
+ * chant lifecycle plan <environment> [lexicon]
408
+ *
409
+ * Promote the live diff to a typed, read-only change set: per-entity
410
+ * create / update / delete / adopt / noop. Strictly read-only — never
411
+ * mutates, never deploys. Deletes are never proposed without ownership
412
+ * data (added in #121); an undeclared live resource is `adopt`.
407
413
  */
408
- export async function runStateLog(ctx: CommandContext): Promise<number> {
414
+ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
415
+ const { args, plugins, serializers } = ctx;
416
+ const environment = args.extraPositional;
417
+ const lexiconFilter = args.extraPositional2;
418
+
419
+ if (!environment) {
420
+ console.error(formatError({ message: "Environment is required: chant lifecycle plan <environment> [lexicon]" }));
421
+ return 1;
422
+ }
423
+
424
+ const targetSerializers = lexiconFilter
425
+ ? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
426
+ : serializers;
427
+
428
+ const projectPath = resolve(".");
429
+ const buildResult = await build(projectPath, targetSerializers);
430
+ if (buildResult.errors.length > 0) {
431
+ console.error(formatError({ message: "Build failed — fix errors before planning" }));
432
+ return 1;
433
+ }
434
+
435
+ await fetchLifecycle();
436
+
437
+ const lexicons = lexiconFilter ? [lexiconFilter] : Array.from(buildResult.manifest.lexicons);
438
+
439
+ const merged: ChangeSet = { env: environment, entries: [] };
440
+ let checked = 0;
441
+
442
+ for (const lexiconName of lexicons) {
443
+ const plugin = plugins.find((p) => p.name === lexiconName);
444
+ if (!plugin) continue;
445
+ if (!plugin.describeResources) {
446
+ // Plan is entity-keyed; artifact-only lexicons have no declared axis.
447
+ if (!args.json) {
448
+ console.error(formatWarning({
449
+ message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
450
+ }));
451
+ }
452
+ continue;
453
+ }
454
+
455
+ const declared = new Set<string>();
456
+ const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
457
+ for (const [name, entity] of buildResult.entities) {
458
+ if (entity.lexicon === lexiconName) {
459
+ declared.add(name);
460
+ entities.set(name, {
461
+ entityType: entity.entityType,
462
+ props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
463
+ });
464
+ }
465
+ }
466
+
467
+ const rawOutput = buildResult.outputs.get(lexiconName);
468
+ const buildOutput =
469
+ rawOutput === undefined
470
+ ? ""
471
+ : typeof rawOutput === "string"
472
+ ? rawOutput
473
+ : (rawOutput as SerializerResult).primary;
474
+
475
+ let observedNow: Record<string, ResourceMetadata>;
476
+ try {
477
+ observedNow = await plugin.describeResources({
478
+ environment,
479
+ buildOutput,
480
+ entityNames: Array.from(declared),
481
+ entities,
482
+ owned: args.owned,
483
+ });
484
+ } catch (err) {
485
+ console.error(formatError({
486
+ message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
487
+ }));
488
+ continue;
489
+ }
490
+
491
+ const content = await readSnapshot(environment, lexiconName);
492
+ const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
493
+
494
+ const cs = buildChangeSet(environment, { declared, observedNow, observedThen });
495
+ merged.entries.push(...cs.entries);
496
+ checked++;
497
+ }
498
+
499
+ if (checked === 0) {
500
+ console.error(formatWarning({
501
+ message: "No lexicons implement describeResources — nothing to plan",
502
+ }));
503
+ return 1;
504
+ }
505
+
506
+ merged.entries.sort((a, b) => a.name.localeCompare(b.name));
507
+
508
+ if (args.json) {
509
+ console.log(JSON.stringify(merged, null, 2));
510
+ } else {
511
+ console.log(renderChangeSet(merged));
512
+ }
513
+
514
+ return 0;
515
+ }
516
+
517
+ /**
518
+ * chant lifecycle log [environment]
519
+ */
520
+ export async function runLifecycleLog(ctx: CommandContext): Promise<number> {
409
521
  const environment = ctx.args.extraPositional;
410
522
 
411
- await fetchState();
523
+ await fetchLifecycle();
412
524
 
413
525
  const entries = await listSnapshots({ environment });
414
526
  if (entries.length === 0) {
@@ -427,15 +539,15 @@ export async function runStateLog(ctx: CommandContext): Promise<number> {
427
539
  /**
428
540
  * Fallback for unknown state subcommands.
429
541
  */
430
- export async function runStateUnknown(ctx: CommandContext): Promise<number> {
542
+ export async function runLifecycleUnknown(ctx: CommandContext): Promise<number> {
431
543
  console.error(formatError({
432
544
  message: `Unknown state subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
433
- hint: "Available: chant state snapshot, chant state show, chant state diff, chant state log",
545
+ hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle log",
434
546
  }));
435
547
  return 1;
436
548
  }
437
549
 
438
- function printSnapshotTable(snapshot: StateSnapshot): void {
550
+ function printSnapshotTable(snapshot: LifecycleSnapshot): void {
439
551
  console.log("RESOURCE".padEnd(20) + "TYPE".padEnd(28) + "PHYSICAL ID".padEnd(44) + "STATUS");
440
552
  console.log("-".repeat(100));
441
553
 
@@ -1,6 +1,7 @@
1
1
  import { listCommand, printListResult } from "../commands/list";
2
- import { importCommand, printImportResult } from "../commands/import";
3
- import { formatError, formatSuccess } from "../format";
2
+ import { importCommand, importFromLive, printImportResult } from "../commands/import";
3
+ import type { ResourceSelector } from "../../lexicon";
4
+ import { formatError, formatSuccess, formatWarning } from "../format";
4
5
  import type { CommandContext } from "../registry";
5
6
 
6
7
  export async function runList(ctx: CommandContext): Promise<number> {
@@ -21,6 +22,34 @@ export async function runList(ctx: CommandContext): Promise<number> {
21
22
  }
22
23
 
23
24
  export async function runImport(ctx: CommandContext): Promise<number> {
25
+ const { args } = ctx;
26
+
27
+ // `--from <env>` switches import from a template file to a live source.
28
+ if (args.migrateFrom) {
29
+ const selector: ResourceSelector | undefined =
30
+ args.selectType || args.selectName
31
+ ? { type: args.selectType, name: args.selectName }
32
+ : undefined;
33
+
34
+ // Live config may carry secrets into generated source — warn before writing.
35
+ console.error(formatWarning({
36
+ message: "Live import may emit sensitive values (keys, tokens, passwords) into generated source. Review before committing.",
37
+ }));
38
+
39
+ const result = await importFromLive({
40
+ environment: args.migrateFrom,
41
+ lexicon: args.lexicon,
42
+ output: args.output,
43
+ force: args.force,
44
+ selector,
45
+ owned: args.owned,
46
+ verbatim: args.verbatim,
47
+ });
48
+
49
+ printImportResult(result);
50
+ return result.success ? 0 : 1;
51
+ }
52
+
24
53
  const result = await importCommand({
25
54
  templatePath: ctx.args.path,
26
55
  output: ctx.args.output,