@adhdev/daemon-core 0.9.82-rc.374 → 0.9.82-rc.375

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.
@@ -21,6 +21,47 @@
21
21
  * a static prompt, which is also fine.
22
22
  */
23
23
  import type { LocalMeshEntry, RepoMeshStatus } from '../repo-mesh-types.js';
24
+ /**
25
+ * Cheap, locally-derived "what just happened" snapshot for the coordinator
26
+ * prompt. Built at launch from the local ledger + work-queue stats — no remote
27
+ * peer probe. Surfaces the gap a fresh coordinator otherwise misses: it can't
28
+ * see recent failures / queue depth until it manually calls mesh_task_history.
29
+ *
30
+ * All fields are optional so callers that have nothing to report (or fail to
31
+ * read the ledger) simply omit the section — the prompt output stays identical
32
+ * to the pre-activity form in that case.
33
+ */
34
+ export interface CoordinatorRecentActivity {
35
+ /** task_failed entries from the recent window, newest last. */
36
+ recentFailures?: Array<{
37
+ timestamp?: string;
38
+ nodeId?: string;
39
+ /** Short task title/message, already truncated by the caller. */
40
+ summary?: string;
41
+ }>;
42
+ /** Count of task_failed entries inside the recent (30-min) window. */
43
+ recentFailureCount?: number;
44
+ /** Pending (unclaimed) tasks in the work queue. */
45
+ pendingTasks?: number;
46
+ /** Assigned-but-not-yet-terminal tasks in the work queue. */
47
+ assignedTasks?: number;
48
+ /** Stalled tasks recorded in the ledger. */
49
+ stalledTasks?: number;
50
+ /** ISO timestamp of the most recent ledger activity, if any. */
51
+ lastActivityAt?: string | null;
52
+ }
53
+ /**
54
+ * One coordinator operating note — a runtime-accumulated lesson (provider
55
+ * quirk, pattern to avoid, recovery lesson) persisted in the ledger so it
56
+ * survives coordinator restarts and is provider-neutral (visible to codex /
57
+ * hermes / antigravity coordinators, not just Claude's memory).
58
+ */
59
+ export interface CoordinatorOperatingNote {
60
+ text: string;
61
+ category?: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson';
62
+ createdAt?: string;
63
+ sourceCoordinator?: string;
64
+ }
24
65
  export interface CoordinatorPromptContext {
25
66
  mesh: LocalMeshEntry;
26
67
  status?: RepoMeshStatus;
@@ -32,6 +73,18 @@ export interface CoordinatorPromptContext {
32
73
  * stays identical to the pre-M3 form in that case.
33
74
  */
34
75
  missionSection?: string;
76
+ /**
77
+ * Gap1: recent ledger/queue activity surfaced so a freshly-launched
78
+ * coordinator sees recent failures + queue depth without first calling
79
+ * mesh_task_history. Omitted → no "## Recent Activity" section.
80
+ */
81
+ recentActivity?: CoordinatorRecentActivity;
82
+ /**
83
+ * Gap2-A: runtime-accumulated operating notes (provider-neutral lessons)
84
+ * read from the ledger at launch. Omitted/empty → no "## Operating Notes"
85
+ * section.
86
+ */
87
+ operatingNotes?: CoordinatorOperatingNote[];
35
88
  }
36
89
  /**
37
90
  * Compose the final coordinator prompt from four layers, in this precedence:
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed';
17
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note';
18
18
  export interface MeshLedgerEntry {
19
19
  id: string;
20
20
  meshId: string;
@@ -87,6 +87,7 @@ export declare class CliProviderInstance implements ProviderInstance {
87
87
  private runtimeMessages;
88
88
  private lastPersistedHistoryMessages;
89
89
  private lastAcknowledgedUserInputAt;
90
+ private recentUserInputAcks;
90
91
  private lastNativeSourceCanonicalCheckAt;
91
92
  private lastNativeSourceCanonicalCacheKey;
92
93
  private cachedSqliteDb;
@@ -168,6 +169,8 @@ export declare class CliProviderInstance implements ProviderInstance {
168
169
  isModalParked(): boolean;
169
170
  onEvent(event: string, data?: any): void;
170
171
  recordAcknowledgedUserInput(input: InputEnvelope | string): void;
172
+ /** Drop user-input ack entries older than the dedup window so the map can't grow unbounded. */
173
+ private pruneRecentUserInputAcks;
171
174
  dispose(): void;
172
175
  private completedDebounceTimer;
173
176
  private completedDebouncePending;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.374",
3
+ "version": "0.9.82-rc.375",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.374",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.375",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -59,6 +59,71 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
59
59
  try { return buildMissionPromptSection(id); } catch { return ''; }
60
60
  };
61
61
 
62
+ // Gap1: surface recent ledger/queue activity (recent failures +
63
+ // queue depth) so a freshly-launched coordinator sees them
64
+ // without first calling mesh_task_history. All cheap local reads
65
+ // — no remote peer probe. Best-effort: a read failure just omits
66
+ // the section, never blocks launch.
67
+ const buildRecentActivityBestEffort = async (id: string) => {
68
+ try {
69
+ const { getLedgerSummary, readLedgerEntries } = await import('../../mesh/mesh-ledger.js');
70
+ const { getMeshQueueStats } = await import('../../mesh/mesh-work-queue.js');
71
+ const summary = getLedgerSummary(id);
72
+ const queue = getMeshQueueStats(id);
73
+ const failureEntries = readLedgerEntries(id, { kind: ['task_failed'], tail: 5 });
74
+ const recentFailures = failureEntries.map((e) => {
75
+ const p = (e.payload || {}) as Record<string, unknown>;
76
+ const raw = typeof p.taskSummary === 'string' ? p.taskSummary
77
+ : typeof p.message === 'string' ? p.message
78
+ : typeof p.error === 'string' ? p.error
79
+ : '';
80
+ const summaryText = raw.length > 160 ? `${raw.slice(0, 160)}…` : raw;
81
+ return {
82
+ timestamp: e.timestamp,
83
+ nodeId: e.nodeId,
84
+ summary: summaryText,
85
+ };
86
+ });
87
+ return {
88
+ recentFailures,
89
+ recentFailureCount: summary.recentFailures,
90
+ pendingTasks: queue.pending,
91
+ assignedTasks: queue.assigned,
92
+ stalledTasks: summary.taskStalled,
93
+ lastActivityAt: summary.lastActivityAt,
94
+ };
95
+ } catch { return undefined; }
96
+ };
97
+
98
+ // Gap2-A: load accumulated operating notes (provider-neutral
99
+ // lessons) from the ledger so they ride into the prompt. Newest
100
+ // last; cap to the most recent 20 so the section stays lean.
101
+ // Best-effort: a read failure just omits the section.
102
+ const buildOperatingNotesBestEffort = async (id: string) => {
103
+ try {
104
+ const { readLedgerEntries } = await import('../../mesh/mesh-ledger.js');
105
+ const noteEntries = readLedgerEntries(id, { kind: ['coordinator_operating_note'], tail: 20 });
106
+ const notes = noteEntries
107
+ .map((e) => {
108
+ const p = (e.payload || {}) as Record<string, unknown>;
109
+ const text = typeof p.text === 'string' ? p.text.trim() : '';
110
+ if (!text) return null;
111
+ const category: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson' | undefined =
112
+ p.category === 'provider_quirk' || p.category === 'pattern_to_avoid' || p.category === 'recovery_lesson'
113
+ ? p.category
114
+ : undefined;
115
+ return {
116
+ text,
117
+ category,
118
+ createdAt: typeof p.createdAt === 'string' ? p.createdAt : e.timestamp,
119
+ sourceCoordinator: typeof p.sourceCoordinator === 'string' ? p.sourceCoordinator : undefined,
120
+ };
121
+ })
122
+ .filter((n): n is NonNullable<typeof n> => n !== null);
123
+ return notes.length ? notes : undefined;
124
+ } catch { return undefined; }
125
+ };
126
+
62
127
  // Support inline mesh data from cloud (bypasses local meshes.json lookup)
63
128
  let mesh: any;
64
129
  if (args?.inlineMesh && typeof args.inlineMesh === 'object') {
@@ -164,7 +229,7 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
164
229
  // Build coordinator prompt first — fail closed on errors.
165
230
  let cliCmdSystemPrompt = '';
166
231
  try {
167
- cliCmdSystemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined, missionSection: buildMissionSectionBestEffort(mesh.id) });
232
+ cliCmdSystemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined, missionSection: buildMissionSectionBestEffort(mesh.id), recentActivity: await buildRecentActivityBestEffort(mesh.id), operatingNotes: await buildOperatingNotesBestEffort(mesh.id) });
168
233
  } catch (error: any) {
169
234
  const message = error?.message || String(error);
170
235
  LOG.error('MeshCoordinator', `Failed to build coordinator prompt: ${message}`);
@@ -382,7 +447,7 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
382
447
  // broken mesh state is visible instead of silently launching with weaker rules.
383
448
  let systemPrompt = '';
384
449
  try {
385
- systemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined, missionSection: buildMissionSectionBestEffort(mesh.id) });
450
+ systemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined, missionSection: buildMissionSectionBestEffort(mesh.id), recentActivity: await buildRecentActivityBestEffort(mesh.id), operatingNotes: await buildOperatingNotesBestEffort(mesh.id) });
386
451
  } catch (error: any) {
387
452
  const message = error?.message || String(error);
388
453
  LOG.error('MeshCoordinator', `Failed to build coordinator prompt: ${message}`);
@@ -96,15 +96,22 @@ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
96
96
  } catch { /* best-effort — dispatch still proceeds without the stamp */ }
97
97
  }
98
98
  }
99
- const agentResult = await ctx.deps.cliManager.handleCliCommand('agent_command', args);
100
- // Bug C fix (part 2): when dispatching a task to a mesh node session, override
101
- // the dispatch acknowledgement risk reason to 'bootstrap_still_running' when
102
- // the target node's worktree bootstrap is still running. Informational only
103
- // dispatch is NOT blocked.
99
+ // Bug C fix / bootstrapPending dispatch gap: a task dispatched to a mesh node
100
+ // whose worktree bootstrap is STILL running must NOT be injected yet. Before this
101
+ // gate the dispatch proceeded and the prompt landed in the session's input buffer
102
+ // while the provider (e.g. claude CLI) was not yet ready to consume it the inject
103
+ // was silently swallowed (the chat bubble showed the text but the session never
104
+ // transitioned to generating and never claimed the task). The prior code only
105
+ // ANNOTATED the already-completed dispatch with 'bootstrap_still_running' after the
106
+ // fact, which did not close the gap. Defer instead: refuse the inject with a
107
+ // recoverable signal so the coordinator re-sends once the node is ready (the
108
+ // confirmed "re-send to a ready session works" path), mirroring the queued-delivery
109
+ // contract for busy sessions. send_chat only — non-task actions still pass through.
104
110
  const meshCtx = args?.meshContext as Record<string, unknown> | undefined;
105
111
  const dispatchNodeId = readStringValue(meshCtx?.nodeId);
106
112
  const dispatchMeshId = readStringValue(meshCtx?.meshId);
107
- if (dispatchNodeId && dispatchMeshId && agentResult?.success !== false) {
113
+ const isSendChat = args?.action === 'send_chat';
114
+ if (isSendChat && dispatchNodeId && dispatchMeshId) {
108
115
  try {
109
116
  const { getMesh } = await import('../../config/mesh-config.js');
110
117
  const meshObj = getMesh(dispatchMeshId) ?? ctx.getCachedInlineMesh(dispatchMeshId);
@@ -114,16 +121,21 @@ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
114
121
  const bootstrapStatus = readStringValue(nodeObj?.worktreeBootstrap?.status);
115
122
  if (bootstrapStatus === 'running') {
116
123
  return {
117
- success: true,
118
- ...agentResult,
119
- dispatchAcknowledgementRisk: true,
120
- dispatchAcknowledgementRiskReason: 'bootstrap_still_running',
121
- nextAction: 'Wait for worktree_bootstrap_complete event before dispatching work to this node.',
124
+ success: false,
125
+ recoverable: true,
126
+ dispatched: false,
127
+ code: 'mesh_node_bootstrap_pending',
128
+ reason: 'bootstrap_still_running',
129
+ nodeId: dispatchNodeId,
130
+ meshId: dispatchMeshId,
131
+ ...(readStringValue(meshCtx?.taskId) ? { taskId: readStringValue(meshCtx?.taskId) } : {}),
132
+ error: `Node '${dispatchNodeId}' worktree bootstrap is still running; a task injected now would land in the session input buffer before the provider is ready to consume it and be silently lost. Dispatch deferred.`,
133
+ nextAction: 'Wait for the worktree_bootstrap_complete event (or poll mesh_status until the node session is ready), then re-send the task with mesh_send_task. Alternatively use mesh_enqueue_task so the queue auto-assigns it once a ready session is available.',
122
134
  };
123
135
  }
124
- } catch { /* best-effort */ }
136
+ } catch { /* best-effort — if the bootstrap probe fails, fall through and dispatch */ }
125
137
  }
126
- return agentResult;
138
+ return ctx.deps.cliManager.handleCliCommand('agent_command', args);
127
139
  },
128
140
 
129
141
  // ─── Logs ───
@@ -51,58 +51,90 @@ export const fastForwardHandlers: Record<string, MedFamilyHandler> = {
51
51
  },
52
52
 
53
53
  fast_forward_mesh_node: async (ctx: MedFamilyContext, args: any) => {
54
- const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
55
- const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
56
- let workspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
57
- let submoduleIgnorePaths = Array.isArray(args?.submoduleIgnorePaths)
58
- ? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
59
- : undefined;
60
- let nodeDaemonId: string | undefined;
61
- let allowAutoPublishSubmoduleMainCommits = false;
62
- if (meshId && nodeId) {
63
- // preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
64
- const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
65
- const mesh = meshRecord?.mesh;
66
- const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
67
- if (!workspace) {
68
- workspace = typeof node?.workspace === 'string' ? node.workspace.trim() : '';
54
+ // The whole handler is wrapped: the safety-gate evaluation (mesh resolution,
55
+ // git status/stash/submodule fan-out) can throw on some platforms notably a
56
+ // win32 daemon whose git invocation parsing or slow submodule probe raises
57
+ // before fastForwardMeshNode's own guarded body runs. router.execute re-throws,
58
+ // so an escaping throw surfaces to the coordinator as an opaque
59
+ // "Daemon IPC command failed" instead of the structured blockingReasons result
60
+ // a mac node returns cleanly. Catch it and return the same blocked shape so the
61
+ // coordinator can read the reason and route, never an IPC crash. Mirrors the
62
+ // mesh_init handler's try-catch contract.
63
+ const workspaceForError = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
64
+ const meshIdForError = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
65
+ const nodeIdForError = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
66
+ try {
67
+ const meshId = meshIdForError;
68
+ const nodeId = nodeIdForError;
69
+ let workspace = workspaceForError;
70
+ let submoduleIgnorePaths = Array.isArray(args?.submoduleIgnorePaths)
71
+ ? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
72
+ : undefined;
73
+ let nodeDaemonId: string | undefined;
74
+ let allowAutoPublishSubmoduleMainCommits = false;
75
+ if (meshId && nodeId) {
76
+ // preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
77
+ const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
78
+ const mesh = meshRecord?.mesh;
79
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
80
+ if (!workspace) {
81
+ workspace = typeof node?.workspace === 'string' ? node.workspace.trim() : '';
82
+ }
83
+ if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
84
+ submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
85
+ }
86
+ allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
87
+ nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
69
88
  }
70
- if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
71
- submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
89
+ // If the target node belongs to a remote daemon, forward the command there.
90
+ // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) when the stored
91
+ // daemonId uses a legacy format that doesn't match the receiving daemon's identity.
92
+ const selfDaemonId = ctx.deps.statusInstanceId;
93
+ // daemonIdsEquivalent: a legacy-form stored daemonId that resolves to THIS
94
+ // machine's core must be treated as local (not remote) so it is not forwarded /
95
+ // P2P self-dialed. Equivalent → local.
96
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
97
+ if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
98
+ const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'fast_forward_mesh_node', {
99
+ ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
100
+ workspace,
101
+ _meshDirectDispatch: true,
102
+ });
103
+ return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
72
104
  }
73
- allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
74
- nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
75
- }
76
- // If the target node belongs to a remote daemon, forward the command there.
77
- // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) when the stored
78
- // daemonId uses a legacy format that doesn't match the receiving daemon's identity.
79
- const selfDaemonId = ctx.deps.statusInstanceId;
80
- // daemonIdsEquivalent: a legacy-form stored daemonId that resolves to THIS
81
- // machine's core must be treated as local (not remote) so it is not forwarded /
82
- // P2P self-dialed. Equivalent → local.
83
- const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
84
- if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
85
- const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'fast_forward_mesh_node', {
86
- ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
105
+ const result = await (fastForwardMeshNode({
106
+ meshId: meshId || undefined,
107
+ nodeId: nodeId || undefined,
87
108
  workspace,
88
- _meshDirectDispatch: true,
89
- });
90
- return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
109
+ branch: typeof args?.branch === 'string' ? args.branch : undefined,
110
+ execute: args?.execute === true,
111
+ dryRun: args?.dryRun === true,
112
+ updateSubmodules: args?.updateSubmodules === true,
113
+ submoduleIgnorePaths,
114
+ mode: args?.mode === 'push' ? 'push' : 'merge',
115
+ pushSubmodules: args?.pushSubmodules === true,
116
+ allowAutoPublishSubmoduleMainCommits,
117
+ }) as Promise<unknown>);
118
+ return result as CommandRouterResult;
119
+ } catch (e: any) {
120
+ const errorMessage = e?.message || String(e);
121
+ return {
122
+ success: false,
123
+ code: 'fast_forward_safety_gate_error',
124
+ ...(meshIdForError ? { meshId: meshIdForError } : {}),
125
+ ...(nodeIdForError ? { nodeId: nodeIdForError } : {}),
126
+ workspace: workspaceForError,
127
+ mode: args?.mode === 'push' ? 'push' : 'merge',
128
+ allowed: false,
129
+ willRun: false,
130
+ executed: false,
131
+ // Surface the throw as a blocking reason instead of an opaque IPC crash
132
+ // so the coordinator gets the same structured shape a clean node returns.
133
+ blockingReasons: ['fast_forward_safety_gate_error'],
134
+ operationError: errorMessage,
135
+ error: errorMessage,
136
+ } as CommandRouterResult;
91
137
  }
92
- const result = await (fastForwardMeshNode({
93
- meshId: meshId || undefined,
94
- nodeId: nodeId || undefined,
95
- workspace,
96
- branch: typeof args?.branch === 'string' ? args.branch : undefined,
97
- execute: args?.execute === true,
98
- dryRun: args?.dryRun === true,
99
- updateSubmodules: args?.updateSubmodules === true,
100
- submoduleIgnorePaths,
101
- mode: args?.mode === 'push' ? 'push' : 'merge',
102
- pushSubmodules: args?.pushSubmodules === true,
103
- allowAutoPublishSubmoduleMainCommits,
104
- }) as Promise<unknown>);
105
- return result as CommandRouterResult;
106
138
  },
107
139
 
108
140
  refine_mesh_node: async (ctx: MedFamilyContext, args: any) => {
@@ -33,6 +33,49 @@ import type {
33
33
  } from '../repo-mesh-types.js';
34
34
  import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
35
35
 
36
+ /**
37
+ * Cheap, locally-derived "what just happened" snapshot for the coordinator
38
+ * prompt. Built at launch from the local ledger + work-queue stats — no remote
39
+ * peer probe. Surfaces the gap a fresh coordinator otherwise misses: it can't
40
+ * see recent failures / queue depth until it manually calls mesh_task_history.
41
+ *
42
+ * All fields are optional so callers that have nothing to report (or fail to
43
+ * read the ledger) simply omit the section — the prompt output stays identical
44
+ * to the pre-activity form in that case.
45
+ */
46
+ export interface CoordinatorRecentActivity {
47
+ /** task_failed entries from the recent window, newest last. */
48
+ recentFailures?: Array<{
49
+ timestamp?: string;
50
+ nodeId?: string;
51
+ /** Short task title/message, already truncated by the caller. */
52
+ summary?: string;
53
+ }>;
54
+ /** Count of task_failed entries inside the recent (30-min) window. */
55
+ recentFailureCount?: number;
56
+ /** Pending (unclaimed) tasks in the work queue. */
57
+ pendingTasks?: number;
58
+ /** Assigned-but-not-yet-terminal tasks in the work queue. */
59
+ assignedTasks?: number;
60
+ /** Stalled tasks recorded in the ledger. */
61
+ stalledTasks?: number;
62
+ /** ISO timestamp of the most recent ledger activity, if any. */
63
+ lastActivityAt?: string | null;
64
+ }
65
+
66
+ /**
67
+ * One coordinator operating note — a runtime-accumulated lesson (provider
68
+ * quirk, pattern to avoid, recovery lesson) persisted in the ledger so it
69
+ * survives coordinator restarts and is provider-neutral (visible to codex /
70
+ * hermes / antigravity coordinators, not just Claude's memory).
71
+ */
72
+ export interface CoordinatorOperatingNote {
73
+ text: string;
74
+ category?: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson';
75
+ createdAt?: string;
76
+ sourceCoordinator?: string;
77
+ }
78
+
36
79
  // ─── Prompt Builder ─────────────────────────────
37
80
 
38
81
  export interface CoordinatorPromptContext {
@@ -46,6 +89,18 @@ export interface CoordinatorPromptContext {
46
89
  * stays identical to the pre-M3 form in that case.
47
90
  */
48
91
  missionSection?: string;
92
+ /**
93
+ * Gap1: recent ledger/queue activity surfaced so a freshly-launched
94
+ * coordinator sees recent failures + queue depth without first calling
95
+ * mesh_task_history. Omitted → no "## Recent Activity" section.
96
+ */
97
+ recentActivity?: CoordinatorRecentActivity;
98
+ /**
99
+ * Gap2-A: runtime-accumulated operating notes (provider-neutral lessons)
100
+ * read from the ledger at launch. Omitted/empty → no "## Operating Notes"
101
+ * section.
102
+ */
103
+ operatingNotes?: CoordinatorOperatingNote[];
49
104
  }
50
105
 
51
106
  /**
@@ -132,6 +187,14 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
132
187
  sections.push(ctx.missionSection.trim());
133
188
  }
134
189
 
190
+ // ── Recent Activity (Gap1) — only present when there's something to show ──
191
+ const recentActivity = buildRecentActivitySection(ctx.recentActivity);
192
+ if (recentActivity) sections.push(recentActivity);
193
+
194
+ // ── Operating Notes (Gap2-A) — only present when notes exist ──
195
+ const operatingNotes = buildOperatingNotesSection(ctx.operatingNotes);
196
+ if (operatingNotes) sections.push(operatingNotes);
197
+
135
198
  // ── Policy ──
136
199
  sections.push(buildPolicySection({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }));
137
200
 
@@ -187,6 +250,8 @@ function readUserPromptFile(cliType: string | undefined, suffix: string): string
187
250
  * {{cliType}} — coordinator CLI type or empty
188
251
  * {{nodes}} — full node section (status if known, otherwise config)
189
252
  * {{mission}} — active mission summary section (empty when none)
253
+ * {{recentActivity}} — recent failures + queue depth section (empty when none)
254
+ * {{operatingNotes}} — accumulated operating notes section (empty when none)
190
255
  * {{policy}} — full policy section
191
256
  * {{tools}} — the canonical tools table
192
257
  * {{workflow}} — the canonical orchestration workflow
@@ -211,6 +276,8 @@ function expandPromptPlaceholders(template: string, ctx: CoordinatorPromptContex
211
276
  cliType: coordinatorCliType || '',
212
277
  nodes: nodesSection,
213
278
  mission: ctx.missionSection?.trim() || '',
279
+ recentActivity: buildRecentActivitySection(ctx.recentActivity) || '',
280
+ operatingNotes: buildOperatingNotesSection(ctx.operatingNotes) || '',
214
281
  policy: buildPolicySection({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }),
215
282
  tools: TOOLS_SECTION,
216
283
  workflow: WORKFLOW_SECTION,
@@ -303,6 +370,83 @@ function indentFollowing(text: string, pad: string): string {
303
370
  return [lines[0], ...lines.slice(1).map(l => pad + l)].join('\n');
304
371
  }
305
372
 
373
+ /**
374
+ * Gap1 — render the "## Recent Activity" section from the locally-derived
375
+ * activity snapshot. Returns '' (no section) when there's nothing worth
376
+ * surfacing: no recent failures, no queued work, no stalls. This keeps a quiet
377
+ * mesh's prompt identical to the pre-activity form.
378
+ */
379
+ function buildRecentActivitySection(activity?: CoordinatorRecentActivity): string {
380
+ if (!activity) return '';
381
+ const failures = Array.isArray(activity.recentFailures) ? activity.recentFailures : [];
382
+ const pending = Number.isFinite(activity.pendingTasks) ? Number(activity.pendingTasks) : 0;
383
+ const assigned = Number.isFinite(activity.assignedTasks) ? Number(activity.assignedTasks) : 0;
384
+ const stalled = Number.isFinite(activity.stalledTasks) ? Number(activity.stalledTasks) : 0;
385
+ const recentFailureCount = Number.isFinite(activity.recentFailureCount)
386
+ ? Number(activity.recentFailureCount)
387
+ : failures.length;
388
+
389
+ // Nothing actionable to show → omit the section entirely.
390
+ if (failures.length === 0 && pending === 0 && assigned === 0 && stalled === 0 && recentFailureCount === 0) {
391
+ return '';
392
+ }
393
+
394
+ const lines: string[] = ['## Recent Activity', ''];
395
+ lines.push('A snapshot of this mesh\'s recent ledger/queue state at launch. Use it to decide what needs attention first; call `mesh_task_history` / `mesh_view_queue` for full detail.');
396
+ lines.push('');
397
+
398
+ const counts: string[] = [];
399
+ if (pending > 0) counts.push(`**${pending}** pending`);
400
+ if (assigned > 0) counts.push(`**${assigned}** assigned`);
401
+ if (stalled > 0) counts.push(`**${stalled}** stalled`);
402
+ if (recentFailureCount > 0) counts.push(`**${recentFailureCount}** failed in the last 30 min`);
403
+ if (counts.length) lines.push(`- Queue/ledger: ${counts.join(', ')}.`);
404
+ if (activity.lastActivityAt) lines.push(`- Last ledger activity: ${activity.lastActivityAt}.`);
405
+
406
+ if (failures.length > 0) {
407
+ // Newest first, capped to the 5 most recent so the prompt stays lean.
408
+ const recent = failures.slice(-5).reverse();
409
+ lines.push('', 'Recent failures (newest first):');
410
+ for (const f of recent) {
411
+ const when = f.timestamp ? `${f.timestamp} ` : '';
412
+ const node = f.nodeId ? `node \`${f.nodeId}\`` : 'unknown node';
413
+ const summary = (f.summary || '').trim();
414
+ lines.push(`- ${when}${node}${summary ? ` — ${summary}` : ''}`);
415
+ }
416
+ lines.push('', '_Check `mesh_task_history` before retrying; repeated failures on the same node mean reassign or escalate, not retry._');
417
+ }
418
+
419
+ return lines.join('\n');
420
+ }
421
+
422
+ /**
423
+ * Gap2-A — render the "## Operating Notes" section from accumulated coordinator
424
+ * notes. Returns '' when there are none, so a mesh that has never recorded a
425
+ * note gets the unchanged prompt. Notes are runtime-accumulated lessons that
426
+ * persist across coordinator restarts and are provider-neutral.
427
+ */
428
+ function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[]): string {
429
+ const valid = Array.isArray(notes)
430
+ ? notes.filter(n => n && typeof n.text === 'string' && n.text.trim())
431
+ : [];
432
+ if (valid.length === 0) return '';
433
+
434
+ const categoryLabel: Record<string, string> = {
435
+ provider_quirk: 'provider quirk',
436
+ pattern_to_avoid: 'pattern to avoid',
437
+ recovery_lesson: 'recovery lesson',
438
+ };
439
+
440
+ const lines: string[] = ['## Operating Notes', ''];
441
+ lines.push('Lessons earlier coordinators on this mesh recorded via `mesh_record_note`. Treat them as accumulated operating knowledge — apply them. When you learn a durable lesson (a provider quirk, a pattern to avoid, a recovery lesson), record it with `mesh_record_note` so future coordinators inherit it.');
442
+ lines.push('');
443
+ for (const n of valid) {
444
+ const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
445
+ lines.push(`- ${cat}${n.text.trim()}`);
446
+ }
447
+ return lines.join('\n');
448
+ }
449
+
306
450
  function buildPolicySection(policy: RepoMeshPolicy): string {
307
451
  const rules: string[] = [];
308
452
  if (policy.requirePreTaskCheckpoint) rules.push('- Create a git checkpoint **before** starting each task');
@@ -340,6 +484,7 @@ const TOOLS_SECTION = `## Available Tools
340
484
  | \`mesh_read_chat\` | Read recent chat messages from a delegated agent session |
341
485
  | \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
342
486
  | \`mesh_task_history\` | Read the task ledger — dispatches, completions, failures. Use to understand what has been done before deciding next steps |
487
+ | \`mesh_record_note\` | Record a durable, provider-neutral operating note (provider quirk / pattern to avoid / recovery lesson). Future coordinators see it under "## Operating Notes" at launch |
343
488
  | \`mesh_git_status\` | Check git status on a specific node |
344
489
  | \`mesh_read_node_logs\` | Fetch a remote node's daemon log tail directly over P2P (grep/since/byte-bounded, secrets redacted) — no session/PowerShell needed to debug a node's daemon |
345
490
  | \`mesh_fast_forward_node\` | Safely dry-run or explicitly execute an obvious clean fast-forward without launching an agent session |