pm-context 2026.10.21 → 2026.10.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,16 @@
1
+ import { packCriterionEvidence, encodeEvidencePack, readEvidencePacket, EvidencePackingError } from "./evidence-packing.js";
2
+ export { packCriterionEvidence, encodeEvidencePack, parseEvidencePacket, readEvidencePacket, EvidencePackingError } from "./evidence-packing.js";
3
+ import { createFreshnessReader, encodeFreshness, FreshnessError, parseFreshnessPacket, readFreshnessJson, recordFreshness, resumeFreshness } from "./freshness.js";
4
+ import { checkContextWitnesses, encodeWitnessReport, readWitnessPacket, WitnessError } from "./witnesses.js";
5
+ export { checkContextWitnesses, encodeWitnessReport, parseWitnessPacket, readWitnessPacket, WitnessError } from "./witnesses.js";
6
+ export { createFreshnessReader, encodeFreshness, FRESHNESS_LIMITS, FreshnessError, parseFreshnessPacket, readFreshnessFile, readFreshnessJson, recordFreshness, resumeFreshness } from "./freshness.js";
7
+ import { buildSincePack, encodeSincePack } from "./since.js";
1
8
  import { writeFileSync } from "node:fs";
9
+ import { isDeepStrictEqual } from "node:util";
2
10
  import { resolveFileFocus, matchFileFocus, rankFileFocus, renderFileFocus, readFileFocusItems } from "./file-focus.js";
3
11
  import { createRelationshipKindRegistry, readSettings, resolveRuntimeStatusRegistry, resolveWorkspaceRelationshipKindRegistry, } from "@unbrained/pm-cli/sdk";
4
- import { list as pmList } from "@unbrained/pm-cli/sdk/core";
5
- import { buildItemContextRelevanceCandidates, defaultScoreContextCandidates, packContextCandidates, recordContextUsageServing, } from "@unbrained/pm-cli/sdk/query";
12
+ import { listAllComplete } from "@unbrained/pm-cli/sdk/core";
13
+ import { buildItemContextRelevanceCandidates, certifyCompleteListResult, PmCompleteListValidationError, defaultScoreContextCandidates, packContextCandidates, recordContextUsageServing, } from "@unbrained/pm-cli/sdk/query";
6
14
  import { DEFAULT_REPORT_LIMIT, renderUsageReport, reportContextUsage, resolveSince } from "./context-usage.js";
7
15
  import { readContextUsageAffinity } from "@unbrained/pm-cli/sdk/query";
8
16
  import { recordReceiptUsage, reportContextReceipts, renderReceiptReport, serveContextReceipt } from "./context-receipts.js";
@@ -730,97 +738,23 @@ export function renderAgentHandoff(pack, options = {}) {
730
738
  return output;
731
739
  }
732
740
  /**
733
- * Refuse an incomplete SDK `list-all` result instead of consuming it.
734
- *
735
- * Reading `.items` without consulting the result's completeness receipt is how
736
- * this package once built a context pack from 10 of 682 workspace items and
737
- * reported success: pm 2026.8.14 defaulted the list to a truncated answer and
738
- * nothing here checked. Any one of four independent signals means the rows in
739
- * `items` are NOT the whole workspace, so this throws (never returns a partial
740
- * list, never logs and continues) naming the signal that tripped plus the
741
- * `count`/`total` figures:
742
- *
743
- * - `truncated === true` — the row list was cut short (output budget, limit);
744
- * - `has_more === true` — rows exist past the returned cursor boundary;
745
- * - `completeness.status !== "complete"` — items/directories were unreadable
746
- * ("partial") or the corpus was never scanned ("unchecked"); either way the
747
- * answer is not attested complete;
748
- * - `omission_receipt.has_omissions === true` — field groups were dropped from
749
- * the projection, so rows are present but degraded.
750
- *
751
- * Thrown errors are {@link CommandError} so the command runtime turns them into
752
- * a clean nonzero exit. Paging is deliberately NOT attempted: a context pack
753
- * silently built from a partial page is the failure mode this guard exists to
754
- * prevent, so refusing loudly is simpler and safer.
755
- *
756
- * @param result - SDK list result (a result missing its receipt trips the
757
- * completeness signal: an unverifiable answer is not complete).
758
- * @throws {@link CommandError} naming the first tripped signal and the counts.
741
+ * Re-certify the full SDK envelope and require its producer's certificate.
742
+ * The SDK owns corpus and output invariants; this consumer also refuses a
743
+ * missing or inconsistent producer certificate before constructing a pack.
759
744
  */
760
745
  export function assertListResultComplete(result) {
761
- const count = typeof result.count === "number" ? result.count : result.items.length;
762
- const total = typeof result.total === "number" ? result.total : count;
763
- const counts = `count ${count} of total ${total}`;
764
- if (result.truncated === true) {
765
- throw new CommandError(`Refusing incomplete pm list-all result: truncated=true (${counts}). `
766
- + "The item list was cut short (output budget or limit); a context pack "
767
- + "built from it would silently miss items. Narrow the operation or "
768
- + "raise the output budget, then retry.");
769
- }
770
- if (result.has_more === true) {
771
- throw new CommandError(`Refusing incomplete pm list-all result: has_more=true (${counts}). `
772
- + "Rows exist beyond the returned page; packing the page as the whole "
773
- + "workspace would silently drop them.");
774
- }
775
- if (result.completeness?.status !== "complete") {
776
- const status = result.completeness?.status === undefined
777
- ? "(missing)"
778
- : JSON.stringify(result.completeness.status);
779
- const unreadable = `unreadable_item_count=${result.completeness?.unreadable_item_count ?? 0}`
780
- + `, unreadable_directory_count=${result.completeness?.unreadable_directory_count ?? 0}`;
781
- throw new CommandError(`Refusing incomplete pm list-all result: completeness.status=${status} `
782
- + `(${unreadable}; ${counts}). The workspace corpus behind this list was `
783
- + "not attested completely read, so the rows are not the whole workspace.");
784
- }
785
- const omission = result.omission_receipt;
786
- if (omission?.has_omissions === true) {
787
- const groups = omission.omitted_field_groups ?? [];
788
- throw new CommandError(`Refusing incomplete pm list-all result: omission_receipt.has_omissions=true `
789
- + `(omitted_field_groups: ${groups.length ? groups.join(", ") : "(none listed)"}; ${counts}). `
790
- + "Field groups were dropped from the projection, so the rows are present "
791
- + "but degraded; re-read without the field-omitting options.");
746
+ const certified = certifyCompleteListResult(result);
747
+ if (!isDeepStrictEqual(result.complete_list, certified.complete_list)) {
748
+ throw new PmCompleteListValidationError([{
749
+ code: "invalid_envelope", message: "Context input requires a matching complete_list certificate from listAllComplete.",
750
+ }]);
792
751
  }
793
752
  }
794
- /**
795
- * Read every pm item (full metadata + body) in-process through the SDK.
796
- *
797
- * Replaces the previous `spawnSync("pm", ["list-all", "--json", "--include-body"])`
798
- * shell-out with the typed {@link list} action from `@unbrained/pm-cli/sdk/core`.
799
- * `list-all` is the SDK alias for `list` with `excludeTerminal: false`; passing
800
- * `full: true` + `includeBody: true` reproduces the full-metadata-with-body
801
- * projection the shell-out parsed. `noTruncate: true` deliberately overrides any
802
- * truncating limit and `strictRead: true` makes unreadable corpus entries a hard
803
- * SDK error, and the returned receipt is STILL verified by
804
- * {@link assertListResultComplete} — the 2026.8.14 truncated-default regression
805
- * is exactly the silently-partial read this package refuses to reintroduce, so
806
- * the guard does not trust the request options alone. Being an in-process SDK
807
- * action (no child process), there is no spawn stdout buffer to size here; the
808
- * completeness receipt is the over-run guard.
809
- *
810
- * @param pmRoot - Tracker storage root to read.
811
- * @param list - Injectable SDK list action (tests substitute a canned real
812
- * result); defaults to the real {@link pmList}.
813
- */
814
- export async function readPmItems(pmRoot, list = pmList) {
815
- let result;
816
- try {
817
- result = await list({ full: true, includeBody: true, excludeTerminal: false, noTruncate: true, strictRead: true }, { pmRoot });
818
- }
819
- catch (err) {
820
- throw new CommandError(`Could not read pm items via SDK list: ${err instanceof Error ? err.message : String(err)}`);
821
- }
753
+ /** Read all types and lifecycle states without extension side effects, preserving SDK refusals. */
754
+ export async function readPmItems(pmRoot, list = listAllComplete) {
755
+ const result = await list({ includeBody: true }, { pmRoot, noExtensions: true });
822
756
  assertListResultComplete(result);
823
- return result.items.filter((item) => typeof item.id === "string");
757
+ return result.items;
824
758
  }
825
759
  /** Per-item token estimate for one character of text (rough 4 chars/token). */
826
760
  const TOKENS_PER_CHAR = 0.25;
@@ -936,21 +870,22 @@ function estimateProjectionCosts(item) {
936
870
  * focus items are required anchors, neighbors compete by relevance rank for the
937
871
  * remaining budget. The packer first selects under that token budget with pm's
938
872
  * projection-degradation optimizer, then enforces the command's explicit item
939
- * ceiling while preserving the SDK-selected focus-first order.
873
+ * ceiling. The incoming focus order is authoritative (including file-overlap
874
+ * ranking); corpus ranks apply only to neighbors, so equal-cost focus ties keep
875
+ * the selection order established by buildContextPack.
940
876
  */
941
877
  export function createSdkPacker(rankedFocus, rankedNeighbors) {
942
- const focusRank = new Map(rankedFocus.map((item, index) => [item.id, index + 1]));
943
878
  const neighborRank = new Map(rankedNeighbors.map((item, index) => [item.id, rankedFocus.length + index + 1]));
944
879
  return (focus, neighbors, maxItems) => {
945
880
  if (!(maxItems > 0))
946
881
  return { focus, neighbors };
947
882
  const tokenBudget = maxItems * TOKENS_PER_ITEM_SLOT;
948
883
  const candidates = [
949
- ...focus.map((item) => ({
884
+ ...focus.map((item, index) => ({
950
885
  id: item.id,
951
886
  item,
952
- rank: focusRank.get(item.id) ?? 1,
953
- score: 1 / (focusRank.get(item.id) ?? 1),
887
+ rank: index + 1,
888
+ score: 1 / (index + 1),
954
889
  token_costs: estimateProjectionCosts(item),
955
890
  required: true,
956
891
  })),
@@ -1108,7 +1043,7 @@ async function recordPackServing(ctx, focus, neighbors) {
1108
1043
  */
1109
1044
  async function measureContextOutput(ctx, pack, output, format, command, compress, outputPath) {
1110
1045
  try {
1111
- const measured = serveContextReceipt(ctx.pm_root, output, format === "json" ? "json" : "text", {
1046
+ const measured = await serveContextReceipt(ctx.pm_root, output, format === "json" ? "json" : "text", {
1112
1047
  item_ids: [...pack.items, ...pack.neighbors].map((item) => item.id),
1113
1048
  files: pack.links.filter((link) => link.kind === "file"),
1114
1049
  }, {
@@ -1188,7 +1123,7 @@ function setupCommands(api) {
1188
1123
  throw new CommandError("--format must be markdown, json, agent, or compact", EXIT_CODE.USAGE);
1189
1124
  }
1190
1125
  const selection = resolveSelectionOptions(options);
1191
- const fileFocus = resolveFileFocus(options, ctx.args, ctx.repo_root ?? ctx.source_workspace_root ?? process.cwd(), ctx.pm_root);
1126
+ const fileFocus = resolveFileFocus(options, ctx.args, ctx.repo_root ?? ctx.source_workspace_root ?? process.cwd(), ctx.pm_root, process.cwd());
1192
1127
  const limit = intOption(options, "limit", contextPackDefaultLimit);
1193
1128
  const includeBody = boolOption(options, "include-body", "includeBody");
1194
1129
  const includeClosed = boolOption(options, "include-closed", "includeClosed");
@@ -1203,8 +1138,8 @@ function setupCommands(api) {
1203
1138
  const items = fileFocus ? await readFileFocusItems(ctx.pm_root) : await readPmItems(ctx.pm_root);
1204
1139
  const rankOptions = await resolveSdkRankOptions(ctx);
1205
1140
  const ranker = createSdkRanker(items, rankOptions);
1206
- // Pre-rank the full corpus so the token-budgeted packer has relevance ranks
1207
- // for both focus and neighbor candidates before buildContextPack trims.
1141
+ // Pre-rank the corpus for neighbor relevance. Focus arrives in the final
1142
+ // selection order, including file overlap, after buildContextPack trims.
1208
1143
  let packer;
1209
1144
  if (maxItems) {
1210
1145
  const rankedAll = rankContextItems(items, rankOptions);
@@ -1299,6 +1234,96 @@ function setupCommands(api) {
1299
1234
  return renderedCommandResult(output);
1300
1235
  },
1301
1236
  });
1237
+ api.registerCommand({
1238
+ name: "context-evidence",
1239
+ description: "Pack explicit criterion evidence with exact coverage lost at a byte budget.",
1240
+ flags: [
1241
+ { long: "--packet", value_name: "file", description: "JSON acceptance criteria and tracker field bindings", type: "string" },
1242
+ { long: "--format", value_name: "format", description: "toon (default) or json", type: "string" },
1243
+ { long: "--byte-budget", value_name: "n", description: "Complete-output UTF-8 byte ceiling (default 4000)", type: "string" },
1244
+ ],
1245
+ async run(ctx) {
1246
+ const path = stringOption(ctx.options, "packet");
1247
+ const format = stringOption(ctx.options, "format") ?? (ctx.global.json ? "json" : "toon");
1248
+ if (!path || (format !== "json" && format !== "toon") || ctx.args.length)
1249
+ throw new EvidencePackingError("INVALID_ARGUMENT", "Supply --packet JSON with --format json or toon; no positional arguments are accepted.");
1250
+ const pack = await packCriterionEvidence(readEvidencePacket(path), createFreshnessReader(ctx.pm_root, "."), {
1251
+ format, byteBudget: Number(stringOption(ctx.options, "byte-budget", "byteBudget") ?? "4000"),
1252
+ });
1253
+ return renderedCommandResult(encodeEvidencePack(pack, format));
1254
+ },
1255
+ });
1256
+ api.registerCommand({
1257
+ name: "context-witnesses",
1258
+ description: "Find the smallest cited contradiction among declared packet assertions without editing sources.",
1259
+ flags: [
1260
+ { long: "--packet", value_name: "file", description: "JSON packet of cited co-current claims", type: "string" },
1261
+ { long: "--format", value_name: "format", description: "toon (default) or json", type: "string" },
1262
+ { long: "--byte-budget", value_name: "n", description: "Complete-output UTF-8 byte ceiling (default 4000)", type: "string" },
1263
+ ],
1264
+ async run(ctx) {
1265
+ const path = stringOption(ctx.options, "packet");
1266
+ const format = stringOption(ctx.options, "format") ?? (ctx.global.json ? "json" : "toon");
1267
+ if (!path || (format !== "json" && format !== "toon") || ctx.args.length)
1268
+ throw new WitnessError("INVALID_ARGUMENT", "Supply --packet JSON with --format json or toon; no positional arguments are accepted.");
1269
+ const budget = Number(stringOption(ctx.options, "byte-budget", "byteBudget") ?? "4000");
1270
+ const report = checkContextWitnesses(readWitnessPacket(path));
1271
+ return renderedCommandResult(encodeWitnessReport(report, format, budget));
1272
+ },
1273
+ });
1274
+ for (const action of ["record", "resume"]) {
1275
+ api.registerCommand({
1276
+ name: `context-freshness ${action}`,
1277
+ description: action === "record" ? "Record source digests behind declared packet assumptions." : "Return only assumptions invalidated by later source changes.",
1278
+ flags: [
1279
+ { long: "--packet", value_name: "file", description: "JSON packet with section content and source dependencies", type: "string" },
1280
+ { long: "--manifest", value_name: "file", description: "Original JSON digest manifest (resume only)", type: "string" },
1281
+ { long: "--evidence-root", value_name: "directory", description: "Root for relative evidence files (default: source workspace)", type: "string" },
1282
+ { long: "--format", value_name: "format", description: "Record: json; resume: toon (default) or json", type: "string" },
1283
+ { long: "--byte-budget", value_name: "n", description: "Output UTF-8 byte ceiling (default: record 262144; resume 4000)", type: "string" },
1284
+ ],
1285
+ async run(ctx) {
1286
+ const path = stringOption(ctx.options, "packet");
1287
+ const manifestPath = stringOption(ctx.options, "manifest");
1288
+ const format = stringOption(ctx.options, "format") ?? (ctx.global.json || action === "record" ? "json" : "toon");
1289
+ if (!path || (action === "resume" ? !manifestPath : Boolean(manifestPath)) || (format !== "json" && format !== "toon") || (action === "record" && format !== "json")) {
1290
+ throw new FreshnessError("INVALID_ARGUMENT", "Supply --packet JSON; resume also requires --manifest JSON. Record uses json; resume accepts --format json or toon.");
1291
+ }
1292
+ const budget = Number(stringOption(ctx.options, "byte-budget", "byteBudget") ?? (action === "record" ? "262144" : "4000"));
1293
+ if (!Number.isSafeInteger(budget) || budget < 512)
1294
+ throw new FreshnessError("INVALID_ARGUMENT", "Use an integer --byte-budget of at least 512.");
1295
+ const packet = parseFreshnessPacket(readFreshnessJson(path));
1296
+ const read = createFreshnessReader(ctx.pm_root, stringOption(ctx.options, "evidence-root", "evidenceRoot") ?? ctx.source_workspace_root ?? ctx.repo_root ?? process.cwd());
1297
+ if (action === "record")
1298
+ return renderedCommandResult(encodeFreshness(await recordFreshness(packet, read), "json", Math.min(budget, 262144)));
1299
+ const report = await resumeFreshness(packet, readFreshnessJson(manifestPath), read);
1300
+ const output = encodeFreshness(report, format, budget);
1301
+ if (report.code === "SOURCE_UNAVAILABLE")
1302
+ process.exitCode = EXIT_CODE.USAGE;
1303
+ return renderedCommandResult(output);
1304
+ },
1305
+ });
1306
+ }
1307
+ api.registerCommand({
1308
+ name: "context-pack since",
1309
+ description: "Resume with ranked changes, substantive excerpts, claims and an opaque cursor.",
1310
+ flags: [
1311
+ { long: "--cursor", value_name: "token", description: "Resume a retained delta checkpoint", type: "string" },
1312
+ { long: "--from", value_name: "timestamp", description: "Start from an inclusive ISO timestamp", type: "string" },
1313
+ { long: "--token-budget", value_name: "n", description: "Complete JSON byte-token upper bound (default: 4000)", type: "string" },
1314
+ ],
1315
+ async run(ctx) {
1316
+ const settings = await readSettings(ctx.pm_root);
1317
+ const registry = resolveRuntimeStatusRegistry(settings.schema);
1318
+ const pack = await buildSincePack(ctx.pm_root, await readPmItems(ctx.pm_root), new Set(registry.terminal_statuses), {
1319
+ cursor: stringOption(ctx.options, "cursor"),
1320
+ from: stringOption(ctx.options, "from"),
1321
+ author: ctx.global.author ?? process.env.PM_AUTHOR ?? settings.author_default,
1322
+ tokenBudget: Number(stringOption(ctx.options, "token-budget", "tokenBudget") ?? "4000"),
1323
+ });
1324
+ return renderedCommandResult(encodeSincePack(pack));
1325
+ },
1326
+ });
1302
1327
  api.registerCommand({
1303
1328
  name: "context-handoff",
1304
1329
  description: "Generate an agent-ready handoff with concise defaults.",
@@ -1460,7 +1485,7 @@ function setupCommands(api) {
1460
1485
  if (!receiptId)
1461
1486
  throw new CommandError("Usage evidence requires --receipt", EXIT_CODE.USAGE);
1462
1487
  try {
1463
- recordReceiptUsage(ctx.pm_root, { receipt_id: receiptId, session, citations, touched_items: touchedItems, edited_files: editedFiles });
1488
+ await recordReceiptUsage(ctx.pm_root, { receipt_id: receiptId, session, citations, touched_items: touchedItems, edited_files: editedFiles });
1464
1489
  }
1465
1490
  catch (error) {
1466
1491
  throw new CommandError(error instanceof Error ? error.message : String(error), EXIT_CODE.USAGE);
@@ -1501,13 +1526,13 @@ function setupCommands(api) {
1501
1526
  /** Register context commands and renderers with checked host behavior and package metadata. */
1502
1527
  export default {
1503
1528
  name: "pm-context",
1504
- version: "2026.10.21",
1529
+ version: "2026.10.24",
1505
1530
  description: "Generate deterministic pm context packs for agent handoffs, reviews, and status briefs",
1506
1531
  activate(api) {
1507
1532
  setupCommands(api);
1508
1533
  if (typeof api.registerRenderer === "function") {
1509
1534
  const rendererOwnership = {
1510
- commands: ["context-pack", "context-handoff", "context-usage"],
1535
+ commands: ["context-pack", "context-pack since", "context-handoff", "context-usage", "context-freshness record", "context-freshness resume", "context-witnesses", "context-evidence"],
1511
1536
  resultDiscriminator: isRenderedCommandResult,
1512
1537
  };
1513
1538
  api.registerRenderer("toon", renderCommandResult, rendererOwnership);