@m6d/cortex-cli 0.14.0 → 0.16.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
@@ -48,11 +48,11 @@ manifest takes its id from the name and Cortex keeps the id `cortex` for its own
48
48
  bunx @m6d/cortex-cli new shop --datasets
49
49
  ```
50
50
 
51
- On a TTY it asks only what the flags left open: the name, whether to set up the `helper` agent on
52
- the first `bun run dev`, and if so the model provider (name, base URL, API key, model). It checks
51
+ On a terminal it asks only what the flags left open: the name, whether to set up the `helper` agent
52
+ on the first `bun run dev`, and if so the model provider (name, base URL, API key, model). It checks
53
53
  the provider with one chat message to the model, and reuses it for embeddings when it serves the
54
- stack's embedding model. With no TTY it never prompts and behaves as `--yes`, so a missing name is a
55
- hard error rather than a hang.
54
+ stack's embedding model. With no terminal (stdin and stderr) to ask on, it never prompts and behaves
55
+ as `--yes`, so a missing name is a hard error rather than a hang.
56
56
 
57
57
  | Flag | Default | Writes |
58
58
  | ------------------------ | ------- | ------------------------------------------------------------------------------------------- |
@@ -62,15 +62,15 @@ hard error rather than a hang.
62
62
 
63
63
  The image runs at the Cortex release that was current when this CLI was published
64
64
  (`CORTEX_VERSION` in the shell picks another); the backend depends on the `@m6d/cortex` release of
65
- that time. A server name stops at Cortex's 100 characters, so a longer project name is
65
+ that time. A server name stops at Cortex's 120 characters, so a longer project name is
66
66
  registered cut to that length.
67
67
 
68
68
  ## `cortex setup`
69
69
 
70
70
  Registers a `cortex new` project's backend in its Cortex, then applies its manifest. It reads the
71
71
  backend's name, URL and key (`CORTEX_SERVER_*`) and the model provider (`MODEL_PROVIDER_*`) from
72
- `.env`, and goes in the one order Cortex accepts: the server key's secret, the provider's key and
73
- the provider, the server on the provider's model, a sync of the server, then the apply. Each step
72
+ `.env`, and goes in the one order Cortex accepts: the provider's key and the provider, the server
73
+ key's secret and the server on the provider's model, a sync of the server, then the apply. Each step
74
74
  creates what is missing and keeps what exists, so a second run changes nothing. A key's secret is
75
75
  named for its value (`openai-api-key-1a2b3c4d`), so a changed key gets a new secret the provider or
76
76
  server moves to, and whatever else presents the old one keeps it. A provider or server under
@@ -87,8 +87,14 @@ bun cortex setup --wait --yes # what `bun run dev` runs
87
87
  image. Without `-p`, setup sets up the Cortex `.env` names: a different `CORTEX_URL` exported in the
88
88
  shell, which Bun lets win over `.env`, ends the run. The retried sync, a minute while Cortex cannot
89
89
  reach the backend, gives up at once on any other refusal. With no model provider and `--yes`, it
90
- registers the server and applies nothing: Cortex publishes no agent without a model. The run that
91
- first creates something says how to sign in, with the password only on a terminal.
90
+ registers the server without a model and still applies, since an agent may name its own; when
91
+ Cortex refuses an agent for want of one (`chat_model_required`), nothing is applied and setup says
92
+ how to add a provider. The apply
93
+ deletes what the manifest created and no longer lists, so like `cortex apply` it asks on a terminal
94
+ before it writes anything, and needs `--yes` without one. A field the manifest changes that was
95
+ also edited live is asked about on a terminal. Under `--yes`, as `bun run dev` runs it, such a
96
+ conflict stops every run until `bun cortex apply --yes --prefer live` (or `manifest`) settles it.
97
+ The run that first creates something says how to sign in, with the password only on a terminal.
92
98
 
93
99
  ## Management commands
94
100
 
@@ -121,8 +127,8 @@ field left out, `id` included, is asked for on a terminal and named as the
121
127
  missing flag or positional otherwise. Listings take `--pageIndex`, `--pageSize`, `--q`, and `--all` to
122
128
  walk every page. `-o json` prints the raw result for pipes.
123
129
 
124
- Writes to agents, tools, services, skills, triggers, and dataset hosts save
125
- drafts.
130
+ Writes to agents, tools, services, skills, triggers, MCP servers, and dataset
131
+ hosts save drafts.
126
132
  `--publish` on a write the API marks publishable (`update`, and a `create`
127
133
  whose result is complete enough) runs that entity's publish procedure right
128
134
  after; without it the command says so and names the command. Fields the API
@@ -132,6 +138,11 @@ next turn, and the command names them: a trigger's
132
138
  `url`, `credentials` and `timeoutMs`. `cortex datasetHosts sync <id>` reads a host's manifest, and a
133
139
  draft host publishes only after `datasetHosts test` reached it once.
134
140
 
141
+ A procedure the API marks destructive (`x-destructive`: delete, publish, rotate, toggle and the
142
+ like) asks before anything is sent, and so does a `--publish` that runs one. `--yes` skips the
143
+ question. With no terminal to ask on, the command fails unless `--yes` is given, so scripts and CI
144
+ pass it. Questions render on stderr, so `-o json` output stays parseable.
145
+
135
146
  ## `cortex apply`
136
147
 
137
148
  Makes Cortex match a manifest, in one transaction: what is new is
@@ -142,9 +153,17 @@ touched, and a name one of them holds fails the apply.
142
153
  ```sh
143
154
  cortex apply # ./cortex.manifest.ts
144
155
  cortex apply deploy/manifest.json --dry-run
145
- cortex apply -p prod
156
+ cortex apply -p prod --yes # no question, as CI needs
157
+ cortex apply --prefer live # keep every live edit the manifest also changed
146
158
  ```
147
159
 
160
+ An entry's `overridable` lists the fields people may change in Cortex, such
161
+ as an agent's `prompt`. An apply keeps those edits. When the manifest changes
162
+ a field that was also edited live, `cortex apply` asks which to keep for
163
+ each one. `--prefer live` or `--prefer manifest` answers them all. `--yes`
164
+ never asks: under it, as without a terminal, an apply with conflicts and no
165
+ `--prefer` changes nothing and lists them. `--dry-run` marks each with `!`.
166
+
148
167
  The path defaults to exactly `./cortex.manifest.ts` in the directory you run
149
168
  in. TypeScript and JSON load the same way: the file's default export is the
150
169
  manifest. `defineManifest` from `@m6d/cortex` types one as you write it:
@@ -182,6 +201,8 @@ openapi: [
182
201
  name: "shop",
183
202
  url: "https://shop.example.com/openapi.json",
184
203
  credentials: [{ header: "Authorization", prefix: "Bearer ", credential: null }],
204
+ // For an API that wraps every answer: where each one keeps its result.
205
+ // resultPath: "/data",
185
206
  pinned: ["findOrder"],
186
207
  // A GET runs at once; any other method waits for the end user's approval.
187
208
  approval: { never: ["addOrderNote"], always: ["exportOrders"] },
@@ -207,12 +228,15 @@ The manifest is checked against the contract before anything is sent, then
207
228
  sent as written, so Cortex fills its own defaults. It goes to the
208
229
  management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
209
230
  which changes nothing) under the same profile the management commands use, so
210
- the key needs `publishing:publish` and the write permission of every kind the
231
+ the key needs `manifests:apply` and the write permission of every kind the
211
232
  apply changes. The output lists each changed entry under its kind: `+`
212
233
  created, `~` updated, `-` deleted. A trigger the apply creates, or whose
213
234
  `fireCredential` it drops, gets a fire secret, printed once below the summary
214
235
  and never again.
215
236
 
237
+ Since a sync deletes, `cortex apply` asks before sending it. `--yes` skips the
238
+ question, and is required with no terminal to ask on. `--dry-run` never asks.
239
+
216
240
  References between entries (mentions, service scopes, which role a
217
241
  credential or secret plays) are judged on the state the apply leaves, so
218
242
  the order the manifest lists them in does not matter. A dataset host's
@@ -232,9 +256,9 @@ it; drop it first, then apply again.
232
256
 
233
257
  ## Exit codes
234
258
 
235
- | Command | 0 | 1 |
236
- | ------- | ---------------------------------------------------------- | --------------------------------------------------- |
237
- | `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
238
- | `setup` | Set up, or the server registered with no model to apply on | Not a project, or Cortex refused or was unreachable |
239
- | `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, or refused |
240
- | others | The procedure ran | It failed, or couldn't run |
259
+ | Command | 0 | 1 |
260
+ | ------- | ---------------------------------------------------------- | --------------------------------------------------------- |
261
+ | `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
262
+ | `setup` | Set up, or the server registered with no model to apply on | Not a project, Cortex refused or unreachable, or declined |
263
+ | `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, refused, or declined |
264
+ | others | The procedure ran | It failed, or couldn't run |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Scaffold Cortex projects and manage a Cortex deployment",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,7 +16,7 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "check": "tsc --noEmit",
19
- "prepack": "rm -rf src/contracts && cp -R ../../internal/contracts/src src/contracts",
19
+ "prepack": "rm -rf src/contracts && bun ../../tooling/semantic-release/release-dirs.js src/contracts",
20
20
  "postpack": "rm -rf src/contracts",
21
21
  "test": "bun test"
22
22
  },
@@ -37,16 +37,6 @@
37
37
  },
38
38
  "releaseWatchPaths": [
39
39
  "apps/cortex",
40
- "packages/cortex-typescript",
41
- "internal/contracts/src/client-tools",
42
- "internal/contracts/src/datasets",
43
- "internal/contracts/src/egress-policy",
44
- "internal/contracts/src/errors",
45
- "internal/contracts/src/json",
46
- "internal/contracts/src/manifest",
47
- "internal/contracts/src/manifest-file",
48
- "internal/contracts/src/server",
49
- "internal/contracts/src/triggers",
50
- "internal/contracts/src/wire"
40
+ "packages/cortex-typescript"
51
41
  ]
52
42
  }
@@ -1,19 +1,38 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { relative, resolve } from "node:path";
3
3
 
4
+ import { Option } from "@commander-js/extra-typings";
4
5
  import type { Command } from "@commander-js/extra-typings";
5
- import { appliedManifestSchema, manifestChangesSchema } from "@cortex/contracts/manifest";
6
+ import { isRecord } from "@cortex/contracts/json";
7
+ import {
8
+ appliedManifestSchema,
9
+ manifestApplyInputSchema,
10
+ manifestPreviewSchema,
11
+ } from "@cortex/contracts/manifest";
6
12
  import { loadManifestFile } from "@cortex/contracts/manifest-file";
7
13
  import { z } from "zod";
8
14
 
9
- import { callManagement } from "@/management/client";
10
- import { callOrFail, requireProfile } from "@/commands/management";
15
+ import { callManagement, ManagementError } from "@/management/client";
16
+ import { callOrFail, confirmDestructive, requireProfile } from "@/commands/management";
17
+ import { askChoice, canAsk } from "@/ui/prompts";
11
18
  import * as report from "@/ui/report";
12
19
 
13
20
  // Discovery is this exact name in the cwd, with no upward walk, so the
14
21
  // argument's default is the discovery rule.
15
22
  export const DEFAULT_MANIFEST = "./cortex.manifest.ts";
16
23
 
24
+ /** Which value a field both the manifest and a live write changed keeps. */
25
+ const SIDES = manifestApplyInputSchema.shape.prefer.unwrap().options;
26
+ type Side = (typeof SIDES)[number];
27
+ type Resolution = z.input<typeof manifestApplyInputSchema>["resolutions"];
28
+ type Conflict = z.infer<typeof manifestPreviewSchema>["conflicts"][number];
29
+
30
+ /** A sync refused for fields both sides changed that nothing answered. */
31
+ const unresolvedSchema = z.object({
32
+ error: z.literal("unresolved_conflicts"),
33
+ conflicts: manifestPreviewSchema.shape.conflicts,
34
+ });
35
+
17
36
  const CHANGES = ["create", "update", "delete"] as const;
18
37
  const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
19
38
  (typeof CHANGES)[number],
@@ -23,7 +42,11 @@ const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
23
42
  /**
24
43
  * `cortex apply [path]`: makes Cortex match a manifest in one
25
44
  * transaction, through the management API with the profile's key.
26
- * `--dry-run` answers what would change and changes nothing.
45
+ * `--dry-run` answers what would change and changes nothing; anything
46
+ * else asks first, since a sync deletes. A field both the manifest and a
47
+ * live edit in Cortex changed is asked about too, unless `--prefer` answers
48
+ * every one. `--yes` never asks: the sync goes ahead, and a conflict
49
+ * `--prefer` leaves open ends the run.
27
50
  */
28
51
  export function registerApply(program: Command<[], { verbose?: true; profile?: string }>) {
29
52
  const command = program
@@ -31,27 +54,62 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
31
54
  .helpGroup("Cortex:")
32
55
  .description("Make Cortex match a manifest")
33
56
  .argument("[path]", "The manifest, TypeScript or JSON", DEFAULT_MANIFEST)
34
- .option("--dry-run", "Show what would change, and change nothing");
57
+ .option("--dry-run", "Show what would change, and change nothing")
58
+ .option(
59
+ "--yes",
60
+ "Never ask: apply without confirming, and stop on conflicts --prefer leaves open",
61
+ )
62
+ .addOption(
63
+ new Option(
64
+ "--prefer <side>",
65
+ "Keep the live or the manifest value of every field both changed, without asking",
66
+ ).choices(SIDES),
67
+ );
35
68
 
36
69
  command.action(async function (path, options) {
37
70
  const { verbose = false, profile: profileName } = program.opts();
38
71
  const dryRun = options.dryRun === true;
39
- const file = findManifest(path);
72
+ const file = findManifest(path, "cortex apply");
40
73
  const profile = requireProfile(profileName);
41
74
 
42
75
  report.header(dryRun ? "cortex apply --dry-run" : "cortex apply");
76
+ const manifest = await loadManifest(file, verbose);
43
77
  report.input("Manifest", label(file));
44
78
  report.input("Cortex", profile.url);
79
+ if (!dryRun) {
80
+ await confirmDestructive(
81
+ `Applying deletes what the manifest created on ${profile.url} and no longer lists.`,
82
+ options.yes,
83
+ );
84
+ }
45
85
 
46
- const { changes, triggerSecrets } = await applyManifest({ profile, file, dryRun, verbose });
86
+ const { changes, triggerSecrets, conflicts } = await callOrFail(profile, () =>
87
+ applyManifest({
88
+ profile,
89
+ manifest,
90
+ dryRun,
91
+ prefer: options.prefer,
92
+ interactive: canAsk() && options.yes !== true,
93
+ // A run that does not ask confirmed its sync with --yes.
94
+ command: [
95
+ "cortex apply",
96
+ ...(label(file) === DEFAULT_MANIFEST ? [] : [label(file)]),
97
+ "--yes",
98
+ ].join(" "),
99
+ }),
100
+ );
47
101
  const created = countNames(changes.create);
48
102
  const updated = countNames(changes.update);
49
103
  const deleted = countNames(changes.delete);
104
+ const conflicted =
105
+ conflicts.length > 0
106
+ ? `, ${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`
107
+ : "";
50
108
  report.footer(
51
- created + updated + deleted === 0
109
+ created + updated + deleted + conflicts.length === 0
52
110
  ? "no changes"
53
111
  : dryRun
54
- ? `would create ${created}, update ${updated}, delete ${deleted}`
112
+ ? `would create ${created}, update ${updated}, delete ${deleted}${conflicted}`
55
113
  : `created ${created}, updated ${updated}, deleted ${deleted}`,
56
114
  );
57
115
  printTriggerSecrets(triggerSecrets);
@@ -59,34 +117,35 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
59
117
  }
60
118
 
61
119
  /**
62
- * Loads the manifest at `file` and sends it, a preview under `dryRun`, then
63
- * prints its changes one row per entry. Ends the run on an invalid file, a
64
- * refusal, or a summary this CLI cannot read. `cortex apply` and `cortex
65
- * setup` both apply through it; each prints the trigger secrets it returns.
120
+ * Sends a manifest `loadManifest` read, a preview under `dryRun`, then
121
+ * prints its changes one row per entry, and a preview's conflicts. A sync
122
+ * deletes what the manifest created and no longer lists, so its caller
123
+ * confirms first (`confirmDestructive`). Ends the run on a summary this CLI
124
+ * cannot read; a refusal throws, for the caller to end the run on
125
+ * (`callOrFail`) or to answer itself. `cortex apply` and `cortex setup` both
126
+ * apply through it; each prints the trigger secrets it returns.
66
127
  */
67
128
  export async function applyManifest(options: {
68
129
  profile: ReturnType<typeof requireProfile>;
69
- file: string;
130
+ manifest: Record<string, unknown>;
70
131
  dryRun: boolean;
71
- verbose: boolean;
132
+ prefer?: Side;
133
+ interactive: boolean;
134
+ /** What to run again to answer the conflicts it could not ask about. */
135
+ command: string;
72
136
  }) {
73
- const { profile, file, dryRun, verbose } = options;
74
- const manifest = await loadManifest(file, verbose);
75
- const answer = await callOrFail(profile, () =>
76
- callManagement(
77
- profile,
78
- "post",
79
- dryRun ? "/manifests/preview" : "/manifests/sync",
80
- manifest,
81
- ),
82
- );
137
+ const { profile, dryRun } = options;
138
+ const answer = await sendManifest({
139
+ ...options,
140
+ path: dryRun ? "/manifests/preview" : "/manifests/sync",
141
+ });
83
142
  // The secrets are read apart from the changes: a sync has committed by
84
143
  // now, and a summary this CLI is too old to read must not lose them.
85
144
  const { triggerSecrets = {} } = appliedManifestSchema
86
145
  .pick({ triggerSecrets: true })
87
146
  .partial()
88
147
  .parse(answer);
89
- const applied = manifestChangesSchema.safeParse(answer);
148
+ const applied = manifestPreviewSchema.partial({ conflicts: true }).safeParse(answer);
90
149
  if (!applied.success) {
91
150
  printTriggerSecrets(triggerSecrets);
92
151
  report.fail({
@@ -97,8 +156,68 @@ export async function applyManifest(options: {
97
156
  ],
98
157
  });
99
158
  }
100
- printChanges(applied.data);
101
- return { changes: applied.data, triggerSecrets };
159
+ const { conflicts = [], ...changes } = applied.data;
160
+ printChanges(changes, conflicts);
161
+ return { changes, triggerSecrets, conflicts };
162
+ }
163
+
164
+ /**
165
+ * Sends the manifest until nothing in it is left unanswered. A sync refused
166
+ * for fields both the manifest and a live edit changed asks about each one
167
+ * when `interactive`, then goes again with the answers; otherwise it ends
168
+ * the run naming them. Any other refusal throws.
169
+ */
170
+ async function sendManifest(options: {
171
+ profile: ReturnType<typeof requireProfile>;
172
+ path: string;
173
+ command: string;
174
+ manifest: Record<string, unknown>;
175
+ prefer?: Side;
176
+ interactive: boolean;
177
+ }) {
178
+ const { profile, path, command, manifest, prefer, interactive } = options;
179
+ const resolutions: Resolution = [];
180
+ for (;;) {
181
+ try {
182
+ return await callManagement(profile, "post", path, {
183
+ ...manifest,
184
+ prefer,
185
+ resolutions,
186
+ });
187
+ } catch (error) {
188
+ const refused =
189
+ error instanceof ManagementError
190
+ ? unresolvedSchema.safeParse(error.detail)
191
+ : undefined;
192
+ if (!refused?.success) throw error;
193
+ if (!interactive) failUnresolved(refused.data.conflicts, command);
194
+ for (const conflict of refused.data.conflicts) {
195
+ const keep = await askChoice<Side>(`${conflictLabel(conflict)}. Keep which?`, [
196
+ { value: "live", label: "Keep live" },
197
+ { value: "manifest", label: "Use manifest" },
198
+ ]);
199
+ resolutions.push({ ...conflict, keep });
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ /** How a conflict reads: its entry and the field both sides changed. */
206
+ function conflictLabel({ kind, name, field }: Conflict) {
207
+ return `${kind} ${name}: ${field} changed in both places`;
208
+ }
209
+
210
+ /** Ends a run that does not ask, naming every conflict and how to run `command` again to answer them. */
211
+ function failUnresolved(conflicts: ReadonlyArray<Conflict>, command: string): never {
212
+ const count = conflicts.length;
213
+ return report.fail({
214
+ what: `${count} field${count === 1 ? "" : "s"} changed both in Cortex and in the manifest`,
215
+ blocks: [
216
+ conflicts.map((conflict) => ` ${conflictLabel(conflict)}`).join("\n"),
217
+ "Keep one side for all of them, or run in a terminal without --yes to choose each:",
218
+ ` ${command} --prefer live\n ${command} --prefer manifest`,
219
+ ],
220
+ });
102
221
  }
103
222
 
104
223
  /** How a manifest path prints: relative to the cwd, `./`-prefixed. */
@@ -107,8 +226,12 @@ function label(path: string) {
107
226
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
108
227
  }
109
228
 
110
- /** The manifest's absolute path; a missing file ends the run before anything prints. */
111
- export function findManifest(path: string) {
229
+ /**
230
+ * The manifest's absolute path; a missing file ends the run before anything
231
+ * prints. `command`, when the command also takes a path, is offered as the
232
+ * way to point at a manifest elsewhere.
233
+ */
234
+ export function findManifest(path: string, command?: string) {
112
235
  const absolute = resolve(process.cwd(), path);
113
236
  if (existsSync(absolute)) return absolute;
114
237
  return report.fail({
@@ -116,9 +239,10 @@ export function findManifest(path: string) {
116
239
  blocks:
117
240
  path === DEFAULT_MANIFEST
118
241
  ? [
119
- "cortex apply reads exactly ./cortex.manifest.ts in the directory you run in.\nIt does not search parent directories.",
120
- "Run it from the project root, or point at the file:",
121
- " cortex apply ./deploy/cortex.manifest.ts",
242
+ "The CLI reads exactly ./cortex.manifest.ts in the directory you run in.\nIt does not search parent directories.",
243
+ command
244
+ ? `Run it from the project root, or point at the file:\n ${command} ./deploy/cortex.manifest.ts`
245
+ : "Run it from the project root.",
122
246
  ]
123
247
  : ["The path is resolved from the directory you run in."],
124
248
  });
@@ -129,7 +253,7 @@ export function findManifest(path: string) {
129
253
  * a mistake is named before anything is sent, then sent as written: Cortex
130
254
  * fills its own defaults, which may be newer than this CLI's.
131
255
  */
132
- async function loadManifest(path: string, verbose: boolean) {
256
+ export async function loadManifest(path: string, verbose: boolean) {
133
257
  const { written, parsed } = await loadManifestFile(path).catch((error: unknown) =>
134
258
  report.fail({
135
259
  what: `Could not load ${label(path)}`,
@@ -140,17 +264,23 @@ async function loadManifest(path: string, verbose: boolean) {
140
264
  verbose,
141
265
  }),
142
266
  );
143
- if (!parsed.success) {
267
+ if (!parsed.success || !isRecord(written)) {
144
268
  report.fail({
145
269
  what: `${label(path)} is not a valid manifest`,
146
- blocks: [z.prettifyError(parsed.error)],
270
+ blocks: parsed.success ? [] : [z.prettifyError(parsed.error)],
147
271
  });
148
272
  }
149
273
  return written;
150
274
  }
151
275
 
152
- /** One row per changed entry under its kind: `+` created, `~` updated, `-` deleted. */
153
- function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, string[]>>) {
276
+ /**
277
+ * One row per changed entry under its kind: `+` created, `~` updated, `-`
278
+ * deleted, and `!` a field both the manifest and a live edit changed.
279
+ */
280
+ function printChanges(
281
+ changes: Record<(typeof CHANGES)[number], Record<string, string[]>>,
282
+ conflicts: ReadonlyArray<Conflict>,
283
+ ) {
154
284
  const rows = new Map<string, string[]>();
155
285
  for (const change of CHANGES) {
156
286
  for (const [kind, names] of Object.entries(changes[change])) {
@@ -158,6 +288,9 @@ function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, s
158
288
  rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
159
289
  }
160
290
  }
291
+ for (const { kind, name, field } of conflicts) {
292
+ rows.set(kind, [...(rows.get(kind) ?? []), `! ${name} ${field} changed in both places`]);
293
+ }
161
294
  for (const [kind, lines] of rows) {
162
295
  for (const [index, line] of lines.entries()) {
163
296
  report.row(index === 0 ? kind : "", line, 14);