@etiquekit/etq 1.0.11 → 1.0.13

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 (149) hide show
  1. package/AGENTS.md +19 -26
  2. package/LICENSE +2 -3
  3. package/NOTICE +6 -4
  4. package/QuickStart.md +35 -47
  5. package/README.md +54 -56
  6. package/bin/etiquette +1 -5
  7. package/bin/etiquette-core +1 -5
  8. package/docs/ARCHITECTURE.md +5 -5
  9. package/docs/CODEX_CLIENT_COMPATIBILITY.md +1 -1
  10. package/docs/CONCEPTS.md +7 -23
  11. package/docs/CONCEPT_STATUS.md +66 -0
  12. package/docs/CORE_PROFILE.md +3 -5
  13. package/docs/LANE_PROVISIONING.md +7 -2
  14. package/docs/README.md +4 -4
  15. package/docs/RELEASE_SURFACE_AUDIT.md +7 -6
  16. package/docs/SEAT_DISCIPLINE.md +55 -91
  17. package/docs/SEAT_PROVISIONING.md +19 -22
  18. package/docs/TEAM_HANDOFF.md +21 -21
  19. package/docs/WORKTREE_QOL.md +7 -6
  20. package/docs/contracts/ledger-entry/README.md +11 -11
  21. package/docs/contracts/ledger-entry/ledger-entry.v0.2.md +3 -3
  22. package/docs/contracts/ledger-entry/ledger-entry.v0.md +8 -8
  23. package/package.json +11 -11
  24. package/packages/control/src/authority/lease.ts +261 -0
  25. package/packages/control/src/authority/node-delegation.ts +257 -0
  26. package/packages/control/src/authority/rig-conductor.ts +632 -0
  27. package/packages/control/src/cli/argv.ts +155 -0
  28. package/packages/control/src/cli/commands/console.ts +200 -0
  29. package/packages/control/src/cli/commands/dispatch-core.ts +89 -0
  30. package/packages/control/src/cli/commands/dispatch.ts +279 -0
  31. package/packages/control/src/cli/commands/harness.ts +89 -0
  32. package/packages/control/src/cli/commands/hook.ts +50 -0
  33. package/packages/control/src/cli/commands/ledger.ts +91 -0
  34. package/packages/control/src/cli/commands/local-workflow.ts +4686 -0
  35. package/packages/control/src/cli/commands/memory.ts +445 -0
  36. package/packages/control/src/cli/commands/release.ts +108 -0
  37. package/packages/control/src/cli/commands/rubric.ts +103 -0
  38. package/packages/control/src/cli/commands/seat.ts +179 -0
  39. package/packages/control/src/cli/commands/session.ts +127 -0
  40. package/packages/control/src/cli/commands/supervision.ts +246 -0
  41. package/packages/control/src/cli/commands/sync.ts +119 -0
  42. package/packages/control/src/cli/commands/workflow.ts +86 -0
  43. package/packages/control/src/cli/commands/workspace.ts +50 -0
  44. package/packages/control/src/cli/core-usage.ts +34 -0
  45. package/packages/control/src/cli/prompt.ts +67 -0
  46. package/packages/control/src/cli/supervision-deps.ts +44 -0
  47. package/packages/control/src/cli/usage.ts +241 -0
  48. package/packages/control/src/cli.ts +207 -0
  49. package/packages/control/src/core-cli.ts +50 -0
  50. package/packages/control/src/dispatch/decision.ts +202 -0
  51. package/packages/control/src/dispatch/projection.ts +293 -0
  52. package/packages/control/src/dispatch/record.ts +153 -0
  53. package/packages/control/src/engagement/project.ts +170 -0
  54. package/packages/control/src/fs.ts +19 -0
  55. package/packages/control/src/harness/pruning.ts +406 -0
  56. package/packages/control/src/hooks/dispatcher.ts +117 -0
  57. package/packages/control/src/hooks/outbox.ts +86 -0
  58. package/packages/control/src/hooks/sanitize.ts +6 -0
  59. package/packages/control/src/hooks/types.ts +34 -0
  60. package/packages/control/src/index.ts +384 -0
  61. package/packages/control/src/ledger/entry.ts +303 -0
  62. package/packages/control/src/ledger/indexer.ts +542 -0
  63. package/packages/control/src/memory/context.ts +149 -0
  64. package/packages/control/src/memory/drain-import.ts +207 -0
  65. package/packages/control/src/memory/indexer.ts +284 -0
  66. package/packages/control/src/memory/query.ts +75 -0
  67. package/packages/control/src/memory/sanitize.ts +50 -0
  68. package/packages/control/src/memory/sharded-drain-import.ts +212 -0
  69. package/packages/control/src/memory/status.ts +211 -0
  70. package/packages/control/src/memory/store-lifecycle.ts +509 -0
  71. package/packages/control/src/memory/store.ts +284 -0
  72. package/packages/control/src/memory/types.ts +146 -0
  73. package/packages/control/src/parity/surfaces.ts +748 -0
  74. package/packages/control/src/project.ts +141 -0
  75. package/packages/control/src/projection/local-ledger-view.ts +373 -0
  76. package/packages/control/src/projection/return-enforcement.ts +48 -0
  77. package/packages/control/src/projection/timeline-preview.ts +539 -0
  78. package/packages/control/src/projection/timeline.ts +708 -0
  79. package/packages/control/src/release/readiness.ts +831 -0
  80. package/packages/control/src/rubric/loader.ts +326 -0
  81. package/packages/control/src/rubric/promotion.ts +54 -0
  82. package/packages/control/src/rubric/runner.ts +159 -0
  83. package/packages/control/src/rubric/types.ts +158 -0
  84. package/packages/control/src/seat/owner-card.ts +388 -0
  85. package/packages/control/src/seat/readiness.ts +834 -0
  86. package/packages/control/src/session/runbook.ts +431 -0
  87. package/packages/control/src/shared/sanitize.ts +49 -0
  88. package/packages/control/src/supervision/action-classes.ts +192 -0
  89. package/packages/control/src/supervision/command-apply.ts +378 -0
  90. package/packages/control/src/supervision/errors.ts +14 -0
  91. package/packages/control/src/supervision/event-replay.ts +155 -0
  92. package/packages/control/src/supervision/events.ts +109 -0
  93. package/packages/control/src/supervision/index.ts +16 -0
  94. package/packages/control/src/supervision/manifest.ts +127 -0
  95. package/packages/control/src/supervision/paths.ts +49 -0
  96. package/packages/control/src/supervision/projection-adapter.ts +274 -0
  97. package/packages/control/src/supervision/projection.ts +75 -0
  98. package/packages/control/src/supervision/rebuild.ts +99 -0
  99. package/packages/control/src/supervision/session-open.ts +131 -0
  100. package/packages/control/src/supervision/session-read.ts +99 -0
  101. package/packages/control/src/supervision/sqlite-impl.ts +71 -0
  102. package/packages/control/src/supervision/sqlite.ts +121 -0
  103. package/packages/control/src/supervision/store-rows.ts +371 -0
  104. package/packages/control/src/supervision/turn-close.ts +154 -0
  105. package/packages/control/src/supervision/turn-open.ts +284 -0
  106. package/packages/control/src/sync/event-log-merge-driver.ts +263 -0
  107. package/packages/control/src/sync/join-plan.ts +375 -0
  108. package/packages/control/src/sync/outbox.ts +492 -0
  109. package/packages/control/src/workflow/evaluator.ts +140 -0
  110. package/packages/control/src/workflow/loader.ts +200 -0
  111. package/packages/control/src/workflow/types.ts +90 -0
  112. package/packages/control/src/workspace/authority.ts +499 -0
  113. package/packages/protocol/src/guards.ts +119 -0
  114. package/packages/protocol/src/huddle-board.ts +198 -0
  115. package/packages/protocol/src/huddle.ts +295 -0
  116. package/packages/protocol/src/incident.ts +251 -0
  117. package/packages/protocol/src/index.ts +8 -0
  118. package/packages/protocol/src/interfaces.ts +107 -0
  119. package/packages/protocol/src/packet-profile.ts +195 -0
  120. package/packages/protocol/src/state.ts +81 -0
  121. package/packages/protocol/src/types.ts +434 -0
  122. package/release/lineage.v0.json +14 -0
  123. package/scripts/release-candidate-verify.sh +175 -0
  124. package/scripts/release-checksum.sh +25 -0
  125. package/scripts/release-pack-canary.sh +97 -0
  126. package/scripts/release-scan.sh +249 -0
  127. package/scripts/release-sign.sh +34 -0
  128. package/templates/etiquette-vanilla-v0/README.md +2 -3
  129. package/templates/etiquette-vanilla-v0/source/control-seat/README.md +0 -6
  130. package/templates/etiquette-vanilla-v0/source/control-seat/bin/seat-doctor +0 -5
  131. package/templates/etiquette-vanilla-v0/validate-vanilla.sh +0 -5
  132. package/templates/seat-packs-v0/README.md +3 -2
  133. package/templates/seat-packs-v0/source/claude-code-seat/README.md +3 -29
  134. package/templates/seat-packs-v0/source/codex-seat/README.md +3 -17
  135. package/templates/seat-packs-v0/source/gemini-seat/README.md +3 -23
  136. package/templates/seat-packs-v0/source/ollama-seat/README.md +3 -3
  137. package/templates/seat-packs-v0/source/openrouter-seat/README.md +3 -3
  138. package/docs/AGENT_SPAWNING.md +0 -78
  139. package/docs/INTEGRATION_UI.md +0 -215
  140. package/docs/OPERATOR_PLAYBOOK.md +0 -77
  141. package/docs/TAG_ROUTE.md +0 -95
  142. package/lib/etiquette-core.js +0 -315
  143. package/lib/etiquette.js +0 -1160
  144. package/templates/DISPATCH_PREMISES.md +0 -39
  145. package/templates/etiquette-vanilla-v0/source/control-seat/bin/access-assurance-check +0 -55
  146. package/templates/etiquette-vanilla-v0/source/control-seat/docs/work/access/ACCESS-ASSURANCE.md +0 -39
  147. package/templates/etiquette-vanilla-v0/source/control-seat/docs/work/access/workspace-secure-profile.v0.json +0 -53
  148. package/templates/hosted-receiver/README.md +0 -41
  149. package/templates/hosted-receiver/w1-github-webhook-receiver.mjs +0 -129
@@ -0,0 +1,293 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import type { OperationalDecision } from './decision';
5
+ import type { OperationalDispatchRecord } from './record';
6
+
7
+ export const HOSTED_RECEIPT_STATUSES = ['success', 'partial', 'blocked', 'failed'] as const;
8
+
9
+ export type HostedReceiptStatus = typeof HOSTED_RECEIPT_STATUSES[number];
10
+
11
+ export interface HostedReceiptMetadataProjection {
12
+ schema: 'hosted-receipt-metadata-projection.v0';
13
+ projection_id: string;
14
+ source_schema: 'operational-dispatch-record.v0';
15
+ source_record_id: string;
16
+ hosted_payload: {
17
+ task_id: string;
18
+ seat_id: string;
19
+ status: HostedReceiptStatus;
20
+ artifact_ref?: string;
21
+ receipt_ref: string;
22
+ validation_summary: string;
23
+ };
24
+ authority_boundary: {
25
+ hosted_plane_can_execute: false;
26
+ hosted_plane_can_authorize: false;
27
+ hosted_plane_can_attach_grant: false;
28
+ hosted_plane_can_change_local_ledger: false;
29
+ };
30
+ projection_boundary: {
31
+ metadata_only: true;
32
+ raw_receipt_body_included: false;
33
+ source_decision_included: false;
34
+ local_paths_included: false;
35
+ };
36
+ }
37
+
38
+ const PATH_FRAGMENTS = [
39
+ '/U' + 'sers/',
40
+ '/h' + 'ome/',
41
+ '$H' + 'OME',
42
+ '/pr' + 'ivate/',
43
+ '/t' + 'mp/',
44
+ ] as const;
45
+
46
+ function trimString(value: unknown, name: string): string {
47
+ if (typeof value !== 'string') {
48
+ throw new Error(`${name} must be a string`);
49
+ }
50
+ return value.trim();
51
+ }
52
+
53
+ function hasPathShape(value: string): boolean {
54
+ return PATH_FRAGMENTS.some((fragment) => value.includes(fragment))
55
+ || /^[A-Za-z]:[\\/]/.test(value)
56
+ || value.startsWith('/')
57
+ || value.startsWith('\\');
58
+ }
59
+
60
+ function hasLeakagePathShape(value: string): boolean {
61
+ return PATH_FRAGMENTS.some((fragment) => value.includes(fragment))
62
+ || /^[A-Za-z]:[\\/]/.test(value);
63
+ }
64
+
65
+ function hasHostnameShape(value: string): boolean {
66
+ return /\.(local|lan|internal|intranet|localdomain)(\/.*)?$/i.test(value)
67
+ || /^[0-9]{1,3}(\.[0-9]{1,3}){3}/.test(value)
68
+ || /\b[0-9a-f]{1,4}:[0-9a-f]{1,4}:/i.test(value);
69
+ }
70
+
71
+ function hasCredentialShape(value: string): boolean {
72
+ return /(api[_-]?key|secret|token|password|credential)/i.test(value);
73
+ }
74
+
75
+ function hasPromptOrToolShape(value: string): boolean {
76
+ return /(raw[_ -]?prompt|tool[_ -]?output|tool_use|assistant_thought|chain[_ -]?of[_ -]?thought)/i.test(value);
77
+ }
78
+
79
+ function assertCleanSharedString(value: string, name: string): void {
80
+ if (value.length === 0) {
81
+ throw new Error(`${name} must not be empty`);
82
+ }
83
+ if (/[\s\x00-\x1f\x7f]/.test(value)) {
84
+ throw new Error(`${name} must not contain whitespace or control characters`);
85
+ }
86
+ if (hasLeakagePathShape(value)) {
87
+ throw new Error(`${name} must not contain local path markers`);
88
+ }
89
+ if (hasHostnameShape(value)) {
90
+ throw new Error(`${name} must not contain hostnames or IP addresses`);
91
+ }
92
+ if (hasCredentialShape(value)) {
93
+ throw new Error(`${name} must not contain credential-shaped text`);
94
+ }
95
+ if (hasPromptOrToolShape(value)) {
96
+ throw new Error(`${name} must not contain prompt/tool-output markers`);
97
+ }
98
+ }
99
+
100
+ function sanitizeOpaqueRef(value: unknown, name: string, maxLength: number): string {
101
+ const v = trimString(value, name);
102
+ if (v.length > maxLength) {
103
+ throw new Error(`${name} length must be <= ${maxLength}`);
104
+ }
105
+ assertCleanSharedString(v, name);
106
+ if (/[^A-Za-z0-9._:-]/.test(v)) {
107
+ throw new Error(`${name} contains unsafe characters`);
108
+ }
109
+ return v;
110
+ }
111
+
112
+ function sanitizeRepoRelative(value: unknown, name: string): string {
113
+ const v = trimString(value, name);
114
+ if (v.length > 256) {
115
+ throw new Error(`${name} length must be <= 256`);
116
+ }
117
+ if (hasPathShape(v)) {
118
+ throw new Error(`${name} must be repo-relative and must not contain local path markers`);
119
+ }
120
+ if (hasHostnameShape(v)) {
121
+ throw new Error(`${name} must not contain hostnames or IP addresses`);
122
+ }
123
+ if (hasCredentialShape(v) || hasPromptOrToolShape(v)) {
124
+ throw new Error(`${name} must not contain sensitive markers`);
125
+ }
126
+ return v;
127
+ }
128
+
129
+ function sanitizeArtifactRef(value: unknown, name: string): string {
130
+ const v = trimString(value, name);
131
+ if (v.length > 128) {
132
+ throw new Error(`${name} length must be <= 128`);
133
+ }
134
+ if (hasPathShape(v)) {
135
+ throw new Error(`${name} must not be an absolute path or local path marker`);
136
+ }
137
+ if (hasHostnameShape(v)) {
138
+ throw new Error(`${name} must not contain hostnames or IP addresses`);
139
+ }
140
+ if (hasCredentialShape(v) || hasPromptOrToolShape(v)) {
141
+ throw new Error(`${name} must not contain sensitive markers`);
142
+ }
143
+ return v;
144
+ }
145
+
146
+ function sanitizeSummary(value: string): string {
147
+ const trimmed = value.trim().slice(0, 200);
148
+ if (hasLeakagePathShape(trimmed) || hasHostnameShape(trimmed)) {
149
+ throw new Error('--validation-summary must not contain path or hostname markers');
150
+ }
151
+ if (hasCredentialShape(trimmed) || hasPromptOrToolShape(trimmed)) {
152
+ throw new Error('--validation-summary must not contain sensitive markers');
153
+ }
154
+ return trimmed;
155
+ }
156
+
157
+ function assertStringArray(value: unknown, name: string): string[] {
158
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
159
+ throw new Error(`${name} must be a string array`);
160
+ }
161
+ return value;
162
+ }
163
+
164
+ function assertOperationalDecision(value: unknown): OperationalDecision {
165
+ if (
166
+ value !== 'dispatch_ready'
167
+ && value !== 'hold_for_grant'
168
+ && value !== 'blocked_by_capability'
169
+ && value !== 'blocked_by_validation'
170
+ && value !== 'quarantine_hold'
171
+ ) {
172
+ throw new Error('record.decision must be an operational dispatch decision');
173
+ }
174
+ return value;
175
+ }
176
+
177
+ function assertRecordAuthorityBoundary(value: unknown): OperationalDispatchRecord['authority_boundary'] {
178
+ if (!value || typeof value !== 'object') {
179
+ throw new Error('record.authority_boundary must be an object');
180
+ }
181
+ const boundary = value as Record<string, unknown>;
182
+ const expectedFalse = [
183
+ 'can_dispatch',
184
+ 'can_authorize',
185
+ 'can_attach_grant',
186
+ 'can_execute',
187
+ 'consumer_is_engine',
188
+ ];
189
+ for (const key of expectedFalse) {
190
+ if (boundary[key] !== false) {
191
+ throw new Error(`record.authority_boundary.${key} must be false`);
192
+ }
193
+ }
194
+ return boundary as OperationalDispatchRecord['authority_boundary'];
195
+ }
196
+
197
+ function assertHostedReceiptStatus(value: string): HostedReceiptStatus {
198
+ if (!(HOSTED_RECEIPT_STATUSES as readonly string[]).includes(value)) {
199
+ throw new Error(`--status must be one of: ${HOSTED_RECEIPT_STATUSES.join(', ')}`);
200
+ }
201
+ return value as HostedReceiptStatus;
202
+ }
203
+
204
+ function defaultHostedStatus(decision: OperationalDecision): HostedReceiptStatus {
205
+ return decision === 'dispatch_ready' ? 'success' : 'blocked';
206
+ }
207
+
208
+ function defaultValidationSummary(record: OperationalDispatchRecord): string {
209
+ const reasons = record.reasons.join(',') || 'none';
210
+ const warnings = record.warnings.join(',') || 'none';
211
+ return sanitizeSummary(`dispatch=${record.decision}; reasons=${reasons}; warnings=${warnings}; authority=advisory-only`);
212
+ }
213
+
214
+ function projectionId(payload: HostedReceiptMetadataProjection['hosted_payload'], recordId: string): string {
215
+ return `hosted-receipt:${createHash('sha256')
216
+ .update(JSON.stringify({ recordId, payload }))
217
+ .digest('hex')
218
+ .slice(0, 12)}`;
219
+ }
220
+
221
+ function parseOperationalDispatchRecord(raw: unknown): OperationalDispatchRecord {
222
+ if (!raw || typeof raw !== 'object') {
223
+ throw new Error('record must be a YAML object');
224
+ }
225
+ const record = raw as Record<string, unknown>;
226
+ if (record.schema !== 'operational-dispatch-record.v0') {
227
+ throw new Error('record schema must be operational-dispatch-record.v0');
228
+ }
229
+ return {
230
+ schema: 'operational-dispatch-record.v0',
231
+ record_id: sanitizeOpaqueRef(record.record_id, 'record.record_id', 128),
232
+ recorded_at: trimString(record.recorded_at, 'record.recorded_at'),
233
+ recorded_by: sanitizeOpaqueRef(record.recorded_by, 'record.recorded_by', 64),
234
+ task_ref: sanitizeOpaqueRef(record.task_ref, 'record.task_ref', 128),
235
+ seat: sanitizeOpaqueRef(record.seat, 'record.seat', 64),
236
+ decision: assertOperationalDecision(record.decision),
237
+ reasons: assertStringArray(record.reasons, 'record.reasons'),
238
+ warnings: assertStringArray(record.warnings, 'record.warnings'),
239
+ profile_hint: record.profile_hint as OperationalDispatchRecord['profile_hint'],
240
+ receipt_ref: sanitizeRepoRelative(record.receipt_ref, 'record.receipt_ref'),
241
+ ledger_ref: sanitizeRepoRelative(record.ledger_ref, 'record.ledger_ref'),
242
+ authority_boundary: assertRecordAuthorityBoundary(record.authority_boundary),
243
+ source_decision: record.source_decision as OperationalDispatchRecord['source_decision'],
244
+ };
245
+ }
246
+
247
+ export async function loadOperationalDispatchRecord(path: string): Promise<OperationalDispatchRecord> {
248
+ return parseOperationalDispatchRecord(parseYaml(await readFile(path, 'utf-8')));
249
+ }
250
+
251
+ export function projectDispatchRecordToHostedReceipt(options: {
252
+ record: OperationalDispatchRecord;
253
+ artifactRef?: string;
254
+ receiptRef?: string;
255
+ status?: string;
256
+ validationSummary?: string;
257
+ }): HostedReceiptMetadataProjection {
258
+ const status = options.status
259
+ ? assertHostedReceiptStatus(options.status)
260
+ : defaultHostedStatus(options.record.decision);
261
+ const payload: HostedReceiptMetadataProjection['hosted_payload'] = {
262
+ task_id: sanitizeOpaqueRef(options.record.task_ref, 'hosted_payload.task_id', 128),
263
+ seat_id: sanitizeOpaqueRef(options.record.seat, 'hosted_payload.seat_id', 64),
264
+ status,
265
+ receipt_ref: sanitizeRepoRelative(options.receiptRef ?? options.record.receipt_ref, 'hosted_payload.receipt_ref'),
266
+ validation_summary: sanitizeSummary(options.validationSummary ?? defaultValidationSummary(options.record)),
267
+ };
268
+ const artifactRef = options.artifactRef
269
+ ? sanitizeArtifactRef(options.artifactRef, 'hosted_payload.artifact_ref')
270
+ : undefined;
271
+ if (artifactRef) {
272
+ payload.artifact_ref = artifactRef;
273
+ }
274
+ return {
275
+ schema: 'hosted-receipt-metadata-projection.v0',
276
+ projection_id: projectionId(payload, options.record.record_id),
277
+ source_schema: 'operational-dispatch-record.v0',
278
+ source_record_id: options.record.record_id,
279
+ hosted_payload: payload,
280
+ authority_boundary: {
281
+ hosted_plane_can_execute: false,
282
+ hosted_plane_can_authorize: false,
283
+ hosted_plane_can_attach_grant: false,
284
+ hosted_plane_can_change_local_ledger: false,
285
+ },
286
+ projection_boundary: {
287
+ metadata_only: true,
288
+ raw_receipt_body_included: false,
289
+ source_decision_included: false,
290
+ local_paths_included: false,
291
+ },
292
+ };
293
+ }
@@ -0,0 +1,153 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
+ import { stringify as stringifyYaml } from 'yaml';
5
+ import { sanitize } from '../fs';
6
+ import { formatLedgerEntryLine, type LedgerEntryV0 } from '../ledger/entry';
7
+ import type { OperationalDispatchDecision } from './decision';
8
+
9
+ export interface OperationalDispatchRecord {
10
+ schema: 'operational-dispatch-record.v0';
11
+ record_id: string;
12
+ recorded_at: string;
13
+ recorded_by: string;
14
+ task_ref: string;
15
+ seat: string;
16
+ decision: OperationalDispatchDecision['decision'];
17
+ reasons: string[];
18
+ warnings: string[];
19
+ profile_hint: OperationalDispatchDecision['profile_hint'];
20
+ receipt_ref: string;
21
+ ledger_ref: string;
22
+ authority_boundary: OperationalDispatchDecision['authority_boundary'];
23
+ source_decision: OperationalDispatchDecision;
24
+ }
25
+
26
+ export interface RecordedDispatch {
27
+ record: OperationalDispatchRecord;
28
+ receiptPath: string;
29
+ ledgerPath: string;
30
+ }
31
+
32
+ function assertIsoTimestamp(value: string): void {
33
+ if (!Number.isFinite(Date.parse(value))) {
34
+ throw new Error('--at must be an ISO-8601 timestamp');
35
+ }
36
+ }
37
+
38
+ function isoNow(at?: string): string {
39
+ const value = at ?? new Date().toISOString();
40
+ assertIsoTimestamp(value);
41
+ return new Date(value).toISOString();
42
+ }
43
+
44
+ function assertOpaqueId(value: string, name: string): void {
45
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) {
46
+ throw new Error(`${name} must be an opaque id using letters, numbers, hyphen, or underscore`);
47
+ }
48
+ }
49
+
50
+ function projectRelative(projectRoot: string, path: string): string {
51
+ const resolvedRoot = resolve(projectRoot);
52
+ const resolvedPath = resolve(path);
53
+ const rel = relative(resolvedRoot, resolvedPath);
54
+ if (rel.length === 0 || rel.startsWith('..') || rel.includes('\\')) {
55
+ throw new Error(`path must stay inside project root: ${path}`);
56
+ }
57
+ return rel;
58
+ }
59
+
60
+ function hashDecision(decision: OperationalDispatchDecision, recordedAt: string): string {
61
+ return createHash('sha256')
62
+ .update(JSON.stringify({ decision, recordedAt }))
63
+ .digest('hex')
64
+ .slice(0, 12);
65
+ }
66
+
67
+ function taskSlug(taskRef: string): string {
68
+ return sanitize(taskRef).slice(0, 48) || 'task';
69
+ }
70
+
71
+ async function ensureLedger(ledgerPath: string): Promise<void> {
72
+ await mkdir(dirname(ledgerPath), { recursive: true });
73
+ try {
74
+ await readFile(ledgerPath, 'utf-8');
75
+ } catch (err) {
76
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
77
+ await writeFile(ledgerPath, '# Local Ledger\n\n');
78
+ }
79
+ }
80
+
81
+ function ledgerLine(record: OperationalDispatchRecord): string {
82
+ const reasons = record.reasons.join(',') || 'none';
83
+ const entry: LedgerEntryV0 = {
84
+ schema: 'ledger-entry.v0',
85
+ entry_id: `ledger-entry:${record.record_id}`,
86
+ created_at: record.recorded_at,
87
+ entry_type: 'decision',
88
+ spine_eligible: true,
89
+ authority: {
90
+ class: 'advisory',
91
+ grants_execution: false,
92
+ },
93
+ refs: {
94
+ receipts: [record.receipt_ref],
95
+ },
96
+ export_class: 'workspace_shared',
97
+ summary: `dispatch ${record.decision} for ${record.task_ref}`,
98
+ };
99
+
100
+ return formatLedgerEntryLine(entry, {
101
+ task: record.task_ref,
102
+ seat: record.seat,
103
+ decision: record.decision,
104
+ reasons,
105
+ receipt: record.receipt_ref,
106
+ authority: 'advisory-only',
107
+ });
108
+ }
109
+
110
+ export async function recordOperationalDispatch(options: {
111
+ projectRoot: string;
112
+ decision: OperationalDispatchDecision;
113
+ recordedBy?: string;
114
+ at?: string;
115
+ }): Promise<RecordedDispatch> {
116
+ const projectRoot = resolve(options.projectRoot);
117
+ const recordedAt = isoNow(options.at);
118
+ const recordedBy = options.recordedBy ?? options.decision.seat;
119
+ assertOpaqueId(recordedBy, '--recorded-by');
120
+
121
+ const slug = taskSlug(options.decision.task_ref);
122
+ const hash = hashDecision(options.decision, recordedAt);
123
+ const recordId = `dispatch-record:${slug}:${hash}`;
124
+ const stamp = recordedAt.replace(/[:.]/g, '-');
125
+ const receiptPath = join(projectRoot, 'docs', 'work', 'receipts', `${stamp}-${slug}-${hash}.yaml`);
126
+ const ledgerPath = join(projectRoot, 'docs', 'work', 'ledger', 'LEDGER.md');
127
+ const receiptRef = projectRelative(projectRoot, receiptPath);
128
+ const ledgerRef = projectRelative(projectRoot, ledgerPath);
129
+
130
+ const record: OperationalDispatchRecord = {
131
+ schema: 'operational-dispatch-record.v0',
132
+ record_id: recordId,
133
+ recorded_at: recordedAt,
134
+ recorded_by: recordedBy,
135
+ task_ref: options.decision.task_ref,
136
+ seat: options.decision.seat,
137
+ decision: options.decision.decision,
138
+ reasons: options.decision.reasons,
139
+ warnings: options.decision.warnings,
140
+ profile_hint: options.decision.profile_hint,
141
+ receipt_ref: receiptRef,
142
+ ledger_ref: ledgerRef,
143
+ authority_boundary: options.decision.authority_boundary,
144
+ source_decision: options.decision,
145
+ };
146
+
147
+ await mkdir(dirname(receiptPath), { recursive: true });
148
+ await writeFile(receiptPath, stringifyYaml(record));
149
+ await ensureLedger(ledgerPath);
150
+ await appendFile(ledgerPath, `${ledgerLine(record)}\n`);
151
+
152
+ return { record, receiptPath, ledgerPath };
153
+ }
@@ -0,0 +1,170 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { parse as parseYaml } from 'yaml';
6
+
7
+ export const PROJECT_BINDING_SCHEMA = 'project-binding.v0';
8
+
9
+ export interface ProjectBinding {
10
+ schema: typeof PROJECT_BINDING_SCHEMA;
11
+ project_id: string;
12
+ project_name: string;
13
+ project_ref: '.';
14
+ created_at: string;
15
+ authority_boundary: {
16
+ project_binding_authorizes_work: false;
17
+ can_merge: false;
18
+ can_close: false;
19
+ can_grant: false;
20
+ can_write_global_ledger: false;
21
+ };
22
+ }
23
+
24
+ const PROJECT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/;
25
+
26
+ export function projectBindingPath(projectRoot: string): string {
27
+ return join(resolve(projectRoot), '.etiquette', 'project.yaml');
28
+ }
29
+
30
+ function safeProjectName(root: string): string {
31
+ return basename(resolve(root)).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
32
+ }
33
+
34
+ function assertProjectId(value: string, label = 'project_id'): void {
35
+ if (!PROJECT_ID_RE.test(value)) {
36
+ throw new Error(`${label} must be an opaque id using letters, numbers, hyphen, or underscore`);
37
+ }
38
+ }
39
+
40
+ function assertIsoTimestamp(value: string, label: string): void {
41
+ if (!Number.isFinite(Date.parse(value))) throw new Error(`${label} must be an ISO-8601 timestamp`);
42
+ }
43
+
44
+ function renderProjectBinding(binding: ProjectBinding): string {
45
+ return [
46
+ `schema: ${binding.schema}`,
47
+ `project_id: ${binding.project_id}`,
48
+ `project_name: ${binding.project_name}`,
49
+ 'project_ref: .',
50
+ `created_at: ${binding.created_at}`,
51
+ 'authority_boundary:',
52
+ ' project_binding_authorizes_work: false',
53
+ ' can_merge: false',
54
+ ' can_close: false',
55
+ ' can_grant: false',
56
+ ' can_write_global_ledger: false',
57
+ '',
58
+ ].join('\n');
59
+ }
60
+
61
+ export function validateProjectBinding(value: unknown): ProjectBinding {
62
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
63
+ throw new Error('project binding must be an object');
64
+ }
65
+ const record = value as Record<string, unknown>;
66
+ if (record.schema !== PROJECT_BINDING_SCHEMA) {
67
+ throw new Error(`project binding schema must be ${PROJECT_BINDING_SCHEMA}`);
68
+ }
69
+ if (typeof record.project_id !== 'string') throw new Error('project_id is required');
70
+ if (typeof record.project_name !== 'string' || record.project_name.trim().length === 0) {
71
+ throw new Error('project_name is required');
72
+ }
73
+ if (record.project_ref !== '.') throw new Error('project_ref must be .');
74
+ if (typeof record.created_at !== 'string') throw new Error('created_at is required');
75
+ assertProjectId(record.project_id);
76
+ assertIsoTimestamp(record.created_at, 'created_at');
77
+ const boundary = record.authority_boundary;
78
+ if (typeof boundary !== 'object' || boundary === null || Array.isArray(boundary)) {
79
+ throw new Error('authority_boundary is required');
80
+ }
81
+ for (const key of [
82
+ 'project_binding_authorizes_work',
83
+ 'can_merge',
84
+ 'can_close',
85
+ 'can_grant',
86
+ 'can_write_global_ledger',
87
+ ]) {
88
+ if ((boundary as Record<string, unknown>)[key] !== false) {
89
+ throw new Error(`authority_boundary.${key} must be false`);
90
+ }
91
+ }
92
+ return {
93
+ schema: PROJECT_BINDING_SCHEMA,
94
+ project_id: record.project_id,
95
+ project_name: record.project_name.trim(),
96
+ project_ref: '.',
97
+ created_at: new Date(record.created_at).toISOString(),
98
+ authority_boundary: {
99
+ project_binding_authorizes_work: false,
100
+ can_merge: false,
101
+ can_close: false,
102
+ can_grant: false,
103
+ can_write_global_ledger: false,
104
+ },
105
+ };
106
+ }
107
+
108
+ export async function readProjectBinding(projectRoot: string): Promise<ProjectBinding | null> {
109
+ const path = projectBindingPath(projectRoot);
110
+ if (!existsSync(path)) return null;
111
+ return validateProjectBinding(parseYaml(await readFile(path, 'utf-8')));
112
+ }
113
+
114
+ export async function ensureProjectBinding(projectRoot: string, at?: string): Promise<ProjectBinding> {
115
+ const existing = await readProjectBinding(projectRoot);
116
+ if (existing) return existing;
117
+ const root = resolve(projectRoot);
118
+ const createdAt = at ? new Date(at).toISOString() : new Date().toISOString();
119
+ assertIsoTimestamp(createdAt, 'created_at');
120
+ const projectName = safeProjectName(root);
121
+ const binding: ProjectBinding = {
122
+ schema: PROJECT_BINDING_SCHEMA,
123
+ project_id: `${projectName}-${randomUUID().slice(0, 8)}`,
124
+ project_name: projectName,
125
+ project_ref: '.',
126
+ created_at: createdAt,
127
+ authority_boundary: {
128
+ project_binding_authorizes_work: false,
129
+ can_merge: false,
130
+ can_close: false,
131
+ can_grant: false,
132
+ can_write_global_ledger: false,
133
+ },
134
+ };
135
+ await mkdir(dirname(projectBindingPath(root)), { recursive: true });
136
+ await writeFile(projectBindingPath(root), renderProjectBinding(binding));
137
+ return binding;
138
+ }
139
+
140
+ export function assertEngagementProject(
141
+ actualProjectId: string | null | undefined,
142
+ expectedProjectId: string | null | undefined,
143
+ context: string,
144
+ ): void {
145
+ if (!expectedProjectId) return;
146
+ assertProjectId(expectedProjectId, '--expect-project-id');
147
+ if (!actualProjectId) {
148
+ throw new Error(`engagement mismatch: ${context} has no project_id; expected ${expectedProjectId}`);
149
+ }
150
+ if (actualProjectId !== expectedProjectId) {
151
+ throw new Error(
152
+ `ERROR: different engagement - ${context} project_id=${actualProjectId} does not match expected project_id=${expectedProjectId}`,
153
+ );
154
+ }
155
+ }
156
+
157
+ export function assertSessionProject(
158
+ sessionProjectId: string | null | undefined,
159
+ currentProjectId: string,
160
+ context: string,
161
+ ): void {
162
+ if (!sessionProjectId) {
163
+ throw new Error(`engagement mismatch: ${context} has no project_id; recreate the session under the current project`);
164
+ }
165
+ if (sessionProjectId !== currentProjectId) {
166
+ throw new Error(
167
+ `ERROR: different engagement - ${context} project_id=${sessionProjectId} does not match current project_id=${currentProjectId}`,
168
+ );
169
+ }
170
+ }
@@ -0,0 +1,19 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+
4
+ export function now(): string {
5
+ return new Date().toISOString();
6
+ }
7
+
8
+ export function sanitize(value: string): string {
9
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
10
+ }
11
+
12
+ export async function readJson<T>(path: string): Promise<T> {
13
+ return JSON.parse(await readFile(path, 'utf-8')) as T;
14
+ }
15
+
16
+ export async function writeJson(path: string, value: unknown): Promise<void> {
17
+ await mkdir(dirname(path), { recursive: true });
18
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
19
+ }