pi-daddy 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +154 -80
  2. package/README.md +54 -25
  3. package/dist/approval-prompt.d.ts +3 -1
  4. package/dist/approval-prompt.d.ts.map +1 -1
  5. package/dist/approval-prompt.js +1 -1
  6. package/dist/approval-prompt.js.map +1 -1
  7. package/dist/approval-store.d.ts.map +1 -1
  8. package/dist/approval-store.js +4 -1
  9. package/dist/approval-store.js.map +1 -1
  10. package/dist/approval.d.ts +20 -2
  11. package/dist/approval.d.ts.map +1 -1
  12. package/dist/approval.js +26 -6
  13. package/dist/approval.js.map +1 -1
  14. package/dist/chain.d.ts +6 -1
  15. package/dist/chain.d.ts.map +1 -1
  16. package/dist/chain.js +1 -1
  17. package/dist/chain.js.map +1 -1
  18. package/dist/check-runner.d.ts +61 -0
  19. package/dist/check-runner.d.ts.map +1 -0
  20. package/dist/check-runner.js +237 -0
  21. package/dist/check-runner.js.map +1 -0
  22. package/dist/correlation.d.ts +90 -0
  23. package/dist/correlation.d.ts.map +1 -0
  24. package/dist/correlation.js +183 -0
  25. package/dist/correlation.js.map +1 -0
  26. package/dist/delegate-types.d.ts +140 -0
  27. package/dist/delegate-types.d.ts.map +1 -0
  28. package/dist/delegate-types.js +8 -0
  29. package/dist/delegate-types.js.map +1 -0
  30. package/dist/delegate.d.ts +4 -128
  31. package/dist/delegate.d.ts.map +1 -1
  32. package/dist/delegate.js +68 -72
  33. package/dist/delegate.js.map +1 -1
  34. package/dist/delegation-approval.d.ts +36 -0
  35. package/dist/delegation-approval.d.ts.map +1 -0
  36. package/dist/delegation-approval.js +67 -0
  37. package/dist/delegation-approval.js.map +1 -0
  38. package/dist/git-identity.d.ts +13 -0
  39. package/dist/git-identity.d.ts.map +1 -0
  40. package/dist/git-identity.js +44 -0
  41. package/dist/git-identity.js.map +1 -0
  42. package/dist/index.d.ts +5 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +5 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/lease-record.d.ts +78 -0
  47. package/dist/lease-record.d.ts.map +1 -0
  48. package/dist/lease-record.js +52 -0
  49. package/dist/lease-record.js.map +1 -0
  50. package/dist/ledger-events.d.ts +87 -0
  51. package/dist/ledger-events.d.ts.map +1 -0
  52. package/dist/ledger-events.js +45 -0
  53. package/dist/ledger-events.js.map +1 -0
  54. package/dist/ledger-report.d.ts +19 -12
  55. package/dist/ledger-report.d.ts.map +1 -1
  56. package/dist/ledger-report.js +81 -1
  57. package/dist/ledger-report.js.map +1 -1
  58. package/dist/ledger.d.ts +44 -1
  59. package/dist/ledger.d.ts.map +1 -1
  60. package/dist/ledger.js +20 -2
  61. package/dist/ledger.js.map +1 -1
  62. package/dist/refusals.d.ts +16 -0
  63. package/dist/refusals.d.ts.map +1 -0
  64. package/dist/refusals.js +51 -0
  65. package/dist/refusals.js.map +1 -0
  66. package/dist/run-child.d.ts +4 -0
  67. package/dist/run-child.d.ts.map +1 -1
  68. package/dist/run-child.js +21 -1
  69. package/dist/run-child.js.map +1 -1
  70. package/dist/run-herdr.d.ts +7 -0
  71. package/dist/run-herdr.d.ts.map +1 -1
  72. package/dist/run-herdr.js +41 -12
  73. package/dist/run-herdr.js.map +1 -1
  74. package/dist/workspace-lease.d.ts +28 -0
  75. package/dist/workspace-lease.d.ts.map +1 -0
  76. package/dist/workspace-lease.js +276 -0
  77. package/dist/workspace-lease.js.map +1 -0
  78. package/dist/workspace.d.ts +32 -0
  79. package/dist/workspace.d.ts.map +1 -0
  80. package/dist/workspace.js +79 -0
  81. package/dist/workspace.js.map +1 -0
  82. package/extensions/approval-banking.ts +66 -0
  83. package/extensions/approvals.ts +95 -7
  84. package/extensions/chain-approval-facts.ts +51 -0
  85. package/extensions/chain-ledger.ts +48 -0
  86. package/extensions/delegate-chain.ts +115 -81
  87. package/extensions/delegation.ts +81 -23
  88. package/extensions/execute-child.ts +289 -0
  89. package/extensions/fanout-outcome.ts +97 -0
  90. package/extensions/run-delegation.ts +130 -120
  91. package/extensions/session.ts +4 -0
  92. package/extensions/workspace-runtime.ts +165 -0
  93. package/package.json +17 -1
  94. package/src/approval-prompt.ts +4 -2
  95. package/src/approval-store.ts +4 -1
  96. package/src/approval.ts +47 -14
  97. package/src/chain.ts +3 -1
  98. package/src/check-runner.ts +341 -0
  99. package/src/correlation.ts +260 -0
  100. package/src/delegate-types.ts +144 -0
  101. package/src/delegate.ts +80 -179
  102. package/src/delegation-approval.ts +99 -0
  103. package/src/git-identity.ts +52 -0
  104. package/src/index.ts +50 -0
  105. package/src/lease-record.ts +119 -0
  106. package/src/ledger-events.ts +138 -0
  107. package/src/ledger-report.ts +90 -2
  108. package/src/ledger.ts +68 -3
  109. package/src/refusals.ts +66 -0
  110. package/src/run-child.ts +23 -1
  111. package/src/run-herdr.ts +41 -11
  112. package/src/workspace-lease.ts +303 -0
  113. package/src/workspace.ts +135 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The lease's RECORD: what a workspace lease writes down about who holds it and how they let it go.
3
+ *
4
+ * Split from `./workspace-lease.ts` to stay under the 400-line module ceiling, along the seam this
5
+ * review round kept finding defects on — absent vs unreadable metadata, a swallowed handover write, a
6
+ * successor's token — all of which are about what the record SAYS rather than about the kernel lock.
7
+ */
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { readFile, rename, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import type { ValidatedWorkspace, WorkspaceAccess } from "./workspace.ts";
12
+
13
+ export interface LeaseMetadata {
14
+ version: 1;
15
+ state: "active" | "released";
16
+ token: string;
17
+ owner_id: string;
18
+ workspace_id: string;
19
+ root: string;
20
+ pid: number;
21
+ acquired_at: string;
22
+ released_at?: string;
23
+ release_reason?: string;
24
+ }
25
+
26
+ export interface WorkspaceLease {
27
+ workspace: ValidatedWorkspace;
28
+ access: WorkspaceAccess;
29
+ ownerId: string;
30
+ /**
31
+ * `true` only when the kernel lock was free but metadata said the prior owner never released.
32
+ * `"unknown"` when the prior owner's record could not be read at all — that is not evidence of a
33
+ * clean handover and must not be recorded as one (R-100).
34
+ */
35
+ recovered: boolean | "unknown";
36
+ /** Attach the governed resource so parent death cleans it before the kernel lock is released. */
37
+ attachProcess(pid: number): void;
38
+ attachHerdrTab(tabId: string): void;
39
+ /** Resolves only when the kernel-lock helper exits before explicit release. */
40
+ lost: Promise<Error>;
41
+ /**
42
+ * NEVER throws. A cleanup function that can throw destroys its caller's return value — a completed
43
+ * child's entire output was discarded this way (R-99) — so the outcome is a VALUE the caller records:
44
+ * `"released"` the kernel lock was handed back and the handover recorded;
45
+ * `"released-unrecorded"` the lock was handed back but the record failed, so the NEXT owner will
46
+ * report a recovery that did not happen unless this is ledgered;
47
+ * `"lost"` the helper was already gone, so this owner never released anything.
48
+ */
49
+ release(reason?: string): Promise<LeaseReleaseOutcome>;
50
+ /**
51
+ * Record that this lease is being kept deliberately, WITHOUT releasing the kernel lock.
52
+ *
53
+ * Needed because a retained lease never calls `release()`, so the metadata stays `state: "active"` and
54
+ * whoever acquires next reports `recovered: true` — blaming a crash on a known-good path.
55
+ */
56
+ markRetained(reason?: string): Promise<void>;
57
+ /** Reads the give-up marker the helper leaves when it could not close a herdr writer tab. */
58
+ readCloseFailure(): Promise<{ reason: string; herdr_tab?: string } | null>;
59
+ }
60
+
61
+ /**
62
+ * What a release actually did. Five members rather than three, because the first version conflated facts
63
+ * that call for different responses — and one of them is an alarm:
64
+ * `released` the lock went back and THIS owner wrote its own handover;
65
+ * `released-unrecorded` the lock went back and the record does not say so, so the next owner will report
66
+ * a recovery that never happened unless the caller ledgers this. **The alarm.**
67
+ * `released-superseded` the lock went back and a successor already owns the metadata, so we correctly
68
+ * declined to overwrite it. Healthy, and previously indistinguishable from the
69
+ * alarm above;
70
+ * `not-held` a read lease, which never took a kernel lock — recording it as `released`
71
+ * overstated how many handovers the kernel performed, which is R-105 on the
72
+ * release side of the same defect;
73
+ * `lost` the helper was already gone; this owner released nothing.
74
+ */
75
+ export type LeaseReleaseOutcome =
76
+ | "released"
77
+ | "released-unrecorded"
78
+ | "released-superseded"
79
+ | "not-held"
80
+ | "lost";
81
+
82
+ export function leasePaths(leaseDir: string, root: string) {
83
+ // Canonical root, never caller-chosen workspace ID: aliases for one worktree must contend.
84
+ const key = createHash("sha256").update(root, "utf8").digest("hex");
85
+ return {
86
+ lock: join(leaseDir, `${key}.lock`),
87
+ metadata: join(leaseDir, `${key}.json`),
88
+ marker: join(leaseDir, `${key}.close-failed.json`),
89
+ };
90
+ }
91
+
92
+ export async function atomicMetadata(path: string, value: LeaseMetadata): Promise<void> {
93
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
94
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
95
+ await rename(temp, path);
96
+ }
97
+
98
+ /**
99
+ * `null` means no prior owner was recorded. `"malformed"` means a record exists and cannot be read,
100
+ * which is strictly LESS evidence than a readable "active" one and must never read as a clean handover
101
+ * (R-100). Absent and unreadable are different facts; conflating them silently erases a real recovery.
102
+ */
103
+ export async function readMetadata(path: string): Promise<LeaseMetadata | null | "malformed"> {
104
+ let raw: string;
105
+ try {
106
+ raw = await readFile(path, "utf8");
107
+ } catch (error) {
108
+ return (error as NodeJS.ErrnoException)?.code === "ENOENT" ? null : "malformed";
109
+ }
110
+ try {
111
+ const value = JSON.parse(raw) as Partial<LeaseMetadata>;
112
+ if (value?.version !== 1) return "malformed";
113
+ if (value.state !== "active" && value.state !== "released") return "malformed";
114
+ if (typeof value.token !== "string" || !value.token) return "malformed";
115
+ return value as LeaseMetadata;
116
+ } catch {
117
+ return "malformed";
118
+ }
119
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The v2 runtime-event half of the ledger: workspace leases, child lifecycle and check receipts, with
3
+ * their builders. Split out of `./ledger.ts` only to stay under the 400-line module ceiling this
4
+ * project enforces mechanically; `./ledger.ts` re-exports everything here, so "the ledger module"
5
+ * remains one import.
6
+ */
7
+ import { LEDGER_VERSION, type LedgerEventBase, type GrantRecord } from "./ledger.ts";
8
+ import type { ExecutorKind } from "./executor.ts";
9
+ import type { CorrelationMetadata } from "./correlation.ts";
10
+ import type { StructuredRefusal } from "./refusals.ts";
11
+
12
+ /**
13
+ * `released` is a handover this owner performed. FOUR members were added by the 0.18.0 review pass, and
14
+ * they were not all previously recorded the same way — `uncontended` was recorded as an *acquisition*, and
15
+ * `lost` was recorded as nothing at all, because the old `release()` threw before the append ran:
16
+ * `lost` the kernel lock went away under a live governed writer — NOT the same fact as a user
17
+ * cancelling, which is what it used to be indistinguishable from (R-103);
18
+ * `retained` the lease was deliberately kept because a herdr writer tab would not close, so the
19
+ * successor's `recovered: true` would otherwise blame a healthy path (R-104);
20
+ * `uncontended` a read lease took no kernel lock at all, so counting it as an acquisition
21
+ * overstated how many exclusions the kernel actually performed (R-105).
22
+ */
23
+ export type WorkspaceLeaseOutcome =
24
+ | "acquired"
25
+ | "uncontended"
26
+ | "refused"
27
+ | "released"
28
+ | "released-unrecorded"
29
+ | "lost"
30
+ | "retained"
31
+ | "timeout"
32
+ | "recovered";
33
+
34
+ export interface WorkspaceLeaseEvent extends LedgerEventBase {
35
+ ledgerVersion: typeof LEDGER_VERSION;
36
+ event: "workspace_lease";
37
+ childId: string;
38
+ workspaceId: string;
39
+ root: string;
40
+ access: "read" | "write";
41
+ outcome: WorkspaceLeaseOutcome;
42
+ /** `"unknown"` when the prior owner's record was unreadable — not evidence of a clean handover. */
43
+ recovered?: boolean | "unknown";
44
+ releaseReason?: string;
45
+ refusal?: StructuredRefusal;
46
+ }
47
+
48
+ export interface ChildLifecycleEvent extends LedgerEventBase {
49
+ ledgerVersion: typeof LEDGER_VERSION;
50
+ event: "child_lifecycle";
51
+ childId: string;
52
+ state: "starting" | "completed" | "failed";
53
+ executor: ExecutorKind;
54
+ exitCode?: number | null;
55
+ signal?: NodeJS.Signals | null;
56
+ timedOut?: boolean;
57
+ aborted?: boolean;
58
+ truncated?: boolean;
59
+ reason?: string;
60
+ }
61
+
62
+ export interface CheckReceiptLedgerEvent extends LedgerEventBase {
63
+ ledgerVersion: typeof LEDGER_VERSION;
64
+ event: "check_receipt";
65
+ childId: string;
66
+ receiptId: string;
67
+ workspaceId: string;
68
+ checkId: string;
69
+ treeSha: string;
70
+ }
71
+
72
+ export type RuntimeLedgerEvent =
73
+ | (GrantRecord & { ledgerVersion: typeof LEDGER_VERSION; event: "capability_decision" })
74
+ | WorkspaceLeaseEvent
75
+ | ChildLifecycleEvent
76
+ | CheckReceiptLedgerEvent;
77
+
78
+ export function buildWorkspaceLeaseEvent(args: {
79
+ childId: string;
80
+ workspaceId: string;
81
+ root: string;
82
+ access: "read" | "write";
83
+ outcome: WorkspaceLeaseOutcome;
84
+ recovered?: boolean | "unknown";
85
+ releaseReason?: string;
86
+ refusal?: StructuredRefusal;
87
+ correlation?: CorrelationMetadata;
88
+ now: Date;
89
+ }): WorkspaceLeaseEvent {
90
+ return {
91
+ ledgerVersion: LEDGER_VERSION,
92
+ event: "workspace_lease",
93
+ ts: args.now.toISOString(),
94
+ childId: args.childId,
95
+ workspaceId: args.workspaceId,
96
+ root: args.root,
97
+ access: args.access,
98
+ outcome: args.outcome,
99
+ // `!== undefined`, not truthiness. `recovered: false` — a POSITIVE assertion that the predecessor
100
+ // handed over cleanly — was dropped while the truthy `"unknown"` survived, so absence meant both
101
+ // "clean" and "never supplied". R-100's whole argument is that absence of evidence is not evidence,
102
+ // and this was the one line discarding the evidence.
103
+ ...(args.recovered !== undefined ? { recovered: args.recovered } : {}),
104
+ ...(args.releaseReason ? { releaseReason: args.releaseReason } : {}),
105
+ ...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
106
+ ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
107
+ };
108
+ }
109
+
110
+ export function buildChildLifecycleEvent(args: {
111
+ childId: string;
112
+ state: "starting" | "completed" | "failed";
113
+ executor: ExecutorKind;
114
+ exitCode?: number | null;
115
+ signal?: NodeJS.Signals | null;
116
+ timedOut?: boolean;
117
+ aborted?: boolean;
118
+ truncated?: boolean;
119
+ reason?: string;
120
+ correlation?: CorrelationMetadata;
121
+ now: Date;
122
+ }): ChildLifecycleEvent {
123
+ return {
124
+ ledgerVersion: LEDGER_VERSION,
125
+ event: "child_lifecycle",
126
+ ts: args.now.toISOString(),
127
+ childId: args.childId,
128
+ state: args.state,
129
+ executor: args.executor,
130
+ ...(args.exitCode !== undefined ? { exitCode: args.exitCode } : {}),
131
+ ...(args.signal !== undefined ? { signal: args.signal } : {}),
132
+ ...(args.timedOut ? { timedOut: true } : {}),
133
+ ...(args.aborted ? { aborted: true } : {}),
134
+ ...(args.truncated ? { truncated: true } : {}),
135
+ ...(args.reason ? { reason: args.reason } : {}),
136
+ ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
137
+ };
138
+ }
@@ -17,12 +17,20 @@ import { DELEGATE_SUBJECT } from "./approval.ts";
17
17
  import type { ApprovalSource } from "./approval.ts";
18
18
  import type { GrantRecord } from "./ledger.ts";
19
19
  import { isEscalationAttempt } from "./ledger.ts";
20
+ import type { WorkspaceLeaseOutcome } from "./ledger-events.ts";
20
21
 
21
22
  export interface LedgerReport {
22
23
  /** False when the file is absent — a configuration state, not damage. */
23
24
  exists: boolean;
24
- /** Lines that parsed as records. */
25
+ /** Capability-decision records (legacy or v2). */
25
26
  records: number;
27
+ /** Every parsed ledger event, including lifecycle and lease events. */
28
+ events: number;
29
+ workspaceLeases: {
30
+ acquired: number; uncontended: number; refused: number; released: number;
31
+ releasedUnrecorded: number; lost: number; retained: number; timeout: number; recovered: number;
32
+ };
33
+ lifecycle: { starting: number; completed: number; failed: number };
26
34
  /** Lines that did not, with 1-based line numbers so the report is actionable. */
27
35
  corrupt: Array<{ line: number; text: string }>;
28
36
  /** Records where an agent asked for more than it held — ADR-0008's designated signal. */
@@ -116,6 +124,43 @@ export interface LedgerReport {
116
124
  * Deliberately reports rather than repairs. A corrupt line is evidence; rewriting the file to make it parse
117
125
  * would destroy the one artifact an investigation has.
118
126
  */
127
+ // Keyed by the UNION, not by `string`. Typed loosely, adding a `WorkspaceLeaseOutcome` member without a
128
+ // counter compiled fine and then made `verifyLedger` file the package's own valid event as CORRUPTION —
129
+ // the worst available outcome for an integrity signal, since real damage becomes noise.
130
+ const LEASE_OUTCOME_COUNTERS: Record<WorkspaceLeaseOutcome, keyof LedgerReport["workspaceLeases"]> = {
131
+ acquired: "acquired",
132
+ uncontended: "uncontended",
133
+ refused: "refused",
134
+ released: "released",
135
+ "released-unrecorded": "releasedUnrecorded",
136
+ lost: "lost",
137
+ retained: "retained",
138
+ timeout: "timeout",
139
+ recovered: "recovered",
140
+ };
141
+
142
+ function validV2Base(event: Record<string, unknown>): boolean {
143
+ return event.ledgerVersion === 2 && typeof event.ts === "string";
144
+ }
145
+
146
+ function requireV2(event: Record<string, unknown>, fields: string[]): void {
147
+ if (!validV2Base(event) || fields.some((field) => typeof event[field] !== "string" || event[field] === "")) {
148
+ throw new Error("invalid versioned ledger event");
149
+ }
150
+ }
151
+
152
+ function requireCapabilityDecision(event: Record<string, unknown>): void {
153
+ requireV2(event, ["parentId", "childId", "executor", "taskDigest"]);
154
+ for (const field of ["requested", "parentGrant", "effective", "denied", "clipped", "gatedBlocked"]) {
155
+ if (!Array.isArray(event[field]) || !(event[field] as unknown[]).every((value) => typeof value === "string")) {
156
+ throw new Error("invalid capability decision arrays");
157
+ }
158
+ }
159
+ if (!Number.isInteger(event.depth) || typeof event.blocked !== "boolean" || !/^[a-f0-9]{64}$/i.test(String(event.taskDigest))) {
160
+ throw new Error("invalid capability decision identity");
161
+ }
162
+ }
163
+
119
164
  export async function verifyLedger(path: string): Promise<LedgerReport> {
120
165
  let text: string;
121
166
  try {
@@ -125,6 +170,9 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
125
170
  return {
126
171
  exists: false,
127
172
  records: 0,
173
+ events: 0,
174
+ workspaceLeases: { acquired: 0, uncontended: 0, refused: 0, released: 0, releasedUnrecorded: 0, lost: 0, retained: 0, timeout: 0, recovered: 0 },
175
+ lifecycle: { starting: 0, completed: 0, failed: 0 },
128
176
  corrupt: [],
129
177
  escalationAttempts: 0,
130
178
  executors: { herdr: 0, process: 0, unknown: 0 },
@@ -146,6 +194,9 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
146
194
  // Keyed by name+digest: one name with two entries is the signal that the definition changed mid-ledger.
147
195
  const digests = new Map<string, { name: string; source: string; sha256: string; spawns: number }>();
148
196
  let records = 0;
197
+ let events = 0;
198
+ const workspaceLeases = { acquired: 0, uncontended: 0, refused: 0, released: 0, releasedUnrecorded: 0, lost: 0, retained: 0, timeout: 0, recovered: 0 };
199
+ const lifecycle = { starting: 0, completed: 0, failed: 0 };
149
200
  let escalationAttempts = 0;
150
201
  const executors = { herdr: 0, process: 0, unknown: 0 };
151
202
  const bySource: Record<ApprovalSource, number> = { prompt: 0, session: 0, persisted: 0, inherited: 0 };
@@ -165,9 +216,43 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
165
216
  // A trailing newline yields one empty final element, which is normal rather than damage.
166
217
  if (raw.trim().length === 0) return;
167
218
  try {
168
- const parsed = JSON.parse(raw) as GrantRecord;
219
+ const event = JSON.parse(raw) as Record<string, unknown>;
220
+ if (event.ledgerVersion !== undefined && event.event === undefined) {
221
+ throw new Error("versioned ledger line has no event discriminator");
222
+ }
223
+ if (event.event === "workspace_lease") {
224
+ requireV2(event, ["childId", "workspaceId", "root", "access"]);
225
+ const outcome = typeof event.outcome === "string" ? event.outcome : "";
226
+ // Explicit map, not a name match: the ledger's outcome vocabulary and these counter names are
227
+ // allowed to differ, and a valid event must never be counted as corruption because a counter
228
+ // happens to be spelled differently.
229
+ const counter = Object.hasOwn(LEASE_OUTCOME_COUNTERS, outcome)
230
+ ? LEASE_OUTCOME_COUNTERS[outcome as WorkspaceLeaseOutcome]
231
+ : undefined;
232
+ if (!counter) throw new Error("invalid workspace lease event");
233
+ workspaceLeases[counter] += 1;
234
+ events += 1;
235
+ return;
236
+ }
237
+ if (event.event === "child_lifecycle") {
238
+ requireV2(event, ["childId", "executor"]);
239
+ const state = typeof event.state === "string" ? event.state : "";
240
+ if (!Object.hasOwn(lifecycle, state)) throw new Error("invalid child lifecycle event");
241
+ lifecycle[state as keyof typeof lifecycle] += 1;
242
+ events += 1;
243
+ return;
244
+ }
245
+ if (event.event === "check_receipt") {
246
+ requireV2(event, ["childId", "receiptId", "workspaceId", "checkId", "treeSha"]);
247
+ events += 1;
248
+ return;
249
+ }
250
+ if (event.event !== undefined && event.event !== "capability_decision") throw new Error("unknown ledger event");
251
+ const parsed = event as unknown as GrantRecord;
252
+ if (event.event === "capability_decision") requireCapabilityDecision(event);
169
253
  if (!Array.isArray(parsed.denied)) throw new Error("not a grant record");
170
254
  records += 1;
255
+ events += 1;
171
256
  if (isEscalationAttempt(parsed)) escalationAttempts += 1;
172
257
  const executor = (parsed as { executor?: unknown }).executor;
173
258
  if (executor === "herdr") executors.herdr += 1;
@@ -227,6 +312,9 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
227
312
  return {
228
313
  exists: true,
229
314
  records,
315
+ events,
316
+ workspaceLeases,
317
+ lifecycle,
230
318
  corrupt,
231
319
  escalationAttempts,
232
320
  executors,
package/src/ledger.ts CHANGED
@@ -28,9 +28,29 @@ import type { DefinitionDigest } from "./definitions.ts";
28
28
  import { DELEGATE_SUBJECT } from "./approval.ts";
29
29
  import type { ApprovalScope, ApprovalSource } from "./approval.ts";
30
30
  import type { PromptOutcomeKind } from "./approval-prompt.ts";
31
+ import type { CorrelationMetadata } from "./correlation.ts";
32
+ import type { StructuredRefusal } from "./refusals.ts";
33
+ // Type-only, so the cycle with ./ledger-events.ts is erased at runtime.
34
+ import type { RuntimeLedgerEvent } from "./ledger-events.ts";
31
35
 
32
- export interface GrantRecord {
36
+ export const LEDGER_VERSION = 2 as const;
37
+
38
+ export interface LedgerEventBase {
39
+ /** Optional only on the legacy-compatible `GrantRecord` public type; every v2 event builder writes it. */
40
+ ledgerVersion?: typeof LEDGER_VERSION;
41
+ event?: "capability_decision" | "workspace_lease" | "child_lifecycle" | "check_receipt";
33
42
  ts: string;
43
+ childId?: string;
44
+ correlation?: CorrelationMetadata;
45
+ }
46
+
47
+ export interface GrantRecord extends LedgerEventBase {
48
+ /**
49
+ * Present only when a trusted `taskDigest` was supplied — that is what makes a line v2. `buildRecord`
50
+ * does NOT always emit it, and the comment here used to say it did while the emission site 200 lines
51
+ * down said the opposite. Two comments asserting contradictory facts about one field is R-28's shape.
52
+ */
53
+ event?: "capability_decision";
34
54
  parentId: string;
35
55
  childId: string;
36
56
  depth: number;
@@ -77,6 +97,10 @@ export interface GrantRecord {
77
97
  * one capability while claiming to describe the set was not merely a reporting defect.
78
98
  */
79
99
  approvalScopes?: Record<Capability, ApprovalScope>;
100
+ /** Persisted approval expiry, by capability. Session-scoped approvals have no wall-clock expiry. */
101
+ approvalExpiresAt?: Record<Capability, string>;
102
+ /** Count bounds where one exists. A consumed `once` approval records `{max:1, remaining:0}`. */
103
+ approvalUses?: Record<Capability, { max: number; remaining: number }>;
80
104
  /** Present only when the source was a live prompt, and only when one scope covers the whole set. */
81
105
  approvalScope?: ApprovalScope;
82
106
  /** A human was asked and declined. Distinct from `denied`, which is an escalation attempt. */
@@ -134,6 +158,10 @@ export interface GrantRecord {
134
158
  * `definitionDigest` names its instructions, and neither says where the TASK came from.
135
159
  */
136
160
  taskFrom?: string;
161
+ /** Trusted SHA-256 of the exact task; task text remains forbidden (ADR-0034). */
162
+ taskDigest?: string;
163
+ /** Stable machine-readable refusal accompanying `reason`. */
164
+ refusal?: StructuredRefusal;
137
165
  }
138
166
 
139
167
  export interface LedgerOptions {
@@ -146,6 +174,11 @@ export interface LedgerOptions {
146
174
  * closed. Set false only where the ledger is advisory.
147
175
  */
148
176
  strict?: boolean;
177
+ /**
178
+ * Called when a NON-strict append failed. Required in spirit rather than in types: a caller that opts
179
+ * out of failing closed still has to say something, or the ledger silently develops holes.
180
+ */
181
+ onFailure?: (error: unknown) => void;
149
182
  }
150
183
 
151
184
  export function buildRecord(args: {
@@ -161,6 +194,8 @@ export function buildRecord(args: {
161
194
  approved?: Capability[];
162
195
  approvalSources?: Record<Capability, ApprovalSource>;
163
196
  approvalScopes?: Record<Capability, ApprovalScope>;
197
+ approvalExpiresAt?: Record<Capability, string>;
198
+ approvalUses?: Record<Capability, { max: number; remaining: number }>;
164
199
  humanDenied?: boolean;
165
200
  gateOutcome?: PromptOutcomeKind;
166
201
  definitionDigest?: DefinitionDigest;
@@ -168,6 +203,9 @@ export function buildRecord(args: {
168
203
  executor: ExecutorKind;
169
204
  /** The child whose output composed this task (ADR-0033). Absent for anything but a chain step. */
170
205
  taskFrom?: string;
206
+ taskDigest?: string;
207
+ correlation?: CorrelationMetadata;
208
+ refusal?: StructuredRefusal;
171
209
  now: Date;
172
210
  }): GrantRecord {
173
211
  // R-46: the scalar is a SUMMARY, emitted only when it cannot mislead. `buildRecord` derives it rather
@@ -177,6 +215,9 @@ export function buildRecord(args: {
177
215
  const scopes = args.approvalScopes ?? {};
178
216
  const distinctScopes = [...new Set(Object.values(scopes))];
179
217
  return {
218
+ // A trusted task digest is what distinguishes a v2 capability event from the legacy construction API.
219
+ // Production delegation always supplies it; old TypeScript callers remain able to append legacy lines.
220
+ ...(args.taskDigest ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
180
221
  ts: args.now.toISOString(),
181
222
  parentId: args.parentId,
182
223
  childId: args.childId,
@@ -184,6 +225,9 @@ export function buildRecord(args: {
184
225
  agentType: args.agentType,
185
226
  executor: args.executor,
186
227
  ...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
228
+ ...(args.taskDigest ? { taskDigest: args.taskDigest } : {}),
229
+ ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
230
+ ...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
187
231
  requested: args.requested,
188
232
  parentGrant: args.parentGrant,
189
233
  effective: args.result.effective,
@@ -197,6 +241,10 @@ export function buildRecord(args: {
197
241
  ...(Object.keys(sources).length > 0 ? { approvalSources: sources } : {}),
198
242
  ...(distinctScopes.length === 1 ? { approvalScope: distinctScopes[0] } : {}),
199
243
  ...(Object.keys(scopes).length > 0 ? { approvalScopes: scopes } : {}),
244
+ ...(args.approvalExpiresAt && Object.keys(args.approvalExpiresAt).length > 0
245
+ ? { approvalExpiresAt: args.approvalExpiresAt }
246
+ : {}),
247
+ ...(args.approvalUses && Object.keys(args.approvalUses).length > 0 ? { approvalUses: args.approvalUses } : {}),
200
248
  ...(args.humanDenied ? { humanDenied: true } : {}),
201
249
  // Written whenever a gate was reached and not satisfied by a yes. `granted` is omitted deliberately —
202
250
  // an approved spawn already says so through `approvalSources`, and a field that appears on every record
@@ -228,8 +276,8 @@ export { verifyLedger, type LedgerReport } from "./ledger-report.ts";
228
276
  const withLedgerLock = <T>(path: string, write: () => Promise<T>): Promise<T> =>
229
277
  withFileLock(path, "grant ledger", write);
230
278
 
231
- export async function appendRecord(options: LedgerOptions, record: GrantRecord): Promise<void> {
232
- const line = `${JSON.stringify(record)}\n`;
279
+ export async function appendLedgerEvent(options: LedgerOptions, event: RuntimeLedgerEvent | GrantRecord): Promise<void> {
280
+ const line = `${JSON.stringify(event)}\n`;
233
281
  try {
234
282
  await mkdir(dirname(options.path), { recursive: true });
235
283
  // O_APPEND alone is not enough once several processes write to one ledger — see `withLedgerLock`.
@@ -238,10 +286,27 @@ export async function appendRecord(options: LedgerOptions, record: GrantRecord):
238
286
  if (options.strict ?? true) {
239
287
  throw new Error(`grant ledger write failed (failing closed): ${String(error)}`);
240
288
  }
289
+ // A non-strict append is a deliberate choice not to fail closed. It is NOT a choice to be silent:
290
+ // a silent safe-mode is as confusing as a silent unsafe one, so the caller is always told.
291
+ options.onFailure?.(error);
241
292
  }
242
293
  }
243
294
 
295
+ export async function appendRecord(options: LedgerOptions, record: GrantRecord): Promise<void> {
296
+ return appendLedgerEvent(options, record);
297
+ }
298
+
244
299
  /** True when this record shows an agent asking for more than it holds. */
245
300
  export function isEscalationAttempt(record: GrantRecord): boolean {
246
301
  return record.denied.length > 0;
247
302
  }
303
+
304
+ export {
305
+ buildChildLifecycleEvent,
306
+ buildWorkspaceLeaseEvent,
307
+ type ChildLifecycleEvent,
308
+ type CheckReceiptLedgerEvent,
309
+ type RuntimeLedgerEvent,
310
+ type WorkspaceLeaseEvent,
311
+ type WorkspaceLeaseOutcome,
312
+ } from "./ledger-events.ts";
@@ -0,0 +1,66 @@
1
+ export const REFUSAL_CODES = [
2
+ "CAPABILITY_ESCALATION",
3
+ "DEFINITION_NOT_AUTHORIZED",
4
+ "UNDECLARED_TOOLS",
5
+ "UNKNOWN_TOOL",
6
+ "GATED_UNAPPROVED",
7
+ "APPROVAL_EXPIRED",
8
+ "APPROVAL_SCOPE_MISMATCH",
9
+ "APPROVAL_FLOW_FAILED",
10
+ "DEPTH_EXCEEDED",
11
+ "FANOUT_EXCEEDED",
12
+ "EXECUTOR_UNAVAILABLE",
13
+ // Execution-phase outcomes. Without these, an external controller could distinguish a policy refusal
14
+ // from an internal error but not a lost writer lease from a user pressing stop (R-103).
15
+ "CHILD_TIMED_OUT",
16
+ "CHILD_CANCELLED",
17
+ "CHILD_EXIT_NONZERO",
18
+ // Planner refusals that previously carried a message and no code at all — including the narrowing
19
+ // violation, which is the hardest rule this package enforces (R-109).
20
+ "TASK_MISSING",
21
+ "UNKNOWN_DEFINITION",
22
+ "CEILING_PATTERNS_UNRESOLVED",
23
+ "NARROWING_VIOLATED",
24
+ "DEFINITION_UNREADABLE",
25
+ "CORRELATION_TOO_LARGE",
26
+ "CORRELATION_INVALID",
27
+ "LEDGER_WRITE_FAILED",
28
+ "FANOUT_FAILED",
29
+ "WORKSPACE_NOT_REGISTERED",
30
+ "WORKSPACE_WRITE_CONFLICT",
31
+ "WORKSPACE_LEASE_STALE",
32
+ "CHECK_NOT_CONFIGURED",
33
+ "CHECK_CONFIGURATION_INVALID",
34
+ "CHECK_IDENTITY_UNAVAILABLE",
35
+ "CHECK_IDENTITY_MISMATCH",
36
+ ] as const;
37
+
38
+ export type RefusalCode = (typeof REFUSAL_CODES)[number];
39
+
40
+ export interface StructuredRefusal {
41
+ code: RefusalCode;
42
+ /** Existing actionable diagnostic. Codes accompany rather than replace it. */
43
+ message: string;
44
+ details?: Record<string, string | number | boolean | null>;
45
+ }
46
+
47
+ export function refusal(
48
+ code: RefusalCode,
49
+ message: string,
50
+ details?: StructuredRefusal["details"],
51
+ ): StructuredRefusal {
52
+ return { code, message, ...(details && Object.keys(details).length > 0 ? { details: { ...details } } : {}) };
53
+ }
54
+
55
+ /** Error shape for API callers. pi-facing tools still throw, as pi requires, while retaining a stable code. */
56
+ export class GovernanceRefusal extends Error {
57
+ readonly code: RefusalCode;
58
+ readonly details?: StructuredRefusal["details"];
59
+
60
+ constructor(value: StructuredRefusal) {
61
+ super(value.message);
62
+ this.name = "GovernanceRefusal";
63
+ this.code = value.code;
64
+ this.details = value.details;
65
+ }
66
+ }
package/src/run-child.ts CHANGED
@@ -81,6 +81,8 @@ export interface ChildRunRequest {
81
81
  * governed child mid-task.
82
82
  */
83
83
  onOutput?: (chunk: string) => void;
84
+ /** Security hook called immediately after spawn, before output handling (for lease attachment). */
85
+ onSpawn?: (pid: number) => void;
84
86
  }
85
87
 
86
88
  export interface ChildRunResult {
@@ -90,6 +92,8 @@ export interface ChildRunResult {
90
92
  truncated: boolean;
91
93
  timedOut: boolean;
92
94
  aborted: boolean;
95
+ /** Signal that ended the process, separate from the nullable numeric exit code. */
96
+ signal?: NodeJS.Signals | null;
93
97
  /** Set when the process could not be started at all. */
94
98
  spawnError?: string;
95
99
  }
@@ -125,6 +129,17 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
125
129
  settle({ code: null, text: "", truncated: false, timedOut: false, aborted: false, spawnError: String(error) });
126
130
  return;
127
131
  }
132
+ try {
133
+ if (child.pid !== undefined) request.onSpawn?.(child.pid);
134
+ } catch (error) {
135
+ const failed = { code: null, text: "", truncated: false, timedOut: false, aborted: false, spawnError: String(error) };
136
+ child.stdout?.destroy();
137
+ child.stderr?.destroy();
138
+ child.once("error", () => settle(failed));
139
+ child.once("close", () => settle(failed));
140
+ child.kill("SIGKILL");
141
+ return;
142
+ }
128
143
 
129
144
  // One decoder for BOTH streams is deliberate: they are already interleaved into one `text`, and two
130
145
  // decoders would each hold their own partial character, so a split byte could surface out of order.
@@ -227,12 +242,19 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
227
242
  done = true;
228
243
  clearTimers();
229
244
  request.signal?.removeEventListener("abort", onAbort);
245
+ child.stdout?.destroy();
246
+ child.stderr?.destroy();
230
247
  settle(result);
231
248
  };
232
249
 
233
250
  child.on("error", (error) =>
234
251
  finish({ code: null, text, truncated, timedOut, aborted, spawnError: String(error) }),
235
252
  );
236
- child.on("close", (code) => finish({ code, text, truncated, timedOut, aborted }));
253
+ // `close` waits for every inherited pipe, including one retained by a detached grandchild. Once the
254
+ // governed PID exits, allow a short drain and settle anyway so timeout/output bounds remain bounds.
255
+ child.on("exit", (code, signal) => {
256
+ timers.push(setTimeout(() => finish({ code, signal, text, truncated, timedOut, aborted }), 100));
257
+ });
258
+ child.on("close", (code, signal) => finish({ code, signal, text, truncated, timedOut, aborted }));
237
259
  });
238
260
  }