@adhdev/mesh-shared 1.0.82-rc.3 → 1.0.82-rc.31

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 (55) hide show
  1. package/dist/assistant-protocol.d.ts +140 -6
  2. package/dist/index.d.ts +6 -0
  3. package/dist/index.js +529 -110
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +500 -110
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/launch-defaults.d.ts +194 -0
  8. package/dist/mesh-tool-names.d.ts +2 -2
  9. package/dist/mesh-vocabulary.d.ts +13 -4
  10. package/dist/node-facts.d.ts +41 -1
  11. package/dist/outbound-message.d.ts +7 -0
  12. package/dist/path-ownership.d.ts +1 -1
  13. package/dist/protocol/daemon-server.d.ts +9 -0
  14. package/dist/protocol/dashboard-daemon-p2p.d.ts +1 -1
  15. package/dist/protocol/dashboard-server.d.ts +10 -0
  16. package/dist/provider-approval.d.ts +36 -0
  17. package/dist/provider-question.d.ts +19 -0
  18. package/dist/quota-freshness.d.ts +65 -0
  19. package/dist/role-launch.d.ts +11 -0
  20. package/dist/session-launch.d.ts +3 -22
  21. package/dist/slot-proposal.d.ts +14 -0
  22. package/dist/task-source-phase.d.ts +75 -0
  23. package/dist/turn-evidence.d.ts +24 -12
  24. package/dist/turn-ipc-mission.d.ts +4 -0
  25. package/dist/turn-ipc-queue.d.ts +19 -0
  26. package/dist/turn-ipc.d.ts +2 -2
  27. package/dist/types.d.ts +1 -0
  28. package/dist/worker-protocol.d.ts +1 -1
  29. package/package.json +1 -1
  30. package/src/assistant-protocol.ts +247 -6
  31. package/src/daemon-normalize.ts +7 -5
  32. package/src/index.ts +7 -0
  33. package/src/keyed-doc-delta.ts +9 -1
  34. package/src/launch-defaults.ts +168 -0
  35. package/src/mesh-tool-names.ts +5 -0
  36. package/src/mesh-vocabulary.ts +12 -2
  37. package/src/node-facts.ts +36 -1
  38. package/src/outbound-message.ts +17 -0
  39. package/src/path-ownership.ts +4 -6
  40. package/src/protocol/daemon-server.ts +7 -2
  41. package/src/protocol/dashboard-server.ts +8 -2
  42. package/src/provider-approval.ts +59 -0
  43. package/src/provider-question.ts +31 -0
  44. package/src/quota-freshness.ts +139 -0
  45. package/src/role-launch.ts +51 -0
  46. package/src/session-launch.ts +8 -1
  47. package/src/slot-proposal.ts +61 -2
  48. package/src/task-source-phase.ts +61 -0
  49. package/src/turn-evidence.ts +22 -19
  50. package/src/turn-ipc-mission.ts +8 -2
  51. package/src/turn-ipc-queue.ts +29 -1
  52. package/src/turn-ipc-turn.ts +1 -1
  53. package/src/turn-ipc.ts +1 -0
  54. package/src/types.ts +1 -0
  55. package/src/worker-protocol.ts +4 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Quota snapshot freshness — the ONE decision every display surface shares.
3
+ *
4
+ * Why this lives in the leaf package: the dashboard chips (web-core
5
+ * `quotaWindowCue`), the `adhdev quota` CLI (daemon-core `quota/cli.ts`) and the
6
+ * coordinator-facing `mesh_status` fold (mcp-server `summarizeNodeQuota`) each
7
+ * carried a hand-copied version of this rule, and a fix to one left the other
8
+ * two telling a different story about the same snapshot. They all call
9
+ * `assessQuotaFreshness` now; only the WORDING stays per-surface.
10
+ *
11
+ * Pure: plain objects in, plain object out, no clock except the `now` argument.
12
+ */
13
+ import type { MeshNodeFactsProviderQuota } from './node-facts'
14
+
15
+ export type QuotaFreshnessCue = 'refreshing' | 'stale'
16
+
17
+ export interface QuotaFreshness {
18
+ /** Undefined = nothing to say (a fresh reading, or no retained numbers). */
19
+ cue?: QuotaFreshnessCue
20
+ /**
21
+ * Only the USER can produce a new reading (aged-out Claude statusline;
22
+ * expired CLI access token — the daemon never redeems it). The surface
23
+ * should say what to run, not promise a self-heal.
24
+ */
25
+ userMustAct: boolean
26
+ /**
27
+ * Unix ms by which the daemon will have looked again, for a `stale` reading
28
+ * the daemon re-checks on its own. Absent when the user must act, when the
29
+ * reading is `refreshing` (a retry is already scheduled), or when the check
30
+ * is already due.
31
+ */
32
+ nextCheckAt?: number
33
+ }
34
+
35
+ /**
36
+ * How long past `metadata.retryAtMs` a retry still reads as "in flight". The
37
+ * retry timer fires at retryAtMs and the probe itself takes a few seconds; a
38
+ * retryAtMs well in the past with no newer attempt means no retry is coming.
39
+ */
40
+ export const QUOTA_RETRY_INFLIGHT_GRACE_MS = 60_000
41
+
42
+ /**
43
+ * The daemon's backfill horizon: a snapshot is re-probed once it is this old
44
+ * even on an idle machine. Mirrors daemon-core `QUOTA_ROUTABLE_MAX_AGE_MS`
45
+ * (quota/refresh.ts); daemon-core's quota-freshness test pins the two equal.
46
+ */
47
+ export const QUOTA_BACKFILL_HORIZON_MS = 60 * 60 * 1000
48
+
49
+ function hasUsableWindow(window: { usedPercent?: unknown } | null | undefined): boolean {
50
+ return !!window && typeof window.usedPercent === 'number' && Number.isFinite(window.usedPercent)
51
+ }
52
+
53
+ /**
54
+ * Does the snapshot carry any renderable number — session/weekly/monthly
55
+ * windows OR per-pool buckets? `monthly` counts on its own: cursor-cli reports
56
+ * ONLY that axis, and leaving it out made a retained cursor reading look
57
+ * numberless. A window without a finite `usedPercent` is unknown, not 0%.
58
+ */
59
+ function hasReading(quota: MeshNodeFactsProviderQuota): boolean {
60
+ if (hasUsableWindow(quota.session) || hasUsableWindow(quota.weekly) || hasUsableWindow(quota.monthly)) return true
61
+ return Array.isArray(quota.buckets) && quota.buckets.some((b) => !!b && typeof b.usedPercent === 'number' && Number.isFinite(b.usedPercent))
62
+ }
63
+
64
+ function finiteMs(value: unknown): number | undefined {
65
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
66
+ }
67
+
68
+ /**
69
+ * When the daemon will next look, from the snapshot alone — the same
70
+ * "both clocks" rule the refresh loop's backfill uses (data age AND attempt
71
+ * age must both pass the horizon). Undefined when that moment has passed.
72
+ */
73
+ function backfillCheckAt(quota: MeshNodeFactsProviderQuota, now: number): number | undefined {
74
+ const dataDue = finiteMs(quota.updatedAt)
75
+ const attempted = finiteMs(quota.metadata?.fetchedAt)
76
+ const dueAt = Math.max(
77
+ dataDue === undefined ? Number.NEGATIVE_INFINITY : dataDue + QUOTA_BACKFILL_HORIZON_MS,
78
+ attempted === undefined ? Number.NEGATIVE_INFINITY : attempted + QUOTA_BACKFILL_HORIZON_MS,
79
+ )
80
+ return Number.isFinite(dueAt) && dueAt > now ? dueAt : undefined
81
+ }
82
+
83
+ /**
84
+ * Which freshness cue a snapshot's retained numbers should carry.
85
+ *
86
+ * - `stale` + `userMustAct` — nothing will refresh it without the user:
87
+ * · `no-data` on any provider EXCEPT codex (Claude's statusline aged out —
88
+ * a session must run). Codex's `no-data` is the daemon re-reading the
89
+ * rollout files itself (≈60s while a CLI is active, ≤60 min idle), so it
90
+ * is `stale` WITHOUT userMustAct;
91
+ * · `expired-token` on Grok/Kimi/antigravity — only the owning CLI
92
+ * renews it on use. An idle CLI has no background renewal cadence.
93
+ * A scheduled quota retry does not itself renew credentials.
94
+ * - `refreshing` — retained numbers after a transient failure with a retry
95
+ * actually pending. ★Honest about the end of the retry budget: once the
96
+ * daemon has stopped retrying (`metadata.retryExhausted`, or `retryAtMs`
97
+ * long past with no newer attempt) the numbers are `stale` with the next
98
+ * scheduled check, not "refreshing" for the up-to-60 minutes until the
99
+ * hourly backfill. A snapshot with NO retry bookkeeping at all (a daemon
100
+ * predating the stamp) keeps the old `refreshing` reading.
101
+ */
102
+ export function assessQuotaFreshness(quota: MeshNodeFactsProviderQuota, now: number = Date.now()): QuotaFreshness {
103
+ const meta = quota.metadata
104
+ const kind = meta?.failureKind
105
+ const userMustAct = kind === 'no-data' && quota.provider !== 'codex-cli'
106
+ const awaitsCliUse = kind === 'expired-token' && ['grok-cli', 'kimi', 'antigravity-cli'].includes(quota.provider)
107
+ // Order matters: the aged-out Claude shape and the retained expired-token
108
+ // shape both ALSO mark lastGoodWindows, so they must be classified before
109
+ // the generic branch. An expired short-lived access token that the CLI
110
+ // renews itself is NOT an auth failure requiring user action (userMustAct:
111
+ // false), while any retained lastGood numbers are marked stale.
112
+ if (userMustAct) return hasReading(quota) ? { cue: 'stale', userMustAct: true } : { userMustAct: true }
113
+ if (awaitsCliUse) return hasReading(quota) ? { cue: 'stale', userMustAct: false } : { userMustAct: false }
114
+ if (meta?.lastGoodWindows !== true) {
115
+ // A successful fetch is not a perpetual live reading. Use the DATA
116
+ // clock; a recent failed attempt must never rejuvenate old numbers.
117
+ const measuredAt = finiteMs(quota.updatedAt)
118
+ if (hasReading(quota) && (measuredAt === undefined || now - measuredAt >= QUOTA_BACKFILL_HORIZON_MS)) {
119
+ return { cue: 'stale', userMustAct }
120
+ }
121
+ return { userMustAct }
122
+ }
123
+
124
+ const retryAtMs = finiteMs(meta?.retryAtMs)
125
+ const retryStopped = meta?.retryExhausted === true
126
+ || (retryAtMs !== undefined && retryAtMs + QUOTA_RETRY_INFLIGHT_GRACE_MS < now)
127
+ // A `no-data` retained reading (codex) has no retry at all — it is a
128
+ // snapshot the daemon will simply look at again, never an in-flight refresh.
129
+ if (kind !== 'no-data' && !retryStopped) return { cue: 'refreshing', userMustAct: false }
130
+ const nextCheckAt = backfillCheckAt(quota, now)
131
+ return nextCheckAt === undefined
132
+ ? { cue: 'stale', userMustAct: false }
133
+ : { cue: 'stale', userMustAct: false, nextCheckAt }
134
+ }
135
+
136
+ /** Whole minutes (min 1) until `nextCheckAt`, for the "next check ~Nm" wording. */
137
+ export function minutesUntilQuotaCheck(nextCheckAt: number, now: number = Date.now()): number {
138
+ return Math.max(1, Math.round((nextCheckAt - now) / 60_000))
139
+ }
@@ -0,0 +1,51 @@
1
+ /** Persisted role-launch intent. Restart replays this selection rather than
2
+ * consulting newer machine defaults; restore preserves its original provenance. */
3
+ import { isLaunchDefaultRole, isLaunchDefaultSource, type LaunchDefaultRole,
4
+ type LaunchDefaultSelection, type LaunchDefaultAxis, type LaunchDefaultRef } from './launch-defaults';
5
+ export interface RoleLaunchSelection {
6
+ role: LaunchDefaultRole;
7
+ selection: LaunchDefaultSelection;
8
+ applied: Record<keyof LaunchDefaultSelection, LaunchDefaultAxis>;
9
+ defaultRef?: LaunchDefaultRef;
10
+ documentRevision: number;
11
+ }
12
+ function record(value: unknown): Record<string, unknown> | undefined {
13
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
14
+ ? value as Record<string, unknown> : undefined;
15
+ }
16
+ function ref(value: unknown): LaunchDefaultRef | undefined {
17
+ const r = record(value);
18
+ if (!r || !isLaunchDefaultRole(r.role) || (r.scope !== 'global' && r.scope !== 'project')
19
+ || !Number.isSafeInteger(r.revision) || (r.revision as number) < 1
20
+ || (r.scope === 'project' && (typeof r.projectId !== 'string' || !r.projectId))) return undefined;
21
+ return { role: r.role, scope: r.scope, revision: r.revision as number,
22
+ ...(r.scope === 'project' ? { projectId: r.projectId as string } : {}) };
23
+ }
24
+ function axis(value: unknown): LaunchDefaultAxis | undefined {
25
+ const a = record(value);
26
+ if (!a || !isLaunchDefaultSource(a.source) || (a.value !== null && typeof a.value !== 'string')) return undefined;
27
+ const defaultRef = ref(a.defaultRef);
28
+ if (a.source === 'stored_default' && defaultRef?.scope !== 'global'
29
+ || a.source === 'project_default' && defaultRef?.scope !== 'project'
30
+ || (a.source === 'user' || a.source === 'provider_default') && a.defaultRef !== undefined) return undefined;
31
+ return { value: a.value as string | null, source: a.source, ...(defaultRef ? { defaultRef } : {}) };
32
+ }
33
+ export function parseRoleLaunchSelection(value: unknown): RoleLaunchSelection | undefined {
34
+ const r = record(value), s = record(r?.selection), a = record(r?.applied);
35
+ if (!r || !isLaunchDefaultRole(r.role) || !s || typeof s.provider !== 'string' || !s.provider
36
+ || !Number.isSafeInteger(r.documentRevision) || (r.documentRevision as number) < 1
37
+ || (s.model !== undefined && typeof s.model !== 'string')
38
+ || (s.thinkingLevel !== undefined && typeof s.thinkingLevel !== 'string')) return undefined;
39
+ const provider = axis(a?.provider), model = axis(a?.model), thinkingLevel = axis(a?.thinkingLevel);
40
+ if (!provider || !model || !thinkingLevel
41
+ || [provider, model, thinkingLevel].some(axis => axis.defaultRef && (axis.defaultRef.role !== r.role
42
+ || axis.defaultRef.scope === 'global' && axis.defaultRef.revision > (r.documentRevision as number)))
43
+ || provider.value !== s.provider
44
+ || model.value !== (s.model ?? null) || thinkingLevel.value !== (s.thinkingLevel ?? null)) return undefined;
45
+ const defaultRef = ref(r.defaultRef);
46
+ return { role: r.role, selection: { provider: s.provider,
47
+ ...(typeof s.model === 'string' ? { model: s.model } : {}),
48
+ ...(typeof s.thinkingLevel === 'string' ? { thinkingLevel: s.thinkingLevel } : {}) },
49
+ applied: { provider, model, thinkingLevel }, ...(defaultRef ? { defaultRef } : {}),
50
+ documentRevision: r.documentRevision as number };
51
+ }
@@ -1,3 +1,4 @@
1
+ import { parseRoleLaunchSelection, type RoleLaunchSelection } from './role-launch'
1
2
  /**
2
3
  * session-launch — how a session was launched: provider, model and thinking
3
4
  * level, and WHERE each value came from.
@@ -35,11 +36,14 @@ export const MODEL_AXIS_SOURCES = [
35
36
  'user',
36
37
  /** The dashboard dialog restored it from the last launch (browser-local memory). */
37
38
  'remembered',
39
+ /** The authoritative daemon role default or host project override. */
40
+ 'stored_default',
41
+ 'project_default',
38
42
  /** A mesh capability slot supplied it (or a difficulty preset the slot outranked). */
39
43
  'mesh_slot',
40
44
  /** An explicit per-task model / thinking level on a mesh queue task. */
41
45
  'task_override',
42
- /** Nothing was requested; the provider's default is known (discovery / manifest). */
46
+ /** Nothing requested: the provider chooses; resolvedDefault records it when known. */
43
47
  'provider_default',
44
48
  /** A value was requested without saying where it came from, or nothing is known. */
45
49
  'unspecified',
@@ -102,6 +106,7 @@ export interface ModelSelection {
102
106
  }
103
107
 
104
108
  export interface SessionLaunchRecord {
109
+ roleLaunch?: RoleLaunchSelection
105
110
  sessionId: string
106
111
  providerType: string
107
112
  providerVersion?: string
@@ -230,7 +235,9 @@ export function parseSessionLaunchRecord(raw: unknown): SessionLaunchRecord | un
230
235
  const providerVersion = readString(record.providerVersion)
231
236
  const workspace = readString(record.workspace)
232
237
  const autoApproveModeId = readString(record.autoApproveModeId)
238
+ const roleLaunch = parseRoleLaunchSelection(record.roleLaunch)
233
239
  return {
240
+ ...(roleLaunch ? { roleLaunch } : {}),
234
241
  sessionId,
235
242
  providerType,
236
243
  ...(providerVersion ? { providerVersion } : {}),
@@ -40,6 +40,13 @@ import {
40
40
  export interface CliSlotRecipe {
41
41
  /** Optional model to pin on the slot. Best-effort at launch. */
42
42
  model?: string
43
+ /**
44
+ * Optional ordered candidate models to prefer when the provider's live
45
+ * `modelOptions` (from model discovery) is present on `DetectedCliProvider`.
46
+ * The first candidate present in `modelOptions` wins; falls back to `model`
47
+ * when `modelOptions` is absent or contains none of the candidates.
48
+ */
49
+ modelCandidates?: readonly string[]
43
50
  /** Optional thinking level, in the provider's own vocabulary. */
44
51
  thinkingLevel?: string
45
52
  /** Difficulty range this slot handles. Empty = general-purpose. */
@@ -101,11 +108,26 @@ export const CLI_SLOT_RECIPES: Readonly<Record<string, readonly CliSlotRecipe[]>
101
108
  ],
102
109
  'antigravity-cli': [
103
110
  {
104
- model: 'Gemini 3.1 Pro (High)',
111
+ model: 'Gemini 3.8 Flash (High)',
112
+ modelCandidates: ['Gemini 3.8 Flash (High)', 'Gemini 4 Argon (Low)', 'Gemini 3.7 Flash (High)', 'Gemini 3.1 Pro (High)'],
105
113
  difficulty: ['easy'],
106
114
  maxParallel: 2,
107
115
  rationale: 'Cheap capacity for easy tasks.',
108
116
  },
117
+ {
118
+ model: 'Gemini 4 Argon (Medium)',
119
+ modelCandidates: ['Gemini 4 Argon (Medium)', 'Gemini 3.8 Flash (High)', 'Gemini 3.7 Flash (High)', 'Gemini 3.1 Pro (High)'],
120
+ difficulty: ['medium'],
121
+ maxParallel: 2,
122
+ rationale: 'Balanced Argon tier for medium tasks.',
123
+ },
124
+ {
125
+ model: 'Gemini 4 Argon (High)',
126
+ modelCandidates: ['Gemini 4 Argon (High)', 'Gemini 3.8 Flash (High)', 'Gemini 3.1 Pro (High)'],
127
+ difficulty: ['difficult'],
128
+ maxParallel: 1,
129
+ rationale: 'Reserved for hard tasks; capped at 1 to bound cost.',
130
+ },
109
131
  ],
110
132
  'cursor-cli': [
111
133
  {
@@ -137,6 +159,13 @@ export interface DetectedCliProvider {
137
159
  displayName?: string
138
160
  /** Detected version, when known. Display only — never affects the mapping. */
139
161
  version?: string
162
+ /**
163
+ * Live discovered / resolved `modelOptions` for this provider on the node,
164
+ * when available on the status catalog. When present and non-empty, recipes
165
+ * prefer the first matching model from `[recipe.model, ...recipe.modelCandidates]`
166
+ * that exists in `modelOptions`.
167
+ */
168
+ modelOptions?: readonly string[]
140
169
  }
141
170
 
142
171
  /** One proposed slot plus why it was proposed. */
@@ -197,6 +226,36 @@ function dedupeDetected(detected: readonly DetectedCliProvider[]): DetectedCliPr
197
226
  return out
198
227
  }
199
228
 
229
+ /**
230
+ * Resolve a recipe's pinned model against the provider's live `modelOptions`
231
+ * (populated from the daemon's model-discovery overlay when available). If
232
+ * `modelOptions` is absent or none of the recipe's candidates appear in it,
233
+ * falls back to `recipe.model`.
234
+ */
235
+ function resolveRecipeModel(
236
+ recipe: CliSlotRecipe,
237
+ modelOptions: readonly string[] | undefined,
238
+ ): string | undefined {
239
+ if (!Array.isArray(modelOptions) || modelOptions.length === 0) {
240
+ return recipe.model
241
+ }
242
+ const available = new Set(
243
+ modelOptions
244
+ .filter((m): m is string => typeof m === 'string' && m.trim().length > 0)
245
+ .map(m => m.trim()),
246
+ )
247
+ if (available.size === 0) return recipe.model
248
+ const candidates = [
249
+ ...(recipe.model ? [recipe.model] : []),
250
+ ...(recipe.modelCandidates ?? []),
251
+ ]
252
+ for (const candidate of candidates) {
253
+ const trimmed = typeof candidate === 'string' ? candidate.trim() : ''
254
+ if (trimmed && available.has(trimmed)) return trimmed
255
+ }
256
+ return recipe.model
257
+ }
258
+
200
259
  /**
201
260
  * Build a capability-slot proposal from a node's detected CLI providers.
202
261
  *
@@ -229,7 +288,7 @@ export function buildSlotProposal(
229
288
  // a proposal can never preview a shape the write would reshape.
230
289
  const slot = normalizeNodeCapabilitySlot({
231
290
  provider: provider.type,
232
- model: recipe.model,
291
+ model: resolveRecipeModel(recipe, provider.modelOptions),
233
292
  thinkingLevel: recipe.thinkingLevel,
234
293
  difficulty: recipe.difficulty,
235
294
  maxParallel: recipe.maxParallel,
@@ -0,0 +1,61 @@
1
+ import type { OwnedPathEntry } from './path-ownership'
2
+
3
+ export type MeshTaskPhase = 'source' | 'integration'
4
+
5
+ /** Host-produced evidence. Never accepted from MCP/peer-supplied paths or branch labels. */
6
+ export interface PhysicalOwnershipScope {
7
+ version: 1
8
+ repoIdentity: string
9
+ machineId: string
10
+ nodeId: string
11
+ worktree: string
12
+ worktreeIdentity: string
13
+ gitDir: string
14
+ commonGitDir: string
15
+ phase: MeshTaskPhase
16
+ gitResources: { index: string; refs: string; hooks: string; config: string }
17
+ resources: Array<{ path: string; identity?: string; subtree: boolean }>
18
+ declaration: readonly OwnedPathEntry[]
19
+ }
20
+
21
+ /** A source packet is an input to integration, never release/feature acceptance. */
22
+ export interface MeshSourceCandidate {
23
+ version: 1
24
+ readiness: 'uncommitted' | 'verified_source_candidate'
25
+ taskId: string
26
+ missionId: string
27
+ revision: number
28
+ repoIdentity: string
29
+ machineId: string
30
+ canonicalWorktree: string
31
+ rootBaseSha: string
32
+ ossBaseSha: string
33
+ rootCandidateSha?: string
34
+ ossCandidateSha?: string
35
+ patchSha256: string
36
+ manifestSha256: string
37
+ touchedPaths: string[]
38
+ tests: Array<{ command: string; exit: number; logSha256: string }>
39
+ settledCommands: boolean
40
+ integrationTaskId: string
41
+ logicalOverlapTaskIds: string[]
42
+ }
43
+
44
+ /** Product B consumes one frozen batch; normal hooks/parity/gates remain required. */
45
+ export interface MeshIntegrationBatchContract {
46
+ version: 1
47
+ batchId: string
48
+ candidates: MeshSourceCandidate[]
49
+ integrationOrder: string[]
50
+ approvedRootSha: string
51
+ approvedOssSha: string
52
+ bundleInvocationCount: 1
53
+ requiredGates: string[]
54
+ normalHooksRequired: true
55
+ independentReviewRequired: true
56
+ ownerLandingApprovalRequired: true
57
+ }
58
+
59
+ /** These only rank legal targets; targetNodeId/SessionId and requiredTags remain hard. */
60
+ export interface MeshRoutingPreferences { nodeIds?: string[]; providers?: string[] }
61
+ export interface MeshReviewConstraint { sourceTaskId: string; differentProvider: true }
@@ -83,9 +83,9 @@ export type SessionReboundReason = typeof SESSION_REBOUND_REASONS[number]
83
83
 
84
84
  export const SUSPENSION_MODALS = ['approval', 'choice'] as const
85
85
  export type SuspensionModal = typeof SUSPENSION_MODALS[number]
86
- export const SUSPENSION_RESOLUTIONS = ['approved', 'rejected', 'answered'] as const
86
+ export const SUSPENSION_RESOLUTIONS = ['approved', 'rejected', 'answered', 'dismissed'] as const
87
87
  export type SuspensionResolution = typeof SUSPENSION_RESOLUTIONS[number]
88
- export const SUSPENSION_RESOLUTION_VIAS = ['modal_button', 'auto_approve', 'prompt_answer'] as const
88
+ export const SUSPENSION_RESOLUTION_VIAS = ['modal_button', 'auto_approve', 'prompt_answer', 'modal_disappeared'] as const
89
89
  export type SuspensionResolutionVia = typeof SUSPENSION_RESOLUTION_VIAS[number]
90
90
 
91
91
  export const TURN_END_STRENGTHS = ['genuine', 'weak'] as const
@@ -97,13 +97,13 @@ export const TURN_END_BLOCK_REASONS = [
97
97
  ] as const
98
98
  export type TurnEndBlockReason = typeof TURN_END_BLOCK_REASONS[number]
99
99
 
100
- export const NATIVE_TURN_OUTCOMES = ['completed', 'aborted'] as const
100
+ export const NATIVE_TURN_OUTCOMES = ['completed', 'aborted', 'failed'] as const
101
101
  export type NativeTurnOutcome = typeof NATIVE_TURN_OUTCOMES[number]
102
102
 
103
103
  export const LIVENESS_RESULTS = ['alive', 'unknown', 'read_failed', 'dead'] as const
104
104
  export type LivenessResult = typeof LIVENESS_RESULTS[number]
105
105
 
106
- export const PROVIDER_FAILURES = ['auth_failed', 'billing_failed'] as const
106
+ export const PROVIDER_FAILURES = ['auth_failed', 'billing_failed', 'usage_limit_exceeded', 'server_overloaded', 'provider_error'] as const
107
107
  export type ProviderFailure = typeof PROVIDER_FAILURES[number]
108
108
 
109
109
  /**
@@ -150,11 +150,11 @@ export const HOLD_REASONS = [
150
150
  // — a turn_end commits the report (R9t), expiry commits it anyway (R13t) so
151
151
  // a session whose FSM never shows the idle edge again does not wait for
152
152
  // liveness / hard_ceiling.
153
- 'await_end',
153
+ 'await_end', 'report_correction',
154
154
  ] as const
155
155
  export type HoldReason = typeof HOLD_REASONS[number]
156
156
 
157
- export const TURN_OUTCOMES = ['completed', 'failed', 'cancelled'] as const
157
+ export const TURN_OUTCOMES = ['completed', 'blocked', 'failed', 'cancelled'] as const
158
158
  export type TurnOutcome = typeof TURN_OUTCOMES[number]
159
159
 
160
160
  export const COMMIT_STRENGTHS = ['genuine', 'weak', 'tool_report', 'operator'] as const
@@ -164,7 +164,7 @@ export type CommitStrength = typeof COMMIT_STRENGTHS[number]
164
164
  export const TURN_REASONS = [
165
165
  // commits
166
166
  'turn_end', 'weak_end_confirmed', 'transcript_final', 'worker_reported',
167
- 'finalization_timeout_no_response', 'session_error', 'provider_auth_failed', 'provider_billing_failed',
167
+ 'finalization_timeout_no_response', 'weak_end_liveness_unconfirmed', 'weak_end_probe_unobserved', 'report_correction_expired', 'session_error', 'provider_auth_failed', 'provider_billing_failed', 'provider_usage_limit_exceeded', 'provider_server_overloaded', 'provider_error',
168
168
  'operator_cancel', 'intentional_cleanup', 'task_removed', 'mission_abandoned', 'superseded',
169
169
  'operator_update', 'refine_terminal', 'validation_terminal',
170
170
  'hard_ceiling', 'reclaim_budget_exhausted', 'hollow_max_retries', 'migration_orphan',
@@ -186,7 +186,7 @@ export type TurnReason = typeof TURN_REASONS[number]
186
186
  * owner's local `turn_events.payload_json` and is rendered at deliver time.
187
187
  */
188
188
  export const NOTIFY_KINDS = [
189
- 'completed', 'failed', 'cancelled', 'stopped', 'approval', 'choice', 'approval_resolved',
189
+ 'completed', 'blocked', 'failed', 'cancelled', 'stopped', 'approval', 'choice', 'approval_resolved',
190
190
  'candidate', 'no_progress', 'progress', 'late_completion', 'mesh_event',
191
191
  ] as const
192
192
  export type NotifyKind = typeof NOTIFY_KINDS[number]
@@ -207,7 +207,7 @@ export interface LiveTurnPending { modal: boolean; adapterPending: boolean; trai
207
207
  /** Structural copy of daemon-core's `CoordinatorIdentity` (this package cannot import daemon-core). */
208
208
  export interface EvidenceCoordinatorIdentity { daemonId: string; coordinatorRunId: string; sessionId?: string }
209
209
 
210
- export interface NativeTurnMarkerRef { outcome: NativeTurnOutcome; turnId?: string }
210
+ export interface NativeTurnMarkerRef { outcome: NativeTurnOutcome; turnId?: string; receivedAt?: number; startedAt?: number; errorAt?: number }
211
211
 
212
212
  export interface EvidenceEnvelope {
213
213
  /** Stable id of this observation; dedupe key across republish/replication. */
@@ -250,10 +250,10 @@ export type TurnEvidenceBody =
250
250
  | { kind: 'session_rebound'; toSessionId: string; reason: SessionReboundReason }
251
251
  // ── worker turn lifecycle ──
252
252
  | { kind: 'turn_started'; retro: boolean }
253
- | { kind: 'suspension'; modal: SuspensionModal; modalKey?: string }
254
- | { kind: 'suspension_resolved'; resolution: SuspensionResolution; via: SuspensionResolutionVia }
253
+ | { kind: 'suspension'; modal: SuspensionModal; modalKey?: string; providerSetup?: boolean; clockUnknown?: boolean }
254
+ | { kind: 'suspension_resolved'; resolution: SuspensionResolution; via: SuspensionResolutionVia; modalKey?: string }
255
255
  | { kind: 'turn_end'; strength: TurnEndStrength; afterFinalizationTimeout?: boolean; hollow?: boolean; summary?: SummaryRef
256
- blockReason?: TurnEndBlockReason; releasedByHardCap?: boolean; nativeOutcome?: NativeTurnOutcome; live?: LiveTurnPending
256
+ blockReason?: TurnEndBlockReason; releasedByHardCap?: boolean; nativeOutcome?: NativeTurnOutcome; nativeMarker?: NativeTurnMarkerRef; live?: LiveTurnPending
257
257
  /**
258
258
  * Stamped (true only) by the WORKER's daemon when the session holds a live
259
259
  * worker-MCP session bind, i.e. the worker can call report_completion. The
@@ -273,7 +273,7 @@ export type TurnEvidenceBody =
273
273
  live: LiveTurnPending; summary?: SummaryRef; messageAt?: number }
274
274
  | { kind: 'transcript_activity'; newestActivityAt: number }
275
275
  | { kind: 'no_progress'; stalledMs: number; observedStatus: NoProgressObservedStatus; finalAssistantPresent: boolean }
276
- | { kind: 'liveness'; result: LivenessResult }
276
+ | { kind: 'liveness'; result: LivenessResult; probeStartedAtMs?: number }
277
277
  | { kind: 'process_exit'; exitCode: number | null; providerFailure?: ProviderFailure }
278
278
  | { kind: 'session_error'; reason: SessionErrorReason }
279
279
  // ── worker MCP (F2) ──
@@ -284,6 +284,7 @@ export type TurnEvidenceBody =
284
284
  */
285
285
  | { kind: 'worker_report'; outcome: WorkerReportOutcome; summary?: SummaryRef; hasHandoffNotes: boolean
286
286
  branchState?: WorkerBranchState; touchedFileCount?: number }
287
+ | { kind: 'worker_report_correction_requested'; deliveryId?: string }
287
288
  | { kind: 'worker_progress'; note?: SummaryRef }
288
289
  // ── coordinator / operator ──
289
290
  | { kind: 'git_side_effect'; dirty: boolean; commitsSinceDispatch: number; attributable: boolean }
@@ -352,13 +353,13 @@ export const TURN_EVIDENCE_FIELD_SPECS: TurnEvidenceFieldSpecs = {
352
353
  duplicate_dispatch_refusal: { holderSessionId: id, holderAttemptId: idOpt },
353
354
  session_rebound: { toSessionId: id, reason: en(SESSION_REBOUND_REASONS) },
354
355
  turn_started: { retro: bool },
355
- suspension: { modal: en(SUSPENSION_MODALS), modalKey: idOpt },
356
- suspension_resolved: { resolution: en(SUSPENSION_RESOLUTIONS), via: en(SUSPENSION_RESOLUTION_VIAS) },
356
+ suspension: { modal: en(SUSPENSION_MODALS), modalKey: idOpt, providerSetup: boolOpt, clockUnknown: boolOpt },
357
+ suspension_resolved: { resolution: en(SUSPENSION_RESOLUTIONS), via: en(SUSPENSION_RESOLUTION_VIAS), modalKey: idOpt },
357
358
  turn_end: {
358
359
  strength: en(TURN_END_STRENGTHS), afterFinalizationTimeout: boolOpt, hollow: boolOpt,
359
360
  summary: { t: 'summary_ref', optional: true }, blockReason: enOpt(TURN_END_BLOCK_REASONS),
360
361
  releasedByHardCap: boolOpt, nativeOutcome: enOpt(NATIVE_TURN_OUTCOMES), live: { t: 'live', optional: true },
361
- reportExpected: boolOpt, providerFailure: enOpt(PROVIDER_FAILURES),
362
+ nativeMarker: { t: 'native_marker', optional: true }, reportExpected: boolOpt, providerFailure: enOpt(PROVIDER_FAILURES),
362
363
  },
363
364
  transcript_final: {
364
365
  selfAttributing: bool, nativeRead: bool, nativeMarker: { t: 'native_marker', optional: true },
@@ -366,13 +367,14 @@ export const TURN_EVIDENCE_FIELD_SPECS: TurnEvidenceFieldSpecs = {
366
367
  },
367
368
  transcript_activity: { newestActivityAt: ms },
368
369
  no_progress: { stalledMs: ms, observedStatus: en(NO_PROGRESS_OBSERVED_STATUSES), finalAssistantPresent: bool },
369
- liveness: { result: en(LIVENESS_RESULTS) },
370
+ liveness: { result: en(LIVENESS_RESULTS), probeStartedAtMs: msOpt },
370
371
  process_exit: { exitCode: { t: 'int', nullable: true }, providerFailure: enOpt(PROVIDER_FAILURES) },
371
372
  session_error: { reason: en(SESSION_ERROR_REASONS) },
372
373
  worker_report: {
373
374
  outcome: en(WORKER_REPORT_OUTCOMES), summary: { t: 'summary_ref', optional: true }, hasHandoffNotes: bool,
374
375
  branchState: enOpt(WORKER_BRANCH_STATES), touchedFileCount: intOpt,
375
376
  },
377
+ worker_report_correction_requested: { deliveryId: { t: 'id', optional: true } },
376
378
  worker_progress: { note: { t: 'summary_ref', optional: true } },
377
379
  git_side_effect: { dirty: bool, commitsSinceDispatch: { t: 'int' }, attributable: bool },
378
380
  cancel: { reason: en(CANCEL_REASONS) },
@@ -440,11 +442,12 @@ function isLiveTurnPending(value: unknown): value is LiveTurnPending {
440
442
  && (value.newestActivityAt === undefined || isFiniteNumber(value.newestActivityAt))
441
443
  }
442
444
 
443
- function isNativeMarkerRef(value: unknown): value is NativeTurnMarkerRef {
445
+ export function isNativeMarkerRef(value: unknown): value is NativeTurnMarkerRef {
444
446
  return isRecord(value)
445
- && hasOnlyKeys(value, ['outcome', 'turnId'])
447
+ && hasOnlyKeys(value, ['outcome', 'turnId', 'receivedAt', 'startedAt', 'errorAt'])
446
448
  && (NATIVE_TURN_OUTCOMES as readonly string[]).includes(value.outcome as string)
447
449
  && (value.turnId === undefined || isEvidenceIdentifier(value.turnId))
450
+ && ['receivedAt', 'startedAt', 'errorAt'].every((key) => value[key] === undefined || isFiniteNumber(value[key]))
448
451
  }
449
452
 
450
453
  function isCoordinatorIdentity(value: unknown): value is EvidenceCoordinatorIdentity {
@@ -221,6 +221,8 @@ export interface MeshMissionTaskAggregateWire {
221
221
  }
222
222
 
223
223
  export interface MeshMissionStatsWire {
224
+ /** Present on peers that project proven blocked outcomes separately. */
225
+ blocked?: number
224
226
  missionId: string
225
227
  taskCount: number
226
228
  completed: number
@@ -313,12 +315,13 @@ function isMeshMissionTaskAggregateWire(value: unknown): value is MeshMissionTas
313
315
  }
314
316
 
315
317
  function isMeshMissionStatsWire(value: unknown): value is MeshMissionStatsWire {
316
- const keys = ['missionId', 'taskCount', 'completed', 'failed', 'totalDurationMs', 'wallClockMs', 'retries', 'incompleteTaskIds'] as const
318
+ const keys = ['missionId', 'taskCount', 'completed', 'failed', 'blocked', 'totalDurationMs', 'wallClockMs', 'retries', 'incompleteTaskIds'] as const
317
319
  if (!isRecord(value) || !hasOnlyKeys(value, keys)) return false
318
320
  if (!isEvidenceIdentifier(value.missionId)) return false
319
321
  for (const k of ['taskCount', 'completed', 'failed', 'totalDurationMs', 'retries'] as const) {
320
322
  if (!isNonNegativeInt(value[k])) return false
321
323
  }
324
+ if (value.blocked !== undefined && !isNonNegativeInt(value.blocked)) return false
322
325
  if (value.wallClockMs !== null && !isNonNegativeInt(value.wallClockMs)) return false
323
326
  if (!Array.isArray(value.incompleteTaskIds) || !value.incompleteTaskIds.every(isEvidenceIdentifier)) return false
324
327
  return true
@@ -580,6 +583,8 @@ export interface LedgerQueryEntryWire {
580
583
  }
581
584
 
582
585
  export interface LedgerQuerySummaryWire {
586
+ /** Absent on older peers; never folded into failed by the decoder. */
587
+ taskBlocked?: number
583
588
  meshId: string
584
589
  totalEntries: number
585
590
  taskDispatched: number
@@ -625,12 +630,13 @@ function isLedgerQueryEntryWire(value: unknown): value is LedgerQueryEntryWire {
625
630
  }
626
631
 
627
632
  function isLedgerQuerySummaryWire(value: unknown): value is LedgerQuerySummaryWire {
628
- const keys = ['meshId', 'totalEntries', 'taskDispatched', 'taskCompleted', 'taskFailed', 'taskStalled', 'sessionLaunched', 'checkpointCreated', 'lastActivityAt', 'recentFailures'] as const
633
+ const keys = ['meshId', 'totalEntries', 'taskDispatched', 'taskCompleted', 'taskFailed', 'taskBlocked', 'taskStalled', 'sessionLaunched', 'checkpointCreated', 'lastActivityAt', 'recentFailures'] as const
629
634
  if (!isRecord(value) || !hasOnlyKeys(value, keys)) return false
630
635
  if (!isEvidenceIdentifier(value.meshId)) return false
631
636
  for (const k of ['totalEntries', 'taskDispatched', 'taskCompleted', 'taskFailed', 'taskStalled', 'sessionLaunched', 'checkpointCreated', 'recentFailures'] as const) {
632
637
  if (!isNonNegativeInt(value[k])) return false
633
638
  }
639
+ if (value.taskBlocked !== undefined && !isNonNegativeInt(value.taskBlocked)) return false
634
640
  if (value.lastActivityAt !== null && typeof value.lastActivityAt !== 'string') return false
635
641
  return true
636
642
  }
@@ -135,6 +135,8 @@ export interface QueueQueryResponse {
135
135
  counts?: Record<string, number>
136
136
  /** `withCounts` + `historicalOlderThanMs`: terminal rows older than that age. */
137
137
  oldHistoricalCount?: number
138
+ /** Included in counts.blocked, derived from proven worker reports; stored failed rows stay unchanged. */
139
+ legacyReportedBlockedCount?: number
138
140
  /** `withDependencyHeads`. */
139
141
  dependencyHeads?: readonly QueueDependencyHeadWire[]
140
142
  }
@@ -159,9 +161,10 @@ function isQueueDependencyHeadWire(value: unknown): value is QueueDependencyHead
159
161
  }
160
162
 
161
163
  function isQueueQueryResponse(value: unknown): value is QueueQueryResponse {
162
- if (!isRecord(value) || !hasOnlyKeys(value, ['entries', 'counts', 'oldHistoricalCount', 'dependencyHeads'])) return false
164
+ if (!isRecord(value) || !hasOnlyKeys(value, ['entries', 'counts', 'oldHistoricalCount', 'legacyReportedBlockedCount', 'dependencyHeads'])) return false
163
165
  if (!Array.isArray(value.entries) || !value.entries.every(isQueueEntryWire)) return false
164
166
  if (value.counts !== undefined && (!isRecord(value.counts) || !Object.values(value.counts).every(isNonNegativeInt))) return false
167
+ if (value.legacyReportedBlockedCount !== undefined && !isNonNegativeInt(value.legacyReportedBlockedCount)) return false
165
168
  if (value.oldHistoricalCount !== undefined && !isNonNegativeInt(value.oldHistoricalCount)) return false
166
169
  if (value.dependencyHeads !== undefined && (!Array.isArray(value.dependencyHeads) || !value.dependencyHeads.every(isQueueDependencyHeadWire))) return false
167
170
  return true
@@ -491,3 +494,28 @@ function isRecoveryContextQueryResponse(value: unknown): value is RecoveryContex
491
494
  export function decodeRecoveryContextQueryResponse(value: unknown): RecoveryContextQueryResponse | null {
492
495
  return isRecoveryContextQueryResponse(value) ? value : null
493
496
  }
497
+
498
+ // ── queue_revise: host-authenticated pending CAS, never a force requeue ──
499
+ export interface QueueReviseRequest {
500
+ v: typeof TURN_IPC_PROTOCOL_VERSION
501
+ meshId: string
502
+ taskId: string
503
+ expectedRevision: number
504
+ patch: { message?: string; ownedPaths?: string[]; phase?: 'source' | 'integration'; dependsOn?: string[]; routingPreferences?: import('./task-source-phase').MeshRoutingPreferences; review?: import('./task-source-phase').MeshReviewConstraint }
505
+ }
506
+ export type QueueReviseResponse = QueueRequeueResponse
507
+ export function decodeQueueReviseRequest(value: unknown): QueueReviseRequest | null {
508
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'taskId', 'expectedRevision', 'patch'])
509
+ || value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.taskId)
510
+ || !Number.isSafeInteger(value.expectedRevision) || (value.expectedRevision as number) < 0
511
+ || !isRecord(value.patch) || !Object.keys(value.patch).length
512
+ || !hasOnlyKeys(value.patch, ['message', 'ownedPaths', 'phase', 'dependsOn', 'routingPreferences', 'review'])) return null
513
+ const p = value.patch
514
+ if (p.message !== undefined && (typeof p.message !== 'string' || !p.message.trim())) return null
515
+ if (p.phase !== undefined && p.phase !== 'source' && p.phase !== 'integration') return null
516
+ for (const key of ['ownedPaths', 'dependsOn']) {
517
+ if (p[key] !== undefined && (!Array.isArray(p[key]) || !(p[key] as unknown[]).every(x => typeof x === 'string' && !!x.trim()))) return null
518
+ }
519
+ return value as unknown as QueueReviseRequest
520
+ }
521
+ export const decodeQueueReviseResponse = decodeQueueRequeueResponse
@@ -66,7 +66,7 @@ function isTurnObserveResponse(value: unknown): value is TurnObserveResponse {
66
66
  && (value.outcome === undefined || isTurnOutcomeValue(value.outcome))
67
67
  }
68
68
 
69
- const TURN_OUTCOMES_LOCAL = ['completed', 'failed', 'cancelled'] as const
69
+ const TURN_OUTCOMES_LOCAL = ['completed', 'blocked', 'failed', 'cancelled'] as const
70
70
  function isTurnOutcomeValue(value: unknown): value is TurnOutcome {
71
71
  return typeof value === 'string' && (TURN_OUTCOMES_LOCAL as readonly string[]).includes(value)
72
72
  }