@adhdev/daemon-core 0.9.82-rc.522 → 0.9.82-rc.524

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.
@@ -316,6 +316,15 @@ export class DaemonCommandRouter {
316
316
  runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
317
317
  /** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
318
318
  terminalRefineBatchJobs = new Map<string, MeshRefineBatchTerminalJob>();
319
+ /**
320
+ * DS2: in-process refinement leases keyed by `${repoRoot}::${baseBranch}`. Serialize
321
+ * the base-mutating window (candidate-SHA pin → merge → push) of concurrent single-node
322
+ * refines that target the SAME base branch in the same repo, so two refines cannot both
323
+ * validate against one baseHead and then race their merges (the base-movement race). The
324
+ * batch path is already sequential, so this only matters for overlapping single-node
325
+ * async jobs. Value = the meshId:nodeId job key holding the lease (for diagnostics).
326
+ */
327
+ refineBaseLeases = new Map<string, string>();
319
328
 
320
329
  constructor(deps: CommandRouterDeps) {
321
330
  this.deps = deps;
@@ -443,6 +443,29 @@ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy):
443
443
  export interface ChangedPackageClassification {
444
444
  isDaemonAffecting: boolean;
445
445
  affectedPackages: string[];
446
+ /**
447
+ * DOCS-ROOT: three-way change area, refining the binary isDaemonAffecting so a
448
+ * docs-only branch is distinguishable from a code (web) branch:
449
+ * 'daemon' — a daemon-runtime package (or an unknown/ambiguous path) changed;
450
+ * full validation + daemon rebuild/restart required.
451
+ * 'web' — only web-only packages changed; web validation but no daemon restart.
452
+ * 'none' — no package changed at all; every changed file is a benign non-runtime
453
+ * root file (docs/markers). No code validation is meaningful — only an
454
+ * explicit docs-scoped profile (e.g. docs:verify) should run.
455
+ * Derived from the same facts as isDaemonAffecting, so it never contradicts it
456
+ * (changeArea === 'daemon' ⇔ isDaemonAffecting === true).
457
+ */
458
+ changeArea: ChangeImpactKind;
459
+ }
460
+
461
+ /**
462
+ * Derive the three-way {@link ChangeImpactKind} from the binary daemon verdict and the
463
+ * affected-package set, using the exact rule the stale-build warning layer already
464
+ * applies: daemon-affecting → 'daemon'; else any package changed → 'web'; else (only
465
+ * benign non-runtime files, no package) → 'none'.
466
+ */
467
+ function deriveChangeArea(isDaemonAffecting: boolean, affectedPackages: string[]): ChangeImpactKind {
468
+ return isDaemonAffecting ? 'daemon' : affectedPackages.length > 0 ? 'web' : 'none';
446
469
  }
447
470
 
448
471
  /**
@@ -452,7 +475,10 @@ export interface ChangedPackageClassification {
452
475
  * gitlinks — a bare submodule path (e.g. `oss`) is runtime-ambiguous from the root's
453
476
  * point of view, but its *content* diff may be entirely web-only.
454
477
  */
455
- interface ChangedFileListClassification extends ChangedPackageClassification {
478
+ // Intermediate verdict: carries the binary daemon signal + ambiguous paths, but NOT
479
+ // the derived `changeArea` — that is computed once at the final classifyChangedPackages
480
+ // boundary (`strip` / the submodule folds) so the file-list bucketer stays area-agnostic.
481
+ interface ChangedFileListClassification extends Omit<ChangedPackageClassification, 'changeArea'> {
456
482
  /** Non-package paths that were not recognized as benign root files. */
457
483
  ambiguousNonPackageFiles: string[];
458
484
  }
@@ -517,10 +543,11 @@ async function classifyDaemonBuildChange(
517
543
  .split('\n')
518
544
  .map((line) => line.trim())
519
545
  .filter(Boolean);
520
- return classifyChangedFileList(files, policy);
546
+ const { isDaemonAffecting, affectedPackages } = classifyChangedFileList(files, policy);
547
+ return { isDaemonAffecting, affectedPackages, changeArea: deriveChangeArea(isDaemonAffecting, affectedPackages) };
521
548
  } catch {
522
549
  // diff probe failed → can't prove web-only; stay conservative.
523
- return { isDaemonAffecting: true, affectedPackages: [] };
550
+ return { isDaemonAffecting: true, affectedPackages: [], changeArea: 'daemon' };
524
551
  }
525
552
  }
526
553
 
@@ -584,7 +611,8 @@ async function refineVerdictThroughSubmodules(
584
611
  policy: ResolvedChangeImpactPolicy,
585
612
  rootVerdict: ChangedFileListClassification,
586
613
  ): Promise<ChangedPackageClassification> {
587
- const strip = ({ isDaemonAffecting, affectedPackages }: ChangedPackageClassification) => ({ isDaemonAffecting, affectedPackages });
614
+ const strip = ({ isDaemonAffecting, affectedPackages }: ChangedFileListClassification): ChangedPackageClassification =>
615
+ ({ isDaemonAffecting, affectedPackages, changeArea: deriveChangeArea(isDaemonAffecting, affectedPackages) });
588
616
  const ambiguous = rootVerdict.ambiguousNonPackageFiles;
589
617
  // Fast path: nothing to descend into, or the root is daemon-affecting for a reason
590
618
  // other than an ambiguous path (an unknown/daemon package). Submodule descent only
@@ -628,9 +656,11 @@ async function refineVerdictThroughSubmodules(
628
656
  if (subVerdict.isDaemonAffecting) {
629
657
  // The submodule content really does touch daemon runtime → keep daemon-affecting,
630
658
  // surfacing the submodule packages so the reason is visible.
659
+ const affectedPackages = [...new Set([...rootVerdict.affectedPackages, ...subVerdict.affectedPackages])].sort();
631
660
  return {
632
661
  isDaemonAffecting: true,
633
- affectedPackages: [...new Set([...rootVerdict.affectedPackages, ...subVerdict.affectedPackages])].sort(),
662
+ affectedPackages,
663
+ changeArea: deriveChangeArea(true, affectedPackages),
634
664
  };
635
665
  }
636
666
  submoduleAffectedPackages.push(...subVerdict.affectedPackages);
@@ -642,9 +672,11 @@ async function refineVerdictThroughSubmodules(
642
672
  const rootPackagesBenign = rootVerdict.affectedPackages.every(
643
673
  (p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p),
644
674
  );
675
+ const affectedPackages = [...new Set([...rootVerdict.affectedPackages, ...submoduleAffectedPackages])].sort();
645
676
  return {
646
677
  isDaemonAffecting: !rootPackagesBenign,
647
- affectedPackages: [...new Set([...rootVerdict.affectedPackages, ...submoduleAffectedPackages])].sort(),
678
+ affectedPackages,
679
+ changeArea: deriveChangeArea(!rootPackagesBenign, affectedPackages),
648
680
  };
649
681
  }
650
682
 
@@ -15,12 +15,12 @@ import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
16
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
17
17
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
- import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
19
19
  import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
20
20
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
21
21
  import { readNonEmptyString } from './mesh-events-utils.js';
22
22
  import { readMeshNodeDaemonId, isMeshNodeHealthLaunchable } from './mesh-node-identity.js';
23
- import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent } from './mesh-events-pending.js';
23
+ import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
24
24
  import { isWorktreeBootstrapStaleRunning, shouldDeferDispatchForBootstrap } from './worktree-bootstrap-config.js';
25
25
  import { isWithinCloneBootstrapGrace } from './mesh-clone-grace.js';
26
26
  import { beginTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
@@ -2746,6 +2746,68 @@ export async function runContinuousAutoFastForwardScan(components: DaemonCompone
2746
2746
  }
2747
2747
  }
2748
2748
 
2749
+ /**
2750
+ * DS3: drain and act on `coordinator_catchup` markers queued by a remote node's Refinery
2751
+ * after it pushed the base branch to origin. The originating coordinator is THIS daemon;
2752
+ * its local base checkout is now behind origin. Bring it up to date with a guarded ff-only
2753
+ * merge — but ONLY when the coordinator base node has no active mesh work (busy → leave the
2754
+ * marker for the next idle tick) and fastForwardMeshNode's own clean/ahead=0/behind>0 gate
2755
+ * is satisfied (ahead/diverged/dirty → it returns a structured block, never a rebase).
2756
+ *
2757
+ * These markers are drained on a DEDICATED event-name filter so they never reach the
2758
+ * coordinator chat-injection path (they are actions, not messages). A busy/blocked node
2759
+ * re-queues the marker so a later idle tick retries; a successful/no-op ff consumes it.
2760
+ */
2761
+ export async function runPendingCoordinatorCatchupScan(components: DaemonComponents, mesh: any): Promise<void> {
2762
+ const meshId = readNonEmptyString(mesh?.id);
2763
+ if (!meshId) return;
2764
+ const localIds = expandDaemonIdForms([
2765
+ readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId),
2766
+ readNonEmptyString(loadConfig().machineId),
2767
+ ]);
2768
+ let markers: Awaited<ReturnType<typeof drainPendingMeshCoordinatorEvents>> = [];
2769
+ try {
2770
+ markers = drainPendingMeshCoordinatorEvents(
2771
+ meshId,
2772
+ localIds.length > 0 ? localIds : undefined,
2773
+ { onlyEvents: new Set(['coordinator_catchup']) },
2774
+ );
2775
+ } catch (e: any) {
2776
+ LOG.warn('MeshReconcile', `Coordinator-catchup drain failed for mesh ${meshId}: ${e?.message || e}`);
2777
+ return;
2778
+ }
2779
+ if (markers.length === 0) return;
2780
+ const nodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
2781
+ for (const marker of markers) {
2782
+ const meta = (marker.metadataEvent || {}) as Record<string, unknown>;
2783
+ const nodeId = readNonEmptyString(marker.nodeId) || readNonEmptyString(meta.nodeId as string);
2784
+ const workspace = readNonEmptyString(marker.workspace) || readNonEmptyString(meta.workspace as string);
2785
+ const baseBranch = readNonEmptyString(meta.baseBranch as string);
2786
+ if (!workspace) continue;
2787
+ // Busy node → re-queue and defer to the next idle tick (never advance a base a
2788
+ // session is actively working on).
2789
+ if (nodeId && nodeHasActiveMeshWork(components, meshId, nodeId)) {
2790
+ try { queuePendingMeshCoordinatorEvent(marker); } catch { /* best-effort re-queue */ }
2791
+ continue;
2792
+ }
2793
+ try {
2794
+ const ff = await fastForwardMeshNode({
2795
+ meshId,
2796
+ ...(nodeId ? { nodeId } : {}),
2797
+ workspace,
2798
+ ...(baseBranch ? { branch: baseBranch } : {}),
2799
+ mode: 'merge',
2800
+ execute: true,
2801
+ trigger: 'refine_post_push_catchup',
2802
+ allowAutoPublishSubmoduleMainCommits: mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true,
2803
+ });
2804
+ LOG.info('MeshReconcile', `Coordinator catch-up ff for ${meshId}/${nodeId || workspace}: ${ff.code} (executed=${ff.executed})`);
2805
+ } catch (e: any) {
2806
+ LOG.warn('MeshReconcile', `Coordinator catch-up ff failed for ${meshId}/${nodeId || workspace}: ${e?.message || e}`);
2807
+ }
2808
+ }
2809
+ }
2810
+
2749
2811
  export function runIdleMaintenanceThenAssignQueue(components: DaemonComponents, args: {
2750
2812
  meshId: string;
2751
2813
  nodeId: string;
@@ -61,7 +61,7 @@ import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage }
61
61
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
62
62
  import { expandDaemonIdForms, daemonIdsEquivalent, sessionIdsEquivalent, meshNodeIdMatches, withStatusProbeMarker } from '@adhdev/mesh-shared';
63
63
  import { getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
64
- import { resolveSessionBusyVerdict, runContinuousAutoFastForwardScan } from './mesh-queue-assignment.js';
64
+ import { resolveSessionBusyVerdict, runContinuousAutoFastForwardScan, runPendingCoordinatorCatchupScan } from './mesh-queue-assignment.js';
65
65
  import { readLedgerEntries } from './mesh-ledger.js';
66
66
  import type { MeshLedgerEntry } from './mesh-ledger.js';
67
67
  import { findTerminalLedgerEvidenceForTask } from './mesh-events-stale.js';
@@ -1082,6 +1082,23 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1082
1082
  }
1083
1083
  }
1084
1084
 
1085
+ // ── PHASE 2.6: DS3 coordinator local catch-up ──────────────────────────────
1086
+ // Drain `coordinator_catchup` markers a remote node's Refinery queued after pushing
1087
+ // the base to origin, and guarded-ff this daemon's own coordinator base checkout up to
1088
+ // the pushed commit (busy → deferred to a later tick; ahead/diverged/dirty → the ff
1089
+ // helper structured-blocks, never rebases). Runs for EVERY mesh this daemon hosts
1090
+ // (not gated on continuous mode) and BEFORE PHASE 3 so a caught-up base is current
1091
+ // before any new task is dispatched. No markers → immediate no-op.
1092
+ for (const mesh of listMeshes()) {
1093
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1094
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
1095
+ try {
1096
+ await runPendingCoordinatorCatchupScan(components, mesh);
1097
+ } catch (e: any) {
1098
+ LOG.warn('MeshReconcile', `Coordinator catch-up scan failed for mesh ${mesh.id}: ${e?.message || e}`);
1099
+ }
1100
+ }
1101
+
1085
1102
  // ── PHASE 2.7: continuous remote auto fast-forward (opt-in, default OFF) ────
1086
1103
  // mode:"continuous" + remoteNodes:true only. Catch up an online/clean/behind
1087
1104
  // REMOTE base node that emits no fresh idle edge (e.g. a long-idle base node while
@@ -16,7 +16,7 @@ import { getGitRepoStatus } from '../git/git-status.js';
16
16
  import type { ChangedPackageClassification } from '../git/git-status.js';
17
17
  import * as yaml from 'js-yaml';
18
18
  import { loadMeshRefineConfig, resolveMeshRefineValidationPlan } from '../mesh/refine-config.js';
19
- import type { MeshRefineValidationCommandPlan } from '../mesh/refine-config.js';
19
+ import type { MeshRefineValidationCommandPlan, MeshRefineValidationScope } from '../mesh/refine-config.js';
20
20
  import { evaluateWorktreeBootstrapState, loadMeshWorktreeBootstrapConfig, runMeshWorktreeBootstrap, resolveSubmoduleDefaultBranch } from '../mesh/worktree-bootstrap-config.js';
21
21
  import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
22
22
  import { basename as pathBasename, join as pathJoin, resolve as pathResolve } from 'path';
@@ -85,8 +85,12 @@ type MeshRefineValidationSummary = {
85
85
  changeImpact?: {
86
86
  isDaemonAffecting: boolean;
87
87
  affectedPackages: string[];
88
+ /** DOCS-ROOT: three-way change area ('none' | 'web' | 'daemon') when known. */
89
+ changeArea?: MeshRefineValidationScope;
88
90
  /** displayCommands skipped because the daemon scope is unaffected. */
89
91
  skippedDaemonCommands?: string[];
92
+ /** DOCS-ROOT: displayCommands skipped because the change-area scope excluded them. */
93
+ skippedScopeCommands?: string[];
90
94
  };
91
95
  };
92
96
 
@@ -1474,6 +1478,9 @@ export function buildMeshRefineValidationPlan(mesh: any, workspace: string): Rec
1474
1478
  source: command.source,
1475
1479
  cwd: command.cwd,
1476
1480
  timeoutMs: command.timeoutMs,
1481
+ // DOCS-ROOT: surface the change-impact scopes so `mesh_refine_config` shows which
1482
+ // area(s) each command runs in (absent → every area).
1483
+ ...(command.scopes ? { scopes: command.scopes } : {}),
1477
1484
  });
1478
1485
  return {
1479
1486
  source: plan.source,
@@ -1633,9 +1640,48 @@ export async function runMeshRefineValidationGate(
1633
1640
  };
1634
1641
 
1635
1642
  const scopeUnaffectedDaemon = opts?.changeImpact?.isDaemonAffecting === false;
1643
+ // DOCS-ROOT: the branch's three-way change area ('none' | 'web' | 'daemon'), when
1644
+ // known. `none` (docs-only) is the case this scoping exists for: a docs-only branch
1645
+ // must skip every code validation command and run ONLY commands explicitly scoped
1646
+ // ['none'] (e.g. a light docs:verify profile). Fail-open: an unknown change area
1647
+ // (changeImpact undefined) leaves changeArea undefined → no scope filtering, the full
1648
+ // command set runs exactly as before.
1649
+ const changeArea: MeshRefineValidationScope | undefined = opts?.changeImpact?.changeArea;
1650
+ // A command runs in the current change area when: the branch area is unknown (run
1651
+ // everything), OR the command declared no scopes (runs everywhere), OR the command's
1652
+ // scopes include the current area. When the branch is docs-only ('none'), an
1653
+ // un-scoped command does NOT run — only commands that explicitly opted into 'none'.
1654
+ const commandRunsInArea = (candidate: MeshRefineValidationCommand): boolean => {
1655
+ if (!changeArea) return true; // fail-open on unknown area
1656
+ const scopes = candidate.scopes;
1657
+ if (scopes && scopes.length) return scopes.includes(changeArea);
1658
+ // Un-scoped command: runs in web/daemon (code areas) but NOT on a docs-only
1659
+ // branch — there is nothing for a code command to validate when only docs changed.
1660
+ return changeArea !== 'none';
1661
+ };
1636
1662
  const skippedDaemonCommands: string[] = [];
1663
+ const skippedScopeCommands: string[] = [];
1637
1664
  const commandsToRun: MeshRefineValidationCommand[] = [];
1638
1665
  for (const candidate of selection.commands) {
1666
+ // DOCS-ROOT scope filter runs first: it is the explicit, config-declared signal
1667
+ // and supersedes the coarse daemon heuristic. A command excluded by change-area
1668
+ // scope is recorded skipped with `unaffected_change_scope`.
1669
+ if (!commandRunsInArea(candidate)) {
1670
+ skippedScopeCommands.push(candidate.displayCommand);
1671
+ summary.commandsRun.push({
1672
+ command: candidate.command,
1673
+ args: candidate.args,
1674
+ displayCommand: candidate.displayCommand,
1675
+ category: candidate.category,
1676
+ source: candidate.source,
1677
+ passed: true,
1678
+ skipped: true,
1679
+ skipReason: 'unaffected_change_scope',
1680
+ changeArea,
1681
+ ...(candidate.scopes ? { scopes: candidate.scopes } : {}),
1682
+ });
1683
+ continue;
1684
+ }
1639
1685
  if (scopeUnaffectedDaemon && isDaemonScopedCommand(candidate)) {
1640
1686
  skippedDaemonCommands.push(candidate.displayCommand);
1641
1687
  // Record the skip so it's visible in the summary, never silently dropped.
@@ -1657,7 +1703,9 @@ export async function runMeshRefineValidationGate(
1657
1703
  summary.changeImpact = {
1658
1704
  isDaemonAffecting: opts.changeImpact.isDaemonAffecting,
1659
1705
  affectedPackages: opts.changeImpact.affectedPackages,
1706
+ ...(changeArea ? { changeArea } : {}),
1660
1707
  ...(skippedDaemonCommands.length ? { skippedDaemonCommands } : {}),
1708
+ ...(skippedScopeCommands.length ? { skippedScopeCommands } : {}),
1661
1709
  };
1662
1710
  }
1663
1711
 
@@ -5,6 +5,22 @@ import * as yaml from 'js-yaml';
5
5
  export const MESH_REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
6
6
  export type MeshRefineValidationCategory = typeof MESH_REFINE_VALIDATION_CATEGORIES[number];
7
7
 
8
+ /**
9
+ * DOCS-ROOT: change-impact scope for a validation command. Mirrors ChangeImpactKind
10
+ * (git-status) so a command can declare WHICH change areas it should run in:
11
+ * 'daemon' — run when a daemon-runtime package changed,
12
+ * 'web' — run when a web package changed,
13
+ * 'none' — run when ONLY docs/markers changed (a docs-only branch).
14
+ * A command with NO `scopes` runs in every area (backward-compatible: the full set
15
+ * runs as before). This lets a repo declare a light docs-only profile — e.g. a
16
+ * `docs:verify` command scoped `['none']` — that runs on a docs-only branch while the
17
+ * heavy typecheck/test commands (implicitly all-areas, or scoped ['web','daemon'])
18
+ * are skipped. The scoping is applied only when the branch's changeArea is known;
19
+ * fail-open (unknown change area → every command runs).
20
+ */
21
+ export const MESH_REFINE_VALIDATION_SCOPES = ['none', 'web', 'daemon'] as const;
22
+ export type MeshRefineValidationScope = typeof MESH_REFINE_VALIDATION_SCOPES[number];
23
+
8
24
  export interface RepoMeshRefineValidationCommandConfig {
9
25
  /** Executable name or a whitespace-tokenized command string. Never executed through a shell. */
10
26
  command: string;
@@ -15,6 +31,12 @@ export interface RepoMeshRefineValidationCommandConfig {
15
31
  timeoutMs?: number;
16
32
  outputLimitBytes?: number;
17
33
  env?: Record<string, string>;
34
+ /**
35
+ * DOCS-ROOT: change-impact scopes this command runs in ('none' | 'web' | 'daemon').
36
+ * Omitted → runs in every area (backward-compatible). Empty array is treated the
37
+ * same as omitted (runs everywhere) rather than "runs nowhere".
38
+ */
39
+ scopes?: MeshRefineValidationScope[];
18
40
  }
19
41
 
20
42
  export interface RepoMeshRefineConfig {
@@ -57,6 +79,8 @@ export interface MeshRefineValidationCommandPlan {
57
79
  timeoutMs?: number;
58
80
  outputLimitBytes?: number;
59
81
  env?: Record<string, string>;
82
+ /** DOCS-ROOT: normalized change-impact scopes; absent → runs in every area. */
83
+ scopes?: MeshRefineValidationScope[];
60
84
  }
61
85
 
62
86
  export interface MeshRefineConfigLoadResult {
@@ -133,6 +157,11 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
133
157
  timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
134
158
  outputLimitBytes: { type: 'number', minimum: 1024, maximum: 1048576 },
135
159
  env: { type: 'object', additionalProperties: { type: 'string' } },
160
+ scopes: {
161
+ type: 'array',
162
+ items: { enum: [...MESH_REFINE_VALIDATION_SCOPES] },
163
+ description: "DOCS-ROOT: change-impact scopes this command runs in ('none'=docs-only, 'web', 'daemon'). Omitted/empty → runs in every area.",
164
+ },
136
165
  },
137
166
  },
138
167
  },
@@ -152,6 +181,10 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
152
181
  timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
153
182
  outputLimitBytes: { type: 'number', minimum: 1024, maximum: 1048576 },
154
183
  env: { type: 'object', additionalProperties: { type: 'string' } },
184
+ scopes: {
185
+ type: 'array',
186
+ items: { enum: [...MESH_REFINE_VALIDATION_SCOPES] },
187
+ },
155
188
  },
156
189
  },
157
190
  },
@@ -239,6 +272,16 @@ export function normalizeMeshCommandConfig(entry: unknown, source: string): { co
239
272
  if (entry.env !== undefined && (!isMeshConfigRecord(entry.env) || !Object.values(entry.env).every(value => typeof value === 'string'))) {
240
273
  return { rejected: { source, command: commandText, reason: 'env must be an object of string values' } };
241
274
  }
275
+ // DOCS-ROOT: validate + normalize the optional change-impact scopes.
276
+ let scopes: MeshRefineValidationScope[] | undefined;
277
+ if (entry.scopes !== undefined) {
278
+ if (!Array.isArray(entry.scopes) || !entry.scopes.every(s => (MESH_REFINE_VALIDATION_SCOPES as readonly string[]).includes(s as string))) {
279
+ return { rejected: { source, command: commandText, reason: `scopes must be an array of ${MESH_REFINE_VALIDATION_SCOPES.join(' | ')}` } };
280
+ }
281
+ // De-dupe; an empty array means "no restriction" (runs everywhere), so drop it.
282
+ const deduped = [...new Set(entry.scopes as MeshRefineValidationScope[])];
283
+ scopes = deduped.length ? deduped : undefined;
284
+ }
242
285
 
243
286
  return {
244
287
  command: {
@@ -251,6 +294,7 @@ export function normalizeMeshCommandConfig(entry: unknown, source: string): { co
251
294
  ...(typeof entry.timeoutMs === 'number' ? { timeoutMs: entry.timeoutMs } : {}),
252
295
  ...(typeof entry.outputLimitBytes === 'number' ? { outputLimitBytes: entry.outputLimitBytes } : {}),
253
296
  ...(isMeshConfigRecord(entry.env) ? { env: entry.env as Record<string, string> } : {}),
297
+ ...(scopes ? { scopes } : {}),
254
298
  },
255
299
  };
256
300
  }