@lotics/cli 0.76.0 → 0.76.1

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `lotics package *` subcommands — the app-package authoring + dev/test/run loop
3
- * (see docs/app_packages.md). An app *package* is a versioned, workspace-agnostic
3
+ * (see docs/packages.md). An app *package* is a versioned, workspace-agnostic
4
4
  * blueprint Lotics maintains once and installs into many workspaces. This module
5
5
  * implements the full harness:
6
6
  *
@@ -24,7 +24,9 @@
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { tmpdir } from "node:os";
27
+ import { createHash } from "node:crypto";
27
28
  import "./client.js";
29
+ import { knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
28
30
  import { buildStarterTemplate, buildPackageStarterOverrides, STARTER_FALLBACK_SDK_VERSION, } from "./starter_template.js";
29
31
  import { generatePackageAppFields, } from "./generate_package_fields.js";
30
32
  import { appDirName, fetchLatestNpmVersion, runNpm, runTar, writeAppDts } from "./app_commands.js";
@@ -37,6 +39,13 @@ const CONTRACT_FILE = "contract.json";
37
39
  * is in `SOURCE_STAGE_EXCLUDES` — so it never rides along in a published bundle.
38
40
  */
39
41
  const ADOPT_BINDING_FILE = "adopt_binding.json";
42
+ const PACKAGE_TEMPLATE_KINDS = new Set([
43
+ "html",
44
+ "email",
45
+ "excel",
46
+ "word",
47
+ "pdf-form",
48
+ ]);
40
49
  function packageJsonPath(projectDir) {
41
50
  return path.join(projectDir, "package.json");
42
51
  }
@@ -65,11 +74,51 @@ export function readPackageProject(projectDir) {
65
74
  }
66
75
  }
67
76
  }
77
+ const knowledge = {};
78
+ if (isPlainObject(pkg.knowledge)) {
79
+ for (const [alias, entry] of Object.entries(pkg.knowledge)) {
80
+ if (!isPlainObject(entry) || typeof entry.name !== "string" || entry.name.length === 0) {
81
+ throw new Error(`${pkgPath}: lotics.package.knowledge.${alias} must be an object with a non-empty "name".`);
82
+ }
83
+ knowledge[alias] = {
84
+ name: entry.name,
85
+ description: typeof entry.description === "string" ? entry.description : null,
86
+ active_by_default: typeof entry.active_by_default === "boolean" ? entry.active_by_default : true,
87
+ };
88
+ }
89
+ }
90
+ const templates = {};
91
+ if (isPlainObject(pkg.templates)) {
92
+ for (const [alias, entry] of Object.entries(pkg.templates)) {
93
+ if (!isPlainObject(entry) ||
94
+ typeof entry.name !== "string" ||
95
+ entry.name.length === 0 ||
96
+ typeof entry.type !== "string" ||
97
+ !PACKAGE_TEMPLATE_KINDS.has(entry.type) ||
98
+ typeof entry.file !== "string" ||
99
+ entry.file.length === 0) {
100
+ throw new Error(`${pkgPath}: lotics.package.templates.${alias} must be an object with a non-empty "name", ` +
101
+ `a "type" of html|email|excel|word|pdf-form, and a non-empty "file" (a name in templates/).`);
102
+ }
103
+ templates[alias] = {
104
+ name: entry.name,
105
+ type: entry.type,
106
+ file: entry.file,
107
+ };
108
+ }
109
+ }
110
+ const knowledge_expects = Array.isArray(pkg.knowledge_expects)
111
+ ? pkg.knowledge_expects.filter((v) => typeof v === "string")
112
+ : [];
68
113
  const manifest = {
69
114
  id: typeof pkg.id === "string" ? pkg.id : null,
70
115
  name: pkg.name,
71
116
  description: typeof pkg.description === "string" ? pkg.description : null,
117
+ kind: pkg.kind === "content" ? "content" : "app",
72
118
  version: typeof pkg.version === "number" ? pkg.version : null,
119
+ knowledge,
120
+ templates,
121
+ knowledge_expects,
73
122
  dev,
74
123
  };
75
124
  return { pkgJson: parsed, manifest };
@@ -118,7 +167,18 @@ export function draftPackageProjectFromApp(appPkgJson, args) {
118
167
  const { lotics: _appManifest, ...rest } = appPkgJson;
119
168
  return {
120
169
  pkgJson: rest,
121
- manifest: { id: null, name: args.name, description: args.description, version: null, dev: {} },
170
+ manifest: {
171
+ id: null,
172
+ name: args.name,
173
+ description: args.description,
174
+ // Extraction promotes a bespoke APP to a package — always app kind.
175
+ kind: "app",
176
+ version: null,
177
+ knowledge: {},
178
+ templates: {},
179
+ knowledge_expects: [],
180
+ dev: {},
181
+ },
122
182
  };
123
183
  }
124
184
  /**
@@ -140,11 +200,21 @@ export function parseAdoptBindingFile(raw, expectedAppId) {
140
200
  `run "lotics package extract ${expectedAppId}" to produce the right pin.`);
141
201
  }
142
202
  // Boundary adapter: the file is untyped JSON; `isPlainObject` proved the shape
143
- // and the CLI passes the map straight to the server (the validating authority).
203
+ // and the CLI passes the maps straight to the server (the validating authority).
204
+ // `knowledge_binding` is optional (absent for a pre-knowledge extract / an app
205
+ // with no package-managed docs) → defaults to {}.
206
+ const knowledge_binding = {};
207
+ if (isPlainObject(raw.knowledge_binding)) {
208
+ for (const [alias, id] of Object.entries(raw.knowledge_binding)) {
209
+ if (typeof id === "string")
210
+ knowledge_binding[alias] = id;
211
+ }
212
+ }
144
213
  return {
145
214
  app_id: raw.app_id,
146
215
  workspace_id: raw.workspace_id,
147
216
  binding: raw.binding,
217
+ knowledge_binding,
148
218
  };
149
219
  }
150
220
  /**
@@ -199,6 +269,133 @@ function readContract(projectDir) {
199
269
  }
200
270
  return JSON.parse(fs.readFileSync(contractPath, "utf-8"));
201
271
  }
272
+ /**
273
+ * Fold a package project's knowledge corpus into its contract before publish:
274
+ * per `lotics.package.knowledge` alias, read `knowledge/<alias>.md`, sha256 the
275
+ * bytes, and emit the contract's `knowledge` namespace (alias → {name,
276
+ * description, content_ref, content_sha256, active_by_default}) +
277
+ * `knowledge_expects` (from `lotics.package.knowledge_expects`). This is the ONE
278
+ * place the sha is computed — it can't be hand-authored, which is why knowledge
279
+ * (unlike a template `bytes_ref`) is derived rather than written into
280
+ * contract.json. The content files themselves ride in `source.tar.gz`
281
+ * automatically: `knowledge/` is a top-level dir, so `stagePackageSource` copies
282
+ * it like any other source (the same mechanism that carries template bytes). Pure
283
+ * w.r.t. `contract` — returns a new object; the server re-validates the folded
284
+ * namespace at publish.
285
+ */
286
+ export function foldKnowledgeIntoContract(projectDir, project, contract) {
287
+ const aliases = Object.keys(project.manifest.knowledge);
288
+ const expects = project.manifest.knowledge_expects;
289
+ if (aliases.length === 0 && expects.length === 0)
290
+ return contract;
291
+ const knowledge = {};
292
+ for (const alias of aliases) {
293
+ const entry = project.manifest.knowledge[alias];
294
+ const contentRef = `knowledge/${alias}.md`;
295
+ const filePath = path.join(projectDir, "knowledge", `${alias}.md`);
296
+ if (!fs.existsSync(filePath)) {
297
+ throw new Error(`Declared knowledge doc "${alias}" (lotics.package.knowledge) has no ${contentRef} in the project. ` +
298
+ `Create the file, or remove the manifest entry.`);
299
+ }
300
+ const bytes = fs.readFileSync(filePath);
301
+ knowledge[alias] = {
302
+ name: entry.name,
303
+ description: entry.description ?? "",
304
+ content_ref: contentRef,
305
+ content_sha256: createHash("sha256").update(bytes).digest("hex"),
306
+ active_by_default: entry.active_by_default,
307
+ };
308
+ }
309
+ return { ...contract, knowledge, knowledge_expects: expects };
310
+ }
311
+ /**
312
+ * Fold a CONTENT package's manifest-declared templates into its contract before
313
+ * publish: per `lotics.package.templates` alias, an INLINE kind (html/email) reads
314
+ * `templates/<file>` into the contract template's `content`; a FILE-BACKED kind
315
+ * (excel/word/pdf-form) records `bytes_ref: templates/<file>` (the bytes ride in
316
+ * `source.tar.gz` — `templates/` is a top-level dir `stagePackageSource` copies,
317
+ * the same mechanism that carries knowledge content + app-package template bytes).
318
+ * The per-template `content_sha256` is NOT computed here — `foldTemplateShasIntoContract`
319
+ * runs after and derives it (inline: the utf-8 content; file-backed: the file bytes).
320
+ *
321
+ * Appends to any templates the contract already declares (an app package authors
322
+ * templates in `contract.json` directly; the manifest path is how a content
323
+ * package — with no `contract.json` templates — declares them). An alias in BOTH
324
+ * is a loud error. Pure w.r.t. `contract` — returns a new object.
325
+ */
326
+ export function foldTemplatesIntoContract(projectDir, project, contract) {
327
+ if (!isPlainObject(contract)) {
328
+ throw new Error(`${CONTRACT_FILE} in ${projectDir} must be a JSON object.`);
329
+ }
330
+ const aliases = Object.keys(project.manifest.templates);
331
+ if (aliases.length === 0)
332
+ return contract;
333
+ const existing = Array.isArray(contract.templates) ? contract.templates : [];
334
+ const existingAliases = new Set(existing
335
+ .filter((t) => isPlainObject(t))
336
+ .map((t) => t.alias)
337
+ .filter((a) => typeof a === "string"));
338
+ const folded = aliases.map((alias) => {
339
+ if (existingAliases.has(alias)) {
340
+ throw new Error(`Template alias "${alias}" is declared in both lotics.package.templates and ${CONTRACT_FILE} — ` +
341
+ `declare each template once.`);
342
+ }
343
+ const entry = project.manifest.templates[alias];
344
+ const filePath = path.join(projectDir, "templates", entry.file);
345
+ if (!fs.existsSync(filePath)) {
346
+ throw new Error(`Declared template "${alias}" (lotics.package.templates) has no templates/${entry.file} in the project. ` +
347
+ `Stage the file, then rebuild.`);
348
+ }
349
+ if (entry.type === "html" || entry.type === "email") {
350
+ return { alias, label: entry.name, type: entry.type, content: fs.readFileSync(filePath, "utf-8") };
351
+ }
352
+ return { alias, label: entry.name, type: entry.type, bytes_ref: `templates/${entry.file}` };
353
+ });
354
+ return { ...contract, templates: [...existing, ...folded] };
355
+ }
356
+ /**
357
+ * Fold a freshly-computed `content_sha256` into every template of a package
358
+ * contract before publish — the modified-detection reference the install/upgrade
359
+ * flow compares live content against. Like knowledge's sha (foldKnowledge…), it
360
+ * can't be hand-authored: an INLINE template's sha covers its utf-8 `content`; a
361
+ * FILE-BACKED template's sha covers the `bytes_ref` file's bytes (the same bytes
362
+ * that ride in `source.tar.gz` and install stores verbatim). Always recomputed
363
+ * (never trusted from contract.json), so the published contract's sha is exact.
364
+ * Pure w.r.t. `contract` — returns a new object; the server re-validates.
365
+ */
366
+ export function foldTemplateShasIntoContract(projectDir, contract) {
367
+ const templates = contract.templates;
368
+ if (!Array.isArray(templates) || templates.length === 0)
369
+ return contract;
370
+ const withShas = templates.map((template) => {
371
+ if (!isPlainObject(template) ||
372
+ typeof template.type !== "string" ||
373
+ typeof template.alias !== "string") {
374
+ // Malformed — leave as-is; the server's contract validator rejects it.
375
+ return template;
376
+ }
377
+ if (template.type === "html" || template.type === "email") {
378
+ if (typeof template.content !== "string") {
379
+ throw new Error(`Inline template "${template.alias}" must carry a string "content".`);
380
+ }
381
+ return {
382
+ ...template,
383
+ content_sha256: createHash("sha256").update(Buffer.from(template.content, "utf-8")).digest("hex"),
384
+ };
385
+ }
386
+ if (typeof template.bytes_ref !== "string") {
387
+ throw new Error(`File-backed template "${template.alias}" must carry a string "bytes_ref".`);
388
+ }
389
+ const filePath = path.join(projectDir, template.bytes_ref);
390
+ if (!fs.existsSync(filePath)) {
391
+ throw new Error(`Template "${template.alias}" references "${template.bytes_ref}", which is not in the project. ` +
392
+ `Stage the file, then rebuild.`);
393
+ }
394
+ const bytes = fs.readFileSync(filePath);
395
+ return { ...template, content_sha256: createHash("sha256").update(bytes).digest("hex") };
396
+ });
397
+ return { ...contract, templates: withShas };
398
+ }
202
399
  /**
203
400
  * (Re)generate the package project's `.lotics/app_fields.ts` from its
204
401
  * contract.json — the runtime-resolving F/OPT/ROLE surface (see
@@ -242,23 +439,39 @@ export function stagePackageSource(projectDir, sourceStage) {
242
439
  fs.writeFileSync(path.join(sourceStage, "package.json"), JSON.stringify(sanitizePackageJsonForSource(project.pkgJson), null, 2) + "\n");
243
440
  }
244
441
  /**
245
- * Build the publishable bundle: `npm run build` (vite → dist/), then a gzipped
246
- * tarball carrying `source.tar.gz` (the pullable project tree) + `dist.tar.gz`
247
- * (the prebuilt assets) as its two top-level members — the publish↔install
248
- * contract the backend's `extractPackageBundle` reads. Returns the bytes.
442
+ * Build the publishable bundle: a gzipped tarball carrying `source.tar.gz` (the
443
+ * pullable project tree) + `dist.tar.gz` (the prebuilt assets) as its two
444
+ * top-level members — the publish↔install contract the backend's
445
+ * `extractPackageBundle` reads (both members are required). Returns the bytes.
446
+ *
447
+ * An `app` package compiles its frontend (`npm run build` → `dist/`). A
448
+ * `content` package has NO app source to compile, so the build is skipped and a
449
+ * minimal placeholder `dist.tar.gz` is shipped: the bundle format requires the
450
+ * member, but a content install never deploys a frontend (it only reads the
451
+ * `knowledge/*.md` files from `source.tar.gz`), so nothing ever reads it.
249
452
  */
250
453
  async function buildPackageBundle(projectDir) {
251
- console.error("Building...");
252
- await runNpm(["run", "build"], projectDir);
253
- const distDir = path.join(projectDir, "dist");
254
- if (!fs.existsSync(distDir)) {
255
- throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
256
- `Check that 'npm run build' is configured correctly.`);
257
- }
454
+ const { manifest } = readPackageProject(projectDir);
258
455
  // Stage the two member archives in a temp dir, then tar them into the bundle.
259
456
  const stage = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-"));
260
457
  const bundlePath = path.join(tmpdir(), `lotics-bundle-${Date.now()}.tar.gz`);
261
458
  try {
459
+ let distDir;
460
+ if (manifest.kind === "content") {
461
+ console.error("Content package — skipping app build.");
462
+ distDir = path.join(stage, "empty-dist");
463
+ fs.mkdirSync(distDir, { recursive: true });
464
+ fs.writeFileSync(path.join(distDir, ".content-package"), "Content package — no frontend bundle. Content ships in source.tar.gz under knowledge/.\n");
465
+ }
466
+ else {
467
+ console.error("Building...");
468
+ await runNpm(["run", "build"], projectDir);
469
+ distDir = path.join(projectDir, "dist");
470
+ if (!fs.existsSync(distDir)) {
471
+ throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
472
+ `Check that 'npm run build' is configured correctly.`);
473
+ }
474
+ }
262
475
  console.error("Packaging source...");
263
476
  // Stage a copy of the source tree with EXPLICIT top-level excludes, then
264
477
  // tar the stage with no --exclude flags at all. tar exclude patterns are
@@ -328,12 +541,79 @@ export function starterContract(name) {
328
541
  ],
329
542
  };
330
543
  }
544
+ /** An empty contract — all app namespaces empty (knowledge is folded in at build). */
545
+ function emptyContract() {
546
+ return {
547
+ entities: [],
548
+ roles: [],
549
+ templates: [],
550
+ queries: [],
551
+ workflows: [],
552
+ agents: [],
553
+ config: [],
554
+ };
555
+ }
331
556
  /**
332
- * `lotics package new <name> [path]`scaffold a package project. Reuses the
333
- * app starter (Vite+React+TS) for the code surface, swaps its app manifest for a
334
- * package manifest, and adds a starter `contract.json`. The project publishes
335
- * with `lotics package publish` and runs against a dev workspace with
336
- * `lotics package dev`.
557
+ * Scaffold a CONTENT package project — a standalone doc corpus, NO app source.
558
+ * Just a `lotics.package` manifest (kind: "content"), an empty base
559
+ * `contract.json` (the app namespaces MUST stay empty for a content package;
560
+ * `build` folds the docs into the `knowledge` namespace), and a `knowledge/` dir
561
+ * with one example doc wired into the manifest so the scaffold is immediately
562
+ * publishable (publish refuses a content package with zero docs). No npm
563
+ * install — a content package has no dependencies to compile.
564
+ */
565
+ function scaffoldContentPackage(name, targetPath) {
566
+ const exampleAlias = "overview";
567
+ const manifest = {
568
+ id: null,
569
+ name,
570
+ description: null,
571
+ kind: "content",
572
+ version: null,
573
+ knowledge: {
574
+ [exampleAlias]: {
575
+ name: `${name} Overview`,
576
+ description: "What this corpus covers — replace with your doc's summary.",
577
+ active_by_default: true,
578
+ },
579
+ },
580
+ // A content package can also ship standalone document templates — declare them
581
+ // under `lotics.package.templates` with the files in a `templates/` dir. See
582
+ // the commented example printed by the scaffold + docs/packages.md § Content.
583
+ templates: {},
584
+ knowledge_expects: [],
585
+ dev: {},
586
+ };
587
+ const pkgJson = {
588
+ name: appDirName(name),
589
+ private: true,
590
+ version: "0.0.0",
591
+ lotics: { package: manifest },
592
+ };
593
+ fs.writeFileSync(packageJsonPath(targetPath), JSON.stringify(pkgJson, null, 2) + "\n");
594
+ fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(emptyContract(), null, 2) + "\n");
595
+ const knowledgeDir = path.join(targetPath, "knowledge");
596
+ fs.mkdirSync(knowledgeDir, { recursive: true });
597
+ fs.writeFileSync(path.join(knowledgeDir, `${exampleAlias}.md`), `# ${name}\n\nReplace this file with the corpus content. Structure it under clear\n` +
598
+ `Markdown headers so an agent can outline and read it by section.\n`);
599
+ console.error(`Scaffolded a content package into ${targetPath}`);
600
+ console.error(`\nReady. Next steps:`);
601
+ console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
602
+ console.error(` # add docs: create knowledge/<alias>.md + a lotics.package.knowledge.<alias> entry`);
603
+ console.error(` # (name/description/active_by_default), then publish:`);
604
+ console.error(` # add templates: create templates/<file> + a lotics.package.templates.<alias> entry:`);
605
+ console.error(` # "templates": { "quote": { "name": "Quote", "type": "html", "file": "quote.html" } }`);
606
+ console.error(` # inline kinds html|email read templates/<file> as content; file-backed`);
607
+ console.error(` # excel|word|pdf-form pack the bytes into the bundle (bytes_ref = templates/<file>).`);
608
+ console.error(` lotics package publish -m "v1"`);
609
+ console.error(` lotics package install <package_id>`);
610
+ }
611
+ /**
612
+ * `lotics package new <name> [path] [--kind content]` — scaffold a package
613
+ * project. An `app` package reuses the app starter (Vite+React+TS) for the code
614
+ * surface, swaps its app manifest for a package manifest, and adds a starter
615
+ * `contract.json`; a `content` package scaffolds a docs-only corpus (no app
616
+ * source). The project publishes with `lotics package publish`.
337
617
  */
338
618
  export async function packageNew(args) {
339
619
  const targetPath = path.resolve(args.targetPath ?? appDirName(args.name));
@@ -341,6 +621,10 @@ export async function packageNew(args) {
341
621
  throw new Error(`Target directory ${targetPath} is not empty.`);
342
622
  }
343
623
  fs.mkdirSync(targetPath, { recursive: true });
624
+ if ((args.kind ?? "app") === "content") {
625
+ scaffoldContentPackage(args.name, targetPath);
626
+ return;
627
+ }
344
628
  // The starter bakes an app manifest (app_id/workspace_id) into package.json;
345
629
  // a package is workspace-agnostic, so those placeholders are replaced with the
346
630
  // package manifest below. The rest of the starter (config, src, tests) is the
@@ -375,7 +659,11 @@ export async function packageNew(args) {
375
659
  id: null,
376
660
  name: args.name,
377
661
  description: null,
662
+ kind: "app",
378
663
  version: null,
664
+ knowledge: {},
665
+ templates: {},
666
+ knowledge_expects: [],
379
667
  dev: {},
380
668
  };
381
669
  pkg.lotics = { package: manifest };
@@ -418,12 +706,25 @@ export async function packageBuild(args) {
418
706
  */
419
707
  async function publishVersion(client, projectDir, opts) {
420
708
  const project = readPackageProject(projectDir);
421
- const contract = readContract(projectDir);
709
+ const rawContract = readContract(projectDir);
710
+ if (!isPlainObject(rawContract)) {
711
+ throw new Error(`${CONTRACT_FILE} in ${projectDir} must be a JSON object.`);
712
+ }
713
+ // Fold the manifest-declared templates + the knowledge corpus + the per-template
714
+ // content shas into the contract before it is sent. Order: manifest templates
715
+ // FIRST (so their bytes/content land in the contract), then knowledge, then the
716
+ // sha fold LAST (it hashes whatever templates now exist — manifest-folded +
717
+ // contract-authored). All shas are derived (never hand-authored) so the
718
+ // published contract's modified-detection references are exact.
719
+ const contract = foldTemplateShasIntoContract(projectDir, foldKnowledgeIntoContract(projectDir, project, foldTemplatesIntoContract(projectDir, project, rawContract)));
422
720
  let packageId = project.manifest.id;
423
721
  if (packageId === null) {
424
- const pkg = await client.createAppPackage({
722
+ const pkg = await client.createPackage({
425
723
  name: project.manifest.name,
426
724
  description: project.manifest.description,
725
+ // Kind is fixed at package creation and immutable after — a knowledge
726
+ // package must be created as such so publish enforces its constraint.
727
+ kind: project.manifest.kind,
427
728
  });
428
729
  packageId = pkg.id;
429
730
  project.manifest.id = packageId;
@@ -432,7 +733,7 @@ async function publishVersion(client, projectDir, opts) {
432
733
  }
433
734
  const bundle = await buildPackageBundle(projectDir);
434
735
  console.error("Publishing version...");
435
- const version = await client.publishAppPackageVersion(packageId, {
736
+ const version = await client.publishPackageVersion(packageId, {
436
737
  contract,
437
738
  bundle,
438
739
  changelog: opts.changelog ?? null,
@@ -507,13 +808,19 @@ async function syncToDevWorkspace(client, projectDir) {
507
808
  let appId;
508
809
  if (existing) {
509
810
  console.error(`Upgrading dev installation ${existing.app_id} → v${version}...`);
510
- const app = await client.upgradeAppPackage(existing.app_id, { version });
811
+ const app = await client.upgradePackage(existing.app_id, { version });
511
812
  appId = app.id;
512
813
  }
513
814
  else {
514
815
  console.error(`Installing ${package_id} v${version} into dev workspace ${devWorkspaceId}...`);
515
- const app = await client.installAppPackage(package_id, { version });
516
- appId = app.id;
816
+ const result = await client.installPackage(package_id, { version });
817
+ // The dev/sync loop scaffolds an APP installation to run the dev server
818
+ // against; a content package has no app to run.
819
+ if (result.kind !== "app") {
820
+ throw new Error(`Package ${package_id} is a content package — "lotics package dev/sync" run an app installation. ` +
821
+ `Install a content package with "lotics package install ${package_id}".`);
822
+ }
823
+ appId = result.app.id;
517
824
  }
518
825
  project.manifest.dev[devWorkspaceId] = { app_id: appId, version };
519
826
  writePackageManifest(projectDir, project);
@@ -606,7 +913,7 @@ export async function packageReset(client, args) {
606
913
  throw new Error(`No dev installation recorded for workspace ${devWorkspaceId}. Run "lotics package dev --workspace ${devWorkspaceId}" first.`);
607
914
  }
608
915
  console.error(`Resetting dev installation ${pin.app_id} in workspace ${devWorkspaceId}...`);
609
- const app = await client.resetAppPackage(pin.app_id);
916
+ const app = await client.resetPackage(pin.app_id);
610
917
  console.error(`Reset ${app.name} → ${app.id}. The scaffolded tables were dropped and re-created clean.`);
611
918
  }
612
919
  /**
@@ -664,7 +971,7 @@ export function parseResolveFlags(resolve) {
664
971
  */
665
972
  export async function packageDoctor(client, args) {
666
973
  const app_id = resolveInstallationAppId(client, args.app_id);
667
- const health = await client.getAppPackageHealth(app_id);
974
+ const health = await client.getPackageHealth(app_id);
668
975
  console.error(`${health.package_name} — installation ${health.app_id}`);
669
976
  console.error(` Installed: v${health.installed_version} Latest: v${health.latest_version}` +
670
977
  (health.update_available ? " → update available" : ""));
@@ -692,17 +999,52 @@ export async function packageDoctor(client, args) {
692
999
  ` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
693
1000
  process.exitCode = 1;
694
1001
  }
1002
+ // Package-managed knowledge (bundled with this app install): drift + local edits
1003
+ // + unmet expects. All advisory; resolved through the app's package upgrade.
1004
+ if (health.knowledge_drift.length > 0) {
1005
+ console.error(` Knowledge binding drift (${health.knowledge_drift.length}):`);
1006
+ for (const d of health.knowledge_drift) {
1007
+ console.error(` - ${d.alias} "${d.name}" → ${d.doc_id ?? "(unbound)"} (missing from the workspace)`);
1008
+ }
1009
+ }
1010
+ if (health.knowledge_modified.length > 0) {
1011
+ console.error(` Locally edited package knowledge (${health.knowledge_modified.length}):`);
1012
+ for (const m of health.knowledge_modified) {
1013
+ console.error(` - ${m.alias} "${m.name}" (an upgrade overwrites this unless kept)`);
1014
+ }
1015
+ }
1016
+ if (health.missing_expected_docs.length > 0) {
1017
+ console.error(` Missing expected knowledge docs (${health.missing_expected_docs.length}):`);
1018
+ for (const name of health.missing_expected_docs) {
1019
+ console.error(` - "${name}" (the package's agents route to this name; no matching doc exists)`);
1020
+ }
1021
+ }
1022
+ if (health.knowledge_drift.length > 0 || health.knowledge_modified.length > 0) {
1023
+ process.exitCode = 1;
1024
+ }
1025
+ if (health.knowledge_drift.length === 0 &&
1026
+ health.knowledge_modified.length === 0 &&
1027
+ health.missing_expected_docs.length === 0) {
1028
+ console.error(" Knowledge: healthy — bound docs resolve, none locally edited, expects met.");
1029
+ }
695
1030
  if (health.update_available) {
696
1031
  console.error(` Upgrade: lotics package upgrade ${app_id}`);
697
1032
  }
698
1033
  }
699
1034
  /**
700
- * Preview-then-apply upgrade. Prints the additive plan + informational
701
- * removals; refuses (exit 1, with the exact --resolve syntax) while any
702
- * binding drift lacks a resolution.
1035
+ * Preview-then-apply an app-installation upgrade. Prints the additive plan +
1036
+ * informational removals + any bundled-knowledge changes; refuses (exit 1, with
1037
+ * the exact --resolve syntax) while any binding drift, modified core artifact, or
1038
+ * consent-requiring bundled-knowledge doc lacks a resolution.
1039
+ *
1040
+ * `--resolve` is shared across the finding classes: a key naming a bundled
1041
+ * knowledge doc routes to `knowledge_resolutions` (apply|keep|archive|recreate|
1042
+ * unbind), everything else to core `resolutions`; `--bind-to` consents an
1043
+ * added-knowledge-doc name collision; `--apply-all` accepts the package's version
1044
+ * for every consent-requiring knowledge doc (overwriting local edits).
703
1045
  */
704
1046
  export async function packageUpgrade(client, args) {
705
- const preview = await client.previewAppPackageUpgrade(args.app_id, {
1047
+ const preview = await client.previewPackageUpgrade(args.app_id, {
706
1048
  ...(args.version !== undefined ? { version: args.version } : {}),
707
1049
  });
708
1050
  console.error(`Upgrade v${preview.from_version} → v${preview.to_version}` +
@@ -717,6 +1059,12 @@ export async function packageUpgrade(client, args) {
717
1059
  console.error(` Adds: ${added}`);
718
1060
  if (removed)
719
1061
  console.error(` Unbinds (workspace data kept): ${removed}`);
1062
+ if (preview.knowledge.length > 0) {
1063
+ console.error(` Knowledge docs (${preview.knowledge.length}):`);
1064
+ for (const entry of preview.knowledge) {
1065
+ console.error(` ${formatKnowledgeEntryLine(entry)}`);
1066
+ }
1067
+ }
720
1068
  // Breaking contract changes are NOT resolvable via --resolve — scaffold would
721
1069
  // refuse them on a bound alias, and there is no in-flow remediation. Report
722
1070
  // every entry and the only two real remedies, then hard-stop.
@@ -730,9 +1078,31 @@ export async function packageUpgrade(client, args) {
730
1078
  ` before making the change: lotics package eject ${args.app_id}`);
731
1079
  process.exit(1);
732
1080
  }
733
- const unresolvedDrift = preview.drift.filter((d) => args.resolutions[`${d.namespace}.${d.alias}`] === undefined);
734
- const unresolvedModified = preview.modified.filter((m) => args.resolutions[`${m.kind}.${m.alias}`] === undefined);
735
- if (unresolvedDrift.length > 0 || unresolvedModified.length > 0) {
1081
+ // Route the shared --resolve flags: a knowledge-doc alias resolves the doc, a
1082
+ // core artifact key resolves the artifact; a key that is BOTH throws.
1083
+ const coreKeys = new Set([
1084
+ ...preview.drift.map((d) => `${d.namespace}.${d.alias}`),
1085
+ ...preview.modified.map((m) => `${m.kind}.${m.alias}`),
1086
+ ]);
1087
+ const { coreResolve, knowledgeResolutions } = routeAppUpgradeResolve(args.resolve, preview.knowledge, coreKeys);
1088
+ const resolutions = parseResolveFlags(coreResolve);
1089
+ if (args.applyAll) {
1090
+ for (const entry of preview.knowledge) {
1091
+ if (knowledgeEntryNeedsConsent(entry) && knowledgeResolutions[entry.alias] === undefined) {
1092
+ knowledgeResolutions[entry.alias] = knowledgeAcceptResolution(entry.change);
1093
+ }
1094
+ }
1095
+ }
1096
+ const knowledge_resolutions = {
1097
+ resolutions: knowledgeResolutions,
1098
+ bind_to: parseBindToFlags(args.bindTo),
1099
+ };
1100
+ const unresolvedDrift = preview.drift.filter((d) => resolutions[`${d.namespace}.${d.alias}`] === undefined);
1101
+ const unresolvedModified = preview.modified.filter((m) => resolutions[`${m.kind}.${m.alias}`] === undefined);
1102
+ const unresolvedKnowledge = preview.knowledge.filter((e) => knowledgeEntryNeedsConsent(e) && knowledgeResolutions[e.alias] === undefined);
1103
+ if (unresolvedDrift.length > 0 ||
1104
+ unresolvedModified.length > 0 ||
1105
+ unresolvedKnowledge.length > 0) {
736
1106
  if (unresolvedDrift.length > 0) {
737
1107
  console.error(" Binding drift must be resolved before upgrading:");
738
1108
  for (const d of unresolvedDrift) {
@@ -745,14 +1115,36 @@ export async function packageUpgrade(client, args) {
745
1115
  console.error(` --resolve ${m.kind}.${m.alias}=revert (overwrite the local edit; or =keep to retain it)`);
746
1116
  }
747
1117
  }
1118
+ if (unresolvedKnowledge.length > 0) {
1119
+ console.error(" Bundled knowledge docs need consent before upgrading (or --apply-all to accept the package's version for all):");
1120
+ for (const entry of unresolvedKnowledge) {
1121
+ console.error(formatKnowledgeResolveHint(entry));
1122
+ }
1123
+ }
748
1124
  process.exit(1);
749
1125
  }
750
- const app = await client.upgradeAppPackage(args.app_id, {
1126
+ const hasKnowledgeResolutions = Object.keys(knowledge_resolutions.resolutions).length > 0 ||
1127
+ Object.keys(knowledge_resolutions.bind_to).length > 0;
1128
+ const app = await client.upgradePackage(args.app_id, {
751
1129
  ...(args.version !== undefined ? { version: args.version } : {}),
752
- ...(Object.keys(args.resolutions).length > 0 ? { resolutions: args.resolutions } : {}),
1130
+ ...(Object.keys(resolutions).length > 0 ? { resolutions } : {}),
1131
+ ...(hasKnowledgeResolutions ? { knowledge_resolutions } : {}),
753
1132
  });
754
1133
  console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
755
1134
  }
1135
+ /** `--apply-all`'s accept-the-package resolution for a consent-requiring entry. */
1136
+ function knowledgeAcceptResolution(change) {
1137
+ switch (change) {
1138
+ case "changed":
1139
+ return "apply";
1140
+ case "removed":
1141
+ return "archive";
1142
+ case "drifted":
1143
+ return "recreate";
1144
+ case "added":
1145
+ return "apply"; // unreachable (added never needs consent), kept total.
1146
+ }
1147
+ }
756
1148
  /** The install-consent trust line: official > your own org > third-party. */
757
1149
  function trustBadge(pkg) {
758
1150
  if (pkg.is_official)
@@ -764,11 +1156,12 @@ function trustBadge(pkg) {
764
1156
  /**
765
1157
  * `lotics package show <package_id>` — registry metadata + version history
766
1158
  * (trust badge, retirement, per-version channel/yank/changelog). The read
767
- * surface for "what is this package and what shipped when".
1159
+ * surface for "what is this package and what shipped when". Kind-agnostic — a
1160
+ * content package shows here the same way.
768
1161
  */
769
1162
  export async function packageShow(client, args) {
770
- const pkg = await client.getAppPackage(args.package_id);
771
- const { versions } = await client.listAppPackageVersions(args.package_id);
1163
+ const pkg = await client.getPackage(args.package_id);
1164
+ const { versions } = await client.listPackageVersions(args.package_id);
772
1165
  console.error(`${pkg.name} (${pkg.id}) — ${trustBadge(pkg)}`);
773
1166
  if (pkg.description)
774
1167
  console.error(` ${pkg.description}`);
@@ -787,22 +1180,338 @@ export async function packageShow(client, args) {
787
1180
  if (versions.length === 0)
788
1181
  console.error(" (no published versions)");
789
1182
  }
1183
+ /**
1184
+ * One preview line for a knowledge upgrade entry — `[change] alias "name"` with
1185
+ * `modified` / `needs consent` marks. Shared verbatim by the standalone (`pci_`)
1186
+ * and the app-install upgrade previews so both speak one vocabulary; the caller
1187
+ * owns the leading indent.
1188
+ */
1189
+ function formatKnowledgeEntryLine(entry) {
1190
+ const marks = [
1191
+ entry.modified ? "modified" : null,
1192
+ knowledgeEntryNeedsConsent(entry) ? "needs consent" : null,
1193
+ ].filter((m) => m !== null);
1194
+ return `[${entry.change}] ${entry.alias} "${entry.name}"${marks.length ? ` (${marks.join(", ")})` : ""}`;
1195
+ }
1196
+ /**
1197
+ * The exact `--resolve <alias>=<valid options>` remediation line for a
1198
+ * consent-requiring knowledge entry (options from the shared
1199
+ * `validKnowledgeResolutions`). Shared by both upgrade paths' gate output.
1200
+ */
1201
+ function formatKnowledgeResolveHint(entry) {
1202
+ const note = entry.change === "changed" || entry.change === "removed"
1203
+ ? " (a local edit — apply/archive overwrites it; keep retains it)"
1204
+ : " (bound doc is gone; recreate from the package, or unbind)";
1205
+ return ` --resolve ${entry.alias}=${validKnowledgeResolutions(entry.change).join("|")}${note}`;
1206
+ }
1207
+ /**
1208
+ * Route an app-install upgrade's `--resolve` entries into the core-artifact
1209
+ * bucket vs the bundled-knowledge bucket. `--resolve` is shared between the two
1210
+ * finding classes, so each key is matched against the preview: a key that names a
1211
+ * bundled knowledge doc resolves that doc (its value validated against the doc's
1212
+ * change class here — the app path has no separate knowledge-only parser);
1213
+ * anything else stays a core resolution (a `<namespace>.<alias>` drift, a
1214
+ * `<kind>.<alias>` modified core, or a key the server validates). A key that is
1215
+ * BOTH a knowledge alias and a core-artifact key is ambiguous — throw rather than
1216
+ * guess which the operator meant.
1217
+ */
1218
+ export function routeAppUpgradeResolve(resolve, knowledgeEntries, coreKeys) {
1219
+ const byAlias = new Map(knowledgeEntries.map((e) => [e.alias, e]));
1220
+ const coreResolve = [];
1221
+ const knowledgeResolutions = {};
1222
+ for (const entry of resolve) {
1223
+ const eq = entry.indexOf("=");
1224
+ if (eq <= 0 || eq === entry.length - 1) {
1225
+ throw new Error(`Invalid --resolve "${entry}" — expected <key>=<value>.`);
1226
+ }
1227
+ const key = entry.slice(0, eq);
1228
+ const value = entry.slice(eq + 1);
1229
+ const known = byAlias.get(key);
1230
+ if (known && coreKeys.has(key)) {
1231
+ throw new Error(`--resolve "${key}" is ambiguous — it names both a bundled knowledge doc and a core artifact in this upgrade. ` +
1232
+ `Rename one alias so it is unambiguous; refusing to guess.`);
1233
+ }
1234
+ if (known) {
1235
+ const valid = validKnowledgeResolutions(known.change);
1236
+ const match = valid.find((v) => v === value);
1237
+ if (match === undefined) {
1238
+ throw new Error(`Invalid --resolve value "${value}" for knowledge doc "${key}" (${known.change}) — expected ${valid.join("|")}.`);
1239
+ }
1240
+ knowledgeResolutions[key] = match;
1241
+ }
1242
+ else {
1243
+ coreResolve.push(entry);
1244
+ }
1245
+ }
1246
+ return { coreResolve, knowledgeResolutions };
1247
+ }
1248
+ /**
1249
+ * Route a STANDALONE content upgrade's shared `--resolve <alias>=<value>` flags
1250
+ * into the knowledge vs templates buckets by matching each alias against the two
1251
+ * preview namespaces. A doc alias takes apply|keep|archive|recreate|unbind; a
1252
+ * template alias takes revert|keep. An alias present in BOTH namespaces is
1253
+ * ambiguous → loud error (no guess). An alias in NEITHER → error. The value is
1254
+ * validated against the namespace it routes to. Exported for testing.
1255
+ */
1256
+ export function routeContentResolveFlags(resolve, knowledgeAliases, templateAliases) {
1257
+ const knowledge = {};
1258
+ const templates = {};
1259
+ const knowledgeValid = new Set(["apply", "keep", "archive", "recreate", "unbind"]);
1260
+ const templateValid = new Set(["revert", "keep"]);
1261
+ for (const entry of resolve) {
1262
+ const eq = entry.indexOf("=");
1263
+ if (eq <= 0 || eq === entry.length - 1) {
1264
+ throw new Error(`Invalid --resolve "${entry}" — expected <alias>=<value>.`);
1265
+ }
1266
+ const key = entry.slice(0, eq);
1267
+ const value = entry.slice(eq + 1);
1268
+ const isKnowledge = knowledgeAliases.has(key);
1269
+ const isTemplate = templateAliases.has(key);
1270
+ if (isKnowledge && isTemplate) {
1271
+ throw new Error(`--resolve "${key}" is ambiguous — it names both a knowledge doc and a template in this upgrade. ` +
1272
+ `Rename one alias in the package so the two content namespaces are disjoint.`);
1273
+ }
1274
+ if (isTemplate) {
1275
+ if (!templateValid.has(value)) {
1276
+ throw new Error(`Invalid --resolve value "${value}" for template "${key}" — expected revert|keep.`);
1277
+ }
1278
+ templates[key] = value;
1279
+ continue;
1280
+ }
1281
+ if (isKnowledge) {
1282
+ if (!knowledgeValid.has(value)) {
1283
+ throw new Error(`Invalid --resolve value "${value}" for knowledge alias "${key}" — expected apply|keep|archive|recreate|unbind.`);
1284
+ }
1285
+ knowledge[key] = value;
1286
+ continue;
1287
+ }
1288
+ throw new Error(`--resolve "${key}" does not name a knowledge doc or template in this upgrade.`);
1289
+ }
1290
+ return { knowledge, templates };
1291
+ }
1292
+ /** The `--resolve <alias>=revert|keep` remediation line for a consent-requiring template. */
1293
+ function formatTemplateResolveHint(entry) {
1294
+ const note = entry.baseline_unknown
1295
+ ? " (can't verify the local edit — older package version; revert overwrites, keep retains)"
1296
+ : " (a local edit — revert overwrites it with the package's version; keep retains it)";
1297
+ return ` --resolve ${entry.alias}=revert|keep${note}`;
1298
+ }
1299
+ /**
1300
+ * Preview-then-apply a STANDALONE content installation upgrade — knowledge docs
1301
+ * AND document templates behind one review gate. Prints the per-alias plan;
1302
+ * refuses (exit 1, with the exact `--resolve` syntax) while any consent-requiring
1303
+ * entry lacks a resolution — a modified knowledge change/removal or drift, or a
1304
+ * locally-edited changed template. `--apply-all` auto-resolves EVERY consent entry
1305
+ * by accepting the package's version (knowledge changed→apply, removed→archive,
1306
+ * drifted→recreate; template→revert) — an explicit bulk "take upstream" that
1307
+ * discards local edits. `--resolve <alias>=<value>` and `--bind-to <alias>=<kdc_id>`
1308
+ * resolve entries individually (`--resolve` is routed to the right namespace by
1309
+ * matching the alias against the preview).
1310
+ */
1311
+ export async function packageUpgradeKnowledge(client, args) {
1312
+ const preview = await client.previewContentInstallationUpgrade(args.installation_id, {
1313
+ ...(args.version !== undefined ? { version: args.version } : {}),
1314
+ });
1315
+ console.error(`Content upgrade v${preview.from_version} → v${preview.to_version}` +
1316
+ (preview.changelog ? ` — ${preview.changelog}` : ""));
1317
+ const apply = async (resolutions) => {
1318
+ const updated = await client.applyContentInstallationUpgrade(args.installation_id, {
1319
+ ...(args.version !== undefined ? { version: args.version } : {}),
1320
+ resolutions,
1321
+ });
1322
+ console.error(`Upgraded ${args.installation_id} → v${updated.package_version}.`);
1323
+ };
1324
+ if (preview.entries.length === 0 && preview.templates.length === 0) {
1325
+ if (preview.to_version === preview.from_version) {
1326
+ console.error(" Already up to date — no content changes.");
1327
+ return;
1328
+ }
1329
+ console.error(" No changes needing consent — advancing the version pin; clean template updates apply automatically.");
1330
+ await apply({ knowledge: { resolutions: {}, bind_to: {} }, templates: {} });
1331
+ return;
1332
+ }
1333
+ for (const entry of preview.entries) {
1334
+ console.error(` ${formatKnowledgeEntryLine(entry)}`);
1335
+ }
1336
+ for (const entry of preview.templates) {
1337
+ console.error(` [template] ${entry.alias} (modified — needs consent)`);
1338
+ }
1339
+ // Route the shared --resolve flags across the two namespaces (loud on an alias
1340
+ // in both), then optionally bulk-accept every remaining consent entry.
1341
+ const routed = routeContentResolveFlags(args.resolve, new Set(preview.entries.map((e) => e.alias)), new Set(preview.templates.map((t) => t.alias)));
1342
+ const resolutions = {
1343
+ knowledge: { resolutions: { ...routed.knowledge }, bind_to: { ...args.bind_to } },
1344
+ templates: { ...routed.templates },
1345
+ };
1346
+ if (args.applyAll) {
1347
+ for (const entry of preview.entries) {
1348
+ if (knowledgeEntryNeedsConsent(entry) && resolutions.knowledge.resolutions[entry.alias] === undefined) {
1349
+ resolutions.knowledge.resolutions[entry.alias] = knowledgeAcceptResolution(entry.change);
1350
+ }
1351
+ }
1352
+ for (const entry of preview.templates) {
1353
+ if (resolutions.templates[entry.alias] === undefined)
1354
+ resolutions.templates[entry.alias] = "revert";
1355
+ }
1356
+ }
1357
+ const unresolvedKnowledge = preview.entries.filter((entry) => knowledgeEntryNeedsConsent(entry) && resolutions.knowledge.resolutions[entry.alias] === undefined);
1358
+ const unresolvedTemplates = preview.templates.filter((entry) => resolutions.templates[entry.alias] === undefined);
1359
+ if (unresolvedKnowledge.length > 0 || unresolvedTemplates.length > 0) {
1360
+ console.error(" These need a resolution before upgrading (or pass --apply-all to accept the package's version for all):");
1361
+ for (const entry of unresolvedKnowledge)
1362
+ console.error(formatKnowledgeResolveHint(entry));
1363
+ for (const entry of unresolvedTemplates)
1364
+ console.error(formatTemplateResolveHint(entry));
1365
+ process.exit(1);
1366
+ }
1367
+ await apply(resolutions);
1368
+ }
1369
+ /** Parse repeated `--bind-to alias=kdc_id` flags into an alias → doc-id consent map. */
1370
+ export function parseBindToFlags(bindTo) {
1371
+ const map = {};
1372
+ for (const entry of bindTo) {
1373
+ const eq = entry.indexOf("=");
1374
+ if (eq <= 0 || eq === entry.length - 1) {
1375
+ throw new Error(`Invalid --bind-to "${entry}" — expected <alias>=<kdc_id>.`);
1376
+ }
1377
+ map[entry.slice(0, eq)] = entry.slice(eq + 1);
1378
+ }
1379
+ return map;
1380
+ }
1381
+ /** Print advisory `knowledge_expects` misses loudly (never blocks the install). */
1382
+ function warnMissingExpectedDocs(missing) {
1383
+ if (missing.length === 0)
1384
+ return;
1385
+ console.error(` ⚠ ${missing.length} expected knowledge doc(s) the package's agents route to are missing from this workspace:`);
1386
+ for (const name of missing)
1387
+ console.error(` - "${name}"`);
1388
+ console.error(" The package installed, but those agents will degrade until a doc with each name exists.");
1389
+ }
790
1390
  export async function packageInstall(client, args) {
791
1391
  // Surface the trust badge at the consent point: installing materializes the
792
- // package's workflows/agents under YOUR authority, so say whose package it is.
793
- const pkg = await client.getAppPackage(args.package_id);
794
- console.error(`Installing ${pkg.name} ${trustBadge(pkg)}...`);
795
- const app = await client.installAppPackage(args.package_id, {
1392
+ // package's workflows/agents (or its doc corpus) under YOUR authority.
1393
+ const pkg = await client.getPackage(args.package_id);
1394
+ const kindLabel = pkg.kind === "content" ? "content package" : "package";
1395
+ console.error(`Installing ${pkg.name} ${trustBadge(pkg)} (${kindLabel})...`);
1396
+ const result = await client.installPackage(args.package_id, {
796
1397
  ...(args.version !== undefined ? { version: args.version } : {}),
797
- ...(args.config !== undefined ? { config: args.config } : {}),
1398
+ ...(args.bind_to && Object.keys(args.bind_to).length > 0 ? { bind_to: args.bind_to } : {}),
1399
+ ...(args.config && Object.keys(args.config).length > 0 ? { config: args.config } : {}),
798
1400
  });
1401
+ if (result.kind === "content") {
1402
+ const { installation, warnings } = result;
1403
+ const docs = installation.binding.knowledge;
1404
+ const templates = installation.binding.templates;
1405
+ console.error(`Installed ${pkg.name} v${installation.package_version} → content installation ${installation.id} ` +
1406
+ `(workspace ${installation.workspace_id}).`);
1407
+ const docAliases = Object.keys(docs);
1408
+ if (docAliases.length > 0) {
1409
+ console.error(` ${docAliases.length} doc(s) live: ${docAliases.map((a) => `${a}→${docs[a]}`).join(", ")}`);
1410
+ }
1411
+ const templateAliases = Object.keys(templates);
1412
+ if (templateAliases.length > 0) {
1413
+ console.error(` ${templateAliases.length} template(s) live: ${templateAliases.map((a) => `${a}→${templates[a]}`).join(", ")}`);
1414
+ }
1415
+ warnMissingExpectedDocs(warnings.missing_expected_docs);
1416
+ console.error(` Upgrade later: lotics package upgrade ${installation.id}`);
1417
+ console.error(` Uninstall: lotics package uninstall ${installation.id} [--keep-content]`);
1418
+ return;
1419
+ }
1420
+ const { app, knowledge_warnings } = result;
799
1421
  const versionLabel = app.package_version !== null ? `v${app.package_version}` : "(unknown version)";
800
1422
  console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
801
1423
  console.error(" The data model, queries, workflows, and agents are live.");
1424
+ warnMissingExpectedDocs(knowledge_warnings.missing_expected_docs);
802
1425
  console.error(" Pull it for local editing: lotics app pull " + app.id);
803
1426
  }
1427
+ /**
1428
+ * `lotics package uninstall <app_id|pci_id>` — ONE command over both installation
1429
+ * kinds, dispatched by the id form (mirrors `package upgrade`):
1430
+ * - a `pci_` id → a STANDALONE CONTENT installation: deletes the row and (unless
1431
+ * `--keep-content`) archives its package-bound docs AND templates, listing each
1432
+ * archived id.
1433
+ * - anything else (an `app_id`) → an APP installation: archives its workflow
1434
+ * artifacts and — with `--archive-tables` — the scaffolded entity tables
1435
+ * (provenance- + reference-gated server-side).
1436
+ * A flag used on the wrong path is a loud error, never silently ignored.
1437
+ */
1438
+ export async function packageUninstall(client, args) {
1439
+ if (args.id.startsWith("pci_")) {
1440
+ if (args.archive_tables) {
1441
+ throw new Error("--archive-tables applies only to an app installation (<app_id>). A content installation " +
1442
+ "(pci_) has no scaffolded tables — use --keep-content to retain its docs/templates.");
1443
+ }
1444
+ const result = await client.uninstallContentPackage(args.id, {
1445
+ keep_content: args.keep_content,
1446
+ });
1447
+ if (args.keep_content) {
1448
+ console.error(`Uninstalled content installation ${result.installation_id} — its docs and templates were kept as ordinary workspace content.`);
1449
+ }
1450
+ else {
1451
+ console.error(`Uninstalled content installation ${result.installation_id} — archived ` +
1452
+ `${result.archived_doc_ids.length} doc(s) and ${result.archived_template_ids.length} template(s).`);
1453
+ for (const docId of result.archived_doc_ids)
1454
+ console.error(` ${docId}`);
1455
+ for (const templateId of result.archived_template_ids)
1456
+ console.error(` ${templateId}`);
1457
+ }
1458
+ return;
1459
+ }
1460
+ if (args.keep_content) {
1461
+ throw new Error("--keep-content applies only to a standalone content installation (pci_). An app installation " +
1462
+ "(<app_id>) uses --archive-tables to also archive its scaffolded tables.");
1463
+ }
1464
+ const app = await client.getApp(args.id);
1465
+ if (!app.package_id) {
1466
+ throw new Error(`App ${args.id} is not a package installation — use the app delete flow for a bespoke app.`);
1467
+ }
1468
+ const lifecycleCount = Object.keys(app.binding?.workflows ?? {}).length;
1469
+ const boundCount = Object.keys(app.workflows ?? {}).length;
1470
+ const tableCount = Object.keys(app.binding?.entities ?? {}).length;
1471
+ console.error(`Uninstalling ${app.name} (${app.id}):`);
1472
+ console.error(` Archives ${boundCount + lifecycleCount} workflow(s) (${boundCount} app, ${lifecycleCount} lifecycle).`);
1473
+ console.error(args.archive_tables
1474
+ ? ` Archives ${tableCount} scaffolded table(s) — refused if they were adopted or are still referenced.`
1475
+ : ` Leaves the data model (${tableCount} table(s)) intact. Pass --archive-tables to also archive them.`);
1476
+ const result = await client.uninstallAppPackage(args.id, {
1477
+ archive_tables: args.archive_tables,
1478
+ });
1479
+ console.error(`Uninstalled ${app.name} (${app.id}).`);
1480
+ if (result.archived_table_ids.length > 0) {
1481
+ console.error(` Archived tables: ${result.archived_table_ids.join(", ")}`);
1482
+ }
1483
+ }
1484
+ /**
1485
+ * `lotics package list-content` — list the selected workspace's package-managed
1486
+ * content installations (standalone `pci_` corpora + app-bundled ones), each
1487
+ * with its registry status. The read surface that surfaces a standalone `pci_` id
1488
+ * for `upgrade` / `uninstall` (install prints it once; nothing else did before).
1489
+ */
1490
+ export async function packageListContent(client) {
1491
+ const workspaceId = client.getWorkspaceId();
1492
+ if (!workspaceId) {
1493
+ throw new Error("No workspace selected. Pass --workspace <ws> (or select one) to list its content installations.");
1494
+ }
1495
+ const installations = await client.listContentInstallations(workspaceId);
1496
+ if (installations.length === 0) {
1497
+ console.error(`No package-managed content installations in workspace ${workspaceId}.`);
1498
+ return;
1499
+ }
1500
+ console.error(`Content installations in workspace ${workspaceId} (${installations.length}):`);
1501
+ for (const inst of installations) {
1502
+ const name = inst.package_registry?.name ?? "(unknown package)";
1503
+ const latest = inst.package_registry?.latest_version;
1504
+ const updateAvailable = inst.package_registry?.update_available ?? false;
1505
+ const source = inst.app_id === null ? "standalone" : `app-bundled (${inst.app_id})`;
1506
+ const versionLabel = latest !== undefined && latest !== inst.package_version
1507
+ ? `v${inst.package_version} → latest v${latest}`
1508
+ : `v${inst.package_version}`;
1509
+ console.error(` ${inst.id} ${name} ${versionLabel} ${source}` +
1510
+ (updateAvailable ? " → update available" : ""));
1511
+ }
1512
+ }
804
1513
  export async function packageEject(client, args) {
805
- const app = await client.ejectAppPackage(args.app_id);
1514
+ const app = await client.ejectPackage(args.app_id);
806
1515
  console.error(`Ejected ${app.name} → ${app.id} (workspace ${app.workspace_id}).`);
807
1516
  console.error(" The package link is severed — it's now a normal bespoke app and can no longer be upgraded.");
808
1517
  console.error(" Its data model, queries, workflows, and templates are unchanged.");
@@ -888,38 +1597,12 @@ export async function packageConfig(client, args) {
888
1597
  console.log(` ${key} = ${JSON.stringify(config[key])}${marker}`);
889
1598
  }
890
1599
  }
891
- /**
892
- * `lotics package uninstall <app_id> [--archive-tables]` — remove a package
893
- * installation. Prints what will be archived (workflow artifacts, plus the
894
- * scaffolded tables when the flag is set), then uninstalls.
895
- */
896
- export async function packageUninstall(client, args) {
897
- const app = await client.getApp(args.app_id);
898
- if (!app.package_id) {
899
- throw new Error(`App ${args.app_id} is not a package installation — use the app delete flow for a bespoke app.`);
900
- }
901
- const lifecycleCount = Object.keys(app.binding?.workflows ?? {}).length;
902
- const boundCount = Object.keys(app.workflows ?? {}).length;
903
- const tableCount = Object.keys(app.binding?.entities ?? {}).length;
904
- console.error(`Uninstalling ${app.name} (${app.id}):`);
905
- console.error(` Archives ${boundCount + lifecycleCount} workflow(s) (${boundCount} app, ${lifecycleCount} lifecycle).`);
906
- console.error(args.archive_tables
907
- ? ` Archives ${tableCount} scaffolded table(s) — refused if they were adopted or are still referenced.`
908
- : ` Leaves the data model (${tableCount} table(s)) intact. Pass --archive-tables to also archive them.`);
909
- const result = await client.uninstallAppPackage(args.app_id, {
910
- archive_tables: args.archive_tables,
911
- });
912
- console.error(`Uninstalled ${app.name} (${app.id}).`);
913
- if (result.archived_table_ids.length > 0) {
914
- console.error(` Archived tables: ${result.archived_table_ids.join(", ")}`);
915
- }
916
- }
917
1600
  /**
918
1601
  * `lotics package retire <package_id> [--undo]` — retire (or un-retire) a
919
1602
  * registry package. Owner-org admin-only.
920
1603
  */
921
1604
  export async function packageRetire(client, args) {
922
- const pkg = await client.retireAppPackage(args.package_id, { undo: args.undo });
1605
+ const pkg = await client.retirePackage(args.package_id, { undo: args.undo });
923
1606
  if (pkg.retired_at !== null) {
924
1607
  console.error(`Retired ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
925
1608
  `existing installations keep working and may still upgrade.`);
@@ -930,7 +1613,7 @@ export async function packageRetire(client, args) {
930
1613
  }
931
1614
  /**
932
1615
  * `lotics package extract <app_id> [path]` — promote a bespoke app to a DRAFT
933
- * package project (docs/app_packages.md § Promotion). Calls the extract read,
1616
+ * package project (docs/packages.md § Promotion). Calls the extract read,
934
1617
  * prints the findings report grouped by severity, then ALWAYS emits the draft
935
1618
  * project (a broken contract is still the reviewable starting point): the app's
936
1619
  * current source archive (same mechanics as `lotics app pull`) with the app
@@ -940,28 +1623,57 @@ export async function packageRetire(client, args) {
940
1623
  * Exits non-zero when any `error` finding exists — the draft is written, but
941
1624
  * publish re-validates and nothing should ship unreviewed.
942
1625
  */
1626
+ /**
1627
+ * Read an APP project's package.json `lotics.knowledge` (`[{ alias, doc_id }]`)
1628
+ * + `lotics.knowledge_expects` (`string[]`) authoring declaration — the docs the
1629
+ * package will own + the doc NAMES its agents route to but do not own. Distinct
1630
+ * from a PACKAGE project's `lotics.package.knowledge` (a metadata record).
1631
+ */
1632
+ function readAppKnowledgeDeclaration(appPkgJson) {
1633
+ const lotics = isPlainObject(appPkgJson.lotics) ? appPkgJson.lotics : {};
1634
+ const knowledge = [];
1635
+ if (Array.isArray(lotics.knowledge)) {
1636
+ for (const entry of lotics.knowledge) {
1637
+ if (isPlainObject(entry) && typeof entry.alias === "string" && typeof entry.doc_id === "string") {
1638
+ knowledge.push({ alias: entry.alias, doc_id: entry.doc_id });
1639
+ }
1640
+ }
1641
+ }
1642
+ const knowledge_expects = Array.isArray(lotics.knowledge_expects)
1643
+ ? lotics.knowledge_expects.filter((v) => typeof v === "string")
1644
+ : [];
1645
+ return { knowledge, knowledge_expects };
1646
+ }
1647
+ /** Derive the package project's `lotics.package.knowledge` metadata from the extracted contract's knowledge namespace. */
1648
+ function knowledgeManifestFromContract(contract) {
1649
+ const out = {};
1650
+ if (!isPlainObject(contract) || !isPlainObject(contract.knowledge))
1651
+ return out;
1652
+ for (const [alias, entry] of Object.entries(contract.knowledge)) {
1653
+ if (!isPlainObject(entry) || typeof entry.name !== "string")
1654
+ continue;
1655
+ out[alias] = {
1656
+ name: entry.name,
1657
+ description: typeof entry.description === "string" ? entry.description : null,
1658
+ active_by_default: typeof entry.active_by_default === "boolean" ? entry.active_by_default : true,
1659
+ };
1660
+ }
1661
+ return out;
1662
+ }
943
1663
  export async function packageExtract(client, args) {
944
1664
  const app = await client.getApp(args.app_id);
945
1665
  if (!app.current_version_id) {
946
1666
  throw new Error(`App ${app.id} has no deployed version — extract stages its source archive, so deploy it first.`);
947
1667
  }
948
- const extracted = await client.extractAppPackage(args.app_id);
949
- const { lines, hasError } = formatExtractReport(extracted.report);
950
- if (lines.length > 0) {
951
- console.error(`Extraction report (${extracted.report.length}):`);
952
- for (const line of lines)
953
- console.error(line);
954
- }
955
- else {
956
- console.error("Extraction report: no findings.");
957
- }
958
1668
  const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
959
1669
  if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
960
1670
  throw new Error(`Target directory ${targetPath} is not empty.`);
961
1671
  }
962
1672
  fs.mkdirSync(targetPath, { recursive: true });
963
- // Pull the app's current source archive — same mechanics as `lotics app pull`
964
- // (getAppVersion → presigned source URL → download → untar into the project).
1673
+ // Pull the app's current source archive FIRST — same mechanics as `lotics app
1674
+ // pull` (getAppVersion → presigned source URL → download → untar). The app's
1675
+ // package.json (in the archive) carries the `lotics.knowledge` declaration
1676
+ // extract needs, so the download precedes the extract call.
965
1677
  const version = await client.getAppVersion(app.id, app.current_version_id);
966
1678
  const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
967
1679
  const tmpFile = path.join(tmpdir(), `lotics-extract-${app.id}-${Date.now()}.tar.gz`);
@@ -975,10 +1687,31 @@ export async function packageExtract(client, args) {
975
1687
  if (fs.existsSync(tmpFile))
976
1688
  fs.unlinkSync(tmpFile);
977
1689
  }
1690
+ const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
1691
+ const appKnowledge = readAppKnowledgeDeclaration(appPkgJson);
1692
+ // Agents reference docs in free text (uninvertible), so the author DECLARED the
1693
+ // package-managed docs in the app manifest; pass them to extract, which loads
1694
+ // each live doc + emits its content bytes to write locally.
1695
+ const extracted = await client.extractPackage(args.app_id, {
1696
+ knowledge: appKnowledge.knowledge,
1697
+ });
1698
+ const { lines, hasError } = formatExtractReport(extracted.report);
1699
+ if (lines.length > 0) {
1700
+ console.error(`Extraction report (${extracted.report.length}):`);
1701
+ for (const line of lines)
1702
+ console.error(line);
1703
+ }
1704
+ else {
1705
+ console.error("Extraction report: no findings.");
1706
+ }
978
1707
  // Swap the app manifest (lotics.app_id/workspace_id) for a fresh, unpublished
979
1708
  // package manifest — atomic write via the existing manifest writer.
980
- const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
981
1709
  const draft = draftPackageProjectFromApp(appPkgJson, { name: app.name, description: null });
1710
+ // Fold the extracted knowledge metadata + the app's `knowledge_expects` into the
1711
+ // package manifest (the content bytes are written to knowledge/<alias>.md below;
1712
+ // build re-derives the shas from them).
1713
+ draft.manifest.knowledge = knowledgeManifestFromContract(extracted.contract);
1714
+ draft.manifest.knowledge_expects = appKnowledge.knowledge_expects;
982
1715
  // The origin app's package.json rides in the source archive with ITS OWN
983
1716
  // @lotics/app-sdk range — raise it to the getAppBinding floor the generated
984
1717
  // .lotics/app_fields.ts needs (never lower an already-newer range).
@@ -1041,11 +1774,26 @@ export async function packageExtract(client, args) {
1041
1774
  fs.renameSync(downloaded, dest);
1042
1775
  console.error(`Wrote ${tf.bytes_ref}`);
1043
1776
  }
1044
- // The origin pin the `adopt` step reads back. `.lotics` is in
1045
- // SOURCE_STAGE_EXCLUDES, so this never ships in a published bundle.
1777
+ // Write each package-managed knowledge doc's content at `knowledge/<alias>.md`
1778
+ // (the path the contract's content_ref names) build re-derives the sha from
1779
+ // these bytes, so they are the reviewable master alongside contract.json.
1780
+ for (const kf of extracted.knowledge_files) {
1781
+ const dest = path.join(targetPath, kf.content_ref);
1782
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1783
+ fs.writeFileSync(dest, kf.content);
1784
+ console.error(`Wrote ${kf.content_ref}`);
1785
+ }
1786
+ // The origin pin the `adopt` step reads back — incl. the knowledge binding
1787
+ // (alias → origin kdc_ id) so adopt can pin the corpus as installation #1.
1788
+ // `.lotics` is in SOURCE_STAGE_EXCLUDES, so this never ships in a published bundle.
1046
1789
  const dotLotics = path.join(targetPath, ".lotics");
1047
1790
  fs.mkdirSync(dotLotics, { recursive: true });
1048
- fs.writeFileSync(path.join(dotLotics, ADOPT_BINDING_FILE), JSON.stringify({ app_id: app.id, workspace_id: app.workspace_id, binding: extracted.binding }, null, 2) + "\n");
1791
+ fs.writeFileSync(path.join(dotLotics, ADOPT_BINDING_FILE), JSON.stringify({
1792
+ app_id: app.id,
1793
+ workspace_id: app.workspace_id,
1794
+ binding: extracted.binding,
1795
+ knowledge_binding: extracted.knowledge_binding,
1796
+ }, null, 2) + "\n");
1049
1797
  console.error(`Wrote .lotics/${ADOPT_BINDING_FILE}`);
1050
1798
  console.error("Installing npm dependencies...");
1051
1799
  await runNpm(["install"], targetPath);
@@ -1063,7 +1811,7 @@ export async function packageExtract(client, args) {
1063
1811
  }
1064
1812
  /**
1065
1813
  * `lotics package adopt <app_id> [path]` — bind the published package project
1066
- * onto the origin app (docs/app_packages.md § Promotion). Reads the project
1814
+ * onto the origin app (docs/packages.md § Promotion). Reads the project
1067
1815
  * manifest (must be published — refuses otherwise), resolves the version
1068
1816
  * (`--version N` else the manifest's), and reads `.lotics/adopt_binding.json`,
1069
1817
  * REFUSING a pin recorded for a different app. On success the app becomes
@@ -1086,10 +1834,15 @@ export async function packageAdopt(client, args) {
1086
1834
  `"lotics package extract" recorded — run extract to produce this project.`);
1087
1835
  }
1088
1836
  const pin = parseAdoptBindingFile(JSON.parse(fs.readFileSync(bindingPath, "utf-8")), args.app_id);
1089
- const app = await client.adoptAppPackage(args.app_id, {
1837
+ const app = await client.adoptPackage(args.app_id, {
1090
1838
  package_id: manifest.id,
1091
1839
  version,
1092
1840
  binding: pin.binding,
1841
+ // The origin knowledge binding (empty when the app owns no package-managed
1842
+ // docs) — the server verifies it maps the contract's knowledge aliases faithfully.
1843
+ ...(Object.keys(pin.knowledge_binding).length > 0
1844
+ ? { knowledge_binding: pin.knowledge_binding }
1845
+ : {}),
1093
1846
  });
1094
1847
  console.error(`Adopted ${app.name} → installation of ${app.package_id} v${app.package_version}.`);
1095
1848
  console.error(` Verify the installation: lotics package doctor ${app.id}`);
@@ -1108,7 +1861,7 @@ export async function packageAdopt(client, args) {
1108
1861
  * running. Owner-org admin-only.
1109
1862
  */
1110
1863
  export async function packageYank(client, args) {
1111
- const result = await client.yankAppPackageVersion(args.package_id, args.version, !args.undo);
1864
+ const result = await client.yankPackageVersion(args.package_id, args.version, !args.undo);
1112
1865
  if (result.yanked_at !== null) {
1113
1866
  console.error(`Yanked ${result.package_id} v${result.version} (${result.yanked_at}). ` +
1114
1867
  `New installs/upgrades refuse it; pinned installations keep running.`);
@@ -1119,7 +1872,7 @@ export async function packageYank(client, args) {
1119
1872
  console.error(` Latest installable version: ${result.latest_version === 0 ? "none" : `v${result.latest_version}`}`);
1120
1873
  }
1121
1874
  export async function packageFleetUpgrade(client, args) {
1122
- const result = await client.fleetUpgradeAppPackage(args.package_id, {
1875
+ const result = await client.fleetUpgradePackage(args.package_id, {
1123
1876
  ...(args.version !== undefined ? { version: args.version } : {}),
1124
1877
  });
1125
1878
  console.error(`Fleet upgrade of ${result.package_id} → v${result.target_version}:`);