@etiquekit/etq 1.0.13 → 1.0.15

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 (143) hide show
  1. package/AGENTS.md +26 -19
  2. package/LICENSE +3 -2
  3. package/NOTICE +4 -6
  4. package/QuickStart.md +46 -35
  5. package/README.md +57 -55
  6. package/bin/etiquette +5 -1
  7. package/bin/etiquette-core +5 -1
  8. package/docs/ARCHITECTURE.md +17 -20
  9. package/docs/CONCEPTS.md +5 -5
  10. package/docs/CORE_PROFILE.md +15 -7
  11. package/docs/LANE_PROVISIONING.md +2 -7
  12. package/docs/README.md +14 -13
  13. package/docs/RELEASE_SURFACE_AUDIT.md +6 -7
  14. package/docs/SEAT_DISCIPLINE.md +91 -55
  15. package/docs/SEAT_PROVISIONING.md +19 -16
  16. package/docs/TEAM_HANDOFF.md +21 -21
  17. package/docs/WORKTREE_QOL.md +0 -7
  18. package/docs/contracts/ledger-entry/README.md +11 -11
  19. package/docs/contracts/ledger-entry/ledger-entry.v0.2.md +2 -2
  20. package/docs/contracts/ledger-entry/ledger-entry.v0.md +8 -8
  21. package/lib/etiquette-core.js +313 -0
  22. package/lib/etiquette.js +1151 -0
  23. package/package.json +13 -12
  24. package/templates/etiquette-vanilla-v0/README.md +3 -2
  25. package/templates/etiquette-vanilla-v0/source/control-seat/README.md +6 -0
  26. package/templates/etiquette-vanilla-v0/source/control-seat/bin/access-assurance-check +55 -0
  27. package/templates/etiquette-vanilla-v0/source/control-seat/bin/seat-doctor +5 -0
  28. package/templates/etiquette-vanilla-v0/source/control-seat/docs/work/access/ACCESS-ASSURANCE.md +39 -0
  29. package/templates/etiquette-vanilla-v0/source/control-seat/docs/work/access/workspace-secure-profile.v0.json +53 -0
  30. package/templates/etiquette-vanilla-v0/validate-vanilla.sh +5 -0
  31. package/templates/hosted-receiver/README.md +41 -0
  32. package/templates/hosted-receiver/w1-github-webhook-receiver.mjs +129 -0
  33. package/templates/seat-packs-v0/README.md +2 -3
  34. package/templates/seat-packs-v0/source/claude-code-seat/README.md +3 -3
  35. package/templates/seat-packs-v0/source/codex-seat/README.md +3 -3
  36. package/templates/seat-packs-v0/source/gemini-seat/README.md +3 -3
  37. package/templates/seat-packs-v0/source/ollama-seat/README.md +3 -3
  38. package/templates/seat-packs-v0/source/openrouter-seat/README.md +3 -3
  39. package/docs/CONCEPT_STATUS.md +0 -66
  40. package/packages/control/src/authority/lease.ts +0 -261
  41. package/packages/control/src/authority/node-delegation.ts +0 -257
  42. package/packages/control/src/authority/rig-conductor.ts +0 -632
  43. package/packages/control/src/cli/argv.ts +0 -155
  44. package/packages/control/src/cli/commands/console.ts +0 -200
  45. package/packages/control/src/cli/commands/dispatch-core.ts +0 -89
  46. package/packages/control/src/cli/commands/dispatch.ts +0 -279
  47. package/packages/control/src/cli/commands/harness.ts +0 -89
  48. package/packages/control/src/cli/commands/hook.ts +0 -50
  49. package/packages/control/src/cli/commands/ledger.ts +0 -91
  50. package/packages/control/src/cli/commands/local-workflow.ts +0 -4686
  51. package/packages/control/src/cli/commands/memory.ts +0 -445
  52. package/packages/control/src/cli/commands/release.ts +0 -108
  53. package/packages/control/src/cli/commands/rubric.ts +0 -103
  54. package/packages/control/src/cli/commands/seat.ts +0 -179
  55. package/packages/control/src/cli/commands/session.ts +0 -127
  56. package/packages/control/src/cli/commands/supervision.ts +0 -246
  57. package/packages/control/src/cli/commands/sync.ts +0 -119
  58. package/packages/control/src/cli/commands/workflow.ts +0 -86
  59. package/packages/control/src/cli/commands/workspace.ts +0 -50
  60. package/packages/control/src/cli/core-usage.ts +0 -34
  61. package/packages/control/src/cli/prompt.ts +0 -67
  62. package/packages/control/src/cli/supervision-deps.ts +0 -44
  63. package/packages/control/src/cli/usage.ts +0 -241
  64. package/packages/control/src/cli.ts +0 -207
  65. package/packages/control/src/core-cli.ts +0 -50
  66. package/packages/control/src/dispatch/decision.ts +0 -202
  67. package/packages/control/src/dispatch/projection.ts +0 -293
  68. package/packages/control/src/dispatch/record.ts +0 -153
  69. package/packages/control/src/engagement/project.ts +0 -170
  70. package/packages/control/src/fs.ts +0 -19
  71. package/packages/control/src/harness/pruning.ts +0 -406
  72. package/packages/control/src/hooks/dispatcher.ts +0 -117
  73. package/packages/control/src/hooks/outbox.ts +0 -86
  74. package/packages/control/src/hooks/sanitize.ts +0 -6
  75. package/packages/control/src/hooks/types.ts +0 -34
  76. package/packages/control/src/index.ts +0 -384
  77. package/packages/control/src/ledger/entry.ts +0 -303
  78. package/packages/control/src/ledger/indexer.ts +0 -542
  79. package/packages/control/src/memory/context.ts +0 -149
  80. package/packages/control/src/memory/drain-import.ts +0 -207
  81. package/packages/control/src/memory/indexer.ts +0 -284
  82. package/packages/control/src/memory/query.ts +0 -75
  83. package/packages/control/src/memory/sanitize.ts +0 -50
  84. package/packages/control/src/memory/sharded-drain-import.ts +0 -212
  85. package/packages/control/src/memory/status.ts +0 -211
  86. package/packages/control/src/memory/store-lifecycle.ts +0 -509
  87. package/packages/control/src/memory/store.ts +0 -284
  88. package/packages/control/src/memory/types.ts +0 -146
  89. package/packages/control/src/parity/surfaces.ts +0 -748
  90. package/packages/control/src/project.ts +0 -141
  91. package/packages/control/src/projection/local-ledger-view.ts +0 -373
  92. package/packages/control/src/projection/return-enforcement.ts +0 -48
  93. package/packages/control/src/projection/timeline-preview.ts +0 -539
  94. package/packages/control/src/projection/timeline.ts +0 -708
  95. package/packages/control/src/release/readiness.ts +0 -831
  96. package/packages/control/src/rubric/loader.ts +0 -326
  97. package/packages/control/src/rubric/promotion.ts +0 -54
  98. package/packages/control/src/rubric/runner.ts +0 -159
  99. package/packages/control/src/rubric/types.ts +0 -158
  100. package/packages/control/src/seat/owner-card.ts +0 -388
  101. package/packages/control/src/seat/readiness.ts +0 -834
  102. package/packages/control/src/session/runbook.ts +0 -431
  103. package/packages/control/src/shared/sanitize.ts +0 -49
  104. package/packages/control/src/supervision/action-classes.ts +0 -192
  105. package/packages/control/src/supervision/command-apply.ts +0 -378
  106. package/packages/control/src/supervision/errors.ts +0 -14
  107. package/packages/control/src/supervision/event-replay.ts +0 -155
  108. package/packages/control/src/supervision/events.ts +0 -109
  109. package/packages/control/src/supervision/index.ts +0 -16
  110. package/packages/control/src/supervision/manifest.ts +0 -127
  111. package/packages/control/src/supervision/paths.ts +0 -49
  112. package/packages/control/src/supervision/projection-adapter.ts +0 -274
  113. package/packages/control/src/supervision/projection.ts +0 -75
  114. package/packages/control/src/supervision/rebuild.ts +0 -99
  115. package/packages/control/src/supervision/session-open.ts +0 -131
  116. package/packages/control/src/supervision/session-read.ts +0 -99
  117. package/packages/control/src/supervision/sqlite-impl.ts +0 -71
  118. package/packages/control/src/supervision/sqlite.ts +0 -121
  119. package/packages/control/src/supervision/store-rows.ts +0 -371
  120. package/packages/control/src/supervision/turn-close.ts +0 -154
  121. package/packages/control/src/supervision/turn-open.ts +0 -284
  122. package/packages/control/src/sync/event-log-merge-driver.ts +0 -263
  123. package/packages/control/src/sync/join-plan.ts +0 -375
  124. package/packages/control/src/sync/outbox.ts +0 -492
  125. package/packages/control/src/workflow/evaluator.ts +0 -140
  126. package/packages/control/src/workflow/loader.ts +0 -200
  127. package/packages/control/src/workflow/types.ts +0 -90
  128. package/packages/control/src/workspace/authority.ts +0 -499
  129. package/packages/protocol/src/guards.ts +0 -119
  130. package/packages/protocol/src/huddle-board.ts +0 -198
  131. package/packages/protocol/src/huddle.ts +0 -295
  132. package/packages/protocol/src/incident.ts +0 -251
  133. package/packages/protocol/src/index.ts +0 -8
  134. package/packages/protocol/src/interfaces.ts +0 -107
  135. package/packages/protocol/src/packet-profile.ts +0 -195
  136. package/packages/protocol/src/state.ts +0 -81
  137. package/packages/protocol/src/types.ts +0 -434
  138. package/release/lineage.v0.json +0 -14
  139. package/scripts/release-candidate-verify.sh +0 -175
  140. package/scripts/release-checksum.sh +0 -25
  141. package/scripts/release-pack-canary.sh +0 -97
  142. package/scripts/release-scan.sh +0 -249
  143. package/scripts/release-sign.sh +0 -34
@@ -1,4686 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
3
- import { existsSync } from 'node:fs';
4
- import { appendFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
5
- import { dirname, join, relative, resolve } from 'node:path';
6
- import { parse as parseYaml } from 'yaml';
7
- import { optionalString, parseList, requirePositional, requireString } from '../argv';
8
- import { askChoice, askConfirm, canPrompt } from '../prompt';
9
- import {
10
- parsePacketProfileV0,
11
- renderPacketProfileV0,
12
- type PacketProfileValueV0,
13
- } from '../../../../protocol/src/index';
14
- import {
15
- assertEngagementProject,
16
- assertSessionProject,
17
- ensureProjectBinding,
18
- projectBindingPath,
19
- readProjectBinding,
20
- } from '../../engagement/project';
21
- import { appendSessionRunbookNote, createSessionRunbook } from '../../session/runbook';
22
- import { reportSeatReadiness, type ModelTier, type SeatType } from '../../seat/readiness';
23
- import { assertSharedMemoryScopeId, assertSharedMemoryText } from '../../shared/sanitize';
24
- import {
25
- buildLocalLedgerView,
26
- renderLocalLedgerViewMarkdown,
27
- renderLocalLedgerViewText,
28
- type LocalEvent,
29
- type LocalLedgerViewKind,
30
- } from '../../projection/local-ledger-view';
31
- import {
32
- buildTimelineView,
33
- renderTimelineViewMarkdown,
34
- renderTimelineViewText,
35
- type TimelineScope,
36
- } from '../../projection/timeline';
37
- import { timelinePreviewResponseForRequest } from '../../projection/timeline-preview';
38
- import {
39
- detectReturnPackets,
40
- type ReturnCheckVerdict,
41
- type ReturnRole,
42
- } from '../../projection/return-enforcement';
43
- import { evaluateRigConductorEnvelope } from '../../authority/rig-conductor';
44
-
45
- type JsonRecord = Record<string, unknown>;
46
-
47
- const SEAT_LIFECYCLE_SCHEMA = 'seat_lifecycle.v0';
48
- const SEAT_LIFECYCLE_STATES = [
49
- 'enrolled',
50
- 'provisioned',
51
- 'ready',
52
- 'assigned',
53
- 'active',
54
- 'idle',
55
- 'away',
56
- 'absent',
57
- 'stale',
58
- 'resuming',
59
- 'retired',
60
- ] as const;
61
- const ROUTABLE_LIFECYCLE_STATES = new Set(['ready', 'idle']);
62
- const DEFAULT_LIFECYCLE_TTL_SECONDS = 8 * 60 * 60;
63
-
64
- type SeatLifecycleState = typeof SEAT_LIFECYCLE_STATES[number];
65
-
66
- interface WakeQueueItem {
67
- seq: number;
68
- event_ref: string;
69
- created_at: string;
70
- type: string;
71
- lane: string | null;
72
- role: string | null;
73
- to: string | null;
74
- next_owner: string | null;
75
- packet_ref: string | null;
76
- requires_ack: boolean;
77
- match: 'direct' | 'next_owner' | 'subscription';
78
- }
79
-
80
- interface WakeQueue {
81
- schema: 'remote-wake-queue.v0';
82
- seat: string;
83
- updated_at: string;
84
- source: {
85
- event_log_ref: '.etiquette/events/local.ndjson';
86
- after_seq: number;
87
- max_seq_seen: number;
88
- subscriptions_ref: string;
89
- };
90
- transport: {
91
- mode: 'local-poll';
92
- webhook_enabled: false;
93
- sns_sqs_enabled: false;
94
- external_delivery_enabled: false;
95
- };
96
- payload_boundary: {
97
- pointers_only: true;
98
- packet_body_included: false;
99
- raw_prompt_included: false;
100
- diff_included: false;
101
- secret_values_included: false;
102
- };
103
- items: WakeQueueItem[];
104
- authority_boundary: {
105
- queue_is_authority: false;
106
- notification_can_authorize: false;
107
- notification_can_grant: false;
108
- notification_can_merge: false;
109
- notification_can_close: false;
110
- can_write_global_ledger: false;
111
- };
112
- }
113
-
114
- interface GitHubWebhookWake {
115
- schema: 'github-webhook-wake.v0';
116
- provider: 'github';
117
- event: 'push';
118
- delivery_id: string;
119
- received_at: string;
120
- repository: {
121
- full_name: string;
122
- html_url: string | null;
123
- default_branch: string | null;
124
- };
125
- ref: string;
126
- before: string | null;
127
- after: string;
128
- compare_url: string | null;
129
- pusher: string | null;
130
- sender: string | null;
131
- source: {
132
- payload_ref: string;
133
- payload_sha256: string;
134
- signature_header: 'X-Hub-Signature-256';
135
- delivery_header: 'X-GitHub-Delivery';
136
- event_header: 'X-GitHub-Event';
137
- };
138
- transport: {
139
- mode: 'github-webhook';
140
- webhook_enabled: true;
141
- signature_verified: true;
142
- receiver_network_required: true;
143
- receiver_must_fetch_and_verify: true;
144
- webhook_payload_trusted_as_truth: false;
145
- sns_sqs_enabled: false;
146
- aws_enabled: false;
147
- };
148
- payload_boundary: {
149
- pointers_only: true;
150
- raw_payload_included: false;
151
- commit_messages_included: false;
152
- diff_included: false;
153
- secret_values_included: false;
154
- };
155
- authority_boundary: {
156
- notification_is_authority: false;
157
- webhook_can_authorize: false;
158
- webhook_can_grant: false;
159
- webhook_can_merge: false;
160
- webhook_can_close: false;
161
- webhook_can_write_global_ledger: false;
162
- webhook_can_write_local_ledger: false;
163
- };
164
- next_action: 'receiver_fetch_and_verify_git_ref';
165
- }
166
-
167
- interface GitHubWebhookPing {
168
- schema: 'github-webhook-ping.v0';
169
- provider: 'github';
170
- event: 'ping';
171
- delivery_id: string;
172
- received_at: string;
173
- repository: {
174
- full_name: string;
175
- html_url: string | null;
176
- default_branch: string | null;
177
- };
178
- sender: string | null;
179
- hook_id: string | null;
180
- source: {
181
- payload_ref: string;
182
- payload_sha256: string;
183
- signature_header: 'X-Hub-Signature-256';
184
- delivery_header: 'X-GitHub-Delivery';
185
- event_header: 'X-GitHub-Event';
186
- };
187
- transport: {
188
- mode: 'github-webhook';
189
- webhook_enabled: true;
190
- signature_verified: true;
191
- receiver_network_required: true;
192
- receiver_must_fetch_and_verify: false;
193
- webhook_payload_trusted_as_truth: false;
194
- sns_sqs_enabled: false;
195
- aws_enabled: false;
196
- };
197
- payload_boundary: {
198
- pointers_only: true;
199
- raw_payload_included: false;
200
- commit_messages_included: false;
201
- diff_included: false;
202
- secret_values_included: false;
203
- };
204
- authority_boundary: GitHubWebhookWake['authority_boundary'];
205
- next_action: 'receiver_acknowledged_github_ping';
206
- }
207
-
208
- function wakeQueueTransport(): WakeQueue['transport'] {
209
- return {
210
- mode: 'local-poll',
211
- webhook_enabled: false,
212
- sns_sqs_enabled: false,
213
- external_delivery_enabled: false,
214
- };
215
- }
216
-
217
- function wakeQueuePayloadBoundary(): WakeQueue['payload_boundary'] {
218
- return {
219
- pointers_only: true,
220
- packet_body_included: false,
221
- raw_prompt_included: false,
222
- diff_included: false,
223
- secret_values_included: false,
224
- };
225
- }
226
-
227
- function wakeQueueAuthorityBoundary(): WakeQueue['authority_boundary'] {
228
- return {
229
- queue_is_authority: false,
230
- notification_can_authorize: false,
231
- notification_can_grant: false,
232
- notification_can_merge: false,
233
- notification_can_close: false,
234
- can_write_global_ledger: false,
235
- };
236
- }
237
-
238
- function normalizeWakeQueue(queue: WakeQueue): WakeQueue {
239
- return {
240
- ...queue,
241
- transport: queue.transport ?? wakeQueueTransport(),
242
- payload_boundary: queue.payload_boundary ?? wakeQueuePayloadBoundary(),
243
- authority_boundary: {
244
- ...wakeQueueAuthorityBoundary(),
245
- ...(queue.authority_boundary ?? {}),
246
- },
247
- };
248
- }
249
-
250
- function githubWebhookTransport(): GitHubWebhookWake['transport'] {
251
- return {
252
- mode: 'github-webhook',
253
- webhook_enabled: true,
254
- signature_verified: true,
255
- receiver_network_required: true,
256
- receiver_must_fetch_and_verify: true,
257
- webhook_payload_trusted_as_truth: false,
258
- sns_sqs_enabled: false,
259
- aws_enabled: false,
260
- };
261
- }
262
-
263
- function githubWebhookPingTransport(): GitHubWebhookPing['transport'] {
264
- return {
265
- ...githubWebhookTransport(),
266
- receiver_must_fetch_and_verify: false,
267
- };
268
- }
269
-
270
- function githubWebhookPayloadBoundary(): GitHubWebhookWake['payload_boundary'] {
271
- return {
272
- pointers_only: true,
273
- raw_payload_included: false,
274
- commit_messages_included: false,
275
- diff_included: false,
276
- secret_values_included: false,
277
- };
278
- }
279
-
280
- function githubWebhookAuthorityBoundary(): GitHubWebhookWake['authority_boundary'] {
281
- return {
282
- notification_is_authority: false,
283
- webhook_can_authorize: false,
284
- webhook_can_grant: false,
285
- webhook_can_merge: false,
286
- webhook_can_close: false,
287
- webhook_can_write_global_ledger: false,
288
- webhook_can_write_local_ledger: false,
289
- };
290
- }
291
-
292
- const LOCAL_WORKFLOW_COMMANDS = new Set([
293
- 'setup',
294
- 'handoff',
295
- 'init',
296
- 'bootstrap',
297
- 'whereami',
298
- 'join',
299
- 'pickup',
300
- 'enter',
301
- 'audit',
302
- 'roster',
303
- 'post',
304
- 'notify',
305
- 'return',
306
- 'retire',
307
- 'rig',
308
- 'doctor',
309
- 'bus',
310
- 'timeline',
311
- 'status',
312
- 'diagnostics',
313
- 'trash',
314
- ]);
315
-
316
- const FALSE_AUTHORITY_FIELDS = [
317
- 'can_authorize_work',
318
- 'can_close_lane_without_grant',
319
- 'can_merge_or_push_without_grant',
320
- ];
321
-
322
- const TASK_EXECUTION_STRATEGIES = ['solo', 'tango', 'rig', 'shadow'] as const;
323
-
324
- type TaskExecutionStrategy = typeof TASK_EXECUTION_STRATEGIES[number];
325
-
326
- const READ_ONLY_AUDIT_WRITE_PREFIXES = [
327
- '.etiquette/events/local.ndjson',
328
- '.etiquette/notify/',
329
- 'docs/work/ledger/',
330
- 'docs/work/packets/',
331
- 'docs/work/receipts/',
332
- 'docs/work/sessions/',
333
- ];
334
-
335
- type RunnerDiagnosticKind =
336
- | 'unsupported_content_type'
337
- | 'tool_result_transport_error'
338
- | 'rendering_error'
339
- | 'unknown_runner_glitch';
340
-
341
- type RunnerDiagnosticSeverity = 'info' | 'warning' | 'blocked';
342
-
343
- interface BootstrapSeat {
344
- seatId: string;
345
- operatorPrincipal: string;
346
- displayName: string;
347
- seatType: SeatType;
348
- asRole: string;
349
- authorityScope: string;
350
- mode: string;
351
- modelRef: string;
352
- modelTier: ModelTier;
353
- modelId: string;
354
- supportedStrategies: string[];
355
- activeWorkers: number;
356
- maxRecommendedWorkers: number;
357
- supportsParallel: boolean;
358
- allowedWork: string[];
359
- forbiddenWork: string[];
360
- }
361
-
362
- interface BootstrapTask {
363
- id: string;
364
- title: string;
365
- mode: string;
366
- assignedTo: BootstrapSeat;
367
- allowedWrites: string[];
368
- validation: string[];
369
- reviewFocus?: string[];
370
- planNote: string;
371
- }
372
-
373
- interface BootstrapPlan {
374
- profile: string;
375
- taskPrefix: string;
376
- lead: BootstrapSeat;
377
- reviewers: BootstrapSeat[];
378
- bootstrapSeat: BootstrapSeat | null;
379
- tasks: BootstrapTask[];
380
- onboarding: {
381
- runtime: string;
382
- runtimeCaption: string;
383
- writeScope: string[];
384
- twoSeatOffered: boolean;
385
- twoSeatAccepted: boolean;
386
- interactive: boolean;
387
- };
388
- }
389
-
390
- type SetupTarget = 'codex' | 'claude-code' | 'gemini';
391
-
392
- interface SetupTargetConfig {
393
- target: SetupTarget;
394
- display: string;
395
- cliCommand: string;
396
- configDir: string;
397
- skillRef: string;
398
- notesRef: string;
399
- }
400
-
401
- interface SetupResult {
402
- schema: 'agent-host-setup.v0';
403
- project: string;
404
- target_mode: string;
405
- targets: Array<{
406
- target: SetupTarget;
407
- display: string;
408
- detected: boolean;
409
- config_dir: string;
410
- skill_ref: string;
411
- notes_ref: string;
412
- action: 'installed' | 'dry-run' | 'skipped' | 'ejected';
413
- generated: string[];
414
- skipped: string[];
415
- removed: string[];
416
- }>;
417
- dry_run: boolean;
418
- ejected: boolean;
419
- authority_boundary: {
420
- setup_installs_project_guidance_only: true;
421
- can_install_hooks: false;
422
- can_write_global_config: false;
423
- can_bootstrap_project: false;
424
- can_join_seat: false;
425
- can_pickup_task: false;
426
- can_grant: false;
427
- can_merge: false;
428
- can_close: false;
429
- can_push: false;
430
- };
431
- next_step: string;
432
- }
433
-
434
- interface TrashEntry extends JsonRecord {
435
- schema: 'trash-index-entry.v0';
436
- trash_id: string;
437
- path: string;
438
- removed_at: string;
439
- removed_by: string;
440
- reason: string;
441
- retirable_class: string;
442
- superseded_by: string | null;
443
- evidence_ref: string;
444
- last_commit: string;
445
- restore_hint: string;
446
- authority_boundary: {
447
- trash_index_is_authority: false;
448
- can_close: false;
449
- can_merge: false;
450
- can_grant: false;
451
- };
452
- }
453
-
454
- export function isLocalWorkflowCommand(command: string): boolean {
455
- return LOCAL_WORKFLOW_COMMANDS.has(command);
456
- }
457
-
458
- function projectRoot(flags: Record<string, string | boolean>): string {
459
- return resolve(optionalString(flags, 'project') ?? process.cwd());
460
- }
461
-
462
- function gitStatusPorcelain(root: string): string {
463
- const inside = git(root, ['rev-parse', '--is-inside-work-tree'], true);
464
- if (inside.status !== 0 || inside.stdout.trim() !== 'true') return '';
465
- return git(root, ['status', '--porcelain'], true).stdout.trim();
466
- }
467
-
468
- async function applyFirstRunOnboardingPrompts(
469
- root: string,
470
- flags: Record<string, string | boolean>,
471
- ): Promise<void> {
472
- const promptRequested = isTrueFlag(flags, 'ask');
473
- const promptAllowed = promptRequested || (
474
- flags.json !== true
475
- && flags.yes !== true
476
- && flags.profile === undefined
477
- && canPrompt()
478
- );
479
- if (!promptAllowed) {
480
- if (!optionalString(flags, 'runtime')) flags.runtime = detectRuntimeFromEnvironment();
481
- return;
482
- }
483
-
484
- const dirty = gitStatusPorcelain(root);
485
- if (dirty.length > 0) {
486
- const proceed = await askConfirm({
487
- label: 'This git worktree has uncommitted changes. Continue with repo-local Etiquette bootstrap?',
488
- defaultValue: true,
489
- });
490
- if (!proceed) throw new Error('bootstrap cancelled by operator because the worktree is dirty');
491
- }
492
-
493
- const topology = await askChoice({
494
- label: 'First run shape',
495
- choices: [
496
- { value: 'solo', label: 'Solo implementation seat' },
497
- { value: 'lead-review', label: 'Implementation seat plus review seat' },
498
- ],
499
- defaultValue: 'solo',
500
- });
501
- flags.profile = topology;
502
-
503
- const detected = detectRuntimeFromEnvironment();
504
- flags.runtime = await askChoice({
505
- label: 'Current runtime occupant (a seat is the durable role; the runtime is the temporary occupant)',
506
- choices: [
507
- { value: detected, label: detected === 'none' ? 'No runtime detected' : `Detected: ${detected}` },
508
- { value: 'codex', label: 'Codex' },
509
- { value: 'claude-code', label: 'Claude Code' },
510
- { value: 'gemini', label: 'Gemini' },
511
- { value: 'none', label: 'None / manual terminal' },
512
- ],
513
- defaultValue: detected,
514
- });
515
-
516
- const scopeAccepted = await askConfirm({
517
- label: 'Use the default write boundary: root manifest plus src/**, app/**, tests/**; governance surfaces stay protected?',
518
- defaultValue: true,
519
- });
520
- if (!scopeAccepted) {
521
- throw new Error('bootstrap cancelled; choose a narrower task envelope before first pickup');
522
- }
523
-
524
- if (topology === 'solo') {
525
- const addReview = await askConfirm({
526
- label: 'Add a review seat now?',
527
- defaultValue: false,
528
- });
529
- if (addReview) flags.profile = 'lead-review';
530
- }
531
- flags.ask = true;
532
- }
533
-
534
- function nowIso(flags?: Record<string, string | boolean>): string {
535
- const raw = flags ? optionalString(flags, 'at') : undefined;
536
- const value = raw ?? new Date().toISOString();
537
- if (!Number.isFinite(Date.parse(value))) throw new Error('--at must be an ISO-8601 timestamp');
538
- return new Date(value).toISOString();
539
- }
540
-
541
- function eventPath(root: string): string {
542
- return join(root, '.etiquette', 'events', 'local.ndjson');
543
- }
544
-
545
- function ledgerPath(root: string): string {
546
- return join(root, 'docs', 'work', 'ledger', 'LEDGER.md');
547
- }
548
-
549
- function isInitializedLocalProject(root: string): boolean {
550
- return existsSync(projectBindingPath(root)) && existsSync(eventPath(root));
551
- }
552
-
553
- function relativeRef(root: string, path: string): string {
554
- const rel = relative(root, resolve(path));
555
- if (rel.length === 0 || rel.startsWith('..') || rel.includes('\\')) {
556
- throw new Error(`path must stay inside project root: ${path}`);
557
- }
558
- return rel;
559
- }
560
-
561
- function pickupCommand(seat: string, task: string, projectId?: string | null): string {
562
- const expectation = projectId ? ` --expect-project-id ${projectId}` : '';
563
- return `etiquette pickup --project . --seat ${seat} --task ${task}${expectation} --text`;
564
- }
565
-
566
- function assertPickupProjectExpectation(projectId: string, flags: Record<string, string | boolean>): void {
567
- const expectedProjectId = optionalString(flags, 'expect-project-id');
568
- if (expectedProjectId) {
569
- assertEngagementProject(projectId, expectedProjectId, 'current project');
570
- return;
571
- }
572
- if (Boolean(flags['allow-current-project'])) return;
573
- throw new Error(
574
- 'pickup requires --expect-project-id <project_id>; run etiquette whereami --project . --json or use a generated pickup command. For manual local-only pickup, pass --allow-current-project.',
575
- );
576
- }
577
-
578
- function safeName(value: string): string {
579
- const out = value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
580
- return out || 'item';
581
- }
582
-
583
- function yamlList(values: string[], indent = ' '): string {
584
- if (values.length === 0) return `${indent}[]`;
585
- return values.map((value) => `${indent}- ${value}`).join('\n');
586
- }
587
-
588
- function localWorkflowBookkeepingWrites(task: BootstrapTask): string[] {
589
- return [
590
- '.etiquette/events/local.ndjson',
591
- `.etiquette/notify/queues/${task.assignedTo.seatId}.json`,
592
- 'docs/work/ledger/LEDGER.md',
593
- `docs/work/packets/*${safeName(task.id)}*.json`,
594
- ];
595
- }
596
-
597
- function taskAllowedWrites(task: BootstrapTask): string[] {
598
- return [
599
- ...task.allowedWrites,
600
- ...localWorkflowBookkeepingWrites(task),
601
- ];
602
- }
603
-
604
- function taskExecutionStrategy(task: BootstrapTask): TaskExecutionStrategy {
605
- return task.mode === 'read-only-audit' ? 'tango' : 'solo';
606
- }
607
-
608
- function localReturnRole(task: BootstrapTask): string {
609
- return task.mode === 'product-implementation' ? 'implementation-return' : 'review-return';
610
- }
611
-
612
- function localReturnTarget(plan: BootstrapPlan, task: BootstrapTask): string {
613
- return task.mode === 'product-implementation'
614
- ? (plan.reviewers[0]?.seatId ?? plan.lead.seatId)
615
- : plan.lead.seatId;
616
- }
617
-
618
- function planSeats(plan: BootstrapPlan): BootstrapSeat[] {
619
- return [plan.lead, ...plan.reviewers, ...(plan.bootstrapSeat ? [plan.bootstrapSeat] : [])];
620
- }
621
-
622
- function isReturnRole(role: string): boolean {
623
- return role === 'implementation-return' || role === 'review-return';
624
- }
625
-
626
- function optionalFlag(flags: Record<string, string | boolean>, key: string, fallback: string): string {
627
- return optionalString(flags, key) ?? fallback;
628
- }
629
-
630
- function detectRuntimeFromEnvironment(): string {
631
- const agent = process.env.TERM_PROGRAM ?? process.env.CODEX_SANDBOX ?? process.env.CLAUDECODE ?? '';
632
- const normalized = agent.toLowerCase();
633
- if (normalized.includes('codex') || process.env.CODEX_SANDBOX) return 'codex';
634
- if (normalized.includes('claude') || process.env.CLAUDECODE) return 'claude-code';
635
- if (normalized.includes('gemini')) return 'gemini';
636
- return 'none';
637
- }
638
-
639
- function isTrueFlag(flags: Record<string, string | boolean>, key: string): boolean {
640
- const value = flags[key];
641
- return value === true || value === 'true' || value === '1' || value === 'yes';
642
- }
643
-
644
- function makeSeat(input: {
645
- seatId: string;
646
- operatorPrincipal: string;
647
- displayName: string;
648
- seatType: SeatType;
649
- asRole: string;
650
- authorityScope: string;
651
- mode: string;
652
- modelRef: string;
653
- modelTier: ModelTier;
654
- modelId: string;
655
- supportedStrategies: string[];
656
- activeWorkers?: number;
657
- maxRecommendedWorkers?: number;
658
- supportsParallel?: boolean;
659
- allowedWork: string[];
660
- forbiddenWork: string[];
661
- }): BootstrapSeat {
662
- return {
663
- activeWorkers: input.activeWorkers ?? 0,
664
- maxRecommendedWorkers: input.maxRecommendedWorkers ?? 1,
665
- supportsParallel: input.supportsParallel ?? false,
666
- ...input,
667
- };
668
- }
669
-
670
- function buildBootstrapPlan(flags: Record<string, string | boolean>): BootstrapPlan {
671
- const profile = optionalFlag(flags, 'profile', 'solo');
672
- const taskPrefix = optionalFlag(flags, 'task-prefix', 'ETQ-BOOTSTRAP');
673
- const runtime = optionalString(flags, 'runtime') ?? detectRuntimeFromEnvironment();
674
-
675
- const solo = profile === 'solo';
676
- const leadReview = profile === 'lead-review';
677
- if (profile !== 'solo' && profile !== 'codex-cc-gemini' && profile !== 'lead-review') {
678
- throw new Error('--profile must be solo, codex-cc-gemini, or lead-review');
679
- }
680
- const bootstrapSeat = solo
681
- ? null
682
- : makeSeat({
683
- seatId: optionalFlag(flags, 'bootstrap-seat', 'manager-seat'),
684
- operatorPrincipal: optionalFlag(flags, 'bootstrap-principal', 'manager'),
685
- displayName: 'Bootstrap Provisioner Seat',
686
- seatType: 'control_seat',
687
- asRole: 'observer',
688
- authorityScope: 'bootstrap-provenance',
689
- mode: 'docs-only',
690
- modelRef: 'bootstrap-provenance',
691
- modelTier: 'local_unknown',
692
- modelId: 'bootstrap-provenance',
693
- supportedStrategies: ['bootstrap-provenance'],
694
- allowedWork: ['repo-local bootstrap provenance', 'lead handoff records'],
695
- forbiddenWork: [
696
- 'routine task ownership after lead handoff',
697
- 'external ledger writes for routine execution',
698
- 'merge, push, closeout, promotion, or grants',
699
- ],
700
- });
701
-
702
- const lead = makeSeat({
703
- seatId: optionalFlag(flags, 'lead-seat', leadReview || solo ? 'impl-seat' : 'codex-seat'),
704
- operatorPrincipal: optionalFlag(flags, 'lead-principal', leadReview || solo ? 'impl' : 'codex'),
705
- displayName: 'Local Lead Seat',
706
- seatType: 'control_seat',
707
- asRole: 'principal',
708
- authorityScope: 'repo-local-routing',
709
- mode: 'product-implementation',
710
- modelRef: leadReview || solo ? 'local-implementation-session' : 'local-codex-session',
711
- modelTier: 'frontier_high_reasoning',
712
- modelId: leadReview || solo ? 'local-implementation-session' : 'local-codex-session',
713
- supportedStrategies: ['local-routing', 'product-implementation', 'validation', 'review-gate'],
714
- activeWorkers: 1,
715
- maxRecommendedWorkers: 2,
716
- supportsParallel: true,
717
- allowedWork: [
718
- 'repo-local task routing',
719
- 'repo-local board updates',
720
- 'repo-local session creation',
721
- 'assigned implementation',
722
- 'local receipt review',
723
- ],
724
- forbiddenWork: [
725
- 'external ledger writes for routine execution',
726
- 'merge, push, closeout, promotion, or grants without explicit decision',
727
- ],
728
- });
729
-
730
- const primaryReview = solo ? null : makeSeat({
731
- seatId: optionalFlag(flags, 'review-seat', leadReview ? 'review-seat' : 'cc-seat'),
732
- operatorPrincipal: optionalFlag(flags, 'review-principal', leadReview ? 'review' : 'cc'),
733
- displayName: leadReview ? 'Local Review Seat' : 'Claude Code Review Seat',
734
- seatType: 'review_seat',
735
- asRole: 'reviewer',
736
- authorityScope: 'review-only',
737
- mode: 'read-only-audit',
738
- modelRef: leadReview ? 'local-review-session' : 'local-claude-code-session',
739
- modelTier: 'local_unknown',
740
- modelId: leadReview ? 'local-review-session' : 'local-claude-code-session',
741
- supportedStrategies: ['read-only-audit', 'receipt-review'],
742
- allowedWork: [
743
- 'repo-local read-only review',
744
- 'product and docs critique',
745
- 'bounded session notes',
746
- 'receipt writing under docs/work/receipts',
747
- ],
748
- forbiddenWork: [
749
- 'external ledger writes',
750
- 'product file edits without a later implementation task',
751
- 'merge, push, closeout, promotion, or grants',
752
- ],
753
- });
754
-
755
- const reviewers = primaryReview ? [primaryReview] : [];
756
- const qaSeatRaw = optionalString(flags, 'qa-seat');
757
- const includeQa = !solo && flags['no-qa'] !== true && (!leadReview || qaSeatRaw !== undefined);
758
- if (includeQa) {
759
- reviewers.push(makeSeat({
760
- seatId: optionalFlag(flags, 'qa-seat', 'gemini-seat'),
761
- operatorPrincipal: optionalFlag(flags, 'qa-principal', 'gemini'),
762
- displayName: 'Gemini QA Seat',
763
- seatType: 'review_seat',
764
- asRole: 'reviewer',
765
- authorityScope: 'review-only',
766
- mode: 'read-only-audit',
767
- modelRef: 'local-gemini-session',
768
- modelTier: 'local_unknown',
769
- modelId: 'local-gemini-session',
770
- supportedStrategies: ['read-only-audit', 'adversarial-qa', 'receipt-review'],
771
- allowedWork: [
772
- 'repo-local adversarial review',
773
- 'edge-case review',
774
- 'mobile usability review',
775
- 'bounded session notes',
776
- 'receipt writing under docs/work/receipts',
777
- ],
778
- forbiddenWork: [
779
- 'external ledger writes',
780
- 'product file edits without a later implementation task',
781
- 'merge, push, closeout, promotion, or grants',
782
- ],
783
- }));
784
- }
785
-
786
- const tasks: BootstrapTask[] = [
787
- {
788
- id: `${taskPrefix}-001`,
789
- title: 'First local implementation lane',
790
- mode: 'product-implementation',
791
- assignedTo: lead,
792
- allowedWrites: [
793
- 'package.json',
794
- 'bun.lock',
795
- 'package-lock.json',
796
- 'pnpm-lock.yaml',
797
- 'yarn.lock',
798
- 'src/**',
799
- 'app/**',
800
- 'test/**',
801
- 'tests/**',
802
- `docs/work/receipts/${taskPrefix}-001-*.md`,
803
- `docs/work/sessions/session-${taskPrefix}-001/**`,
804
- ],
805
- validation: ['git diff --check'],
806
- planNote: `Lead pickup: execute ${taskPrefix}-001 using repo-local Etiquette only.`,
807
- },
808
- ...(!primaryReview ? [] : [{
809
- id: `${taskPrefix}-002`,
810
- title: 'Review first local implementation lane',
811
- mode: 'read-only-audit',
812
- assignedTo: primaryReview,
813
- allowedWrites: [
814
- `docs/work/receipts/${taskPrefix}-002-*.md`,
815
- `docs/work/sessions/session-${taskPrefix}-002/**`,
816
- ],
817
- validation: [
818
- 'etiquette doctor --project .',
819
- `etiquette console board --project . --seat ${primaryReview.seatId} --text`,
820
- ],
821
- planNote: `Review pickup: pressure-test ${taskPrefix}-001 for scope, validation, and authority drift.`,
822
- }]),
823
- ];
824
-
825
- const qa = reviewers[1];
826
- if (qa) {
827
- tasks.push({
828
- id: `${taskPrefix}-003`,
829
- title: 'Adversarial QA for the first local implementation lane',
830
- mode: 'read-only-audit',
831
- assignedTo: qa,
832
- allowedWrites: [
833
- `docs/work/receipts/${taskPrefix}-003-*.md`,
834
- `docs/work/sessions/session-${taskPrefix}-003/**`,
835
- ],
836
- validation: ['git diff --check'],
837
- reviewFocus: [
838
- 'edge-case behavior',
839
- 'validation blind spots',
840
- 'mobile or repeated-use ergonomics',
841
- 'authority drift',
842
- ],
843
- planNote: `QA pickup: adversarial review for ${taskPrefix}-001 edge cases and validation gaps.`,
844
- });
845
- }
846
-
847
- return {
848
- profile,
849
- taskPrefix,
850
- lead,
851
- reviewers,
852
- bootstrapSeat,
853
- tasks,
854
- onboarding: {
855
- runtime,
856
- runtimeCaption: 'A seat is the durable role; the runtime is the temporary occupant.',
857
- writeScope: ['package/root manifest', 'src/**', 'app/**', 'tests/**', 'docs/work receipts and sessions'],
858
- twoSeatOffered: true,
859
- twoSeatAccepted: !solo,
860
- interactive: isTrueFlag(flags, 'ask') && canPrompt(),
861
- },
862
- };
863
- }
864
-
865
- function asRecord(value: unknown, label: string): JsonRecord {
866
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
867
- throw new Error(`${label} must be an object`);
868
- }
869
- return value as JsonRecord;
870
- }
871
-
872
- function stringField(record: JsonRecord, field: string): string | undefined {
873
- const value = record[field];
874
- return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
875
- }
876
-
877
- function boolField(record: JsonRecord, field: string): boolean | undefined {
878
- const value = record[field];
879
- return typeof value === 'boolean' ? value : undefined;
880
- }
881
-
882
- function arrayField(record: JsonRecord, field: string): unknown[] | undefined {
883
- const value = record[field];
884
- return Array.isArray(value) ? value : undefined;
885
- }
886
-
887
- function assertLifecycleState(value: string, label = 'lifecycle_state'): asserts value is SeatLifecycleState {
888
- if (!(SEAT_LIFECYCLE_STATES as readonly string[]).includes(value)) {
889
- throw new Error(`${label} must be one of: ${SEAT_LIFECYCLE_STATES.join(', ')}`);
890
- }
891
- }
892
-
893
- function addSecondsIso(at: string, seconds: number): string {
894
- return new Date(Date.parse(at) + seconds * 1000).toISOString();
895
- }
896
-
897
- function lifecycleState(enrollment: JsonRecord): SeatLifecycleState {
898
- const raw = stringField(enrollment, 'lifecycle_state');
899
- if (raw) {
900
- assertLifecycleState(raw);
901
- return raw;
902
- }
903
- return enrollment.ready === true ? 'ready' : 'enrolled';
904
- }
905
-
906
- function lifecycleWorkflowId(projectId: string): string {
907
- return projectId;
908
- }
909
-
910
- function runnerBinding(input: {
911
- runnerProvider: string;
912
- executionSurface: string;
913
- modelId?: string | null;
914
- modelTier?: string | null;
915
- permissionProfileRef?: string | null;
916
- capabilitySource?: string | null;
917
- previous?: JsonRecord | null;
918
- }): JsonRecord {
919
- return {
920
- runner_provider: input.runnerProvider,
921
- execution_surface: input.executionSurface,
922
- model_id: input.modelId ?? null,
923
- model_tier: input.modelTier ?? 'local_unknown',
924
- permission_profile_ref: input.permissionProfileRef ?? null,
925
- capability_source: input.capabilitySource ?? 'config',
926
- runner_permission_profile_verified: true,
927
- capability_tier_revalidated: true,
928
- ...(input.previous ? { previous: input.previous } : {}),
929
- authority_boundary: {
930
- runner_binding_grants_authority: false,
931
- runner_switch_grants_authority: false,
932
- },
933
- };
934
- }
935
-
936
- function lifecycleReadyPatch(input: {
937
- projectId: string;
938
- at: string;
939
- runnerBinding?: JsonRecord;
940
- }): JsonRecord {
941
- return {
942
- lifecycle_schema: SEAT_LIFECYCLE_SCHEMA,
943
- lifecycle_state: 'ready',
944
- workflow_id: lifecycleWorkflowId(input.projectId),
945
- ready: true,
946
- ready_at: input.at,
947
- readiness_ttl_seconds: DEFAULT_LIFECYCLE_TTL_SECONDS,
948
- readiness_expires_at: addSecondsIso(input.at, DEFAULT_LIFECYCLE_TTL_SECONDS),
949
- lifecycle_checks: {
950
- remote_synced: true,
951
- inbox_read: true,
952
- workflow_binding_confirmed: true,
953
- runner_binding_confirmed: true,
954
- runner_permission_profile_verified: true,
955
- capability_tier_revalidated: true,
956
- stale_tasks_cleared: true,
957
- },
958
- ...(input.runnerBinding ? { runner_binding: input.runnerBinding } : {}),
959
- authority_granted: false,
960
- lifecycle_authority_boundary: {
961
- lifecycle_grants_authority: false,
962
- readiness_authorizes_work: false,
963
- can_merge: false,
964
- can_close: false,
965
- can_grant: false,
966
- },
967
- };
968
- }
969
-
970
- function assertLifecycleRoutable(enrollment: JsonRecord, projectId: string, seat: string, at = new Date().toISOString()): void {
971
- const state = lifecycleState(enrollment);
972
- if (!ROUTABLE_LIFECYCLE_STATES.has(state)) {
973
- throw new Error(`seat lifecycle not routable: ${seat} state=${state}; run etiquette roster ready ${seat} after lifecycle preflight`);
974
- }
975
- const workflowId = stringField(enrollment, 'workflow_id');
976
- if (workflowId && workflowId !== lifecycleWorkflowId(projectId)) {
977
- throw new Error(`seat workflow mismatch: ${seat} workflow_id=${workflowId} current=${lifecycleWorkflowId(projectId)}`);
978
- }
979
- const expiresAt = stringField(enrollment, 'readiness_expires_at');
980
- if (expiresAt) {
981
- const atMs = Date.parse(at);
982
- const expiresAtMs = Date.parse(expiresAt);
983
- if (!Number.isFinite(atMs) || !Number.isFinite(expiresAtMs)) {
984
- throw new Error(`seat lifecycle readiness timestamp unparseable: ${seat}`);
985
- }
986
- if (atMs > expiresAtMs) {
987
- throw new Error(`seat lifecycle readiness expired: ${seat}; run etiquette roster ready ${seat} after preflight`);
988
- }
989
- }
990
- if (enrollment.authority_granted === true) {
991
- throw new Error(`seat lifecycle must not grant authority: ${seat}`);
992
- }
993
- const binding = enrollment.runner_binding === undefined ? null : asRecord(enrollment.runner_binding, `${seat} runner_binding`);
994
- if (!binding) return;
995
- if (stringField(binding, 'capability_source') === 'agent_self_declared') {
996
- throw new Error(`seat runner capability is self-declared: ${seat}; require local validation before pickup`);
997
- }
998
- if (binding.runner_switch_requires_preflight === true) {
999
- throw new Error(`seat runner binding requires lifecycle preflight: ${seat}`);
1000
- }
1001
- if (binding.runner_permission_profile_verified === false) {
1002
- throw new Error(`seat runner permission profile not verified: ${seat}`);
1003
- }
1004
- if (binding.capability_tier_revalidated === false) {
1005
- throw new Error(`seat runner capability tier not revalidated: ${seat}`);
1006
- }
1007
- }
1008
-
1009
- async function readTextIfExists(path: string): Promise<string | null> {
1010
- try {
1011
- return await readFile(path, 'utf-8');
1012
- } catch (err) {
1013
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
1014
- throw err;
1015
- }
1016
- }
1017
-
1018
- async function readJsonIfExists<T>(path: string, fallback: T): Promise<T> {
1019
- const raw = await readTextIfExists(path);
1020
- return raw ? JSON.parse(raw) as T : fallback;
1021
- }
1022
-
1023
- async function writeJson(path: string, value: unknown): Promise<void> {
1024
- await mkdir(dirname(path), { recursive: true });
1025
- await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
1026
- }
1027
-
1028
- const ETIQUETTE_GITIGNORE_LINES = [
1029
- '.etiquette/state/',
1030
- '.etiquette/diagnostics/',
1031
- '.etiquette/notify/queues/',
1032
- '.etiquette/events/*.lock/',
1033
- ];
1034
-
1035
- async function ensureMachinePlaneGitignore(root: string, result: { generated: string[]; skipped: string[] }): Promise<void> {
1036
- const rel = '.gitignore';
1037
- const path = join(root, rel);
1038
- const raw = await readTextIfExists(path);
1039
- const existingLines = new Set((raw ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
1040
- const missing = ETIQUETTE_GITIGNORE_LINES.filter((line) => !existingLines.has(line));
1041
- if (missing.length === 0) {
1042
- result.skipped.push(rel);
1043
- return;
1044
- }
1045
-
1046
- const prefix = raw && raw.length > 0
1047
- ? `${raw.replace(/\s*$/, '\n')}\n`
1048
- : '';
1049
- await writeFile(path, [
1050
- prefix,
1051
- '# Etiquette transient machine state',
1052
- ...missing,
1053
- '',
1054
- ].join('\n'));
1055
- result.generated.push(rel);
1056
- }
1057
-
1058
- async function writeGeneratedText(
1059
- root: string,
1060
- rel: string,
1061
- content: string,
1062
- force: boolean,
1063
- result: { generated: string[]; skipped: string[] },
1064
- ): Promise<void> {
1065
- const path = join(root, rel);
1066
- if (existsSync(path) && !force) {
1067
- result.skipped.push(rel);
1068
- return;
1069
- }
1070
- await mkdir(dirname(path), { recursive: true });
1071
- await writeFile(path, content.endsWith('\n') ? content : `${content}\n`);
1072
- result.generated.push(rel);
1073
- }
1074
-
1075
- const SETUP_TARGETS: SetupTargetConfig[] = [
1076
- {
1077
- target: 'codex',
1078
- display: 'Codex',
1079
- cliCommand: 'codex',
1080
- configDir: '.codex',
1081
- skillRef: '.codex/skills/etiquette/SKILL.md',
1082
- notesRef: '.codex/etiquette-setup.md',
1083
- },
1084
- {
1085
- target: 'claude-code',
1086
- display: 'Claude Code',
1087
- cliCommand: 'claude',
1088
- configDir: '.claude',
1089
- skillRef: '.claude/skills/etiquette/SKILL.md',
1090
- notesRef: '.claude/etiquette-setup.md',
1091
- },
1092
- {
1093
- target: 'gemini',
1094
- display: 'Gemini CLI',
1095
- cliCommand: 'gemini',
1096
- configDir: '.gemini',
1097
- skillRef: '.gemini/skills/etiquette/SKILL.md',
1098
- notesRef: '.gemini/etiquette-setup.md',
1099
- },
1100
- ];
1101
-
1102
- function commandAvailable(command: string): boolean {
1103
- return spawnSync('which', [command], { encoding: 'utf-8' }).status === 0;
1104
- }
1105
-
1106
- function setupTargetConfig(target: SetupTarget): SetupTargetConfig {
1107
- const config = SETUP_TARGETS.find((item) => item.target === target);
1108
- if (!config) throw new Error(`unsupported setup target: ${target}`);
1109
- return config;
1110
- }
1111
-
1112
- function parseSetupTargets(root: string, flags: Record<string, string | boolean>): { mode: string; targets: SetupTargetConfig[] } {
1113
- const mode = optionalString(flags, 'target') ?? 'auto';
1114
- if (flags.global === true) {
1115
- throw new Error('setup --global is not available in v0; use project-local setup only');
1116
- }
1117
- if (mode === 'all') {
1118
- return { mode, targets: [...SETUP_TARGETS] };
1119
- }
1120
- if (mode === 'auto') {
1121
- const detected = SETUP_TARGETS.filter((target) => (
1122
- commandAvailable(target.cliCommand) || existsSync(join(root, target.configDir))
1123
- ));
1124
- if (detected.length === 0) {
1125
- throw new Error('setup --target auto found no supported agent host; rerun with --target codex, --target claude-code, --target gemini, or --target all');
1126
- }
1127
- if (detected.length > 1) {
1128
- throw new Error(`setup --target auto found multiple agent hosts (${detected.map((target) => target.target).join(', ')}); choose one with --target codex, --target claude-code, or --target gemini, or use --target all deliberately`);
1129
- }
1130
- return { mode, targets: detected };
1131
- }
1132
- const targets = mode.split(',').map((value) => value.trim()).filter(Boolean).map((value) => {
1133
- if (!['codex', 'claude-code', 'gemini'].includes(value)) {
1134
- throw new Error('--target must be one of: auto, all, codex, claude-code, gemini');
1135
- }
1136
- return setupTargetConfig(value as SetupTarget);
1137
- });
1138
- return { mode, targets };
1139
- }
1140
-
1141
- function renderEtiquetteSkill(target: SetupTargetConfig): string {
1142
- return [
1143
- '---',
1144
- 'name: etiquette-seat-pickup',
1145
- 'description: Use when entering or resuming an Etiquette-managed project. Read the project head-start docs, orient with whereami, join the assigned seat, read the task envelope and runbook, respect allowed writes, run validation, and write a receipt before asking for promotion.',
1146
- '---',
1147
- '',
1148
- '# Etiquette Seat Pickup',
1149
- '',
1150
- `This project installed Etiquette guidance for ${target.display}.`,
1151
- '',
1152
- '## Start Here',
1153
- '',
1154
- 'Before pickup, read the repo-local head-start surfaces when they exist:',
1155
- '',
1156
- '- `AGENTS.md`',
1157
- '- `docs/work/BOARD.md`',
1158
- '- `docs/work/runbooks/SESSION_PICKUP.md`',
1159
- '- `docs/work/manuals/SEAT_OPERATING_MANUAL.md`',
1160
- '- `docs/work/cookbooks/ADD_A_LANE.md`',
1161
- '',
1162
- 'If the seat is interrupted, cwd/worktree ambiguous, role-swapped, or',
1163
- 'vendor-swapped, re-check the live project before pickup:',
1164
- '',
1165
- '```sh',
1166
- 'etiquette whereami --project . --seat <seat-id> --task <task-id>',
1167
- 'etiquette doctor --project .',
1168
- 'etiquette notify poll --project . --seat <seat-id> --text',
1169
- '```',
1170
- '',
1171
- 'This rebind check verifies the live project, task, branch, worktree,',
1172
- 'routing surface, evidence surface, and local event-log head. It grants no',
1173
- 'authority. Lifecycle readiness is evidence only; the task envelope remains',
1174
- 'the work authority.',
1175
- '',
1176
- 'When a task gives you a seat id and task id, run:',
1177
- '',
1178
- '```sh',
1179
- 'etiquette whereami --project . --seat <seat-id> --task <task-id>',
1180
- 'etiquette join --project . --seat <seat-id>',
1181
- 'etiquette pickup --project . --seat <seat-id> --task <task-id> --text',
1182
- '```',
1183
- '',
1184
- 'If `pickup` prints an `--expect-project-id` command, use that exact command.',
1185
- '',
1186
- '## Operating Rules',
1187
- '',
1188
- '- Treat the task envelope as the write boundary.',
1189
- '- Read the runbook before editing.',
1190
- '- Hooks and readiness are evidence only; they never grant task authority.',
1191
- '- Use repo-local `.etiquette` and `docs/work` surfaces, not a global bus, unless explicitly routed.',
1192
- '- Run the task validation before returning.',
1193
- '- Write a receipt under `docs/work/receipts/` when the task asks for one.',
1194
- '- Stop if authority is unclear, validation cannot run, a secret appears, or writes exceed the envelope.',
1195
- '',
1196
- '## Authority Boundary',
1197
- '',
1198
- 'This skill does not grant authority. It cannot merge, close, promote, push, sign, or approve work.',
1199
- ].join('\n');
1200
- }
1201
-
1202
- function renderSetupNotes(target: SetupTargetConfig): string {
1203
- return [
1204
- '# Etiquette Agent Host Setup',
1205
- '',
1206
- `Target: ${target.display}`,
1207
- '',
1208
- 'This directory contains project-local guidance for using Etiquette in this repository.',
1209
- '',
1210
- 'The installed guidance is intentionally advisory:',
1211
- '',
1212
- '- no lifecycle hooks are installed in v0;',
1213
- '- no global agent config is written;',
1214
- '- no project bootstrap is performed;',
1215
- '- no seat is joined;',
1216
- '- no task is picked up;',
1217
- '- no authority is granted.',
1218
- '',
1219
- 'Head-start read order for fresh sessions:',
1220
- '',
1221
- '1. `AGENTS.md`',
1222
- '2. `docs/work/BOARD.md`',
1223
- '3. `docs/work/runbooks/SESSION_PICKUP.md`',
1224
- '4. `docs/work/manuals/SEAT_OPERATING_MANUAL.md`',
1225
- '5. `docs/work/cookbooks/ADD_A_LANE.md`',
1226
- '',
1227
- 'After interrupted sessions, cwd/worktree ambiguity, role changes, or',
1228
- 'vendor swaps, verify `whereami`, `doctor`, local queue, branch, and',
1229
- 'worktree before pickup. Lifecycle checks are evidence only; they do not',
1230
- 'authorize work.',
1231
- '',
1232
- 'A live seat should still run:',
1233
- '',
1234
- '```sh',
1235
- 'etiquette whereami --project . --seat <seat-id> --task <task-id>',
1236
- 'etiquette join --project . --seat <seat-id>',
1237
- 'etiquette pickup --project . --seat <seat-id> --task <task-id> --text',
1238
- '```',
1239
- ].join('\n');
1240
- }
1241
-
1242
- async function dispatchSetup(flags: Record<string, string | boolean>): Promise<void> {
1243
- const root = projectRoot(flags);
1244
- const dryRun = flags['dry-run'] === true;
1245
- const ejected = flags.eject === true;
1246
- const force = flags.force === true;
1247
- const { mode, targets } = parseSetupTargets(root, flags);
1248
-
1249
- const result: SetupResult = {
1250
- schema: 'agent-host-setup.v0',
1251
- project: root,
1252
- target_mode: mode,
1253
- targets: [],
1254
- dry_run: dryRun,
1255
- ejected,
1256
- authority_boundary: {
1257
- setup_installs_project_guidance_only: true,
1258
- can_install_hooks: false,
1259
- can_write_global_config: false,
1260
- can_bootstrap_project: false,
1261
- can_join_seat: false,
1262
- can_pickup_task: false,
1263
- can_grant: false,
1264
- can_merge: false,
1265
- can_close: false,
1266
- can_push: false,
1267
- },
1268
- next_step: ejected
1269
- ? 'project-local Etiquette agent guidance removed; reinstall with etiquette setup --target <host>'
1270
- : 'restart or refresh the agent session, then run etiquette whereami/join/pickup for the assigned seat and task',
1271
- };
1272
-
1273
- for (const target of targets) {
1274
- const detected = commandAvailable(target.cliCommand) || existsSync(join(root, target.configDir));
1275
- const generated: string[] = [];
1276
- const skipped: string[] = [];
1277
- const removed: string[] = [];
1278
- const files = [
1279
- { ref: target.skillRef, content: renderEtiquetteSkill(target) },
1280
- { ref: target.notesRef, content: renderSetupNotes(target) },
1281
- ];
1282
-
1283
- if (ejected) {
1284
- for (const file of files) {
1285
- const path = join(root, file.ref);
1286
- if (existsSync(path)) {
1287
- if (!dryRun) await rm(path, { force: true });
1288
- removed.push(file.ref);
1289
- } else {
1290
- skipped.push(file.ref);
1291
- }
1292
- }
1293
- } else {
1294
- for (const file of files) {
1295
- if (dryRun) {
1296
- generated.push(file.ref);
1297
- continue;
1298
- }
1299
- await writeGeneratedText(root, file.ref, file.content, force, { generated, skipped });
1300
- }
1301
- }
1302
-
1303
- result.targets.push({
1304
- target: target.target,
1305
- display: target.display,
1306
- detected,
1307
- config_dir: target.configDir,
1308
- skill_ref: target.skillRef,
1309
- notes_ref: target.notesRef,
1310
- action: dryRun ? 'dry-run' : ejected ? 'ejected' : generated.length > 0 ? 'installed' : 'skipped',
1311
- generated,
1312
- skipped,
1313
- removed,
1314
- });
1315
- }
1316
-
1317
- if (flags.json) {
1318
- console.log(JSON.stringify(result, null, 2));
1319
- return;
1320
- }
1321
-
1322
- console.log([
1323
- 'agent_host_setup:',
1324
- ` project: ${result.project}`,
1325
- ` target_mode: ${result.target_mode}`,
1326
- ` dry_run: ${String(result.dry_run)}`,
1327
- ` eject: ${String(result.ejected)}`,
1328
- ' authority_boundary: project-local guidance only; no hooks, global config, bootstrap, join, pickup, push, merge, close, or grant',
1329
- 'targets:',
1330
- ...result.targets.flatMap((target) => [
1331
- ` - ${target.target} detected=${String(target.detected)} action=${target.action}`,
1332
- ` skill_ref: ${target.skill_ref}`,
1333
- ` notes_ref: ${target.notes_ref}`,
1334
- ` generated: ${target.generated.join(', ') || 'none'}`,
1335
- ` skipped: ${target.skipped.join(', ') || 'none'}`,
1336
- ` removed: ${target.removed.join(', ') || 'none'}`,
1337
- ]),
1338
- `next_step: ${result.next_step}`,
1339
- ].join('\n'));
1340
- }
1341
-
1342
- async function ensureLocalProject(root: string): Promise<void> {
1343
- await mkdir(join(root, '.etiquette', 'events'), { recursive: true });
1344
- await mkdir(join(root, '.etiquette', 'audit'), { recursive: true });
1345
- await mkdir(join(root, '.etiquette', 'diagnostics'), { recursive: true });
1346
- await mkdir(join(root, '.etiquette', 'notify', 'queues'), { recursive: true });
1347
- await mkdir(join(root, '.etiquette', 'notify', 'subscriptions'), { recursive: true });
1348
- await mkdir(join(root, '.etiquette', 'roster', 'enrollments'), { recursive: true });
1349
- await mkdir(join(root, '.etiquette', 'state'), { recursive: true });
1350
- await mkdir(join(root, 'docs', 'work', 'cookbooks'), { recursive: true });
1351
- await mkdir(join(root, 'docs', 'work', 'huddle-cards'), { recursive: true });
1352
- await mkdir(join(root, 'docs', 'work', 'ledger'), { recursive: true });
1353
- await mkdir(join(root, 'docs', 'work', 'manuals'), { recursive: true });
1354
- await mkdir(join(root, 'docs', 'work', 'onboarding'), { recursive: true });
1355
- await mkdir(join(root, 'docs', 'work', 'packets'), { recursive: true });
1356
- await mkdir(join(root, 'docs', 'work', 'receipts'), { recursive: true });
1357
- await mkdir(join(root, 'docs', 'work', 'runbooks'), { recursive: true });
1358
- await mkdir(join(root, 'docs', 'work', 'seat-cards'), { recursive: true });
1359
- await mkdir(join(root, 'docs', 'work', 'sessions'), { recursive: true });
1360
- await mkdir(join(root, 'docs', 'work', 'tasks'), { recursive: true });
1361
- await mkdir(join(root, 'docs', 'work', 'trash'), { recursive: true });
1362
-
1363
- await ensureProjectBinding(root);
1364
- if (!existsSync(eventPath(root))) await writeFile(eventPath(root), '');
1365
- if (!existsSync(ledgerPath(root))) {
1366
- await writeFile(ledgerPath(root), '# Local Etiquette Ledger\n\n');
1367
- }
1368
- }
1369
-
1370
- async function readEvents(root: string): Promise<LocalEvent[]> {
1371
- await ensureLocalProject(root);
1372
- const raw = await readFile(eventPath(root), 'utf-8');
1373
- return raw
1374
- .split('\n')
1375
- .map((line) => line.trim())
1376
- .filter(Boolean)
1377
- .map((line) => JSON.parse(line) as LocalEvent);
1378
- }
1379
-
1380
- async function readEventsIfInitialized(root: string): Promise<LocalEvent[]> {
1381
- const raw = await readTextIfExists(eventPath(root));
1382
- if (!raw) return [];
1383
- return raw
1384
- .split('\n')
1385
- .map((line) => line.trim())
1386
- .filter(Boolean)
1387
- .map((line) => JSON.parse(line) as LocalEvent);
1388
- }
1389
-
1390
- function sleep(ms: number): Promise<void> {
1391
- return new Promise((resolveSleep) => {
1392
- setTimeout(resolveSleep, ms);
1393
- });
1394
- }
1395
-
1396
- async function withEventLock<T>(root: string, fn: () => Promise<T>): Promise<T> {
1397
- const lockDir = join(root, '.etiquette', 'events', 'local.ndjson.lock');
1398
- for (let attempt = 0; attempt < 200; attempt += 1) {
1399
- try {
1400
- await mkdir(lockDir);
1401
- try {
1402
- return await fn();
1403
- } finally {
1404
- await rm(lockDir, { recursive: true, force: true });
1405
- }
1406
- } catch (err) {
1407
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
1408
- await sleep(10);
1409
- }
1410
- }
1411
- throw new Error('timed out waiting for local event lock');
1412
- }
1413
-
1414
- async function appendLedger(root: string, event: LocalEvent): Promise<void> {
1415
- const fields = [
1416
- `type=${event.type}`,
1417
- typeof event.lane === 'string' ? `lane=${event.lane}` : undefined,
1418
- typeof event.from === 'string' ? `from=${event.from}` : undefined,
1419
- typeof event.to === 'string' ? `to=${event.to}` : undefined,
1420
- typeof event.next_owner === 'string' ? `next_owner=${event.next_owner}` : undefined,
1421
- ].filter(Boolean).join(' ');
1422
- await appendFile(ledgerPath(root), `- ${event.created_at} ${event.event_ref} ${fields}\n`);
1423
- }
1424
-
1425
- async function appendLocalEventUnlocked(
1426
- root: string,
1427
- type: string,
1428
- payload: JsonRecord,
1429
- flags?: Record<string, string | boolean>,
1430
- ): Promise<LocalEvent> {
1431
- const events = await readEvents(root);
1432
- const seq = events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
1433
- const event: LocalEvent = {
1434
- schema: 'local-event.v0',
1435
- seq,
1436
- event_ref: `local://event/${seq}`,
1437
- created_at: nowIso(flags),
1438
- type,
1439
- ...payload,
1440
- };
1441
- await appendFile(eventPath(root), `${JSON.stringify(event)}\n`);
1442
- await appendLedger(root, event);
1443
- return event;
1444
- }
1445
-
1446
- async function appendLocalEvent(
1447
- root: string,
1448
- type: string,
1449
- payload: JsonRecord,
1450
- flags?: Record<string, string | boolean>,
1451
- ): Promise<LocalEvent> {
1452
- await ensureLocalProject(root);
1453
- return withEventLock(root, () => appendLocalEventUnlocked(root, type, payload, flags));
1454
- }
1455
-
1456
- function renderSeatCard(seat: BootstrapSeat): string {
1457
- return [
1458
- 'schema: seat-model-card.v0',
1459
- `seat_id: ${seat.seatId}`,
1460
- `display_name: ${seat.displayName}`,
1461
- `operator_principal: ${seat.operatorPrincipal}`,
1462
- 'execution_surface: local-etiquette',
1463
- `seat_type: ${seat.seatType}`,
1464
- `model_ref: ${seat.modelRef}`,
1465
- 'model_posture_ref: docs/work/onboarding/local-etiquette-canary.md',
1466
- 'authority_boundary:',
1467
- ' can_authorize_work: false',
1468
- ' can_close_lane_without_grant: false',
1469
- ' can_merge_or_push_without_grant: false',
1470
- 'allowed_work:',
1471
- yamlList(seat.allowedWork),
1472
- 'forbidden_work:',
1473
- yamlList(seat.forbiddenWork),
1474
- '',
1475
- ].join('\n');
1476
- }
1477
-
1478
- function renderTask(root: string, plan: BootstrapPlan, task: BootstrapTask): string {
1479
- const taskIndex = task.id.slice(plan.taskPrefix.length + 1);
1480
- const reviewFocus = task.reviewFocus ? [
1481
- 'review_focus:',
1482
- yamlList(task.reviewFocus),
1483
- ] : [];
1484
- return [
1485
- 'schema: task-envelope.v0',
1486
- `task_id: ${task.id}`,
1487
- `title: ${task.title}`,
1488
- `mode: ${task.mode}`,
1489
- `owner: ${plan.lead.seatId}`,
1490
- `assigned_to: ${task.assignedTo.seatId}`,
1491
- 'project_ref: .',
1492
- 'startup_docs:',
1493
- ' - AGENTS.md',
1494
- ' - docs/work/BOARD.md',
1495
- ' - docs/work/onboarding/local-etiquette-canary.md',
1496
- ` - docs/work/huddle-cards/${task.id}.yaml`,
1497
- 'allowed_writes:',
1498
- yamlList(taskAllowedWrites(task)),
1499
- 'forbidden_sources:',
1500
- ' - external coordination repos unless explicitly routed',
1501
- ' - raw chat transcripts',
1502
- 'validation:',
1503
- yamlList(task.validation),
1504
- 'execution_strategy:',
1505
- ` strategy: ${taskExecutionStrategy(task)}`,
1506
- ' strategy_policy: executor_selected',
1507
- ' authority_boundary:',
1508
- ' strategy_metadata_grants_authority: false',
1509
- ' can_authorize: false',
1510
- ' can_merge: false',
1511
- ' can_close: false',
1512
- ' can_grant: false',
1513
- ' can_sign: false',
1514
- 'topology_constraints:',
1515
- ' reviewers_read_only: true',
1516
- ' child_agents_cannot_promote: true',
1517
- ' one_writer_per_worktree: true',
1518
- ' runner_swaps_require_lifecycle_preflight: true',
1519
- ' strategy_metadata_grants_authority: false',
1520
- ' topology_grants_authority: false',
1521
- ' can_promote: false',
1522
- ' can_merge: false',
1523
- ' can_close: false',
1524
- ' can_grant: false',
1525
- ' can_sign: false',
1526
- 'expected_return:',
1527
- ` receipt: docs/work/receipts/${task.id}-${task.assignedTo.seatId}.md`,
1528
- ...reviewFocus,
1529
- 'stop_conditions:',
1530
- task.mode === 'product-implementation' ? ' - edit outside allowed_writes required' : ' - product edit requested',
1531
- ' - authority-bearing action requested',
1532
- 'evidence_sink:',
1533
- ` session: session-${task.id}`,
1534
- ` runbook: docs/work/sessions/session-${task.id}/RUNBOOK.md`,
1535
- 'authority_boundary:',
1536
- ' can_authorize: false',
1537
- ' can_merge: false',
1538
- ' can_close: false',
1539
- ' can_write_global_ledger: false',
1540
- `# task_index: ${taskIndex}`,
1541
- '',
1542
- ].join('\n');
1543
- }
1544
-
1545
- function renderHuddleCard(root: string, plan: BootstrapPlan, task: BootstrapTask): string {
1546
- return [
1547
- 'schema_version: huddle-card.v1',
1548
- `repo: ${safeName(root.split('/').pop() ?? 'repo')}`,
1549
- 'repo_path: .',
1550
- `lane: ${task.id}`,
1551
- `task_id: ${task.id}`,
1552
- 'status: open',
1553
- `mode: ${task.mode}`,
1554
- 'startup_docs:',
1555
- ' - AGENTS.md',
1556
- ' - docs/work/BOARD.md',
1557
- ' - docs/work/onboarding/local-etiquette-canary.md',
1558
- ` - docs/work/tasks/${task.id}.yaml`,
1559
- 'allowed_writes:',
1560
- yamlList(taskAllowedWrites(task)),
1561
- 'forbidden_sources:',
1562
- ' - external coordination repos unless explicitly routed',
1563
- ' - raw chat transcripts',
1564
- 'validation:',
1565
- yamlList(task.validation),
1566
- `current_owner: ${task.assignedTo.seatId}`,
1567
- `next_owner: ${task.assignedTo.seatId}`,
1568
- 'expires_or_refresh_after: 2099-01-01T00:00:00Z',
1569
- `task_packet: docs/work/tasks/${task.id}.yaml`,
1570
- `evidence_sink: docs/work/sessions/session-${task.id}/RUNBOOK.md`,
1571
- '',
1572
- ].join('\n');
1573
- }
1574
-
1575
- function renderWorkspacePrincipal(root: string, plan: BootstrapPlan): string {
1576
- const notes = [
1577
- `${plan.lead.seatId} is the local orchestration lead.`,
1578
- ...(plan.reviewers.length > 0
1579
- ? [`${plan.reviewers.map((seat) => seat.seatId).join(', ')} review or pressure-test the lead's work.`]
1580
- : []),
1581
- ...(plan.bootstrapSeat ? [`${plan.bootstrapSeat.seatId} is bootstrap provenance only after handoff.`] : []),
1582
- ];
1583
- return `${JSON.stringify({
1584
- schema: 'workspace-principal.v0',
1585
- workspace: root.split('/').pop() ?? 'workspace',
1586
- principal_seat: plan.lead.seatId,
1587
- operator_principal: plan.lead.operatorPrincipal,
1588
- execution_surface: 'local-etiquette',
1589
- bootstrap_profile: plan.profile,
1590
- authority_model: {
1591
- scope: 'repo-local',
1592
- source_of_truth: "this repository's .etiquette and docs/work surfaces",
1593
- global_bus_authority: false,
1594
- can_delegate_tasks: true,
1595
- delegation_grants_merge: false,
1596
- delegation_grants_close: false,
1597
- delegation_grants_push: false,
1598
- },
1599
- notes,
1600
- }, null, 2)}\n`;
1601
- }
1602
-
1603
- function renderOnboarding(plan: BootstrapPlan): string {
1604
- return [
1605
- '# Local Etiquette Onboarding',
1606
- '',
1607
- 'Start with `AGENTS.md` and `docs/work/BOARD.md`. Use the runbook and manuals',
1608
- 'after pickup, when a specific task envelope is active.',
1609
- '',
1610
- '## Local Lead',
1611
- '',
1612
- `\`${plan.lead.seatId}\` is the repo-local orchestration lead for this workspace.`,
1613
- ...plan.reviewers.map((seat) => `\`${seat.seatId}\` is a review/pressure-test seat.`),
1614
- ...(plan.bootstrapSeat ? [`\`${plan.bootstrapSeat.seatId}\` is bootstrap provenance only after lead handoff.`] : []),
1615
- '',
1616
- '## Runtime Occupant',
1617
- '',
1618
- `Current runtime occupant: \`${plan.onboarding.runtime}\`.`,
1619
- plan.onboarding.runtimeCaption,
1620
- 'Changing runtime does not change the seat, task envelope, or authority.',
1621
- '',
1622
- '## First Write Boundary',
1623
- '',
1624
- 'The first implementation lane may write only:',
1625
- yamlList(plan.onboarding.writeScope),
1626
- 'Governance surfaces such as `.etiquette/**` and `docs/work/tasks/**` are generated by the tool and are not app-code scratch space.',
1627
- '',
1628
- '## Boundaries',
1629
- '',
1630
- '- Use this repository\'s `.etiquette` and `docs/work/*` only.',
1631
- '- Do not use an external ledger for routine task pickup, execution notes, diagnostics, or receipts.',
1632
- '- Session runbooks are the bounded evidence sink for task-level detail.',
1633
- '- The task card controls allowed writes.',
1634
- '- No seat may merge, push, close, promote, or grant authority from readiness alone.',
1635
- '',
1636
- ].join('\n');
1637
- }
1638
-
1639
- function renderAgents(plan: BootstrapPlan): string {
1640
- return [
1641
- '# AGENTS.md',
1642
- '',
1643
- '## Local Etiquette Workspace',
1644
- '',
1645
- `- Principal seat: \`${plan.lead.seatId}\``,
1646
- ...(plan.reviewers.length > 0 ? [`- Review seats: ${plan.reviewers.map((seat) => `\`${seat.seatId}\``).join(', ')}`] : []),
1647
- ...(plan.bootstrapSeat ? [`- Bootstrap provenance seat: \`${plan.bootstrapSeat.seatId}\``] : []),
1648
- '- Board: `docs/work/BOARD.md`',
1649
- '- Local event log: `.etiquette/events/local.ndjson`',
1650
- '',
1651
- '## Start Every Session',
1652
- '',
1653
- '1. Run `etiquette whereami --project . --seat <seat> --task <task>`.',
1654
- '2. Read `docs/work/BOARD.md`.',
1655
- '3. Run `etiquette join --project . --seat <seat>`.',
1656
- '4. Run the generated pickup command with `--expect-project-id <project_id>`.',
1657
- '5. Then read `docs/work/runbooks/SESSION_PICKUP.md` for the active task.',
1658
- '6. After interrupted sessions, cwd/worktree ambiguity, role changes, or vendor swaps, rerun `whereami`, `doctor`, and `notify poll`.',
1659
- '',
1660
- 'Detailed references, when needed:',
1661
- '- `docs/work/manuals/SEAT_OPERATING_MANUAL.md`',
1662
- '- `docs/work/cookbooks/ADD_A_LANE.md`',
1663
- '',
1664
- 'The task envelope and session runbook are the active work contract.',
1665
- '',
1666
- '## Boundary',
1667
- '',
1668
- '- Use this repository\'s `.etiquette` and `docs/work/*`.',
1669
- '- Do not use an external ledger for routine task execution.',
1670
- '- Do not merge, push, close, promote, or grant authority from readiness alone.',
1671
- '',
1672
- ].join('\n');
1673
- }
1674
-
1675
- function renderBoard(plan: BootstrapPlan): string {
1676
- return [
1677
- '# Local Etiquette Board',
1678
- '',
1679
- '## Local Lead',
1680
- '',
1681
- `- \`${plan.lead.seatId}\``,
1682
- ` - Operator principal: \`${plan.lead.operatorPrincipal}\``,
1683
- ' - Scope: repo-local orchestration.',
1684
- ' - Principal record: `docs/work/workspace-principal.v0.json`',
1685
- ...plan.reviewers.flatMap((seat) => [
1686
- `- \`${seat.seatId}\``,
1687
- ` - Operator principal: \`${seat.operatorPrincipal}\``,
1688
- ` - Role: ${seat.allowedWork[1] ?? 'review/pressure-test'}.`,
1689
- ]),
1690
- ...(plan.bootstrapSeat ? [
1691
- `- \`${plan.bootstrapSeat.seatId}\``,
1692
- ` - Operator principal: \`${plan.bootstrapSeat.operatorPrincipal}\``,
1693
- ' - Role: bootstrap provenance only after handoff.',
1694
- ] : []),
1695
- '',
1696
- '## Active Tasks',
1697
- '',
1698
- ...plan.tasks.map((task) => `- \`${task.id}\`: ${task.title} for \`${task.assignedTo.seatId}\``),
1699
- '',
1700
- '## Boundary',
1701
- '',
1702
- '- Repo-local read model only.',
1703
- '- No external-ledger traffic for routine execution.',
1704
- '- No merge, push, close, promote, or grants from readiness.',
1705
- '',
1706
- ].join('\n');
1707
- }
1708
-
1709
- function renderSeatManual(plan: BootstrapPlan): string {
1710
- return [
1711
- '# Seat Operating Manual',
1712
- '',
1713
- '## Roles',
1714
- '',
1715
- `- \`${plan.lead.seatId}\`: routes local work, updates board/task evidence, and performs assigned implementation.`,
1716
- ...plan.reviewers.map((seat) => `- \`${seat.seatId}\`: reviews and challenges the lead's returns.`),
1717
- ...(plan.bootstrapSeat ? [`- \`${plan.bootstrapSeat.seatId}\`: records bootstrap provenance only after handoff.`] : []),
1718
- '',
1719
- '## Authority',
1720
- '',
1721
- 'Readiness is not authority. These seats cannot merge, push, close, promote, or',
1722
- 'grant authority unless a later explicit decision says so.',
1723
- '',
1724
- '## Lifecycle Preflight',
1725
- '',
1726
- 'After interrupted sessions, cwd/worktree ambiguity, role changes, or',
1727
- 'vendor swaps, rerun `whereami`, `doctor`, and `notify poll` before pickup.',
1728
- 'Lifecycle output is readiness evidence only; the task envelope remains the',
1729
- 'work authority.',
1730
- '',
1731
- '## Evidence',
1732
- '',
1733
- 'Use local sessions for detail:',
1734
- '',
1735
- ...plan.tasks.map((task) => `- \`docs/work/sessions/session-${task.id}/RUNBOOK.md\``),
1736
- '',
1737
- 'Use local receipts for completion summaries:',
1738
- '',
1739
- ...plan.tasks.map((task) => `- \`docs/work/receipts/${task.id}-${task.assignedTo.seatId}.md\``),
1740
- '',
1741
- ].join('\n');
1742
- }
1743
-
1744
- function renderSessionPickup(plan: BootstrapPlan): string {
1745
- return [
1746
- '# Session Pickup Runbook',
1747
- '',
1748
- '## Before Pickup',
1749
- '',
1750
- '1. Read `AGENTS.md` and `docs/work/BOARD.md`.',
1751
- '2. Read `docs/work/manuals/SEAT_OPERATING_MANUAL.md`.',
1752
- '3. After interrupted sessions, cwd/worktree ambiguity, role changes, or vendor swaps, rerun `whereami`, `doctor`, and `notify poll`.',
1753
- '4. Treat hooks and readiness as evidence only, not authority.',
1754
- '',
1755
- ...plan.tasks.flatMap((task) => [
1756
- `## ${task.assignedTo.displayName}`,
1757
- '',
1758
- '```bash',
1759
- 'ETQ_BIN="${ETQ_BIN:-etiquette}"',
1760
- `"$ETQ_BIN" notify poll --project . --seat ${task.assignedTo.seatId} --text`,
1761
- `"$ETQ_BIN" console board --project . --seat ${task.assignedTo.seatId} --text`,
1762
- `"$ETQ_BIN" notify feed --project . --seat ${task.assignedTo.seatId} --text`,
1763
- `"$ETQ_BIN" bus inbox --addressee ${task.assignedTo.seatId} --project . --text`,
1764
- `"$ETQ_BIN" session note --project . --session session-${task.id} --section note --message "Started pickup."`,
1765
- '```',
1766
- '',
1767
- `Work from \`docs/work/tasks/${task.id}.yaml\`.`,
1768
- `Write task detail to \`docs/work/sessions/session-${task.id}/RUNBOOK.md\`.`,
1769
- task.mode === 'read-only-audit' ? 'Do not edit product files unless a later task envelope allows it.' : 'Stay inside the task envelope allowed writes.',
1770
- '',
1771
- 'After writing the receipt, post the local return so the next seat can see it:',
1772
- '',
1773
- '```bash',
1774
- 'ETQ_BIN="${ETQ_BIN:-etiquette}"',
1775
- `"$ETQ_BIN" post --project . --role ${localReturnRole(task)} --lane ${task.id} --from ${task.assignedTo.seatId} --to ${localReturnTarget(plan, task)} --emit`,
1776
- `"$ETQ_BIN" notify feed --project . --seat ${localReturnTarget(plan, task)} --text`,
1777
- '```',
1778
- '',
1779
- ]),
1780
- ].join('\n');
1781
- }
1782
-
1783
- function renderAddLaneCookbook(plan: BootstrapPlan): string {
1784
- return [
1785
- '# Add A Local Lane',
1786
- '',
1787
- '1. Create a task envelope under `docs/work/tasks/<task-id>.yaml`.',
1788
- '2. Create a huddle card under `docs/work/huddle-cards/<task-id>.yaml`.',
1789
- '3. Create a bounded session:',
1790
- '',
1791
- '```bash',
1792
- 'ETQ_BIN="${ETQ_BIN:-etiquette}"',
1793
- `"$ETQ_BIN" session create --project . --seat ${plan.lead.seatId} --task <task-id> --session-id session-<task-id> --operator ${plan.lead.operatorPrincipal} --owner ${plan.lead.seatId}`,
1794
- '```',
1795
- '',
1796
- '4. Subscribe the target seat:',
1797
- '',
1798
- '```bash',
1799
- 'ETQ_BIN="${ETQ_BIN:-etiquette}"',
1800
- '"$ETQ_BIN" notify subscribe --project . --seat <seat-id> --lane <task-id> --roles task-bundle,implementation-return',
1801
- '```',
1802
- '',
1803
- '5. Poll the local wake queue:',
1804
- '',
1805
- '```bash',
1806
- 'ETQ_BIN="${ETQ_BIN:-etiquette}"',
1807
- '"$ETQ_BIN" notify poll --project . --seat <seat-id> --text',
1808
- '```',
1809
- '',
1810
- '6. Post the local packet:',
1811
- '',
1812
- '```bash',
1813
- 'ETQ_BIN="${ETQ_BIN:-etiquette}"',
1814
- `"$ETQ_BIN" post --project . --role task-bundle --lane <task-id> --from ${plan.lead.seatId} --to <seat-id> --mode product-implementation --next-owner <seat-id> --emit`,
1815
- '```',
1816
- '',
1817
- 'Keep routine execution in this repo. Do not write an external ledger for local task',
1818
- 'pickup, retries, diagnostics, or runbook detail.',
1819
- '',
1820
- ].join('\n');
1821
- }
1822
-
1823
- async function readStructured(path: string): Promise<unknown> {
1824
- const raw = await readFile(path, 'utf-8');
1825
- if (path.endsWith('.json')) return JSON.parse(raw) as unknown;
1826
- return parseYaml(raw) as unknown;
1827
- }
1828
-
1829
- function validateSeatCardObject(card: JsonRecord): string {
1830
- if (card.schema !== 'seat-model-card.v0') throw new Error('seat card schema must be seat-model-card.v0');
1831
- const seatId = stringField(card, 'seat_id');
1832
- if (!seatId) throw new Error('seat_id is required');
1833
- const boundary = asRecord(card.authority_boundary, 'authority_boundary');
1834
- for (const field of FALSE_AUTHORITY_FIELDS) {
1835
- if (boolField(boundary, field) !== false) {
1836
- throw new Error(`authority_boundary.${field} must be false`);
1837
- }
1838
- }
1839
- return seatId;
1840
- }
1841
-
1842
- async function validateSeatCard(path: string): Promise<string> {
1843
- return validateSeatCardObject(asRecord(await readStructured(path), 'seat card'));
1844
- }
1845
-
1846
- async function enrollmentPath(root: string, seat: string): Promise<string> {
1847
- await ensureLocalProject(root);
1848
- return join(root, '.etiquette', 'roster', 'enrollments', `${safeName(seat)}.json`);
1849
- }
1850
-
1851
- async function subscriptionPath(root: string, seat: string): Promise<string> {
1852
- await ensureLocalProject(root);
1853
- return join(root, '.etiquette', 'notify', 'subscriptions', `${safeName(seat)}.json`);
1854
- }
1855
-
1856
- async function wakeQueuePath(root: string, seat: string): Promise<string> {
1857
- await ensureLocalProject(root);
1858
- return join(root, '.etiquette', 'notify', 'queues', `${safeName(seat)}.json`);
1859
- }
1860
-
1861
- function parseOptionalPositiveInt(raw: string | boolean | undefined, key: string, fallback: number): number {
1862
- if (raw === undefined || raw === false) return fallback;
1863
- if (raw === true) throw new Error(`--${key} must be an integer`);
1864
- const n = Number.parseInt(raw, 10);
1865
- if (!Number.isFinite(n) || n < 0) throw new Error(`--${key} must be a non-negative integer`);
1866
- return n;
1867
- }
1868
-
1869
- async function listJsonFiles(dir: string): Promise<string[]> {
1870
- if (!existsSync(dir)) return [];
1871
- const entries = await readdir(dir, { withFileTypes: true });
1872
- return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).map((entry) => join(dir, entry.name));
1873
- }
1874
-
1875
- function renderEvents(events: LocalEvent[]): string {
1876
- if (events.length === 0) return 'no local events';
1877
- return events.map((event) => {
1878
- const parts = [
1879
- `${event.event_ref}`,
1880
- event.type,
1881
- typeof event.lane === 'string' ? `lane=${event.lane}` : undefined,
1882
- typeof event.from === 'string' ? `from=${event.from}` : undefined,
1883
- typeof event.to === 'string' ? `to=${event.to}` : undefined,
1884
- typeof event.next_owner === 'string' ? `next_owner=${event.next_owner}` : undefined,
1885
- ].filter(Boolean);
1886
- return parts.join(' ');
1887
- }).join('\n');
1888
- }
1889
-
1890
- function busViewFormat(flags: Record<string, string | boolean>): 'text' | 'json' | 'markdown' {
1891
- const format = optionalString(flags, 'format') ?? (flags.json === true ? 'json' : 'text');
1892
- if (format !== 'text' && format !== 'json' && format !== 'markdown') {
1893
- throw new Error('--format must be one of: text, json, markdown');
1894
- }
1895
- return format;
1896
- }
1897
-
1898
- function busViewKind(flags: Record<string, string | boolean>): LocalLedgerViewKind {
1899
- const kind = optionalString(flags, 'kind') ?? 'all';
1900
- if (kind !== 'all' && kind !== 'events' && kind !== 'packets') {
1901
- throw new Error('--kind must be one of: all, events, packets');
1902
- }
1903
- return kind;
1904
- }
1905
-
1906
- function timelineScope(flags: Record<string, string | boolean>): TimelineScope {
1907
- const scope = optionalString(flags, 'scope') ?? 'project';
1908
- if (scope !== 'project' && scope !== 'seat' && scope !== 'lane' && scope !== 'principal') {
1909
- throw new Error('--scope must be one of: project, seat, lane, principal');
1910
- }
1911
- return scope;
1912
- }
1913
-
1914
- type ReturnEnforcementTier = 'local-advisory' | 'remote-runner-hard-gate';
1915
-
1916
- interface ReturnCheckResult {
1917
- schema: 'return-enforcement-check.v0';
1918
- task: string;
1919
- seat: string;
1920
- seat_alias: string | null;
1921
- expected_roles: ('implementation-return' | 'review-return')[];
1922
- enforcement_tier: ReturnEnforcementTier;
1923
- verdict: ReturnCheckVerdict;
1924
- completion_gate_hard: boolean;
1925
- matched_return_packets: ReturnType<typeof detectReturnPackets>['matched_return_packets'];
1926
- posted_return_packets: ReturnType<typeof detectReturnPackets>['posted_return_packets'];
1927
- latest_event_ref: string;
1928
- event_log_hash: string;
1929
- advisory: string;
1930
- authority_boundary: {
1931
- check_can_authorize: false;
1932
- check_can_execute: false;
1933
- check_can_write: false;
1934
- check_can_post_return: false;
1935
- check_can_merge: false;
1936
- check_can_close: false;
1937
- check_can_grant: false;
1938
- check_can_write_global_ledger: false;
1939
- };
1940
- }
1941
-
1942
- function boolFlag(flags: Record<string, string | boolean>, key: string): boolean {
1943
- const value = flags[key];
1944
- return value === true || value === 'true' || value === '1' || value === 'yes';
1945
- }
1946
-
1947
- function returnCheckTier(flags: Record<string, string | boolean>): ReturnEnforcementTier {
1948
- if (boolFlag(flags, 'remote-runner')) return 'remote-runner-hard-gate';
1949
- const tier = optionalString(flags, 'tier') ?? 'local-advisory';
1950
- if (tier !== 'local-advisory' && tier !== 'remote-runner-hard-gate') {
1951
- throw new Error('--tier must be one of: local-advisory, remote-runner-hard-gate');
1952
- }
1953
- return tier;
1954
- }
1955
-
1956
- function expectedReturnRoles(flags: Record<string, string | boolean>): ReturnRole[] {
1957
- const role = optionalString(flags, 'role') ?? 'any';
1958
- if (role === 'any') return ['implementation-return', 'review-return'];
1959
- if (role === 'implementation-return' || role === 'review-return') return [role];
1960
- throw new Error('--role must be one of: any, implementation-return, review-return');
1961
- }
1962
-
1963
- function renderReturnCheck(result: ReturnCheckResult): string {
1964
- return [
1965
- `return_check: ${result.verdict}`,
1966
- `task: ${result.task}`,
1967
- `seat: ${result.seat}`,
1968
- result.seat_alias ? `seat_alias: ${result.seat_alias} -> ${result.seat}` : null,
1969
- `expected_roles: ${result.expected_roles.join(',')}`,
1970
- `enforcement_tier: ${result.enforcement_tier}`,
1971
- `completion_gate_hard: ${String(result.completion_gate_hard)}`,
1972
- `matched_return_packets: ${result.matched_return_packets.length}`,
1973
- `posted_return_packets: ${result.posted_return_packets.length}`,
1974
- `latest_event_ref: ${result.latest_event_ref}`,
1975
- `event_log_hash: ${result.event_log_hash}`,
1976
- `advisory: ${result.advisory}`,
1977
- 'authority_boundary: read-only check; cannot authorize, execute, write, post returns, merge, close, grant, or write global ledger',
1978
- ].filter((line): line is string => line !== null).join('\n');
1979
- }
1980
-
1981
- function queueMatch(event: LocalEvent, seat: string, lanes: Set<string>): WakeQueueItem['match'] | null {
1982
- if (event.to === seat) return 'direct';
1983
- if (event.next_owner === seat) return 'next_owner';
1984
- if (typeof event.lane === 'string' && lanes.has(event.lane)) return 'subscription';
1985
- return null;
1986
- }
1987
-
1988
- function buildWakeQueueItem(event: LocalEvent, match: WakeQueueItem['match']): WakeQueueItem {
1989
- return {
1990
- seq: event.seq,
1991
- event_ref: event.event_ref,
1992
- created_at: event.created_at,
1993
- type: event.type,
1994
- lane: typeof event.lane === 'string' ? event.lane : null,
1995
- role: typeof event.role === 'string' ? event.role : null,
1996
- to: typeof event.to === 'string' ? event.to : null,
1997
- next_owner: typeof event.next_owner === 'string' ? event.next_owner : null,
1998
- packet_ref: typeof event.packet_ref === 'string' ? event.packet_ref : null,
1999
- requires_ack: event.requires_ack === true,
2000
- match,
2001
- };
2002
- }
2003
-
2004
- async function buildWakeQueue(
2005
- root: string,
2006
- seat: string,
2007
- flags: Record<string, string | boolean>,
2008
- ): Promise<WakeQueue> {
2009
- assertSharedMemoryScopeId(seat, '--seat');
2010
- const subscriptionsRef = await subscriptionPath(root, seat);
2011
- const subscriptions = await readJsonIfExists<{ subscriptions: JsonRecord[] }>(
2012
- subscriptionsRef,
2013
- { subscriptions: [] },
2014
- );
2015
- const lanes = new Set(subscriptions.subscriptions.map((entry) => stringField(entry, 'lane')).filter((value): value is string => Boolean(value)));
2016
- const afterSeq = parseOptionalPositiveInt(flags['after-seq'], 'after-seq', 0);
2017
- const limit = parseOptionalPositiveInt(flags.limit, 'limit', 50);
2018
- const events = await readEvents(root);
2019
- const items = events
2020
- .filter((event) => event.seq > afterSeq)
2021
- .map((event) => {
2022
- const match = queueMatch(event, seat, lanes);
2023
- return match ? buildWakeQueueItem(event, match) : null;
2024
- })
2025
- .filter((item): item is WakeQueueItem => item !== null)
2026
- .slice(-limit);
2027
-
2028
- return {
2029
- schema: 'remote-wake-queue.v0',
2030
- seat,
2031
- updated_at: nowIso(flags),
2032
- source: {
2033
- event_log_ref: '.etiquette/events/local.ndjson',
2034
- after_seq: afterSeq,
2035
- max_seq_seen: events.reduce((max, event) => Math.max(max, event.seq), 0),
2036
- subscriptions_ref: relativeRef(root, subscriptionsRef),
2037
- },
2038
- transport: wakeQueueTransport(),
2039
- payload_boundary: wakeQueuePayloadBoundary(),
2040
- items,
2041
- authority_boundary: wakeQueueAuthorityBoundary(),
2042
- };
2043
- }
2044
-
2045
- async function wakeSeatsForEvent(root: string, event: LocalEvent): Promise<string[]> {
2046
- const seats = new Set<string>();
2047
- if (typeof event.to === 'string') seats.add(event.to);
2048
- if (typeof event.next_owner === 'string') seats.add(event.next_owner);
2049
-
2050
- const subscriptionFiles = await listJsonFiles(join(root, '.etiquette', 'notify', 'subscriptions'));
2051
- for (const file of subscriptionFiles) {
2052
- const record = await readJsonIfExists<{ seat?: string; subscriptions?: JsonRecord[] }>(
2053
- file,
2054
- { subscriptions: [] },
2055
- );
2056
- const seat = typeof record.seat === 'string' ? record.seat : null;
2057
- if (!seat) continue;
2058
- const lanes = new Set((record.subscriptions ?? [])
2059
- .map((entry) => stringField(entry, 'lane'))
2060
- .filter((value): value is string => Boolean(value)));
2061
- if (queueMatch(event, seat, lanes)) seats.add(seat);
2062
- }
2063
-
2064
- return [...seats].sort();
2065
- }
2066
-
2067
- async function refreshWakeQueuesForEvent(root: string, event: LocalEvent, flags: Record<string, string | boolean>): Promise<void> {
2068
- for (const seat of await wakeSeatsForEvent(root, event)) {
2069
- const queue = await buildWakeQueue(root, seat, flags);
2070
- await writeJson(await wakeQueuePath(root, seat), queue);
2071
- }
2072
- }
2073
-
2074
- function renderWakeQueue(queue: WakeQueue): string {
2075
- const rows = queue.items.length === 0
2076
- ? ['no queued wake items']
2077
- : queue.items.map((item) => [
2078
- item.event_ref,
2079
- item.type,
2080
- item.lane ? `lane=${item.lane}` : undefined,
2081
- item.role ? `role=${item.role}` : undefined,
2082
- item.to ? `to=${item.to}` : undefined,
2083
- item.next_owner ? `next_owner=${item.next_owner}` : undefined,
2084
- `match=${item.match}`,
2085
- item.requires_ack ? 'requires_ack=true' : undefined,
2086
- item.packet_ref ? `packet_ref=${item.packet_ref}` : undefined,
2087
- ].filter(Boolean).join(' '));
2088
- return [
2089
- `wake_queue: ${queue.seat}`,
2090
- `items: ${queue.items.length}`,
2091
- `after_seq: ${queue.source.after_seq}`,
2092
- `max_seq_seen: ${queue.source.max_seq_seen}`,
2093
- `transport: ${queue.transport.mode}`,
2094
- `pointers_only: ${String(queue.payload_boundary.pointers_only)}`,
2095
- `queue_is_authority: ${String(queue.authority_boundary.queue_is_authority)}`,
2096
- ...rows,
2097
- ].join('\n');
2098
- }
2099
-
2100
- function parseGitHubSignature(signature: string): Buffer {
2101
- const prefix = 'sha256=';
2102
- if (!signature.startsWith(prefix)) {
2103
- throw new Error('--signature-256 must start with sha256=');
2104
- }
2105
- const hex = signature.slice(prefix.length);
2106
- if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
2107
- throw new Error('--signature-256 must be a sha256 hex digest');
2108
- }
2109
- return Buffer.from(hex, 'hex');
2110
- }
2111
-
2112
- function verifyGitHubSignature(rawPayload: Buffer, signature: string, secret: string): void {
2113
- const expected = createHmac('sha256', secret).update(rawPayload).digest();
2114
- const actual = parseGitHubSignature(signature);
2115
- if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
2116
- throw new Error('github webhook signature verification failed');
2117
- }
2118
- }
2119
-
2120
- function commitRef(value: string | undefined, field: string): string | null {
2121
- if (!value) return null;
2122
- if (!/^[0-9a-f]{40}$/i.test(value)) throw new Error(`GitHub webhook ${field} must be a full commit SHA`);
2123
- return value;
2124
- }
2125
-
2126
- function renderGitHubWebhookWake(wake: GitHubWebhookWake | GitHubWebhookPing): string {
2127
- if (wake.schema === 'github-webhook-ping.v0') {
2128
- return [
2129
- `github_webhook_ping: ${wake.repository.full_name}`,
2130
- `event: ${wake.event}`,
2131
- `delivery_id: ${wake.delivery_id}`,
2132
- `signature_verified: ${String(wake.transport.signature_verified)}`,
2133
- `pointers_only: ${String(wake.payload_boundary.pointers_only)}`,
2134
- `notification_is_authority: ${String(wake.authority_boundary.notification_is_authority)}`,
2135
- `next_action: ${wake.next_action}`,
2136
- ].join('\n');
2137
- }
2138
- return [
2139
- `github_webhook_wake: ${wake.repository.full_name}`,
2140
- `event: ${wake.event}`,
2141
- `delivery_id: ${wake.delivery_id}`,
2142
- `ref: ${wake.ref}`,
2143
- `after: ${wake.after}`,
2144
- wake.before ? `before: ${wake.before}` : null,
2145
- `signature_verified: ${String(wake.transport.signature_verified)}`,
2146
- `pointers_only: ${String(wake.payload_boundary.pointers_only)}`,
2147
- `notification_is_authority: ${String(wake.authority_boundary.notification_is_authority)}`,
2148
- `next_action: ${wake.next_action}`,
2149
- ].filter((line): line is string => line !== null).join('\n');
2150
- }
2151
-
2152
- async function buildGitHubWebhookWake(flags: Record<string, string | boolean>): Promise<GitHubWebhookWake | GitHubWebhookPing> {
2153
- const provider = optionalString(flags, 'provider') ?? 'github';
2154
- if (provider !== 'github') throw new Error('--provider must be github');
2155
- const event = optionalString(flags, 'event') ?? 'push';
2156
- if (event !== 'push' && event !== 'ping') throw new Error('W1 supports only GitHub push and ping webhook events');
2157
- const deliveryId = requireString(flags, 'delivery');
2158
- assertSharedMemoryScopeId(deliveryId, '--delivery');
2159
- const signature = requireString(flags, 'signature-256');
2160
- const secretEnv = optionalString(flags, 'secret-env') ?? 'ETIQUETTE_GITHUB_WEBHOOK_SECRET';
2161
- assertSharedMemoryScopeId(secretEnv, '--secret-env');
2162
- const secret = process.env[secretEnv];
2163
- if (!secret) throw new Error(`missing GitHub webhook secret in ${secretEnv}`);
2164
-
2165
- const payloadPath = resolve(requireString(flags, 'payload'));
2166
- const rawPayload = await readFile(payloadPath);
2167
- verifyGitHubSignature(rawPayload, signature, secret);
2168
-
2169
- const payload = asRecord(JSON.parse(rawPayload.toString('utf-8')), 'GitHub webhook payload');
2170
- const repository = asRecord(payload.repository, 'GitHub webhook repository');
2171
- const sender = payload.sender ? asRecord(payload.sender, 'GitHub webhook sender') : null;
2172
- const fullName = stringField(repository, 'full_name');
2173
- if (!fullName) throw new Error('GitHub webhook repository.full_name is required');
2174
- const source = {
2175
- payload_ref: `github://delivery/${deliveryId}`,
2176
- payload_sha256: createHash('sha256').update(rawPayload).digest('hex'),
2177
- signature_header: 'X-Hub-Signature-256' as const,
2178
- delivery_header: 'X-GitHub-Delivery' as const,
2179
- event_header: 'X-GitHub-Event' as const,
2180
- };
2181
- if (event === 'ping') {
2182
- return {
2183
- schema: 'github-webhook-ping.v0',
2184
- provider: 'github',
2185
- event,
2186
- delivery_id: deliveryId,
2187
- received_at: nowIso(flags),
2188
- repository: {
2189
- full_name: fullName,
2190
- html_url: stringField(repository, 'html_url') ?? null,
2191
- default_branch: stringField(repository, 'default_branch') ?? null,
2192
- },
2193
- sender: sender ? (stringField(sender, 'login') ?? null) : null,
2194
- hook_id: payload.hook_id === undefined || payload.hook_id === null ? null : String(payload.hook_id),
2195
- source,
2196
- transport: githubWebhookPingTransport(),
2197
- payload_boundary: githubWebhookPayloadBoundary(),
2198
- authority_boundary: githubWebhookAuthorityBoundary(),
2199
- next_action: 'receiver_acknowledged_github_ping',
2200
- };
2201
- }
2202
- const pusher = payload.pusher ? asRecord(payload.pusher, 'GitHub webhook pusher') : null;
2203
- const ref = stringField(payload, 'ref');
2204
- const after = commitRef(stringField(payload, 'after'), 'after');
2205
- if (!ref) throw new Error('GitHub webhook ref is required');
2206
- if (!after) throw new Error('GitHub webhook after is required');
2207
-
2208
- return {
2209
- schema: 'github-webhook-wake.v0',
2210
- provider: 'github',
2211
- event: 'push',
2212
- delivery_id: deliveryId,
2213
- received_at: nowIso(flags),
2214
- repository: {
2215
- full_name: fullName,
2216
- html_url: stringField(repository, 'html_url') ?? null,
2217
- default_branch: stringField(repository, 'default_branch') ?? null,
2218
- },
2219
- ref,
2220
- before: commitRef(stringField(payload, 'before'), 'before'),
2221
- after,
2222
- compare_url: stringField(payload, 'compare') ?? null,
2223
- pusher: pusher ? (stringField(pusher, 'name') ?? null) : null,
2224
- sender: sender ? (stringField(sender, 'login') ?? null) : null,
2225
- source,
2226
- transport: githubWebhookTransport(),
2227
- payload_boundary: githubWebhookPayloadBoundary(),
2228
- authority_boundary: githubWebhookAuthorityBoundary(),
2229
- next_action: 'receiver_fetch_and_verify_git_ref',
2230
- };
2231
- }
2232
-
2233
- function trashIndexPath(root: string): string {
2234
- return join(root, 'docs', 'work', 'trash', 'TRASH.ndjson');
2235
- }
2236
-
2237
- function escapeRegex(value: string): string {
2238
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2239
- }
2240
-
2241
- function pathPatternMatch(rel: string, pattern: string): boolean {
2242
- if (pattern.endsWith('/**')) return rel.startsWith(pattern.slice(0, -3));
2243
- return rel === pattern;
2244
- }
2245
-
2246
- function hasYamlMarker(text: string, key: string, values: string[]): boolean {
2247
- const valuePattern = values.map(escapeRegex).join('|');
2248
- const re = new RegExp(`(?:^|\\n)\\s*${escapeRegex(key)}\\s*:\\s*(${valuePattern})\\s*(?:\\n|$)`, 'i');
2249
- return re.test(text);
2250
- }
2251
-
2252
- function hasBooleanMarker(text: string, key: string): boolean {
2253
- const re = new RegExp(`(?:^|\\n)\\s*${escapeRegex(key)}\\s*:\\s*true\\s*(?:\\n|$)`, 'i');
2254
- return re.test(text);
2255
- }
2256
-
2257
- function assertKnownReason(reason: string): void {
2258
- if (!['superseded', 'rejected', 'closed', 'duplicate'].includes(reason)) {
2259
- throw new Error('--reason must be one of: superseded, rejected, closed, duplicate');
2260
- }
2261
- }
2262
-
2263
- function protectedReason(rel: string, text: string): string | null {
2264
- const protectedPatterns = [
2265
- '.etiquette/events/**',
2266
- 'docs/work/ledger/**',
2267
- 'docs/work/receipts/**',
2268
- 'docs/work/trash/**',
2269
- 'scripts/release-sign.sh',
2270
- 'scripts/release-checksum.sh',
2271
- ];
2272
- const matched = protectedPatterns.find((pattern) => pathPatternMatch(rel, pattern));
2273
- if (matched) return `protected path pattern: ${matched}`;
2274
-
2275
- if (hasBooleanMarker(text, 'append_only')) return 'content marker append_only:true';
2276
- if (hasYamlMarker(text, 'content_class', [
2277
- 'append-only',
2278
- 'canonical-evidence',
2279
- 'decision',
2280
- 'evidence',
2281
- 'ledger',
2282
- 'release-receipt',
2283
- 'trusted-checksum',
2284
- ])) return 'protected content_class marker';
2285
- if (hasYamlMarker(text, 'role', ['closeout', 'decision', 'review-return', 'implementation-return'])) {
2286
- return 'protected role marker';
2287
- }
2288
- return null;
2289
- }
2290
-
2291
- function retirableClass(rel: string, text: string, reason: string): string | null {
2292
- if (hasBooleanMarker(text, 'retirable')) return 'explicit-retirable-marker';
2293
- if (hasYamlMarker(text, 'content_class', [
2294
- 'draft',
2295
- 'rejected-draft',
2296
- 'resolved-task-envelope',
2297
- 'superseded-proposal',
2298
- ])) return 'content-class-allowlist';
2299
- if (rel.startsWith('docs/work/drafts/') && ['rejected', 'duplicate'].includes(reason)) {
2300
- return 'draft-path-allowlist';
2301
- }
2302
- if (rel.startsWith('docs/work/proposals/') && ['superseded', 'rejected'].includes(reason)
2303
- && hasYamlMarker(text, 'status', ['superseded', 'rejected'])) {
2304
- return 'proposal-status-allowlist';
2305
- }
2306
- if (rel.startsWith('docs/work/tasks/') && reason === 'closed'
2307
- && hasYamlMarker(text, 'status', ['closed', 'resolved'])) {
2308
- return 'resolved-task-envelope-allowlist';
2309
- }
2310
- return null;
2311
- }
2312
-
2313
- function declaresSupersession(text: string, retiredRel: string): boolean {
2314
- const quoted = escapeRegex(retiredRel);
2315
- const singleLine = new RegExp(`(?:^|\\n)\\s*supersedes\\s*:\\s*["']?${quoted}["']?\\s*(?:\\n|$)`);
2316
- const listLine = new RegExp(`(?:^|\\n)\\s*-\\s*["']?${quoted}["']?\\s*(?:\\n|$)`);
2317
- return singleLine.test(text) || (/(?:^|\n)\s*supersedes\s*:\s*\n/.test(text) && listLine.test(text));
2318
- }
2319
-
2320
- function git(root: string, args: string[], allowFailure = false): { status: number; stdout: string; stderr: string } {
2321
- const result = spawnSync('git', args, { cwd: root, encoding: 'utf-8' });
2322
- const status = result.status ?? 1;
2323
- const output = {
2324
- status,
2325
- stdout: result.stdout ?? '',
2326
- stderr: result.stderr ?? '',
2327
- };
2328
- if (!allowFailure && status !== 0) {
2329
- throw new Error(output.stderr.trim() || output.stdout.trim() || `git ${args.join(' ')} failed`);
2330
- }
2331
- return output;
2332
- }
2333
-
2334
- function ensureTrackedCleanPath(root: string, rel: string): void {
2335
- git(root, ['ls-files', '--error-unmatch', '--', rel]);
2336
- const status = git(root, ['status', '--porcelain', '--', rel]).stdout.trim();
2337
- if (status.length > 0) {
2338
- throw new Error(`retire refuses dirty or staged path: ${rel}`);
2339
- }
2340
- }
2341
-
2342
- function assertCleanIfTracked(root: string, rel: string): void {
2343
- const tracked = git(root, ['ls-files', '--error-unmatch', '--', rel], true);
2344
- if (tracked.status !== 0) return;
2345
- const status = git(root, ['status', '--porcelain', '--', rel]).stdout.trim();
2346
- if (status.length > 0) {
2347
- throw new Error(`retire refuses dirty or staged evidence surface: ${rel}`);
2348
- }
2349
- }
2350
-
2351
- function headCommit(root: string): string {
2352
- return git(root, ['rev-parse', 'HEAD']).stdout.trim();
2353
- }
2354
-
2355
- async function readTrashEntries(root: string): Promise<TrashEntry[]> {
2356
- const raw = await readTextIfExists(trashIndexPath(root));
2357
- if (!raw) return [];
2358
- return raw
2359
- .split('\n')
2360
- .map((line) => line.trim())
2361
- .filter(Boolean)
2362
- .map((line) => JSON.parse(line) as TrashEntry);
2363
- }
2364
-
2365
- async function writeTrashEntries(root: string, entries: TrashEntry[]): Promise<void> {
2366
- const path = trashIndexPath(root);
2367
- await mkdir(dirname(path), { recursive: true });
2368
- const seen = new Set<string>();
2369
- const compacted = entries.filter((entry) => {
2370
- if (seen.has(entry.trash_id)) return false;
2371
- seen.add(entry.trash_id);
2372
- return true;
2373
- });
2374
- await writeFile(path, compacted.map((entry) => JSON.stringify(entry)).join('\n') + (compacted.length > 0 ? '\n' : ''));
2375
- }
2376
-
2377
- function renderTrashList(root: string, entries: TrashEntry[]): string {
2378
- if (entries.length === 0) return 'trash: empty';
2379
- return [
2380
- `trash: ${entries.length}`,
2381
- ...entries.map((entry) => `${entry.trash_id} ${entry.reason} ${entry.path} restore_hint=${entry.restore_hint}`),
2382
- `trash_index_ref: ${relativeRef(root, trashIndexPath(root))}`,
2383
- 'trash_index_is_authority: false',
2384
- ].join('\n');
2385
- }
2386
-
2387
- async function retirePath(
2388
- rawPath: string,
2389
- flags: Record<string, string | boolean>,
2390
- ): Promise<void> {
2391
- const root = projectRoot(flags);
2392
- await ensureLocalProject(root);
2393
- const rel = relativeRef(root, resolve(root, rawPath));
2394
- const evidenceRef = requireString(flags, 'evidence-ref');
2395
- const removedBy = requireString(flags, 'removed-by');
2396
- const reason = requireString(flags, 'reason');
2397
- assertKnownReason(reason);
2398
- assertSharedMemoryScopeId(removedBy, '--removed-by');
2399
- assertSharedMemoryText(evidenceRef, '--evidence-ref');
2400
-
2401
- await withEventLock(root, async () => {
2402
- const path = join(root, rel);
2403
- if (!existsSync(path)) throw new Error(`retire target does not exist: ${rel}`);
2404
- const text = await readFile(path, 'utf-8');
2405
- const blocked = protectedReason(rel, text);
2406
- if (blocked) throw new Error(`retire refused protected evidence (${blocked}): ${rel}`);
2407
- const klass = retirableClass(rel, text, reason);
2408
- if (!klass) {
2409
- throw new Error(`retire refused; path is not in the retirable allowlist: ${rel}`);
2410
- }
2411
- let supersededBy: string | null = null;
2412
- if (reason === 'superseded') {
2413
- const superseder = requireString(flags, 'superseded-by');
2414
- supersededBy = relativeRef(root, resolve(root, superseder));
2415
- const supersederText = await readFile(join(root, supersededBy), 'utf-8');
2416
- if (!declaresSupersession(supersederText, rel)) {
2417
- throw new Error(`--superseded-by must declare supersedes: ${rel}`);
2418
- }
2419
- }
2420
-
2421
- ensureTrackedCleanPath(root, rel);
2422
- assertCleanIfTracked(root, '.etiquette/events/local.ndjson');
2423
- assertCleanIfTracked(root, 'docs/work/ledger/LEDGER.md');
2424
- assertCleanIfTracked(root, 'docs/work/trash/TRASH.ndjson');
2425
- const commit = headCommit(root);
2426
- git(root, ['show', `${commit}:${rel}`]);
2427
-
2428
- const removedAt = nowIso(flags);
2429
- const entry: TrashEntry = {
2430
- schema: 'trash-index-entry.v0',
2431
- trash_id: `trash-${removedAt.replace(/[^0-9TZ]/g, '')}-${safeName(rel)}`,
2432
- path: rel,
2433
- removed_at: removedAt,
2434
- removed_by: removedBy,
2435
- reason,
2436
- retirable_class: klass,
2437
- superseded_by: supersededBy,
2438
- evidence_ref: evidenceRef,
2439
- last_commit: commit,
2440
- restore_hint: `git show ${commit}:${rel}`,
2441
- authority_boundary: {
2442
- trash_index_is_authority: false,
2443
- can_close: false,
2444
- can_merge: false,
2445
- can_grant: false,
2446
- },
2447
- };
2448
-
2449
- const indexPath = trashIndexPath(root);
2450
- const previousIndex = await readTextIfExists(indexPath);
2451
- const previousEvents = await readTextIfExists(eventPath(root));
2452
- const previousLedger = await readTextIfExists(ledgerPath(root));
2453
- const entries = await readTrashEntries(root);
2454
- git(root, ['rm', '--', rel]);
2455
- try {
2456
- await writeTrashEntries(root, [...entries, entry]);
2457
- const event = await appendLocalEventUnlocked(root, 'trash_retired', {
2458
- trash_entry: entry,
2459
- authority_boundary: {
2460
- retirement_authorizes_work: false,
2461
- can_close: false,
2462
- can_merge: false,
2463
- can_grant: false,
2464
- },
2465
- }, flags);
2466
- git(root, ['add', '--', relativeRef(root, indexPath), '.etiquette/events/local.ndjson', 'docs/work/ledger/LEDGER.md']);
2467
- if (flags.json) {
2468
- console.log(JSON.stringify({ ...entry, event_ref: event.event_ref, trash_index_ref: relativeRef(root, indexPath) }, null, 2));
2469
- return;
2470
- }
2471
- console.log([
2472
- `trash_retired: ${entry.trash_id}`,
2473
- `path: ${entry.path}`,
2474
- `event: ${event.event_ref}`,
2475
- `trash_index_ref: ${relativeRef(root, indexPath)}`,
2476
- 'trash_index_is_authority: false',
2477
- ].join('\n'));
2478
- } catch (err) {
2479
- if (previousIndex === null) {
2480
- await rm(indexPath, { force: true });
2481
- } else {
2482
- await writeFile(indexPath, previousIndex);
2483
- }
2484
- if (previousEvents !== null) await writeFile(eventPath(root), previousEvents);
2485
- if (previousLedger !== null) await writeFile(ledgerPath(root), previousLedger);
2486
- git(root, ['checkout', '--', rel], true);
2487
- throw err;
2488
- }
2489
- });
2490
- }
2491
-
2492
- async function dispatchTrash(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
2493
- const verb = positionals[1];
2494
- const root = projectRoot(flags);
2495
- await ensureLocalProject(root);
2496
-
2497
- if (verb === 'retire') {
2498
- await retirePath(requirePositional(positionals, 2, 'path'), flags);
2499
- return;
2500
- }
2501
-
2502
- if (verb === 'list') {
2503
- const entries = await readTrashEntries(root);
2504
- console.log(flags.json ? JSON.stringify({ schema: 'trash-index.v0', entries, trash_index_ref: relativeRef(root, trashIndexPath(root)) }, null, 2) : renderTrashList(root, entries));
2505
- return;
2506
- }
2507
-
2508
- if (verb === 'show') {
2509
- const trashId = requirePositional(positionals, 2, 'trash-id');
2510
- const entry = (await readTrashEntries(root)).find((candidate) => candidate.trash_id === trashId);
2511
- if (!entry) throw new Error(`trash entry not found: ${trashId}`);
2512
- console.log(flags.json ? JSON.stringify(entry, null, 2) : [
2513
- `trash_id: ${entry.trash_id}`,
2514
- `path: ${entry.path}`,
2515
- `reason: ${entry.reason}`,
2516
- `superseded_by: ${entry.superseded_by ?? 'none'}`,
2517
- `restore_hint: ${entry.restore_hint}`,
2518
- 'trash_index_is_authority: false',
2519
- ].join('\n'));
2520
- return;
2521
- }
2522
-
2523
- if (verb === 'restore') {
2524
- const trashId = requirePositional(positionals, 2, 'trash-id');
2525
- const entries = await readTrashEntries(root);
2526
- const entry = entries.find((candidate) => candidate.trash_id === trashId);
2527
- if (!entry) throw new Error(`trash entry not found: ${trashId}`);
2528
- const targetRel = optionalString(flags, 'to') ? relativeRef(root, resolve(root, optionalString(flags, 'to') as string)) : entry.path;
2529
- const targetPath = join(root, targetRel);
2530
- if (existsSync(targetPath)) throw new Error(`restore refuses to overwrite existing path: ${targetRel}`);
2531
- const content = git(root, ['show', `${entry.last_commit}:${entry.path}`]).stdout;
2532
- await mkdir(dirname(targetPath), { recursive: true });
2533
- await writeFile(targetPath, content);
2534
- const event = await appendLocalEvent(root, 'trash_restored', {
2535
- trash_id: entry.trash_id,
2536
- restored_path: targetRel,
2537
- source_path: entry.path,
2538
- restore_is_content_only: true,
2539
- authority_boundary: {
2540
- restore_authorizes_work: false,
2541
- can_close: false,
2542
- can_merge: false,
2543
- can_grant: false,
2544
- },
2545
- }, flags);
2546
- console.log(flags.json
2547
- ? JSON.stringify({ trash_id: entry.trash_id, restored_path: targetRel, event_ref: event.event_ref, restore_is_content_only: true }, null, 2)
2548
- : [`trash_restored: ${entry.trash_id}`, `path: ${targetRel}`, `event: ${event.event_ref}`, 'restore_is_content_only: true'].join('\n'));
2549
- return;
2550
- }
2551
-
2552
- if (verb === 'rebuild') {
2553
- const events = await readEvents(root);
2554
- const entries = events
2555
- .map((event) => asRecord(event, 'local event').trash_entry)
2556
- .filter((entry): entry is TrashEntry => typeof entry === 'object' && entry !== null && (entry as TrashEntry).schema === 'trash-index-entry.v0');
2557
- await writeTrashEntries(root, entries);
2558
- console.log(flags.json
2559
- ? JSON.stringify({ schema: 'trash-rebuild-result.v0', rebuilt_entries: entries.length, trash_index_ref: relativeRef(root, trashIndexPath(root)) }, null, 2)
2560
- : [`trash_rebuild: ok entries=${entries.length}`, `trash_index_ref: ${relativeRef(root, trashIndexPath(root))}`].join('\n'));
2561
- return;
2562
- }
2563
-
2564
- throw new Error('trash verb must be one of: retire, list, show, restore, rebuild');
2565
- }
2566
-
2567
- async function dispatchInit(flags: Record<string, string | boolean>): Promise<void> {
2568
- const root = projectRoot(flags);
2569
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
2570
- await ensureLocalProject(root);
2571
- const event = await appendLocalEvent(root, 'workspace_initialized', {
2572
- project_ref: '.',
2573
- project_id: project.project_id,
2574
- authority_boundary: {
2575
- can_authorize_work: false,
2576
- can_merge: false,
2577
- can_close: false,
2578
- },
2579
- }, flags);
2580
- console.log(`initialized: ${root}`);
2581
- console.log(`project_id: ${project.project_id}`);
2582
- console.log(`event: ${event.event_ref}`);
2583
- }
2584
-
2585
- async function enrollBootstrapSeat(
2586
- root: string,
2587
- seat: BootstrapSeat,
2588
- projectId: string,
2589
- flags: Record<string, string | boolean>,
2590
- ): Promise<LocalEvent> {
2591
- const cardRef = `docs/work/seat-cards/${seat.seatId}.yaml`;
2592
- const cardPath = join(root, cardRef);
2593
- const cardSeat = await validateSeatCard(cardPath);
2594
- if (cardSeat !== seat.seatId) throw new Error(`seat card seat_id ${cardSeat} does not match ${seat.seatId}`);
2595
- const enrollment = {
2596
- schema: 'seat-enrollment.v0',
2597
- seat_id: seat.seatId,
2598
- card_ref: cardRef,
2599
- operator_principal: seat.operatorPrincipal,
2600
- execution_seat: 'local-etiquette',
2601
- as_role: seat.asRole,
2602
- authority_scope: seat.authorityScope,
2603
- mode: seat.mode,
2604
- orientation_ref: seat.authorityScope === 'bootstrap-provenance' ? 'docs/work/workspace-principal.v0.json' : 'docs/work/onboarding/local-etiquette-canary.md',
2605
- ready: false,
2606
- lifecycle_schema: SEAT_LIFECYCLE_SCHEMA,
2607
- lifecycle_state: 'enrolled',
2608
- workflow_id: lifecycleWorkflowId(projectId),
2609
- runner_binding: runnerBinding({
2610
- runnerProvider: seat.modelRef,
2611
- executionSurface: 'local-etiquette',
2612
- modelId: seat.modelId,
2613
- modelTier: seat.modelTier,
2614
- capabilitySource: 'config',
2615
- }),
2616
- authority_granted: false,
2617
- lifecycle_authority_boundary: {
2618
- lifecycle_grants_authority: false,
2619
- readiness_authorizes_work: false,
2620
- can_merge: false,
2621
- can_close: false,
2622
- can_grant: false,
2623
- },
2624
- enrolled_at: nowIso(flags),
2625
- authority_boundary: {
2626
- enrollment_authorizes_work: false,
2627
- can_merge: false,
2628
- can_close: false,
2629
- },
2630
- };
2631
- await writeJson(await enrollmentPath(root, seat.seatId), enrollment);
2632
- return appendLocalEvent(root, 'seat_enrolled', enrollment, flags);
2633
- }
2634
-
2635
- async function readyBootstrapSeat(
2636
- root: string,
2637
- seat: BootstrapSeat,
2638
- projectId: string,
2639
- flags: Record<string, string | boolean>,
2640
- ): Promise<LocalEvent> {
2641
- const path = await enrollmentPath(root, seat.seatId);
2642
- const enrollment = await readJsonIfExists<JsonRecord | null>(path, null);
2643
- if (!enrollment) throw new Error(`seat is not enrolled: ${seat.seatId}`);
2644
- const orientationRef = seat.authorityScope === 'bootstrap-provenance' ? 'docs/work/workspace-principal.v0.json' : 'docs/work/onboarding/local-etiquette-canary.md';
2645
- const readyAt = nowIso(flags);
2646
- const updated = {
2647
- ...enrollment,
2648
- ...lifecycleReadyPatch({
2649
- projectId,
2650
- at: readyAt,
2651
- runnerBinding: runnerBinding({
2652
- runnerProvider: seat.modelRef,
2653
- executionSurface: 'local-etiquette',
2654
- modelId: seat.modelId,
2655
- modelTier: seat.modelTier,
2656
- capabilitySource: 'config',
2657
- }),
2658
- }),
2659
- ack_ref: `docs/work/seat-cards/${seat.seatId}.yaml`,
2660
- orientation_ref: orientationRef,
2661
- };
2662
- await writeJson(path, updated);
2663
- return appendLocalEvent(root, 'seat_ready', {
2664
- seat_id: seat.seatId,
2665
- lifecycle_state: 'ready',
2666
- workflow_id: lifecycleWorkflowId(projectId),
2667
- ack_ref: updated.ack_ref,
2668
- orientation_ref: updated.orientation_ref,
2669
- authority_granted: false,
2670
- authority_boundary: {
2671
- readiness_authorizes_work: false,
2672
- can_merge: false,
2673
- can_close: false,
2674
- },
2675
- }, flags);
2676
- }
2677
-
2678
- async function subscribeBootstrapSeat(
2679
- root: string,
2680
- seat: BootstrapSeat,
2681
- task: BootstrapTask,
2682
- flags: Record<string, string | boolean>,
2683
- ): Promise<void> {
2684
- const current = await readJsonIfExists<{ schema: string; seat: string; subscriptions: JsonRecord[] }>(
2685
- await subscriptionPath(root, seat.seatId),
2686
- { schema: 'seat-subscriptions.v0', seat: seat.seatId, subscriptions: [] },
2687
- );
2688
- current.subscriptions.push({
2689
- lane: task.id,
2690
- roles: task.mode === 'product-implementation' ? ['task-bundle', 'implementation-return'] : ['task-bundle', 'review-return'],
2691
- subscribed_at: nowIso(flags),
2692
- });
2693
- await writeJson(await subscriptionPath(root, seat.seatId), current);
2694
- }
2695
-
2696
- async function postBootstrapPacket(
2697
- root: string,
2698
- plan: BootstrapPlan,
2699
- task: BootstrapTask,
2700
- flags: Record<string, string | boolean>,
2701
- ): Promise<LocalEvent> {
2702
- const packet = {
2703
- schema: 'local-packet.v0',
2704
- role: 'task-bundle',
2705
- lane: task.id,
2706
- from: plan.lead.seatId,
2707
- to: task.assignedTo.seatId,
2708
- verdict: 'routed',
2709
- packet_tier: '3',
2710
- mode: task.mode,
2711
- next_owner: task.assignedTo.seatId,
2712
- created_at: nowIso(flags),
2713
- changes_state: true,
2714
- authority_boundary: {
2715
- packet_authorizes_work: false,
2716
- can_merge: false,
2717
- can_close: false,
2718
- can_grant: false,
2719
- },
2720
- };
2721
- const packetName = `${safeName(packet.created_at)}-${safeName(task.id)}-task-bundle.json`;
2722
- const packetPath = join(root, 'docs', 'work', 'packets', packetName);
2723
- await writeJson(packetPath, packet);
2724
- const event = await appendLocalEvent(root, 'packet_posted', {
2725
- ...packet,
2726
- packet_ref: relativeRef(root, packetPath),
2727
- }, flags);
2728
- await refreshWakeQueuesForEvent(root, event, flags);
2729
- return event;
2730
- }
2731
-
2732
- async function dispatchBootstrap(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
2733
- const verb = positionals[1];
2734
- if (verb !== 'local') throw new Error('bootstrap verb must be: local');
2735
- const root = projectRoot(flags);
2736
- const force = flags.force === true;
2737
- const json = Boolean(flags.json);
2738
- await applyFirstRunOnboardingPrompts(root, flags);
2739
- const plan = buildBootstrapPlan(flags);
2740
- const result = {
2741
- schema: 'local-bootstrap-result.v0',
2742
- profile: plan.profile,
2743
- project: root,
2744
- project_id: null as string | null,
2745
- lead_start: null as JsonRecord | null,
2746
- generated: [] as string[],
2747
- skipped: [] as string[],
2748
- events: [] as string[],
2749
- seats: planSeats(plan).map((seat) => seat.seatId),
2750
- tasks: plan.tasks.map((task) => task.id),
2751
- sessions: plan.tasks.map((task) => `session-${task.id}`),
2752
- onboarding: plan.onboarding,
2753
- authority_boundary: {
2754
- can_authorize: false,
2755
- can_merge: false,
2756
- can_close: false,
2757
- can_write_global_ledger: false,
2758
- },
2759
- };
2760
-
2761
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
2762
- await ensureLocalProject(root);
2763
- await ensureMachinePlaneGitignore(root, result);
2764
- result.project_id = project.project_id;
2765
- const leadTask = plan.tasks[0]?.id ?? `${plan.taskPrefix}-001`;
2766
- result.lead_start = {
2767
- seat: plan.lead.seatId,
2768
- operator_principal: plan.lead.operatorPrincipal,
2769
- task: leadTask,
2770
- task_ref: `docs/work/tasks/${leadTask}.yaml`,
2771
- runbook_ref: `docs/work/sessions/session-${leadTask}/RUNBOOK.md`,
2772
- commands: [
2773
- `etiquette whereami --project . --seat ${plan.lead.seatId} --task ${leadTask} --expect-project-id ${project.project_id}`,
2774
- `etiquette join --project . --seat ${plan.lead.seatId}`,
2775
- pickupCommand(plan.lead.seatId, leadTask, project.project_id),
2776
- `etiquette console board --project . --seat ${plan.lead.seatId} --text`,
2777
- `etiquette notify feed --project . --seat ${plan.lead.seatId} --text`,
2778
- ],
2779
- };
2780
- result.events.push((await appendLocalEvent(root, 'workspace_initialized', {
2781
- project_ref: '.',
2782
- project_id: project.project_id,
2783
- bootstrap_profile: plan.profile,
2784
- authority_boundary: {
2785
- can_authorize_work: false,
2786
- can_merge: false,
2787
- can_close: false,
2788
- },
2789
- }, flags)).event_ref);
2790
-
2791
- await writeGeneratedText(root, 'docs/work/workspace-principal.v0.json', renderWorkspacePrincipal(root, plan), force, result);
2792
- await writeGeneratedText(root, 'docs/work/onboarding/local-etiquette-canary.md', renderOnboarding(plan), force, result);
2793
- await writeGeneratedText(root, 'docs/work/BOARD.md', renderBoard(plan), force, result);
2794
- await writeGeneratedText(root, 'docs/work/manuals/SEAT_OPERATING_MANUAL.md', renderSeatManual(plan), force, result);
2795
- await writeGeneratedText(root, 'docs/work/runbooks/SESSION_PICKUP.md', renderSessionPickup(plan), force, result);
2796
- await writeGeneratedText(root, 'docs/work/cookbooks/ADD_A_LANE.md', renderAddLaneCookbook(plan), force, result);
2797
- if (!existsSync(join(root, 'AGENTS.md'))) {
2798
- await writeGeneratedText(root, 'AGENTS.md', renderAgents(plan), force, result);
2799
- } else {
2800
- result.skipped.push('AGENTS.md');
2801
- }
2802
-
2803
- for (const seat of planSeats(plan)) {
2804
- await writeGeneratedText(root, `docs/work/seat-cards/${seat.seatId}.yaml`, renderSeatCard(seat), force, result);
2805
- }
2806
- for (const task of plan.tasks) {
2807
- await writeGeneratedText(root, `docs/work/tasks/${task.id}.yaml`, renderTask(root, plan, task), force, result);
2808
- await writeGeneratedText(root, `docs/work/huddle-cards/${task.id}.yaml`, renderHuddleCard(root, plan, task), force, result);
2809
- }
2810
-
2811
- for (const seat of planSeats(plan)) {
2812
- result.events.push((await enrollBootstrapSeat(root, seat, project.project_id, flags)).event_ref);
2813
- result.events.push((await readyBootstrapSeat(root, seat, project.project_id, flags)).event_ref);
2814
- await reportSeatReadiness({
2815
- projectRoot: root,
2816
- seat: seat.seatId,
2817
- seatType: seat.seatType,
2818
- executionSurface: 'local-etiquette',
2819
- declaredBy: 'config',
2820
- capabilitySource: 'config',
2821
- operationalStatus: 'operational',
2822
- modelTier: seat.modelTier,
2823
- modelId: seat.modelId,
2824
- activeWorkers: seat.activeWorkers,
2825
- maxRecommendedWorkers: seat.maxRecommendedWorkers,
2826
- supportsParallel: seat.supportsParallel,
2827
- supportedStrategies: seat.supportedStrategies,
2828
- unsupportedStrategies: [],
2829
- constraints: [],
2830
- at: optionalString(flags, 'at'),
2831
- });
2832
- }
2833
-
2834
- for (const task of plan.tasks) {
2835
- await createSessionRunbook({
2836
- projectRoot: root,
2837
- seat: task.assignedTo.seatId,
2838
- taskRef: task.id,
2839
- sessionId: `session-${task.id}`,
2840
- operatorPrincipal: task.assignedTo.operatorPrincipal,
2841
- ownerPrincipal: plan.lead.seatId,
2842
- creationMode: 'explicit',
2843
- at: optionalString(flags, 'at'),
2844
- });
2845
- await appendSessionRunbookNote({
2846
- projectRoot: root,
2847
- sessionId: `session-${task.id}`,
2848
- section: 'plan',
2849
- message: task.planNote,
2850
- at: optionalString(flags, 'at'),
2851
- });
2852
- await subscribeBootstrapSeat(root, task.assignedTo, task, flags);
2853
- result.events.push((await postBootstrapPacket(root, plan, task, flags)).event_ref);
2854
- }
2855
-
2856
- const cardDir = join(root, 'docs', 'work', 'seat-cards');
2857
- const cardFiles = await readdir(cardDir, { withFileTypes: true });
2858
- for (const entry of cardFiles) {
2859
- if (entry.isFile() && (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml') || entry.name.endsWith('.json'))) {
2860
- await validateSeatCard(join(cardDir, entry.name));
2861
- }
2862
- }
2863
-
2864
- if (json) {
2865
- console.log(JSON.stringify(result, null, 2));
2866
- return;
2867
- }
2868
- console.log([
2869
- `bootstrap_local: ok project=${root}`,
2870
- `project_id: ${project.project_id}`,
2871
- 'ledger: .etiquette/events/local.ndjson',
2872
- `profile: ${plan.profile}`,
2873
- `runtime_occupant: ${plan.onboarding.runtime}`,
2874
- `seats: ${result.seats.join(', ')}`,
2875
- `tasks: ${result.tasks.join(', ')}`,
2876
- `sessions: ${result.sessions.join(', ')}`,
2877
- `events: ${result.events.join(', ')}`,
2878
- `generated: ${result.generated.length}`,
2879
- `skipped: ${result.skipped.length}`,
2880
- 'start_here:',
2881
- ' 1. AGENTS.md',
2882
- ' 2. docs/work/BOARD.md',
2883
- ' details_after_pickup: docs/work/runbooks/SESSION_PICKUP.md',
2884
- 'lead_start:',
2885
- ` seat: ${plan.lead.seatId}`,
2886
- ` operator_principal: ${plan.lead.operatorPrincipal}`,
2887
- ` task: ${leadTask}`,
2888
- ` task_ref: docs/work/tasks/${leadTask}.yaml`,
2889
- ` runbook_ref: docs/work/sessions/session-${leadTask}/RUNBOOK.md`,
2890
- ' commands:',
2891
- ...((result.lead_start?.commands as string[] | undefined) ?? []).map((command) => ` ${command}`),
2892
- 'next:',
2893
- ` etiquette console board --project . --seat ${plan.lead.seatId} --text`,
2894
- ` etiquette notify feed --project . --seat ${plan.lead.seatId} --text`,
2895
- ` etiquette bus inbox --addressee ${plan.lead.seatId} --project . --text`,
2896
- ].join('\n'));
2897
- }
2898
-
2899
- async function dispatchEnter(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
2900
- const root = projectRoot(flags);
2901
- const task = requirePositional(positionals, 1, 'task');
2902
- const seat = requireString(flags, 'seat');
2903
- const operator = requireString(flags, 'operator');
2904
- const event = await appendLocalEvent(root, 'seat_entered', {
2905
- task,
2906
- seat,
2907
- operator_principal: operator,
2908
- authority_boundary: {
2909
- authentication_authorizes_work: false,
2910
- can_merge: false,
2911
- can_close: false,
2912
- },
2913
- }, flags);
2914
- console.log(`entered: ${seat} task=${task} operator=${operator}`);
2915
- console.log(`event: ${event.event_ref}`);
2916
- }
2917
-
2918
- async function dispatchHandoff(flags: Record<string, string | boolean>): Promise<void> {
2919
- const root = projectRoot(flags);
2920
- const plan = buildBootstrapPlan(flags);
2921
- const initialized = existsSync(join(root, '.etiquette')) && existsSync(join(root, 'docs', 'work'));
2922
- const project = initialized ? await readProjectBinding(root) : null;
2923
- const taskIds = initialized ? await listTaskIds(root) : plan.tasks.map((task) => task.id);
2924
- const leadTask = taskIds.find((task) => task.endsWith('-001')) ?? plan.tasks[0]?.id ?? `${plan.taskPrefix}-001`;
2925
- const reviewTask = taskIds.find((task) => task.endsWith('-002')) ?? plan.tasks[1]?.id ?? `${plan.taskPrefix}-002`;
2926
- const qaTask = taskIds.find((task) => task.endsWith('-003')) ?? plan.tasks[2]?.id ?? null;
2927
- const seatCommands = [
2928
- {
2929
- label: 'lead',
2930
- seat: plan.lead.seatId,
2931
- principal: plan.lead.operatorPrincipal,
2932
- task: leadTask,
2933
- },
2934
- {
2935
- label: 'review',
2936
- seat: plan.reviewers[0]?.seatId ?? 'review-seat',
2937
- principal: plan.reviewers[0]?.operatorPrincipal ?? 'reviewer',
2938
- task: reviewTask,
2939
- },
2940
- ...(qaTask && plan.reviewers[1]
2941
- ? [{
2942
- label: 'qa',
2943
- seat: plan.reviewers[1].seatId,
2944
- principal: plan.reviewers[1].operatorPrincipal,
2945
- task: qaTask,
2946
- }]
2947
- : []),
2948
- ];
2949
-
2950
- const result = {
2951
- schema: 'local-handoff-plan.v0',
2952
- project_ref: '.',
2953
- initialized,
2954
- profile: plan.profile,
2955
- task_prefix: plan.taskPrefix,
2956
- seats: seatCommands,
2957
- install_env: {
2958
- ETQ_VERSION: '<release-version>',
2959
- ETQ_INSTALLER_URL: '<signed-installer-url>',
2960
- ETQ_RELEASES_URL: '<signed-release-base-url>',
2961
- ETQ_PUBLIC_KEY: '<public-key-path-or-url>',
2962
- },
2963
- commands: {
2964
- install: [
2965
- 'curl -fsSL "$ETQ_INSTALLER_URL" -o "${TMPDIR:-.}/etiquette-install.sh"',
2966
- 'sh "${TMPDIR:-.}/etiquette-install.sh" --version "$ETQ_VERSION" --base-url "$ETQ_RELEASES_URL" --public-key "$ETQ_PUBLIC_KEY" --install-root "$HOME/.etiquette" --modify-profile',
2967
- 'export PATH="$HOME/.etiquette/bin:$PATH"',
2968
- 'etiquette help',
2969
- ],
2970
- bootstrap: [
2971
- `etiquette bootstrap local --project . --profile ${plan.profile} --task-prefix ${plan.taskPrefix}`,
2972
- 'etiquette doctor --project .',
2973
- 'etiquette console board --project . --text',
2974
- ],
2975
- seats: seatCommands.flatMap((item) => [
2976
- `etiquette whereami --project . --seat ${item.principal} --task ${item.task}`,
2977
- `etiquette join --project . --seat ${item.principal}`,
2978
- pickupCommand(item.principal, item.task, project?.project_id ?? null),
2979
- ]),
2980
- },
2981
- docs: [
2982
- 'AGENTS.md',
2983
- 'docs/work/BOARD.md',
2984
- 'docs/work/onboarding/local-etiquette-canary.md',
2985
- 'docs/work/runbooks/SESSION_PICKUP.md',
2986
- 'docs/work/manuals/SEAT_OPERATING_MANUAL.md',
2987
- 'docs/work/cookbooks/ADD_A_LANE.md',
2988
- ],
2989
- authority_boundary: {
2990
- handoff_is_read_only: true,
2991
- handoff_installs_nothing: true,
2992
- handoff_bootstraps_nothing: true,
2993
- can_merge: false,
2994
- can_push: false,
2995
- can_close: false,
2996
- can_grant: false,
2997
- can_write_global_ledger: false,
2998
- },
2999
- };
3000
-
3001
- if (flags.json) {
3002
- console.log(JSON.stringify(result, null, 2));
3003
- return;
3004
- }
3005
-
3006
- console.log([
3007
- 'handoff_plan:',
3008
- ` project: ${result.project_ref}`,
3009
- ` initialized: ${String(result.initialized)}`,
3010
- ` profile: ${result.profile}`,
3011
- ` task_prefix: ${result.task_prefix}`,
3012
- ' authority_boundary: read-only guide; installs nothing, bootstraps nothing, grants nothing',
3013
- '',
3014
- 'install_env:',
3015
- ...Object.entries(result.install_env).map(([key, value]) => ` ${key}=${value}`),
3016
- '',
3017
- 'install:',
3018
- ...result.commands.install.map((command) => ` ${command}`),
3019
- '',
3020
- 'bootstrap:',
3021
- ...result.commands.bootstrap.map((command) => ` ${command}`),
3022
- '',
3023
- 'seat_pickups:',
3024
- ...seatCommands.flatMap((item) => [
3025
- ` # ${item.label}: ${item.seat} (${item.principal})`,
3026
- ` etiquette whereami --project . --seat ${item.principal} --task ${item.task}`,
3027
- ` etiquette join --project . --seat ${item.principal}`,
3028
- ` ${pickupCommand(item.principal, item.task, project?.project_id ?? null)}`,
3029
- ]),
3030
- '',
3031
- 'docs:',
3032
- ...result.docs.map((ref) => ` - ${ref}`),
3033
- '',
3034
- 'stop_conditions:',
3035
- ' - task needs writes outside its envelope',
3036
- ' - secret or credential appears',
3037
- ' - validation cannot run',
3038
- ' - authority is unclear',
3039
- ].join('\n'));
3040
- }
3041
-
3042
- async function dispatchJoin(flags: Record<string, string | boolean>): Promise<void> {
3043
- const root = projectRoot(flags);
3044
- await ensureLocalProject(root);
3045
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
3046
- assertEngagementProject(project.project_id, optionalString(flags, 'expect-project-id'), 'current project');
3047
- const seatInput = requireString(flags, 'seat');
3048
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), seatInput);
3049
- const seat = resolvedSeat.seat;
3050
- const enrollment = resolvedSeat.enrollment;
3051
- if (!enrollment) throw new Error(`seat is not enrolled: ${seatInput}`);
3052
- const queue = await buildWakeQueue(root, seat, flags);
3053
- const firstTask = queue.items.find((item) => item.lane)?.lane ?? null;
3054
- const result = {
3055
- schema: 'seat-join.v0',
3056
- requested_seat: seatInput,
3057
- seat,
3058
- seat_alias_used: resolvedSeat.aliasUsed,
3059
- project_id: project.project_id,
3060
- ready: enrollment.ready === true,
3061
- lifecycle_state: lifecycleState(enrollment),
3062
- workflow_id: enrollment.workflow_id ?? null,
3063
- operator_principal: stringField(enrollment, 'operator_principal') ?? null,
3064
- orientation_ref: stringField(enrollment, 'orientation_ref') ?? null,
3065
- board_ref: 'docs/work/BOARD.md',
3066
- event_log_ref: '.etiquette/events/local.ndjson',
3067
- queued_items: queue.items.length,
3068
- next_task: firstTask,
3069
- next_commands: [
3070
- `etiquette console board --project . --seat ${seat} --text`,
3071
- `etiquette notify feed --project . --seat ${seat} --text`,
3072
- firstTask ? pickupCommand(seat, firstTask, project.project_id) : null,
3073
- ].filter((value): value is string => Boolean(value)),
3074
- authority_boundary: {
3075
- join_authorizes_work: false,
3076
- can_merge: false,
3077
- can_close: false,
3078
- can_grant: false,
3079
- can_write_global_ledger: false,
3080
- },
3081
- };
3082
- if (flags.json) {
3083
- console.log(JSON.stringify(result, null, 2));
3084
- return;
3085
- }
3086
- console.log([
3087
- `seat_join: ${seat}`,
3088
- resolvedSeat.aliasUsed ? `seat_alias: ${seatInput} -> ${seat}` : null,
3089
- `project_id: ${project.project_id}`,
3090
- `ready: ${String(result.ready)}`,
3091
- `lifecycle_state: ${result.lifecycle_state}`,
3092
- `workflow_id: ${String(result.workflow_id ?? 'legacy')}`,
3093
- `operator_principal: ${result.operator_principal ?? 'unknown'}`,
3094
- `orientation_ref: ${result.orientation_ref ?? 'none'}`,
3095
- `queued_items: ${result.queued_items}`,
3096
- `next_task: ${result.next_task ?? 'none'}`,
3097
- 'next:',
3098
- ...result.next_commands.map((command) => ` ${command}`),
3099
- 'can_authorize: false',
3100
- ].filter((line): line is string => line !== null).join('\n'));
3101
- }
3102
-
3103
- async function listTaskIds(root: string): Promise<string[]> {
3104
- const dir = join(root, 'docs', 'work', 'tasks');
3105
- if (!existsSync(dir)) return [];
3106
- const entries = await readdir(dir, { withFileTypes: true });
3107
- return entries
3108
- .filter((entry) => entry.isFile() && (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml') || entry.name.endsWith('.json')))
3109
- .map((entry) => entry.name.replace(/\.(ya?ml|json)$/i, ''))
3110
- .sort();
3111
- }
3112
-
3113
- async function listSessions(root: string): Promise<JsonRecord[]> {
3114
- const dir = join(root, 'docs', 'work', 'sessions');
3115
- if (!existsSync(dir)) return [];
3116
- const entries = await readdir(dir, { withFileTypes: true });
3117
- const sessions: JsonRecord[] = [];
3118
- for (const entry of entries) {
3119
- if (!entry.isDirectory()) continue;
3120
- const manifestRef = join('docs', 'work', 'sessions', entry.name, 'session.json');
3121
- const manifest = await readJsonIfExists<JsonRecord | null>(join(root, manifestRef), null);
3122
- sessions.push(manifest ?? {
3123
- session_id: entry.name,
3124
- manifest_ref: manifestRef,
3125
- runbook_ref: join('docs', 'work', 'sessions', entry.name, 'RUNBOOK.md'),
3126
- });
3127
- }
3128
- return sessions.sort((left, right) => String(left.session_id ?? '').localeCompare(String(right.session_id ?? '')));
3129
- }
3130
-
3131
- async function readTaskEnvelope(root: string, task: string): Promise<JsonRecord> {
3132
- return asRecord(await readStructured(join(root, 'docs', 'work', 'tasks', `${task}.yaml`)), `task envelope ${task}`);
3133
- }
3134
-
3135
- async function listEnrollments(root: string): Promise<JsonRecord[]> {
3136
- const files = await listJsonFiles(join(root, '.etiquette', 'roster', 'enrollments'));
3137
- const rows = await Promise.all(files.map((path) => readJsonIfExists<JsonRecord>(path, {})));
3138
- return rows.sort((left, right) => String(left.seat_id ?? '').localeCompare(String(right.seat_id ?? '')));
3139
- }
3140
-
3141
- function assertReadyEnrollment(resolvedSeat: {
3142
- requestedSeat: string;
3143
- seat: string;
3144
- enrollment: JsonRecord | null;
3145
- aliasUsed: boolean;
3146
- }, projectId?: string, at?: string): void {
3147
- if (!resolvedSeat.enrollment) throw new Error(`seat is not enrolled: ${resolvedSeat.requestedSeat}`);
3148
- if (resolvedSeat.enrollment.ready !== true) {
3149
- throw new Error(`seat is not ready: ${resolvedSeat.seat}; run etiquette roster ready ${resolvedSeat.seat} after onboarding`);
3150
- }
3151
- if (projectId) assertLifecycleRoutable(resolvedSeat.enrollment, projectId, resolvedSeat.seat, at);
3152
- }
3153
-
3154
- function assertFalseAuthorityBoundary(record: JsonRecord, label: string, fields: string[]): void {
3155
- const boundary = asRecord(record.authority_boundary, `${label} authority_boundary`);
3156
- for (const field of fields) {
3157
- if (boolField(boundary, field) !== false) throw new Error(`${label} authority_boundary.${field} must be false`);
3158
- }
3159
- }
3160
-
3161
- function assertKnownTaskStrategy(value: string, label: string): void {
3162
- if (!(TASK_EXECUTION_STRATEGIES as readonly string[]).includes(value)) {
3163
- throw new Error(`${label} execution_strategy.strategy must be one of: ${TASK_EXECUTION_STRATEGIES.join(', ')}`);
3164
- }
3165
- }
3166
-
3167
- function assertNoAuthorityClaims(value: unknown, label: string): void {
3168
- if (!value || typeof value !== 'object' || Array.isArray(value)) return;
3169
- for (const [key, child] of Object.entries(value as JsonRecord)) {
3170
- const path = `${label}.${key}`;
3171
- const normalizedKey = key.toLowerCase();
3172
- const isProhibition = normalizedKey.includes('cannot')
3173
- || normalizedKey.includes('require')
3174
- || normalizedKey.includes('read_only')
3175
- || normalizedKey.includes('one_writer');
3176
- if (!isProhibition && /(authorize|authority|grant|merge|close|promote|push|sign)/i.test(key) && child === true) {
3177
- throw new Error(`${path} must not claim authority`);
3178
- }
3179
- assertNoAuthorityClaims(child, path);
3180
- }
3181
- }
3182
-
3183
- function isReadOnlyAuditWrite(ref: string): boolean {
3184
- return READ_ONLY_AUDIT_WRITE_PREFIXES.some((prefix) => (
3185
- prefix.endsWith('/') ? ref.startsWith(prefix) : ref === prefix
3186
- ));
3187
- }
3188
-
3189
- function assertTaskStrategyConstraints(taskEnvelope: JsonRecord, task: string): void {
3190
- let strategyName: string | undefined;
3191
- if (taskEnvelope.execution_strategy !== undefined) {
3192
- const strategy = asRecord(taskEnvelope.execution_strategy, `task envelope ${task} execution_strategy`);
3193
- strategyName = stringField(strategy, 'strategy');
3194
- if (!strategyName) throw new Error(`task envelope ${task} execution_strategy.strategy is required`);
3195
- assertKnownTaskStrategy(strategyName, `task envelope ${task}`);
3196
- if (strategyName === 'shadow') {
3197
- throw new Error(`task envelope ${task} execution_strategy.strategy=shadow is reserved and not executable in V0`);
3198
- }
3199
- assertNoAuthorityClaims(strategy, `task envelope ${task} execution_strategy`);
3200
- }
3201
-
3202
- if (taskEnvelope.rig_conductor !== undefined) {
3203
- if (strategyName !== 'rig') {
3204
- throw new Error(`task envelope ${task} rig_conductor requires execution_strategy.strategy=rig`);
3205
- }
3206
- const result = evaluateRigConductorEnvelope(taskEnvelope.rig_conductor);
3207
- if (result.verdict !== 'legitimate') {
3208
- throw new Error(`task envelope ${task} rig_conductor blocked: ${result.reasons.join(', ')}`);
3209
- }
3210
- } else if (strategyName === 'rig') {
3211
- throw new Error(`task envelope ${task} execution_strategy.strategy=rig requires rig_conductor`);
3212
- }
3213
-
3214
- if (taskEnvelope.topology_constraints === undefined) return;
3215
- const constraints = asRecord(taskEnvelope.topology_constraints, `task envelope ${task} topology_constraints`);
3216
- for (const field of [
3217
- 'reviewers_read_only',
3218
- 'child_agents_cannot_promote',
3219
- 'one_writer_per_worktree',
3220
- 'runner_swaps_require_lifecycle_preflight',
3221
- ]) {
3222
- if (boolField(constraints, field) !== true) {
3223
- throw new Error(`task envelope ${task} topology_constraints.${field} must be true`);
3224
- }
3225
- }
3226
- assertNoAuthorityClaims(constraints, `task envelope ${task} topology_constraints`);
3227
-
3228
- const mode = stringField(taskEnvelope, 'mode');
3229
- if (mode === 'read-only-audit' && boolField(constraints, 'reviewers_read_only') === true) {
3230
- for (const ref of (arrayField(taskEnvelope, 'allowed_writes') ?? [])) {
3231
- if (typeof ref !== 'string' || !isReadOnlyAuditWrite(ref)) {
3232
- throw new Error(`task envelope ${task} reviewers_read_only prohibits write path: ${String(ref)}`);
3233
- }
3234
- }
3235
- }
3236
-
3237
- const maxParallelWriters = taskEnvelope.max_parallel_writers;
3238
- if (
3239
- boolField(constraints, 'one_writer_per_worktree') === true
3240
- && maxParallelWriters !== undefined
3241
- && maxParallelWriters !== 1
3242
- ) {
3243
- throw new Error(`task envelope ${task} max_parallel_writers must be 1 when one_writer_per_worktree is true`);
3244
- }
3245
- }
3246
-
3247
- function assertTaskEnvelopeIntegrity(taskEnvelope: JsonRecord, task: string, seat: string): void {
3248
- if (taskEnvelope.schema !== 'task-envelope.v0') throw new Error(`task envelope ${task} schema must be task-envelope.v0`);
3249
- if (stringField(taskEnvelope, 'task_id') !== task) {
3250
- throw new Error(`task envelope mismatch: ${task} has task_id=${String(taskEnvelope.task_id ?? 'missing')}`);
3251
- }
3252
- const assignedTo = stringField(taskEnvelope, 'assigned_to');
3253
- if (!assignedTo) throw new Error(`task envelope ${task} missing assigned_to`);
3254
- if (assignedTo !== seat) {
3255
- throw new Error(`task/seat mismatch: ${task} assigned_to=${assignedTo} but pickup seat=${seat}`);
3256
- }
3257
- if (!stringField(taskEnvelope, 'mode')) throw new Error(`task envelope ${task} missing mode`);
3258
- if ((arrayField(taskEnvelope, 'allowed_writes') ?? []).length === 0) {
3259
- throw new Error(`task envelope ${task} missing allowed_writes`);
3260
- }
3261
- if ((arrayField(taskEnvelope, 'validation') ?? []).length === 0) {
3262
- throw new Error(`task envelope ${task} missing validation`);
3263
- }
3264
- assertFalseAuthorityBoundary(taskEnvelope, `task envelope ${task}`, [
3265
- 'can_authorize',
3266
- 'can_merge',
3267
- 'can_close',
3268
- 'can_write_global_ledger',
3269
- ]);
3270
- assertTaskStrategyConstraints(taskEnvelope, task);
3271
- }
3272
-
3273
- function assertSessionManifestIntegrity(sessionManifest: JsonRecord, task: string, seat: string): void {
3274
- if (sessionManifest.schema !== 'session-runbook.v0') throw new Error(`session session-${task} schema must be session-runbook.v0`);
3275
- if (stringField(sessionManifest, 'session_id') !== `session-${task}`) {
3276
- throw new Error(`session manifest mismatch: expected session-${task} got ${String(sessionManifest.session_id ?? 'missing')}`);
3277
- }
3278
- if (stringField(sessionManifest, 'task_ref') !== task) {
3279
- throw new Error(`session/task mismatch: session-${task} task_ref=${String(sessionManifest.task_ref ?? 'missing')} expected ${task}`);
3280
- }
3281
- if (stringField(sessionManifest, 'seat') !== seat) {
3282
- throw new Error(`session/seat mismatch: session-${task} seat=${String(sessionManifest.seat ?? 'missing')} expected ${seat}`);
3283
- }
3284
- assertFalseAuthorityBoundary(sessionManifest, `session session-${task}`, [
3285
- 'can_authorize',
3286
- 'can_merge',
3287
- 'can_close',
3288
- 'can_write_global_ledger',
3289
- ]);
3290
- }
3291
-
3292
- async function collectRosterSessionIntegrityErrors(root: string, projectId: string): Promise<string[]> {
3293
- const errors: string[] = [];
3294
- const enrollments = await listEnrollments(root);
3295
- const enrolledSeats = new Set(enrollments.map((row) => stringField(row, 'seat_id')).filter((seat): seat is string => Boolean(seat)));
3296
- const readySeats = new Set(enrollments.filter((row) => row.ready === true).map((row) => stringField(row, 'seat_id')).filter((seat): seat is string => Boolean(seat)));
3297
- const enrollmentBySeat = new Map(enrollments.map((row) => [stringField(row, 'seat_id'), row]).filter((entry): entry is [string, JsonRecord] => Boolean(entry[0])));
3298
- for (const task of await listTaskIds(root)) {
3299
- let taskEnvelope: JsonRecord;
3300
- try {
3301
- taskEnvelope = await readTaskEnvelope(root, task);
3302
- } catch (err) {
3303
- errors.push((err as Error).message);
3304
- continue;
3305
- }
3306
- const assignedTo = stringField(taskEnvelope, 'assigned_to');
3307
- const session = await readJsonIfExists<JsonRecord | null>(
3308
- join(root, 'docs', 'work', 'sessions', `session-${task}`, 'session.json'),
3309
- null,
3310
- );
3311
- if (!assignedTo && !session) continue;
3312
- if (!assignedTo) {
3313
- errors.push(`task envelope ${task} missing assigned_to`);
3314
- continue;
3315
- }
3316
- if (!enrolledSeats.has(assignedTo)) errors.push(`task envelope ${task} assigned_to ${assignedTo} is not enrolled`);
3317
- if (!readySeats.has(assignedTo)) errors.push(`task envelope ${task} assigned_to ${assignedTo} is not ready`);
3318
- const enrollment = enrollmentBySeat.get(assignedTo);
3319
- if (enrollment) {
3320
- try {
3321
- assertLifecycleRoutable(enrollment, projectId, assignedTo);
3322
- } catch (err) {
3323
- errors.push((err as Error).message);
3324
- }
3325
- }
3326
- try {
3327
- assertTaskEnvelopeIntegrity(taskEnvelope, task, assignedTo);
3328
- } catch (err) {
3329
- errors.push((err as Error).message);
3330
- }
3331
- if (!session) {
3332
- errors.push(`session manifest missing: docs/work/sessions/session-${task}/session.json`);
3333
- continue;
3334
- }
3335
- try {
3336
- assertSessionProject(typeof session.project_id === 'string' ? session.project_id : null, projectId, `session session-${task}`);
3337
- assertSessionManifestIntegrity(session, task, assignedTo);
3338
- } catch (err) {
3339
- errors.push((err as Error).message);
3340
- }
3341
- }
3342
- return [...new Set(errors)];
3343
- }
3344
-
3345
- function resolveSeatFromEnrollments(enrollments: JsonRecord[], requestedSeat: string, label = '--seat'): {
3346
- requestedSeat: string;
3347
- seat: string;
3348
- enrollment: JsonRecord | null;
3349
- aliasUsed: boolean;
3350
- } {
3351
- assertSharedMemoryScopeId(requestedSeat, label);
3352
- const exact = enrollments.find((row) => stringField(row, 'seat_id') === requestedSeat) ?? null;
3353
- if (exact) {
3354
- return { requestedSeat, seat: requestedSeat, enrollment: exact, aliasUsed: false };
3355
- }
3356
-
3357
- const principalMatches = enrollments.filter((row) => stringField(row, 'operator_principal') === requestedSeat);
3358
- if (principalMatches.length === 1) {
3359
- const seat = stringField(principalMatches[0], 'seat_id');
3360
- if (seat) return { requestedSeat, seat, enrollment: principalMatches[0], aliasUsed: true };
3361
- }
3362
- if (principalMatches.length > 1) {
3363
- throw new Error(`seat alias is ambiguous: ${requestedSeat}`);
3364
- }
3365
- return { requestedSeat, seat: requestedSeat, enrollment: null, aliasUsed: false };
3366
- }
3367
-
3368
- function gitInfo(root: string): JsonRecord {
3369
- const top = git(root, ['rev-parse', '--show-toplevel'], true);
3370
- if (top.status !== 0) {
3371
- return {
3372
- inside_worktree: false,
3373
- root: null,
3374
- branch: null,
3375
- dirty: null,
3376
- status_ref: 'git unavailable',
3377
- };
3378
- }
3379
- const branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], true);
3380
- const status = git(root, ['status', '--short', '--branch'], true);
3381
- const statusLines = status.stdout.split('\n').filter((line) => line.trim().length > 0);
3382
- const dirtyLines = statusLines.filter((line) => !line.startsWith('## '));
3383
- return {
3384
- inside_worktree: true,
3385
- root: top.stdout.trim(),
3386
- branch: branch.status === 0 ? branch.stdout.trim() : null,
3387
- dirty: dirtyLines.length > 0,
3388
- status_ref: statusLines[0] ?? null,
3389
- dirty_entries: dirtyLines.length,
3390
- };
3391
- }
3392
-
3393
- async function dispatchWhereami(flags: Record<string, string | boolean>): Promise<void> {
3394
- const root = projectRoot(flags);
3395
- const initialized = existsSync(join(root, '.etiquette')) || existsSync(join(root, 'docs', 'work'));
3396
- const project = initialized ? await readProjectBinding(root) : null;
3397
- assertEngagementProject(project?.project_id, optionalString(flags, 'expect-project-id'), 'current project');
3398
- const events = await readEventsIfInitialized(root);
3399
- const enrollments = await listEnrollments(root);
3400
- const tasks = await listTaskIds(root);
3401
- const sessions = await listSessions(root);
3402
- const seatInput = optionalString(flags, 'seat') ?? null;
3403
- const task = optionalString(flags, 'task') ?? null;
3404
- if (task) assertSharedMemoryScopeId(task, '--task');
3405
- const resolvedSeat = seatInput ? resolveSeatFromEnrollments(enrollments, seatInput) : null;
3406
- const seat = resolvedSeat?.seat ?? null;
3407
- const enrollment = resolvedSeat?.enrollment ?? null;
3408
- const taskRef = task ? `docs/work/tasks/${task}.yaml` : null;
3409
- const runbookRef = task ? `docs/work/sessions/session-${task}/RUNBOOK.md` : null;
3410
- const matchingSessions = task
3411
- ? sessions.filter((session) => session.task_ref === task || session.session_id === `session-${task}`)
3412
- : sessions;
3413
- if (project && task) {
3414
- for (const session of matchingSessions) {
3415
- assertSessionProject(
3416
- typeof session.project_id === 'string' ? session.project_id : null,
3417
- project.project_id,
3418
- `session ${String(session.session_id ?? 'unknown')}`,
3419
- );
3420
- }
3421
- }
3422
- const result = {
3423
- schema: 'workspace-context-readback.v0',
3424
- cwd: process.cwd(),
3425
- project: root,
3426
- project_id: project?.project_id ?? null,
3427
- project_ref: project ? '.etiquette/project.yaml' : null,
3428
- initialized,
3429
- git: gitInfo(root),
3430
- events: events.length,
3431
- seats: enrollments.map((row) => ({
3432
- seat_id: row.seat_id,
3433
- ready: row.ready === true,
3434
- lifecycle_state: lifecycleState(row),
3435
- workflow_id: row.workflow_id ?? null,
3436
- operator_principal: row.operator_principal ?? null,
3437
- })),
3438
- tasks,
3439
- sessions: matchingSessions.map((session) => ({
3440
- session_id: session.session_id ?? null,
3441
- project_id: session.project_id ?? null,
3442
- seat: session.seat ?? null,
3443
- task_ref: session.task_ref ?? null,
3444
- runbook_ref: session.runbook_ref ?? null,
3445
- manifest_ref: session.manifest_ref ?? null,
3446
- })),
3447
- requested: {
3448
- seat_input: seatInput,
3449
- seat,
3450
- seat_alias_used: resolvedSeat?.aliasUsed ?? false,
3451
- seat_ready: enrollment ? enrollment.ready === true : null,
3452
- lifecycle_state: enrollment ? lifecycleState(enrollment) : null,
3453
- workflow_id: enrollment?.workflow_id ?? null,
3454
- task,
3455
- task_ref: taskRef && existsSync(join(root, taskRef)) ? taskRef : null,
3456
- runbook_ref: runbookRef && existsSync(join(root, runbookRef)) ? runbookRef : null,
3457
- },
3458
- guidance: {
3459
- resume_conversation: 'Use your agent runner resume command for chat context; do not git checkout for conversation history.',
3460
- enter_code_context: 'Use the project/worktree path shown here for code context.',
3461
- avoid_checkout_when_dirty: 'If git_dirty is true, do not checkout another branch until the dirty worktree is triaged or moved to a separate worktree.',
3462
- },
3463
- next_commands: [
3464
- `cd ${root}`,
3465
- seat ? `etiquette join --project . --seat ${seat}` : null,
3466
- seat && task ? pickupCommand(seat, task, project?.project_id ?? null) : null,
3467
- 'etiquette status --project .',
3468
- ].filter((value): value is string => Boolean(value)),
3469
- authority_boundary: {
3470
- whereami_authorizes_work: false,
3471
- can_checkout: false,
3472
- can_merge: false,
3473
- can_close: false,
3474
- can_grant: false,
3475
- can_write_global_ledger: false,
3476
- },
3477
- };
3478
- if (flags.json) {
3479
- console.log(JSON.stringify(result, null, 2));
3480
- return;
3481
- }
3482
- const gitRecord = result.git as JsonRecord;
3483
- const lines = [
3484
- 'whereami:',
3485
- ` cwd: ${result.cwd}`,
3486
- ` project: ${result.project}`,
3487
- ` project_id: ${result.project_id ?? 'none'}`,
3488
- ` initialized: ${String(result.initialized)}`,
3489
- ` git_root: ${String(gitRecord.root ?? 'none')}`,
3490
- ` git_branch: ${String(gitRecord.branch ?? 'none')}`,
3491
- ` git_dirty: ${String(gitRecord.dirty ?? 'unknown')}`,
3492
- ` events: ${result.events}`,
3493
- ` seats: ${result.seats.length === 0 ? 'none' : result.seats.map((row) => `${String(row.seat_id)}(ready=${String(row.ready)} lifecycle=${row.lifecycle_state})`).join(', ')}`,
3494
- ` tasks: ${result.tasks.length === 0 ? 'none' : result.tasks.join(', ')}`,
3495
- ` sessions: ${result.sessions.length === 0 ? 'none' : result.sessions.map((session) => String(session.session_id ?? 'unknown')).join(', ')}`,
3496
- ];
3497
- if (seat) {
3498
- if (resolvedSeat?.aliasUsed) lines.push(` seat_alias: ${resolvedSeat.requestedSeat} -> ${seat}`);
3499
- lines.push(` requested_seat: ${seat} ready=${String(result.requested.seat_ready ?? 'unknown')}`);
3500
- lines.push(` lifecycle_state: ${String(result.requested.lifecycle_state ?? 'unknown')}`);
3501
- lines.push(` workflow_id: ${String(result.requested.workflow_id ?? 'legacy')}`);
3502
- }
3503
- if (task) {
3504
- lines.push(` requested_task: ${task}`);
3505
- lines.push(` task_ref: ${result.requested.task_ref ?? 'missing'}`);
3506
- lines.push(` runbook_ref: ${result.requested.runbook_ref ?? 'missing'}`);
3507
- }
3508
- lines.push(
3509
- 'next:',
3510
- ...result.next_commands.map((command) => ` ${command}`),
3511
- 'guidance:',
3512
- ` ${result.guidance.resume_conversation}`,
3513
- ` ${result.guidance.enter_code_context}`,
3514
- ` ${result.guidance.avoid_checkout_when_dirty}`,
3515
- 'authority_boundary: read-only; no checkout, merge, close, grant, or global ledger write',
3516
- );
3517
- console.log(lines.join('\n'));
3518
- }
3519
-
3520
- async function dispatchPickup(flags: Record<string, string | boolean>): Promise<void> {
3521
- const root = projectRoot(flags);
3522
- await ensureLocalProject(root);
3523
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
3524
- assertPickupProjectExpectation(project.project_id, flags);
3525
- const seatInput = requireString(flags, 'seat');
3526
- const task = requireString(flags, 'task');
3527
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), seatInput);
3528
- const seat = resolvedSeat.seat;
3529
- assertReadyEnrollment(resolvedSeat, project.project_id, optionalString(flags, 'at'));
3530
- assertSharedMemoryScopeId(task, '--task');
3531
- const taskRef = `docs/work/tasks/${task}.yaml`;
3532
- const runbookRef = `docs/work/sessions/session-${task}/RUNBOOK.md`;
3533
- const taskText = await readTextIfExists(join(root, taskRef));
3534
- if (!taskText) throw new Error(`task envelope not found: ${taskRef}`);
3535
- const taskEnvelope = await readTaskEnvelope(root, task);
3536
- assertTaskEnvelopeIntegrity(taskEnvelope, task, seat);
3537
- const runbookText = await readTextIfExists(join(root, runbookRef));
3538
- if (!runbookText) throw new Error(`session runbook not found: ${runbookRef}`);
3539
- const sessionManifest = await readJsonIfExists<JsonRecord | null>(
3540
- join(root, 'docs', 'work', 'sessions', `session-${task}`, 'session.json'),
3541
- null,
3542
- );
3543
- if (!sessionManifest) throw new Error(`session manifest not found: docs/work/sessions/session-${task}/session.json`);
3544
- assertSessionProject(
3545
- typeof sessionManifest.project_id === 'string' ? sessionManifest.project_id : null,
3546
- project.project_id,
3547
- `session session-${task}`,
3548
- );
3549
- assertSessionManifestIntegrity(sessionManifest, task, seat);
3550
- const queue = await buildWakeQueue(root, seat, flags);
3551
- const result = {
3552
- schema: 'seat-pickup.v0',
3553
- requested_seat: seatInput,
3554
- seat,
3555
- seat_alias_used: resolvedSeat.aliasUsed,
3556
- project_id: project.project_id,
3557
- task,
3558
- task_ref: taskRef,
3559
- runbook_ref: runbookRef,
3560
- roster_integrity: 'pass',
3561
- lifecycle_state: lifecycleState(resolvedSeat.enrollment ?? {}),
3562
- workflow_id: resolvedSeat.enrollment?.workflow_id ?? null,
3563
- feed_items: queue.items.length,
3564
- authority_boundary: {
3565
- pickup_authorizes_work: false,
3566
- can_merge: false,
3567
- can_close: false,
3568
- can_grant: false,
3569
- can_write_global_ledger: false,
3570
- },
3571
- };
3572
- if (flags.json) {
3573
- console.log(JSON.stringify(result, null, 2));
3574
- return;
3575
- }
3576
- console.log([
3577
- `pickup: ${seat} task=${task}`,
3578
- resolvedSeat.aliasUsed ? `seat_alias: ${seatInput} -> ${seat}` : null,
3579
- `project_id: ${project.project_id}`,
3580
- `task_ref: ${taskRef}`,
3581
- `runbook_ref: ${runbookRef}`,
3582
- 'roster_integrity: pass',
3583
- `lifecycle_state: ${lifecycleState(resolvedSeat.enrollment ?? {})}`,
3584
- `workflow_id: ${String(resolvedSeat.enrollment?.workflow_id ?? 'legacy')}`,
3585
- 'authority_boundary: read-only; no grants, merge, close, push, or global ledger writes',
3586
- '',
3587
- '## Wake Queue',
3588
- '',
3589
- renderWakeQueue(queue),
3590
- '',
3591
- '## Task Envelope',
3592
- '',
3593
- taskText.trim(),
3594
- '',
3595
- '## Runbook',
3596
- '',
3597
- runbookText.trim(),
3598
- '',
3599
- ].filter((line): line is string => line !== null).join('\n'));
3600
- }
3601
-
3602
- async function dispatchAudit(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
3603
- const verb = positionals[1];
3604
- if (verb !== 'append') throw new Error('audit verb must be: append');
3605
- const root = projectRoot(flags);
3606
- await ensureLocalProject(root);
3607
- const event = await appendLocalEvent(root, 'audit_appended', {
3608
- audit_schema: optionalString(flags, 'schema') ?? 'audit-event.v0',
3609
- actor: requireString(flags, 'actor'),
3610
- org_id: optionalString(flags, 'org-id'),
3611
- workspace_id: optionalString(flags, 'workspace-id'),
3612
- action: requireString(flags, 'action'),
3613
- target_type: optionalString(flags, 'target-type'),
3614
- target_id: optionalString(flags, 'target-id'),
3615
- grant_ref: optionalString(flags, 'grant-ref'),
3616
- result: optionalString(flags, 'result') ?? 'recorded',
3617
- reason: optionalString(flags, 'reason'),
3618
- refs: parseList(flags.refs),
3619
- }, flags);
3620
- await appendFile(join(root, '.etiquette', 'audit', 'audit.ndjson'), `${JSON.stringify(event)}\n`);
3621
- console.log(`audit_appended: ${event.event_ref}`);
3622
- }
3623
-
3624
- async function dispatchRoster(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
3625
- const verb = positionals[1];
3626
- const root = projectRoot(flags);
3627
- await ensureLocalProject(root);
3628
-
3629
- if (verb === 'card') {
3630
- const cardVerb = positionals[2];
3631
- if (cardVerb !== 'validate') throw new Error('roster card verb must be: validate');
3632
- const cardPath = resolve(requireString(flags, 'card'));
3633
- const seatId = await validateSeatCard(cardPath);
3634
- console.log(`seat_card_valid: ${seatId}`);
3635
- return;
3636
- }
3637
-
3638
- if (verb === 'enroll') {
3639
- const seat = requirePositional(positionals, 2, 'seat');
3640
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
3641
- const card = requireString(flags, 'card');
3642
- const cardPath = resolve(root, card);
3643
- const cardSeat = await validateSeatCard(cardPath);
3644
- const executionSeat = requireString(flags, 'execution-seat');
3645
- if (cardSeat !== seat) throw new Error(`seat card seat_id ${cardSeat} does not match ${seat}`);
3646
- const enrollment = {
3647
- schema: 'seat-enrollment.v0',
3648
- seat_id: seat,
3649
- card_ref: relativeRef(root, cardPath),
3650
- operator_principal: requireString(flags, 'operator-principal'),
3651
- execution_seat: executionSeat,
3652
- as_role: requireString(flags, 'as-role'),
3653
- authority_scope: requireString(flags, 'authority-scope'),
3654
- mode: requireString(flags, 'mode'),
3655
- orientation_ref: requireString(flags, 'orientation-ref'),
3656
- ready: false,
3657
- lifecycle_schema: SEAT_LIFECYCLE_SCHEMA,
3658
- lifecycle_state: 'enrolled',
3659
- workflow_id: lifecycleWorkflowId(project.project_id),
3660
- runner_binding: runnerBinding({
3661
- runnerProvider: optionalString(flags, 'runner-provider') ?? executionSeat,
3662
- executionSurface: optionalString(flags, 'execution-surface') ?? executionSeat,
3663
- modelId: optionalString(flags, 'model-id'),
3664
- modelTier: optionalString(flags, 'model-tier'),
3665
- permissionProfileRef: optionalString(flags, 'permission-profile-ref'),
3666
- capabilitySource: optionalString(flags, 'capability-source') ?? 'config',
3667
- }),
3668
- authority_granted: false,
3669
- lifecycle_authority_boundary: {
3670
- lifecycle_grants_authority: false,
3671
- readiness_authorizes_work: false,
3672
- can_merge: false,
3673
- can_close: false,
3674
- can_grant: false,
3675
- },
3676
- enrolled_at: nowIso(flags),
3677
- authority_boundary: {
3678
- enrollment_authorizes_work: false,
3679
- can_merge: false,
3680
- can_close: false,
3681
- },
3682
- };
3683
- await writeJson(await enrollmentPath(root, seat), enrollment);
3684
- const event = await appendLocalEvent(root, 'seat_enrolled', enrollment, flags);
3685
- console.log(`seat_enrolled: ${seat} ${event.event_ref}`);
3686
- return;
3687
- }
3688
-
3689
- if (verb === 'ready') {
3690
- const seat = requirePositional(positionals, 2, 'seat');
3691
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
3692
- const path = await enrollmentPath(root, seat);
3693
- const enrollment = await readJsonIfExists<JsonRecord | null>(path, null);
3694
- if (!enrollment) throw new Error(`seat is not enrolled: ${seat}`);
3695
- const readyAt = nowIso(flags);
3696
- const previousBinding = enrollment.runner_binding === undefined ? null : asRecord(enrollment.runner_binding, `${seat} runner_binding`);
3697
- const executionSurface = optionalString(flags, 'execution-surface') ?? stringField(previousBinding ?? {}, 'execution_surface') ?? stringField(enrollment, 'execution_seat') ?? 'local-etiquette';
3698
- const updated = {
3699
- ...enrollment,
3700
- ...lifecycleReadyPatch({
3701
- projectId: project.project_id,
3702
- at: readyAt,
3703
- runnerBinding: runnerBinding({
3704
- runnerProvider: optionalString(flags, 'runner-provider') ?? stringField(previousBinding ?? {}, 'runner_provider') ?? executionSurface,
3705
- executionSurface,
3706
- modelId: optionalString(flags, 'model-id') ?? stringField(previousBinding ?? {}, 'model_id') ?? null,
3707
- modelTier: optionalString(flags, 'model-tier') ?? stringField(previousBinding ?? {}, 'model_tier') ?? null,
3708
- permissionProfileRef: optionalString(flags, 'permission-profile-ref') ?? stringField(previousBinding ?? {}, 'permission_profile_ref') ?? null,
3709
- capabilitySource: optionalString(flags, 'capability-source') ?? stringField(previousBinding ?? {}, 'capability_source') ?? 'config',
3710
- previous: previousBinding,
3711
- }),
3712
- }),
3713
- ack_ref: requireString(flags, 'ack-ref'),
3714
- orientation_ref: requireString(flags, 'orientation-ref'),
3715
- };
3716
- await writeJson(path, updated);
3717
- const event = await appendLocalEvent(root, 'seat_ready', {
3718
- seat_id: seat,
3719
- lifecycle_state: 'ready',
3720
- workflow_id: lifecycleWorkflowId(project.project_id),
3721
- ack_ref: updated.ack_ref,
3722
- orientation_ref: updated.orientation_ref,
3723
- authority_granted: false,
3724
- authority_boundary: {
3725
- readiness_authorizes_work: false,
3726
- can_merge: false,
3727
- can_close: false,
3728
- },
3729
- }, flags);
3730
- console.log(`seat_ready: ${seat} ${event.event_ref}`);
3731
- return;
3732
- }
3733
-
3734
- if (verb === 'lifecycle') {
3735
- const seat = requirePositional(positionals, 2, 'seat');
3736
- const rawState = requireString(flags, 'state');
3737
- assertLifecycleState(rawState, '--state');
3738
- if (rawState === 'ready' || rawState === 'idle') {
3739
- throw new Error('use roster ready to move a seat to ready/idle after preflight');
3740
- }
3741
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
3742
- const path = await enrollmentPath(root, seat);
3743
- const enrollment = await readJsonIfExists<JsonRecord | null>(path, null);
3744
- if (!enrollment) throw new Error(`seat is not enrolled: ${seat}`);
3745
- const at = nowIso(flags);
3746
- const updated = {
3747
- ...enrollment,
3748
- ready: false,
3749
- lifecycle_schema: SEAT_LIFECYCLE_SCHEMA,
3750
- lifecycle_state: rawState,
3751
- workflow_id: lifecycleWorkflowId(project.project_id),
3752
- lifecycle_reason: requireString(flags, 'reason'),
3753
- lifecycle_updated_at: at,
3754
- authority_granted: false,
3755
- lifecycle_authority_boundary: {
3756
- lifecycle_grants_authority: false,
3757
- readiness_authorizes_work: false,
3758
- can_merge: false,
3759
- can_close: false,
3760
- can_grant: false,
3761
- },
3762
- };
3763
- await writeJson(path, updated);
3764
- const event = await appendLocalEvent(root, 'seat_lifecycle', {
3765
- seat_id: seat,
3766
- workflow_id: lifecycleWorkflowId(project.project_id),
3767
- lifecycle_state: rawState,
3768
- reason: updated.lifecycle_reason,
3769
- authority_granted: false,
3770
- authority_boundary: {
3771
- lifecycle_authorizes_work: false,
3772
- can_merge: false,
3773
- can_close: false,
3774
- can_grant: false,
3775
- },
3776
- }, flags);
3777
- console.log(`seat_lifecycle: ${seat} state=${rawState} ${event.event_ref}`);
3778
- return;
3779
- }
3780
-
3781
- if (verb === 'list') {
3782
- const files = await listJsonFiles(join(root, '.etiquette', 'roster', 'enrollments'));
3783
- const rows = await Promise.all(files.map(async (path) => readJsonIfExists<JsonRecord>(path, {})));
3784
- if (flags.json) {
3785
- console.log(JSON.stringify(rows, null, 2));
3786
- return;
3787
- }
3788
- console.log(rows.length === 0 ? 'no enrolled seats' : rows.map((row) => `${row.seat_id} ready=${String(row.ready)} lifecycle=${lifecycleState(row)} operator=${row.operator_principal}`).join('\n'));
3789
- return;
3790
- }
3791
-
3792
- if (verb === 'show' || verb === 'posture') {
3793
- const seat = requirePositional(positionals, 2, 'seat');
3794
- const enrollment = await readJsonIfExists<JsonRecord | null>(await enrollmentPath(root, seat), null);
3795
- if (!enrollment) throw new Error(`seat is not enrolled: ${seat}`);
3796
- if (verb === 'show') {
3797
- console.log(flags.json ? JSON.stringify(enrollment, null, 2) : [
3798
- `seat: ${enrollment.seat_id}`,
3799
- `operator_principal: ${enrollment.operator_principal}`,
3800
- `execution_seat: ${enrollment.execution_seat}`,
3801
- `as_role: ${enrollment.as_role}`,
3802
- `authority_scope: ${enrollment.authority_scope}`,
3803
- `mode: ${enrollment.mode}`,
3804
- `ready: ${String(enrollment.ready)}`,
3805
- `lifecycle_state: ${lifecycleState(enrollment)}`,
3806
- `workflow_id: ${String(enrollment.workflow_id ?? 'legacy')}`,
3807
- ].join('\n'));
3808
- return;
3809
- }
3810
- const cardRef = stringField(enrollment, 'card_ref');
3811
- const card = cardRef ? asRecord(await readStructured(join(root, cardRef)), 'seat card') : {};
3812
- console.log(flags.json ? JSON.stringify({ enrollment, card }, null, 2) : [
3813
- `seat_posture: ${seat}`,
3814
- `model_ref: ${String(card.model_ref ?? 'unknown')}`,
3815
- `model_posture_ref: ${String(card.model_posture_ref ?? 'unknown')}`,
3816
- 'authority_boundary:',
3817
- ' can_authorize_work: false',
3818
- ' can_close_lane_without_grant: false',
3819
- ' can_merge_or_push_without_grant: false',
3820
- ].join('\n'));
3821
- return;
3822
- }
3823
-
3824
- if (verb === 'doctor') {
3825
- const cardsDir = resolve(root, optionalString(flags, 'cards') ?? join('docs', 'work', 'seat-cards'));
3826
- const cardFiles = existsSync(cardsDir)
3827
- ? (await readdir(cardsDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => join(cardsDir, entry.name))
3828
- : [];
3829
- const validated: string[] = [];
3830
- for (const path of cardFiles) {
3831
- if (!path.endsWith('.yaml') && !path.endsWith('.yml') && !path.endsWith('.json')) continue;
3832
- validated.push(await validateSeatCard(path));
3833
- }
3834
- const enrollments = await listJsonFiles(join(root, '.etiquette', 'roster', 'enrollments'));
3835
- const result = {
3836
- schema: 'roster-doctor.v0',
3837
- verdict: 'pass',
3838
- cards_validated: validated.length,
3839
- enrolled_seats: enrollments.length,
3840
- authority_boundary: {
3841
- can_authorize_work: false,
3842
- can_merge: false,
3843
- can_close: false,
3844
- },
3845
- };
3846
- console.log(flags.json ? JSON.stringify(result, null, 2) : `roster_doctor: pass cards=${validated.length} enrolled=${enrollments.length}`);
3847
- return;
3848
- }
3849
-
3850
- throw new Error('roster verb must be one of: card, enroll, ready, lifecycle, list, show, posture, doctor');
3851
- }
3852
-
3853
- async function dispatchPost(flags: Record<string, string | boolean>): Promise<void> {
3854
- const root = projectRoot(flags);
3855
- const draft = isTrueFlag(flags, 'draft');
3856
- const profileRef = optionalString(flags, 'profile');
3857
- const outputRef = optionalString(flags, 'out');
3858
-
3859
- if (draft || profileRef || outputRef) {
3860
- if (!draft) throw new Error('--profile and --out are available only with --draft in v0');
3861
- if (!profileRef) throw new Error('--profile is required with --draft');
3862
- if (!outputRef) throw new Error('--out is required with --draft');
3863
-
3864
- const profilePath = resolve(profileRef);
3865
- const outputPath = resolve(outputRef);
3866
- const profile = parsePacketProfileV0(await readStructured(profilePath));
3867
- const profileInput: Record<string, PacketProfileValueV0> = {
3868
- created_at: nowIso(flags),
3869
- };
3870
- const aliases: Record<string, string> = { tier: 'packet_tier' };
3871
- const reserved = new Set(['project', 'profile', 'draft', 'out', 'force', 'emit', 'json', 'at']);
3872
- for (const [rawKey, rawValue] of Object.entries(flags)) {
3873
- if (reserved.has(rawKey)) continue;
3874
- const normalized = aliases[rawKey] ?? rawKey.replaceAll('-', '_');
3875
- const declaredDefault = profile.frontmatter.fields.find((field) => field.key === normalized)?.default;
3876
- if (typeof rawValue === 'string' && typeof declaredDefault === 'boolean') {
3877
- const lowered = rawValue.toLowerCase();
3878
- if (['true', 'yes', '1'].includes(lowered)) profileInput[normalized] = true;
3879
- else if (['false', 'no', '0'].includes(lowered)) profileInput[normalized] = false;
3880
- else throw new Error(`--${rawKey} must be true or false`);
3881
- } else if (typeof rawValue === 'string' && typeof declaredDefault === 'number') {
3882
- const value = Number(rawValue);
3883
- if (!Number.isFinite(value)) throw new Error(`--${rawKey} must be a number`);
3884
- profileInput[normalized] = value;
3885
- } else if (typeof rawValue === 'string' && Array.isArray(declaredDefault)) {
3886
- profileInput[normalized] = rawValue.split(',').map((value) => value.trim()).filter(Boolean);
3887
- } else {
3888
- profileInput[normalized] = rawValue;
3889
- }
3890
- }
3891
- const rendered = renderPacketProfileV0(profile, profileInput);
3892
- if (existsSync(outputPath) && !isTrueFlag(flags, 'force')) {
3893
- throw new Error(`refusing to overwrite existing draft: ${outputPath}; pass --force to replace it`);
3894
- }
3895
- await writeFile(outputPath, rendered.markdown, isTrueFlag(flags, 'force') ? undefined : { flag: 'wx' });
3896
- console.log(`packet_drafted: ${outputPath} profile=${profilePath} filename=${rendered.filename}`);
3897
- return;
3898
- }
3899
-
3900
- const role = requireString(flags, 'role');
3901
- const emit = flags.emit === true || isReturnRole(role);
3902
- if (emit) {
3903
- const eventsDir = join(root, '.etiquette', 'events');
3904
- let entries: string[] = [];
3905
- try {
3906
- entries = await readdir(eventsDir);
3907
- } catch (err) {
3908
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
3909
- }
3910
- const foreignStreams = entries.filter((name) => name.endsWith('.ndjson') && name !== 'local.ndjson');
3911
- if (foreignStreams.length > 0) {
3912
- throw new Error(`refusing local emit because foreign event streams exist: ${foreignStreams.sort().join(', ')}`);
3913
- }
3914
- }
3915
- await ensureLocalProject(root);
3916
- const lane = requireString(flags, 'lane');
3917
- const packet = {
3918
- schema: 'local-packet.v0',
3919
- role,
3920
- lane,
3921
- from: requireString(flags, 'from'),
3922
- to: requireString(flags, 'to'),
3923
- verdict: optionalString(flags, 'verdict') ?? 'routed',
3924
- packet_tier: optionalString(flags, 'tier') ?? '3',
3925
- mode: optionalString(flags, 'mode') ?? 'read-only-audit',
3926
- next_owner: optionalString(flags, 'next-owner') ?? requireString(flags, 'to'),
3927
- created_at: nowIso(flags),
3928
- changes_state: isTrueFlag(flags, 'changes-state'),
3929
- authority_boundary: {
3930
- packet_authorizes_work: false,
3931
- can_merge: false,
3932
- can_close: false,
3933
- can_grant: false,
3934
- },
3935
- };
3936
- const packetName = `${safeName(packet.created_at)}-${safeName(lane)}-${safeName(role)}.json`;
3937
- const packetPath = join(root, 'docs', 'work', 'packets', packetName);
3938
- await writeJson(packetPath, packet);
3939
- if (emit) {
3940
- const event = await appendLocalEvent(root, 'packet_posted', {
3941
- ...packet,
3942
- packet_ref: relativeRef(root, packetPath),
3943
- }, flags);
3944
- await refreshWakeQueuesForEvent(root, event, flags);
3945
- console.log(`packet_posted: ${event.event_ref} ${relativeRef(root, packetPath)}`);
3946
- return;
3947
- }
3948
- console.log(`packet_written: ${relativeRef(root, packetPath)}`);
3949
- }
3950
-
3951
- async function dispatchNotify(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
3952
- const verb = positionals[1];
3953
- const root = projectRoot(flags);
3954
-
3955
- if (verb === 'webhook') {
3956
- const provider = positionals[2] ?? optionalString(flags, 'provider') ?? 'github';
3957
- const wake = await buildGitHubWebhookWake({ ...flags, provider });
3958
- if (flags.json) {
3959
- console.log(JSON.stringify(wake, null, 2));
3960
- return;
3961
- }
3962
- console.log(renderGitHubWebhookWake(wake));
3963
- return;
3964
- }
3965
-
3966
- await ensureLocalProject(root);
3967
-
3968
- if (verb === 'subscribe') {
3969
- const seatInput = requireString(flags, 'seat');
3970
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), seatInput);
3971
- const seat = resolvedSeat.seat;
3972
- const current = await readJsonIfExists<{ schema: string; seat: string; subscriptions: JsonRecord[] }>(
3973
- await subscriptionPath(root, seat),
3974
- { schema: 'seat-subscriptions.v0', seat, subscriptions: [] },
3975
- );
3976
- current.subscriptions.push({
3977
- lane: requireString(flags, 'lane'),
3978
- roles: parseList(flags.roles),
3979
- subscribed_at: nowIso(flags),
3980
- });
3981
- await writeJson(await subscriptionPath(root, seat), current);
3982
- if (resolvedSeat.aliasUsed) console.log(`seat_alias: ${seatInput} -> ${seat}`);
3983
- console.log(`subscribed: ${seat} lane=${requireString(flags, 'lane')}`);
3984
- return;
3985
- }
3986
-
3987
- if (verb === 'feed') {
3988
- const seatInput = requireString(flags, 'seat');
3989
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), seatInput);
3990
- const seat = resolvedSeat.seat;
3991
- const subscriptions = await readJsonIfExists<{ subscriptions: JsonRecord[] }>(
3992
- await subscriptionPath(root, seat),
3993
- { subscriptions: [] },
3994
- );
3995
- const lanes = new Set(subscriptions.subscriptions.map((entry) => stringField(entry, 'lane')).filter((value): value is string => Boolean(value)));
3996
- const events = (await readEvents(root)).filter((event) => event.to === seat || event.next_owner === seat || (typeof event.lane === 'string' && lanes.has(event.lane)));
3997
- console.log([
3998
- `feed: ${seat}`,
3999
- resolvedSeat.aliasUsed ? `seat_alias: ${seatInput} -> ${seat}` : null,
4000
- `subscriptions: ${subscriptions.subscriptions.length}`,
4001
- renderEvents(events),
4002
- ].filter((line): line is string => line !== null).join('\n'));
4003
- return;
4004
- }
4005
-
4006
- if (verb === 'poll') {
4007
- const seatInput = requireString(flags, 'seat');
4008
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), seatInput);
4009
- const seat = resolvedSeat.seat;
4010
- const queue = await buildWakeQueue(root, seat, flags);
4011
- const path = await wakeQueuePath(root, seat);
4012
- await writeJson(path, queue);
4013
- if (flags.json) {
4014
- console.log(JSON.stringify({
4015
- ...queue,
4016
- queue_ref: relativeRef(root, path),
4017
- }, null, 2));
4018
- return;
4019
- }
4020
- console.log([
4021
- resolvedSeat.aliasUsed ? `seat_alias: ${seatInput} -> ${seat}` : null,
4022
- renderWakeQueue(queue),
4023
- `queue_ref: ${relativeRef(root, path)}`,
4024
- ].filter((line): line is string => line !== null).join('\n'));
4025
- return;
4026
- }
4027
-
4028
- if (verb === 'queue') {
4029
- const seatInput = requireString(flags, 'seat');
4030
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), seatInput);
4031
- const seat = resolvedSeat.seat;
4032
- const path = await wakeQueuePath(root, seat);
4033
- const queue = normalizeWakeQueue(await readJsonIfExists<WakeQueue>(path, {
4034
- schema: 'remote-wake-queue.v0',
4035
- seat,
4036
- updated_at: nowIso(flags),
4037
- source: {
4038
- event_log_ref: '.etiquette/events/local.ndjson',
4039
- after_seq: 0,
4040
- max_seq_seen: 0,
4041
- subscriptions_ref: relativeRef(root, await subscriptionPath(root, seat)),
4042
- },
4043
- transport: wakeQueueTransport(),
4044
- payload_boundary: wakeQueuePayloadBoundary(),
4045
- items: [],
4046
- authority_boundary: wakeQueueAuthorityBoundary(),
4047
- }));
4048
- if (flags.json) {
4049
- console.log(JSON.stringify({
4050
- ...queue,
4051
- queue_ref: relativeRef(root, path),
4052
- }, null, 2));
4053
- return;
4054
- }
4055
- console.log([
4056
- resolvedSeat.aliasUsed ? `seat_alias: ${seatInput} -> ${seat}` : null,
4057
- renderWakeQueue(queue),
4058
- ].filter((line): line is string => line !== null).join('\n'));
4059
- return;
4060
- }
4061
-
4062
- throw new Error('notify verb must be one of: subscribe, feed, poll, queue, webhook');
4063
- }
4064
-
4065
- async function dispatchReturn(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
4066
- const verb = positionals[1];
4067
- if (verb !== 'check') throw new Error('return verb must be: check');
4068
- const root = projectRoot(flags);
4069
- const task = optionalString(flags, 'task') ?? requirePositional(positionals, 2, 'task');
4070
- const seatInput = requireString(flags, 'seat');
4071
- assertSharedMemoryScopeId(task, '--task');
4072
- assertSharedMemoryScopeId(seatInput, '--seat');
4073
-
4074
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), seatInput);
4075
- const seat = resolvedSeat.seat;
4076
- const expectedRoles = expectedReturnRoles(flags);
4077
- const tier = returnCheckTier(flags);
4078
- const projection = await buildLocalLedgerView(root, {
4079
- lane: task,
4080
- from: seat,
4081
- kind: 'packets',
4082
- limit: parseOptionalPositiveInt(flags.limit, 'limit', 50),
4083
- computedAt: nowIso(flags),
4084
- });
4085
- const detection = detectReturnPackets(projection, { task, seat, expectedRoles });
4086
- const result: ReturnCheckResult = {
4087
- schema: 'return-enforcement-check.v0',
4088
- task,
4089
- seat,
4090
- seat_alias: resolvedSeat.aliasUsed ? seatInput : null,
4091
- expected_roles: expectedRoles,
4092
- enforcement_tier: tier,
4093
- verdict: detection.verdict,
4094
- completion_gate_hard: tier === 'remote-runner-hard-gate',
4095
- matched_return_packets: detection.matched_return_packets,
4096
- posted_return_packets: detection.posted_return_packets,
4097
- latest_event_ref: detection.latest_event_ref,
4098
- event_log_hash: detection.event_log_hash,
4099
- advisory: detection.verdict === 'pass'
4100
- ? 'posted return found'
4101
- : tier === 'remote-runner-hard-gate'
4102
- ? 'remote runner completion must stop until the seat emits a return packet'
4103
- : 'local advisory only; write the receipt and emit the return packet so the next seat can wake',
4104
- authority_boundary: {
4105
- check_can_authorize: false,
4106
- check_can_execute: false,
4107
- check_can_write: false,
4108
- check_can_post_return: false,
4109
- check_can_merge: false,
4110
- check_can_close: false,
4111
- check_can_grant: false,
4112
- check_can_write_global_ledger: false,
4113
- },
4114
- };
4115
-
4116
- console.log(flags.json ? JSON.stringify(result, null, 2) : renderReturnCheck(result));
4117
- if (tier === 'remote-runner-hard-gate' && detection.verdict !== 'pass') process.exit(1);
4118
- }
4119
-
4120
- async function dispatchRig(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
4121
- const verb = positionals[1];
4122
- if (verb !== 'doctor') throw new Error('rig verb must be: doctor');
4123
- const root = projectRoot(flags);
4124
- await ensureLocalProject(root);
4125
- const required = [
4126
- projectBindingPath(root),
4127
- eventPath(root),
4128
- ledgerPath(root),
4129
- join(root, 'docs', 'work', 'tasks'),
4130
- ];
4131
- const missing = required.filter((path) => !existsSync(path));
4132
- if (missing.length > 0) throw new Error(`rig doctor failed; missing: ${missing.map((path) => relativeRef(root, path)).join(', ')}`);
4133
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
4134
- const sessions = await listSessions(root);
4135
- const mismatched = sessions.filter((session) => session.project_id !== project.project_id);
4136
- if (mismatched.length > 0) {
4137
- throw new Error(`rig doctor failed; session project_id mismatch: ${mismatched.map((session) => String(session.session_id ?? 'unknown')).join(', ')}`);
4138
- }
4139
- const integrityErrors = await collectRosterSessionIntegrityErrors(root, project.project_id);
4140
- if (integrityErrors.length > 0) {
4141
- throw new Error(`rig doctor failed; roster/session integrity: ${integrityErrors.join('; ')}`);
4142
- }
4143
- console.log(`rig_doctor: pass project_id=${project.project_id} roster_session_integrity=pass`);
4144
- }
4145
-
4146
- async function dispatchTimeline(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
4147
- const verb = positionals[1];
4148
- if (verb !== 'view' && verb !== 'preview') throw new Error('timeline verb must be one of: view, preview');
4149
- const root = projectRoot(flags);
4150
- const viewOptions = {
4151
- scope: timelineScope(flags),
4152
- since: optionalString(flags, 'since') ?? null,
4153
- until: optionalString(flags, 'until') ?? null,
4154
- lane: optionalString(flags, 'lane') ?? null,
4155
- seat: optionalString(flags, 'seat') ?? null,
4156
- from: optionalString(flags, 'from') ?? null,
4157
- to: optionalString(flags, 'to') ?? null,
4158
- role: optionalString(flags, 'role') ?? null,
4159
- action: optionalString(flags, 'action') ?? null,
4160
- match: optionalString(flags, 'match') ?? null,
4161
- limit: parseOptionalPositiveInt(flags.limit, 'limit', 20),
4162
- computedAt: nowIso(flags),
4163
- };
4164
-
4165
- if (verb === 'preview') {
4166
- await runTimelinePreviewServer(root, flags, viewOptions);
4167
- return;
4168
- }
4169
-
4170
- const view = await buildTimelineView(root, viewOptions);
4171
- const format = busViewFormat(flags);
4172
- if (format === 'json') {
4173
- console.log(JSON.stringify(view, null, 2));
4174
- return;
4175
- }
4176
- console.log(format === 'markdown' ? renderTimelineViewMarkdown(view) : renderTimelineViewText(view));
4177
- }
4178
-
4179
- function parseTimelinePreviewPort(flags: Record<string, string | boolean>): number {
4180
- const port = parseOptionalPositiveInt(flags.port, 'port', 4174);
4181
- if (port > 65535) throw new Error('--port must be between 1 and 65535');
4182
- return port;
4183
- }
4184
-
4185
- function assertTimelinePreviewLoopbackHost(host: string): string {
4186
- const normalized = host.trim().toLowerCase();
4187
- if (normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1') return normalized;
4188
- throw new Error('--host for timeline preview must be loopback-only in v0: use 127.0.0.1, localhost, or ::1');
4189
- }
4190
-
4191
- async function runTimelinePreviewServer(
4192
- root: string,
4193
- flags: Record<string, string | boolean>,
4194
- viewOptions: Parameters<typeof buildTimelineView>[1],
4195
- ): Promise<never> {
4196
- const host = assertTimelinePreviewLoopbackHost(optionalString(flags, 'host') ?? '127.0.0.1');
4197
- const port = parseTimelinePreviewPort(flags);
4198
- const server = Bun.serve({
4199
- hostname: host,
4200
- port,
4201
- fetch: (request) => timelinePreviewResponseForRequest(
4202
- request,
4203
- () => buildTimelineView(root, viewOptions),
4204
- ),
4205
- });
4206
- console.log(`etiquette timeline preview listening on http://${server.hostname}:${server.port}/`);
4207
- console.log('boundary: read-only loopback GET/HEAD/OPTIONS surface; no auth, hosted backend, execution, write-back, grants, merge, close, or signing');
4208
- return new Promise<never>(() => {
4209
- // Keep the CLI process alive for the browser preview.
4210
- });
4211
- }
4212
-
4213
- async function dispatchBus(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
4214
- const verb = positionals[1];
4215
- const root = projectRoot(flags);
4216
- if (verb === 'view') {
4217
- const view = await buildLocalLedgerView(root, {
4218
- since: optionalString(flags, 'since') ?? null,
4219
- until: optionalString(flags, 'until') ?? null,
4220
- lane: optionalString(flags, 'lane') ?? null,
4221
- seat: optionalString(flags, 'seat') ?? null,
4222
- from: optionalString(flags, 'from') ?? null,
4223
- to: optionalString(flags, 'to') ?? null,
4224
- role: optionalString(flags, 'role') ?? null,
4225
- action: optionalString(flags, 'action') ?? null,
4226
- match: optionalString(flags, 'match') ?? null,
4227
- kind: busViewKind(flags),
4228
- limit: parseOptionalPositiveInt(flags.limit, 'limit', 20),
4229
- computedAt: nowIso(flags),
4230
- });
4231
- const format = busViewFormat(flags);
4232
- if (format === 'json') {
4233
- console.log(JSON.stringify(view, null, 2));
4234
- return;
4235
- }
4236
- console.log(format === 'markdown' ? renderLocalLedgerViewMarkdown(view) : renderLocalLedgerViewText(view));
4237
- return;
4238
- }
4239
- await ensureLocalProject(root);
4240
- const events = await readEvents(root);
4241
-
4242
- if (verb === 'status') {
4243
- const rawLimit = positionals[2] ? Number.parseInt(positionals[2], 10) : 20;
4244
- const limit = Number.isFinite(rawLimit) ? rawLimit : 20;
4245
- console.log(renderEvents(events.slice(-limit)));
4246
- return;
4247
- }
4248
-
4249
- if (verb === 'inbox') {
4250
- const addresseeInput = requireString(flags, 'addressee');
4251
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), addresseeInput, '--addressee');
4252
- const addressee = resolvedSeat.seat;
4253
- const inbox = events.filter((event) => event.to === addressee || event.next_owner === addressee);
4254
- console.log([
4255
- resolvedSeat.aliasUsed ? `addressee_alias: ${addresseeInput} -> ${addressee}` : null,
4256
- renderEvents(inbox),
4257
- ].filter((line): line is string => line !== null).join('\n'));
4258
- return;
4259
- }
4260
-
4261
- throw new Error('bus verb must be one of: status, inbox, view');
4262
- }
4263
-
4264
- async function dispatchStatus(flags: Record<string, string | boolean>): Promise<void> {
4265
- const root = projectRoot(flags);
4266
- await ensureLocalProject(root);
4267
- const project = await ensureProjectBinding(root, optionalString(flags, 'at'));
4268
- const events = await readEvents(root);
4269
- const enrollments = await listJsonFiles(join(root, '.etiquette', 'roster', 'enrollments'));
4270
- console.log([
4271
- 'workspace_status:',
4272
- ` project: ${root}`,
4273
- ` project_id: ${project.project_id}`,
4274
- ` events: ${events.length}`,
4275
- ` enrolled_seats: ${enrollments.length}`,
4276
- ' authority_boundary: read_only_without_grant',
4277
- ].join('\n'));
4278
- }
4279
-
4280
- function classifyRunnerDiagnostic(symptom: string): RunnerDiagnosticKind {
4281
- const normalized = symptom.toLowerCase();
4282
- if (normalized.includes('unsupported content type')) return 'unsupported_content_type';
4283
- if (normalized.includes('content type')) return 'tool_result_transport_error';
4284
- if (normalized.includes('render')) return 'rendering_error';
4285
- return 'unknown_runner_glitch';
4286
- }
4287
-
4288
- function runnerDiagnosticSeverity(
4289
- flags: Record<string, string | boolean>,
4290
- kind: RunnerDiagnosticKind,
4291
- ): RunnerDiagnosticSeverity {
4292
- const explicit = optionalString(flags, 'severity');
4293
- if (explicit) {
4294
- if (explicit !== 'info' && explicit !== 'warning' && explicit !== 'blocked') {
4295
- throw new Error('--severity must be one of: info, warning, blocked');
4296
- }
4297
- return explicit;
4298
- }
4299
- return kind === 'unknown_runner_glitch' ? 'info' : 'warning';
4300
- }
4301
-
4302
- function noReturnDiagnosticSeverity(verdict: ReturnCheckVerdict): RunnerDiagnosticSeverity {
4303
- return verdict === 'pass' ? 'info' : 'warning';
4304
- }
4305
-
4306
- function noReturnNextAction(verdict: ReturnCheckVerdict): string {
4307
- if (verdict === 'pass') return 'none';
4308
- if (verdict === 'unposted_return') return 'emit_return_packet';
4309
- return 'write_and_emit_return_packet';
4310
- }
4311
-
4312
- function sameStringArray(actual: unknown[] | undefined, expected: string[]): boolean {
4313
- if (!actual || actual.length !== expected.length) return false;
4314
- return actual.every((value, index) => value === expected[index]);
4315
- }
4316
-
4317
- function numberField(record: JsonRecord, field: string): number | undefined {
4318
- const value = record[field];
4319
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
4320
- }
4321
-
4322
- async function readNoReturnDiagnostics(path: string): Promise<JsonRecord[]> {
4323
- const raw = await readTextIfExists(path);
4324
- if (!raw) return [];
4325
- return raw
4326
- .split('\n')
4327
- .map((line) => line.trim())
4328
- .filter(Boolean)
4329
- .map((line) => JSON.parse(line) as JsonRecord);
4330
- }
4331
-
4332
- function findExistingNoReturnDiagnostic(
4333
- diagnostics: JsonRecord[],
4334
- expected: {
4335
- task: string;
4336
- seat: string;
4337
- expectedRoles: ReturnRole[];
4338
- verdict: ReturnCheckVerdict;
4339
- severity: RunnerDiagnosticSeverity;
4340
- matchedReturnPackets: number;
4341
- postedReturnPackets: number;
4342
- nextAction: string;
4343
- },
4344
- ): JsonRecord | null {
4345
- const matches = diagnostics.filter((diagnostic) => (
4346
- stringField(diagnostic, 'schema') === 'no-return-diagnostic.v0' &&
4347
- stringField(diagnostic, 'task') === expected.task &&
4348
- stringField(diagnostic, 'seat') === expected.seat &&
4349
- sameStringArray(arrayField(diagnostic, 'expected_roles'), expected.expectedRoles) &&
4350
- stringField(diagnostic, 'verdict') === expected.verdict &&
4351
- stringField(diagnostic, 'severity') === expected.severity &&
4352
- numberField(diagnostic, 'matched_return_packets') === expected.matchedReturnPackets &&
4353
- numberField(diagnostic, 'posted_return_packets') === expected.postedReturnPackets &&
4354
- stringField(diagnostic, 'next_action') === expected.nextAction
4355
- ));
4356
- return matches.length > 0 ? matches[matches.length - 1] : null;
4357
- }
4358
-
4359
- async function dispatchDiagnostics(positionals: string[], flags: Record<string, string | boolean>): Promise<void> {
4360
- const verb = positionals[1];
4361
- const root = projectRoot(flags);
4362
-
4363
- if (verb === 'no-return') {
4364
- if (!isInitializedLocalProject(root)) {
4365
- throw new Error('diagnostics no-return requires an initialized local Etiquette project; run etiquette init --project <path> first');
4366
- }
4367
- const task = optionalString(flags, 'task') ?? requirePositional(positionals, 2, 'task');
4368
- const seatInput = requireString(flags, 'seat');
4369
- assertSharedMemoryScopeId(task, '--task');
4370
- assertSharedMemoryScopeId(seatInput, '--seat');
4371
-
4372
- const resolvedSeat = resolveSeatFromEnrollments(await listEnrollments(root), seatInput);
4373
- const seat = resolvedSeat.seat;
4374
- const expectedRoles = expectedReturnRoles(flags);
4375
- const projection = await buildLocalLedgerView(root, {
4376
- lane: task,
4377
- from: seat,
4378
- kind: 'packets',
4379
- limit: parseOptionalPositiveInt(flags.limit, 'limit', 50),
4380
- computedAt: nowIso(flags),
4381
- });
4382
- const detection = detectReturnPackets(projection, { task, seat, expectedRoles });
4383
- const severity = noReturnDiagnosticSeverity(detection.verdict);
4384
- const nextAction = noReturnNextAction(detection.verdict);
4385
- const diagnosticPath = join(root, '.etiquette', 'diagnostics', 'no-return.ndjson');
4386
- const existingDiagnostic = findExistingNoReturnDiagnostic(await readNoReturnDiagnostics(diagnosticPath), {
4387
- task,
4388
- seat,
4389
- expectedRoles,
4390
- verdict: detection.verdict,
4391
- severity,
4392
- matchedReturnPackets: detection.matched_return_packets.length,
4393
- postedReturnPackets: detection.posted_return_packets.length,
4394
- nextAction,
4395
- });
4396
- if (existingDiagnostic) {
4397
- const output = {
4398
- ...existingDiagnostic,
4399
- deduped: true,
4400
- diagnostic_ref: relativeRef(root, diagnosticPath),
4401
- };
4402
- if (flags.json) {
4403
- console.log(JSON.stringify(output, null, 2));
4404
- return;
4405
- }
4406
- console.log([
4407
- `no_return_diagnostic: ${stringField(existingDiagnostic, 'diagnostic_id') ?? 'existing'}`,
4408
- `event: ${stringField(existingDiagnostic, 'event_ref') ?? 'existing'}`,
4409
- `verdict: ${detection.verdict}`,
4410
- `severity: ${severity}`,
4411
- `task: ${task}`,
4412
- `seat: ${seat}`,
4413
- `diagnostic_ref: ${output.diagnostic_ref}`,
4414
- 'deduped: true',
4415
- 'can_authorize: false',
4416
- `next_action: ${nextAction}`,
4417
- ].join('\n'));
4418
- return;
4419
- }
4420
- await mkdir(dirname(diagnosticPath), { recursive: true });
4421
- const event = await appendLocalEvent(root, 'no_return_diagnostic', {
4422
- diagnostic_schema: 'no-return-diagnostic.v0',
4423
- task,
4424
- seat,
4425
- seat_alias: resolvedSeat.aliasUsed ? seatInput : null,
4426
- expected_roles: expectedRoles,
4427
- verdict: detection.verdict,
4428
- severity,
4429
- matched_return_packets: detection.matched_return_packets.length,
4430
- posted_return_packets: detection.posted_return_packets.length,
4431
- latest_event_ref: detection.latest_event_ref,
4432
- event_log_hash: detection.event_log_hash,
4433
- next_action: nextAction,
4434
- authority_boundary: {
4435
- diagnostic_can_authorize: false,
4436
- diagnostic_can_merge: false,
4437
- diagnostic_can_close: false,
4438
- diagnostic_can_promote: false,
4439
- diagnostic_can_post_return: false,
4440
- diagnostic_can_write_global_ledger: false,
4441
- },
4442
- }, flags);
4443
- const diagnostic = {
4444
- schema: 'no-return-diagnostic.v0',
4445
- diagnostic_id: `no-return-${event.seq}`,
4446
- event_ref: event.event_ref,
4447
- created_at: event.created_at,
4448
- task,
4449
- seat,
4450
- seat_alias: resolvedSeat.aliasUsed ? seatInput : null,
4451
- expected_roles: expectedRoles,
4452
- verdict: detection.verdict,
4453
- severity,
4454
- matched_return_packets: detection.matched_return_packets.length,
4455
- posted_return_packets: detection.posted_return_packets.length,
4456
- latest_event_ref: detection.latest_event_ref,
4457
- event_log_hash: detection.event_log_hash,
4458
- next_action: nextAction,
4459
- authority_boundary: {
4460
- diagnostic_can_authorize: false,
4461
- diagnostic_can_merge: false,
4462
- diagnostic_can_close: false,
4463
- diagnostic_can_promote: false,
4464
- diagnostic_can_post_return: false,
4465
- diagnostic_can_write_global_ledger: false,
4466
- },
4467
- };
4468
- await appendFile(diagnosticPath, `${JSON.stringify(diagnostic)}\n`);
4469
- const output = {
4470
- ...diagnostic,
4471
- deduped: false,
4472
- diagnostic_ref: relativeRef(root, diagnosticPath),
4473
- };
4474
- if (flags.json) {
4475
- console.log(JSON.stringify(output, null, 2));
4476
- return;
4477
- }
4478
-
4479
- console.log([
4480
- `no_return_diagnostic: ${diagnostic.diagnostic_id}`,
4481
- `event: ${event.event_ref}`,
4482
- `verdict: ${detection.verdict}`,
4483
- `severity: ${severity}`,
4484
- `task: ${task}`,
4485
- `seat: ${seat}`,
4486
- `diagnostic_ref: ${output.diagnostic_ref}`,
4487
- 'can_authorize: false',
4488
- `next_action: ${nextAction}`,
4489
- ].join('\n'));
4490
- return;
4491
- }
4492
-
4493
- await ensureLocalProject(root);
4494
- if (verb !== 'runner-glitch') throw new Error('diagnostics verb must be one of: runner-glitch, no-return');
4495
-
4496
- const runner = optionalString(flags, 'runner') ?? 'codex';
4497
- const symptom = requireString(flags, 'symptom');
4498
- const sessionId = optionalString(flags, 'session');
4499
- const commandRef = optionalString(flags, 'command-ref');
4500
- assertSharedMemoryScopeId(runner, '--runner');
4501
- assertSharedMemoryText(symptom, '--symptom');
4502
- if (sessionId) {
4503
- assertSharedMemoryScopeId(sessionId, '--session');
4504
- const sessionManifest = join(root, 'docs', 'work', 'sessions', sessionId, 'session.json');
4505
- if (!existsSync(sessionManifest)) throw new Error(`session does not exist: ${sessionId}`);
4506
- }
4507
- if (commandRef) assertSharedMemoryText(commandRef, '--command-ref');
4508
-
4509
- const kind = classifyRunnerDiagnostic(symptom);
4510
- const severity = runnerDiagnosticSeverity(flags, kind);
4511
- const event = await appendLocalEvent(root, 'runner_glitch_diagnostic', {
4512
- diagnostic_schema: 'runner-glitch-diagnostic.v0',
4513
- runner,
4514
- kind,
4515
- severity,
4516
- symptom,
4517
- command_ref: commandRef ?? null,
4518
- session_id: sessionId ?? null,
4519
- next_action: 'verify_underlying_command_directly',
4520
- authority_boundary: {
4521
- diagnostic_can_authorize: false,
4522
- diagnostic_can_merge: false,
4523
- diagnostic_can_close: false,
4524
- diagnostic_can_promote: false,
4525
- diagnostic_can_write_global_ledger: false,
4526
- },
4527
- }, flags);
4528
-
4529
- const diagnostic = {
4530
- schema: 'runner-glitch-diagnostic.v0',
4531
- diagnostic_id: `runner-glitch-${event.seq}`,
4532
- event_ref: event.event_ref,
4533
- created_at: event.created_at,
4534
- runner,
4535
- kind,
4536
- severity,
4537
- symptom,
4538
- command_ref: commandRef ?? null,
4539
- session_id: sessionId ?? null,
4540
- next_action: 'verify_underlying_command_directly',
4541
- authority_boundary: {
4542
- diagnostic_can_authorize: false,
4543
- diagnostic_can_merge: false,
4544
- diagnostic_can_close: false,
4545
- diagnostic_can_promote: false,
4546
- diagnostic_can_write_global_ledger: false,
4547
- },
4548
- };
4549
-
4550
- const diagnosticPath = join(root, '.etiquette', 'diagnostics', 'runner-glitches.ndjson');
4551
- await appendFile(diagnosticPath, `${JSON.stringify(diagnostic)}\n`);
4552
-
4553
- let sessionNoteRef: string | null = null;
4554
- if (sessionId) {
4555
- const note = [
4556
- `Runner diagnostic ${diagnostic.diagnostic_id} recorded ${kind};`,
4557
- 'verify underlying command directly before treating client output as truth.',
4558
- commandRef ? `Command ref: ${commandRef}.` : '',
4559
- ].filter(Boolean).join(' ');
4560
- const result = await appendSessionRunbookNote({
4561
- projectRoot: root,
4562
- sessionId,
4563
- section: 'retry',
4564
- message: note,
4565
- at: optionalString(flags, 'at'),
4566
- });
4567
- sessionNoteRef = relativeRef(root, result.runbookPath);
4568
- }
4569
-
4570
- const output = {
4571
- ...diagnostic,
4572
- diagnostic_ref: relativeRef(root, diagnosticPath),
4573
- session_note_ref: sessionNoteRef,
4574
- };
4575
- if (flags.json) {
4576
- console.log(JSON.stringify(output, null, 2));
4577
- return;
4578
- }
4579
-
4580
- console.log([
4581
- `runner_glitch_diagnostic: ${diagnostic.diagnostic_id}`,
4582
- `event: ${event.event_ref}`,
4583
- `kind: ${kind}`,
4584
- `severity: ${severity}`,
4585
- `runner: ${runner}`,
4586
- `diagnostic_ref: ${output.diagnostic_ref}`,
4587
- `session_note_ref: ${sessionNoteRef ?? 'none'}`,
4588
- 'can_authorize: false',
4589
- 'next_action: verify_underlying_command_directly',
4590
- ].join('\n'));
4591
- }
4592
-
4593
- export async function dispatchLocalWorkflow(
4594
- positionals: string[],
4595
- flags: Record<string, string | boolean>,
4596
- ): Promise<void> {
4597
- const command = positionals[0];
4598
- switch (command) {
4599
- case 'setup':
4600
- await dispatchSetup(flags);
4601
- return;
4602
- case 'handoff':
4603
- await dispatchHandoff(flags);
4604
- return;
4605
- case 'init':
4606
- await dispatchInit(flags);
4607
- return;
4608
- case 'bootstrap':
4609
- await dispatchBootstrap(positionals, flags);
4610
- return;
4611
- case 'whereami':
4612
- await dispatchWhereami(flags);
4613
- return;
4614
- case 'join':
4615
- await dispatchJoin(flags);
4616
- return;
4617
- case 'pickup':
4618
- await dispatchPickup(flags);
4619
- return;
4620
- case 'enter':
4621
- await dispatchEnter(positionals, flags);
4622
- return;
4623
- case 'audit':
4624
- await dispatchAudit(positionals, flags);
4625
- return;
4626
- case 'roster':
4627
- await dispatchRoster(positionals, flags);
4628
- return;
4629
- case 'post':
4630
- await dispatchPost(flags);
4631
- return;
4632
- case 'notify':
4633
- await dispatchNotify(positionals, flags);
4634
- return;
4635
- case 'return':
4636
- await dispatchReturn(positionals, flags);
4637
- return;
4638
- case 'retire':
4639
- await retirePath(requirePositional(positionals, 1, 'path'), flags);
4640
- return;
4641
- case 'rig':
4642
- await dispatchRig(positionals, flags);
4643
- return;
4644
- case 'doctor':
4645
- await dispatchRig(['rig', 'doctor'], flags);
4646
- return;
4647
- case 'bus':
4648
- await dispatchBus(positionals, flags);
4649
- return;
4650
- case 'timeline':
4651
- await dispatchTimeline(positionals, flags);
4652
- return;
4653
- case 'status':
4654
- await dispatchStatus(flags);
4655
- return;
4656
- case 'diagnostics':
4657
- await dispatchDiagnostics(positionals, flags);
4658
- return;
4659
- case 'trash':
4660
- await dispatchTrash(positionals, flags);
4661
- return;
4662
- default:
4663
- throw new Error(`unknown local workflow command: ${command}`);
4664
- }
4665
- }
4666
-
4667
- export async function renderLocalBoard(projectRoot: string, seat?: string): Promise<string> {
4668
- const root = resolve(projectRoot);
4669
- await ensureLocalProject(root);
4670
- const resolvedSeat = seat ? resolveSeatFromEnrollments(await listEnrollments(root), seat) : null;
4671
- const boardSeat = resolvedSeat?.seat ?? null;
4672
- const board = await readTextIfExists(join(root, 'docs', 'work', 'BOARD.md'));
4673
- const events = await readEvents(root);
4674
- const lines = [
4675
- `console_board: ${boardSeat ?? 'all-seats'}`,
4676
- resolvedSeat?.aliasUsed ? `seat_alias: ${resolvedSeat.requestedSeat} -> ${boardSeat}` : null,
4677
- '',
4678
- board?.trim() || 'No docs/work/BOARD.md found.',
4679
- '',
4680
- 'Recent events:',
4681
- renderEvents(events.slice(-10)),
4682
- '',
4683
- 'authority_boundary: read-only; no grants, merge, or close',
4684
- ];
4685
- return lines.filter((line): line is string => line !== null).join('\n');
4686
- }