@adhdev/daemon-core 0.9.82-rc.486 → 0.9.82-rc.487

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.
@@ -11,6 +11,7 @@
11
11
  * keep working. `CommandRouterResult` is imported type-only from router.ts
12
12
  * (erased at compile time — no runtime import cycle).
13
13
  */
14
+ import type { ChangedPackageClassification } from '../git/git-status.js';
14
15
  import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
15
16
  import type { CommandRouterResult } from '../commands/router.js';
16
17
  export type MeshCoordinatorConfigFormat = 'claude_mcp_json' | 'hermes_config_yaml';
@@ -53,6 +54,19 @@ type MeshRefineValidationSummary = {
53
54
  };
54
55
  /** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
55
56
  deprecationWarnings?: string[];
57
+ /**
58
+ * Coarse daemon-vs-web change-impact used to scope the validation command set.
59
+ * When `isDaemonAffecting === false`, daemon-scoped commands are recorded in
60
+ * `commandsRun` with `skipped: true, skipReason: 'unaffected_daemon_scope'`
61
+ * rather than executed; web + typecheck commands always run. Absent when no
62
+ * change-impact was threaded in (legacy: full command set runs).
63
+ */
64
+ changeImpact?: {
65
+ isDaemonAffecting: boolean;
66
+ affectedPackages: string[];
67
+ /** displayCommands skipped because the daemon scope is unaffected. */
68
+ skippedDaemonCommands?: string[];
69
+ };
56
70
  };
57
71
  type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
58
72
  type MeshRefinePatchEquivalenceSummary = {
@@ -277,6 +291,13 @@ export interface RefineContext {
277
291
  baseBranch: string;
278
292
  baseHead: string;
279
293
  branchHead: string;
294
+ /**
295
+ * Coarse daemon-vs-web change-impact for baseHead..branchHead, resolved in the
296
+ * resolve_refs stage and threaded into the validation gate to scope its command
297
+ * set. `undefined` means "could not classify" → the gate fails open and runs ALL
298
+ * commands (never skip on uncertainty).
299
+ */
300
+ changeImpact?: ChangedPackageClassification;
280
301
  validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
281
302
  patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
282
303
  submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
@@ -424,5 +445,13 @@ export declare function runMeshRefineValidationGate(mesh: any, workspace: string
424
445
  persistedBootstrapState?: WorktreeBootstrapState | null;
425
446
  /** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
426
447
  onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
448
+ /**
449
+ * Coarse daemon-vs-web change-impact for the branch (resolve_refs computes it
450
+ * over baseHead..branchHead). When provided and `isDaemonAffecting === false`,
451
+ * daemon-scoped validation commands are skipped (web + typecheck still run).
452
+ * When omitted or `isDaemonAffecting === true`, the full command set runs —
453
+ * fail-open to full validation on any uncertainty.
454
+ */
455
+ changeImpact?: ChangedPackageClassification;
427
456
  }): Promise<MeshRefineValidationSummary>;
428
457
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.486",
3
+ "version": "0.9.82-rc.487",
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": "0.9.82-rc.486",
51
- "@adhdev/session-host-core": "0.9.82-rc.486",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.487",
51
+ "@adhdev/session-host-core": "0.9.82-rc.487",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -17,6 +17,8 @@ import { handleMeshForwardEvent, queuePendingMeshCoordinatorEvent } from '../mes
17
17
  import { analyzeMeshRefineNodeChangeArea, orderMeshRefineBatchNodes } from '../mesh/mesh-refine-batch.js';
18
18
  import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
19
19
  import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
20
+ import { classifyChangedPackages } from '../git/git-status.js';
21
+ import type { ChangedPackageClassification } from '../git/git-status.js';
20
22
  import { readStringValue } from '../mesh/mesh-node-identity.js';
21
23
  import {
22
24
  alignRefinerySubmodulesAfterMerge,
@@ -312,7 +314,24 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
312
314
  const { stdout: branchHeadStdout } = await execFileAsync('git', ['rev-parse', branch], { cwd: node.workspace, encoding: 'utf8' });
313
315
  const baseHead = baseHeadRaw;
314
316
  const branchHead = branchHeadStdout.trim();
315
- recordMeshRefineStage(refineStages, 'resolve_refs', 'passed', resolveStarted, { branch, baseBranch, baseHead, branchHead, ...(fetchWarning ? { fetchWarning } : {}) });
317
+
318
+ // Coarse daemon-vs-web change-impact for baseHead..branchHead, computed
319
+ // against the worktree so the same policy (.adhdev/change-impact.*) as the
320
+ // stale-build detector applies. Threaded onto ctx so the validation gate
321
+ // can scope its command set: a web-only branch skips daemon-scoped commands.
322
+ // FAIL-OPEN: any classification error leaves changeImpact undefined → the
323
+ // gate runs the full command set (never skip on uncertainty).
324
+ let changeImpact: ChangedPackageClassification | undefined;
325
+ try {
326
+ changeImpact = await classifyChangedPackages(node.workspace, baseHead, branchHead);
327
+ } catch {
328
+ changeImpact = undefined;
329
+ }
330
+ recordMeshRefineStage(refineStages, 'resolve_refs', 'passed', resolveStarted, {
331
+ branch, baseBranch, baseHead, branchHead,
332
+ ...(changeImpact ? { changeImpact } : {}),
333
+ ...(fetchWarning ? { fetchWarning } : {}),
334
+ });
316
335
 
317
336
  return {
318
337
  kind: 'continue',
@@ -330,6 +349,7 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
330
349
  baseBranch,
331
350
  baseHead,
332
351
  branchHead,
352
+ changeImpact,
333
353
  validationSummary: undefined as any,
334
354
  patchEquivalence: undefined as any,
335
355
  submoduleReachability: undefined as any,
@@ -346,6 +366,9 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
346
366
  const { mesh, node, branch, baseBranch, refineStages } = ctx;
347
367
  const validationStarted = Date.now();
348
368
  const validationSummary = await runMeshRefineValidationGate(mesh, node.workspace, {
369
+ // (a) Scope the validation command set by coarse change-impact (resolved
370
+ // in resolve_refs). Undefined → gate runs the full command set (fail-open).
371
+ changeImpact: ctx.changeImpact,
349
372
  // M2-2: consume the node's persisted bootstrap state; persist re-runs.
350
373
  persistedBootstrapState: (node as any).worktreeBootstrap as WorktreeBootstrapState | undefined,
351
374
  onBootstrapStateChange: (state) => {
@@ -369,7 +392,9 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
369
392
  : undefined;
370
393
  const buildValidationFailedError = (): string => {
371
394
  const base = validationSummary.failureCode === 'missing_dependencies'
372
- ? 'Refinery validation dependencies are missing; merge/refine was not attempted. Configure validation.bootstrapCommands if Refinery should bootstrap dependencies before validation.'
395
+ ? 'Refinery validation dependencies are missing for a change-affected package; merge/refine was not attempted. '
396
+ + 'To make this self-service, either (1) configure .adhdev/worktree_bootstrap.json (or validation.bootstrapCommands in .adhdev/refine.json) so Refinery installs deps before validation, '
397
+ + 'or (2) converge the branch via the documented manual fast-forward-only bypass (rebase onto the fetched base, verify strict ancestry, then push ff-only) instead of the refine gate.'
373
398
  : validationSummary.failureCode === 'dependency_bootstrap_failed'
374
399
  ? 'Refinery dependency/bootstrap command failed; merge/refine was not attempted.'
375
400
  : validationSummary.failureCode === 'spawn_resolution_failed'
@@ -438,6 +438,54 @@ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy):
438
438
  return false;
439
439
  }
440
440
 
441
+ /** Coarse change-impact verdict produced from a changed-file list. */
442
+ export interface ChangedPackageClassification {
443
+ isDaemonAffecting: boolean;
444
+ affectedPackages: string[];
445
+ }
446
+
447
+ /**
448
+ * Pure bucketer: classify an already-collected changed-file list into the coarse
449
+ * daemon-vs-web verdict, per the resolved policy. Shared by classifyDaemonBuildChange
450
+ * (buildCommit..HEAD) and classifyChangedPackages (arbitrary ref range) so the
451
+ * daemon/web boundary logic lives in exactly one place. An empty list stays
452
+ * conservative (daemon-affecting) so an actionable warning is never suppressed.
453
+ */
454
+ function classifyChangedFileList(
455
+ files: string[],
456
+ policy: ResolvedChangeImpactPolicy,
457
+ ): ChangedPackageClassification {
458
+ if (files.length === 0) {
459
+ // No file diff (e.g. only merge metadata) — nothing actionable, but stay
460
+ // conservative and treat as daemon-affecting so we don't suppress a real warning.
461
+ return { isDaemonAffecting: true, affectedPackages: [] };
462
+ }
463
+ const pkgs = new Set<string>();
464
+ // A non-package path that is NOT a recognized benign root file (marker/doc).
465
+ // Only these force daemon-affecting; benign markers/docs are ignored so a
466
+ // gitlink-moving root commit over a marker-only oss commit no longer over-warns.
467
+ let sawRuntimeAmbiguousNonPackage = false;
468
+ for (const file of files) {
469
+ const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
470
+ if (!match) {
471
+ if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
472
+ continue;
473
+ }
474
+ pkgs.add(match[1]);
475
+ }
476
+ const affectedPackages = [...pkgs].sort();
477
+ // Daemon-affecting if: any runtime-ambiguous non-package file changed, any
478
+ // unknown package changed, or any explicit daemon-runtime package changed.
479
+ // The daemon is unaffected only when every changed file is either a known
480
+ // web-only package or a recognized benign root file (and at least one such
481
+ // file changed) — i.e. nothing runtime-ambiguous remains. Unlisted/new packages
482
+ // therefore stay daemon-affecting (fail-safe default preserved).
483
+ const allBenign =
484
+ !sawRuntimeAmbiguousNonPackage &&
485
+ affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
486
+ return { isDaemonAffecting: !allBenign, affectedPackages };
487
+ }
488
+
441
489
  /**
442
490
  * Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
443
491
  * package, per the resolved policy. Returns isDaemonAffecting:true conservatively
@@ -449,47 +497,54 @@ async function classifyDaemonBuildChange(
449
497
  buildCommit: string,
450
498
  options: GitStatusOptions,
451
499
  policy: ResolvedChangeImpactPolicy,
452
- ): Promise<{ isDaemonAffecting: boolean; affectedPackages: string[] }> {
500
+ ): Promise<ChangedPackageClassification> {
453
501
  try {
454
502
  const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
455
503
  const files = diff.stdout
456
504
  .split('\n')
457
505
  .map((line) => line.trim())
458
506
  .filter(Boolean);
459
- if (files.length === 0) {
460
- // No file diff (e.g. only merge metadata) — nothing actionable, but stay
461
- // conservative and treat as daemon-affecting so we don't suppress a real warning.
462
- return { isDaemonAffecting: true, affectedPackages: [] };
463
- }
464
- const pkgs = new Set<string>();
465
- // A non-package path that is NOT a recognized benign root file (marker/doc).
466
- // Only these force daemon-affecting; benign markers/docs are ignored so a
467
- // gitlink-moving root commit over a marker-only oss commit no longer over-warns.
468
- let sawRuntimeAmbiguousNonPackage = false;
469
- for (const file of files) {
470
- const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
471
- if (!match) {
472
- if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
473
- continue;
474
- }
475
- pkgs.add(match[1]);
476
- }
477
- const affectedPackages = [...pkgs].sort();
478
- // Daemon-affecting if: any runtime-ambiguous non-package file changed, any
479
- // unknown package changed, or any explicit daemon-runtime package changed.
480
- // The daemon is unaffected only when every changed file is either a known
481
- // web-only package or a recognized benign root file (and at least one such
482
- // file changed) — i.e. nothing runtime-ambiguous remains.
483
- const allBenign =
484
- !sawRuntimeAmbiguousNonPackage &&
485
- affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
486
- return { isDaemonAffecting: !allBenign, affectedPackages };
507
+ return classifyChangedFileList(files, policy);
487
508
  } catch {
488
509
  // diff probe failed → can't prove web-only; stay conservative.
489
510
  return { isDaemonAffecting: true, affectedPackages: [] };
490
511
  }
491
512
  }
492
513
 
514
+ /**
515
+ * Ref-parameterized change-impact classification for a repo/worktree, reusing the
516
+ * exact daemon-vs-web bucketing that the stale-build detector uses — but over an
517
+ * arbitrary `fromRef..toRef` range (e.g. a refine base head → branch head) instead
518
+ * of the live daemon's build commit → HEAD, and WITHOUT any daemonBuildInfo caching.
519
+ *
520
+ * Policy is resolved the same way as getGitRepoStatus: an explicit
521
+ * `options.changeImpactConfig` wins; otherwise the repo's `.adhdev/change-impact.*`
522
+ * is auto-loaded; otherwise the built-in ADHDev default policy applies. The
523
+ * classification uses `git diff --name-only fromRef..toRef`.
524
+ *
525
+ * FAIL-OPEN on error: if the diff can't be collected (bad ref, not a repo), the
526
+ * caller should treat "no verdict" as "run everything" — so we throw rather than
527
+ * returning a misleading benign verdict. Callers wrap this in try/catch and leave
528
+ * changeImpact undefined on failure. Unclassified/new packages still default to
529
+ * isDaemonAffecting:true (never silently skipped).
530
+ */
531
+ export async function classifyChangedPackages(
532
+ repoPath: string,
533
+ fromRef: string,
534
+ toRef: string,
535
+ options: GitStatusOptions = {},
536
+ ): Promise<ChangedPackageClassification> {
537
+ const repo = await resolveGitRepository(repoPath, options);
538
+ const { config } = resolveChangeImpactConfigForRepo(repo.repoRoot, options);
539
+ const policy = resolveChangeImpactPolicy(config);
540
+ const diff = await runGit(repoPath, ['diff', '--name-only', `${fromRef}..${toRef}`], options);
541
+ const files = diff.stdout
542
+ .split('\n')
543
+ .map((line) => line.trim())
544
+ .filter(Boolean);
545
+ return classifyChangedFileList(files, policy);
546
+ }
547
+
493
548
  /**
494
549
  * Resolve the Change Impact config to apply for this status read. Priority:
495
550
  * - options.changeImpactConfig === null → force built-in default policy (no load).
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { getGitRepoStatus } from '../git/git-status.js';
16
+ import type { ChangedPackageClassification } from '../git/git-status.js';
16
17
  import * as yaml from 'js-yaml';
17
18
  import { loadMeshRefineConfig, resolveMeshRefineValidationPlan } from '../mesh/refine-config.js';
18
19
  import type { MeshRefineValidationCommandPlan } from '../mesh/refine-config.js';
@@ -74,6 +75,19 @@ type MeshRefineValidationSummary = {
74
75
  };
75
76
  /** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
76
77
  deprecationWarnings?: string[];
78
+ /**
79
+ * Coarse daemon-vs-web change-impact used to scope the validation command set.
80
+ * When `isDaemonAffecting === false`, daemon-scoped commands are recorded in
81
+ * `commandsRun` with `skipped: true, skipReason: 'unaffected_daemon_scope'`
82
+ * rather than executed; web + typecheck commands always run. Absent when no
83
+ * change-impact was threaded in (legacy: full command set runs).
84
+ */
85
+ changeImpact?: {
86
+ isDaemonAffecting: boolean;
87
+ affectedPackages: string[];
88
+ /** displayCommands skipped because the daemon scope is unaffected. */
89
+ skippedDaemonCommands?: string[];
90
+ };
77
91
  };
78
92
 
79
93
  type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
@@ -343,6 +357,13 @@ export interface RefineContext {
343
357
  baseBranch: string;
344
358
  baseHead: string;
345
359
  branchHead: string;
360
+ /**
361
+ * Coarse daemon-vs-web change-impact for baseHead..branchHead, resolved in the
362
+ * resolve_refs stage and threaded into the validation gate to scope its command
363
+ * set. `undefined` means "could not classify" → the gate fails open and runs ALL
364
+ * commands (never skip on uncertainty).
365
+ */
366
+ changeImpact?: ChangedPackageClassification;
346
367
  validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
347
368
  patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
348
369
  submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
@@ -1477,6 +1498,14 @@ export async function runMeshRefineValidationGate(
1477
1498
  persistedBootstrapState?: WorktreeBootstrapState | null;
1478
1499
  /** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
1479
1500
  onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
1501
+ /**
1502
+ * Coarse daemon-vs-web change-impact for the branch (resolve_refs computes it
1503
+ * over baseHead..branchHead). When provided and `isDaemonAffecting === false`,
1504
+ * daemon-scoped validation commands are skipped (web + typecheck still run).
1505
+ * When omitted or `isDaemonAffecting === true`, the full command set runs —
1506
+ * fail-open to full validation on any uncertainty.
1507
+ */
1508
+ changeImpact?: ChangedPackageClassification;
1480
1509
  },
1481
1510
  ): Promise<MeshRefineValidationSummary> {
1482
1511
  const { execFile } = await import('node:child_process');
@@ -1574,6 +1603,63 @@ export async function runMeshRefineValidationGate(
1574
1603
  return ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'bun.lock']
1575
1604
  .some(lock => fs.existsSync(pathJoin(cwd, lock)));
1576
1605
  };
1606
+ // A validation command needs installed node_modules to run. Only these can hit
1607
+ // the missing-deps hard-block; non-package-manager commands (e.g. a plain
1608
+ // `node scripts/check-vendor-drift.mjs`) need no deps and must never be aborted
1609
+ // by a preceding command's missing-deps.
1610
+ const needsNodeModules = (candidate: MeshRefineValidationCommand, cwd: string): boolean =>
1611
+ isPackageManagerValidation(candidate) && dependenciesLikelyMissing(cwd);
1612
+
1613
+ // (a) Coarse change-impact scoping. When the branch is web-only
1614
+ // (changeImpact.isDaemonAffecting === false), daemon-scoped validation commands
1615
+ // are pointless — and often un-runnable in a web-only worktree that never
1616
+ // bootstrapped daemon deps. Identify daemon-scoped commands ONLY by the coarse
1617
+ // daemon-vs-web bucket: a command whose script/args reference a daemon package
1618
+ // (daemon-core / daemon-cloud) or the vendor-drift check. web-side commands
1619
+ // (test:web-core / test:web-cloud) and `typecheck` ALWAYS run — the daemon/web
1620
+ // boundary is the human-curated safe line; we deliberately do NOT do fine
1621
+ // per-package skipping (web-cloud consumes web-core, so it must still run).
1622
+ const isDaemonScopedCommand = (candidate: MeshRefineValidationCommand): boolean => {
1623
+ const haystack = [candidate.command, ...(candidate.args || []), candidate.displayCommand || '']
1624
+ .join(' ')
1625
+ .toLowerCase();
1626
+ // Never treat a typecheck or an explicit web-side command as daemon-scoped.
1627
+ if (candidate.category === 'typecheck') return false;
1628
+ if (/\btypecheck\b/.test(haystack)) return false;
1629
+ if (/\bweb-core\b|\bweb-cloud\b|\bweb-standalone\b|\btest:web\b/.test(haystack)) return false;
1630
+ // Daemon-scoped signals: a daemon package name, a daemon test script, or the
1631
+ // vendor-drift check (which validates the daemon vendor bundle).
1632
+ return /\bdaemon-core\b|\bdaemon-cloud\b|\btest:daemon\b|check-vendor-drift/.test(haystack);
1633
+ };
1634
+
1635
+ const scopeUnaffectedDaemon = opts?.changeImpact?.isDaemonAffecting === false;
1636
+ const skippedDaemonCommands: string[] = [];
1637
+ const commandsToRun: MeshRefineValidationCommand[] = [];
1638
+ for (const candidate of selection.commands) {
1639
+ if (scopeUnaffectedDaemon && isDaemonScopedCommand(candidate)) {
1640
+ skippedDaemonCommands.push(candidate.displayCommand);
1641
+ // Record the skip so it's visible in the summary, never silently dropped.
1642
+ summary.commandsRun.push({
1643
+ command: candidate.command,
1644
+ args: candidate.args,
1645
+ displayCommand: candidate.displayCommand,
1646
+ category: candidate.category,
1647
+ source: candidate.source,
1648
+ passed: true,
1649
+ skipped: true,
1650
+ skipReason: 'unaffected_daemon_scope',
1651
+ });
1652
+ continue;
1653
+ }
1654
+ commandsToRun.push(candidate);
1655
+ }
1656
+ if (opts?.changeImpact) {
1657
+ summary.changeImpact = {
1658
+ isDaemonAffecting: opts.changeImpact.isDaemonAffecting,
1659
+ affectedPackages: opts.changeImpact.affectedPackages,
1660
+ ...(skippedDaemonCommands.length ? { skippedDaemonCommands } : {}),
1661
+ };
1662
+ }
1577
1663
 
1578
1664
  if (runLegacyBootstrapCommands) {
1579
1665
  summary.bootstrap = { stage: 'legacy' };
@@ -1619,23 +1705,31 @@ export async function runMeshRefineValidationGate(
1619
1705
  }
1620
1706
  }
1621
1707
 
1622
- for (const candidate of selection.commands) {
1708
+ // (b) Track a genuine missing-deps block for an AFFECTED command. Instead of
1709
+ // aborting the whole gate at the first missing-deps hit (which also killed
1710
+ // trailing no-dep commands like check-vendor-drift.mjs), we mark the blocked
1711
+ // command and CONTINUE evaluating the rest: commands whose deps are present, or
1712
+ // which need no deps at all, still run. missing_dependencies only becomes the
1713
+ // gate failure if at least one command that truly needed deps could not run.
1714
+ let missingDepsBlocked = false;
1715
+ for (const candidate of commandsToRun) {
1623
1716
  const startedAt = Date.now();
1624
1717
  const cwd = candidate.cwd ? pathResolve(workspace, candidate.cwd) : workspace;
1625
1718
  const timeout = candidate.timeoutMs || REFINE_VALIDATION_TIMEOUT_MS;
1626
1719
  const bootstrapProvidedDependencies = summary.bootstrap?.stage === 'cached' || summary.bootstrap?.stage === 'ran' || summary.bootstrap?.stage === 'legacy';
1627
- if (!bootstrapProvidedDependencies && isPackageManagerValidation(candidate) && dependenciesLikelyMissing(cwd)) {
1720
+ if (!bootstrapProvidedDependencies && needsNodeModules(candidate, cwd)) {
1721
+ // This command genuinely needs node_modules that are absent. Mark it
1722
+ // blocked, but do NOT abort — a following no-dep command (or one in a
1723
+ // different cwd that DOES have deps) must still get its chance to run.
1628
1724
  summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, {
1629
- stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands in repo mesh/refine config if Refinery should install/bootstrap before validation.',
1725
+ stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands (or .adhdev/worktree_bootstrap.json) in repo mesh/refine config if Refinery should install/bootstrap before validation.',
1630
1726
  }, false, {
1631
1727
  exitCode: null,
1632
1728
  skipped: true,
1633
1729
  failureKind: 'missing_dependencies',
1634
1730
  }));
1635
- summary.status = 'failed';
1636
- summary.failureKind = 'missing_dependencies';
1637
- summary.failureCode = 'missing_dependencies';
1638
- return summary;
1731
+ missingDepsBlocked = true;
1732
+ continue;
1639
1733
  }
1640
1734
  // See the bootstrap loop above: resolve the win32 .cmd shim to an
1641
1735
  // absolute path before handing it to the spawn boundary.
@@ -1681,6 +1775,18 @@ export async function runMeshRefineValidationGate(
1681
1775
  }
1682
1776
  }
1683
1777
 
1778
+ // (b) A command that genuinely needed deps could not run. Surface it as the
1779
+ // gate failure now (after letting no-dep / deps-present commands run), so the
1780
+ // caller can classify it blocked_review and emit a self-service hint. Every
1781
+ // daemon-scoped command in a web-only branch was already filtered above, so a
1782
+ // missing-deps block here is a real affected-command block.
1783
+ if (missingDepsBlocked) {
1784
+ summary.status = 'failed';
1785
+ summary.failureKind = 'missing_dependencies';
1786
+ summary.failureCode = 'missing_dependencies';
1787
+ return summary;
1788
+ }
1789
+
1684
1790
  summary.status = 'passed';
1685
1791
  return summary;
1686
1792
  }