@lotics/cli 0.75.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,10 +24,12 @@
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";
28
- import { buildStarterTemplate, STARTER_FALLBACK_SDK_VERSION } from "./starter_template.js";
29
+ import { knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
30
+ import { buildStarterTemplate, buildPackageStarterOverrides, STARTER_FALLBACK_SDK_VERSION, } from "./starter_template.js";
29
31
  import { generatePackageAppFields, } from "./generate_package_fields.js";
30
- import { appDirName, fetchLatestNpmVersion, runNpm, runTar } from "./app_commands.js";
32
+ import { appDirName, fetchLatestNpmVersion, runNpm, runTar, writeAppDts } from "./app_commands.js";
31
33
  import { writeFileAtomic } from "./file_command_io.js";
32
34
  import { startDevServer, openBrowser } from "./dev/server.js";
33
35
  const CONTRACT_FILE = "contract.json";
@@ -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
+ }
556
+ /**
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
+ }
331
611
  /**
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`.
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
@@ -361,6 +645,12 @@ export async function packageNew(args) {
361
645
  ui_version: uiLatest ? `^${uiLatest}` : undefined,
362
646
  sdk_version: packageSdkRange(sdkLatest),
363
647
  });
648
+ // Package-specific code surface swapped in BY PATH (same mechanism as the
649
+ // package.json manifest swap below): the shared app starter's App.tsx demos
650
+ // in-app routing over static data, wrong for a package. The package starter's
651
+ // App.tsx exercises the binding (F/OPT + useQuery/useConfig), its test mocks the
652
+ // SDK boundary, and its README carries the contract reference.
653
+ const overrides = new Map(buildPackageStarterOverrides({ app_name: args.name }).map((f) => [f.path, f.content]));
364
654
  for (const file of files) {
365
655
  const fullPath = path.join(targetPath, file.path);
366
656
  if (file.path === "package.json") {
@@ -369,7 +659,11 @@ export async function packageNew(args) {
369
659
  id: null,
370
660
  name: args.name,
371
661
  description: null,
662
+ kind: "app",
372
663
  version: null,
664
+ knowledge: {},
665
+ templates: {},
666
+ knowledge_expects: [],
373
667
  dev: {},
374
668
  };
375
669
  pkg.lotics = { package: manifest };
@@ -378,7 +672,7 @@ export async function packageNew(args) {
378
672
  continue;
379
673
  }
380
674
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
381
- fs.writeFileSync(fullPath, file.content);
675
+ fs.writeFileSync(fullPath, overrides.get(file.path) ?? file.content);
382
676
  }
383
677
  fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(starterContract(args.name), null, 2) + "\n");
384
678
  writePackageAppFields(targetPath);
@@ -412,12 +706,25 @@ export async function packageBuild(args) {
412
706
  */
413
707
  async function publishVersion(client, projectDir, opts) {
414
708
  const project = readPackageProject(projectDir);
415
- 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)));
416
720
  let packageId = project.manifest.id;
417
721
  if (packageId === null) {
418
- const pkg = await client.createAppPackage({
722
+ const pkg = await client.createPackage({
419
723
  name: project.manifest.name,
420
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,
421
728
  });
422
729
  packageId = pkg.id;
423
730
  project.manifest.id = packageId;
@@ -426,10 +733,11 @@ async function publishVersion(client, projectDir, opts) {
426
733
  }
427
734
  const bundle = await buildPackageBundle(projectDir);
428
735
  console.error("Publishing version...");
429
- const version = await client.publishAppPackageVersion(packageId, {
736
+ const version = await client.publishPackageVersion(packageId, {
430
737
  contract,
431
738
  bundle,
432
739
  changelog: opts.changelog ?? null,
740
+ channel: opts.channel ?? "release",
433
741
  });
434
742
  project.manifest.version = version.version;
435
743
  writePackageManifest(projectDir, project);
@@ -487,8 +795,12 @@ async function syncToDevWorkspace(client, projectDir) {
487
795
  // Contract may have been edited since the last sync — regenerate the
488
796
  // runtime F/OPT/ROLE surface before the build that publishVersion runs.
489
797
  writePackageAppFields(projectDir);
798
+ // 'dev' channel: a real immutable version this dev installation pins by
799
+ // number, but never what "install latest" resolves to — a broken
800
+ // mid-iteration contract must not become the org-wide installable default.
490
801
  const { package_id, version } = await publishVersion(client, projectDir, {
491
802
  changelog: "dev sync",
803
+ channel: "dev",
492
804
  });
493
805
  // Re-read after publish (publishVersion may have stamped the package id).
494
806
  const project = readPackageProject(projectDir);
@@ -496,17 +808,35 @@ async function syncToDevWorkspace(client, projectDir) {
496
808
  let appId;
497
809
  if (existing) {
498
810
  console.error(`Upgrading dev installation ${existing.app_id} → v${version}...`);
499
- const app = await client.upgradeAppPackage(existing.app_id, { version });
811
+ const app = await client.upgradePackage(existing.app_id, { version });
500
812
  appId = app.id;
501
813
  }
502
814
  else {
503
815
  console.error(`Installing ${package_id} v${version} into dev workspace ${devWorkspaceId}...`);
504
- const app = await client.installAppPackage(package_id, { version });
505
- 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;
506
824
  }
507
825
  project.manifest.dev[devWorkspaceId] = { app_id: appId, version };
508
826
  writePackageManifest(projectDir, project);
509
827
  const installed = await client.getApp(appId);
828
+ // Regenerate the typed `.lotics/app_{queries,workflows,agents}.d.ts` companions
829
+ // from the live installation — the SAME maps (`apps.queries`/`workflows`/`agents`,
830
+ // keyed by the contract's runtime aliases) `lotics app pull` codegens from — so
831
+ // `useQuery("items")` / `useWorkflow` are typed in the package dev loop, not just
832
+ // in pulled app projects. (`.lotics/app_fields.ts`, the runtime F/OPT surface, was
833
+ // already refreshed from the contract before publish.) `writeAppDts` also heals the
834
+ // tsconfig include/exclude so the generated `.d.ts` actually load.
835
+ writeAppDts(projectDir, {
836
+ workflows: installed.workflows ?? undefined,
837
+ queries: installed.queries ?? undefined,
838
+ agents: installed.agents ?? undefined,
839
+ });
510
840
  return { app_id: appId, app_name: installed.name, workspace_id: devWorkspaceId, version };
511
841
  }
512
842
  /**
@@ -583,7 +913,7 @@ export async function packageReset(client, args) {
583
913
  throw new Error(`No dev installation recorded for workspace ${devWorkspaceId}. Run "lotics package dev --workspace ${devWorkspaceId}" first.`);
584
914
  }
585
915
  console.error(`Resetting dev installation ${pin.app_id} in workspace ${devWorkspaceId}...`);
586
- const app = await client.resetAppPackage(pin.app_id);
916
+ const app = await client.resetPackage(pin.app_id);
587
917
  console.error(`Reset ${app.name} → ${app.id}. The scaffolded tables were dropped and re-created clean.`);
588
918
  }
589
919
  /**
@@ -641,7 +971,7 @@ export function parseResolveFlags(resolve) {
641
971
  */
642
972
  export async function packageDoctor(client, args) {
643
973
  const app_id = resolveInstallationAppId(client, args.app_id);
644
- const health = await client.getAppPackageHealth(app_id);
974
+ const health = await client.getPackageHealth(app_id);
645
975
  console.error(`${health.package_name} — installation ${health.app_id}`);
646
976
  console.error(` Installed: v${health.installed_version} Latest: v${health.latest_version}` +
647
977
  (health.update_available ? " → update available" : ""));
@@ -669,17 +999,52 @@ export async function packageDoctor(client, args) {
669
999
  ` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
670
1000
  process.exitCode = 1;
671
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
+ }
672
1030
  if (health.update_available) {
673
1031
  console.error(` Upgrade: lotics package upgrade ${app_id}`);
674
1032
  }
675
1033
  }
676
1034
  /**
677
- * Preview-then-apply upgrade. Prints the additive plan + informational
678
- * removals; refuses (exit 1, with the exact --resolve syntax) while any
679
- * 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).
680
1045
  */
681
1046
  export async function packageUpgrade(client, args) {
682
- const preview = await client.previewAppPackageUpgrade(args.app_id, {
1047
+ const preview = await client.previewPackageUpgrade(args.app_id, {
683
1048
  ...(args.version !== undefined ? { version: args.version } : {}),
684
1049
  });
685
1050
  console.error(`Upgrade v${preview.from_version} → v${preview.to_version}` +
@@ -694,6 +1059,12 @@ export async function packageUpgrade(client, args) {
694
1059
  console.error(` Adds: ${added}`);
695
1060
  if (removed)
696
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
+ }
697
1068
  // Breaking contract changes are NOT resolvable via --resolve — scaffold would
698
1069
  // refuse them on a bound alias, and there is no in-flow remediation. Report
699
1070
  // every entry and the only two real remedies, then hard-stop.
@@ -707,9 +1078,31 @@ export async function packageUpgrade(client, args) {
707
1078
  ` before making the change: lotics package eject ${args.app_id}`);
708
1079
  process.exit(1);
709
1080
  }
710
- const unresolvedDrift = preview.drift.filter((d) => args.resolutions[`${d.namespace}.${d.alias}`] === undefined);
711
- const unresolvedModified = preview.modified.filter((m) => args.resolutions[`${m.kind}.${m.alias}`] === undefined);
712
- 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) {
713
1106
  if (unresolvedDrift.length > 0) {
714
1107
  console.error(" Binding drift must be resolved before upgrading:");
715
1108
  for (const d of unresolvedDrift) {
@@ -722,39 +1115,505 @@ export async function packageUpgrade(client, args) {
722
1115
  console.error(` --resolve ${m.kind}.${m.alias}=revert (overwrite the local edit; or =keep to retain it)`);
723
1116
  }
724
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
+ }
725
1124
  process.exit(1);
726
1125
  }
727
- 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, {
728
1129
  ...(args.version !== undefined ? { version: args.version } : {}),
729
- ...(Object.keys(args.resolutions).length > 0 ? { resolutions: args.resolutions } : {}),
1130
+ ...(Object.keys(resolutions).length > 0 ? { resolutions } : {}),
1131
+ ...(hasKnowledgeResolutions ? { knowledge_resolutions } : {}),
730
1132
  });
731
1133
  console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
732
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
+ }
1148
+ /** The install-consent trust line: official > your own org > third-party. */
1149
+ function trustBadge(pkg) {
1150
+ if (pkg.is_official)
1151
+ return "official Lotics package";
1152
+ if (pkg.owned_by_caller === true)
1153
+ return "your organization's package";
1154
+ return "third-party package (runs under your authority once installed)";
1155
+ }
1156
+ /**
1157
+ * `lotics package show <package_id>` — registry metadata + version history
1158
+ * (trust badge, retirement, per-version channel/yank/changelog). The read
1159
+ * surface for "what is this package and what shipped when". Kind-agnostic — a
1160
+ * content package shows here the same way.
1161
+ */
1162
+ export async function packageShow(client, args) {
1163
+ const pkg = await client.getPackage(args.package_id);
1164
+ const { versions } = await client.listPackageVersions(args.package_id);
1165
+ console.error(`${pkg.name} (${pkg.id}) — ${trustBadge(pkg)}`);
1166
+ if (pkg.description)
1167
+ console.error(` ${pkg.description}`);
1168
+ if (pkg.retired_at)
1169
+ console.error(` RETIRED ${pkg.retired_at}`);
1170
+ console.error(` latest installable: ${pkg.latest_version > 0 ? `v${pkg.latest_version}` : "none"}`);
1171
+ console.error("");
1172
+ for (const v of versions) {
1173
+ const marks = [
1174
+ v.version === pkg.latest_version ? "*" : " ",
1175
+ v.channel === "dev" ? "dev" : " ",
1176
+ v.yanked_at ? "YANKED" : " ",
1177
+ ].join(" ");
1178
+ console.log(`${marks} v${v.version} ${v.created_at} ${v.changelog ?? ""}`.trimEnd());
1179
+ }
1180
+ if (versions.length === 0)
1181
+ console.error(" (no published versions)");
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
+ }
733
1390
  export async function packageInstall(client, args) {
734
1391
  // Surface the trust badge at the consent point: installing materializes the
735
- // package's workflows/agents under YOUR authority, so say whose package it is.
736
- const pkg = await client.getAppPackage(args.package_id);
737
- console.error(pkg.is_official
738
- ? `Installing ${pkg.name} — official Lotics package...`
739
- : `Installing ${pkg.name} third-party package (runs under your authority once installed)...`);
740
- 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, {
741
1397
  ...(args.version !== undefined ? { version: args.version } : {}),
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 } : {}),
742
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;
743
1421
  const versionLabel = app.package_version !== null ? `v${app.package_version}` : "(unknown version)";
744
1422
  console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
745
1423
  console.error(" The data model, queries, workflows, and agents are live.");
1424
+ warnMissingExpectedDocs(knowledge_warnings.missing_expected_docs);
746
1425
  console.error(" Pull it for local editing: lotics app pull " + app.id);
747
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
+ }
748
1513
  export async function packageEject(client, args) {
749
- const app = await client.ejectAppPackage(args.app_id);
1514
+ const app = await client.ejectPackage(args.app_id);
750
1515
  console.error(`Ejected ${app.name} → ${app.id} (workspace ${app.workspace_id}).`);
751
1516
  console.error(" The package link is severed — it's now a normal bespoke app and can no longer be upgraded.");
752
1517
  console.error(" Its data model, queries, workflows, and templates are unchanged.");
753
1518
  console.error(" Pull the pinned source for local editing: lotics app pull " + app.id);
754
1519
  }
1520
+ /**
1521
+ * Parse one `key=value` config assignment. When the knob's type is known (from
1522
+ * the stored value at `package config --set`) the value is parsed to that type
1523
+ * loudly; otherwise (`package install --config`) it is inferred (true/false →
1524
+ * boolean, numeric → number, else string) and the server validates it against
1525
+ * the contract.
1526
+ */
1527
+ function parseConfigAssignment(entry, flag, knownType) {
1528
+ const eq = entry.indexOf("=");
1529
+ if (eq <= 0 || eq === entry.length - 1) {
1530
+ throw new Error(`Invalid ${flag} "${entry}" — expected key=value.`);
1531
+ }
1532
+ const key = entry.slice(0, eq);
1533
+ const raw = entry.slice(eq + 1);
1534
+ if (knownType === "number") {
1535
+ const n = Number(raw);
1536
+ if (!Number.isFinite(n))
1537
+ throw new Error(`Config key "${key}" is a number knob — "${raw}" is not numeric.`);
1538
+ return { key, value: n };
1539
+ }
1540
+ if (knownType === "boolean") {
1541
+ if (raw !== "true" && raw !== "false") {
1542
+ throw new Error(`Config key "${key}" is a boolean knob — expected true or false, got "${raw}".`);
1543
+ }
1544
+ return { key, value: raw === "true" };
1545
+ }
1546
+ if (knownType === "string")
1547
+ return { key, value: raw };
1548
+ // Inferred (install override): the server re-validates against the contract.
1549
+ if (raw === "true" || raw === "false")
1550
+ return { key, value: raw === "true" };
1551
+ if (/^-?\d+(\.\d+)?$/.test(raw))
1552
+ return { key, value: Number(raw) };
1553
+ return { key, value: raw };
1554
+ }
1555
+ /** `--config key=value` (install): inferred types, server-validated. */
1556
+ export function parseInstallConfigFlags(config) {
1557
+ const out = {};
1558
+ for (const entry of config) {
1559
+ const { key, value } = parseConfigAssignment(entry, "--config");
1560
+ out[key] = value;
1561
+ }
1562
+ return out;
1563
+ }
1564
+ /**
1565
+ * `lotics package config <app_id>` — show the installation's effective config;
1566
+ * with `--set key=value` (repeatable) partial-merge edits, each value parsed by
1567
+ * the knob's current type. No `--set` prints the values.
1568
+ */
1569
+ export async function packageConfig(client, args) {
1570
+ const app = await client.getApp(args.app_id);
1571
+ if (!app.package_id) {
1572
+ throw new Error(`App ${args.app_id} is not a package installation — it has no config.`);
1573
+ }
1574
+ const current = app.config ?? {};
1575
+ if (args.sets.length === 0) {
1576
+ const keys = Object.keys(current).sort();
1577
+ if (keys.length === 0) {
1578
+ console.error(`${app.name} (${app.id}) — no config knobs.`);
1579
+ return;
1580
+ }
1581
+ console.error(`${app.name} (${app.id}) config:`);
1582
+ for (const key of keys)
1583
+ console.log(` ${key} = ${JSON.stringify(current[key])}`);
1584
+ return;
1585
+ }
1586
+ const overrides = {};
1587
+ for (const entry of args.sets) {
1588
+ const key = entry.slice(0, Math.max(0, entry.indexOf("=")));
1589
+ const knownType = typeof current[key];
1590
+ const { value } = parseConfigAssignment(entry, "--set", knownType === "number" || knownType === "boolean" || knownType === "string" ? knownType : undefined);
1591
+ overrides[key] = value;
1592
+ }
1593
+ const { config } = await client.updateAppPackageConfig(args.app_id, { config: overrides });
1594
+ console.error(`Updated config for ${app.name} (${app.id}):`);
1595
+ for (const key of Object.keys(config).sort()) {
1596
+ const marker = key in overrides ? " *" : "";
1597
+ console.log(` ${key} = ${JSON.stringify(config[key])}${marker}`);
1598
+ }
1599
+ }
1600
+ /**
1601
+ * `lotics package retire <package_id> [--undo]` — retire (or un-retire) a
1602
+ * registry package. Owner-org admin-only.
1603
+ */
1604
+ export async function packageRetire(client, args) {
1605
+ const pkg = await client.retirePackage(args.package_id, { undo: args.undo });
1606
+ if (pkg.retired_at !== null) {
1607
+ console.error(`Retired ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
1608
+ `existing installations keep working and may still upgrade.`);
1609
+ }
1610
+ else {
1611
+ console.error(`Un-retired ${pkg.name} (${pkg.id}) — installable again.`);
1612
+ }
1613
+ }
755
1614
  /**
756
1615
  * `lotics package extract <app_id> [path]` — promote a bespoke app to a DRAFT
757
- * package project (docs/app_packages.md § Promotion). Calls the extract read,
1616
+ * package project (docs/packages.md § Promotion). Calls the extract read,
758
1617
  * prints the findings report grouped by severity, then ALWAYS emits the draft
759
1618
  * project (a broken contract is still the reviewable starting point): the app's
760
1619
  * current source archive (same mechanics as `lotics app pull`) with the app
@@ -764,28 +1623,57 @@ export async function packageEject(client, args) {
764
1623
  * Exits non-zero when any `error` finding exists — the draft is written, but
765
1624
  * publish re-validates and nothing should ship unreviewed.
766
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
+ }
767
1663
  export async function packageExtract(client, args) {
768
1664
  const app = await client.getApp(args.app_id);
769
1665
  if (!app.current_version_id) {
770
1666
  throw new Error(`App ${app.id} has no deployed version — extract stages its source archive, so deploy it first.`);
771
1667
  }
772
- const extracted = await client.extractAppPackage(args.app_id);
773
- const { lines, hasError } = formatExtractReport(extracted.report);
774
- if (lines.length > 0) {
775
- console.error(`Extraction report (${extracted.report.length}):`);
776
- for (const line of lines)
777
- console.error(line);
778
- }
779
- else {
780
- console.error("Extraction report: no findings.");
781
- }
782
1668
  const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
783
1669
  if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
784
1670
  throw new Error(`Target directory ${targetPath} is not empty.`);
785
1671
  }
786
1672
  fs.mkdirSync(targetPath, { recursive: true });
787
- // Pull the app's current source archive — same mechanics as `lotics app pull`
788
- // (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.
789
1677
  const version = await client.getAppVersion(app.id, app.current_version_id);
790
1678
  const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
791
1679
  const tmpFile = path.join(tmpdir(), `lotics-extract-${app.id}-${Date.now()}.tar.gz`);
@@ -799,10 +1687,31 @@ export async function packageExtract(client, args) {
799
1687
  if (fs.existsSync(tmpFile))
800
1688
  fs.unlinkSync(tmpFile);
801
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
+ }
802
1707
  // Swap the app manifest (lotics.app_id/workspace_id) for a fresh, unpublished
803
1708
  // package manifest — atomic write via the existing manifest writer.
804
- const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
805
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;
806
1715
  // The origin app's package.json rides in the source archive with ITS OWN
807
1716
  // @lotics/app-sdk range — raise it to the getAppBinding floor the generated
808
1717
  // .lotics/app_fields.ts needs (never lower an already-newer range).
@@ -865,11 +1774,26 @@ export async function packageExtract(client, args) {
865
1774
  fs.renameSync(downloaded, dest);
866
1775
  console.error(`Wrote ${tf.bytes_ref}`);
867
1776
  }
868
- // The origin pin the `adopt` step reads back. `.lotics` is in
869
- // 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.
870
1789
  const dotLotics = path.join(targetPath, ".lotics");
871
1790
  fs.mkdirSync(dotLotics, { recursive: true });
872
- 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");
873
1797
  console.error(`Wrote .lotics/${ADOPT_BINDING_FILE}`);
874
1798
  console.error("Installing npm dependencies...");
875
1799
  await runNpm(["install"], targetPath);
@@ -887,7 +1811,7 @@ export async function packageExtract(client, args) {
887
1811
  }
888
1812
  /**
889
1813
  * `lotics package adopt <app_id> [path]` — bind the published package project
890
- * onto the origin app (docs/app_packages.md § Promotion). Reads the project
1814
+ * onto the origin app (docs/packages.md § Promotion). Reads the project
891
1815
  * manifest (must be published — refuses otherwise), resolves the version
892
1816
  * (`--version N` else the manifest's), and reads `.lotics/adopt_binding.json`,
893
1817
  * REFUSING a pin recorded for a different app. On success the app becomes
@@ -910,10 +1834,15 @@ export async function packageAdopt(client, args) {
910
1834
  `"lotics package extract" recorded — run extract to produce this project.`);
911
1835
  }
912
1836
  const pin = parseAdoptBindingFile(JSON.parse(fs.readFileSync(bindingPath, "utf-8")), args.app_id);
913
- const app = await client.adoptAppPackage(args.app_id, {
1837
+ const app = await client.adoptPackage(args.app_id, {
914
1838
  package_id: manifest.id,
915
1839
  version,
916
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
+ : {}),
917
1846
  });
918
1847
  console.error(`Adopted ${app.name} → installation of ${app.package_id} v${app.package_version}.`);
919
1848
  console.error(` Verify the installation: lotics package doctor ${app.id}`);
@@ -932,7 +1861,7 @@ export async function packageAdopt(client, args) {
932
1861
  * running. Owner-org admin-only.
933
1862
  */
934
1863
  export async function packageYank(client, args) {
935
- 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);
936
1865
  if (result.yanked_at !== null) {
937
1866
  console.error(`Yanked ${result.package_id} v${result.version} (${result.yanked_at}). ` +
938
1867
  `New installs/upgrades refuse it; pinned installations keep running.`);
@@ -943,7 +1872,7 @@ export async function packageYank(client, args) {
943
1872
  console.error(` Latest installable version: ${result.latest_version === 0 ? "none" : `v${result.latest_version}`}`);
944
1873
  }
945
1874
  export async function packageFleetUpgrade(client, args) {
946
- const result = await client.fleetUpgradeAppPackage(args.package_id, {
1875
+ const result = await client.fleetUpgradePackage(args.package_id, {
947
1876
  ...(args.version !== undefined ? { version: args.version } : {}),
948
1877
  });
949
1878
  console.error(`Fleet upgrade of ${result.package_id} → v${result.target_version}:`);