@lumenflow/cli 5.1.0 → 5.2.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 (47) hide show
  1. package/dist/capacity-snapshot-emitter.js +10 -12
  2. package/dist/capacity-snapshot-emitter.js.map +1 -1
  3. package/dist/docs-generate-pack-reference.js +54 -0
  4. package/dist/docs-generate-pack-reference.js.map +1 -1
  5. package/dist/kernel-event-sync/lifecycle-emitters.js +13 -14
  6. package/dist/kernel-event-sync/lifecycle-emitters.js.map +1 -1
  7. package/dist/kernel-event-sync/narrow-emissions.js +7 -4
  8. package/dist/kernel-event-sync/narrow-emissions.js.map +1 -1
  9. package/dist/kernel-event-sync/software-delivery-emitters.js +91 -0
  10. package/dist/kernel-event-sync/software-delivery-emitters.js.map +1 -1
  11. package/dist/lumenflow-upgrade.js +11 -8
  12. package/dist/lumenflow-upgrade.js.map +1 -1
  13. package/dist/orchestrate-init-status.js +24 -11
  14. package/dist/orchestrate-init-status.js.map +1 -1
  15. package/dist/pack-install.js +27 -20
  16. package/dist/pack-install.js.map +1 -1
  17. package/dist/pack-publish.js +34 -26
  18. package/dist/pack-publish.js.map +1 -1
  19. package/dist/release.js +293 -146
  20. package/dist/release.js.map +1 -1
  21. package/dist/temp-dir-cleanup.js +112 -0
  22. package/dist/temp-dir-cleanup.js.map +1 -0
  23. package/dist/validate-agent-sync.js +6 -5
  24. package/dist/validate-agent-sync.js.map +1 -1
  25. package/dist/wu-spawn-strategy-resolver.js +4 -0
  26. package/dist/wu-spawn-strategy-resolver.js.map +1 -1
  27. package/package.json +11 -11
  28. package/packs/agent-runtime/manifest.ts +55 -4
  29. package/packs/agent-runtime/manifest.yaml +16 -6
  30. package/packs/agent-runtime/orchestration.ts +26 -1
  31. package/packs/agent-runtime/package.json +1 -1
  32. package/packs/agent-runtime/remote-controls/operations.ts +6 -0
  33. package/packs/agent-runtime/tool-impl/remote-controls.mock.ts +4 -0
  34. package/packs/agent-runtime/turn-lifecycle-events.ts +90 -1
  35. package/packs/sidekick/manifest.yaml +6 -0
  36. package/packs/sidekick/package.json +2 -1
  37. package/packs/sidekick/sidekick-events.ts +195 -18
  38. package/packs/sidekick/src/adapters/control-plane-bridge.adapter.ts +18 -10
  39. package/packs/sidekick/src/adapters/filesystem-bridge.adapter.ts +4 -0
  40. package/packs/sidekick/src/domain/channel.types.ts +34 -54
  41. package/packs/sidekick/src/ports/channel-bridge.port.ts +29 -12
  42. package/packs/sidekick/tool-impl/channel-tools.ts +47 -16
  43. package/packs/sidekick/tool-impl/system-tools.ts +4 -6
  44. package/packs/software-delivery/manifest.ts +94 -7
  45. package/packs/software-delivery/manifest.yaml +42 -5
  46. package/packs/software-delivery/package.json +1 -1
  47. package/packs/software-delivery/tool-impl/wu-lifecycle-tools.ts +30 -0
@@ -2,23 +2,37 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  /**
5
- * WU-2735 (INIT-060 WU-7a, ADR-013 §ChannelBridge):
6
- * Pure domain types for the ChannelBridge port.
5
+ * WU-2735 (INIT-060 WU-7a) + WU-2831 (INIT-062 WU-E):
6
+ * Domain types for the sidekick ChannelBridge adapters.
7
7
  *
8
- * These types are pure domain: they MUST NOT reference Node `fs`, network
9
- * sockets, cloud endpoints, or any adapter-specific concept. Both the
10
- * filesystem adapter (this WU) and the control-plane adapter (WU-2737) shape
11
- * themselves around these definitions.
8
+ * As of WU-2831 the port-level identity, config, and send-result shapes are
9
+ * canonical in @lumenflow/conductor-sdk (Apache-2.0). This file re-exports
10
+ * those types so sidekick adapters implement the neutral port without
11
+ * parallel contract drift.
12
+ *
13
+ * The internal `EnvelopeKind` + `ChannelEnvelope` shape below remains the
14
+ * sidekick-pack wire format: it predates the canonical port and is retained
15
+ * for on-disk/control-plane compatibility of existing adapters. New
16
+ * implementers (e.g. the cloud phone bridge) SHOULD consume
17
+ * `@lumenflow/conductor-sdk`'s `ChannelEnvelope` instead.
12
18
  */
13
19
 
14
- /**
15
- * Branded channel identifier. The bridge mints these during `register`; the
16
- * brand prevents callers from forging ids from raw strings.
17
- */
18
- export type ChannelId = string & { readonly __brand: 'sidekick.ChannelId' };
20
+ // Re-export canonical port types from the Apache-2.0 conductor SDK.
21
+ export type {
22
+ BackpressurePolicy,
23
+ BridgeConfig,
24
+ ChannelId,
25
+ ChannelIdentity,
26
+ DeliveryGuarantee,
27
+ IdentityResolver,
28
+ SendResult,
29
+ } from '@lumenflow/conductor-sdk';
19
30
 
20
31
  /**
21
- * Envelope dispatch classification per ADR-013 §4 backpressure split.
32
+ * Envelope dispatch classification sidekick-internal wire format.
33
+ *
34
+ * Predates the canonical `BackpressurePolicy` enum in conductor-sdk.
35
+ * Maps: `ephemeral` ↔ `ephemeral`, `queue` ↔ `buffered` (conductor-sdk).
22
36
  *
23
37
  * - `ephemeral` — observational; may fail-silent when transport is down.
24
38
  * - `queue` — commands / approvals; must be queued and replayed on reconnect.
@@ -26,14 +40,18 @@ export type ChannelId = string & { readonly __brand: 'sidekick.ChannelId' };
26
40
  export type EnvelopeKind = 'ephemeral' | 'queue';
27
41
 
28
42
  /**
29
- * A channel envelope. Kept transport-agnostic: `body` is an opaque
30
- * JSON-serialisable payload, `content_type` labels it so receivers can dispatch
31
- * without snooping the body.
43
+ * A channel envelope (sidekick-internal wire format).
44
+ * Transport-agnostic: `body` is an opaque JSON-serialisable payload,
45
+ * `content_type` labels it so receivers can dispatch without snooping the body.
46
+ *
47
+ * Note: the canonical port-level envelope is
48
+ * `@lumenflow/conductor-sdk`'s `ChannelEnvelope` (uses `policy` field).
49
+ * Adapters translate between the two when bridging across the Apache boundary.
32
50
  */
33
51
  export interface ChannelEnvelope {
34
52
  /** Unique id per envelope (content-hash or uuid — chosen by the emitter). */
35
53
  id: string;
36
- /** §4 backpressure policy — drives send path (fail-silent vs queued replay). */
54
+ /** Pack-internal backpressure classification. */
37
55
  kind: EnvelopeKind;
38
56
  /** MIME-like content descriptor, e.g. `application/json`, `text/plain`. */
39
57
  content_type: string;
@@ -44,41 +62,3 @@ export interface ChannelEnvelope {
44
62
  /** Optional emitter-supplied metadata (trace ids, correlation keys). */
45
63
  metadata?: Readonly<Record<string, unknown>>;
46
64
  }
47
-
48
- /**
49
- * Bridge registration config — identity under which a channel is opened.
50
- *
51
- * The (`provider`, `name`) pair is the canonical identity: re-registering the
52
- * same pair returns the same `ChannelId` (ADR-013 §ChannelBridge contract
53
- * rule #3 — idempotent register).
54
- *
55
- * `options` is provider-specific bag. The port does NOT inspect it; adapters
56
- * interpret.
57
- */
58
- export interface BridgeConfig {
59
- /** Provider slug — e.g. `filesystem`, `control-plane`. */
60
- provider: string;
61
- /** Human-readable channel name scoped within the provider. */
62
- name: string;
63
- /** Provider-specific options (paths, tokens, etc.). Opaque to the port. */
64
- options?: Readonly<Record<string, unknown>>;
65
- }
66
-
67
- /**
68
- * Result of a `send` attempt. `accepted` reflects the port contract: for
69
- * ephemeral envelopes with an unreachable transport, adapters may return
70
- * `{ accepted: false, reason: '...' }` rather than throwing (§4 fail-silent).
71
- */
72
- export interface SendResult {
73
- accepted: boolean;
74
- /** Transport-assigned id when the sink confirms receipt (optional). */
75
- delivery_id?: string;
76
- /** Reason when `accepted === false` (not meant for programmatic handling). */
77
- reason?: string;
78
- /**
79
- * `true` when the sink recognised `envelope.id` on a registered channel as
80
- * an at-least-once replay (WU-2737 §Idempotency; cloud contract surface).
81
- * Adapters that cannot observe dedup SHOULD omit this field.
82
- */
83
- deduped?: boolean;
84
- }
@@ -2,8 +2,17 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  /**
5
- * WU-2735 (INIT-060 WU-7a, ADR-013 §ChannelBridge):
6
- * ChannelBridge port.
5
+ * WU-2735 (INIT-060 WU-7a, ADR-013 §ChannelBridge) + WU-2831 (INIT-062 WU-E):
6
+ *
7
+ * Sidekick-pack ChannelBridge port.
8
+ *
9
+ * As of WU-2831 the canonical neutral port lives in `@lumenflow/conductor-sdk`
10
+ * under Apache-2.0 — consumers that need an AGPL-free surface (e.g. the
11
+ * cloud phone bridge) MUST import from there. The sidekick port below reuses
12
+ * the conductor-sdk identity / config / result types (no parallel drift) but
13
+ * keeps the pack-internal wire-format envelope (`ChannelEnvelope` with
14
+ * `kind: 'ephemeral' | 'queue'`) for on-disk and control-plane compatibility
15
+ * of existing adapters.
7
16
  *
8
17
  * Port contract — load-bearing across adapters (ADR-013 §ChannelBridge):
9
18
  *
@@ -11,14 +20,12 @@
11
20
  * `ephemeral` envelopes (ADR-013 §4 backpressure split).
12
21
  * - `receive` yields envelopes in emit order on a single channel (§3). No
13
22
  * cross-channel ordering guarantee.
14
- * - `register` is idempotent on `BridgeConfig` identity — same
15
- * `(provider, name)` returns the same `ChannelId`.
23
+ * - `connect` (alias: `register`) is idempotent on `BridgeConfig` identity —
24
+ * same `(provider, name)` returns the same `ChannelId`.
16
25
  * - `disconnect` flushes queued envelopes before closing; in-flight ephemerals
17
26
  * may drop.
18
27
  *
19
- * The port MUST NOT leak filesystem- or network-specific types. Both the
20
- * filesystem adapter (this WU) and the cloud adapter (WU-2737) implement this
21
- * interface unmodified.
28
+ * The port MUST NOT leak filesystem- or network-specific types.
22
29
  */
23
30
 
24
31
  import type {
@@ -29,6 +36,21 @@ import type {
29
36
  } from '../domain/channel.types.js';
30
37
 
31
38
  export interface ChannelBridge {
39
+ /**
40
+ * Open (or reuse) a channel. Idempotent on `(provider, name)`.
41
+ *
42
+ * Canonical method name per the neutral conductor-sdk port (WU-2831).
43
+ */
44
+ connect(bridgeConfig: BridgeConfig): Promise<ChannelId>;
45
+
46
+ /**
47
+ * Legacy alias for `connect`. Retained for pack-internal call sites that
48
+ * predate WU-2831.
49
+ *
50
+ * @deprecated Use `connect` instead (WU-2831 port-contract alignment).
51
+ */
52
+ register(bridgeConfig: BridgeConfig): Promise<ChannelId>;
53
+
32
54
  /**
33
55
  * Send an envelope on a registered channel.
34
56
  *
@@ -42,11 +64,6 @@ export interface ChannelBridge {
42
64
  */
43
65
  receive(channelId: ChannelId): AsyncIterable<ChannelEnvelope>;
44
66
 
45
- /**
46
- * Register a channel and return its id. Idempotent on `(provider, name)`.
47
- */
48
- register(bridgeConfig: BridgeConfig): Promise<ChannelId>;
49
-
50
67
  /**
51
68
  * Close the channel. Queued envelopes flush before this resolves; ephemerals
52
69
  * in flight may be dropped.
@@ -22,7 +22,6 @@ import {
22
22
  buildChannelMessageReceivedEvent,
23
23
  buildChannelMessageSentEvent,
24
24
  emitSidekickEvent,
25
- serializeLocalChannelMessage,
26
25
  } from '../sidekick-events.js';
27
26
 
28
27
  // ---------------------------------------------------------------------------
@@ -300,16 +299,18 @@ async function channelSendViaTransport(
300
299
  };
301
300
  }
302
301
 
303
- await emitSidekickEvent(
304
- buildChannelMessageSentEvent({
305
- provider,
306
- channel,
307
- content,
308
- ...(transportResult.externalMessageId !== undefined
309
- ? { external_message_id: transportResult.externalMessageId }
310
- : {}),
311
- }),
312
- );
302
+ // WU-2830 (INIT-062 WU-D): ChannelMessageSentEvent.message is a typed
303
+ // ChannelMessageRecord. Provider sends do not have a local storage row,
304
+ // so we synthesize a record using the external id when available.
305
+ const providerSentMessage: ChannelMessageRecord = {
306
+ id: transportResult.externalMessageId ?? createId('msg'),
307
+ channel_id: `${provider}:${channel}`,
308
+ sender: 'assistant',
309
+ content,
310
+ created_at: nowIso(),
311
+ };
312
+
313
+ await emitSidekickEvent(buildChannelMessageSentEvent(providerSentMessage));
313
314
 
314
315
  return success(outputData);
315
316
  }
@@ -389,9 +390,8 @@ async function channelSendTool(input: unknown, context?: ToolContextLike): Promi
389
390
  );
390
391
  });
391
392
 
392
- await emitSidekickEvent(
393
- buildChannelMessageSentEvent(serializeLocalChannelMessage(resolvedMessage, channelName)),
394
- );
393
+ // WU-2830: pass the typed ChannelMessageRecord directly.
394
+ await emitSidekickEvent(buildChannelMessageSentEvent(resolvedMessage));
395
395
 
396
396
  return success({ message: resolvedMessage as unknown as Record<string, unknown> });
397
397
  }
@@ -462,17 +462,47 @@ async function channelReceiveViaTransport(
462
462
  };
463
463
  }
464
464
 
465
+ // WU-2830: ChannelMessageReceivedEvent now carries typed
466
+ // ChannelMessageRecord[] (symmetric with the sent event). Coerce
467
+ // provider-opaque records into the canonical shape before emission.
468
+ const receivedRecords = (transportResult.records ?? []).map((record) =>
469
+ coerceProviderRecordToMessageRecord(record, `${provider}:${channel}`),
470
+ );
471
+
465
472
  await emitSidekickEvent(
466
473
  buildChannelMessageReceivedEvent({
467
474
  provider,
468
475
  channel,
469
- count: transportResult.records?.length ?? 0,
476
+ messages: receivedRecords,
470
477
  }),
471
478
  );
472
479
 
473
480
  return success(outputData);
474
481
  }
475
482
 
483
+ function coerceProviderRecordToMessageRecord(
484
+ record: unknown,
485
+ fallbackChannelId: string,
486
+ ): ChannelMessageRecord {
487
+ // WU-2830: provider transports declare `records?: unknown[]`. Type-narrow
488
+ // to build a ChannelMessageRecord without unsafe casts; missing fields
489
+ // fall back to sensible defaults so downstream consumers always see a
490
+ // complete, typed payload.
491
+ const source = record && typeof record === 'object' ? (record as Record<string, unknown>) : {};
492
+ const now = nowIso();
493
+ const id = typeof source.id === 'string' && source.id.length > 0 ? source.id : createId('msg');
494
+ const channel_id =
495
+ typeof source.channel_id === 'string' && source.channel_id.length > 0
496
+ ? source.channel_id
497
+ : fallbackChannelId;
498
+ const sender =
499
+ typeof source.sender === 'string' && source.sender.length > 0 ? source.sender : 'external';
500
+ const content = typeof source.content === 'string' ? source.content : '';
501
+ const created_at =
502
+ typeof source.created_at === 'string' && source.created_at.length > 0 ? source.created_at : now;
503
+ return { id, channel_id, sender, content, created_at };
504
+ }
505
+
476
506
  async function channelReceiveTool(input: unknown, _context?: ToolContextLike): Promise<ToolOutput> {
477
507
  const parsed = toRecord(input);
478
508
  const channelName = asNonEmptyString(parsed.channel);
@@ -511,7 +541,8 @@ async function channelReceiveTool(input: unknown, _context?: ToolContextLike): P
511
541
  await emitSidekickEvent(
512
542
  buildChannelMessageReceivedEvent({
513
543
  channel: channelName ?? DEFAULT_CHANNEL_NAME,
514
- count: items.length,
544
+ // WU-2830: carry the full typed ChannelMessageRecord[] payload.
545
+ messages: items,
515
546
  }),
516
547
  );
517
548
 
@@ -12,11 +12,7 @@ import {
12
12
  type ToolContextLike,
13
13
  type ToolOutput,
14
14
  } from './shared.js';
15
- import {
16
- buildStateRehydratedEvent,
17
- emitSidekickEvent,
18
- snapshotSidekickState,
19
- } from '../sidekick-events.js';
15
+ import { emitSidekickStateRehydration, snapshotSidekickState } from '../sidekick-events.js';
20
16
 
21
17
  // ---------------------------------------------------------------------------
22
18
  // Constants
@@ -51,7 +47,9 @@ async function initTool(context?: ToolContextLike): Promise<ToolOutput> {
51
47
  }),
52
48
  );
53
49
 
54
- await emitSidekickEvent(buildStateRehydratedEvent(await snapshotSidekickState()));
50
+ // WU-2830 (INIT-062 WU-D): chunked rehydration replaces the monolithic
51
+ // `state_rehydrated` emission — unbounded snapshots cannot stream.
52
+ await emitSidekickStateRehydration(await snapshotSidekickState());
55
53
 
56
54
  return success({
57
55
  initialized: true,
@@ -31,6 +31,25 @@ export type {
31
31
  } from './manifest-schema.js';
32
32
 
33
33
  const FULL_WORKSPACE_SCOPE_PATTERN = '**';
34
+ // WU-2833 (INIT-062 WU-G): the canonical read-only workspace scope used by
35
+ // validation runners like gates / gates:docs. Exposed so remote callers
36
+ // cannot mis-declare a read-only runner with a broader write scope.
37
+ export const SOFTWARE_DELIVERY_READ_SCOPE_PATTERN = FULL_WORKSPACE_SCOPE_PATTERN;
38
+ /**
39
+ * WU-2833 (INIT-062 WU-G): tools whose runtime handlers perform only
40
+ * read-side inspection (no filesystem mutation, no git mutation). Any
41
+ * attempt to re-declare these tools with permission: write or admin MUST
42
+ * fail the pack:validate gate so the security posture established by
43
+ * WU-2810/2811/2816 cannot drift without an explicit ADR.
44
+ */
45
+ export const SOFTWARE_DELIVERY_READ_ONLY_RUNNER_TOOLS = ['gates', 'gates:docs'] as const;
46
+ export type SoftwareDeliveryReadOnlyRunnerTool =
47
+ (typeof SOFTWARE_DELIVERY_READ_ONLY_RUNNER_TOOLS)[number];
48
+ // WU-2833: metrics:snapshot reads the workspace to compute DORA metrics
49
+ // but writes the computed snapshot back into workspace state. This narrow
50
+ // write scope keeps mobile/cloud tokens for metrics:snapshot from leaking
51
+ // full-tree write access (principle of least privilege).
52
+ const SOFTWARE_DELIVERY_WORKSPACE_STATE_WRITE_PATTERN = '.lumenflow/state/**';
34
53
  const SOFTWARE_DELIVERY_WRITE_DIRECTORY_PATTERNS = [
35
54
  '.changeset/**',
36
55
  '.claude/**',
@@ -99,6 +118,10 @@ const WU_UNBLOCK_TOOL_ENTRY = 'tool-impl/wu-lifecycle-tools.ts#wuUnblockTool';
99
118
  const WU_RELEASE_TOOL_ENTRY = 'tool-impl/wu-lifecycle-tools.ts#wuReleaseTool';
100
119
  const WU_RECOVER_TOOL_ENTRY = 'tool-impl/wu-lifecycle-tools.ts#wuRecoverTool';
101
120
  const WU_REPAIR_TOOL_ENTRY = 'tool-impl/wu-lifecycle-tools.ts#wuRepairTool';
121
+ // WU-2833 (INIT-062 WU-G): admin-mode wu:repair wrapper that forces the
122
+ // `--admin` flag; exposed as a separate manifest tool so approvals can
123
+ // be attached to the privileged surface independently of wu:repair.
124
+ const WU_REPAIR_ADMIN_TOOL_ENTRY = 'tool-impl/wu-lifecycle-tools.ts#wuRepairAdminTool';
102
125
  const GATES_TOOL_ENTRY = 'tool-impl/wu-lifecycle-tools.ts#gatesTool';
103
126
  // WU-2729 (INIT-060 Phase 2): gates:docs exposes docs-only gate runs via a
104
127
  // dedicated manifest entry so remote callers can request the docs gate
@@ -210,6 +233,11 @@ const TOOL_PERMISSIONS = {
210
233
  'wu:recover': 'write',
211
234
  'wu:release': 'write',
212
235
  'wu:repair': 'write',
236
+ // WU-2833 (INIT-062 WU-G): privileged recovery surface for cloud-team
237
+ // phone UX. Distinct tool name so an approval gate + admin permission
238
+ // can be declared without widening the scope of the default wu:repair
239
+ // implementer tool.
240
+ 'wu:repair:admin': 'admin',
213
241
  'wu:sandbox': 'write',
214
242
  'wu:status': 'read',
215
243
  'wu:unblock': 'write',
@@ -241,8 +269,12 @@ const TOOL_PERMISSIONS = {
241
269
  'lane:suggest': 'write',
242
270
  'flow:bottlenecks': 'read',
243
271
  'flow:report': 'read',
244
- gates: 'write',
245
- 'gates:docs': 'write',
272
+ // WU-2833 (INIT-062 WU-G): gates and gates:docs are read-only validation
273
+ // runners. Mobile/cloud tokens for these tools must not carry workspace
274
+ // write access (principle of least privilege; matches the security
275
+ // posture established by WU-2810/2811/2816).
276
+ gates: 'read',
277
+ 'gates:docs': 'read',
246
278
  'file:delete': 'write',
247
279
  'file:edit': 'write',
248
280
  'file:read': 'read',
@@ -278,7 +310,11 @@ const TOOL_PERMISSIONS = {
278
310
  'lumenflow:release': 'write',
279
311
  'lumenflow:upgrade': 'write',
280
312
  metrics: 'read',
281
- 'metrics:snapshot': 'read',
313
+ // WU-2833 (INIT-062 WU-G): metrics:snapshot reads the workspace and
314
+ // writes a DORA snapshot back into .lumenflow/state/. Permission role
315
+ // is "write" because it mutates state; the explicit SCOPE_OVERRIDE
316
+ // narrows the write path to .lumenflow/state/** (no full-tree write).
317
+ 'metrics:snapshot': 'write',
282
318
  'lumenflow:metrics': 'read',
283
319
  'signal:cleanup': 'write',
284
320
  'sync:templates': 'write',
@@ -320,6 +356,7 @@ const TOOL_ENTRY_OVERRIDES: Partial<Record<ToolName, string>> = {
320
356
  'wu:release': WU_RELEASE_TOOL_ENTRY,
321
357
  'wu:recover': WU_RECOVER_TOOL_ENTRY,
322
358
  'wu:repair': WU_REPAIR_TOOL_ENTRY,
359
+ 'wu:repair:admin': WU_REPAIR_ADMIN_TOOL_ENTRY,
323
360
  'wu:infer-lane': WU_INFER_LANE_TOOL_ENTRY,
324
361
  gates: GATES_TOOL_ENTRY,
325
362
  'gates:docs': GATES_DOCS_TOOL_ENTRY,
@@ -415,9 +452,36 @@ function requiredScopesForPermission(permission: ToolPermission): PathScope[] {
415
452
  return createPathScopes([FULL_WORKSPACE_SCOPE_PATTERN], TOOL_SCOPE_ACCESS.READ);
416
453
  }
417
454
 
455
+ // WU-2833: admin permission inherits the same constrained write-scope
456
+ // set as write permission. The admin distinction is carried by the
457
+ // required_approvals gate, not by broader path access.
418
458
  return createPathScopes(SOFTWARE_DELIVERY_WRITE_SCOPE_PATTERNS, TOOL_SCOPE_ACCESS.WRITE);
419
459
  }
420
460
 
461
+ /**
462
+ * WU-2833 (INIT-062 WU-G): per-tool scope overrides for tools whose
463
+ * runtime semantics do not match the default read/write scope set. Used
464
+ * sparingly — only when a tool legitimately needs both read and a narrow
465
+ * write scope (or vice versa).
466
+ */
467
+ const SCOPE_OVERRIDES: Partial<Record<string, PathScope[]>> = {
468
+ // metrics:snapshot reads the full workspace to compute DORA metrics,
469
+ // then writes the snapshot back into workspace state. The narrow write
470
+ // scope prevents mobile/cloud tokens from leaking full-tree write.
471
+ 'metrics:snapshot': [
472
+ {
473
+ type: TOOL_SCOPE_TYPES.PATH,
474
+ pattern: FULL_WORKSPACE_SCOPE_PATTERN,
475
+ access: TOOL_SCOPE_ACCESS.READ,
476
+ },
477
+ {
478
+ type: TOOL_SCOPE_TYPES.PATH,
479
+ pattern: SOFTWARE_DELIVERY_WORKSPACE_STATE_WRITE_PATTERN,
480
+ access: TOOL_SCOPE_ACCESS.WRITE,
481
+ },
482
+ ],
483
+ };
484
+
421
485
  /**
422
486
  * WU-2729 (INIT-060 Phase 2): the 10 software-delivery pack tools that are
423
487
  * callable remotely via POST /tools/:name. The HTTP surface uses this list
@@ -460,17 +524,23 @@ const APPROVAL_OVERRIDES: Partial<Record<ToolName, readonly string[]>> = {
460
524
  'plan:promote': [SOFTWARE_DELIVERY_APPROVAL_IDS.REMOTE_MUTATION],
461
525
  'initiative:create': [SOFTWARE_DELIVERY_APPROVAL_IDS.REMOTE_MUTATION],
462
526
  'initiative:add-wu': [SOFTWARE_DELIVERY_APPROVAL_IDS.REMOTE_MUTATION],
527
+ // WU-2833: privileged recovery MUST present an explicit approval gate
528
+ // to conductor/phone UX before dispatch. Without this the admin tool
529
+ // is indistinguishable from wu:repair from an authorisation standpoint.
530
+ 'wu:repair:admin': [SOFTWARE_DELIVERY_APPROVAL_IDS.REMOTE_MUTATION],
463
531
  };
464
532
 
465
533
  function requiredApprovalsForTool(name: ToolName): string[] | undefined {
466
- // WU-2729: only the 10 remote-callable tools carry explicit
534
+ // WU-2729: the 10 remote-callable tools carry explicit
467
535
  // required_approvals metadata (even if empty). Other tools leave the
468
536
  // field undefined so the manifest stays minimally descriptive.
537
+ // WU-2833: admin-permission tools also carry explicit approvals so the
538
+ // privileged surface cannot be invoked without a visible approval gate.
469
539
  const isRemoteCallable = (REMOTE_CALLABLE_TOOLS as readonly string[]).includes(name);
470
- if (!isRemoteCallable) {
540
+ const override = APPROVAL_OVERRIDES[name];
541
+ if (!isRemoteCallable && override === undefined) {
471
542
  return undefined;
472
543
  }
473
- const override = APPROVAL_OVERRIDES[name];
474
544
  return override ? [...override] : [];
475
545
  }
476
546
 
@@ -487,11 +557,15 @@ function createManifestTools(): SoftwareDeliveryManifestTool[] {
487
557
  return (Object.keys(TOOL_PERMISSIONS) as ToolName[]).map((name) => {
488
558
  const permission = TOOL_PERMISSIONS[name];
489
559
  const approvals = requiredApprovalsForTool(name);
560
+ // WU-2833: per-tool scope overrides take priority over the default
561
+ // permission-derived scope set so read-plus-narrow-write tools like
562
+ // metrics:snapshot can declare both accesses on a single entry.
563
+ const scopeOverride = SCOPE_OVERRIDES[name];
490
564
  const entry: SoftwareDeliveryManifestTool = {
491
565
  name,
492
566
  entry: resolveToolEntry(name),
493
567
  permission,
494
- required_scopes: requiredScopesForPermission(permission),
568
+ required_scopes: scopeOverride ? [...scopeOverride] : requiredScopesForPermission(permission),
495
569
  };
496
570
  if (approvals !== undefined) {
497
571
  entry.required_approvals = approvals;
@@ -543,6 +617,19 @@ const SOFTWARE_DELIVERY_EMITTED_EVENT_KINDS = [
543
617
  'software-delivery:plan_created',
544
618
  'software-delivery:plan_linked',
545
619
  'software-delivery:plan_promoted',
620
+ // WU-2832 (INIT-062 WU-F): close the cloud-team polling gap with 9
621
+ // additional ephemeral kinds. Validation pair (validated/invalid),
622
+ // recovery, preflight, escalation, ratchet, bottleneck, DORA snapshot,
623
+ // and replay-artifact addressable by event_id.
624
+ 'software-delivery:wu_spec_validated',
625
+ 'software-delivery:wu_spec_invalid',
626
+ 'software-delivery:wu_recovered',
627
+ 'software-delivery:wu_preflight_failed',
628
+ 'software-delivery:wu_escalation_resolved',
629
+ 'software-delivery:test_ratchet_adjusted',
630
+ 'software-delivery:flow_bottleneck_detected',
631
+ 'software-delivery:dora_metric_snapshot',
632
+ 'software-delivery:replay_artifact_published',
546
633
  ] as const;
547
634
  const SOFTWARE_DELIVERY_REQUIRED_SURFACES = ['http'] as const;
548
635
 
@@ -200,6 +200,15 @@ tools:
200
200
  entry: tool-impl/wu-lifecycle-tools.ts#wuRepairTool
201
201
  permission: write
202
202
  required_scopes: *softwareDeliveryWriteScopes
203
+ # WU-2833 (INIT-062 WU-G): privileged wu:repair surface for cloud-team
204
+ # phone UX. Distinct tool name so approval gate + admin permission can
205
+ # be declared without widening the default wu:repair surface.
206
+ - name: wu:repair:admin
207
+ entry: tool-impl/wu-lifecycle-tools.ts#wuRepairAdminTool
208
+ permission: admin
209
+ required_scopes: *softwareDeliveryWriteScopes
210
+ required_approvals:
211
+ - software-delivery:remote_mutation
203
212
  - name: wu:sandbox
204
213
  entry: tool-impl/wu-lifecycle-tools.ts#wuSandboxTool
205
214
  permission: write
@@ -377,15 +386,26 @@ tools:
377
386
  access: read
378
387
  required_approvals: []
379
388
  # gate:*
389
+ # WU-2833 (INIT-062 WU-G): gates / gates:docs are read-only validation
390
+ # runners. Declaring them permission: write would grant mobile/cloud
391
+ # tokens full-tree write access via required_scopes (the security model
392
+ # hardened by WU-2810/2811/2816). Re-declare as permission: read and
393
+ # scope them to SOFTWARE_DELIVERY_READ_SCOPE (wildcard path, read).
380
394
  - name: gates
381
395
  entry: tool-impl/wu-lifecycle-tools.ts#gatesTool
382
- permission: write
383
- required_scopes: *softwareDeliveryWriteScopes
396
+ permission: read
397
+ required_scopes:
398
+ - type: path
399
+ pattern: '**'
400
+ access: read
384
401
  required_approvals: []
385
402
  - name: gates:docs
386
403
  entry: tool-impl/wu-lifecycle-tools.ts#gatesDocsTool
387
- permission: write
388
- required_scopes: *softwareDeliveryWriteScopes
404
+ permission: read
405
+ required_scopes:
406
+ - type: path
407
+ pattern: '**'
408
+ access: read
389
409
  required_approvals: []
390
410
  # file:*
391
411
  - name: file:delete
@@ -577,13 +597,20 @@ tools:
577
597
  - type: path
578
598
  pattern: '**'
579
599
  access: read
600
+ # WU-2833 (INIT-062 WU-G): metrics:snapshot reads the workspace and
601
+ # writes a DORA snapshot to .lumenflow/state/. Declared permission:
602
+ # write with narrow write scope + full-read scope (principle of least
603
+ # privilege — no full-tree write).
580
604
  - name: metrics:snapshot
581
605
  entry: tool-impl/flow-metrics-tools.ts#metricsSnapshotTool
582
- permission: read
606
+ permission: write
583
607
  required_scopes:
584
608
  - type: path
585
609
  pattern: '**'
586
610
  access: read
611
+ - type: path
612
+ pattern: .lumenflow/state/**
613
+ access: write
587
614
  - name: lumenflow:metrics
588
615
  entry: tool-impl/runtime-native-tools.ts#lumenflowMetricsTool
589
616
  permission: read
@@ -686,6 +713,16 @@ emitted_event_kinds:
686
713
  - software-delivery:plan_created
687
714
  - software-delivery:plan_linked
688
715
  - software-delivery:plan_promoted
716
+ # WU-2832 (INIT-062 WU-F): close the cloud-team polling gap.
717
+ - software-delivery:wu_spec_validated
718
+ - software-delivery:wu_spec_invalid
719
+ - software-delivery:wu_recovered
720
+ - software-delivery:wu_preflight_failed
721
+ - software-delivery:wu_escalation_resolved
722
+ - software-delivery:test_ratchet_adjusted
723
+ - software-delivery:flow_bottleneck_detected
724
+ - software-delivery:dora_metric_snapshot
725
+ - software-delivery:replay_artifact_published
689
726
  subscribed_event_kinds: []
690
727
  required_approvals: []
691
728
  surfaces_required:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumenflow/packs-software-delivery",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "description": "Software delivery pack for LumenFlow — work units, gates, lanes, initiatives, and agent coordination",
5
5
  "keywords": [
6
6
  "lumenflow",
@@ -698,6 +698,36 @@ export async function wuRepairTool(input: unknown): Promise<ToolOutput> {
698
698
  return executeLifecycleTool(LIFECYCLE_TOOLS.WU_REPAIR, args);
699
699
  }
700
700
 
701
+ /**
702
+ * WU-2833 (INIT-062 WU-G): privileged wu:repair variant registered as a
703
+ * distinct pack tool (wu:repair:admin) so cloud-team phone UX can expose
704
+ * admin recovery behind a remote_mutation approval gate without widening
705
+ * the default wu:repair surface. Forces `--admin` on every invocation.
706
+ */
707
+ export async function wuRepairAdminTool(input: unknown): Promise<ToolOutput> {
708
+ const parsed = toRecord(input);
709
+
710
+ const args: string[] = ['--admin'];
711
+ const id = toStringValue(parsed.id);
712
+ if (id) {
713
+ args.push('--id', id);
714
+ }
715
+ if (parsed.check === true) {
716
+ args.push('--check');
717
+ }
718
+ if (parsed.all === true) {
719
+ args.push('--all');
720
+ }
721
+ if (parsed.claim === true) {
722
+ args.push('--claim');
723
+ }
724
+ if (parsed.repair_state === true) {
725
+ args.push('--repair-state');
726
+ }
727
+
728
+ return executeLifecycleTool(LIFECYCLE_TOOLS.WU_REPAIR, args);
729
+ }
730
+
701
731
  export async function wuStatusTool(input: unknown): Promise<ToolOutput> {
702
732
  const parsed = toRecord(input);
703
733
  const id = toStringValue(parsed.id);