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 +29 -7
- package/README.md +84 -2
- package/dist/handoff.d.ts +73 -1
- package/dist/handoff.d.ts.map +1 -1
- package/dist/handoff.js +285 -10
- package/dist/handoff.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/docs/examples/handoff-repair.md +39 -0
- package/manifest.json +1 -1
- package/package.json +10 -5
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.
|
|
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
|
|
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
|
package/dist/handoff.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handoff.d.ts","sourceRoot":"","sources":["../handoff.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
|
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")
|