pm-brief 2026.10.24 → 2026.10.26

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.10.26 - 2026-10-26
4
+
5
+ ### Added
6
+
7
+ - Renew only affected handoff obligations after source revisions change ([pm-brief-03r3](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/features/pm-brief-03r3.toon))
8
+
9
+ ## 2026.10.25 - 2026-10-25
10
+
11
+ ### Added
12
+
13
+ - Render stakeholder briefs through explicit disclosure profiles ([pm-brief-nsks](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/features/pm-brief-nsks.toon))
14
+
15
+ ### Fixed
16
+
17
+ - Honor SDK blockers, lifecycle roles and UTF-8 brief budgets ([pm-brief-agze](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/tasks/pm-brief-agze.toon))
18
+
19
+ ### Other
20
+
21
+ - Reconcile stale in-progress tracker records (2026-10-23) ([pm-brief-reconcile-1023](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/chores/pm-brief-reconcile-1023.toon))
22
+
3
23
  ## 2026.10.24 - 2026-10-24
4
24
 
5
25
  ### Added
@@ -7,6 +27,10 @@
7
27
  - Record acknowledged handoff contracts against item revisions ([pm-brief-cjxn](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/features/pm-brief-cjxn.toon))
8
28
  - Compare recorded decision alternatives with cited evidence ([pm-brief-ktjl](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/features/pm-brief-ktjl.toon))
9
29
 
30
+ ### Fixed
31
+
32
+ - Daily Release workflow is failing ([pm-brief-github-7e06b2eb1b44-170](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/issues/pm-brief-github-7e06b2eb1b44-170.toon))
33
+
10
34
  ### Other
11
35
 
12
36
  - Verify evidence for tracker reconciliation review ([pm-brief-review165](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/chores/pm-brief-review165.toon))
@@ -28,13 +52,6 @@
28
52
 
29
53
  - Enable GitHub CodeQL code scanning ([pm-brief-1p5o](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/chores/pm-brief-1p5o.toon))
30
54
 
31
- ## 2026.10.21 - 2026-10-21
32
-
33
- ### Fixed
34
-
35
- - Enforce the requested brief budget after the final compaction step ([pm-brief-github-7e06b2eb1b44-135](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/issues/pm-brief-github-7e06b2eb1b44-135.toon))
36
- - Daily Release workflow is failing ([pm-brief-github-7e06b2eb1b44-84](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/issues/pm-brief-github-7e06b2eb1b44-84.toon))
37
-
38
55
  ## 2026.10.14 - 2026-10-14
39
56
 
40
57
  ### Added
@@ -53,6 +70,10 @@
53
70
 
54
71
  - Enforce the chosen rendered brief token ceiling ([pm-brief-k8no](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/features/pm-brief-k8no.toon))
55
72
 
73
+ ### Fixed
74
+
75
+ - Enforce the requested brief budget after the final compaction step ([pm-brief-github-7e06b2eb1b44-135](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/issues/pm-brief-github-7e06b2eb1b44-135.toon))
76
+
56
77
  ## 2026.10.5 - 2026-10-05
57
78
 
58
79
  ### Other
@@ -103,6 +124,7 @@
103
124
 
104
125
  ### Fixed
105
126
 
127
+ - Daily Release workflow is failing ([pm-brief-github-7e06b2eb1b44-84](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/issues/pm-brief-github-7e06b2eb1b44-84.toon))
106
128
  - Adopt pm CLI 2026.8.30 and stop pinning which way the SDK handles an incomplete merge receipt ([pm-brief-00tc](https://github.com/unbraind/pm-brief/blob/main/.agents/pm/issues/pm-brief-00tc.toon))
107
129
 
108
130
  ### Other
package/README.md CHANGED
@@ -485,7 +485,7 @@ immutable protocol artifacts. Receipts are append-only native comments protected
485
485
  by the host's comment lock and author/text idempotency. Native lock contention
486
486
  is retried up to three times for reads and idempotent appends; creates are never
487
487
  automatically retried. Persistent contention refuses with a retry remedy. Do not edit contract bodies
488
- or receipt comments. Create a fresh packet after a revision changes. A receipt
488
+ or receipt comments. Use repair/renew after source revisions change; create a fresh packet when the contract obligations change. A receipt
489
489
  racing a source edit is reassessed on return and every subsequent read. All source
490
490
  metadata, bodies and linked evidence contribute to content revisions, so even an
491
491
  unrelated source comment conservatively invalidates old understanding. Reads are
@@ -505,10 +505,92 @@ context delivery/usage receipts and the [since-delta candidate](https://github.c
505
505
  (`feat/since-delta-pack`)
506
506
  (`pm context-pack since --from <timestamp>` or `--cursor <cursor>`). That candidate
507
507
  owns cursor progress, changed-context selection and omission receipts. Use its delta to
508
- refresh changed context, then create a new handoff here. No history delta engine
508
+ refresh changed context, then repair existing receiver understanding here. No history delta engine
509
509
  is duplicated. pm-memory continues to own standing knowledge; `brief prompt`
510
510
  continues to generate prose. See the [contract example](docs/examples/handoff.json).
511
511
 
512
+ ## Handoff repair deltas
513
+
514
+ `brief handoff repair <packet-id> --receiver <agent>` returns only groups whose
515
+ sources changed since that receiver's last acknowledgment. `--receiver` defaults
516
+ to the active author. Each focus item is one obligation group, scoped to its
517
+ transitive declared blockers. Evidence requirements belong to each focus closure
518
+ that contains their item; evidence outside every focus closure gets its own group.
519
+ The existing contract has no per-question scope, so questions and assumptions
520
+ conservatively apply to every focus group. A shared blocker can affect several
521
+ groups. A 50-row packet with 25 independent focus/dependency pairs returns one
522
+ group when one dependency changes, preserving the other 24 acknowledgments.
523
+
524
+ ```sh
525
+ PM_AUTHOR=receiver-a pm brief handoff repair <packet-id> --json
526
+ PM_AUTHOR=receiver-a pm brief handoff renew <packet-id> --input renewal.json --json
527
+ ```
528
+
529
+ The delta includes changed source coordinates, current obligation text, affected
530
+ native evidence references, missing-link disclosure and exact group targets.
531
+ Unchanged obligations are represented by `validAcknowledgments` and `totalGroups`.
532
+ An empty `groups` array means that receiver has no pending repairs. Evidence link
533
+ presence and receiver understanding retain their separate meanings from `show`.
534
+ Legacy `show` continues to assess the original whole-packet acknowledgment;
535
+ `repair` assesses the receiver's current understanding at group granularity.
536
+
537
+ Renewal JSON supplies the immutable contract `digest` and a nonempty `groups`
538
+ array: each row has `id`, `target`, `questions`, `assumptions` and `evidence` maps.
539
+ Answer exactly that group's obligation keys. Partial group selection is supported.
540
+ Targets bind the contract and SDK-read source revisions, including native update
541
+ coordinates, so replaying older content does not renew a newer revision. Unchanged
542
+ groups need no response. Matching retries and a peer's completed renewal return
543
+ current validity without adding a duplicate receipt. Renewals append native comments
544
+ to the existing contract; no contract copies, sources, claims or separate stores
545
+ are written. Foreign or malformed receipt-shaped comments are skipped with an
546
+ indexed reason by repair/renew and never edited or deleted.
547
+
548
+ Each new acknowledgment stores `dependencies`, an array of per-group snapshots:
549
+ `{ id, items, edges, evidenceLinks }`. Renewals store `items`, `edges` and
550
+ `evidenceLinks` alongside that group's responses and revision target. `items`
551
+ records every covered item id, full SDK-row revision (or null for a missing item),
552
+ status, title and blocker edges. Native edge coordinates (`from`, `to`, `kind`)
553
+ and native evidence-link coordinates (`item`, `kind`, optional `ref`) each carry
554
+ SHA-256 revisions of their complete SDK-read metadata. These inventories come
555
+ from the same certified source read that validates the acknowledgment or renewal.
556
+
557
+ Repair compares the recorded dependency set plus newly reachable dependencies;
558
+ it never drops an already covered item because today's blocker edge disappeared.
559
+ Coverage identities persist across renewals, including transitive dependencies,
560
+ missing items and evidence outside the current focus closure. For example,
561
+ acknowledge A blocked by B with required evidence on B, remove A's blocker edge,
562
+ and renew A: later edits, link removal or deletion of B still invalidate A's
563
+ understanding. A new blocker can add further evidence obligations to a group.
564
+ Known missing evidence remains distinct from readiness, as in the original
565
+ handoff contract. The 64-item coverage limit and token refusal still apply;
566
+ create a new focused contract when retained coverage outgrows that limit.
567
+
568
+ Older acknowledgments without explicit inventories remain readable using the
569
+ immutable contract's item snapshots. Older renewal snapshots are conservatively
570
+ combined with prior coverage so they cannot erase previously covered identities.
571
+ New acknowledgments and renewals append the full inventory; stored history is
572
+ never rewritten to manufacture historical metadata. See the recorded-coverage
573
+ regression and independent graph model in `test/handoff-coverage.test.ts`.
574
+
575
+ The SDK exports `inspectHandoffRepair`, `renewHandoff`, `parseHandoffRenewal` and
576
+ `HandoffRepair`, `HandoffRepairGroup`, `HandoffRepairChange`, `HandoffRenewal`,
577
+ `HandoffRenewalGroup`, `HandoffDependencySet`, `HandoffAcknowledgedGroup`,
578
+ `HandoffCoveredEdge` and `HandoffCoveredEvidence`. Use the existing `HandoffOptions` with `pmRoot`, `author`,
579
+ optional native `client` and `tokenBudget`. Output uses native TOON/JSON and the
580
+ same 4,000-token default ceiling (maximum 64,000). Oversized deltas refuse rather
581
+ than discard obligations. An unacknowledged receiver gets `handoff_unacknowledged`
582
+ and must first acknowledge a fresh contract. Other typed refusals retain the
583
+ existing `HandoffError` codes and actionable remedies; stale renewal exits 3.
584
+
585
+ Renewal holds an SDK token-owned lease from its initial read through append and
586
+ verification, and uses native `comments({ ifAbsent: true })` for atomic append.
587
+ The SDK releases only its owned lease, last. Arbitrary source writers do not join
588
+ this lease: sources are rechecked after append, and a raced renewal refuses stale;
589
+ its historical comment cannot validate the newer revision. A later edit after the
590
+ final verified read appears on the next repair read. See the
591
+ [complete example](docs/examples/handoff-repair.md) and
592
+ [validation evidence](docs/handoff-repair-validation.md).
593
+
512
594
  ## Decision alternatives digest
513
595
 
514
596
  ```bash
package/dist/handoff.d.ts CHANGED
@@ -61,6 +61,7 @@ export interface HandoffReceipt {
61
61
  id: string;
62
62
  receiver: string;
63
63
  response: HandoffResponse;
64
+ dependencies?: HandoffAcknowledgedGroup[];
64
65
  acknowledgedAt: string;
65
66
  valid: boolean;
66
67
  ready: boolean;
@@ -74,8 +75,73 @@ export interface HandoffStatus {
74
75
  receipts: HandoffReceipt[];
75
76
  estimatedTokenCeiling: number;
76
77
  }
78
+ /** Receiver answers to one group, bound to its exact target source revision. */
79
+ export interface HandoffRenewalGroup {
80
+ id: string;
81
+ target: string;
82
+ questions: Record<string, string>;
83
+ assumptions: Record<string, string>;
84
+ evidence: Record<string, string>;
85
+ }
86
+ /** Partial renewal input; only groups shown by repair need responses. */
87
+ export interface HandoffRenewal {
88
+ digest: string;
89
+ groups: HandoffRenewalGroup[];
90
+ }
91
+ /** Changed source coordinate relative to the receiver's last acknowledgment. */
92
+ export interface HandoffRepairChange {
93
+ id: string;
94
+ from: string | null;
95
+ to: string | null;
96
+ }
97
+ /** One focused obligation group requiring renewed receiver understanding. */
98
+ export interface HandoffRepairGroup extends HandoffContract {
99
+ id: string;
100
+ target: string;
101
+ changes: HandoffRepairChange[];
102
+ missingEvidence: string[];
103
+ }
104
+ /** Compact receiver-specific delta; unchanged obligations are counted, never repeated. */
105
+ export interface HandoffRepair {
106
+ protocol: "pm-brief.repair/v1";
107
+ id: string;
108
+ digest: string;
109
+ receiver: string;
110
+ groups: HandoffRepairGroup[];
111
+ validAcknowledgments: number;
112
+ totalGroups: number;
113
+ skippedReceipts: {
114
+ index: number;
115
+ reason: string;
116
+ }[];
117
+ estimatedTokenCeiling: number;
118
+ }
119
+ /** Exact native edge coordinate covered by one acknowledgment, including its metadata revision. */
120
+ export interface HandoffCoveredEdge {
121
+ from: string;
122
+ to: string;
123
+ kind: string;
124
+ revision: string;
125
+ }
126
+ /** Exact native evidence-link coordinate; revision includes every SDK-read link field. */
127
+ export interface HandoffCoveredEvidence {
128
+ item: string;
129
+ kind: "docs" | "files" | "tests";
130
+ ref?: string;
131
+ revision: string;
132
+ }
133
+ /** Recorded coverage is retained across graph edits; missing items keep a null revision. */
134
+ export interface HandoffDependencySet {
135
+ items: HandoffRevision[];
136
+ edges: HandoffCoveredEdge[];
137
+ evidenceLinks: HandoffCoveredEvidence[];
138
+ }
139
+ /** One initial acknowledgment's exact per-group dependency set. */
140
+ export interface HandoffAcknowledgedGroup extends HandoffDependencySet {
141
+ id: string;
142
+ }
77
143
  /** Stable refusal categories callers can branch on without parsing prose. */
78
- export type HandoffRefusalCode = "invalid" | "missing" | "identity" | "stale" | "responses" | "integrity" | "budget" | "tracker";
144
+ export type HandoffRefusalCode = "invalid" | "missing" | "identity" | "stale" | "responses" | "integrity" | "budget" | "tracker" | "unacknowledged";
79
145
  /** Expected failure with a concrete recovery action; tracker writes never precede validation. */
80
146
  export declare class HandoffError extends Error {
81
147
  /** Machine-readable reason for refusing an operation. */
@@ -97,6 +163,12 @@ export declare function createHandoff(input: HandoffContract, options: HandoffOp
97
163
  export declare function inspectHandoff(id: string, options: HandoffOptions, receiver?: string): Promise<HandoffStatus>;
98
164
  /** Append an idempotent receiver receipt under the native comment lock; concurrent receivers never overwrite. */
99
165
  export declare function acknowledgeHandoff(id: string, input: HandoffResponse, options: HandoffOptions): Promise<HandoffStatus>;
166
+ /** Parse revision-bound partial responses without accepting duplicate group keys. */
167
+ export declare function parseHandoffRenewal(value: unknown): HandoffRenewal;
168
+ /** Read a receiver-specific repair delta without writing or repeating unaffected obligations. */
169
+ export declare function inspectHandoffRepair(id: string, options: HandoffOptions, receiver?: string): Promise<HandoffRepair>;
170
+ /** Serialize renewal planning through verification and let native ifAbsent perform the atomic append. */
171
+ export declare function renewHandoff(id: string, input: HandoffRenewal, options: HandoffOptions): Promise<HandoffRepair>;
100
172
  /** Register native structured TOON/JSON commands; expected refusals carry code and remedy at nonzero exit. */
101
173
  export declare function registerHandoffCommands(api: ExtensionApi): void;
102
174
  //# sourceMappingURL=handoff.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"handoff.d.ts","sourceRoot":"","sources":["../handoff.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"handoff.d.ts","sourceRoot":"","sources":["../handoff.ts"],"names":[],"mappings":"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"}
package/dist/handoff.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { open } from "node:fs/promises";
3
- import { PmClient, certifyCompleteListResult, createPmCliExpectedError, estimatePmOutputTokens, isPmCliExpectedError } from "@unbrained/pm-cli/sdk";
3
+ import { PmClient, acquireLock, certifyCompleteListResult, createPmCliExpectedError, estimatePmOutputTokens, isPmCliExpectedError } from "@unbrained/pm-cli/sdk";
4
4
  /** Expected failure with a concrete recovery action; tracker writes never precede validation. */
5
5
  export class HandoffError extends Error {
6
6
  /** Machine-readable reason for refusing an operation. */
@@ -17,6 +17,7 @@ export class HandoffError extends Error {
17
17
  }
18
18
  const PROTOCOL = "pm-brief.handoff/v1";
19
19
  const ACK_PREFIX = "pm-brief.ack/v1 ";
20
+ const RENEW_PREFIX = "pm-brief.renew/v1 ";
20
21
  const TAG = "pm-brief-handoff";
21
22
  const KEY = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
22
23
  /** Refuse malformed or excessive agent input without leaking its raw value. */
@@ -241,9 +242,12 @@ function requireResponses(response, contract) {
241
242
  }
242
243
  /** Read all receipts and recompute validity; receiver filters affect output only, never stored data. */
243
244
  export async function inspectHandoff(id, options, receiver) {
245
+ return handoffStatus(id, await corpus(options), options, receiver);
246
+ }
247
+ /** Assess receipts using the same certified source snapshot used for recording coverage. */
248
+ function handoffStatus(id, items, options, receiver) {
244
249
  if (receiver !== undefined)
245
250
  key(receiver);
246
- const items = await corpus(options);
247
251
  const { packet, source } = readPacket(id, items);
248
252
  const stale = staleItems(packet, items);
249
253
  packet.missingEvidence = missingEvidence(packet.contract, items);
@@ -261,7 +265,8 @@ export async function inspectHandoff(id, options, receiver) {
261
265
  throw new Error();
262
266
  if (receiver !== undefined && who !== receiver)
263
267
  continue;
264
- receipts.push({ id: digest(raw), receiver: who, response, acknowledgedAt: comment.created_at, valid: stale.length === 0, ready: stale.length === 0 && packet.missingEvidence.length === 0, staleItems: stale, missingEvidence: packet.missingEvidence });
268
+ const dependencies = readAcknowledgedGroups(raw.dependencies, packet, source);
269
+ receipts.push({ id: digest(raw), receiver: who, response, ...(dependencies === undefined ? {} : { dependencies }), acknowledgedAt: comment.created_at, valid: stale.length === 0, ready: stale.length === 0 && packet.missingEvidence.length === 0, staleItems: stale, missingEvidence: packet.missingEvidence });
265
270
  }
266
271
  catch {
267
272
  throw new HandoffError("integrity", "A handoff receipt is malformed or belongs to another contract", "Inspect the native packet comments; preserve history and create a fresh packet");
@@ -273,16 +278,18 @@ export async function inspectHandoff(id, options, receiver) {
273
278
  export async function acknowledgeHandoff(id, input, options) {
274
279
  const receiver = author(options);
275
280
  const response = parseHandoffResponse(input);
276
- const before = await inspectHandoff(id, options, receiver);
281
+ const items = await corpus(options);
282
+ const before = handoffStatus(id, items, options, receiver);
277
283
  if (response.digest !== before.packet.digest || before.staleItems.length > 0)
278
284
  throw new HandoffError("stale", "The handoff no longer matches the receiver's snapshot", "Create and read a fresh packet, then respond against its digest and current blocker revisions");
279
285
  requireResponses(response, before.packet.contract);
280
286
  // Reserve the full receipt before mutation so a small budget never leaves an unseen write.
281
- const raw = { receiver, response };
287
+ const dependencies = obligationGroups(before.packet).map(group => ({ id: group.id, ...dependencySet(group, groupItems(group.id, before.packet.contract.items), items) }));
288
+ const raw = { receiver, response, dependencies };
282
289
  const text = ACK_PREFIX + canonical(raw);
283
290
  if (before.receipts.some(receipt => receipt.id === digest(raw)))
284
291
  return before;
285
- bounded({ ...before, receipts: [...before.receipts, { id: digest(raw), receiver, response, acknowledgedAt: new Date().toISOString(), valid: true, ready: before.packet.missingEvidence.length === 0, staleItems: [], missingEvidence: before.packet.missingEvidence }] }, options);
292
+ bounded({ ...before, receipts: [...before.receipts, { id: digest(raw), receiver, response, dependencies, acknowledgedAt: new Date().toISOString(), valid: true, ready: before.packet.missingEvidence.length === 0, staleItems: [], missingEvidence: before.packet.missingEvidence }] }, options);
286
293
  try {
287
294
  await retryLocked(() => client(options).comments(id, { add: text, author: receiver, ifAbsent: true }));
288
295
  }
@@ -292,6 +299,270 @@ export async function acknowledgeHandoff(id, input, options) {
292
299
  // A source edit racing the append is reported stale, never upgraded into a valid receipt.
293
300
  return inspectHandoff(id, options, receiver);
294
301
  }
302
+ /** Parse revision-bound partial responses without accepting duplicate group keys. */
303
+ export function parseHandoffRenewal(value) {
304
+ const input = record(value);
305
+ const digest = parseHandoffResponse({ digest: input.digest, questions: {}, assumptions: {}, evidence: {} }).digest;
306
+ if (!Array.isArray(input.groups) || input.groups.length < 1 || input.groups.length > 64)
307
+ return invalid();
308
+ const groups = input.groups.map(value => {
309
+ const row = record(value);
310
+ const response = parseHandoffResponse({ digest: row.target, questions: row.questions, assumptions: row.assumptions, evidence: row.evidence });
311
+ return { id: key(row.id, Infinity), target: response.digest, questions: response.questions, assumptions: response.assumptions, evidence: response.evidence };
312
+ });
313
+ unique(groups.map(group => group.id));
314
+ return { digest, groups };
315
+ }
316
+ /** Traverse a saved or current blocker graph without introducing another dependency model. */
317
+ function groupItems(root, items) {
318
+ const pending = [root];
319
+ const found = new Map();
320
+ for (const id of pending) {
321
+ const item = items.find(item => item.id === id);
322
+ if (!item || found.has(id))
323
+ continue;
324
+ found.set(id, item);
325
+ pending.push(...item.blockers);
326
+ }
327
+ return [...found.values()].sort((a, b) => a.id.localeCompare(b.id, "en"));
328
+ }
329
+ /** Bind a group to the immutable contract and full current SDK-read source coordinates. */
330
+ function groupTarget(digestValue, id, items) { return digest({ digest: digestValue, id, items }); }
331
+ /** Partition the existing contract by focus closure; evidence outside every focus gets its own root. */
332
+ function obligationGroups(packet) {
333
+ const contract = packet.contract;
334
+ const roots = [...contract.focus];
335
+ const covered = new Set(roots.flatMap(id => groupItems(id, contract.items).map(row => row.id)));
336
+ for (const evidence of contract.requiredEvidence)
337
+ if (!covered.has(evidence.item) && !roots.includes(evidence.item))
338
+ roots.push(evidence.item);
339
+ return roots.map(id => {
340
+ const ids = new Set(groupItems(id, contract.items).map(row => row.id));
341
+ const focused = contract.focus.includes(id);
342
+ return { id, focus: [id], questions: focused ? contract.questions : [], assumptions: focused ? contract.assumptions : [], requiredEvidence: contract.requiredEvidence.filter(row => ids.has(row.item)) };
343
+ });
344
+ }
345
+ /** Read every previously covered identity plus its live blockers and required evidence; never prune on edge removal. */
346
+ function dependencySet(group, recorded, items) {
347
+ const snapshots = revisions({ ...group, focus: [...new Set([...group.focus, ...recorded.map(row => row.id)])] }, items);
348
+ const ids = new Set(snapshots.map(row => row.id));
349
+ const edges = [];
350
+ const evidenceLinks = [];
351
+ for (const item of items) {
352
+ if (!ids.has(item.id))
353
+ continue;
354
+ for (const edge of item.dependencies ?? [])
355
+ edges.push({ from: item.id, to: edge.id, kind: edge.kind, revision: digest(edge) });
356
+ if (item.blocked_by)
357
+ edges.push({ from: item.id, to: item.blocked_by, kind: "blocked_by", revision: digest({ blocked_by: item.blocked_by }) });
358
+ for (const kind of ["docs", "files", "tests"])
359
+ for (const link of item[kind] ?? [])
360
+ evidenceLinks.push({ item: item.id, kind, ref: "command" in link && typeof link.command === "string" ? link.command : link.path, revision: digest(link) });
361
+ }
362
+ const order = (a, b) => canonical(a).localeCompare(canonical(b), "en");
363
+ return { items: snapshots, edges: edges.sort(order), evidenceLinks: evidenceLinks.sort(order) };
364
+ }
365
+ /** Decode recorded revisions with the existing immutable artifact validator; old receipts remain readable. */
366
+ function recordedItems(value, packet, source) {
367
+ const document = { ...packet.contract, items: value };
368
+ const body = JSON.stringify({ document, digest: digest(document) });
369
+ return readPacket(packet.id, [{ ...source, body }]).packet.contract.items;
370
+ }
371
+ /** Validate explicit edge/link inventories without manufacturing historical data for legacy receipts. */
372
+ function readInventory(raw, snapshots) {
373
+ if (raw.edges === undefined && raw.evidenceLinks === undefined)
374
+ return undefined;
375
+ if (!Array.isArray(raw.edges) || !Array.isArray(raw.evidenceLinks))
376
+ return invalid();
377
+ const ids = new Set(snapshots.map(row => row.id));
378
+ const hash = (value) => parseHandoffResponse({ digest: value, questions: {}, assumptions: {}, evidence: {} }).digest;
379
+ const edges = raw.edges.map(value => {
380
+ const edge = record(value);
381
+ const from = key(edge.from, Infinity);
382
+ if (!ids.has(from))
383
+ return invalid();
384
+ return { from, to: string(edge.to, Infinity), kind: string(edge.kind, Infinity), revision: hash(edge.revision) };
385
+ });
386
+ const evidenceLinks = raw.evidenceLinks.map((value) => {
387
+ const link = record(value);
388
+ const item = key(link.item, Infinity);
389
+ if (!ids.has(item) || (link.kind !== "docs" && link.kind !== "files" && link.kind !== "tests"))
390
+ return invalid();
391
+ return { item, kind: link.kind, ref: link.ref === undefined ? undefined : string(link.ref, Infinity), revision: hash(link.revision) };
392
+ });
393
+ return { edges, evidenceLinks };
394
+ }
395
+ /** Validate an initial acknowledgment's complete group coverage, allowing older records without inventories. */
396
+ function readAcknowledgedGroups(value, packet, source) {
397
+ if (value === undefined)
398
+ return undefined;
399
+ if (!Array.isArray(value))
400
+ return invalid();
401
+ const groups = obligationGroups(packet);
402
+ const dependencies = value.map(value => {
403
+ const row = record(value);
404
+ const id = key(row.id, Infinity);
405
+ const items = recordedItems(row.items, packet, source);
406
+ const inventory = readInventory(row, items);
407
+ if (!inventory || !groups.some(group => group.id === id))
408
+ return invalid();
409
+ return { id, items, ...inventory };
410
+ });
411
+ unique(dependencies.map(row => row.id));
412
+ if (dependencies.length !== groups.length)
413
+ return invalid();
414
+ return dependencies;
415
+ }
416
+ /** Preserve all coverage identities while advancing their last recorded revisions after renewal. */
417
+ function previousCoverage(packet, group, initial, records) {
418
+ const baseline = groupItems(group.id, packet.contract.items);
419
+ const saved = [baseline, ...initial.filter(row => row.id === group.id).map(row => row.items), ...records.flatMap(row => row.groups).filter(row => row.id === group.id).map(row => row.items)];
420
+ const byId = new Map();
421
+ for (const items of saved)
422
+ for (const item of items)
423
+ byId.set(item.id, item);
424
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id, "en"));
425
+ }
426
+ /** Decode only attributable complete protocol records; foreign comments remain untouched and disclosed. */
427
+ function repairReceipts(source, packet, groups, receiver) {
428
+ let acknowledged = false;
429
+ const initial = [];
430
+ const records = [];
431
+ const skipped = [];
432
+ for (const [index, comment] of (source.comments ?? []).entries()) {
433
+ if (!comment.text.startsWith(ACK_PREFIX) && !comment.text.startsWith(RENEW_PREFIX))
434
+ continue;
435
+ try {
436
+ const renewal = comment.text.startsWith(RENEW_PREFIX);
437
+ const raw = record(JSON.parse(comment.text.slice(renewal ? RENEW_PREFIX.length : ACK_PREFIX.length)));
438
+ const who = key(raw.receiver);
439
+ if (who !== comment.author)
440
+ throw new Error();
441
+ if (!renewal) {
442
+ const response = parseHandoffResponse(raw.response);
443
+ requireResponses(response, packet.contract);
444
+ if (response.digest !== packet.digest)
445
+ throw new Error();
446
+ const dependencies = readAcknowledgedGroups(raw.dependencies, packet, source);
447
+ if (who === receiver && !acknowledged) {
448
+ initial.push(...(dependencies ?? []));
449
+ acknowledged = true;
450
+ }
451
+ continue;
452
+ }
453
+ const parsed = parseHandoffRenewal(raw);
454
+ if (parsed.digest !== packet.digest)
455
+ throw new Error();
456
+ const persisted = raw.groups.map((value, i) => {
457
+ const row = record(value);
458
+ const group = parsed.groups[i];
459
+ const obligation = groups.find(candidate => candidate.id === group.id);
460
+ if (!obligation || !Array.isArray(row.items) || row.items.length > 64)
461
+ throw new Error();
462
+ // Reuse the immutable packet decoder for structural and canonical revision validation.
463
+ const items = recordedItems(row.items, packet, source);
464
+ const inventory = readInventory(row, items);
465
+ const covered = new Set(items.map(row => row.id));
466
+ requireResponses({ digest: packet.digest, ...group }, { ...obligation, requiredEvidence: packet.contract.requiredEvidence.filter(row => covered.has(row.item)) });
467
+ if (group.target !== groupTarget(packet.digest, group.id, items))
468
+ throw new Error();
469
+ return { ...group, items, ...inventory };
470
+ });
471
+ if (who === receiver)
472
+ records.push({ receiver: who, digest: parsed.digest, groups: persisted });
473
+ }
474
+ catch {
475
+ skipped.push({ index: index + 1, reason: "Foreign or malformed receipt: protocol, attribution, contract or group binding does not match" });
476
+ }
477
+ }
478
+ return { acknowledged, initial, records, skipped };
479
+ }
480
+ /** Compute only pending groups from one certified SDK snapshot, preserving unrelated understanding. */
481
+ function repairSnapshot(id, items, options, receiver) {
482
+ const { packet, source } = readPacket(id, items);
483
+ const groups = obligationGroups(packet);
484
+ const receipts = repairReceipts(source, packet, groups, receiver);
485
+ if (!receipts.acknowledged)
486
+ throw new HandoffError("unacknowledged", "Receiver has not acknowledged this contract", "Use handoff ack against a fresh contract first; repair only renews an existing receiver acknowledgment");
487
+ const current = new Map();
488
+ const pending = [];
489
+ for (const group of groups) {
490
+ const previous = previousCoverage(packet, group, receipts.initial, receipts.records);
491
+ const coverage = dependencySet(group, previous, items);
492
+ current.set(group.id, coverage);
493
+ const targetItems = coverage.items;
494
+ // Newly reachable evidence requirements become part of this group's existing contract obligations.
495
+ const covered = new Set(targetItems.map(row => row.id));
496
+ const requiredEvidence = packet.contract.requiredEvidence.filter(row => covered.has(row.item));
497
+ const target = groupTarget(packet.digest, group.id, targetItems);
498
+ if (target === groupTarget(packet.digest, group.id, previous))
499
+ continue;
500
+ const ids = [...new Set([...previous, ...targetItems].map(row => row.id))].sort();
501
+ const changes = ids.filter(id => previous.find(row => row.id === id)?.revision !== targetItems.find(row => row.id === id)?.revision).map(id => ({ id, from: previous.find(row => row.id === id)?.revision ?? null, to: targetItems.find(row => row.id === id)?.revision ?? null }));
502
+ pending.push({ ...group, requiredEvidence, target, changes, missingEvidence: missingEvidence({ ...group, requiredEvidence }, items) });
503
+ }
504
+ const repair = { protocol: "pm-brief.repair/v1", id, digest: packet.digest, receiver, groups: pending, validAcknowledgments: groups.length - pending.length, totalGroups: groups.length, skippedReceipts: receipts.skipped, estimatedTokenCeiling: 0 };
505
+ return { repair: bounded(repair, options), current };
506
+ }
507
+ /** Read a receiver-specific repair delta without writing or repeating unaffected obligations. */
508
+ export async function inspectHandoffRepair(id, options, receiver = author(options)) {
509
+ return repairSnapshot(key(id, Infinity), await corpus(options), options, key(receiver)).repair;
510
+ }
511
+ /** Serialize renewal planning through verification and let native ifAbsent perform the atomic append. */
512
+ export async function renewHandoff(id, input, options) {
513
+ key(id, Infinity);
514
+ const receiver = author(options);
515
+ const renewal = parseHandoffRenewal(input);
516
+ let release;
517
+ try {
518
+ release = await acquireLock(options.pmRoot, `brief-renew-${digest(id)}`, 300, receiver, false, true, 1000);
519
+ }
520
+ catch {
521
+ throw new HandoffError("tracker", "Another receiver renewal holds the tracker lease", "Retry after the current writer finishes; inspect pm health if lease contention persists");
522
+ }
523
+ try {
524
+ const items = await corpus(options);
525
+ const { repair, current } = repairSnapshot(id, items, options, receiver);
526
+ const { packet, source } = readPacket(id, items);
527
+ const obligations = obligationGroups(packet);
528
+ const records = repairReceipts(source, packet, obligations, receiver).records;
529
+ if (renewal.digest !== repair.digest)
530
+ throw new HandoffError("stale", "Renewal belongs to an old contract", "Read handoff repair again and answer against its digest and group targets");
531
+ const storedGroups = renewal.groups.map(group => {
532
+ const obligation = obligations.find(row => row.id === group.id);
533
+ const snapshot = current.get(group.id);
534
+ if (!obligation || !snapshot || group.target !== groupTarget(repair.digest, group.id, snapshot.items))
535
+ throw new HandoffError("stale", "Renewal targets an old source revision or unknown group", "Read handoff repair again and use its current target for each affected group");
536
+ const covered = new Set(snapshot.items.map(row => row.id));
537
+ requireResponses({ digest: repair.digest, ...group }, { ...obligation, requiredEvidence: packet.contract.requiredEvidence.filter(row => covered.has(row.item)) });
538
+ const pending = repair.groups.some(row => row.id === group.id);
539
+ const peerSuccess = records.some(record => record.groups.some(row => row.id === group.id && row.target === group.target));
540
+ if (!pending && !peerSuccess)
541
+ throw new HandoffError("responses", "Renewal includes an unaffected group", "Answer only groups returned by handoff repair; unchanged acknowledgments remain valid");
542
+ return { ...group, ...snapshot };
543
+ });
544
+ const raw = { receiver, digest: repair.digest, groups: storedGroups };
545
+ const text = RENEW_PREFIX + canonical(raw);
546
+ // Check the output with the added receipt before any durable write, including foreign disclosures.
547
+ repairSnapshot(id, items.map(row => row.id === id ? { ...row, comments: [...(row.comments ?? []), { author: receiver, text, created_at: new Date().toISOString() }] } : row), options, receiver);
548
+ if (storedGroups.some(group => repair.groups.some(row => row.id === group.id))) {
549
+ try {
550
+ await retryLocked(() => client(options).comments(id, { add: text, author: receiver, ifAbsent: true }));
551
+ }
552
+ catch {
553
+ throw new HandoffError("tracker", "Renewal could not be appended", "Retry the same renewal after the current writer finishes; inspect handoff repair and pm health if contention persists");
554
+ }
555
+ }
556
+ const latest = repairSnapshot(id, await corpus(options), options, receiver);
557
+ const after = latest.repair;
558
+ if (renewal.groups.some(group => group.target !== groupTarget(after.digest, group.id, latest.current.get(group.id).items)))
559
+ throw new HandoffError("stale", "Sources changed while renewal was being recorded", "Read handoff repair again; the raced receipt remains historical and cannot validate the new revision");
560
+ return after;
561
+ }
562
+ finally {
563
+ await release();
564
+ }
565
+ }
295
566
  /** Read a bounded JSON input file; parsing failures give agents a typed remedy. */
296
567
  async function inputFile(value) {
297
568
  try {
@@ -324,15 +595,19 @@ function commandOptions(ctx) {
324
595
  /** Register native structured TOON/JSON commands; expected refusals carry code and remedy at nonzero exit. */
325
596
  export function registerHandoffCommands(api) {
326
597
  const budget = { long: "--token-budget", type: "string", value_name: "n", description: "Conservative output token ceiling (default 4000, maximum 64000); refuses instead of dropping obligations" };
327
- for (const action of ["create", "show", "ack", "preview"]) {
598
+ for (const action of ["create", "show", "ack", "preview", "repair", "renew"]) {
328
599
  api.registerCommand({
329
600
  name: `brief handoff ${action}`,
330
- description: action === "preview" ? "Preview a revision-bound handoff without writing" : action === "create" ? "Create a revision-bound handoff contract" : action === "ack" ? "Record explicit receiver understanding against a handoff digest" : "Inspect current handoff revisions, evidence gaps and receiver receipts",
331
- read_output: action === "show" ? { row_keys: ["receipts"] } : action === "preview" ? { row_keys: ["contract.items"] } : undefined,
332
- flags: [budget, ...(action === "show" ? [{ long: "--receiver", type: "string", value_name: "id", description: "Show one receiver without omitting that receiver's receipts" }] : [{ long: "--input", type: "string", value_name: "json-file", description: "Contract JSON for create; explicit response JSON for ack" }])],
601
+ description: action === "repair" ? "Read only affected obligations for one acknowledged receiver" : action === "renew" ? "Renew affected obligations against exact current source revisions" : action === "preview" ? "Preview a revision-bound handoff without writing" : action === "create" ? "Create a revision-bound handoff contract" : action === "ack" ? "Record explicit receiver understanding against a handoff digest" : "Inspect current handoff revisions, evidence gaps and receiver receipts",
602
+ read_output: action === "repair" ? { row_keys: ["groups"] } : action === "show" ? { row_keys: ["receipts"] } : action === "preview" ? { row_keys: ["contract.items"] } : undefined,
603
+ flags: [budget, ...((action === "show" || action === "repair") ? [{ long: "--receiver", type: "string", value_name: "id", description: "Show one receiver without omitting that receiver's receipts" }] : [{ long: "--input", type: "string", value_name: "json-file", description: "Contract JSON for create/preview; response JSON for ack; group renewal JSON for renew" }])],
333
604
  async run(ctx) {
334
605
  try {
335
606
  const options = commandOptions(ctx);
607
+ if (action === "repair")
608
+ return await inspectHandoffRepair(key(ctx.args[0], Infinity), options, ctx.options.receiver === undefined ? author(options) : key(ctx.options.receiver));
609
+ if (action === "renew")
610
+ return await renewHandoff(key(ctx.args[0], Infinity), parseHandoffRenewal(await inputFile(ctx.options.input)), options);
336
611
  if (action === "preview")
337
612
  return await previewHandoff(parseHandoffContract(await inputFile(ctx.options.input)), options);
338
613
  if (action === "create")