@adhdev/mesh-shared 1.0.60-rc.4 → 1.0.60-rc.41

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 (39) hide show
  1. package/dist/index.d.ts +10 -0
  2. package/dist/index.js +2764 -17
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +2441 -17
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/mesh-tool-names.d.ts +2 -2
  7. package/dist/mesh-vocabulary.d.ts +70 -0
  8. package/dist/mission-brief.d.ts +62 -0
  9. package/dist/outbound-message.d.ts +202 -0
  10. package/dist/path-ownership.d.ts +101 -0
  11. package/dist/protocol/daemon-server.d.ts +388 -0
  12. package/dist/protocol/dashboard-daemon-p2p.d.ts +212 -0
  13. package/dist/protocol/dashboard-server.d.ts +240 -0
  14. package/dist/protocol/envelope.d.ts +55 -0
  15. package/dist/protocol/index.d.ts +15 -0
  16. package/dist/session-launch.d.ts +113 -0
  17. package/dist/session-status.d.ts +61 -0
  18. package/dist/turn-evidence.d.ts +407 -0
  19. package/dist/turn-ipc.d.ts +974 -0
  20. package/dist/worker-protocol.d.ts +69 -0
  21. package/dist/ws-protocol.d.ts +71 -41
  22. package/package.json +1 -1
  23. package/src/index.ts +10 -0
  24. package/src/mesh-tool-names.ts +0 -1
  25. package/src/mesh-vocabulary.ts +98 -0
  26. package/src/mission-brief.ts +153 -0
  27. package/src/outbound-message.ts +320 -0
  28. package/src/path-ownership.ts +271 -0
  29. package/src/protocol/daemon-server.ts +552 -0
  30. package/src/protocol/dashboard-daemon-p2p.ts +272 -0
  31. package/src/protocol/dashboard-server.ts +340 -0
  32. package/src/protocol/envelope.ts +75 -0
  33. package/src/protocol/index.ts +16 -0
  34. package/src/session-launch.ts +247 -0
  35. package/src/session-status.ts +140 -0
  36. package/src/turn-evidence.ts +562 -0
  37. package/src/turn-ipc.ts +2231 -0
  38. package/src/worker-protocol.ts +142 -0
  39. package/src/ws-protocol.ts +102 -93
@@ -0,0 +1,320 @@
1
+ /**
2
+ * outbound-message — the one send-side unit crossing every transport.
3
+ *
4
+ * Wiring-unification Phase D (docs/design/2026-09-23-wiring-unification.md §6,
5
+ * D1) / workstream D-web. Full type derivation in the scratchpad
6
+ * `phase-D-plan.md` §1.1-§1.2 (`OutboundMessage`/`SendPolicy`/`SubmitOutcome`/
7
+ * `SendRefusal`). `SendRefusal` is imported from here by Phase C1's
8
+ * `turn-evidence.ts` (`RECLAIMING_SEND_REFUSALS`) — this file owns the type,
9
+ * C1 owns which of its members reclaim a mesh dispatch attempt.
10
+ *
11
+ * Before this type, a chat send was represented by twelve different loose
12
+ * request shapes (`{message, sendNow?, interrupt?}`, `{message, input}`,
13
+ * mesh `agent_command` args, MCP tool args, ...) and parked messages had no
14
+ * identity at all — the driver queue was keyed by exact text, so send-now,
15
+ * cancel, interrupt and the web echo-retirement all recovered identity by
16
+ * string matching. `OutboundMessage.messageId` is minted once, at the
17
+ * ORIGIN, and is the identity every layer keys on afterwards: the wire
18
+ * field in the `send_chat` command payload, the driver's queue key (daemon
19
+ * side, Phase D-daemon), the web pending-store key (`pendingQueuedMessages.ts`)
20
+ * and the echo-retirement key (`conversation-message-snapshot.ts`).
21
+ *
22
+ * This file is dependency-free by construction (mesh-shared leaf rule):
23
+ * `InputEnvelopeWire`/`InputPartWire` are STRUCTURAL MIRRORS of daemon-core's
24
+ * `InputEnvelope`/`InputPart` (`oss/packages/daemon-core/src/providers/
25
+ * io-contracts.ts`), not re-exports — daemon-core assigns its richer type
26
+ * into this one, never the other way round, so this package's published
27
+ * `.d.ts` never references a package that depends on it.
28
+ */
29
+
30
+ // ─── Input envelope (structural mirror of daemon-core's InputEnvelope) ─────
31
+
32
+ export interface ContentAnnotationsWire {
33
+ audience?: ('user' | 'assistant')[]
34
+ priority?: number
35
+ }
36
+
37
+ export interface TextInputPartWire {
38
+ type: 'text'
39
+ text: string
40
+ }
41
+
42
+ export interface ImageInputPartWire {
43
+ type: 'image'
44
+ mimeType: string
45
+ uri?: string
46
+ data?: string
47
+ alt?: string
48
+ }
49
+
50
+ export interface AudioInputPartWire {
51
+ type: 'audio'
52
+ mimeType: string
53
+ uri?: string
54
+ data?: string
55
+ transcript?: string
56
+ }
57
+
58
+ export interface VideoInputPartWire {
59
+ type: 'video'
60
+ mimeType: string
61
+ uri?: string
62
+ data?: string
63
+ transcript?: string
64
+ posterUri?: string
65
+ }
66
+
67
+ export interface ResourceInputPartWire {
68
+ type: 'resource'
69
+ uri: string
70
+ mimeType?: string
71
+ name?: string
72
+ text?: string
73
+ data?: string
74
+ }
75
+
76
+ export interface ResourceLinkInputPartWire {
77
+ type: 'resource_link'
78
+ uri: string
79
+ name: string
80
+ title?: string
81
+ description?: string
82
+ mimeType?: string
83
+ size?: number
84
+ annotations?: ContentAnnotationsWire
85
+ }
86
+
87
+ export type InputPartWire =
88
+ | TextInputPartWire
89
+ | ImageInputPartWire
90
+ | AudioInputPartWire
91
+ | VideoInputPartWire
92
+ | ResourceLinkInputPartWire
93
+ | ResourceInputPartWire
94
+
95
+ export interface InputEnvelopeWire {
96
+ parts: InputPartWire[]
97
+ textFallback: string
98
+ metadata?: {
99
+ source?: 'dashboard' | 'shortcut_api' | 'provider_script' | 'session_replay'
100
+ clientTimestamp?: number
101
+ }
102
+ }
103
+
104
+ // ─── OutboundMessage ────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Who minted this `OutboundMessage`. Determines default policy and refusal
108
+ * surface — 'mesh' origins get the mesh-specific `SendRefusal` reasons,
109
+ * interactive origins get the interactive ones.
110
+ *
111
+ * `'api'`/`'cli'` are carried per the design doc's D1 type even though no
112
+ * current call site produces them distinct from `'dashboard'`/`'mcp'`/
113
+ * `'mesh'` (flagged as an open question in phase-D-plan.md §7.3 risk 5) —
114
+ * kept here unchanged pending an owner decision; dropping a union member is
115
+ * a decision for whoever lands D-daemon/D2, not this workstream.
116
+ */
117
+ export type OutboundMessageOrigin = 'dashboard' | 'mcp' | 'mesh' | 'api' | 'cli'
118
+
119
+ /**
120
+ * How this message should be admitted when the session is not idle.
121
+ * Replaces the loose `sendNow`/`interrupt`/`force`/`forceSend` booleans (and
122
+ * mesh `delivery_mode`) with one closed union. `'queue'` is the default for
123
+ * every origin.
124
+ */
125
+ export type SendPolicy =
126
+ | { mode: 'queue' }
127
+ | { mode: 'send_now' }
128
+ | { mode: 'interrupt' }
129
+
130
+ /**
131
+ * Every reason `submit()` can refuse a message, closed so callers branch on it
132
+ * instead of matching free text.
133
+ *
134
+ * ★ This is THE `SendRefusal` (docs/design/2026-09-23-wiring-unification.md
135
+ * §6 D1) — Phase C1's `turn-evidence.ts` imports `SendRefusal`/
136
+ * `SEND_REFUSAL_REASONS` from this file (`RECLAIMING_SEND_REFUSALS`, the
137
+ * subset that means a mesh dispatch attempt must be reclaimed rather than
138
+ * retried) rather than declaring its own copy. Do not fork this vocabulary —
139
+ * a member renamed or removed here must be re-checked against
140
+ * `RECLAIMING_SEND_REFUSALS`'s three members (`session_exited`, `no_target`,
141
+ * `unsupported_input`), which `satisfies readonly SendRefusal[]` there keeps
142
+ * honest at compile time.
143
+ */
144
+ export type SendRefusal =
145
+ | 'not_ready'
146
+ | 'send_in_flight'
147
+ | 'not_generating'
148
+ | 'not_supported'
149
+ | 'platform_unsupported'
150
+ | 'modal_parked'
151
+ | 'interrupt_not_implemented'
152
+ | 'interrupt_refused'
153
+ | 'idle_timeout'
154
+ | 'session_exited'
155
+ | 'unsupported_input'
156
+ | 'no_target'
157
+ | 'bootstrap_pending'
158
+ | 'duplicate_dispatch'
159
+ | 'internal_error'
160
+
161
+ /**
162
+ * Which write path carried a delivered/queued message (Phase D2, additive
163
+ * detail — callers branch on `kind`; `route` only refines the report):
164
+ *
165
+ * `pty` — the ordinary composer write (or the driver FIFO for `queued`);
166
+ * `agent_queue` — the POSIX split write the CLI's OWN input queue takes while
167
+ * a turn is generating (`send_now`). Written, not yet answered;
168
+ * `interrupt` — the turn in flight was stopped first, then this body was
169
+ * written (or re-parked) as a new turn;
170
+ * `acp` — handed to an ACP agent's `session/prompt`.
171
+ */
172
+ export type SubmitRoute = 'pty' | 'agent_queue' | 'interrupt' | 'acp'
173
+
174
+ /** The stop key pressed by an `interrupt` submit (logs / operator display). */
175
+ export interface SubmitInterruptDetail {
176
+ keyName: string
177
+ /** 'declared' = spec-declared stop key whose busy→idle effect was never observed live. */
178
+ confidence: 'proven' | 'declared'
179
+ }
180
+
181
+ /**
182
+ * The one closed outcome shape every caller receives from `submit()`.
183
+ *
184
+ * The optional fields are additive DETAIL (Phase D2): `route`/`interrupt` say
185
+ * how a message was written, `message` is a human-readable refusal line and
186
+ * `restored` says whether a parked body that an out-of-band submit had claimed
187
+ * is back in the driver queue (`false` = nothing holds it any more). None of
188
+ * them changes what `kind` means.
189
+ */
190
+ export type SubmitOutcome =
191
+ | { kind: 'delivered'; route?: SubmitRoute; interrupt?: SubmitInterruptDetail }
192
+ | { kind: 'queued'; position: number; route?: SubmitRoute; interrupt?: SubmitInterruptDetail }
193
+ | { kind: 'duplicate'; of: string /* messageId */ }
194
+ | { kind: 'refused'; reason: SendRefusal; message?: string; restored?: boolean }
195
+
196
+ /**
197
+ * The one send-side unit crossing every transport. `messageId` is minted at
198
+ * the ORIGIN (never by the daemon) — this is what makes cross-transport
199
+ * dedupe and identity-based claim/cancel/send-now possible.
200
+ */
201
+ export interface OutboundMessage {
202
+ messageId: string
203
+ sessionId: string
204
+ input: InputEnvelopeWire
205
+ origin: OutboundMessageOrigin
206
+ policy: SendPolicy
207
+ createdAt: number
208
+ /**
209
+ * Present only for a mesh-dispatched message; lets the turn ledger
210
+ * (Phase C) bind the send to the attempt without the sender needing to
211
+ * know what an "attempt" is. Optional and inert until C lands.
212
+ */
213
+ meshAttemptRef?: string
214
+ }
215
+
216
+ // ─── Identity ───────────────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Mint a new `messageId`. `crypto.randomUUID` where available (browser and
220
+ * modern Node); a counter fallback otherwise, since the only requirement is
221
+ * uniqueness within one process/tab, not cryptographic strength.
222
+ */
223
+ let outboundMessageIdCounter = 0
224
+ export function mintMessageId(now: number = Date.now()): string {
225
+ try {
226
+ // `globalThis.crypto` is not in the ES2022 lib (mesh-shared is DOM-free), so read it structurally.
227
+ const cryptoRef = (typeof globalThis !== 'undefined'
228
+ ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
229
+ : undefined)
230
+ if (typeof cryptoRef?.randomUUID === 'function') return `msg_${cryptoRef.randomUUID()}`
231
+ } catch {
232
+ /* fall through to the counter fallback */
233
+ }
234
+ outboundMessageIdCounter += 1
235
+ return `msg_${now}_${outboundMessageIdCounter}`
236
+ }
237
+
238
+ /** `messageId`s minted by this module always carry this prefix; used by the guard below. */
239
+ const MESSAGE_ID_PREFIX = 'msg_'
240
+
241
+ export function isMessageId(value: unknown): value is string {
242
+ return typeof value === 'string' && value.length > MESSAGE_ID_PREFIX.length && value.startsWith(MESSAGE_ID_PREFIX)
243
+ }
244
+
245
+ // ─── Guards ─────────────────────────────────────────────────────────────────
246
+
247
+ export const OUTBOUND_MESSAGE_ORIGINS = ['dashboard', 'mcp', 'mesh', 'api', 'cli'] as const satisfies readonly OutboundMessageOrigin[]
248
+
249
+ export function isOutboundMessageOrigin(value: unknown): value is OutboundMessageOrigin {
250
+ return typeof value === 'string' && (OUTBOUND_MESSAGE_ORIGINS as readonly string[]).includes(value)
251
+ }
252
+
253
+ export const SEND_POLICY_MODES = ['queue', 'send_now', 'interrupt'] as const satisfies readonly SendPolicy['mode'][]
254
+
255
+ export function isSendPolicyMode(value: unknown): value is SendPolicy['mode'] {
256
+ return typeof value === 'string' && (SEND_POLICY_MODES as readonly string[]).includes(value)
257
+ }
258
+
259
+ export function isSendPolicy(value: unknown): value is SendPolicy {
260
+ return !!value && typeof value === 'object' && isSendPolicyMode((value as { mode?: unknown }).mode)
261
+ }
262
+
263
+ export const SEND_REFUSAL_REASONS = [
264
+ 'not_ready',
265
+ 'send_in_flight',
266
+ 'not_generating',
267
+ 'not_supported',
268
+ 'platform_unsupported',
269
+ 'modal_parked',
270
+ 'interrupt_not_implemented',
271
+ 'interrupt_refused',
272
+ 'idle_timeout',
273
+ 'session_exited',
274
+ 'unsupported_input',
275
+ 'no_target',
276
+ 'bootstrap_pending',
277
+ 'duplicate_dispatch',
278
+ 'internal_error',
279
+ ] as const satisfies readonly SendRefusal[]
280
+
281
+ export function isSendRefusal(value: unknown): value is SendRefusal {
282
+ return typeof value === 'string' && (SEND_REFUSAL_REASONS as readonly string[]).includes(value)
283
+ }
284
+
285
+ const SUBMIT_OUTCOME_KINDS = ['delivered', 'queued', 'duplicate', 'refused'] as const
286
+
287
+ export function isSubmitOutcome(value: unknown): value is SubmitOutcome {
288
+ if (!value || typeof value !== 'object') return false
289
+ const kind = (value as { kind?: unknown }).kind
290
+ if (typeof kind !== 'string' || !(SUBMIT_OUTCOME_KINDS as readonly string[]).includes(kind)) return false
291
+ if (kind === 'queued') return typeof (value as { position?: unknown }).position === 'number'
292
+ if (kind === 'duplicate') return typeof (value as { of?: unknown }).of === 'string'
293
+ if (kind === 'refused') return isSendRefusal((value as { reason?: unknown }).reason)
294
+ return true
295
+ }
296
+
297
+ function isInputPartWire(value: unknown): value is InputPartWire {
298
+ if (!value || typeof value !== 'object') return false
299
+ const type = (value as { type?: unknown }).type
300
+ return type === 'text' || type === 'image' || type === 'audio' || type === 'video' || type === 'resource' || type === 'resource_link'
301
+ }
302
+
303
+ export function isInputEnvelopeWire(value: unknown): value is InputEnvelopeWire {
304
+ if (!value || typeof value !== 'object') return false
305
+ const parts = (value as { parts?: unknown }).parts
306
+ const textFallback = (value as { textFallback?: unknown }).textFallback
307
+ return Array.isArray(parts) && parts.every(isInputPartWire) && typeof textFallback === 'string'
308
+ }
309
+
310
+ /** Minimal structural check for the wire boundary — not a full decoder (mesh-shared stays a pure leaf; semantic validation stays with the handler that owns it). */
311
+ export function isOutboundMessage(value: unknown): value is OutboundMessage {
312
+ if (!value || typeof value !== 'object') return false
313
+ const candidate = value as Partial<OutboundMessage>
314
+ return isMessageId(candidate.messageId)
315
+ && typeof candidate.sessionId === 'string' && candidate.sessionId.length > 0
316
+ && isInputEnvelopeWire(candidate.input)
317
+ && isOutboundMessageOrigin(candidate.origin)
318
+ && isSendPolicy(candidate.policy)
319
+ && typeof candidate.createdAt === 'number'
320
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * path-ownership — declared file-ownership sets for `code_change` mesh tasks
3
+ * (wiring-unification Phase H1, `docs/design/2026-09-23-wiring-unification.md`
4
+ * §7c).
5
+ *
6
+ * Today two `code_change` tasks on two different nodes (two worktrees of the
7
+ * same branch, or main + a worktree) can silently touch the same file with
8
+ * zero warning — the only existing isolation is node-level ("one active write
9
+ * task per node"), never path-level. This module is the pure, dependency-free
10
+ * core of the fix: normalizing a caller-declared `owned_paths` list, and
11
+ * comparing declared sets for overlap. It has NO opinion on enforcement policy
12
+ * (refuse vs warn, which axis a caller compares against) — that is the mesh
13
+ * runtime's job (`claimNextTask` / `claimNextQueueTask` in daemon-core); this
14
+ * module only answers "do these two declarations conflict" and "did the
15
+ * touched-files report stay inside the declaration".
16
+ *
17
+ * SCOPE DECISION (glob support): minimal, deliberately. A declaration entry is
18
+ * either an exact repo-relative path (`src/foo.ts`) or a directory prefix
19
+ * spelled with a trailing `/**` (`src/mesh/**`) meaning "this path and
20
+ * everything under it". No `*`/`?` mid-segment wildcards, no `!` negation, no
21
+ * brace expansion. Two `code_change` tasks racing on overlapping files is a
22
+ * coordination bug to catch, not a general glob engine to build — a full
23
+ * minimatch-style matcher would need its own test suite and still leave the
24
+ * same ambiguity (does `src/*.ts` "contain" `src/foo.ts`?) the design doc's
25
+ * risk list calls out. `src/` (no `**`) is treated as an exact single-path
26
+ * entry, i.e. it owns a literal file/dir node named `src`, NOT everything
27
+ * under it — callers who want subtree ownership must write `src/**`.
28
+ *
29
+ * Rejected inputs (absolute paths, `..` segments, backslash-only entries that
30
+ * still resolve outside the repo after normalization) are dropped rather than
31
+ * silently coerced — `normalizeOwnedPaths` reports what it dropped so the
32
+ * caller (enqueue-time validation) can decide whether to warn or refuse.
33
+ */
34
+
35
+ /** One normalized ownership entry: a repo-relative path, optionally a `**` subtree prefix. */
36
+ export interface OwnedPathEntry {
37
+ /** Repo-relative path with forward slashes, no leading `./`, no trailing slash. */
38
+ readonly path: string
39
+ /** True when this entry was declared as `<path>/**` (owns the whole subtree). */
40
+ readonly subtree: boolean
41
+ }
42
+
43
+ /** A caller's normalized ownership declaration. */
44
+ export interface OwnedPathsDeclaration {
45
+ readonly paths: readonly OwnedPathEntry[]
46
+ }
47
+
48
+ export interface NormalizeOwnedPathsResult {
49
+ readonly declaration: OwnedPathsDeclaration
50
+ /** Raw input entries that were dropped, with why — surfaced to the caller, never silently coerced. */
51
+ readonly rejected: ReadonlyArray<{ input: string; reason: string }>
52
+ }
53
+
54
+ /** Cap mirrors `depends_on`'s implicit list-size discipline (design doc H1 proposal). */
55
+ export const MAX_OWNED_PATHS = 64
56
+
57
+ function toPosix(raw: string): string {
58
+ return raw.replace(/\\/g, '/')
59
+ }
60
+
61
+ /**
62
+ * Normalize one raw entry. Returns `null` (with a reason pushed to `rejected`)
63
+ * for anything absolute, escaping the repo via `..`, or empty after trimming.
64
+ */
65
+ function normalizeOne(raw: unknown, rejected: Array<{ input: string; reason: string }>): OwnedPathEntry | null {
66
+ if (typeof raw !== 'string') {
67
+ rejected.push({ input: String(raw), reason: 'not_a_string' })
68
+ return null
69
+ }
70
+ const trimmed = raw.trim()
71
+ if (!trimmed) {
72
+ rejected.push({ input: raw, reason: 'empty' })
73
+ return null
74
+ }
75
+ let posix = toPosix(trimmed)
76
+
77
+ // Absolute paths (POSIX `/…`, Windows drive letter `C:\…` or `C:/…`, or a
78
+ // stray leading `~`) are refused outright — ownership is repo-relative by
79
+ // construction, and accepting an absolute path would let a declaration
80
+ // silently escape the workspace it is meant to scope.
81
+ if (posix.startsWith('/') || /^[a-zA-Z]:\//.test(posix) || posix.startsWith('~')) {
82
+ rejected.push({ input: raw, reason: 'absolute_path' })
83
+ return null
84
+ }
85
+
86
+ // Strip a leading './'.
87
+ posix = posix.replace(/^\.\/+/, '')
88
+
89
+ // Reject any '..' segment anywhere — no escaping the repo root.
90
+ const segments = posix.split('/').filter(Boolean)
91
+ if (segments.some(seg => seg === '..')) {
92
+ rejected.push({ input: raw, reason: 'parent_traversal' })
93
+ return null
94
+ }
95
+ if (segments.length === 0) {
96
+ rejected.push({ input: raw, reason: 'empty' })
97
+ return null
98
+ }
99
+
100
+ // Minimal glob support: a trailing '**' segment (from '<path>/**') marks a
101
+ // subtree declaration. Any OTHER '*'/'?' anywhere is rejected — this module
102
+ // does not implement general glob matching (see module doc).
103
+ let subtree = false
104
+ if (segments[segments.length - 1] === '**') {
105
+ subtree = true
106
+ segments.pop()
107
+ if (segments.length === 0) {
108
+ // '**' alone (or './**') claims the entire repo — allowed, but the
109
+ // caller (enqueue-time validation) is expected to lint/warn on it
110
+ // per the design doc's deadlock risk; this module only normalizes.
111
+ }
112
+ }
113
+ if (segments.some(seg => seg.includes('*') || seg.includes('?'))) {
114
+ rejected.push({ input: raw, reason: 'unsupported_glob' })
115
+ return null
116
+ }
117
+
118
+ return { path: segments.join('/'), subtree }
119
+ }
120
+
121
+ /**
122
+ * Normalize a caller-supplied `owned_paths` array. Never throws — invalid
123
+ * entries are dropped and reported in `rejected`; duplicates (after
124
+ * normalization) collapse to one entry (subtree wins if either declaration
125
+ * form is present for the same path). Entries beyond `MAX_OWNED_PATHS` (after
126
+ * dedup) are dropped with reason `over_cap`, preserving declaration order.
127
+ */
128
+ export function normalizeOwnedPaths(input: unknown): NormalizeOwnedPathsResult {
129
+ const rejected: Array<{ input: string; reason: string }> = []
130
+ if (input === undefined || input === null) {
131
+ return { declaration: { paths: [] }, rejected }
132
+ }
133
+ if (!Array.isArray(input)) {
134
+ return { declaration: { paths: [] }, rejected: [{ input: String(input), reason: 'not_an_array' }] }
135
+ }
136
+
137
+ const byPath = new Map<string, OwnedPathEntry>()
138
+ for (const raw of input) {
139
+ const entry = normalizeOne(raw, rejected)
140
+ if (!entry) continue
141
+ const existing = byPath.get(entry.path)
142
+ if (!existing) {
143
+ byPath.set(entry.path, entry)
144
+ } else if (entry.subtree && !existing.subtree) {
145
+ byPath.set(entry.path, entry)
146
+ }
147
+ // else: duplicate, existing entry already at least as broad — keep it.
148
+ }
149
+
150
+ const all = [...byPath.values()]
151
+ const kept = all.slice(0, MAX_OWNED_PATHS)
152
+ for (const dropped of all.slice(MAX_OWNED_PATHS)) {
153
+ rejected.push({ input: dropped.path, reason: 'over_cap' })
154
+ }
155
+
156
+ return { declaration: { paths: kept }, rejected }
157
+ }
158
+
159
+ /** True when `path` is exactly `base`, or (when `base` is a subtree entry) nested under it. */
160
+ function entryContains(base: OwnedPathEntry, path: string): boolean {
161
+ if (path === base.path) return true
162
+ if (base.subtree && (path === base.path || path.startsWith(`${base.path}/`))) return true
163
+ return false
164
+ }
165
+
166
+ /** True when two normalized entries' claimed sets intersect (exact match, or one contains the other's directory). */
167
+ function entriesOverlap(a: OwnedPathEntry, b: OwnedPathEntry): boolean {
168
+ if (a.path === b.path) return true
169
+ if (a.subtree && (b.path === a.path || b.path.startsWith(`${a.path}/`))) return true
170
+ if (b.subtree && (a.path === b.path || a.path.startsWith(`${b.path}/`))) return true
171
+ return false
172
+ }
173
+
174
+ /** True when two declarations claim any overlapping path. Pure set comparison — no policy (refuse vs warn) here. */
175
+ export function pathsOverlap(a: OwnedPathsDeclaration, b: OwnedPathsDeclaration): boolean {
176
+ if (a.paths.length === 0 || b.paths.length === 0) return false
177
+ for (const pa of a.paths) {
178
+ for (const pb of b.paths) {
179
+ if (entriesOverlap(pa, pb)) return true
180
+ }
181
+ }
182
+ return false
183
+ }
184
+
185
+ export interface InFlightOwnership {
186
+ readonly taskId: string
187
+ readonly paths: OwnedPathsDeclaration
188
+ }
189
+
190
+ export interface OwnershipConflict {
191
+ readonly taskId: string
192
+ /** The specific overlapping paths, for a readable refusal message. */
193
+ readonly overlappingPaths: readonly string[]
194
+ }
195
+
196
+ /**
197
+ * Compare a candidate declaration against every other in-flight task's
198
+ * declaration and return every conflict found (not just the first), each
199
+ * naming the owning task id and the specific overlapping path(s) — so a
200
+ * refusal message can say exactly what collided, not just that it did.
201
+ *
202
+ * Backward compatibility (design doc risk #4): an EMPTY candidate declaration
203
+ * never conflicts (opt-in only — absent `owned_paths` performs no overlap
204
+ * check for that task), and an in-flight entry with an empty declaration is
205
+ * never reported as a conflict source either.
206
+ */
207
+ export function findOwnershipConflicts(
208
+ candidate: OwnedPathsDeclaration,
209
+ inFlight: readonly InFlightOwnership[],
210
+ ): OwnershipConflict[] {
211
+ if (candidate.paths.length === 0) return []
212
+ const conflicts: OwnershipConflict[] = []
213
+ for (const other of inFlight) {
214
+ if (other.paths.paths.length === 0) continue
215
+ const overlappingPaths: string[] = []
216
+ for (const pa of candidate.paths) {
217
+ for (const pb of other.paths.paths) {
218
+ if (entriesOverlap(pa, pb)) {
219
+ const narrower = pa.path.length >= pb.path.length ? pa.path : pb.path
220
+ if (!overlappingPaths.includes(narrower)) overlappingPaths.push(narrower)
221
+ }
222
+ }
223
+ }
224
+ if (overlappingPaths.length > 0) {
225
+ conflicts.push({ taskId: other.taskId, overlappingPaths })
226
+ }
227
+ }
228
+ return conflicts
229
+ }
230
+
231
+ export interface TouchedFilesOutsideOwnershipResult {
232
+ /** Touched files not covered by any declared owned-path entry. */
233
+ readonly undeclaredTouched: readonly string[]
234
+ /** Declared entries that no touched file matched (informational only — under-reporting is not flagged as a mismatch by policy, just visible here). */
235
+ readonly unusedDeclarations: readonly string[]
236
+ }
237
+
238
+ /**
239
+ * Compare a worker's `report_completion.touched_files` against its task's
240
+ * declared `owned_paths`. Pure comparison only — per the design doc, a
241
+ * mismatch is "surfaced as evidence, not silently accepted"; this function
242
+ * does not decide whether that is an error. Each `touched` entry is
243
+ * normalized with the same rules as a declaration (invalid entries — e.g. an
244
+ * absolute path a worker reported by mistake — are treated as undeclared
245
+ * rather than silently dropped, since they cannot match anything).
246
+ */
247
+ export function touchedFilesOutsideOwnership(
248
+ touched: readonly string[],
249
+ owned: OwnedPathsDeclaration,
250
+ ): TouchedFilesOutsideOwnershipResult {
251
+ const undeclaredTouched: string[] = []
252
+ const usedDeclarations = new Set<string>()
253
+
254
+ for (const raw of touched ?? []) {
255
+ const rejectedSink: Array<{ input: string; reason: string }> = []
256
+ const entry = normalizeOne(raw, rejectedSink)
257
+ if (!entry) {
258
+ undeclaredTouched.push(raw)
259
+ continue
260
+ }
261
+ const matches = owned.paths.filter(base => entryContains(base, entry.path))
262
+ if (matches.length === 0) {
263
+ undeclaredTouched.push(entry.path)
264
+ } else {
265
+ for (const m of matches) usedDeclarations.add(m.path)
266
+ }
267
+ }
268
+
269
+ const unusedDeclarations = owned.paths.map(p => p.path).filter(p => !usedDeclarations.has(p))
270
+ return { undeclaredTouched, unusedDeclarations }
271
+ }