@nanobpm/nano-workforce 0.145.0 → 0.146.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.146.0](https://github.com/nanobpm/nano-workforce/compare/v0.145.1...v0.146.0) (2026-08-26)
2
+
3
+ ### Features
4
+
5
+ * **agentic:** add a permission transcript-event kind (request + resolution) ([#560](https://github.com/nanobpm/nano-workforce/issues/560)) ([722bb96](https://github.com/nanobpm/nano-workforce/commit/722bb962b24c85faf585e598b905bf67a43040d4)), closes [#559](https://github.com/nanobpm/nano-workforce/issues/559)
6
+
7
+ ## [0.145.1](https://github.com/nanobpm/nano-workforce/compare/v0.145.0...v0.145.1) (2026-08-26)
8
+
9
+ ### Bug Fixes
10
+
11
+ * bound the merge-loop wait-landed wait so a never-enqueued PR escalates ([#556](https://github.com/nanobpm/nano-workforce/issues/556)) ([#558](https://github.com/nanobpm/nano-workforce/issues/558)) ([0e14578](https://github.com/nanobpm/nano-workforce/commit/0e14578e23d3914f8ae82a9fd84280fbc05c6588))
12
+
1
13
  ## [0.145.0](https://github.com/nanobpm/nano-workforce/compare/v0.144.0...v0.145.0) (2026-08-25)
2
14
 
3
15
  ### Features
@@ -184,3 +184,219 @@ test("deriveViewFromChunks: a mixed log derives typed structure while retaining
184
184
  assertEquals(view.messages.map((m) => m.text), ["hi"]);
185
185
  assertEquals(view.rawChunkCount, 1);
186
186
  });
187
+
188
+ // --- permission kind (issue #559) -----------------------------------------------------------------
189
+
190
+ const REQUEST_OPTIONS = [
191
+ { optionId: "allow", name: "Allow", kind: "allow-once" },
192
+ { optionId: "deny", name: "Deny", kind: "reject-once" },
193
+ ];
194
+
195
+ test("core vocab decodes a permission REQUEST envelope", () => {
196
+ const chunk = env("permission", {
197
+ phase: "request",
198
+ callId: "p1",
199
+ policy: "escalate",
200
+ options: REQUEST_OPTIONS,
201
+ toolName: "write_file",
202
+ title: "Write to /etc/hosts?",
203
+ reason: "The agent wants to modify a system file",
204
+ });
205
+ assertEquals(parseTranscriptEvent({ offset: 1, chunk }), {
206
+ kind: "permission",
207
+ phase: "request",
208
+ offset: 1,
209
+ callId: "p1",
210
+ policy: "escalate",
211
+ options: REQUEST_OPTIONS,
212
+ toolName: "write_file",
213
+ title: "Write to /etc/hosts?",
214
+ reason: "The agent wants to modify a system file",
215
+ });
216
+ });
217
+
218
+ test("core vocab decodes a permission REQUEST with only required fields (optional fields omitted)", () => {
219
+ const chunk = env("permission", { phase: "request", callId: "p2", policy: "yolo", options: REQUEST_OPTIONS });
220
+ assertEquals(parseTranscriptEvent({ offset: 0, chunk }), {
221
+ kind: "permission",
222
+ phase: "request",
223
+ offset: 0,
224
+ callId: "p2",
225
+ policy: "yolo",
226
+ options: REQUEST_OPTIONS,
227
+ });
228
+ });
229
+
230
+ test("core vocab decodes a permission RESOLUTION envelope", () => {
231
+ const chunk = env("permission", { phase: "resolution", callId: "p1", optionId: "allow", allowed: true, by: "operator" });
232
+ assertEquals(parseTranscriptEvent({ offset: 2, chunk }), {
233
+ kind: "permission",
234
+ phase: "resolution",
235
+ offset: 2,
236
+ callId: "p1",
237
+ optionId: "allow",
238
+ allowed: true,
239
+ by: "operator",
240
+ });
241
+ });
242
+
243
+ test("core vocab: malformed permission envelopes fall back to raw stream-chunk", () => {
244
+ // request missing options
245
+ assertEquals(
246
+ parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "request", callId: "p1", policy: "escalate" }) }).kind,
247
+ "stream-chunk",
248
+ );
249
+ // request with a bad policy
250
+ assertEquals(
251
+ parseTranscriptEvent({
252
+ offset: 0,
253
+ chunk: env("permission", { phase: "request", callId: "p1", policy: "maybe", options: REQUEST_OPTIONS }),
254
+ }).kind,
255
+ "stream-chunk",
256
+ );
257
+ // request with a malformed option (bad kind)
258
+ assertEquals(
259
+ parseTranscriptEvent({
260
+ offset: 0,
261
+ chunk: env("permission", { phase: "request", callId: "p1", policy: "escalate", options: [{ optionId: "a", name: "A", kind: "nope" }] }),
262
+ }).kind,
263
+ "stream-chunk",
264
+ );
265
+ // resolution missing allowed
266
+ assertEquals(
267
+ parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "resolution", callId: "p1", optionId: "allow" }) }).kind,
268
+ "stream-chunk",
269
+ );
270
+ // resolution with a present-but-unknown `by` provenance (rejected, not silently dropped)
271
+ assertEquals(
272
+ parseTranscriptEvent({
273
+ offset: 0,
274
+ chunk: env("permission", { phase: "resolution", callId: "p1", optionId: "allow", allowed: true, by: "robot" }),
275
+ }).kind,
276
+ "stream-chunk",
277
+ );
278
+ // missing callId
279
+ assertEquals(
280
+ parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "request", policy: "escalate", options: REQUEST_OPTIONS }) }).kind,
281
+ "stream-chunk",
282
+ );
283
+ // unknown phase
284
+ assertEquals(
285
+ parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "wat", callId: "p1" }) }).kind,
286
+ "stream-chunk",
287
+ );
288
+ });
289
+
290
+ test("mergeTranscriptVocab: the permission kind is additive — provable via a merged vocab too", () => {
291
+ // Registering an unrelated kind via merge must not disturb the core `permission` decoder.
292
+ const vocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
293
+ reasoning: (body, offset) => ({ kind: "message", offset, role: "system", text: String(body.text ?? "") }),
294
+ });
295
+ const chunk = env("permission", { phase: "request", callId: "p9", policy: "escalate", options: REQUEST_OPTIONS });
296
+ assertEquals(parseTranscriptEvent({ offset: 5, chunk }, vocab), {
297
+ kind: "permission",
298
+ phase: "request",
299
+ offset: 5,
300
+ callId: "p9",
301
+ policy: "escalate",
302
+ options: REQUEST_OPTIONS,
303
+ });
304
+ });
305
+
306
+ test("encodeTranscriptEvent round-trips the permission kind through the one parser", () => {
307
+ const events: TranscriptEvent[] = [
308
+ {
309
+ kind: "permission",
310
+ phase: "request",
311
+ offset: 10,
312
+ callId: "p1",
313
+ policy: "escalate",
314
+ options: REQUEST_OPTIONS.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind === "allow-once" ? "allow-once" : "reject-once" })),
315
+ toolName: "rm",
316
+ title: "Delete?",
317
+ reason: "why",
318
+ },
319
+ { kind: "permission", phase: "resolution", offset: 11, callId: "p1", optionId: "deny", allowed: false, by: "auto" },
320
+ ];
321
+ for (const original of events) {
322
+ const chunk = encodeTranscriptEvent(original);
323
+ assertEquals(parseTranscriptEvent({ offset: original.offset, chunk }), original);
324
+ }
325
+ });
326
+
327
+ test("deriveView: pairs a permission request with its resolution by callId, carrying policy + options", () => {
328
+ const view = deriveView([
329
+ { kind: "turn", offset: 0, index: 0 },
330
+ {
331
+ kind: "permission",
332
+ phase: "request",
333
+ offset: 1,
334
+ callId: "p1",
335
+ policy: "escalate",
336
+ options: [
337
+ { optionId: "allow", name: "Allow", kind: "allow-once" },
338
+ { optionId: "deny", name: "Deny", kind: "reject-once" },
339
+ ],
340
+ title: "Write file?",
341
+ },
342
+ { kind: "permission", phase: "resolution", offset: 2, callId: "p1", optionId: "allow", allowed: true, by: "operator" },
343
+ ]);
344
+ assertEquals(view.permissions.length, 1);
345
+ const permission = view.permissions[0];
346
+ assertEquals(permission?.policy, "escalate");
347
+ assertEquals(permission?.title, "Write file?");
348
+ assertEquals(permission?.options.length, 2);
349
+ assertEquals(permission?.resolved, { allowed: true, optionId: "allow", offset: 2, by: "operator" });
350
+ // Also attached to its enclosing turn.
351
+ assertEquals(view.turns[0]?.permissions.length, 1);
352
+ assertEquals(view.turns[0]?.permissions[0]?.resolved?.optionId, "allow");
353
+ });
354
+
355
+ test("deriveView: an unresolved permission request stays pending (no resolution)", () => {
356
+ const view = deriveView([
357
+ {
358
+ kind: "permission",
359
+ phase: "request",
360
+ offset: 0,
361
+ callId: "p1",
362
+ policy: "escalate",
363
+ options: [{ optionId: "allow", name: "Allow", kind: "allow-once" }],
364
+ },
365
+ ]);
366
+ assertEquals(view.permissions.length, 1);
367
+ assertEquals(view.permissions[0]?.resolved, undefined);
368
+ assertEquals(view.permissions[0]?.policy, "escalate");
369
+ // Content before any turn event opens an implicit turn 0 (consistent with messages/tools).
370
+ assertEquals(view.turns.length, 1);
371
+ assertEquals(view.turns[0]?.permissions.length, 1);
372
+ });
373
+
374
+ test("deriveView: the policy field survives derivation for a yolo request", () => {
375
+ const view = deriveView([
376
+ {
377
+ kind: "permission",
378
+ phase: "request",
379
+ offset: 0,
380
+ callId: "p1",
381
+ policy: "yolo",
382
+ options: [{ optionId: "allow", name: "Allow", kind: "allow-always" }],
383
+ },
384
+ { kind: "permission", phase: "resolution", offset: 1, callId: "p1", optionId: "allow", allowed: true, by: "auto" },
385
+ ]);
386
+ assertEquals(view.permissions[0]?.policy, "yolo");
387
+ assertEquals(view.permissions[0]?.resolved?.by, "auto");
388
+ });
389
+
390
+ test("ACP plan updates map onto the existing step/turn vocabulary (no new kind)", () => {
391
+ // An ACP plan entry becomes a `step` (its label the entry title); a plan/turn boundary a `turn`.
392
+ const view = deriveViewFromChunks([
393
+ { offset: 0, chunk: env("turn", { index: 0 }) },
394
+ { offset: 1, chunk: env("step", { label: "Investigate the failing test", index: 0 }) },
395
+ { offset: 2, chunk: env("step", { label: "Fix the bug", index: 1 }) },
396
+ { offset: 3, chunk: env("turn", { index: 1 }) },
397
+ { offset: 4, chunk: env("step", { label: "Write a regression test" }) },
398
+ ]);
399
+ assertEquals(view.turns.length, 2);
400
+ assertEquals(view.turns[0]?.steps, 2);
401
+ assertEquals(view.turns[1]?.steps, 1);
402
+ });
@@ -64,7 +64,8 @@ export type TranscriptEventKind =
64
64
  | "tool-result"
65
65
  | "turn"
66
66
  | "step"
67
- | "lifecycle";
67
+ | "lifecycle"
68
+ | "permission";
68
69
 
69
70
  /** The message roles the derived history distinguishes (assistant is authoritative for derivation). */
70
71
  export type TranscriptRole = "assistant" | "user" | "system" | "tool";
@@ -124,6 +125,70 @@ export interface LifecycleEvent extends TranscriptEventBase {
124
125
  readonly phase: "open" | "completed" | "exited";
125
126
  }
126
127
 
128
+ // --- Permission (ACP `session/request_permission`) — SHARED CONTRACT (issue #559) ------------------
129
+ // A `permission` event models ACP's `session/request_permission`: the agent asks the operator to
130
+ // allow/deny a proposed action (usually a tool call), and the operator (or an auto policy) resolves it.
131
+ // It is decoded here (the ONE parser) and folded here (the ONE fold) into a paired {@link DerivedPermission}.
132
+ // These exported types are the SINGLE SOURCE OF TRUTH the sibling slices (cockpit render, escalation
133
+ // bridge) consume — they must import these, never reinvent a divergent permission shape. See the durable
134
+ // declaration in `app/contracts.ts` (`type:PermissionPolicy`, `wire:transcript.permission`).
135
+
136
+ /**
137
+ * The role's permission policy the PRODUCER tags a request with. `"escalate"` means a human must be
138
+ * asked (the cockpit renders an Allow/Deny prompt, the escalation bridge raises a user task);
139
+ * `"yolo"` means the action is auto-allowed and never prompts a human. Cockpit + bridge branch on this.
140
+ */
141
+ export type PermissionPolicy = "escalate" | "yolo";
142
+
143
+ /** The kind of a permission option — mirrors ACP's option kinds (allow/reject × once/always). */
144
+ export type PermissionOptionKind = "allow-once" | "allow-always" | "reject-once" | "reject-always";
145
+
146
+ /** One offered permission option (ACP `options[]` member): a stable id, a label, and its kind. */
147
+ export interface PermissionOption {
148
+ readonly optionId: string;
149
+ readonly name: string;
150
+ readonly kind: PermissionOptionKind;
151
+ }
152
+
153
+ /**
154
+ * A permission REQUEST: the agent asks the operator to allow/deny a proposed action. The `callId`
155
+ * pairs the eventual {@link PermissionResolutionEvent} back to this request (mirroring how
156
+ * `tool-call`/`tool-result` pair by `callId`).
157
+ */
158
+ export interface PermissionRequestEvent extends TranscriptEventBase {
159
+ readonly kind: "permission";
160
+ readonly phase: "request";
161
+ /** Stable id pairing this request to its resolution. */
162
+ readonly callId: string;
163
+ /** The producer-tagged policy the cockpit + bridge branch on. */
164
+ readonly policy: PermissionPolicy;
165
+ /** The offered options — always at least one (the decoder rejects an empty list). */
166
+ readonly options: readonly PermissionOption[];
167
+ /** The tool the proposed action would invoke, when known. */
168
+ readonly toolName?: string;
169
+ /** A short human-readable title for the prompt. */
170
+ readonly title?: string;
171
+ /** A longer human-readable reason for the prompt. */
172
+ readonly reason?: string;
173
+ }
174
+
175
+ /**
176
+ * A permission RESOLUTION: the operator's (or an auto policy's) decision, carrying the same `callId`,
177
+ * the chosen `optionId`, and whether the action was `allowed`.
178
+ */
179
+ export interface PermissionResolutionEvent extends TranscriptEventBase {
180
+ readonly kind: "permission";
181
+ readonly phase: "resolution";
182
+ /** The `callId` of the {@link PermissionRequestEvent} this resolves. */
183
+ readonly callId: string;
184
+ /** The chosen option's id. */
185
+ readonly optionId: string;
186
+ /** True = allowed, false = denied. */
187
+ readonly allowed: boolean;
188
+ /** Provenance of the decision, when supplied. */
189
+ readonly by?: "operator" | "auto";
190
+ }
191
+
127
192
  /** The core typed transcript-event union (merge-extensible: authors add kinds via the vocab). */
128
193
  export type TranscriptEvent =
129
194
  | StreamChunkEvent
@@ -132,7 +197,9 @@ export type TranscriptEvent =
132
197
  | ToolResultEvent
133
198
  | TurnEvent
134
199
  | StepEvent
135
- | LifecycleEvent;
200
+ | LifecycleEvent
201
+ | PermissionRequestEvent
202
+ | PermissionResolutionEvent;
136
203
 
137
204
  /** A stored chunk as the store/read path exposes it (mirrors `TranscriptChunk`). */
138
205
  export interface StoredChunk {
@@ -172,6 +239,32 @@ function isRecord(value: unknown): value is Record<string, unknown> {
172
239
  return value !== null && typeof value === "object" && !Array.isArray(value);
173
240
  }
174
241
 
242
+ const PERMISSION_OPTION_KINDS: readonly PermissionOptionKind[] = [
243
+ "allow-once",
244
+ "allow-always",
245
+ "reject-once",
246
+ "reject-always",
247
+ ];
248
+
249
+ /**
250
+ * Decode ACP's `options[]` into typed {@link PermissionOption}s, or `undefined` if the array is
251
+ * missing/empty or any member is malformed (so the whole request envelope is rejected → `stream-chunk`).
252
+ */
253
+ function decodePermissionOptions(value: unknown): PermissionOption[] | undefined {
254
+ if (!Array.isArray(value) || value.length === 0) return undefined;
255
+ const options: PermissionOption[] = [];
256
+ for (const raw of value) {
257
+ if (!isRecord(raw)) return undefined;
258
+ const optionId = str(raw, "optionId");
259
+ const name = str(raw, "name");
260
+ const kindRaw = str(raw, "kind");
261
+ const kind = PERMISSION_OPTION_KINDS.find((k) => k === kindRaw);
262
+ if (optionId === undefined || name === undefined || kind === undefined) return undefined;
263
+ options.push({ optionId, name, kind });
264
+ }
265
+ return options;
266
+ }
267
+
175
268
  /**
176
269
  * The opinionated core vocabulary — the built-in event kinds every consumer understands out of the
177
270
  * box. Authors extend it in the SAME schema via {@link mergeTranscriptVocab}; they never fork the
@@ -207,6 +300,11 @@ export const CORE_TRANSCRIPT_VOCAB: TranscriptVocab = Object.freeze({
207
300
  ...(content !== undefined ? { content } : {}),
208
301
  };
209
302
  },
303
+ // ACP `plan` mapping: ACP `session/update` plan updates map onto the EXISTING `step`/`turn`
304
+ // vocabulary rather than a new kind — an ACP plan ENTRY becomes a `step` (its `label` is the plan
305
+ // entry's title; the entry ordinal is not preserved, as `StepEvent` carries only a `label`), and a
306
+ // plan/turn BOUNDARY becomes a `turn` (its `index` the ACP turn/plan ordinal). The decoders below
307
+ // already cope with an ACP-shaped `label` (`step`) / `index` (`turn`), so no new kind is needed.
210
308
  turn: (body, offset) => {
211
309
  const index = num(body, "index");
212
310
  return index !== undefined ? { kind: "turn", offset, index } : { kind: "turn", offset };
@@ -220,6 +318,48 @@ export const CORE_TRANSCRIPT_VOCAB: TranscriptVocab = Object.freeze({
220
318
  if (phase !== "open" && phase !== "completed" && phase !== "exited") return undefined;
221
319
  return { kind: "lifecycle", offset, phase };
222
320
  },
321
+ // A single `permission` decoder handles BOTH shapes (never a parser fork), branching on `phase`.
322
+ // Malformed envelopes return `undefined` and fall back to `stream-chunk`, like the other decoders.
323
+ permission: (body, offset) => {
324
+ const callId = str(body, "callId");
325
+ if (callId === undefined) return undefined;
326
+ const phase = str(body, "phase");
327
+ if (phase === "request") {
328
+ const policy = str(body, "policy");
329
+ if (policy !== "escalate" && policy !== "yolo") return undefined;
330
+ const options = decodePermissionOptions(body.options);
331
+ if (options === undefined) return undefined;
332
+ const toolName = str(body, "toolName");
333
+ const title = str(body, "title");
334
+ const reason = str(body, "reason");
335
+ const event: PermissionRequestEvent = { kind: "permission", phase: "request", offset, callId, policy, options };
336
+ return {
337
+ ...event,
338
+ ...(toolName !== undefined ? { toolName } : {}),
339
+ ...(title !== undefined ? { title } : {}),
340
+ ...(reason !== undefined ? { reason } : {}),
341
+ };
342
+ }
343
+ if (phase === "resolution") {
344
+ const optionId = str(body, "optionId");
345
+ if (optionId === undefined) return undefined;
346
+ if (typeof body.allowed !== "boolean") return undefined;
347
+ const by = str(body, "by");
348
+ // Reject a malformed `by` rather than silently dropping it: a present-but-unknown provenance is a
349
+ // producer bug, and swallowing it would make the typed event diverge from the on-wire JSON.
350
+ if (by !== undefined && by !== "operator" && by !== "auto") return undefined;
351
+ const event: PermissionResolutionEvent = {
352
+ kind: "permission",
353
+ phase: "resolution",
354
+ offset,
355
+ callId,
356
+ optionId,
357
+ allowed: body.allowed,
358
+ };
359
+ return { ...event, ...(by !== undefined ? { by } : {}) };
360
+ }
361
+ return undefined;
362
+ },
223
363
  });
224
364
 
225
365
  /**
@@ -302,12 +442,35 @@ export interface DerivedMessage {
302
442
  readonly offset: number;
303
443
  }
304
444
 
445
+ /**
446
+ * A derived permission: a permission REQUEST paired with its RESOLUTION by `callId` (resolution absent
447
+ * while the request is still pending), mirroring how {@link DerivedTool} pairs a call with its result.
448
+ * The cockpit and the escalation bridge read THIS — they never re-parse the log.
449
+ */
450
+ export interface DerivedPermission {
451
+ readonly callId: string;
452
+ readonly policy: PermissionPolicy;
453
+ readonly options: readonly PermissionOption[];
454
+ readonly toolName?: string;
455
+ readonly title?: string;
456
+ readonly reason?: string;
457
+ readonly offset: number;
458
+ /** The resolution, once present (pending request → `undefined`). */
459
+ readonly resolved?: {
460
+ readonly allowed: boolean;
461
+ readonly optionId: string;
462
+ readonly by?: "operator" | "auto";
463
+ readonly offset: number;
464
+ };
465
+ }
466
+
305
467
  /** A derived turn: the messages, tool cards and step count folded within one turn boundary. */
306
468
  export interface DerivedTurn {
307
469
  readonly index: number;
308
470
  readonly startOffset: number;
309
471
  readonly messages: readonly DerivedMessage[];
310
472
  readonly tools: readonly DerivedTool[];
473
+ readonly permissions: readonly DerivedPermission[];
311
474
  readonly steps: number;
312
475
  }
313
476
 
@@ -319,6 +482,8 @@ export interface DerivedView {
319
482
  readonly messages: readonly DerivedMessage[];
320
483
  /** Every tool card across all turns, in offset order. */
321
484
  readonly tools: readonly DerivedTool[];
485
+ /** Every permission across all turns, in offset order (each request paired to its resolution by `callId`). */
486
+ readonly permissions: readonly DerivedPermission[];
322
487
  /** Total retained raw bytes (UTF-8) across `stream-chunk` events — the byte-replay fidelity accounting. */
323
488
  readonly rawByteLength: number;
324
489
  /** Number of retained raw chunks. */
@@ -334,6 +499,7 @@ interface MutableTurn {
334
499
  startOffset: number;
335
500
  messages: DerivedMessage[];
336
501
  tools: DerivedTool[];
502
+ permissions: DerivedPermission[];
337
503
  steps: number;
338
504
  }
339
505
 
@@ -351,8 +517,10 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
351
517
  const turns: MutableTurn[] = [];
352
518
  const messages: DerivedMessage[] = [];
353
519
  const tools: DerivedTool[] = [];
520
+ const permissions: DerivedPermission[] = [];
354
521
  const openTools = new Map<string, DerivedTool>();
355
522
  let anonymousTool: DerivedTool | undefined;
523
+ const openPermissions = new Map<string, DerivedPermission>();
356
524
  let rawByteLength = 0;
357
525
  let rawChunkCount = 0;
358
526
  let lifecycle: "open" | "completed" | "exited" = "open";
@@ -361,7 +529,7 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
361
529
 
362
530
  const ensureTurn = (offset: number): MutableTurn => {
363
531
  if (current === undefined) {
364
- current = { index: turns.length, startOffset: offset, messages: [], tools: [], steps: 0 };
532
+ current = { index: turns.length, startOffset: offset, messages: [], tools: [], permissions: [], steps: 0 };
365
533
  turns.push(current);
366
534
  }
367
535
  return current;
@@ -371,7 +539,14 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
371
539
  eventCount++;
372
540
  switch (event.kind) {
373
541
  case "turn": {
374
- current = { index: event.index ?? turns.length, startOffset: event.offset, messages: [], tools: [], steps: 0 };
542
+ current = {
543
+ index: event.index ?? turns.length,
544
+ startOffset: event.offset,
545
+ messages: [],
546
+ tools: [],
547
+ permissions: [],
548
+ steps: 0,
549
+ };
375
550
  turns.push(current);
376
551
  break;
377
552
  }
@@ -408,6 +583,33 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
408
583
  }
409
584
  break;
410
585
  }
586
+ case "permission": {
587
+ // A `permission` event is one of two phases (same discriminant `kind`); branch on `phase`. A
588
+ // REQUEST opens a pending DerivedPermission (paired to its turn); a RESOLUTION folds back into
589
+ // the open request by `callId` — mirroring the tool-call/tool-result open-map pairing above.
590
+ if (event.phase === "request") {
591
+ const permission: DerivedPermission = {
592
+ policy: event.policy,
593
+ options: event.options,
594
+ offset: event.offset,
595
+ callId: event.callId,
596
+ ...(event.toolName !== undefined ? { toolName: event.toolName } : {}),
597
+ ...(event.title !== undefined ? { title: event.title } : {}),
598
+ ...(event.reason !== undefined ? { reason: event.reason } : {}),
599
+ };
600
+ permissions.push(permission);
601
+ ensureTurn(event.offset).permissions.push(permission);
602
+ openPermissions.set(event.callId, permission);
603
+ } else {
604
+ const target = openPermissions.get(event.callId);
605
+ if (target !== undefined) {
606
+ pairResolution(permissions, target, event);
607
+ pairResolutionInTurns(turns, target, event);
608
+ openPermissions.delete(event.callId);
609
+ }
610
+ }
611
+ break;
612
+ }
411
613
  case "lifecycle": {
412
614
  lifecycle = event.phase;
413
615
  break;
@@ -421,9 +623,17 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
421
623
  }
422
624
 
423
625
  return {
424
- turns: turns.map((t) => ({ index: t.index, startOffset: t.startOffset, messages: t.messages, tools: t.tools, steps: t.steps })),
626
+ turns: turns.map((t) => ({
627
+ index: t.index,
628
+ startOffset: t.startOffset,
629
+ messages: t.messages,
630
+ tools: t.tools,
631
+ permissions: t.permissions,
632
+ steps: t.steps,
633
+ })),
425
634
  messages,
426
635
  tools,
636
+ permissions,
427
637
  rawByteLength,
428
638
  rawChunkCount,
429
639
  lifecycle,
@@ -457,6 +667,35 @@ function withResult(tool: DerivedTool, result: ToolResultEvent): DerivedTool {
457
667
  };
458
668
  }
459
669
 
670
+ /** Replace a pending permission with its resolution in the flat list (by identity — see {@link pairResult}). */
671
+ function pairResolution(list: DerivedPermission[], target: DerivedPermission, resolution: PermissionResolutionEvent): void {
672
+ const idx = list.indexOf(target);
673
+ if (idx >= 0) list[idx] = withResolution(target, resolution);
674
+ }
675
+
676
+ /** Replace a pending permission with its resolution inside whichever turn holds it. */
677
+ function pairResolutionInTurns(turns: MutableTurn[], target: DerivedPermission, resolution: PermissionResolutionEvent): void {
678
+ for (const turn of turns) {
679
+ const idx = turn.permissions.indexOf(target);
680
+ if (idx >= 0) {
681
+ turn.permissions[idx] = withResolution(target, resolution);
682
+ return;
683
+ }
684
+ }
685
+ }
686
+
687
+ function withResolution(permission: DerivedPermission, resolution: PermissionResolutionEvent): DerivedPermission {
688
+ return {
689
+ ...permission,
690
+ resolved: {
691
+ allowed: resolution.allowed,
692
+ optionId: resolution.optionId,
693
+ offset: resolution.offset,
694
+ ...(resolution.by !== undefined ? { by: resolution.by } : {}),
695
+ },
696
+ };
697
+ }
698
+
460
699
  /**
461
700
  * Convenience: parse a run of stored chunks into typed events through {@link parseTranscriptEvent} (the
462
701
  * one parser) and fold them with {@link deriveView} in a single call — the entry point a consumer uses
package/app/contracts.ts CHANGED
@@ -136,6 +136,13 @@ export const ENV_CONTRACTS = {
136
136
  owner: "app/service.ts",
137
137
  semantics: "Minutes between review nudges.",
138
138
  },
139
+ NANO_PR_MERGE_LANDED_WAIT_TIMEOUT: {
140
+ category: "env",
141
+ name: "NANO_PR_MERGE_LANDED_WAIT_TIMEOUT",
142
+ owner: "app/service.ts",
143
+ semantics:
144
+ "How long the merge loop waits for a queued PR to actually land before escalating (FEEL/ISO-8601 duration).",
145
+ },
139
146
  NANO_PR_AUTO_MERGE: {
140
147
  category: "env",
141
148
  name: "NANO_PR_AUTO_MERGE",
@@ -400,9 +407,26 @@ export const WIRE_CONTRACTS = {
400
407
  "Filesystem-import request body POSTed to /actions/delivery-graph/library/import (issue #524, epic #519 S5). Declared in openapi.yaml as `ImportToLibrarySubmit`; the compose App-View's `<input type=file accept=.json>` reads the picked file's text client-side and POSTs it here as the raw `graphJson` string. The door validates + compiles it through the SAME `parseAndCompileText` pipeline preview/stage/save use, then persists `source: imported` — an uncompilable graph is a clean 400 and NOTHING is written. Its `name` defaults to the imported graph's own `name`; an explicit `name` overrides it (an unnamed graph with no override is a clean 400 — the library id is name-derived). Related to but DISTINCT from `SaveToLibrarySubmit` (which is graphJson-OR-digest and needs no required file text); consume this ONE shape across the openapi edge, the door, and the compose mount — do not re-declare a synonym.",
401
408
  shape: "{ graphJson: string, name?: string, description?: string }",
402
409
  },
410
+ "transcript.permission": {
411
+ category: "wire",
412
+ name: "transcript.permission",
413
+ owner: "app/agentic/transcript-events.ts",
414
+ semantics:
415
+ "The `permission` transcript-event envelope (issue #559) modelling ACP's `session/request_permission`, decoded by the ONE parser (`parseTranscriptEvent`) and folded by the ONE fold (`deriveView`) in app/agentic/transcript-events.ts. Two phases share the `kind:\"permission\"` discriminant, distinguished by `phase`. A REQUEST carries a stable `callId` (pairs the resolution back, like tool-call/tool-result), the producer-tagged `policy` (\"escalate\" = must ask a human, \"yolo\" = auto-allowed), the offered `options` (a NON-EMPTY array of ACP `{optionId,name,kind}` where kind is allow-once/allow-always/reject-once/reject-always — the decoder rejects a missing or empty `options`), and optional `toolName`/`title`/`reason`. A RESOLUTION carries the same `callId`, the chosen `optionId`, a boolean `allowed`, and optional `by` provenance (operator/auto). deriveView surfaces these as `DerivedPermission` (paired by callId) on `DerivedView.permissions` and `DerivedTurn.permissions`. The cockpit-render and escalation-bridge slices CONSUME this exact wire shape — do not re-declare a synonym.",
416
+ shape:
417
+ '{ nwfTranscriptEvent: 1, kind: "permission", phase: "request", callId: string, policy: "escalate"|"yolo", options: [{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }, ...Array<{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }>], toolName?: string, title?: string, reason?: string } | { nwfTranscriptEvent: 1, kind: "permission", phase: "resolution", callId: string, optionId: string, allowed: boolean, by?: "operator"|"auto" }',
418
+ },
403
419
  } as const satisfies Record<string, WireContract>;
404
420
 
405
421
  export const TYPE_CONTRACTS = {
422
+ PermissionPolicy: {
423
+ category: "type",
424
+ name: "PermissionPolicy",
425
+ owner: "app/agentic/transcript-events.ts",
426
+ semantics:
427
+ "The role's permission policy a `permission` transcript-event REQUEST is tagged with (issue #559): `\"escalate\"` (a human must be asked — cockpit renders an Allow/Deny prompt, the escalation bridge raises a user task) vs `\"yolo\"` (auto-allowed, never prompts). Exported from app/agentic/transcript-events.ts alongside the shared permission contract types (`PermissionOption`, `PermissionOptionKind`, `PermissionRequestEvent`, `PermissionResolutionEvent`, and the derived `DerivedPermission` surface on `DerivedView`/`DerivedTurn`). The cockpit-render and escalation-bridge siblings IMPORT these — they must not reinvent a divergent permission shape or a synonym policy enum.",
428
+ module: "app/agentic/transcript-events.ts",
429
+ },
406
430
  BlackboardEntry: {
407
431
  category: "type",
408
432
  name: "BlackboardEntry",
@@ -0,0 +1,39 @@
1
+ // Unit coverage for the merge-queue landing liveness timeout policy. The value is baked into every
2
+ // merge-loop instance's `landedWaitTimeout` process variable and evaluated by the `wait-landed-timeout`
3
+ // timer catch (the timer arm of the `eg-landed` event-based gateway), so a malformed operator env
4
+ // must never deploy an uninterpretable `<bpmn:timeDuration>` — it falls back to the default instead.
5
+ // Run with `node --test`.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+ import { DEFAULT_MERGE_LANDED_WAIT_TIMEOUT, mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
10
+
11
+ test("mergeLandedWaitTimeout: blank / absent / malformed → default", () => {
12
+ assert.equal(mergeLandedWaitTimeout(undefined), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
13
+ assert.equal(mergeLandedWaitTimeout(""), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
14
+ assert.equal(mergeLandedWaitTimeout(" "), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
15
+ assert.equal(mergeLandedWaitTimeout("1h"), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT); // missing leading P/T
16
+ assert.equal(mergeLandedWaitTimeout("P"), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT); // no component
17
+ assert.equal(mergeLandedWaitTimeout("PT"), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT); // T with no time part
18
+ assert.equal(mergeLandedWaitTimeout("garbage"), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
19
+ });
20
+
21
+ test("mergeLandedWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
22
+ assert.equal(mergeLandedWaitTimeout("PT30M"), "PT30M");
23
+ assert.equal(mergeLandedWaitTimeout("pt2h"), "PT2H");
24
+ assert.equal(mergeLandedWaitTimeout("P1D"), "P1D");
25
+ assert.equal(mergeLandedWaitTimeout(" pt90m "), "PT90M");
26
+ });
27
+
28
+ test("mergeLandedWaitTimeout: an explicit fallback is honoured for a bad value", () => {
29
+ assert.equal(mergeLandedWaitTimeout("nope", "PT10M"), "PT10M");
30
+ assert.equal(mergeLandedWaitTimeout("PT45M", "PT10M"), "PT45M");
31
+ });
32
+
33
+ test("the default is itself a well-formed ISO-8601 duration (never an uninterpretable timer)", () => {
34
+ // Validate the default against the grammar with a *distinct* fallback: if the default were
35
+ // malformed it would fall through to the sentinel, so equality to itself proves it parses.
36
+ const sentinel = "PT1S";
37
+ assert.notEqual(DEFAULT_MERGE_LANDED_WAIT_TIMEOUT, sentinel);
38
+ assert.equal(mergeLandedWaitTimeout(DEFAULT_MERGE_LANDED_WAIT_TIMEOUT, sentinel), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
39
+ });