@adhdev/daemon-core 0.9.82-rc.253 → 0.9.82-rc.255

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.
@@ -56,7 +56,6 @@ export declare class CliProviderInstance implements ProviderInstance {
56
56
  private runtimeMessages;
57
57
  private lastPersistedHistoryMessages;
58
58
  private lastAcknowledgedUserInputAt;
59
- private externalBusyIdleFingerprint;
60
59
  private lastNativeSourceCanonicalCheckAt;
61
60
  private lastNativeSourceCanonicalCacheKey;
62
61
  private cachedSqliteDb;
@@ -133,8 +132,6 @@ export declare class CliProviderInstance implements ProviderInstance {
133
132
  private readExternalCompletionMessages;
134
133
  private completionFinalAssistantEvidence;
135
134
  private completionFinalSummary;
136
- private externalNativeFinalFingerprint;
137
- private getExternalNativeFinalReconciliation;
138
135
  private buildCompletedFinalizationDiagnostic;
139
136
  private hasAdapterPendingResponse;
140
137
  private shouldSuppressStaleParsedBusyStatus;
@@ -10,6 +10,7 @@ export type DashboardEvent = {
10
10
  id: string;
11
11
  label: string;
12
12
  title: string | null;
13
+ status: 'idle' | 'generating' | 'approval';
13
14
  };
14
15
  modal: {
15
16
  title: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.253",
3
+ "version": "0.9.82-rc.255",
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",
@@ -627,10 +627,12 @@ function reconcileInlineMeshCache(cached: any, incoming: any): any {
627
627
  if (nodeId) cachedById.set(nodeId, node);
628
628
  }
629
629
 
630
+ const mergedIncomingIds = new Set<string>();
630
631
  const nodes = incomingNodes.map((incomingNode: any) => {
631
632
  const nodeId = readInlineMeshNodeId(incomingNode);
632
633
  const cachedNode = nodeId ? cachedById.get(nodeId) : undefined;
633
634
  if (!cachedNode && preserveCachedMembership) return null;
635
+ if (nodeId) mergedIncomingIds.add(nodeId);
634
636
  if (!cachedNode) return incomingNode;
635
637
  if (hasInlineMeshTransientNodeState(incomingNode)) {
636
638
  return { ...cachedNode, ...incomingNode };
@@ -638,6 +640,21 @@ function reconcileInlineMeshCache(cached: any, incoming: any): any {
638
640
  return { ...stripInlineMeshTransientNodeState(cachedNode), ...incomingNode };
639
641
  }).filter(Boolean);
640
642
 
643
+ // When the cached membership is authoritative (newer than the incoming
644
+ // snapshot), nodes that exist only in the cache must survive reconciliation.
645
+ // A freshly cloned worktree node lives only in the coordinator's cache until
646
+ // the next snapshot catches up; iterating incomingNodes alone would silently
647
+ // drop it, making the node invisible to get_mesh / membership reads even
648
+ // though worktree_bootstrap_complete already fired.
649
+ if (preserveCachedMembership) {
650
+ for (const cachedNode of cachedNodes) {
651
+ const nodeId = readInlineMeshNodeId(cachedNode);
652
+ if (nodeId && !mergedIncomingIds.has(nodeId)) {
653
+ nodes.push(cachedNode);
654
+ }
655
+ }
656
+ }
657
+
641
658
  return {
642
659
  ...cached,
643
660
  ...incoming,
@@ -4292,6 +4309,27 @@ export class DaemonCommandRouter {
4292
4309
  }
4293
4310
 
4294
4311
  case 'launch_cli': {
4312
+ // Worker launch envelope hardening: a mesh worker session must carry
4313
+ // meshCoordinatorDaemonId so completion events route back to the
4314
+ // coordinator (the cloud relay and the daemon-core forwarder both key on
4315
+ // it). mesh_launch_session resolves coordinatorNode.daemonId || ctx.localDaemonId
4316
+ // upstream, but for a freshly-cloned local worktree both can be empty. When the
4317
+ // launch is a coordinator-driven worker on this machine and the id is missing,
4318
+ // stamp this daemon's own id — worker and coordinator are co-located here.
4319
+ {
4320
+ const launchSettings = (args?.settings && typeof args.settings === 'object')
4321
+ ? args.settings as Record<string, unknown>
4322
+ : undefined;
4323
+ const isMeshWorkerLaunch = !!launchSettings
4324
+ && (readStringValue(launchSettings.meshNodeFor) || launchSettings.launchedByCoordinator === true);
4325
+ const hasCoordinatorDaemonId = !!launchSettings && !!readStringValue(launchSettings.meshCoordinatorDaemonId);
4326
+ if (launchSettings && isMeshWorkerLaunch && !hasCoordinatorDaemonId) {
4327
+ try {
4328
+ const localDaemonId = readStringValue(loadConfig().machineId);
4329
+ if (localDaemonId) launchSettings.meshCoordinatorDaemonId = localDaemonId;
4330
+ } catch { /* best-effort — launch proceeds without the stamp */ }
4331
+ }
4332
+ }
4295
4333
  const launchResult = await this.deps.cliManager.handleCliCommand(cmd, args);
4296
4334
  // Bug C fix (part 1): when launching a mesh node worker session, surface
4297
4335
  // bootstrapPending:true if the node's worktree bootstrap is still running.
@@ -6005,7 +6043,16 @@ export class DaemonCommandRouter {
6005
6043
  if (ownerFailure) return ownerFailure;
6006
6044
 
6007
6045
  try {
6008
- const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh);
6046
+ // Resolve with preferInline so the clone writes the new node into the
6047
+ // same representation that get_mesh reads back. The MCP coordinator
6048
+ // passes inlineMesh on every mesh command, so when it owns an inline
6049
+ // mesh the membership read path (get_mesh, preferInline: true) returns
6050
+ // the inline cache. Without preferInline here, clone could resolve to a
6051
+ // local-config mesh and write the node only to config — leaving the
6052
+ // inline cache (and therefore get_mesh / refreshMeshFromDaemon) without
6053
+ // the node, so the new worktree node is never visible in live mesh
6054
+ // membership even though worktree_bootstrap_complete fires.
6055
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
6009
6056
  const mesh = meshRecord?.mesh;
6010
6057
  if (!mesh) return { success: false, error: 'Mesh not found' };
6011
6058
 
@@ -6064,6 +6111,13 @@ export class DaemonCommandRouter {
6064
6111
  policy: { ...(sourceNode.policy || {}) },
6065
6112
  });
6066
6113
  if (!node) return { success: false, error: 'Failed to register worktree node' };
6114
+ // Also reconcile the freshly-registered node into any warmed inline
6115
+ // cache for this mesh. get_mesh (preferInline: true) reads the inline
6116
+ // cache first when one exists; if we only wrote to local config the
6117
+ // node would be invisible to membership reads. updateInlineMeshNode is
6118
+ // a no-op when no inline cache is present.
6119
+ const inlineForReconcile = this.getCachedInlineMesh(meshId);
6120
+ if (inlineForReconcile) this.updateInlineMeshNode(meshId, inlineForReconcile, node);
6067
6121
  this.invalidateAggregateMeshStatus(meshId);
6068
6122
  }
6069
6123
 
@@ -438,7 +438,21 @@ function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
438
438
  }
439
439
 
440
440
  function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
441
- return getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId);
441
+ if (getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId)) {
442
+ return true;
443
+ }
444
+ // Direct dispatches (mesh_send_task) are tracked in mesh_direct_dispatches, not the
445
+ // work queue. A session completing a still-active direct dispatch IS an active
446
+ // assignment — without this, findRecentTerminalLedgerEvidence dedup wrongly suppresses
447
+ // the canonical agent:generating_completed for direct-dispatch tasks (validation/general),
448
+ // so the coordinator polling get_pending_mesh_events never observes task_completed and the
449
+ // session goes silently idle. This check runs before markSessionTerminal marks the
450
+ // dispatch terminal, so the in-flight dispatch is still observable here.
451
+ try {
452
+ if (getActiveDirectDispatches(meshId).some(d => d.sessionId === sessionId)) return true;
453
+ if (hasUnterminalDirectDispatchLedgerEntry(meshId, sessionId)) return true;
454
+ } catch { /* best-effort — fall through to false */ }
455
+ return false;
442
456
  }
443
457
 
444
458
  function liveSessionCountForNode(components: DaemonComponents, meshId: string, nodeId: string): number {
@@ -927,6 +941,27 @@ export function isMeshCoordinatorEvent(eventName: unknown): eventName is string
927
941
  return typeof eventName === 'string' && MESH_COORDINATOR_EVENTS.has(eventName);
928
942
  }
929
943
 
944
+ // Terminal events that the coordinator is actively blocked waiting on. When the
945
+ // coordinator CLI session dispatches a task (e.g. mesh_send_task) it stays in
946
+ // `generating` until the result arrives — but a generating coordinator queues
947
+ // incoming send_message calls into its adapter's pendingOutboundQueue, which is
948
+ // only flushed on the coordinator's OWN idle transition. That transition can't
949
+ // happen until it receives this very event → deadlock. We force-inject these so
950
+ // they bypass the busy send-guard and land in the PTY while generating.
951
+ const MESH_FORCE_INJECT_EVENTS = new Set([
952
+ 'agent:generating_completed',
953
+ 'agent:stopped',
954
+ 'agent:waiting_approval',
955
+ 'refine:completed',
956
+ 'refine:failed',
957
+ 'worktree_bootstrap_complete',
958
+ 'worktree_bootstrap_failed',
959
+ ]);
960
+
961
+ function shouldForceInjectMeshEvent(eventName: unknown): boolean {
962
+ return typeof eventName === 'string' && MESH_FORCE_INJECT_EVENTS.has(eventName);
963
+ }
964
+
930
965
  function injectMeshSystemMessage(components: DaemonComponents, args: {
931
966
  meshId: string;
932
967
  sourceInstanceId?: string;
@@ -1362,10 +1397,14 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1362
1397
  LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
1363
1398
  }
1364
1399
 
1400
+ const forceInject = shouldForceInjectMeshEvent(args.event);
1365
1401
  for (const coord of coordinatorInstances) {
1366
1402
  const coordState = coord.getState();
1367
- LOG.info('MeshEvents', `Forwarding mesh event to coordinator ${coordState.instanceId}`);
1368
- coord.onEvent('send_message', { input: { text: messageText, textFallback: messageText } });
1403
+ LOG.info('MeshEvents', `Forwarding mesh event to coordinator ${coordState.instanceId}${forceInject ? ' (force)' : ''}`);
1404
+ coord.onEvent('send_message', {
1405
+ input: { text: messageText, textFallback: messageText },
1406
+ ...(forceInject ? { force: true } : {}),
1407
+ });
1369
1408
  }
1370
1409
  return { success: true, forwarded: coordinatorInstances.length };
1371
1410
  }
@@ -1424,6 +1463,58 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
1424
1463
 
1425
1464
  export function setupMeshEventForwarding(components: DaemonComponents) {
1426
1465
  components.instanceManager.onEvent((event) => {
1466
+ // --- Coordinator idle auto-flush ---
1467
+ // When a coordinator session becomes idle, flush any pending coordinator events
1468
+ // that accumulated while it was generating. This runs before the delegate routing
1469
+ // below so that coordinator-own idle transitions are handled first.
1470
+ // Exception: a coordinator that is itself a direct-dispatch target still needs
1471
+ // to go through delegate routing so that the dispatching coordinator receives a
1472
+ // pendingCoordinatorEvents entry for the completion.
1473
+ if (event.event === 'agent:ready' || event.event === 'agent:generating_completed') {
1474
+ const flushInstanceId = readNonEmptyString(event.instanceId);
1475
+ if (flushInstanceId) {
1476
+ const flushSource = components.instanceManager.getInstance(flushInstanceId);
1477
+ if (flushSource && flushSource.category === 'cli') {
1478
+ const flushState = flushSource.getState();
1479
+ const flushSettings = flushState.settings && typeof flushState.settings === 'object' ? flushState.settings as Record<string, unknown> : {};
1480
+ const coordinatorMeshId = readNonEmptyString(flushSettings.meshCoordinatorFor);
1481
+ if (coordinatorMeshId) {
1482
+ const status = readNonEmptyString(flushState.status).toLowerCase();
1483
+ if (status === 'idle') {
1484
+ try {
1485
+ const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
1486
+ const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, localDaemonId);
1487
+ if (pendingEvents.length > 0) {
1488
+ LOG.info('MeshEvents', `Auto-flushing ${pendingEvents.length} pending coordinator event(s) for mesh ${coordinatorMeshId} on coordinator idle`);
1489
+ for (const pending of pendingEvents) {
1490
+ if (!pending.coordinatorMessage) continue;
1491
+ const forcePending = shouldForceInjectMeshEvent(pending.event);
1492
+ flushSource.onEvent('send_message', {
1493
+ input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
1494
+ ...(forcePending ? { force: true } : {}),
1495
+ });
1496
+ }
1497
+ }
1498
+ } catch (e: any) {
1499
+ LOG.warn('MeshEvents', `Failed to auto-flush pending coordinator events: ${e?.message || e}`);
1500
+ }
1501
+ }
1502
+ // Skip delegate routing unless this coordinator session is itself
1503
+ // a direct-dispatch target — in that case fall through so the
1504
+ // dispatching coordinator gets a pendingCoordinatorEvents entry.
1505
+ let hasDirectDispatch = false;
1506
+ try {
1507
+ hasDirectDispatch =
1508
+ getActiveDirectDispatches(coordinatorMeshId).some(d => d.sessionId === flushInstanceId)
1509
+ || hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, flushInstanceId);
1510
+ } catch { /* best-effort */ }
1511
+ if (!hasDirectDispatch) return;
1512
+ }
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ // --- Delegate event routing ---
1427
1518
  if (!isMeshCoordinatorEvent(event.event)) return;
1428
1519
 
1429
1520
  const instanceId = readNonEmptyString(event.instanceId);
@@ -1475,36 +1566,4 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1475
1566
  metadataEvent: event,
1476
1567
  });
1477
1568
  });
1478
-
1479
- // Auto-flush pending coordinator events when a coordinator session becomes idle.
1480
- components.instanceManager.onEvent((event) => {
1481
- if (event.event !== 'agent:ready' && event.event !== 'agent:generating_completed') return;
1482
-
1483
- const instanceId = readNonEmptyString(event.instanceId);
1484
- if (!instanceId) return;
1485
-
1486
- const sourceInstance = components.instanceManager.getInstance(instanceId);
1487
- if (!sourceInstance || sourceInstance.category !== 'cli') return;
1488
- const state = sourceInstance.getState();
1489
- const settings = state.settings && typeof state.settings === 'object' ? state.settings as Record<string, unknown> : {};
1490
-
1491
- const coordinatorMeshId = readNonEmptyString(settings.meshCoordinatorFor);
1492
- if (!coordinatorMeshId) return;
1493
-
1494
- const status = readNonEmptyString(state.status).toLowerCase();
1495
- if (status !== 'idle') return;
1496
-
1497
- try {
1498
- const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
1499
- const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, localDaemonId);
1500
- if (pendingEvents.length === 0) return;
1501
- LOG.info('MeshEvents', `Auto-flushing ${pendingEvents.length} pending coordinator event(s) for mesh ${coordinatorMeshId} on coordinator idle`);
1502
- for (const pending of pendingEvents) {
1503
- if (!pending.coordinatorMessage) continue;
1504
- sourceInstance.onEvent('send_message', { input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage } });
1505
- }
1506
- } catch (e: any) {
1507
- LOG.warn('MeshEvents', `Failed to auto-flush pending coordinator events: ${e?.message || e}`);
1508
- }
1509
- });
1510
1569
  }
@@ -185,12 +185,20 @@ function refineTerminalEventFromLedger(meshId: string, pending: readonly Pending
185
185
 
186
186
  function reconcilePendingMeshCoordinatorEvents(meshId: string, events: PendingMeshCoordinatorEvent[]): PendingMeshCoordinatorEvent[] {
187
187
  const backfilled = refineTerminalEventFromLedger(meshId, events);
188
- if (backfilled.length === 0) return events;
189
- const terminalJobIds = new Set(backfilled.map(event => readRefineJobId(event)).filter(Boolean));
190
- return [
191
- ...events.filter(event => !(event.event === 'refine:accepted' && terminalJobIds.has(readRefineJobId(event)))),
192
- ...backfilled,
193
- ];
188
+ // A refine:accepted event is a provisional "job accepted, result to follow" signal.
189
+ // Once its terminal (completed/failed) counterpart for the same jobId exists — whether
190
+ // already direct-queued into the pending store OR backfilled from the ledger here — the
191
+ // accepted is superseded and is dropped so the coordinator isn't shown stale duplicate
192
+ // noise alongside the terminal outcome.
193
+ const terminalJobIds = new Set(
194
+ [...events.filter(event => REFINE_TERMINAL_EVENTS.has(event.event)), ...backfilled]
195
+ .map(event => readRefineJobId(event))
196
+ .filter(Boolean),
197
+ );
198
+ const reconciled = terminalJobIds.size === 0
199
+ ? events
200
+ : events.filter(event => !(event.event === 'refine:accepted' && terminalJobIds.has(readRefineJobId(event))));
201
+ return backfilled.length === 0 ? reconciled : [...reconciled, ...backfilled];
194
202
  }
195
203
 
196
204
  const MAX_PENDING_EVENTS_BYTES = 100 * 1024; // 100 KB — keep the pending file small
@@ -22,7 +22,6 @@ export interface MeshTaskModeValidationResult {
22
22
 
23
23
  const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> = [
24
24
  { label: 'source_edit', pattern: /\b(edit|modify|patch|apply\s+patch|write\s+(?:to\s+)?(?:file|source)|overwrite|delete\s+file|remove\s+file|create\s+file|touch\s+file)\b/i },
25
- { label: 'git_mutation', pattern: /\b(?:git\s+(?:add|commit|push|reset|rebase|clean|checkout|switch|merge|tag|restore|rm|mv|stash|worktree\s+(?:add|remove|move))|push\b)/i },
26
25
  { label: 'checkpoint', pattern: /\b(checkpoint|mesh_checkpoint)\b/i },
27
26
  { label: 'deploy_or_version_bump', pattern: /\b(deploy|wrangler\s+deploy|version[-\s]?bump|npm\s+version|release|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i },
28
27
  { label: 'destructive_shell', pattern: /\b(rm\s+-rf|mv\s+\S+\s+\S+|truncate\s|tee\s+\S+|sed\s+-i|shred\b)\b/i },
@@ -30,6 +29,61 @@ const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> =
30
29
  { label: 'container_mutation', pattern: /\b(docker\s+(?:build|run|exec|push|tag|rmi|rm|create|start|stop|kill)|kubectl\s+(?:apply|delete|patch|replace|create|scale))\b/i },
31
30
  ];
32
31
 
32
+ /**
33
+ * Git subcommands that mutate the working tree, index, refs, or remote.
34
+ * `stash` and `checkout` are intentionally absent here: they have read-only
35
+ * variants (`git stash list`/`show`, `git checkout-index`) and are classified
36
+ * token-by-token in {@link detectGitMutation} rather than by bare keyword.
37
+ */
38
+ const GIT_MUTATION_SUBCOMMANDS = new Set([
39
+ 'add', 'commit', 'push', 'reset', 'rebase', 'clean', 'switch', 'merge',
40
+ 'tag', 'restore', 'rm', 'mv', 'cherry-pick', 'revert', 'pull', 'fetch',
41
+ 'am', 'apply', 'gc', 'prune',
42
+ ]);
43
+
44
+ /**
45
+ * Read-only `git stash` variants. Any other `git stash <x>` (pop/apply/drop/
46
+ * push/save/clear, or bare `git stash` which defaults to push) is a mutation.
47
+ */
48
+ const GIT_STASH_READONLY_SUBCOMMANDS = new Set(['list', 'show']);
49
+
50
+ /**
51
+ * Detects a true git mutation in free-text task message, token-aware so that
52
+ * read-only diagnostics (`git stash list`, `git stash show --stat`,
53
+ * `git checkout-index`, `git status`, `git diff`, `git log`, ...) are allowed.
54
+ * Returns true only when a genuine mutating git invocation is present.
55
+ */
56
+ function detectGitMutation(message: string): boolean {
57
+ const re = /\bgit\s+([a-z][a-z0-9-]*)/gi;
58
+ let match: RegExpExecArray | null;
59
+ while ((match = re.exec(message)) !== null) {
60
+ const sub = match[1].toLowerCase();
61
+ if (GIT_MUTATION_SUBCOMMANDS.has(sub)) return true;
62
+ if (sub === 'stash') {
63
+ // Token following `git stash`; read-only only for list/show.
64
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
65
+ const next = after ? after[1].toLowerCase() : '';
66
+ if (!GIT_STASH_READONLY_SUBCOMMANDS.has(next)) return true; // bare stash = push, or pop/apply/drop/...
67
+ } else if (sub === 'checkout') {
68
+ // `git checkout <ref/path>` mutates; `git checkout-index` is matched
69
+ // as its own token by the regex (sub === 'checkout-index') and is read-only.
70
+ return true;
71
+ } else if (sub === 'submodule') {
72
+ // `git submodule update` mutates; `git submodule status` is read-only.
73
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
74
+ const next = after ? after[1].toLowerCase() : '';
75
+ if (next === 'update' || next === 'add' || next === 'sync' || next === 'deinit') return true;
76
+ } else if (sub === 'worktree') {
77
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
78
+ const next = after ? after[1].toLowerCase() : '';
79
+ if (next === 'add' || next === 'remove' || next === 'move' || next === 'prune') return true;
80
+ }
81
+ // checkout-index, stash-with-no-next-already-handled, status/diff/log/show/
82
+ // rev-parse/branch/submodule status fall through as read-only.
83
+ }
84
+ return false;
85
+ }
86
+
33
87
  export function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined {
34
88
  if (typeof value !== 'string') return undefined;
35
89
  const normalized = value.trim() as MeshTaskMode;
@@ -44,9 +98,13 @@ export function validateMeshTaskModeRequest(mode: unknown, message: string): Mes
44
98
  if (taskMode !== 'live_debug_readonly') {
45
99
  return { valid: true, taskMode, violations: [] };
46
100
  }
101
+ const text = message || '';
47
102
  const violations = LIVE_DEBUG_READONLY_FORBIDDEN
48
- .filter(rule => rule.pattern.test(message || ''))
103
+ .filter(rule => rule.pattern.test(text))
49
104
  .map(rule => rule.label);
105
+ if (detectGitMutation(text)) {
106
+ violations.push('git_mutation');
107
+ }
50
108
  return {
51
109
  valid: violations.length === 0,
52
110
  taskMode,