@adhdev/daemon-core 1.0.18-rc.17 → 1.0.18-rc.18

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.
@@ -15,6 +15,7 @@ import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-mis
15
15
  import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
16
16
  import type { MagiKindPanelMap, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
17
17
  import type { ProviderModule } from './providers/contracts.js';
18
+ import type { RepoMeshDeclarativeConfig } from './config/mesh-json-config.js';
18
19
  export interface RepoMesh {
19
20
  id: string;
20
21
  name: string;
@@ -530,13 +531,34 @@ export declare function normalizeAutoFastForwardPolicy(value: unknown): NonNulla
530
531
  export declare function mergeAndNormalizePolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy;
531
532
  /**
532
533
  * Resolve delegated worker auto-approve. Legacy providers return a boolean. Providers
533
- * with modes return the default mode id, except a dangerous default is downgraded to a
534
+ * with modes return a mode id, except a dangerous mode is downgraded to a
534
535
  * non-dangerous PTY mode unless mesh/node policy explicitly opts in.
536
+ *
537
+ * THREE EXPLICIT STAGES — do not collapse them; the ordering is a hard MAGI
538
+ * invariant:
539
+ *
540
+ * ① ENABLE gate (machine-local policy only): node boolean > mesh boolean.
541
+ * `enabled=false` returns `false` IMMEDIATELY, BEFORE any mode selection.
542
+ * The repo `mesh.json` providerDefaults has ZERO influence here — a
543
+ * node/mesh opt-out is never overridden by a repo-declared requested mode.
544
+ *
545
+ * ② MODE selection (only when enabled === true):
546
+ * task override [FUTURE — param reserved below, not yet wired] >
547
+ * repo mesh.json providerDefaults.autoApproveModes[providerType] >
548
+ * provider spec autoApproveModes.default.
549
+ * A repo-requested mode ID is adopted ONLY when it exists in the provider's
550
+ * own `autoApproveModes.modes`; an unknown/stale/typo'd ID is IGNORED and we
551
+ * fall back to the provider default (fail-closed: never coerce into a
552
+ * dangerous mode via a bad ID).
553
+ *
554
+ * ③ DANGEROUS gate: whichever mode stage ② picked, if it is dangerous and the
555
+ * machine-local delegatedWorkerDangerousModeAllow is not set, downgrade to a
556
+ * non-dangerous PTY-parse mode (or `false` if none exists).
535
557
  */
536
- export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, provider?: Pick<ProviderModule, 'autoApproveModes'> | null): boolean | string;
558
+ export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, provider?: Pick<ProviderModule, 'autoApproveModes'> | null, repoConfig?: RepoMeshDeclarativeConfig | null, providerType?: string | null): boolean | string;
537
559
  export declare function resolveDelegatedWorkerDangerousModeAllow(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerDangerousModeAllow'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerDangerousModeAllow'> | null): boolean;
538
560
  /** Shape a boolean-or-mode resolution for the settings precedence contract. */
539
- export declare function delegatedWorkerAutoApproveSettings(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, provider?: Pick<ProviderModule, 'autoApproveModes'> | null): {
561
+ export declare function delegatedWorkerAutoApproveSettings(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, provider?: Pick<ProviderModule, 'autoApproveModes'> | null, repoConfig?: RepoMeshDeclarativeConfig | null, providerType?: string | null): {
540
562
  autoApprove: boolean | undefined;
541
563
  autoApproveMode: string | undefined;
542
564
  delegatedWorkerDangerousModeAllow: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.17",
3
+ "version": "1.0.18-rc.18",
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",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.18-rc.17",
51
- "@adhdev/session-host-core": "1.0.18-rc.17",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.18",
51
+ "@adhdev/session-host-core": "1.0.18-rc.18",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -399,6 +399,161 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
399
399
  }
400
400
  },
401
401
 
402
+ // READ path for `.adhdev/mesh.json` — returns the currently-committed repo
403
+ // config (parsed + normalized) for a workspace so a UI can render/edit the
404
+ // existing declarative zones (notably `providerDefaults.autoApproveModes`)
405
+ // WITHOUT re-deriving them from the machine-local scaffold. Never writes.
406
+ // `config` is undefined when no repo file exists (sourceType 'unavailable') or
407
+ // it is unparseable (sourceType 'invalid', with the parse error surfaced).
408
+ read_mesh_json_config: async (_ctx: MedFamilyContext, args: any) => {
409
+ const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
410
+ try {
411
+ const { loadRepoMeshJsonConfig } = await import('../../config/mesh-json-config.js');
412
+ const loaded = loadRepoMeshJsonConfig(workspace);
413
+ return {
414
+ success: true,
415
+ workspace,
416
+ sourceType: loaded.sourceType,
417
+ source: loaded.source,
418
+ ...(loaded.path ? { path: loaded.path } : {}),
419
+ ...(loaded.error ? { error: loaded.error } : {}),
420
+ config: loaded.config,
421
+ // Convenience projection so the UI does not have to reach into config.
422
+ providerDefaults: loaded.config?.providerDefaults,
423
+ };
424
+ } catch (e: any) {
425
+ return { success: false, error: e.message };
426
+ }
427
+ },
428
+
429
+ // Partial-edit WRITE path for `.adhdev/mesh.json` `providerDefaults` — a
430
+ // READ-MODIFY-WRITE that preserves operator hand-edits. Unlike
431
+ // write_mesh_json_config (which rebuilds the WHOLE file from the machine-local
432
+ // scaffold and can silently drop hand-edited zones), this parses the existing
433
+ // repo file, merges ONLY the providerDefaults.autoApproveModes zone, and
434
+ // re-serializes — coordinator prompt, operating notes and limits authored in
435
+ // the repo are carried through untouched. Defaults to dry-run.
436
+ //
437
+ // args: { workspace?, autoApproveModes: Record<providerType,modeId>, write?, merge? }
438
+ // merge=true (default): per-provider merge into the existing map; a modeId of
439
+ // '' | null removes that provider's entry. merge=false: REPLACE the whole
440
+ // autoApproveModes map with the supplied one.
441
+ set_mesh_provider_defaults: async (_ctx: MedFamilyContext, args: any) => {
442
+ const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
443
+ const write = args?.write === true;
444
+ const merge = args?.merge !== false; // default true
445
+ const inputModes = args?.autoApproveModes;
446
+ if (inputModes !== undefined && (typeof inputModes !== 'object' || inputModes === null || Array.isArray(inputModes))) {
447
+ return { success: false, error: 'autoApproveModes must be an object (providerType → modeId) when provided' };
448
+ }
449
+ try {
450
+ const {
451
+ loadRepoMeshJsonConfig,
452
+ normalizeRepoMeshDeclarativeConfig,
453
+ MESH_JSON_CONFIG_LOCATIONS,
454
+ } = await import('../../config/mesh-json-config.js');
455
+ const { existsSync, readFileSync, mkdirSync, writeFileSync } = await import('fs');
456
+ const { dirname, join } = await import('path');
457
+ const yaml = await import('js-yaml');
458
+
459
+ const relativePath = MESH_JSON_CONFIG_LOCATIONS[0];
460
+
461
+ // Read-modify-write: parse the EXISTING on-disk document (preferring the
462
+ // first existing json/yaml variant) so unrelated zones survive verbatim.
463
+ let baseDoc: Record<string, any> = { version: 1 };
464
+ let existingPath = join(workspace, relativePath);
465
+ let existedAsYaml = false;
466
+ for (const relative of MESH_JSON_CONFIG_LOCATIONS) {
467
+ const candidate = join(workspace, relative);
468
+ if (!existsSync(candidate)) continue;
469
+ try {
470
+ const text = readFileSync(candidate, 'utf-8');
471
+ const parsed = /\.json$/i.test(candidate) ? JSON.parse(text) : yaml.load(text);
472
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
473
+ baseDoc = parsed as Record<string, any>;
474
+ existingPath = candidate;
475
+ existedAsYaml = !/\.json$/i.test(candidate);
476
+ }
477
+ } catch (e: any) {
478
+ return { success: false, error: `existing ${relative} is unparseable, refusing to overwrite: ${e?.message || e}` };
479
+ }
480
+ break;
481
+ }
482
+
483
+ // Merge ONLY the providerDefaults.autoApproveModes zone.
484
+ const existingPd = baseDoc.providerDefaults && typeof baseDoc.providerDefaults === 'object' && !Array.isArray(baseDoc.providerDefaults)
485
+ ? baseDoc.providerDefaults as Record<string, any>
486
+ : {};
487
+ const existingModes = existingPd.autoApproveModes && typeof existingPd.autoApproveModes === 'object' && !Array.isArray(existingPd.autoApproveModes)
488
+ ? { ...existingPd.autoApproveModes as Record<string, string> }
489
+ : {};
490
+
491
+ const nextModes: Record<string, string> = merge ? existingModes : {};
492
+ if (inputModes) {
493
+ for (const [providerType, modeId] of Object.entries(inputModes as Record<string, unknown>)) {
494
+ const type = typeof providerType === 'string' ? providerType.trim() : '';
495
+ if (!type) continue;
496
+ const id = typeof modeId === 'string' ? modeId.trim() : '';
497
+ if (id) nextModes[type] = id;
498
+ else delete nextModes[type]; // '' / null → remove this provider's entry
499
+ }
500
+ }
501
+
502
+ const nextDoc: Record<string, any> = { ...baseDoc, version: 1 };
503
+ if (Object.keys(nextModes).length) {
504
+ nextDoc.providerDefaults = { ...existingPd, autoApproveModes: nextModes };
505
+ } else {
506
+ // No entries left → drop the zone entirely so we don't leave an empty stub.
507
+ if (nextDoc.providerDefaults) {
508
+ const { autoApproveModes, ...restPd } = nextDoc.providerDefaults;
509
+ if (Object.keys(restPd).length) nextDoc.providerDefaults = restPd;
510
+ else delete nextDoc.providerDefaults;
511
+ }
512
+ }
513
+
514
+ // Validate the merged document before it ever touches disk.
515
+ const validation = normalizeRepoMeshDeclarativeConfig(nextDoc);
516
+ if (!validation.valid) {
517
+ return { success: false, error: `merged mesh.json is invalid: ${validation.errors.join('; ')}` };
518
+ }
519
+
520
+ // Serialize in the on-disk format (JSON unless the existing file was YAML).
521
+ const absolutePath = existingPath;
522
+ const serialized = existedAsYaml
523
+ ? yaml.dump(nextDoc, { indent: 2 })
524
+ : `${JSON.stringify(nextDoc, null, 2)}\n`;
525
+
526
+ if (!write) {
527
+ return {
528
+ success: true,
529
+ written: false,
530
+ dryRun: true,
531
+ path: absolutePath,
532
+ relativePath,
533
+ merge,
534
+ providerDefaults: nextDoc.providerDefaults,
535
+ preview: serialized,
536
+ note: 'Dry-run: nothing written. Re-run with write=true to persist. Only the providerDefaults zone is merged; other repo zones are preserved.',
537
+ };
538
+ }
539
+
540
+ mkdirSync(dirname(absolutePath), { recursive: true });
541
+ writeFileSync(absolutePath, serialized, 'utf-8');
542
+ return {
543
+ success: true,
544
+ written: true,
545
+ dryRun: false,
546
+ path: absolutePath,
547
+ relativePath,
548
+ merge,
549
+ providerDefaults: nextDoc.providerDefaults,
550
+ note: 'Wrote providerDefaults into .adhdev/mesh.json (read-modify-write; other zones preserved). Commit it to the repo.',
551
+ };
552
+ } catch (e: any) {
553
+ return { success: false, error: e.message };
554
+ }
555
+ },
556
+
402
557
  delete_mesh: async (_ctx: MedFamilyContext, args: any) => {
403
558
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
404
559
  if (!meshId) return { success: false, error: 'meshId required' };
@@ -19,6 +19,18 @@
19
19
  * machine-local policy directly. The repo file shapes the coordinator prompt and
20
20
  * operating notes, nothing else.
21
21
  *
22
+ * `providerDefaults` IS NOT POLICY EITHER. It is a repo-shared DECLARATIVE
23
+ * "requested auto-approve mode" per providerType — the answer to "WHEN a delegated
24
+ * worker of type X is auto-approved, WHICH mode should it use by default". It has
25
+ * NO say over WHETHER auto-approve is enabled: the ENABLE decision (and the
26
+ * dangerous-mode opt-in) remains 100% machine-local (meshes.json /
27
+ * RepoMeshPolicy). A repo can declare `providerDefaults` freely; a machine that
28
+ * has delegatedWorkerAutoApprove=false still gets no auto-approve, and a dangerous
29
+ * requested mode is still downgraded to PTY parsing unless the machine opts in.
30
+ * The requested mode ID is validated at runtime against the provider spec — an
31
+ * unknown mode ID is IGNORED (fall back to the provider's own default), never
32
+ * silently coerced into a dangerous mode.
33
+ *
22
34
  * The coordinator/operatingNotes merge is **in-memory only**: the on-disk
23
35
  * machine-local `meshes.json` is never mutated by this module.
24
36
  *
@@ -60,6 +72,19 @@ export interface RepoMeshDeclarativeLimits {
60
72
  maxNotes?: number;
61
73
  }
62
74
 
75
+ /**
76
+ * Repo-shared declarative per-provider defaults. NOT policy — see the file header.
77
+ * `autoApproveModes` maps a providerType (e.g. "claude-cli") to the auto-approve
78
+ * mode ID that a delegated worker of that type should REQUEST when it is
79
+ * auto-approved. The map only influences WHICH mode is used, never WHETHER
80
+ * auto-approve is enabled (that stays machine-local). Mode IDs are validated
81
+ * against the live provider spec at resolve time; an unknown ID is ignored.
82
+ */
83
+ export interface RepoMeshDeclarativeProviderDefaults {
84
+ /** providerType → requested auto-approve mode ID. */
85
+ autoApproveModes?: Record<string, string>;
86
+ }
87
+
63
88
  /**
64
89
  * Parsed + normalized `.adhdev/mesh.json` shape. Every field is optional except
65
90
  * version so a repo can declare only the zone(s) it cares about. Policy is NOT a
@@ -70,6 +95,8 @@ export interface RepoMeshDeclarativeConfig {
70
95
  coordinator?: RepoMeshDeclarativeCoordinatorConfig;
71
96
  operatingNotes?: CoordinatorOperatingNote[];
72
97
  limits?: RepoMeshDeclarativeLimits;
98
+ /** Repo-shared per-provider defaults (requested auto-approve mode). NOT policy. */
99
+ providerDefaults?: RepoMeshDeclarativeProviderDefaults;
73
100
  }
74
101
 
75
102
  export interface RepoMeshJsonConfigLoadResult {
@@ -132,6 +159,20 @@ export const MESH_JSON_CONFIG_SCHEMA = {
132
159
  maxNotes: { type: 'number', minimum: 1 },
133
160
  },
134
161
  },
162
+ providerDefaults: {
163
+ type: 'object',
164
+ additionalProperties: false,
165
+ properties: {
166
+ // providerType → requested auto-approve mode ID. Mode IDs are
167
+ // validated against the live provider spec at resolve time; an
168
+ // unknown ID is ignored (provider default is used), so the schema
169
+ // only constrains the shape (string→string), not the ID values.
170
+ autoApproveModes: {
171
+ type: 'object',
172
+ additionalProperties: { type: 'string' },
173
+ },
174
+ },
175
+ },
135
176
  },
136
177
  } as const;
137
178
 
@@ -226,6 +267,38 @@ export function normalizeRepoMeshDeclarativeConfig(parsed: unknown): {
226
267
  }
227
268
  }
228
269
 
270
+ // providerDefaults: shape-only normalization. We keep every string→string
271
+ // (providerType → requested mode ID) entry after trimming; we deliberately do
272
+ // NOT validate the mode IDs against any provider spec here — the normalizer has
273
+ // no provider registry, and (crucially) validity depends on the LIVE provider
274
+ // at resolve time. The runtime resolver (resolveDelegatedWorkerAutoApprove)
275
+ // checks the requested ID against provider.autoApproveModes.modes and falls
276
+ // back to the provider's own default when the ID is unknown. This keeps the
277
+ // config fail-closed: a stale/typo'd mode ID never coerces into a dangerous
278
+ // mode, it just means "no repo override → provider default".
279
+ if (parsed.providerDefaults !== undefined) {
280
+ if (isRecord(parsed.providerDefaults)) {
281
+ const pd: RepoMeshDeclarativeProviderDefaults = {};
282
+ const rawModes = (parsed.providerDefaults as Record<string, unknown>).autoApproveModes;
283
+ if (rawModes !== undefined) {
284
+ if (isRecord(rawModes)) {
285
+ const modes: Record<string, string> = {};
286
+ for (const [providerType, modeId] of Object.entries(rawModes)) {
287
+ const type = typeof providerType === 'string' ? providerType.trim() : '';
288
+ const id = typeof modeId === 'string' ? modeId.trim() : '';
289
+ if (type && id) modes[type] = id;
290
+ }
291
+ if (Object.keys(modes).length) pd.autoApproveModes = modes;
292
+ } else {
293
+ errors.push('providerDefaults.autoApproveModes must be an object when provided');
294
+ }
295
+ }
296
+ if (Object.keys(pd).length) config.providerDefaults = pd;
297
+ } else {
298
+ errors.push('providerDefaults must be an object when provided');
299
+ }
300
+ }
301
+
229
302
  return { valid: true, config, errors };
230
303
  }
231
304
 
@@ -364,6 +437,21 @@ export function applyRepoMeshConfig<T extends Pick<LocalMeshEntry, 'coordinator'
364
437
  * exported — it is machine-local only and has no place in mesh.json. Operating
365
438
  * notes are intentionally NOT exported either: those are runtime ledger lessons,
366
439
  * and a repo should declare baseline notes deliberately.
440
+ *
441
+ * `providerDefaults` is NOT auto-exported from the machine-local mesh (there is no
442
+ * machine-local source for it — it is a repo-authored declaration). To help an
443
+ * operator hand-author it, the scaffold carries a commented example shape:
444
+ *
445
+ * "providerDefaults": {
446
+ * "autoApproveModes": {
447
+ * "claude-cli": "accept-edits", // requested mode WHEN auto-approve is on
448
+ * "codex-cli": "auto" // (enable/dangerous opt-in stays machine-local)
449
+ * }
450
+ * }
451
+ *
452
+ * The example is surfaced via the scaffold's `_providerDefaultsExample` hint (JSON
453
+ * has no comments) rather than a live `providerDefaults` value, so serializing the
454
+ * scaffold never writes an unwanted requested-mode map into the repo file.
367
455
  */
368
456
  export function buildMeshJsonConfigScaffold(
369
457
  mesh: Pick<LocalMeshEntry, 'coordinator'>,
@@ -378,6 +466,21 @@ export function buildMeshJsonConfigScaffold(
378
466
  return scaffold;
379
467
  }
380
468
 
469
+ /**
470
+ * A copy-paste example of the `providerDefaults` zone for operators hand-authoring
471
+ * a repo `.adhdev/mesh.json`. Returned by the export/write commands as a separate
472
+ * hint field (never merged into the serialized scaffold) so the repo file stays
473
+ * clean. The mode IDs shown are illustrative — a repo should use IDs that exist in
474
+ * its own providers' specs; an unknown ID is ignored at resolve time and the
475
+ * provider's own default is used.
476
+ */
477
+ export const MESH_JSON_PROVIDER_DEFAULTS_EXAMPLE: RepoMeshDeclarativeProviderDefaults = {
478
+ autoApproveModes: {
479
+ 'claude-cli': 'accept-edits',
480
+ 'codex-cli': 'auto',
481
+ },
482
+ };
483
+
381
484
  /** Serialize a scaffold to the canonical 2-space JSON draft text. */
382
485
  export function serializeMeshJsonConfigScaffold(config: RepoMeshDeclarativeConfig): string {
383
486
  return JSON.stringify(config, null, 2);
package/src/index.ts CHANGED
@@ -163,6 +163,24 @@ export {
163
163
  resolveAutoConvergeCodeChange,
164
164
  } from './repo-mesh-types.js';
165
165
 
166
+ // ── Repo-shared declarative mesh config (.adhdev/mesh.json) ──
167
+ export {
168
+ loadRepoMeshJsonConfig,
169
+ normalizeRepoMeshDeclarativeConfig,
170
+ buildMeshJsonConfigScaffold,
171
+ serializeMeshJsonConfigScaffold,
172
+ MESH_JSON_CONFIG_LOCATIONS,
173
+ MESH_JSON_CONFIG_SCHEMA,
174
+ MESH_JSON_PROVIDER_DEFAULTS_EXAMPLE,
175
+ } from './config/mesh-json-config.js';
176
+ export type {
177
+ RepoMeshDeclarativeConfig,
178
+ RepoMeshDeclarativeCoordinatorConfig,
179
+ RepoMeshDeclarativeLimits,
180
+ RepoMeshDeclarativeProviderDefaults,
181
+ RepoMeshJsonConfigLoadResult,
182
+ } from './config/mesh-json-config.js';
183
+
166
184
  // ── Git Surface ──
167
185
  export * from './git/index.js';
168
186
 
@@ -17,6 +17,7 @@ import { enqueueUnresolvedDelegateForward, nudgeUnresolvedForwardRetry } from '.
17
17
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
18
18
  import { getLastDisplayMessage } from '../status/snapshot.js';
19
19
  import { delegatedWorkerAutoApproveSettings } from '../repo-mesh-types.js';
20
+ import { loadRepoMeshJsonConfig } from '../config/mesh-json-config.js';
20
21
  import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, sessionIdsEquivalent, withStatusProbeMarker, type MeshNodeIdentified } from '@adhdev/mesh-shared';
21
22
  import {
22
23
  findRecentTerminalLedgerEvidence,
@@ -1618,6 +1619,17 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1618
1619
  mesh?.policy,
1619
1620
  node?.policy,
1620
1621
  components.providerLoader?.getMeta(recoveryContext.failedProviderType),
1622
+ // Recovery relaunch: same repo-declared requested mode as the
1623
+ // primary path. node.workspace missing → null → provider default.
1624
+ (() => {
1625
+ const ws = typeof node?.workspace === 'string' && node.workspace.trim() ? node.workspace.trim() : '';
1626
+ if (!ws) return null;
1627
+ try {
1628
+ const r = loadRepoMeshJsonConfig(ws);
1629
+ return r.sourceType === 'repo_file' && r.config ? r.config : null;
1630
+ } catch { return null; }
1631
+ })(),
1632
+ recoveryContext.failedProviderType,
1621
1633
  ),
1622
1634
  launchedByCoordinator: true,
1623
1635
  }
@@ -14,6 +14,8 @@ import { MeshRuntimeStore } from './mesh-runtime-store.js';
14
14
  import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
16
  import { delegatedWorkerAutoApproveSettings, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks, resolveCoordinatorIdlePushPolicy } from '../repo-mesh-types.js';
17
+ import { loadRepoMeshJsonConfig } from '../config/mesh-json-config.js';
18
+ import type { RepoMeshDeclarativeConfig } from '../config/mesh-json-config.js';
17
19
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
20
  import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
19
21
  import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
@@ -90,6 +92,25 @@ export function __resetIdleAutoFastForwardForTests(): void {
90
92
  autoFastForwardWorkspaceLease.clear();
91
93
  }
92
94
 
95
+ /**
96
+ * Load the repo-shared `.adhdev/mesh.json` for a node's workspace, tolerating a
97
+ * missing/invalid file (returns null → resolver falls back to provider-spec
98
+ * defaults, i.e. exactly the pre-providerDefaults behavior). Only the
99
+ * `providerDefaults` zone influences the delegated-worker MODE selection; it never
100
+ * touches the ENABLE decision. When a node carries no workspace path (should not
101
+ * happen for a launchable node, but be defensive), we skip the read entirely.
102
+ */
103
+ function loadRepoConfigForNode(node: any): RepoMeshDeclarativeConfig | null {
104
+ const workspace = typeof node?.workspace === 'string' && node.workspace.trim() ? node.workspace.trim() : '';
105
+ if (!workspace) return null;
106
+ try {
107
+ const result = loadRepoMeshJsonConfig(workspace);
108
+ return result.sourceType === 'repo_file' && result.config ? result.config : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
93
114
  export function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
94
115
  const localMesh = getMesh(meshId);
95
116
  const cachedMesh = components.router?.getCachedInlineMesh(meshId);
@@ -708,6 +729,8 @@ export function tryAssignQueueTask(
708
729
  mesh?.policy,
709
730
  node?.policy,
710
731
  components.providerLoader?.getMeta(providerType),
732
+ loadRepoConfigForNode(node),
733
+ providerType,
711
734
  ),
712
735
  ...(localDaemonId ? { meshCoordinatorDaemonId: localDaemonId } : {}),
713
736
  // COMPLETION-PROPAGATION F5: (re)stamp the coordinator SESSION anchor from THIS
@@ -1698,8 +1721,20 @@ function liveSessionCountForNode(components: DaemonComponents, meshId: string, n
1698
1721
  * free claimer, and is excluded — so a read-only auto-launch onto a busy-but-no-idle node
1699
1722
  * is still allowed. A node with NO live mesh session at all (dead, or never launched) does
1700
1723
  * not match, preserving the legitimate first-session spawn.
1724
+ *
1725
+ * PROVIDER-MATCH gate (DISPATCH-DEADLOCK-PROVIDER-MISMATCH): a live session only suppresses
1726
+ * this launch if it could ACTUALLY claim THIS task. A session whose providerType does not
1727
+ * satisfy the task's requiredTags (e.g. a claude-cli coordinator/worker session on a node,
1728
+ * while the pending task is required_tags: [provider=codex-cli]) is NOT a pending claimer for
1729
+ * this task — claimNextQueueTask's nodeSatisfiesRequiredTags gate would reject its claim. Left
1730
+ * unchecked, such a mismatched session made this gate return true for a task it can never claim,
1731
+ * so the required-provider worker never auto-launched AND no session could claim → nobody made
1732
+ * progress → permanent silent deadlock. Mirror the claim path: build the session's own
1733
+ * capability tags (its providerType pinned onto the node) and only count it as a pending claimer
1734
+ * when those tags satisfy task.requiredTags. `node` is passed in so the tag set reflects this
1735
+ * node's os/arch/worktree/converge context, matching claimNextQueueTask exactly.
1701
1736
  */
1702
- function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: string, nodeId: string): boolean {
1737
+ function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: string, nodeId: string, task: MeshWorkQueueEntry, node: any): boolean {
1703
1738
  // (A) AUTOLAUNCH-CLAIM-CHURN remote-awareness: a task whose auto-launch record targets this
1704
1739
  // node and is still inside its await-claim window (base or backoff) already has a session on
1705
1740
  // its way to claim — even when that session is REMOTE and thus invisible to the local
@@ -1738,7 +1773,20 @@ function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: st
1738
1773
  if (isTerminalSessionStatus(status)) return false; // dead → no claimer here, allow launch
1739
1774
  const sessionId = readNonEmptyString(state.instanceId);
1740
1775
  if (sessionId && busySessionIds.has(sessionId)) return false; // busy with its own assigned task
1741
- return true; // live + unassigned will claim the pending task itself
1776
+ // PROVIDER-MATCH gate (DISPATCH-DEADLOCK-PROVIDER-MISMATCH): only count this session as a
1777
+ // pending claimer if its own provider could satisfy THIS task's requiredTags. A session
1778
+ // whose providerType does not match (e.g. a claude-cli session while task requires
1779
+ // provider=codex-cli) can never claim this task via claimNextQueueTask's
1780
+ // nodeSatisfiesRequiredTags gate, so it must not suppress the required-provider launch —
1781
+ // otherwise the task deadlocks (mismatched session blocks launch, yet cannot claim). Mirror
1782
+ // the claim path: pin the session's providerType onto this node and check the tags.
1783
+ if (task.requiredTags?.length) {
1784
+ const sessionProviderType = state.type || readNonEmptyString(settings.providerType);
1785
+ if (sessionProviderType && !nodeSatisfiesRequiredTags(task.requiredTags, buildMeshNodeCapabilityTags(node, sessionProviderType))) {
1786
+ return false; // provider mismatch → this session can't claim this task; not a pending claimer
1787
+ }
1788
+ }
1789
+ return true; // live + unassigned + provider-capable → will claim the pending task itself
1742
1790
  });
1743
1791
  }
1744
1792
 
@@ -2250,7 +2298,7 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
2250
2298
  // busy-but-no-idle node is still allowed. Skip with a transient (non-actionable)
2251
2299
  // reason so the coordinator is not paged; the 4s reconcile retries, and once the
2252
2300
  // existing session goes terminal this gate clears and a legitimate launch proceeds.
2253
- if (nodeHasLiveSessionPendingClaim(components, meshId, nodeId)) {
2301
+ if (nodeHasLiveSessionPendingClaim(components, meshId, nodeId, task, node)) {
2254
2302
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_has_live_session_pending_claim', nodeId });
2255
2303
  continue;
2256
2304
  }
@@ -2327,6 +2375,8 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
2327
2375
  mesh?.policy,
2328
2376
  node?.policy,
2329
2377
  components.providerLoader?.getMeta(resolved.providerType),
2378
+ loadRepoConfigForNode(node),
2379
+ resolved.providerType,
2330
2380
  ),
2331
2381
  launchedByCoordinator: true,
2332
2382
  autoLaunchedForQueueTaskId: task.id,
@@ -17,6 +17,9 @@ import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
17
17
  import type { MagiKindPanelMap, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
18
18
  import type { ProviderModule } from './providers/contracts.js';
19
19
  import { deriveAutoApproveModeRisk } from './providers/auto-approve-modes.js';
20
+ // Type-only import (no runtime cycle) — mesh-json-config imports types from this
21
+ // module, and this direction is `import type` so tsc erases it at emit.
22
+ import type { RepoMeshDeclarativeConfig } from './config/mesh-json-config.js';
20
23
 
21
24
  // ─── Core Mesh Types ────────────────────────────
22
25
 
@@ -804,14 +807,40 @@ export function mergeAndNormalizePolicy(
804
807
 
805
808
  /**
806
809
  * Resolve delegated worker auto-approve. Legacy providers return a boolean. Providers
807
- * with modes return the default mode id, except a dangerous default is downgraded to a
810
+ * with modes return a mode id, except a dangerous mode is downgraded to a
808
811
  * non-dangerous PTY mode unless mesh/node policy explicitly opts in.
812
+ *
813
+ * THREE EXPLICIT STAGES — do not collapse them; the ordering is a hard MAGI
814
+ * invariant:
815
+ *
816
+ * ① ENABLE gate (machine-local policy only): node boolean > mesh boolean.
817
+ * `enabled=false` returns `false` IMMEDIATELY, BEFORE any mode selection.
818
+ * The repo `mesh.json` providerDefaults has ZERO influence here — a
819
+ * node/mesh opt-out is never overridden by a repo-declared requested mode.
820
+ *
821
+ * ② MODE selection (only when enabled === true):
822
+ * task override [FUTURE — param reserved below, not yet wired] >
823
+ * repo mesh.json providerDefaults.autoApproveModes[providerType] >
824
+ * provider spec autoApproveModes.default.
825
+ * A repo-requested mode ID is adopted ONLY when it exists in the provider's
826
+ * own `autoApproveModes.modes`; an unknown/stale/typo'd ID is IGNORED and we
827
+ * fall back to the provider default (fail-closed: never coerce into a
828
+ * dangerous mode via a bad ID).
829
+ *
830
+ * ③ DANGEROUS gate: whichever mode stage ② picked, if it is dangerous and the
831
+ * machine-local delegatedWorkerDangerousModeAllow is not set, downgrade to a
832
+ * non-dangerous PTY-parse mode (or `false` if none exists).
809
833
  */
810
834
  export function resolveDelegatedWorkerAutoApprove(
811
835
  meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
812
836
  nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
813
837
  provider?: Pick<ProviderModule, 'autoApproveModes'> | null,
838
+ repoConfig?: RepoMeshDeclarativeConfig | null,
839
+ // providerType is needed to look up the repo-declared requested mode; it is
840
+ // separate from `provider` because the caller resolves the spec independently.
841
+ providerType?: string | null,
814
842
  ): boolean | string {
843
+ // ── ① ENABLE gate — machine-local only. false short-circuits before mode. ──
815
844
  let enabled = true;
816
845
  if (typeof nodePolicy?.delegatedWorkerAutoApprove === 'boolean') {
817
846
  enabled = nodePolicy.delegatedWorkerAutoApprove;
@@ -822,16 +851,32 @@ export function resolveDelegatedWorkerAutoApprove(
822
851
 
823
852
  const modes = provider?.autoApproveModes;
824
853
  if (!modes) return true;
825
- const defaultMode = modes.modes.find((mode) => mode.id === modes.default);
826
- if (!defaultMode || defaultMode.strategy === 'post-boot-command') return false;
827
854
 
855
+ // ── ② MODE selection (enabled only). Repo providerDefaults MAY override the
856
+ // provider spec default here — but only with a mode ID the spec knows. ──
857
+ // Future: a per-task override would slot in ahead of the repo default; the
858
+ // signature reserves that precedence but no task override is wired yet.
859
+ const requestedModeRaw = typeof providerType === 'string'
860
+ ? repoConfig?.providerDefaults?.autoApproveModes?.[providerType.trim()]
861
+ : undefined;
862
+ const requestedModeId = typeof requestedModeRaw === 'string' && requestedModeRaw.trim()
863
+ ? requestedModeRaw.trim()
864
+ : '';
865
+ const requestedMode = requestedModeId
866
+ ? modes.modes.find((mode) => mode.id === requestedModeId)
867
+ : undefined;
868
+ const selectedMode = requestedMode
869
+ ?? modes.modes.find((mode) => mode.id === modes.default);
870
+ if (!selectedMode || selectedMode.strategy === 'post-boot-command') return false;
871
+
872
+ // ── ③ DANGEROUS gate — downgrade a dangerous selection without machine opt-in. ──
828
873
  const dangerousAllowed = resolveDelegatedWorkerDangerousModeAllow(meshPolicy, nodePolicy);
829
- if (deriveAutoApproveModeRisk(defaultMode) === 'dangerous' && !dangerousAllowed) {
830
- const ptyFallback = modes!.modes.find((mode) =>
874
+ if (deriveAutoApproveModeRisk(selectedMode) === 'dangerous' && !dangerousAllowed) {
875
+ const ptyFallback = modes.modes.find((mode) =>
831
876
  mode.strategy === 'pty-parse-default' && deriveAutoApproveModeRisk(mode) !== 'dangerous');
832
877
  return ptyFallback?.id || false;
833
878
  }
834
- return defaultMode.id;
879
+ return selectedMode.id;
835
880
  }
836
881
 
837
882
  export function resolveDelegatedWorkerDangerousModeAllow(
@@ -849,12 +894,14 @@ export function delegatedWorkerAutoApproveSettings(
849
894
  meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
850
895
  nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null,
851
896
  provider?: Pick<ProviderModule, 'autoApproveModes'> | null,
897
+ repoConfig?: RepoMeshDeclarativeConfig | null,
898
+ providerType?: string | null,
852
899
  ): {
853
900
  autoApprove: boolean | undefined;
854
901
  autoApproveMode: string | undefined;
855
902
  delegatedWorkerDangerousModeAllow: boolean;
856
903
  } {
857
- const resolved = resolveDelegatedWorkerAutoApprove(meshPolicy, nodePolicy, provider);
904
+ const resolved = resolveDelegatedWorkerAutoApprove(meshPolicy, nodePolicy, provider, repoConfig, providerType);
858
905
  const delegatedWorkerDangerousModeAllow = resolveDelegatedWorkerDangerousModeAllow(meshPolicy, nodePolicy);
859
906
  return typeof resolved === 'string'
860
907
  ? { autoApprove: undefined, autoApproveMode: resolved, delegatedWorkerDangerousModeAllow }