pm-context 2026.7.25 → 2026.7.27

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,6 +1,19 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { writeFileSync } from "node:fs";
3
- const defineExtension = ((extension) => extension);
2
+ import { readSettings, resolveRuntimeStatusRegistry } from "@unbrained/pm-cli/sdk";
3
+ import { list as pmList } from "@unbrained/pm-cli/sdk/core";
4
+ import { buildItemContextRelevanceCandidates, defaultScoreContextCandidates, packContextCandidates, recordContextUsageServing, } from "@unbrained/pm-cli/sdk/query";
5
+ import { DEFAULT_REPORT_LIMIT, renderUsageReport, reportContextUsage, resolveSince } from "./context-usage.js";
6
+ import { readContextUsageAffinity } from "@unbrained/pm-cli/sdk/query";
7
+ /**
8
+ * Runtime stand-in for the SDK's `defineExtension`.
9
+ *
10
+ * `defineExtension` is a documented zero-cost identity function. This package
11
+ * now resolves `@unbrained/pm-cli` at runtime (it is a peer dependency the pm
12
+ * host provides) for the `sdk/core` and `sdk/query` engines, but the authoring
13
+ * helper itself has no runtime behavior, so a local identity shim avoids a
14
+ * needless value import while still contract-checking the module against
15
+ * {@link ExtensionModule}.
16
+ */
4
17
  export const EXIT_CODE = {
5
18
  GENERIC_FAILURE: 1,
6
19
  USAGE: 2,
@@ -262,7 +275,8 @@ export function buildContextPack(allItems, options = {}) {
262
275
  const selected = ids.length > 0
263
276
  ? ids.map((id) => byId.get(id)).filter((item) => Boolean(item))
264
277
  : allItems.filter((item) => matchesFilters(item, options));
265
- const limited = sortContextItems(selected).slice(0, options.limit ?? 25);
278
+ const ranker = options.ranker ?? sortContextItems;
279
+ const limited = ranker(selected).slice(0, options.limit ?? 25);
266
280
  const selectedIds = new Set(limited.map((item) => item.id));
267
281
  // Resolve the neighborhood depth. --without-neighborhood (neighborhood === false)
268
282
  // forces depth 0. Otherwise default to 1 hop, capped at MAX_NEIGHBORHOOD_DEPTH.
@@ -299,12 +313,18 @@ export function buildContextPack(allItems, options = {}) {
299
313
  neighborIds.delete(id);
300
314
  let neighbors = sortContextItems([...neighborIds].map((id) => byId.get(id)).filter((item) => Boolean(item)))
301
315
  .sort((a, b) => (neighborDepths.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (neighborDepths.get(b.id) ?? Number.MAX_SAFE_INTEGER));
302
- // --max-items caps the total item count (focus + neighbors). Focus items take
303
- // priority; neighbors are trimmed to fit the remaining budget. If focus
304
- // alone exceeds the cap, focus is also trimmed.
316
+ // --max-items caps the total item count (focus + neighbors). The default
317
+ // packer gives focus items priority and trims neighbors to fit the remaining
318
+ // budget; the command path supplies a `packContextCandidates`-backed packer
319
+ // that selects under a token budget with projection degradation instead.
305
320
  let focusItems = limited;
306
321
  if (typeof options.maxItems === "number" && options.maxItems > 0) {
307
- if (focusItems.length >= options.maxItems) {
322
+ if (options.packer) {
323
+ const packed = options.packer(focusItems, neighbors, options.maxItems);
324
+ focusItems = packed.focus;
325
+ neighbors = packed.neighbors;
326
+ }
327
+ else if (focusItems.length >= options.maxItems) {
308
328
  focusItems = focusItems.slice(0, options.maxItems);
309
329
  neighbors = [];
310
330
  }
@@ -633,25 +653,300 @@ export function renderAgentHandoff(pack, options = {}) {
633
653
  }
634
654
  return output;
635
655
  }
636
- export function readPmItems(pmRoot) {
637
- const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json", "--include-body"], {
638
- encoding: "utf-8",
639
- maxBuffer: 64 * 1024 * 1024,
656
+ /**
657
+ * Read every pm item (full metadata + body) in-process through the SDK.
658
+ *
659
+ * Replaces the previous `spawnSync("pm", ["list-all", "--json", "--include-body"])`
660
+ * shell-out with the typed {@link list} action from `@unbrained/pm-cli/sdk/core`.
661
+ * `list-all` is the SDK alias for `list` with `excludeTerminal: false`; passing
662
+ * `full: true` + `includeBody: true` reproduces the full-metadata-with-body
663
+ * projection the shell-out parsed, including the CLI's default truncation
664
+ * semantics, so the downstream pack shape remains compatible.
665
+ */
666
+ export async function readPmItems(pmRoot) {
667
+ let result;
668
+ try {
669
+ result = await pmList({ full: true, includeBody: true, excludeTerminal: false }, { pmRoot });
670
+ }
671
+ catch (err) {
672
+ throw new CommandError(`Could not read pm items via SDK list: ${err instanceof Error ? err.message : String(err)}`);
673
+ }
674
+ return result.items.filter((item) => typeof item.id === "string");
675
+ }
676
+ /** Per-item token estimate for one character of text (rough 4 chars/token). */
677
+ const TOKENS_PER_CHAR = 0.25;
678
+ /** Default token budget allocated per `--max-items` slot when token-budgeted packing runs. */
679
+ const TOKENS_PER_ITEM_SLOT = 220;
680
+ /**
681
+ * Coerce a {@link PmItem} into the {@link ItemMetadata} shape the SDK relevance
682
+ * engine requires, filling any absent required scalar with a neutral default.
683
+ * Items returned by {@link readPmItems} already carry every required field, so
684
+ * this is idempotent for real packs; it only papers over the optional fields of
685
+ * the loose `PmItem` projection so hand-built test fixtures still rank.
686
+ */
687
+ function toItemMetadata(item, now) {
688
+ const priority = typeof item.priority === "number" && item.priority >= 0 && item.priority <= 4 ? item.priority : 3;
689
+ // `readPmItems` returns the SDK list engine's full projection, which already
690
+ // satisfies `ItemMetadata`; it flows through the loose `PmItem` interface only
691
+ // so `buildContextPack` can keep accepting hand-built fixtures. This cast
692
+ // restores the precise SDK type for the relevance engine — the required
693
+ // scalars are filled above, and the optional collection fields (dependencies,
694
+ // docs, files, blocked_by) come straight from the real item. PmItem types
695
+ // those as `unknown` (wider than ItemMetadata), so the assertion bridges that
696
+ // widening rather than hiding a real mismatch.
697
+ return {
698
+ ...item,
699
+ id: item.id,
700
+ title: typeof item.title === "string" ? item.title : "",
701
+ description: typeof item.description === "string" ? item.description : "",
702
+ type: typeof item.type === "string" ? item.type : "Task",
703
+ status: typeof item.status === "string" ? item.status : "unknown",
704
+ priority: priority,
705
+ tags: Array.isArray(item.tags) ? item.tags.map((tag) => String(tag)) : [],
706
+ created_at: normalizeText(item.created_at) || normalizeText(item.updated_at) || now,
707
+ updated_at: normalizeText(item.updated_at) || normalizeText(item.created_at) || now,
708
+ };
709
+ }
710
+ /**
711
+ * Rank items with pm's deterministic weighted relevance model.
712
+ *
713
+ * Wraps {@link buildItemContextRelevanceCandidates} +
714
+ * {@link defaultScoreContextCandidates} so the command path uses the same
715
+ * relevance signals (`priority_pressure`, `recency`, `claim_focus`, …) as
716
+ * `pm context` / `pm next` instead of the hand-rolled priority-then-recency
717
+ * sort. Returns the items in ranked order and exposes the full report via
718
+ * {@link scoreContextItems} for `--explain`.
719
+ */
720
+ export function rankContextItems(items, options) {
721
+ const report = scoreContextItems(items, options);
722
+ const byId = new Map(items.map((item) => [item.id, item]));
723
+ return report.ranked
724
+ .map((ranked) => byId.get(ranked.id))
725
+ .filter((item) => Boolean(item));
726
+ }
727
+ /**
728
+ * Score items with the SDK relevance model and return the full report.
729
+ *
730
+ * The report's `ranked[].contributions` map each item's score back to the
731
+ * individual signals that produced it — the data behind `pm context-pack
732
+ * --explain`.
733
+ */
734
+ export function scoreContextItems(items, options) {
735
+ const now = options.now;
736
+ const byId = new Map(items.map((item) => [item.id, item]));
737
+ const candidates = buildItemContextRelevanceCandidates(items.map((item) => toItemMetadata(item, now)), {
738
+ statusRegistry: options.statusRegistry,
739
+ now,
740
+ author: options.author,
741
+ usageAffinity: options.usageAffinity,
640
742
  });
641
- if (result.error) {
642
- throw new CommandError(`Could not run pm list-all --json --include-body: ${result.error.message}`);
743
+ // `defaultScoreContextCandidates` is the deterministic, extension-free core of
744
+ // `scoreContextCandidates`. The command path runs in-process and does not need
745
+ // the governed `context_relevance` service override, so the synchronous default
746
+ // keeps pack generation deterministic and side-effect free.
747
+ return defaultScoreContextCandidates(candidates.map((candidate) => ({ id: candidate.id, item: byIdOrFail(byId, candidate.id), signals: candidate.signals })));
748
+ }
749
+ /** Look up an item by id or throw a descriptive error (keeps callers honest). */
750
+ function byIdOrFail(byId, id) {
751
+ const item = byId.get(id);
752
+ if (!item)
753
+ throw new CommandError(`relevance candidate ${id} not found among items`);
754
+ return item;
755
+ }
756
+ /**
757
+ * Build a {@link ContextPackOptions.ranker} closure backed by the SDK relevance
758
+ * model. The closure ranks whatever focus subset {@link buildContextPack} hands
759
+ * it after id/status/type/tag filtering, preserving the SDK's weighted order.
760
+ */
761
+ export function createSdkRanker(allItems, options) {
762
+ return (items) => {
763
+ if (items.length <= 1)
764
+ return items;
765
+ return rankContextItems(items, options);
766
+ };
767
+ }
768
+ /** Rough token estimate for one projection level of a context candidate. */
769
+ function estimateTokens(text) {
770
+ return Math.max(1, Math.ceil(text.length * TOKENS_PER_CHAR));
771
+ }
772
+ /**
773
+ * Estimate the identity / summary / full token costs for one item, matching the
774
+ * monotone projection ladder {@link packContextCandidates} upgrades through.
775
+ */
776
+ function estimateProjectionCosts(item) {
777
+ const identity = estimateTokens(`${item.id} ${normalizeText(item.title)}`);
778
+ const meta = `${itemType(item)} ${itemStatus(item)} ${typeof item.priority === "number" ? item.priority : ""} ${(normalizeTags(item.tags)).join(" ")}`;
779
+ const summary = identity + estimateTokens(meta);
780
+ const body = normalizeText(item.body) || normalizeText(item.description);
781
+ const full = summary + (body ? estimateTokens(body) : 0);
782
+ return { identity, summary, full };
783
+ }
784
+ /**
785
+ * Build a {@link ContextPackOptions.packer} closure backed by
786
+ * {@link packContextCandidates}. Each `--max-items` slot maps to a token budget;
787
+ * focus items are required anchors, neighbors compete by relevance rank for the
788
+ * remaining budget. The packer first selects under that token budget with pm's
789
+ * projection-degradation optimizer, then enforces the command's explicit item
790
+ * ceiling while preserving the SDK-selected focus-first order.
791
+ */
792
+ export function createSdkPacker(rankedFocus, rankedNeighbors) {
793
+ const focusRank = new Map(rankedFocus.map((item, index) => [item.id, index + 1]));
794
+ const neighborRank = new Map(rankedNeighbors.map((item, index) => [item.id, rankedFocus.length + index + 1]));
795
+ return (focus, neighbors, maxItems) => {
796
+ if (!(maxItems > 0))
797
+ return { focus, neighbors };
798
+ const tokenBudget = maxItems * TOKENS_PER_ITEM_SLOT;
799
+ const candidates = [
800
+ ...focus.map((item) => ({
801
+ id: item.id,
802
+ item,
803
+ rank: focusRank.get(item.id) ?? 1,
804
+ score: 1 / (focusRank.get(item.id) ?? 1),
805
+ token_costs: estimateProjectionCosts(item),
806
+ required: true,
807
+ })),
808
+ ...neighbors.map((item) => ({
809
+ id: item.id,
810
+ item,
811
+ rank: neighborRank.get(item.id) ?? focus.length + 1,
812
+ score: 1 / (neighborRank.get(item.id) ?? focus.length + 1),
813
+ token_costs: estimateProjectionCosts(item),
814
+ })),
815
+ ];
816
+ const packed = packContextCandidates(candidates, { tokenBudget, profile: "context" });
817
+ const included = new Set(packed.included.map((candidate) => candidate.id));
818
+ const packedFocus = focus.filter((item) => included.has(item.id)).slice(0, maxItems);
819
+ const remainingSlots = Math.max(0, maxItems - packedFocus.length);
820
+ return {
821
+ focus: packedFocus,
822
+ neighbors: neighbors.filter((item) => included.has(item.id)).slice(0, remainingSlots),
823
+ };
824
+ };
825
+ }
826
+ /**
827
+ * Build the `--explain` report from the SDK relevance model for the emitted
828
+ * pack. Ranks and normalized scores are relative to this supplied population,
829
+ * not the complete workspace corpus.
830
+ */
831
+ export function buildContextExplain(items, options) {
832
+ const report = scoreContextItems(items, options);
833
+ return {
834
+ generatedAt: options.now,
835
+ model: report.model,
836
+ ranking_scope: "emitted_pack",
837
+ available_signals: report.available_signals,
838
+ entries: report.ranked.map((ranked) => ({
839
+ id: ranked.id,
840
+ rank: ranked.rank,
841
+ score: ranked.score,
842
+ contributions: ranked.contributions,
843
+ })),
844
+ };
845
+ }
846
+ /**
847
+ * Render a {@link ContextExplainReport} as an agent-readable Markdown brief.
848
+ *
849
+ * Each focus item is listed with its relevance rank, normalized score, and the
850
+ * per-signal contributions that produced it (sorted most-contributing first), so
851
+ * an agent can judge whether the pack is trustworthy before acting on it.
852
+ */
853
+ export function renderContextExplain(report, options = {}) {
854
+ const lines = [
855
+ "# pm context explain",
856
+ "",
857
+ `Generated: ${report.generatedAt}`,
858
+ `Model: ${report.model}`,
859
+ "Ranking scope: emitted pack (not workspace-wide)",
860
+ `Signals: ${report.available_signals.join(", ")}`,
861
+ "",
862
+ "## Relevance",
863
+ "",
864
+ ];
865
+ if (report.entries.length === 0) {
866
+ lines.push("_No focus items to explain._", "");
643
867
  }
644
- if (result.status !== 0) {
645
- const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
646
- throw new CommandError(stderr || "`pm list-all --json --include-body` failed");
868
+ else {
869
+ for (const entry of report.entries) {
870
+ const contributions = Object.entries(entry.contributions)
871
+ .sort((a, b) => b[1] - a[1])
872
+ .map(([signal, value]) => `${signal}=${value.toFixed(3)}`)
873
+ .join(", ");
874
+ lines.push(`- **${entry.id}** rank ${entry.rank} score ${entry.score.toFixed(3)} — ${contributions}`);
875
+ }
876
+ lines.push("");
647
877
  }
878
+ let output = `${lines.join("\n")}\n`;
879
+ if (options.compress) {
880
+ output = `${output.split("\n").filter((line) => line.trim() !== "").join("\n")}\n`;
881
+ }
882
+ return output;
883
+ }
884
+ /**
885
+ * Resolve the SDK relevance rank options for one command invocation.
886
+ *
887
+ * Builds the workspace lifecycle status registry from pm settings (falling
888
+ * back to the built-in registry when the tracker has no settings file), pins a
889
+ * stable clock, and — when an author is resolvable — pulls decayed
890
+ * served-then-used affinity from the SDK context-usage store so the relevance
891
+ * model's `usage_affinity` signal reflects real agent feedback.
892
+ */
893
+ async function resolveSdkRankOptions(ctx) {
894
+ const now = new Date().toISOString();
895
+ let statusRegistry;
648
896
  try {
649
- const parsed = JSON.parse(result.stdout);
650
- const items = Array.isArray(parsed) ? parsed : parsed.items ?? parsed.results ?? [];
651
- return items.filter((item) => Boolean(item) && typeof item === "object" && typeof item.id === "string");
897
+ // readSettings returns built-in defaults even for an uninitialized tracker,
898
+ // so this yields a valid registry for any real workspace root.
899
+ const settings = await readSettings(ctx.pm_root);
900
+ statusRegistry = resolveRuntimeStatusRegistry(settings.schema);
652
901
  }
653
902
  catch (err) {
654
- throw new CommandError(`Could not parse pm item JSON: ${err instanceof Error ? err.message : String(err)}`);
903
+ throw new CommandError(`Could not resolve workspace status registry for relevance ranking: ${err instanceof Error ? err.message : String(err)}`);
904
+ }
905
+ const author = stringOption(ctx.options ?? {}, "author") ?? ctx.global?.author;
906
+ let usageAffinity;
907
+ if (author) {
908
+ try {
909
+ const affinity = await readContextUsageAffinity({ pmRoot: ctx.pm_root, author });
910
+ usageAffinity = affinity.affinity;
911
+ }
912
+ catch {
913
+ // No ledger yet, or unreadable; ranking proceeds without usage affinity.
914
+ usageAffinity = undefined;
915
+ }
916
+ }
917
+ return { statusRegistry, now, author, usageAffinity };
918
+ }
919
+ /**
920
+ * Record a context-serving event to pm's own context-usage ledger.
921
+ *
922
+ * This closes the feedback loop: every emitted focus and neighborhood item
923
+ * becomes an included `serve` row in the same `runtime/context-usage.jsonl`
924
+ * pm's `usage_affinity` signal reads, so a later
925
+ * `pm context-usage --author <agent>` can report whether the served context was
926
+ * actually touched. Skipped silently when no author is resolvable, since the
927
+ * ledger is per-author.
928
+ */
929
+ async function recordPackServing(ctx, focus, neighbors) {
930
+ const author = stringOption(ctx.options ?? {}, "author") ?? ctx.global?.author;
931
+ if (!author)
932
+ return;
933
+ const rows = [
934
+ ...focus.map((item, index) => ({ id: item.id, rank: index + 1, included: true })),
935
+ ...neighbors.map((item, index) => ({ id: item.id, rank: focus.length + index + 1, included: true })),
936
+ ];
937
+ if (rows.length === 0)
938
+ return;
939
+ try {
940
+ await recordContextUsageServing({
941
+ pmRoot: ctx.pm_root,
942
+ author,
943
+ surface: "context",
944
+ profile: "context",
945
+ rows,
946
+ });
947
+ }
948
+ catch {
949
+ // Recording is best-effort: a ledger write failure must not break a pack.
655
950
  }
656
951
  }
657
952
  function setupCommands(api) {
@@ -671,6 +966,7 @@ function setupCommands(api) {
671
966
  "pm context-pack --id pm-1234 --compress --format json",
672
967
  "pm context-pack --id pm-1234 --format agent --include-deps --section focus --section blockers",
673
968
  "pm context-pack --id pm-1234 --max-items 10",
969
+ "pm context-pack --status in_progress --explain",
674
970
  ],
675
971
  flags: [
676
972
  { long: "--id", value_name: "id", description: "Focus item id (repeatable or comma-separated)", type: "string" },
@@ -692,6 +988,7 @@ function setupCommands(api) {
692
988
  { long: "--include-deps", description: "Include per-item dependency info in the context pack", type: "boolean" },
693
989
  { long: "--max-items", value_name: "n", description: "Maximum total items (focus + neighbors) in the pack", type: "string" },
694
990
  { long: "--section", value_name: "section", description: "Include sections (repeatable). Markdown: summary, focus, neighborhood, neighbors, links, deps. Agent/compact: focus, blockers, next-actions, recent, links, deps, refresh", type: "string" },
991
+ { long: "--explain", description: "Explain emitted items with ranks/scores relative to the packed subset instead of emitting a pack", type: "boolean" },
695
992
  ],
696
993
  async run(ctx) {
697
994
  const options = ctx.options;
@@ -710,8 +1007,19 @@ function setupCommands(api) {
710
1007
  const compress = boolOption(options, "compress");
711
1008
  const includeDeps = boolOption(options, "include-deps", "includeDeps");
712
1009
  const maxItems = intOptionMin0(options, ["max-items", "maxItems"], 0) || undefined;
1010
+ const explain = boolOption(options, "explain");
713
1011
  const sections = validateSections(format, [...asArray(options.section), ...asArray(options.sections)]);
714
- const pack = buildContextPack(readPmItems(ctx.pm_root), {
1012
+ const items = await readPmItems(ctx.pm_root);
1013
+ const rankOptions = await resolveSdkRankOptions(ctx);
1014
+ const ranker = createSdkRanker(items, rankOptions);
1015
+ // Pre-rank the full corpus so the token-budgeted packer has relevance ranks
1016
+ // for both focus and neighbor candidates before buildContextPack trims.
1017
+ let packer;
1018
+ if (maxItems) {
1019
+ const rankedAll = rankContextItems(items, rankOptions);
1020
+ packer = createSdkPacker(rankedAll, rankedAll);
1021
+ }
1022
+ const pack = buildContextPack(items, {
715
1023
  ids: selection.ids,
716
1024
  status: selection.status,
717
1025
  type: selection.type,
@@ -723,7 +1031,22 @@ function setupCommands(api) {
723
1031
  neighborhoodDepth,
724
1032
  includeDeps,
725
1033
  maxItems,
1034
+ ranker,
1035
+ packer,
726
1036
  });
1037
+ if (explain) {
1038
+ const explainReport = buildContextExplain([...pack.items, ...pack.neighbors], rankOptions);
1039
+ const output = format === "json"
1040
+ ? `${JSON.stringify(explainReport, null, compress ? 0 : 2)}\n`
1041
+ : renderContextExplain(explainReport, { compress });
1042
+ const outputPath = stringOption(options, "output");
1043
+ if (outputPath) {
1044
+ writeFileSync(outputPath, output, "utf-8");
1045
+ return { ok: true, format: format === "json" ? "json" : "markdown", explained: explainReport.entries.length };
1046
+ }
1047
+ return renderedCommandResult(output);
1048
+ }
1049
+ await recordPackServing(ctx, pack.items, pack.neighbors);
727
1050
  const suggestedCommand = buildSuggestedAgentCommand({
728
1051
  commandName: "context-pack",
729
1052
  selection,
@@ -804,7 +1127,15 @@ function setupCommands(api) {
804
1127
  const includeDeps = boolOption(options, "include-deps", "includeDeps");
805
1128
  const maxItems = intOptionMin0(options, ["max-items", "maxItems"], 0) || undefined;
806
1129
  const sections = validateSections(format, [...asArray(options.section), ...asArray(options.sections)]);
807
- const pack = buildContextPack(readPmItems(ctx.pm_root), {
1130
+ const items = await readPmItems(ctx.pm_root);
1131
+ const rankOptions = await resolveSdkRankOptions(ctx);
1132
+ const ranker = createSdkRanker(items, rankOptions);
1133
+ let packer;
1134
+ if (maxItems) {
1135
+ const rankedAll = rankContextItems(items, rankOptions);
1136
+ packer = createSdkPacker(rankedAll, rankedAll);
1137
+ }
1138
+ const pack = buildContextPack(items, {
808
1139
  ids: selection.ids,
809
1140
  status: selection.status,
810
1141
  type: selection.type,
@@ -815,7 +1146,10 @@ function setupCommands(api) {
815
1146
  neighborhoodDepth,
816
1147
  includeDeps,
817
1148
  maxItems,
1149
+ ranker,
1150
+ packer,
818
1151
  });
1152
+ await recordPackServing(ctx, pack.items, pack.neighbors);
819
1153
  const suggestedCommand = buildSuggestedAgentCommand({
820
1154
  commandName: "context-handoff",
821
1155
  selection,
@@ -850,10 +1184,88 @@ function setupCommands(api) {
850
1184
  return renderedCommandResult(output);
851
1185
  },
852
1186
  });
1187
+ api.registerCommand({
1188
+ name: "context-usage",
1189
+ description: "Report which items pm context/next served and which of those were actually touched.",
1190
+ intent: "measure whether served context is being used",
1191
+ examples: [
1192
+ "pm context-usage",
1193
+ "pm context-usage --format json",
1194
+ "pm context-usage --surface next --since 7d",
1195
+ "pm context-usage --by agent-a --limit 50",
1196
+ ],
1197
+ flags: [
1198
+ { long: "--by", value_name: "author", description: "Restrict to one recording author", type: "string" },
1199
+ { long: "--surface", value_name: "surface", description: "Restrict serve events to one surface: context or next", type: "string" },
1200
+ { long: "--since", value_name: "when", description: "Drop events at or before this point (ISO timestamp, or a day offset such as 7d)", type: "string" },
1201
+ { long: "--limit", value_name: "n", description: `Maximum per-item rows (default: ${DEFAULT_REPORT_LIMIT})`, type: "string" },
1202
+ { long: "--format", value_name: "format", description: "Output format: markdown or json (default: markdown)", type: "string" },
1203
+ ],
1204
+ async run(ctx) {
1205
+ const options = ctx.options ?? {};
1206
+ const requestedFormat = stringOption(options, "format")?.toLowerCase();
1207
+ if (requestedFormat && requestedFormat !== "markdown" && requestedFormat !== "json") {
1208
+ throw new CommandError("--format must be markdown or json", EXIT_CODE.USAGE);
1209
+ }
1210
+ const surface = stringOption(options, "surface")?.toLowerCase();
1211
+ if (surface && surface !== "context" && surface !== "next") {
1212
+ throw new CommandError("--surface must be context or next", EXIT_CODE.USAGE);
1213
+ }
1214
+ const rawSince = stringOption(options, "since");
1215
+ const since = rawSince === undefined ? undefined : resolveSince(rawSince);
1216
+ if (rawSince !== undefined && since === null) {
1217
+ throw new CommandError(`--since '${rawSince}' is not an ISO timestamp or a day offset such as 7d`, EXIT_CODE.USAGE);
1218
+ }
1219
+ // This is a *filter* over recorded ledger events ("whose usage to report"),
1220
+ // which is a different concept from pm's host-owned global `--author`
1221
+ // ("override mutation author for this invocation"). Reusing the global
1222
+ // would silently narrow every report to the invoking agent, so this keeps
1223
+ // a distinct `--by`.
1224
+ const author = stringOption(options, "by");
1225
+ const report = reportContextUsage(ctx.pm_root, {
1226
+ author,
1227
+ surface,
1228
+ since: since ?? undefined,
1229
+ limit: intOption(options, "limit", DEFAULT_REPORT_LIMIT),
1230
+ });
1231
+ // When an author is selected, fold in pm's own decayed served-then-touched
1232
+ // affinity from the SDK context-usage store. This is the same `usage_affinity`
1233
+ // signal the relevance model consumes, surfaced directly so an agent can see
1234
+ // which items its prior touches reinforced — without this module re-implementing
1235
+ // pm's decay model. The event-based waste/misses/conversion report stays because
1236
+ // the SDK does not expose the raw ledger event stream (see SDK_PROBLEMS).
1237
+ let affinity;
1238
+ if (author) {
1239
+ try {
1240
+ affinity = await readContextUsageAffinity({ pmRoot: ctx.pm_root, author });
1241
+ }
1242
+ catch {
1243
+ affinity = undefined;
1244
+ }
1245
+ }
1246
+ const reportWithAffinity = affinity ? { ...report, affinity } : report;
1247
+ // pm's global --json owns that flag name, so a command-level alias would be
1248
+ // silently shadowed and never populate ctx.options. Read the global instead,
1249
+ // so `pm context-usage --json` returns the raw report as an agent expects.
1250
+ const wantsJson = requestedFormat === "json" || ctx.global?.json === true;
1251
+ return wantsJson ? reportWithAffinity : renderedCommandResult(renderUsageReport(reportWithAffinity));
1252
+ },
1253
+ });
853
1254
  }
1255
+ /**
1256
+ * Local stand-in for the SDK's `defineExtension` identity helper.
1257
+ *
1258
+ * This package resolves `@unbrained/pm-cli` at runtime for the `sdk/core` and
1259
+ * `sdk/query` engines (it is a peer dependency the pm host provides), but
1260
+ * `defineExtension` itself is a pure identity with no runtime behavior, so a
1261
+ * local shim avoids a needless value import while still contract-checking the
1262
+ * extension object against {@link ExtensionModule} exactly as the imported
1263
+ * helper would.
1264
+ */
1265
+ const defineExtension = (module) => module;
854
1266
  export default defineExtension({
855
1267
  name: "pm-context",
856
- version: "2026.7.25",
1268
+ version: "2026.7.27",
857
1269
  description: "Generate deterministic pm context packs for agent handoffs, reviews, and status briefs",
858
1270
  activate(api) {
859
1271
  setupCommands(api);