knodin 0.8.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +6 -0
  2. package/dist/bin/cli.js +459 -95
  3. package/dist/bin/launcher.js +0 -0
  4. package/dist/fixtures/update-verification/config/application.xml +4 -0
  5. package/dist/fixtures/update-verification/dbt/manifest.json +17 -0
  6. package/dist/fixtures/update-verification/dbt/models/accounts.sql +1 -0
  7. package/dist/fixtures/update-verification/dbt/models/contacts.sql +1 -0
  8. package/dist/fixtures/update-verification/fixture.json +147 -0
  9. package/dist/fixtures/update-verification/force-app/main/default/classes/UpdateFixture.cls +14 -0
  10. package/dist/fixtures/update-verification/force-app/main/default/flows/Update_Verification.flow-meta.xml +9 -0
  11. package/dist/fixtures/update-verification/package.json +5 -0
  12. package/dist/fixtures/update-verification/prisma/schema.prisma +10 -0
  13. package/dist/fixtures/update-verification/sql/functions.sql +16 -0
  14. package/dist/fixtures/update-verification/src/CsharpFixture.cs +6 -0
  15. package/dist/fixtures/update-verification/src/JavaFixture.java +9 -0
  16. package/dist/fixtures/update-verification/src/javascript.js +7 -0
  17. package/dist/fixtures/update-verification/src/python_fixture.py +6 -0
  18. package/dist/fixtures/update-verification/src/typescript.ts +7 -0
  19. package/dist/fixtures/update-verification/terraform/main.tf +7 -0
  20. package/dist/fixtures/update-verification/terraform/modules/verified-child/main.tf +3 -0
  21. package/dist/src/agent-integration.js +26 -3
  22. package/dist/src/backup-retention.js +811 -0
  23. package/dist/src/cli-model.js +66 -9
  24. package/dist/src/docs-sections.js +1 -0
  25. package/dist/src/doctor.js +142 -12
  26. package/dist/src/engine/embeddings.js +8 -1
  27. package/dist/src/engine/index.js +355 -292
  28. package/dist/src/init.js +223 -164
  29. package/dist/src/manager-update.js +455 -0
  30. package/dist/src/release-preflight.js +106 -95
  31. package/dist/src/repair-lease.js +77 -8
  32. package/dist/src/repository-management.js +219 -3
  33. package/dist/src/response-budget.js +5 -1
  34. package/dist/src/server.js +18 -5
  35. package/dist/src/shared-index/compatibility.js +4 -2
  36. package/dist/src/skill-management.js +149 -0
  37. package/dist/src/system-config.js +176 -16
  38. package/dist/src/system-management.js +508 -0
  39. package/dist/src/tools/knodin-tools.js +174 -22
  40. package/dist/src/update-ceremony.js +20 -24
  41. package/dist/src/update-coordination.js +158 -0
  42. package/dist/src/update-executor.js +336 -0
  43. package/dist/src/update-verifier.js +358 -0
  44. package/docs/BACKUP-RETENTION.md +54 -0
  45. package/docs/CLI.md +13 -0
  46. package/docs/DOCTOR-AND-UPDATES.md +44 -12
  47. package/docs/SIGNED-UPDATES.md +29 -22
  48. package/docs/SYSTEMS-AND-RELATIONSHIPS.md +27 -6
  49. package/docs/prompts/declare-multi-repository-system.md +18 -0
  50. package/docs/releases/0.8.7.md +27 -0
  51. package/docs/releases/0.9.0.md +43 -0
  52. package/package.json +11 -2
  53. package/roadmap/competitive-roadmap.md +8 -0
  54. package/skills/knodin-debug/SKILL.md +12 -0
  55. package/skills/knodin-debug/agents/openai.yaml +4 -0
  56. package/skills/knodin-explore/SKILL.md +11 -0
  57. package/skills/knodin-explore/agents/openai.yaml +4 -0
  58. package/skills/knodin-impact/SKILL.md +11 -0
  59. package/skills/knodin-impact/agents/openai.yaml +4 -0
  60. package/skills/knodin-lifecycle/SKILL.md +12 -0
  61. package/skills/knodin-lifecycle/agents/openai.yaml +4 -0
  62. package/skills/knodin-refactor/SKILL.md +12 -0
  63. package/skills/knodin-refactor/agents/openai.yaml +4 -0
  64. package/skills/knodin-review/SKILL.md +11 -0
  65. package/skills/knodin-review/agents/openai.yaml +4 -0
  66. package/skills/knodin-systems/SKILL.md +14 -0
  67. package/skills/knodin-systems/agents/openai.yaml +4 -0
@@ -24,6 +24,7 @@ import { diagnoseFailure, } from "../failure-diagnosis.js";
24
24
  import { decorateGraphQueryResult, inspectGraphQueryHealth } from "../graph-query-health.js";
25
25
  import { inspectRepositoryIntegrationStatus } from "../init.js";
26
26
  import { attachLifecycleHealth, attachRepairLifecycle } from "../lifecycle-health.js";
27
+ import { readManagerUpdateState, readUpdateJournal, updateAttention } from "../manager-update.js";
27
28
  import { listMirrors } from "../mirror.js";
28
29
  import { compressOutput, compressOutputFile, deleteOutputArtifact, readOutputArtifact, } from "../output-compression.js";
29
30
  import { appendTelemetryRecord, clearTelemetry, countOutputTokens, exportTelemetry, measureOutput, readTelemetryRecords, telemetryStatus, writeTelemetryReport, } from "../output-telemetry.js";
@@ -35,7 +36,8 @@ import { discoverRepositories, initializeRepositories, inventoryRepository, sear
35
36
  import { applyResponseBudget } from "../response-budget.js";
36
37
  import { loadSharedIndexConfig } from "../shared-index/config.js";
37
38
  import { EMPTY_OVERLAY, readSharedIndexProvenance, readSharedRestoreAttempt, } from "../shared-index/provenance.js";
38
- import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, systemMembershipsForPath, validateSystemHealth, } from "../system-config.js";
39
+ import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, RELATIONSHIP_TYPES, systemMembershipsForPath, validateSystemHealth, } from "../system-config.js";
40
+ import { applySystemPlan, planSystemImport, planSystemLink, planSystemRelate, planSystemUnlink, planSystemUnrelate, } from "../system-management.js";
39
41
  import { trustedUpdateStatus } from "../update-policy.js";
40
42
  import { waitForFresh } from "../wait-for-fresh.js";
41
43
  import { inspectWorktrees, reconcileWorktrees, removeManagedWorktree, } from "../worktree-lifecycle.js";
@@ -157,6 +159,13 @@ const GRAPH_INDEPENDENT_QUERY_PATTERNS = new Set([
157
159
  "lsp_implementations",
158
160
  "file_metrics",
159
161
  ]);
162
+ const BATCH_QUERY_PATTERNS = new Set([
163
+ "callers_of",
164
+ "callees_of",
165
+ "imports_of",
166
+ "importers_of",
167
+ "file_summary",
168
+ ]);
160
169
  function buildDocumentedKnodinTools() {
161
170
  return [
162
171
  {
@@ -317,7 +326,7 @@ function buildDocumentedKnodinTools() {
317
326
  },
318
327
  from: {
319
328
  type: "string",
320
- description: "review: older ref of an explicit revision pair; requires `to`.",
329
+ description: "review: older ref of a revision pair; system relate/unrelate: source component id.",
321
330
  },
322
331
  toRevision: {
323
332
  type: "string",
@@ -494,9 +503,36 @@ function buildDocumentedKnodinTools() {
494
503
  },
495
504
  systemAction: {
496
505
  type: "string",
497
- enum: ["list", "show", "validate", "query"],
498
- description: "system only: list configured systems, show or validate one system, or query its declared relationships.",
506
+ enum: [
507
+ "list",
508
+ "show",
509
+ "validate",
510
+ "query",
511
+ "link",
512
+ "relate",
513
+ "unlink",
514
+ "unrelate",
515
+ "import",
516
+ ],
499
517
  },
518
+ planDigest: {
519
+ type: "string",
520
+ description: "system writes: digest returned by the approved preview",
521
+ },
522
+ repositories: { type: "array", items: { type: "string" } },
523
+ scope: { type: "string", enum: ["personal", "team"] },
524
+ distributed: { type: "boolean" },
525
+ repositoryIds: {
526
+ type: "object",
527
+ additionalProperties: { type: "string" },
528
+ },
529
+ components: {
530
+ type: "object",
531
+ additionalProperties: { type: "object" },
532
+ },
533
+ evidence: { type: "string" },
534
+ relationshipType: { type: "string", enum: RELATIONSHIP_TYPES },
535
+ requireComplete: { type: "boolean" },
500
536
  repositoryAction: {
501
537
  type: "string",
502
538
  enum: ["discover", "init", "status", "doctor", "search"],
@@ -731,14 +767,14 @@ const RESPONSE_DEFAULTS = {
731
767
  "diagnostics:archive": { bytes: 65_536, tokens: 16_384, items: 100 },
732
768
  default: { bytes: 65_536, tokens: 16_384, items: 100 },
733
769
  };
734
- async function populateRepositoryDiagnoses(records, repositories, configRoot) {
770
+ async function populateRepositoryDiagnoses(records, repositories) {
735
771
  repositories.length = 0;
736
772
  for (const record of records) {
737
773
  try {
738
774
  const graph = attachLifecycleHealth(record.path, await engine.status(record.path, { audit: "deep" }));
739
775
  const repository = await inventoryRepository(record, {
740
776
  status: async () => graph,
741
- systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(configRoot), target),
777
+ systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(target), target),
742
778
  });
743
779
  repositories.push({
744
780
  ...repository,
@@ -770,12 +806,11 @@ async function handleRepositoriesOperation(options) {
770
806
  };
771
807
  const inventory = async () => {
772
808
  const discovery = await discoverRepositories(discoveryRoots, discoveryOptions);
773
- const systemConfig = loadSystemConfiguration(discoveryRoots[0] ?? repo);
774
809
  const repositories = [];
775
810
  for (const record of discovery.repositories) {
776
811
  repositories.push(await inventoryRepository(record, {
777
812
  status: async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })),
778
- systemMemberships: (target) => systemMembershipsForPath(systemConfig, target),
813
+ systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(target), target),
779
814
  }));
780
815
  }
781
816
  return { discovery, repositories };
@@ -792,8 +827,12 @@ async function handleRepositoriesOperation(options) {
792
827
  worktrees: linkedWorktrees,
793
828
  dryRun,
794
829
  manifestPath,
795
- indexMode: (target) => indexModeForPath(systemConfig, target),
796
- isolatedInitialize: (target) => runRepositoryInitializationProcess({ repository: target, command, memoryLimitBytes }),
830
+ indexMode: (target) => indexModeForPath(loadSystemConfiguration(target), target),
831
+ isolatedInitialize: (target) => runRepositoryInitializationProcess({
832
+ repository: target,
833
+ command,
834
+ memoryLimitBytes,
835
+ }),
797
836
  });
798
837
  const { repositories } = await inventory();
799
838
  return bounded({ schemaVersion: 1, ...summary, repositories }, "repositories:init");
@@ -801,7 +840,6 @@ async function handleRepositoriesOperation(options) {
801
840
  if (repositoryAction === "search") {
802
841
  if (!query?.trim())
803
842
  throw new Error("knodin repositories search requires a non-empty `query`");
804
- const systemConfig = loadSystemConfiguration(discoveryRoots[0] ?? repo);
805
843
  return searchRepositories(discoveryRoots, query, {
806
844
  ...discoveryOptions,
807
845
  include,
@@ -817,12 +855,12 @@ async function handleRepositoriesOperation(options) {
817
855
  includeSource: true,
818
856
  federate: false,
819
857
  }),
820
- systemMemberships: (target) => systemMembershipsForPath(systemConfig, target),
858
+ systemMemberships: (target) => systemMembershipsForPath(loadSystemConfiguration(target), target),
821
859
  });
822
860
  }
823
861
  const { discovery, repositories } = await inventory();
824
862
  if (repositoryAction === "doctor")
825
- await populateRepositoryDiagnoses(discovery.repositories, repositories, discoveryRoots[0] ?? repo);
863
+ await populateRepositoryDiagnoses(discovery.repositories, repositories);
826
864
  return bounded({
827
865
  schemaVersion: 1,
828
866
  linkedWorktrees: discoveryOptions.linkedWorktrees,
@@ -833,7 +871,67 @@ async function handleRepositoriesOperation(options) {
833
871
  }, `repositories:${repositoryAction}`);
834
872
  }
835
873
  async function handleSystemOperation(options) {
836
- const { systemAction, symbol, repo, allowPartial, bounded } = options;
874
+ const { systemAction, symbol, repo, bounded } = options;
875
+ if (["link", "relate", "unlink", "unrelate", "import"].includes(systemAction)) {
876
+ if (systemAction !== "import" && !symbol)
877
+ throw new Error(`knodin system ${systemAction} requires \`symbol\``);
878
+ const systemId = symbol ?? "";
879
+ let plan;
880
+ if (systemAction === "import") {
881
+ if (!options.manifestPath)
882
+ throw new Error("system import requires manifestPath");
883
+ plan = planSystemImport({
884
+ manifestPath: options.manifestPath,
885
+ scope: options.scope,
886
+ repoPath: repo,
887
+ });
888
+ }
889
+ else if (systemAction === "link")
890
+ plan = planSystemLink({
891
+ systemId,
892
+ repositories: options.repositories ?? [],
893
+ scope: options.scope,
894
+ distributed: options.distributed,
895
+ repositoryIds: options.repositoryIds,
896
+ components: options.components,
897
+ });
898
+ else if (systemAction === "relate") {
899
+ if (!options.relationshipType || !options.from || !options.to || !options.evidence)
900
+ throw new Error("system relate requires relationshipType, from, to, and evidence");
901
+ plan = planSystemRelate({
902
+ systemId,
903
+ type: options.relationshipType,
904
+ from: options.from,
905
+ to: options.to,
906
+ evidence: options.evidence,
907
+ repoPath: repo,
908
+ });
909
+ }
910
+ else if (systemAction === "unlink")
911
+ plan = planSystemUnlink({
912
+ systemId,
913
+ repositories: options.repositories,
914
+ repoPath: repo,
915
+ });
916
+ else {
917
+ if (!options.relationshipType || !options.from || !options.to)
918
+ throw new Error("system unrelate requires relationshipType, from, and to");
919
+ plan = planSystemUnrelate({
920
+ systemId,
921
+ type: options.relationshipType,
922
+ from: options.from,
923
+ to: options.to,
924
+ repoPath: repo,
925
+ });
926
+ }
927
+ if (options.apply === true) {
928
+ if (!options.planDigest)
929
+ throw new Error("system writes require planDigest from an approved preview");
930
+ if (options.planDigest !== plan.planDigest)
931
+ throw new Error("system plan changed after preview; preview again");
932
+ }
933
+ return bounded(options.apply === true ? applySystemPlan(plan) : plan, `system:${systemAction}`);
934
+ }
837
935
  const config = await enrichSystemRelationships(loadSystemConfiguration(repo));
838
936
  if (systemAction === "list") {
839
937
  return bounded({
@@ -859,7 +957,7 @@ async function handleSystemOperation(options) {
859
957
  const validation = await validateSystemHealth(config, symbol, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })), repo);
860
958
  if (systemAction === "validate")
861
959
  return bounded({ systemId: symbol, ...validation }, "system:validate");
862
- return bounded(queryConfiguredSystem(config, symbol, allowPartial === true, validation), "system:query");
960
+ return bounded(queryConfiguredSystem(config, symbol, options.requireComplete === true, validation), "system:query");
863
961
  }
864
962
  async function handlePullRequestsOperation(options) {
865
963
  const { repo, engine, limit, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, } = options;
@@ -1296,7 +1394,7 @@ async function handleDiagnosticsOperation(repo, args, bounded) {
1296
1394
  async function dispatchKnodinTool(args) {
1297
1395
  const startedAt = performance.now();
1298
1396
  const parsedArgs = (args ?? {});
1299
- const { operation, symbol, base, diffScope, from, toRevision, reviewFiles, query, pattern, to, limit, depth, impactMode, direction, relationKinds, minConfidence, includeTests, includeDataFlow, flowVariable, apply, force, detailLevel, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, worktreeAction, worktreePath, telemetryAction, telemetryRetentionDays, task, changedFiles, repoPath, section, systemAction, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, identity, file, kind, toIdentity, toFile, toKind, byteBudget, tokenBudget, itemBudget, includeSource, languages, extensions, kinds, path, architectureFacets, testScope, offset, minLines, minComplexity, topN, sort, packAction, format, include, exclude, filePolicies, alreadyPresent, chatFiles, lineNumbers, includeTree, outputPath, artifactPath, startLine, endLine, regex, regexFlags, gitDiffScope, gitLog, statusAudit, timeoutMs, client, repairPlan, persistTelemetry, } = parsedArgs;
1397
+ const { operation, symbol, base, diffScope, from, toRevision, reviewFiles, query, pattern, queries, to, limit, depth, impactMode, direction, relationKinds, minConfidence, includeTests, includeDataFlow, flowVariable, apply, force, detailLevel, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, worktreeAction, worktreePath, telemetryAction, telemetryRetentionDays, task, changedFiles, repoPath, section, systemAction, repositories, scope, distributed, repositoryIds, components, evidence, relationshipType, requireComplete, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, planDigest, identity, file, kind, toIdentity, toFile, toKind, byteBudget, tokenBudget, itemBudget, includeSource, languages, extensions, kinds, path, architectureFacets, testScope, offset, minLines, minComplexity, topN, sort, packAction, format, include, exclude, filePolicies, alreadyPresent, chatFiles, lineNumbers, includeTree, outputPath, artifactPath, startLine, endLine, regex, regexFlags, gitDiffScope, gitLog, statusAudit, timeoutMs, client, repairPlan, persistTelemetry, } = parsedArgs;
1300
1398
  const repo = repoPath ?? process.cwd();
1301
1399
  const selector = {
1302
1400
  identity: expandCompactIdentity(identity),
@@ -1389,7 +1487,10 @@ async function dispatchKnodinTool(args) {
1389
1487
  "search",
1390
1488
  ].includes(rootOperation);
1391
1489
  const enriched = includeSharedIndex && result && typeof result === "object" && !Array.isArray(result)
1392
- ? { ...result, sharedIndex: sharedIndexEnvelope() }
1490
+ ? {
1491
+ ...result,
1492
+ sharedIndex: sharedIndexEnvelope(),
1493
+ }
1393
1494
  : result;
1394
1495
  const key = `${op}${minimal ? ":minimal" : ""}`;
1395
1496
  const output = applyResponseBudget(enriched, op, budget, RESPONSE_DEFAULTS[key] ?? RESPONSE_DEFAULTS.default);
@@ -1447,11 +1548,16 @@ async function dispatchKnodinTool(args) {
1447
1548
  return engine.status(repo, { audit: statusAudit }).then((result) => bounded({
1448
1549
  ...attachLifecycleHealth(repo, result),
1449
1550
  integration: inspectRepositoryIntegrationStatus(repo),
1450
- update: trustedUpdateStatus({
1451
- currentVersion: result.version,
1452
- installMethod: "unknown",
1453
- env: process.env,
1454
- }),
1551
+ update: {
1552
+ ...trustedUpdateStatus({
1553
+ currentVersion: result.version,
1554
+ installMethod: "unknown",
1555
+ env: process.env,
1556
+ }),
1557
+ managerState: readManagerUpdateState(),
1558
+ attention: updateAttention(readManagerUpdateState()),
1559
+ journal: readUpdateJournal(),
1560
+ },
1455
1561
  }, "status"));
1456
1562
  case "wait":
1457
1563
  return bounded(await waitForFresh(engine, repo, timeoutMs), "wait");
@@ -1519,6 +1625,19 @@ async function dispatchKnodinTool(args) {
1519
1625
  symbol,
1520
1626
  repo,
1521
1627
  allowPartial,
1628
+ apply,
1629
+ repositories,
1630
+ scope,
1631
+ distributed,
1632
+ repositoryIds,
1633
+ components,
1634
+ relationshipType,
1635
+ from,
1636
+ to,
1637
+ evidence,
1638
+ requireComplete,
1639
+ manifestPath,
1640
+ planDigest,
1522
1641
  bounded,
1523
1642
  });
1524
1643
  }
@@ -1626,6 +1745,39 @@ async function dispatchKnodinTool(args) {
1626
1745
  expectedLogin,
1627
1746
  }), "prs");
1628
1747
  }
1748
+ case "query_batch": {
1749
+ if (!process.env.KNODIN_UPDATE_VERIFICATION_TOKEN)
1750
+ throw new Error("knodin query_batch is restricted to the isolated update verifier");
1751
+ if (!Array.isArray(queries) || queries.length < 1 || queries.length > 32)
1752
+ throw new Error("knodin query_batch requires 1-32 `queries`");
1753
+ for (const item of queries) {
1754
+ if (!item ||
1755
+ typeof item.symbol !== "string" ||
1756
+ item.symbol.length === 0 ||
1757
+ !BATCH_QUERY_PATTERNS.has(item.pattern))
1758
+ throw new Error("knodin query_batch accepts only non-empty callers/callees/imports/importers/file-summary queries");
1759
+ }
1760
+ const queryHealth = await inspectGatewayGraphHealth(repo);
1761
+ if (!queryHealth.available)
1762
+ return bounded(queryHealth, "query_batch");
1763
+ const outputs = [];
1764
+ for (const item of queries) {
1765
+ outputs.push({
1766
+ pattern: item.pattern,
1767
+ symbol: item.symbol,
1768
+ output: await engine.query(item.pattern, item.symbol, repo, undefined, Math.min(limit ?? itemBudget ?? 100, itemBudget ?? 1000)),
1769
+ });
1770
+ }
1771
+ const verifiedQueryHealth = await inspectGatewayGraphHealth(repo);
1772
+ if (!verifiedQueryHealth.available)
1773
+ return bounded(verifiedQueryHealth, "query_batch");
1774
+ return bounded({
1775
+ results: outputs.map((item) => ({
1776
+ ...item,
1777
+ output: decorateGraphQueryResult(item.output, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness),
1778
+ })),
1779
+ }, "query_batch");
1780
+ }
1629
1781
  case "query": {
1630
1782
  if (!pattern)
1631
1783
  throw new Error("knodin query requires `pattern`");
@@ -40,7 +40,7 @@ export function validateRootCeremonyManifest(value) {
40
40
  manifest.productionActivation !== false)
41
41
  throw new Error("invalid fail-closed ceremony header");
42
42
  if (!Array.isArray(manifest.custodians) ||
43
- manifest.custodians.length < 3 ||
43
+ manifest.custodians.length < 1 ||
44
44
  new Set(manifest.custodians.map((c) => c.id)).size !== manifest.custodians.length ||
45
45
  manifest.custodians.some((c) => {
46
46
  exactKeys(c, c.role === "root-signer"
@@ -54,7 +54,7 @@ export function validateRootCeremonyManifest(value) {
54
54
  !Array.isArray(c.recoveryKeyIds) ||
55
55
  c.recoveryKeyIds.some((id) => !SHA256.test(id)))));
56
56
  }))
57
- throw new Error("at least three distinct named custodians are required");
57
+ throw new Error("at least one named maintainer custodian is required");
58
58
  const roles = ["root", "targets", "snapshot", "timestamp"];
59
59
  exactKeys(manifest.roleKeyIds, roles, "roleKeyIds");
60
60
  exactKeys(manifest.thresholds, roles, "thresholds");
@@ -68,14 +68,11 @@ export function validateRootCeremonyManifest(value) {
68
68
  throw new Error("role keys must be valid and disjoint");
69
69
  for (const id of ids)
70
70
  all.add(id);
71
- if (!Number.isInteger(threshold) ||
72
- threshold < (role === "root" || role === "targets" ? 2 : 1) ||
73
- threshold > ids.length)
71
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > ids.length)
74
72
  throw new Error(`invalid ${role} threshold`);
75
73
  }
76
- if (manifest.custodians.filter((c) => c.role === "root-signer").length < manifest.thresholds.root ||
77
- !manifest.custodians.some((c) => c.role === "recovery"))
78
- throw new Error("custodian roles do not satisfy threshold and recovery separation");
74
+ if (manifest.custodians.filter((c) => c.role === "root-signer").length < manifest.thresholds.root)
75
+ throw new Error("maintainer custodians do not satisfy the root threshold");
79
76
  const assigned = manifest.custodians
80
77
  .filter((c) => c.role === "root-signer")
81
78
  .map((c) => c.publicKeyId)
@@ -85,15 +82,9 @@ export function validateRootCeremonyManifest(value) {
85
82
  if (assigned.join("\0") !== [...manifest.roleKeyIds.root].sort(compareBytes).join("\0"))
86
83
  throw new Error("root signer assignments must exactly match root role keys");
87
84
  const recovery = manifest.custodians.filter((c) => c.role === "recovery");
88
- if (recovery.length < manifest.thresholds.root ||
89
- new Set(recovery.map((c) => c.evidenceId)).size !== recovery.length ||
90
- recovery.some((c) => !c.recoveryKeyIds?.length ||
91
- new Set(c.recoveryKeyIds).size !== c.recoveryKeyIds.length ||
92
- c.recoveryKeyIds.length >= manifest.thresholds.root))
93
- throw new Error("recovery sets must preserve root threshold independence");
94
- const recovered = new Set(recovery.flatMap((c) => c.recoveryKeyIds ?? []));
95
- if (manifest.roleKeyIds.root.some((id) => !recovered.has(id)))
96
- throw new Error("recovery assignments must cover every root role key");
85
+ if (new Set(recovery.map((c) => c.evidenceId)).size !== recovery.length ||
86
+ recovery.some((c) => !c.recoveryKeyIds?.length || new Set(c.recoveryKeyIds).size !== c.recoveryKeyIds.length))
87
+ throw new Error("recovery assignments must be unique and non-empty when declared");
97
88
  return manifest;
98
89
  }
99
90
  export function validateRootPinReviewReceipt(value, manifest) {
@@ -115,20 +106,21 @@ export function validateRootPinReviewReceipt(value, manifest) {
115
106
  receipt.ceremonyId !== manifest.ceremonyId ||
116
107
  !SHA256.test(receipt.rootEnvelopeSha256) ||
117
108
  !ID.test(receipt.reviewerId) ||
118
- manifest.custodians.some((c) => c.id === receipt.reviewerId) ||
119
109
  receipt.decision !== "approved" ||
120
- receipt.authentication !== "rehearsal-unsigned" ||
110
+ (manifest.purpose === "rehearsal"
111
+ ? receipt.authentication !== "rehearsal-unsigned" ||
112
+ manifest.custodians.some((c) => c.id === receipt.reviewerId)
113
+ : receipt.authentication !== "maintainer-attested" ||
114
+ !manifest.custodians.some((c) => c.id === receipt.reviewerId)) ||
121
115
  receipt.productionActivation !== false ||
122
116
  !Number.isFinite(Date.parse(receipt.reviewedAt)) ||
123
117
  new Date(receipt.reviewedAt).toISOString() !== receipt.reviewedAt)
124
- throw new Error("invalid or non-independent pin-review receipt");
118
+ throw new Error("invalid root-pin attestation receipt");
125
119
  return receipt;
126
120
  }
127
121
  export function preparePublicRootImport(envelope, pin, manifestValue, receiptValue, now = new Date()) {
128
122
  rejectPrivateMaterial(envelope, "rootEnvelope");
129
123
  const manifest = validateRootCeremonyManifest(manifestValue);
130
- if (manifest.purpose === "production")
131
- throw new Error("production import requires externally authenticated review and remains blocked");
132
124
  const receipt = validateRootPinReviewReceipt(receiptValue, manifest);
133
125
  if (Date.parse(receipt.reviewedAt) > now.getTime())
134
126
  throw new Error("pin-review receipt cannot be future-dated");
@@ -156,7 +148,11 @@ export function preparePublicRootImport(envelope, pin, manifestValue, receiptVal
156
148
  }
157
149
  const digest = createHash("sha256").update(canonicalizeUpdateMetadata(envelope)).digest("hex");
158
150
  if (pin !== digest || receipt.rootEnvelopeSha256 !== digest)
159
- throw new Error("root envelope, pin, and independent review receipt disagree");
151
+ throw new Error("root envelope, pin, and maintainer attestation disagree");
160
152
  verifyUpdateRootChain(envelope, pin, [], now);
161
- return { rootEnvelope: envelope, rootEnvelopeSha256: digest, productionActivation: false };
153
+ return {
154
+ rootEnvelope: envelope,
155
+ rootEnvelopeSha256: digest,
156
+ productionActivation: false,
157
+ };
162
158
  }
@@ -0,0 +1,158 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { readUpdateJournal } from "./manager-update.js";
7
+ function coordinationRoot(stateHome) {
8
+ const root = stateHome ?? process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state");
9
+ return path.join(root, "knodin", "coordination");
10
+ }
11
+ function processStartToken(pid) {
12
+ try {
13
+ if (process.platform === "win32") {
14
+ return execFileSync("powershell.exe", ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`], { encoding: "utf8", timeout: 2_000 }).trim();
15
+ }
16
+ return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
17
+ encoding: "utf8",
18
+ timeout: 2_000,
19
+ }).trim();
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function readLease(file) {
26
+ try {
27
+ const stat = fs.lstatSync(file);
28
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)
29
+ return null;
30
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
31
+ return value.schemaVersion === 1 && typeof value.nonce === "string" ? value : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function live(record) {
38
+ return Boolean(record && processStartToken(record.pid) === record.processStartToken);
39
+ }
40
+ function writeExclusive(file, record) {
41
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
42
+ const descriptor = fs.openSync(file, "wx", 0o600);
43
+ try {
44
+ fs.writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8");
45
+ fs.fsyncSync(descriptor);
46
+ }
47
+ finally {
48
+ fs.closeSync(descriptor);
49
+ }
50
+ }
51
+ function releaseOwned(file, nonce) {
52
+ const current = readLease(file);
53
+ if (current?.nonce !== nonce)
54
+ return;
55
+ try {
56
+ fs.unlinkSync(file);
57
+ }
58
+ catch (error) {
59
+ if (error.code !== "ENOENT")
60
+ throw error;
61
+ }
62
+ }
63
+ function updateLeaseFile(stateHome) {
64
+ return path.join(coordinationRoot(stateHome), "update.lock");
65
+ }
66
+ function lifecycleDirectory(stateHome) {
67
+ return path.join(coordinationRoot(stateHome), "lifecycle");
68
+ }
69
+ function tokenHash(token) {
70
+ return createHash("sha256").update(token).digest("hex");
71
+ }
72
+ function activeLifecycleLeases(stateHome) {
73
+ const directory = lifecycleDirectory(stateHome);
74
+ let names = [];
75
+ try {
76
+ names = fs.readdirSync(directory).slice(0, 4096);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ const active = [];
82
+ for (const name of names) {
83
+ const file = path.join(directory, name);
84
+ const record = readLease(file);
85
+ if (live(record))
86
+ active.push(record);
87
+ else
88
+ releaseOwned(file, record?.nonce ?? "");
89
+ }
90
+ return active;
91
+ }
92
+ export function acquireLifecycleCoordination(options) {
93
+ const updateFile = updateLeaseFile(options.stateHome);
94
+ const update = readLease(updateFile);
95
+ if (live(update)) {
96
+ const permitted = options.verificationToken &&
97
+ update?.verificationTokenHash === tokenHash(options.verificationToken);
98
+ if (!permitted)
99
+ throw new Error("an executable update is active; retry lifecycle work after it completes");
100
+ }
101
+ else if (update) {
102
+ const journal = readUpdateJournal(options.stateHome);
103
+ if (journal && !["candidate-verified", "rollback-verified"].includes(journal.state)) {
104
+ throw new Error(`an interrupted update is ${journal.state}; run \`knodin update status\` to recover it`);
105
+ }
106
+ releaseOwned(updateFile, update.nonce);
107
+ }
108
+ const nonce = randomUUID();
109
+ const record = {
110
+ schemaVersion: 1,
111
+ kind: "lifecycle",
112
+ nonce,
113
+ pid: process.pid,
114
+ processStartToken: processStartToken(process.pid) ?? "unavailable",
115
+ command: options.command,
116
+ repository: fs.realpathSync(options.repository),
117
+ startedAt: new Date().toISOString(),
118
+ };
119
+ const file = path.join(lifecycleDirectory(options.stateHome), `${process.pid}-${nonce}.json`);
120
+ writeExclusive(file, record);
121
+ return { record, release: () => releaseOwned(file, nonce) };
122
+ }
123
+ export function acquireExclusiveUpdateCoordination(options) {
124
+ const active = activeLifecycleLeases(options.stateHome);
125
+ if (active.length > 0) {
126
+ throw new Error(`executable replacement deferred while ${active.length} lifecycle operation(s) are active`);
127
+ }
128
+ const file = updateLeaseFile(options.stateHome);
129
+ const existing = readLease(file);
130
+ if (live(existing))
131
+ throw new Error("another executable update is active");
132
+ if (existing) {
133
+ const journal = readUpdateJournal(options.stateHome);
134
+ if (journal &&
135
+ !["candidate-verified", "rollback-verified"].includes(journal.state) &&
136
+ !options.recovery) {
137
+ throw new Error(`interrupted update recovery required from journal state ${journal.state}`);
138
+ }
139
+ releaseOwned(file, existing.nonce);
140
+ }
141
+ const nonce = randomUUID();
142
+ const record = {
143
+ schemaVersion: 1,
144
+ kind: "update",
145
+ nonce,
146
+ pid: process.pid,
147
+ processStartToken: processStartToken(process.pid) ?? "unavailable",
148
+ command: "update",
149
+ startedAt: new Date().toISOString(),
150
+ verificationTokenHash: tokenHash(options.verificationToken),
151
+ };
152
+ writeExclusive(file, record);
153
+ return { record, release: () => releaseOwned(file, nonce) };
154
+ }
155
+ export function coordinationStatus(stateHome) {
156
+ const update = readLease(updateLeaseFile(stateHome));
157
+ return { update: live(update) ? update : null, lifecycle: activeLifecycleLeases(stateHome) };
158
+ }