@praxisflux/gates 0.59.3 → 0.59.6

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.
@@ -14,6 +14,14 @@ export function hasChild(name) {
14
14
  return (dir) => existsSync(join(dir, name));
15
15
  }
16
16
 
17
+ /** Combine markers: true when ANY of `names` exists as a child. One definition composed
18
+ * from `hasChild`, so every caller that needs "either sentinel marks a project" (e.g. the
19
+ * spec-bridge Stop hook and its CLI, which must never disagree about what a project is)
20
+ * shares it instead of duplicating the predicate inline at each call site. */
21
+ export function hasAnyChild(...names) {
22
+ return (dir) => names.some((n) => hasChild(n)(dir));
23
+ }
24
+
17
25
  /**
18
26
  * Walk up from `startDir` until `markerFn(dir)` is truthy. Returns the matching
19
27
  * directory (absolute) or null if the filesystem root is reached without a match.
@@ -14,6 +14,14 @@ export function hasChild(name) {
14
14
  return (dir) => existsSync(join(dir, name));
15
15
  }
16
16
 
17
+ /** Combine markers: true when ANY of `names` exists as a child. One definition composed
18
+ * from `hasChild`, so every caller that needs "either sentinel marks a project" (e.g. the
19
+ * spec-bridge Stop hook and its CLI, which must never disagree about what a project is)
20
+ * shares it instead of duplicating the predicate inline at each call site. */
21
+ export function hasAnyChild(...names) {
22
+ return (dir) => names.some((n) => hasChild(n)(dir));
23
+ }
24
+
17
25
  /**
18
26
  * Walk up from `startDir` until `markerFn(dir)` is truthy. Returns the matching
19
27
  * directory (absolute) or null if the filesystem root is reached without a match.
@@ -14,6 +14,14 @@ export function hasChild(name) {
14
14
  return (dir) => existsSync(join(dir, name));
15
15
  }
16
16
 
17
+ /** Combine markers: true when ANY of `names` exists as a child. One definition composed
18
+ * from `hasChild`, so every caller that needs "either sentinel marks a project" (e.g. the
19
+ * spec-bridge Stop hook and its CLI, which must never disagree about what a project is)
20
+ * shares it instead of duplicating the predicate inline at each call site. */
21
+ export function hasAnyChild(...names) {
22
+ return (dir) => names.some((n) => hasChild(n)(dir));
23
+ }
24
+
17
25
  /**
18
26
  * Walk up from `startDir` until `markerFn(dir)` is truthy. Returns the matching
19
27
  * directory (absolute) or null if the filesystem root is reached without a match.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.59.3",
3
+ "version": "0.59.6",
4
4
  "description": "praxisflux gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,8 +22,8 @@ import { existsSync, readFileSync } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { spawnSync } from "node:child_process";
24
24
  import { deriveSpecState, STATUS, STAGE, STAGES } from "../lib/spec-derive.mjs";
25
- import { hasChild, findRootsDownwards } from "../lib/project-root.mjs";
26
- import { parseLinkedTask, findLinkedTasks } from "../lib/board-mirror.mjs";
25
+ import { hasAnyChild, findRootsDownwards } from "../lib/project-root.mjs";
26
+ import { parseLinkedTask, findLinkedTasks, readMirror, providers, mirrorStaleness } from "../lib/board-mirror.mjs";
27
27
 
28
28
  /**
29
29
  * Per-project bridge config: `.spec-bridge.json` at the project root (beside backlog/).
@@ -264,6 +264,44 @@ const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_E
264
264
  // every existing import site still resolves.
265
265
  export { parseLinkedTask, findLinkedTasks };
266
266
 
267
+ /**
268
+ * The one seam through which the bridge learns what's on the board (spec 053 R1). Resolution
269
+ * order, MIRROR FIRST:
270
+ * 1. `.board/links.json` present → its `links` (readMirror throws fail-closed on a
271
+ * malformed/unknown-schema mirror; that throw is left to propagate so gate-runner.mjs
272
+ * surfaces it as a blocking problem, never a silently empty board).
273
+ * 2. No mirror, but backlog/tasks/ present → project it live via `providers.backlog.project`
274
+ * — the backward-compatibility path: a host that never adopts the mirror keeps working,
275
+ * byte-identically, forever.
276
+ * 3. Neither → [].
277
+ * Mirror-first is deliberate, not incidental: a host that HAS adopted the mirror must be
278
+ * answered from that receipt, not from a live rescan of backlog/tasks/ — otherwise an adopted
279
+ * mirror is never actually exercised by the bridge, and 052's `--check` drift detection would
280
+ * be guarding a file nothing reads.
281
+ */
282
+ export function boardLinks(root) {
283
+ const mirror = readMirror(root);
284
+ if (mirror) return mirror.links;
285
+ if (existsSync(join(root, "backlog", "tasks"))) return providers.backlog.project(root);
286
+ return [];
287
+ }
288
+
289
+ /**
290
+ * `.board.json` (spec 054, not yet merged) declares which provider a host uses. Read
291
+ * defensively — absent, unreadable, or malformed all collapse to `"backlog"` — so this spec
292
+ * lands and tests before 054 merges: `"backlog"` is the one provider that has ever worked
293
+ * without a mirror at all (boardLinks' live-projection fallback), so a host that predates
294
+ * `.board.json` entirely is judged exactly as it always was.
295
+ */
296
+ function declaredProvider(root) {
297
+ try {
298
+ const raw = JSON.parse(readFileSync(join(root, ".board.json"), "utf8"));
299
+ return typeof raw?.provider === "string" && raw.provider ? raw.provider : "backlog";
300
+ } catch {
301
+ return "backlog";
302
+ }
303
+ }
304
+
267
305
  /** Compare a task's Backlog status to its derived status: "exceeds" | "lags" | "ok" | "unknown". */
268
306
  export function verdict(taskStatus, derivedStatus) {
269
307
  const t = RANK[String(taskStatus).toLowerCase()];
@@ -316,6 +354,46 @@ export function checkBridge(root, { runGates = true, run } = {}) {
316
354
  const requireAnalysis = config.strictDone === true;
317
355
  const profile = vocabularyProfile(config);
318
356
  const gatesProfile = projectGatesProfile(config);
357
+
358
+ // R3/R4 (spec 053 phase 2): fail-closed board-evidence findings. Computed once per call,
359
+ // independent of any particular linked task — the finding is about whether evidence EXISTS
360
+ // at all, not about a task's derived state. `readMirror` throws on a malformed/unknown-schema
361
+ // mirror; that throw is left to propagate (boardLinks() below re-reads it for the same
362
+ // reason) so gate-runner.mjs's evaluate() surfaces it as a blocking problem — never a
363
+ // silently empty board (lib/gate-runner.mjs :52-54).
364
+ const mirror = readMirror(root);
365
+ if (mirror) {
366
+ // R3: for a requiresSync:true provider (e.g. Jira) the mirror IS the evidence, so a stale
367
+ // one blocks — name the reason AND the remedy so the reader never needs a second lookup.
368
+ // For requiresSync:false (Backlog) a stale mirror is deliberately NOT blocking: boardLinks()
369
+ // step 2's live backlog/tasks/ projection is preferred over the stale receipt, so the gate
370
+ // recomputes instead of complaining. Same staleness fact, opposite consequence — this
371
+ // asymmetry is R3's point, not an inconsistency to "fix" into symmetry.
372
+ const providerInfo = providers[mirror.provider];
373
+ const requiresSync = providerInfo ? providerInfo.requiresSync : true; // unknown name: fail closed
374
+ if (requiresSync) {
375
+ const { stale, reason } = mirrorStaleness(root, mirror);
376
+ if (stale) {
377
+ problems.push(
378
+ `[spec-bridge] board mirror is stale (${reason}) — run the board:sync skill to refresh .board/links.json before claiming status.`
379
+ );
380
+ }
381
+ }
382
+ } else {
383
+ // R4: no mirror at all. `.board.json` declares which provider the host uses (absence =
384
+ // "backlog", the only provider that has ever worked mirror-less). A requiresSync provider
385
+ // with no mirror has NO evidence for this gate to check — that is the fail-closed finding
386
+ // this whole spec exists to add, never a silently empty board.
387
+ const provider = declaredProvider(root);
388
+ const providerInfo = providers[provider];
389
+ const requiresSync = providerInfo ? providerInfo.requiresSync : true;
390
+ if (requiresSync) {
391
+ problems.push(
392
+ `[spec-bridge] provider "${provider}" is declared but .board/links.json is missing — the gate has no board evidence to check. Run the board:sync skill.`
393
+ );
394
+ }
395
+ }
396
+
319
397
  // Run declared project gates only when they're opted into and the caller asked for it (the Stop
320
398
  // hook runs them in `check` but not the duplicate `warn` pass). SPEC_BRIDGE_GATE_ACTIVE, set on
321
399
  // every child runGateCommand spawns, is the reentrancy guard: a host gate command that itself
@@ -327,7 +405,7 @@ export function checkBridge(root, { runGates = true, run } = {}) {
327
405
  const injected = run !== undefined;
328
406
  const execGates = runGates && !!gatesProfile && (injected || process.env.SPEC_BRIDGE_GATE_ACTIVE !== "1");
329
407
  const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root })));
330
- for (const task of findLinkedTasks(root)) {
408
+ for (const task of boardLinks(root)) {
331
409
  const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
332
410
  // Opted-in boards are judged on the stage ladder against their own status names;
333
411
  // everyone else gets the 3-status comparison, untouched.
@@ -403,7 +481,7 @@ export function verifyBridge(root, { run } = {}) {
403
481
  const requireAnalysis = config.strictDone === true;
404
482
  // Share each distinct gate result across every spec this invocation checks (spec 050 defect 2).
405
483
  const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root })));
406
- for (const task of findLinkedTasks(root)) {
484
+ for (const task of boardLinks(root)) {
407
485
  const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
408
486
  const anyTicked = (derived.phaseBoxes || []).some((p) => (p.boxes || []).some((b) => b.checked));
409
487
  if (!anyTicked) continue; // nothing claims greenness yet — no tick to outrun a gate
@@ -419,7 +497,7 @@ export function verifyBridge(root, { run } = {}) {
419
497
  return problems;
420
498
  }
421
499
 
422
- /* ── plan: the exact backlog edits that reconcile the board ────────────── */
500
+ /* ── plan: reconciliation intents, and the Backlog renderer for them ───── */
423
501
 
424
502
  const PHASE_PREFIX = "Spec phase: ";
425
503
 
@@ -427,33 +505,32 @@ const PHASE_PREFIX = "Spec phase: ";
427
505
  const sq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
428
506
 
429
507
  /**
430
- * Pure planner for ONE linked task: the ordered `backlog task edit` commands that reconcile
431
- * it to its derived state. Command order matters and is: status move → stale phase-AC
432
- * removals (highest index first, so earlier indexes stay valid) phase-AC additions
433
- * check/uncheck at post-edit indexes one progress note (only when something changed).
434
- * ACs that don't start with "Spec phase: " are human-authored and are never touched.
508
+ * Pure reconciliation for ONE linked task (spec 053 R5): the board-neutral INTENT that would
509
+ * bring it in line with its derived state decided here, rendered nowhere. ALL the ordering
510
+ * logic lives here, not in a renderer: stale phase-AC removals highest-index-first (so
511
+ * earlier indexes stay valid while they're being removed), check/uncheck computed at
512
+ * POST-EDIT indexes (after removals and additions have already renumbered the list). That
513
+ * ordering is load-bearing and hard-won — it is reconciliation, not rendering, which is
514
+ * exactly why the split puts it on this side of the line. ACs that don't start with
515
+ * "Spec phase: " are human-authored and are never touched.
516
+ *
517
+ * Shape (spec 053 R5's minimum, so a future provider's renderer has something to render):
518
+ * { id, statusFrom, statusTo, finalSummary, acRemove, acAdd, acCheck, acUncheck, note }
519
+ * `statusTo` / `finalSummary` / `note` are null when nothing moves on that axis.
435
520
  *
436
521
  * With an opted-in vocabulary profile (third argument), status targets are the profile's
437
522
  * stage names instead of the 3-status collapse. Done keeps its meaning: a board that leaves
438
- * "reviewing" at its default still plans `-s Done` with the derived final summary; a board
439
- * that names it (say "In Review") is planned to that name, and moving to Done stays a
440
- * human/consumer act the gate already accepts (Done never exceeds a fully-proven spec).
523
+ * "reviewing" at its default still resolves `statusTo: "Done"` with the derived final
524
+ * summary; a board that names it (say "In Review") resolves to that name, and moving to Done
525
+ * stays a human/consumer act the gate already accepts (Done never exceeds a fully-proven spec).
441
526
  */
442
- export function planLinkedTask(task, derived, profile = null) {
443
- const cmds = [];
444
- const edit = (args) => cmds.push(`backlog task edit ${task.id} ${args}`);
445
-
527
+ export function planIntents(task, derived, profile = null) {
446
528
  // Status — Done-eligible is the only path to Done and carries the derived final summary.
447
529
  const mapped = profile ? profile.names[derived.stage] : null;
448
530
  const target = derived.status === STATUS.DONE_ELIGIBLE
449
531
  ? (mapped && mapped.toLowerCase() !== "done" ? mapped : "Done")
450
532
  : (mapped ?? derived.status);
451
533
  const statusChanged = String(task.status).toLowerCase() !== target.toLowerCase();
452
- if (statusChanged) {
453
- if (target === "Done")
454
- edit(`-s ${sq("Done")} --final-summary ${sq(`All spec tasks complete (${derived.progressNote}). Derived Done by spec-bridge sync.`)}`);
455
- else edit(`-s ${sq(target)}`);
456
- }
457
534
 
458
535
  // Phase-AC reconciliation. The bridge owns exactly the "Spec phase: " ACs.
459
536
  const phaseByName = new Map((derived.phases || []).map((p) => [p.name, p]));
@@ -465,10 +542,10 @@ export function planLinkedTask(task, derived, profile = null) {
465
542
  if (!phaseByName.has(name) || seen.has(name)) removed.add(a.index); // stale or duplicate
466
543
  else seen.add(name);
467
544
  }
468
- for (const i of [...removed].sort((a, z) => z - a)) edit(`--remove-ac ${i}`);
545
+ const acRemove = [...removed].sort((a, z) => z - a); // highest index first, so earlier indexes stay valid
469
546
 
470
547
  const additions = (derived.phases || []).filter((p) => !seen.has(p.name));
471
- for (const p of additions) edit(`--ac ${sq(PHASE_PREFIX + p.name)}`);
548
+ const acAdd = additions.map((p) => PHASE_PREFIX + p.name);
472
549
 
473
550
  // Post-edit indexes: survivors keep their relative order and renumber from 1; additions
474
551
  // append after them, unchecked. Check/uncheck against what each phase actually proves.
@@ -477,42 +554,106 @@ export function planLinkedTask(task, derived, profile = null) {
477
554
  ...survivors.map((a) => ({ text: a.text, checked: a.checked })),
478
555
  ...additions.map((p) => ({ text: PHASE_PREFIX + p.name, checked: false })),
479
556
  ];
557
+ const acCheck = [];
558
+ const acUncheck = [];
480
559
  finalList.forEach((item, i) => {
481
560
  if (!item.text.startsWith(PHASE_PREFIX)) return; // human-authored: never touched
482
561
  const p = phaseByName.get(item.text.slice(PHASE_PREFIX.length));
483
562
  if (!p) return;
484
563
  const want = p.total > 0 && p.done === p.total;
485
- if (want && !item.checked) edit(`--check-ac ${i + 1}`);
486
- else if (!want && item.checked) edit(`--uncheck-ac ${i + 1}`);
564
+ if (want && !item.checked) acCheck.push(i + 1);
565
+ else if (!want && item.checked) acUncheck.push(i + 1);
487
566
  });
488
567
 
489
- // One note, only when this sync changed something — no churn in the task history.
490
- if (cmds.length) {
491
- const suffix = statusChanged ? ` — status ${task.status} → ${target}` : "";
492
- edit(`--append-notes ${sq(`spec-bridge sync: ${derived.progressNote}${suffix}`)}`);
568
+ // Nothing to note when nothing changed — no churn in the task history.
569
+ const changed = statusChanged || acRemove.length > 0 || acAdd.length > 0 || acCheck.length > 0 || acUncheck.length > 0;
570
+ const suffix = statusChanged ? ` — status ${task.status} → ${target}` : "";
571
+
572
+ return {
573
+ id: task.id,
574
+ statusFrom: task.status,
575
+ statusTo: statusChanged ? target : null,
576
+ finalSummary: statusChanged && target === "Done"
577
+ ? `All spec tasks complete (${derived.progressNote}). Derived Done by spec-bridge sync.`
578
+ : null,
579
+ acRemove, acAdd, acCheck, acUncheck,
580
+ note: changed ? `spec-bridge sync: ${derived.progressNote}${suffix}` : null,
581
+ };
582
+ }
583
+
584
+ /**
585
+ * Render one task's intents as the exact `backlog task edit …` command strings — today's
586
+ * literal bytes (spec 053 R5). Order: status move → AC removals → AC additions → check →
587
+ * uncheck → one append-notes. `id` is taken as a parameter rather than read off `intents` so
588
+ * a renderer never has to trust the blob it's handed.
589
+ */
590
+ export function renderBacklog(id, intents) {
591
+ const cmds = [];
592
+ const edit = (args) => cmds.push(`backlog task edit ${id} ${args}`);
593
+ if (intents.statusTo) {
594
+ if (intents.statusTo === "Done") edit(`-s ${sq("Done")} --final-summary ${sq(intents.finalSummary)}`);
595
+ else edit(`-s ${sq(intents.statusTo)}`);
493
596
  }
597
+ for (const i of intents.acRemove) edit(`--remove-ac ${i}`);
598
+ for (const text of intents.acAdd) edit(`--ac ${sq(text)}`);
599
+ for (const i of intents.acCheck) edit(`--check-ac ${i}`);
600
+ for (const i of intents.acUncheck) edit(`--uncheck-ac ${i}`);
601
+ if (intents.note) edit(`--append-notes ${sq(intents.note)}`);
494
602
  return cmds;
495
603
  }
496
604
 
497
605
  /**
498
- * Plan every linked task under <root>, in queue order. Returns:
499
- * commands ordered `backlog task edit` lines; empty on a reconciled board (no-op)
500
- * skipped — [{ id, status }] for verdict-unknown tasks (custom status: don't guess)
501
- * Read-only like everything in gates/: plan PRINTS edits, it never executes them.
606
+ * Backward-compatible single-shot planner: `planIntents` then `renderBacklog` in one call.
607
+ * Every call site inside this module now goes through the two halves directly; this wrapper
608
+ * exists only because it is still a public, imported symbol (spec 053 AC #9).
609
+ */
610
+ export function planLinkedTask(task, derived, profile = null) {
611
+ return renderBacklog(task.id, planIntents(task, derived, profile));
612
+ }
613
+
614
+ /**
615
+ * Which provider `planBridge` renders for (spec 053 R5): the mirror's own declared provider
616
+ * when a mirror is on disk (it's the artifact that exists, so it's the truth), `.board.json`'s
617
+ * declaration otherwise — the same present-artifact split `checkBridge`'s R3/R4 block uses,
618
+ * for the same reason: don't let a config file override live mirror evidence when both exist.
619
+ */
620
+ function resolvedProvider(root) {
621
+ const mirror = readMirror(root);
622
+ return mirror ? mirror.provider : declaredProvider(root);
623
+ }
624
+
625
+ /**
626
+ * Plan every linked task under <root>, in queue order (spec 053 R5). `skipped` — [{ id,
627
+ * status }] for verdict-unknown tasks (custom status: don't guess) — is returned either way.
628
+ * For the `backlog` provider: `{ commands, skipped }`, today's exact `backlog task edit`
629
+ * strings, byte-identical to before the split (empty `commands` on a reconciled board is
630
+ * still a no-op). For any other provider: `{ intents, skipped, notice }` — the same
631
+ * reconciliation as structured intents, plus a notice that command rendering is
632
+ * provider-specific (spec 055 owns that verb table; guessing it here would mean writing it
633
+ * twice). Read-only like everything in gates/: plan computes edits, it never executes them.
502
634
  */
503
635
  export function planBridge(root) {
504
- const commands = [];
505
636
  const skipped = [];
637
+ const perTask = [];
506
638
  const config = loadBridgeConfig(root);
507
639
  const requireAnalysis = config.strictDone === true;
508
640
  const profile = vocabularyProfile(config);
509
- for (const task of findLinkedTasks(root)) {
641
+ for (const task of boardLinks(root)) {
510
642
  const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
511
643
  const v = profile ? stageVerdict(task.status, derived.stage, profile) : verdict(task.status, derived.status);
512
644
  if (v === "unknown") { skipped.push({ id: task.id, status: task.status }); continue; }
513
- commands.push(...planLinkedTask(task, derived, profile));
645
+ perTask.push(planIntents(task, derived, profile));
514
646
  }
515
- return { commands, skipped };
647
+ if (resolvedProvider(root) === "backlog") {
648
+ return { commands: perTask.flatMap((intents) => renderBacklog(intents.id, intents)), skipped };
649
+ }
650
+ return {
651
+ intents: perTask,
652
+ skipped,
653
+ notice:
654
+ "command rendering is provider-specific — \"backlog\" is the only rendered provider today " +
655
+ "(spec 055 owns the verb table for the rest); these are structured intents, not commands.",
656
+ };
516
657
  }
517
658
 
518
659
  /**
@@ -522,7 +663,7 @@ export function planBridge(root) {
522
663
  */
523
664
  export const bridgeGate = {
524
665
  name: "spec-bridge",
525
- resolveRoots: (startDir) => findRootsDownwards(startDir, hasChild("backlog")),
666
+ resolveRoots: (startDir) => findRootsDownwards(startDir, hasAnyChild(".board", "backlog")),
526
667
  // The runner calls check() then warn() per root; run the (possibly costly) project-gate
527
668
  // commands only in check so a Stop pays for them once, not twice. Warnings never depend on
528
669
  // gate execution, so runGates:false loses nothing.
@@ -13,7 +13,7 @@
13
13
  // board). Prints, NEVER executes — the sync skill runs them.
14
14
  import { resolve } from "node:path";
15
15
  import { deriveSpecState } from "../lib/spec-derive.mjs";
16
- import { findRootUpwards, hasChild } from "../lib/project-root.mjs";
16
+ import { findRootUpwards, hasAnyChild } from "../lib/project-root.mjs";
17
17
  import { checkBridge, loadBridgeConfig, planBridge, verifyBridge, vocabularyProfile } from "./bridge.mjs";
18
18
 
19
19
  const [cmd, target] = process.argv.slice(2);
@@ -24,7 +24,9 @@ if (!cmd || !target) {
24
24
 
25
25
  if (cmd === "state") {
26
26
  // Honor the project's .spec-bridge.json (strictDone) — same config checkBridge uses.
27
- const root = findRootUpwards(resolve(target), hasChild("backlog"));
27
+ // hasAnyChild(".board", "backlog") in lockstep with bridgeGate.resolveRoots (bridge.mjs)
28
+ // the hook and this CLI must never disagree about what a project is (spec 053 R2).
29
+ const root = findRootUpwards(resolve(target), hasAnyChild(".board", "backlog"));
28
30
  const requireAnalysis = root ? loadBridgeConfig(root).strictDone === true : false;
29
31
  console.log(JSON.stringify(deriveSpecState(target, { requireAnalysis }), null, 2));
30
32
  } else if (cmd === "links") {
@@ -14,6 +14,14 @@ export function hasChild(name) {
14
14
  return (dir) => existsSync(join(dir, name));
15
15
  }
16
16
 
17
+ /** Combine markers: true when ANY of `names` exists as a child. One definition composed
18
+ * from `hasChild`, so every caller that needs "either sentinel marks a project" (e.g. the
19
+ * spec-bridge Stop hook and its CLI, which must never disagree about what a project is)
20
+ * shares it instead of duplicating the predicate inline at each call site. */
21
+ export function hasAnyChild(...names) {
22
+ return (dir) => names.some((n) => hasChild(n)(dir));
23
+ }
24
+
17
25
  /**
18
26
  * Walk up from `startDir` until `markerFn(dir)` is truthy. Returns the matching
19
27
  * directory (absolute) or null if the filesystem root is reached without a match.