@bridge4dev/runner 0.52.0 → 0.54.0

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.
package/dist/index.js CHANGED
@@ -17,12 +17,14 @@ import { Supervisor } from './supervisor.js';
17
17
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
18
18
  import { RunnerWsClient } from './ws-client.js';
19
19
  import { RUNNER_VERSION } from './version.js';
20
- import { buildUnit, cpuQuotaPercent, limitsOverrideIsOutdated, limitsOverridePath, memoryPolicy, readMemoryFacts, unitExecTarget, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
20
+ import { buildUnit, cpuQuotaPercent, devbridgeSliceOverridePath, limitsOverrideIsOutdated, limitsOverridePath, memoryPolicy, readMemoryFacts, sessionsSliceOverridePath, unitExecTarget, unitPath, writeLimitsOverride, DEVBRIDGE_SLICE, LIMITS_VERSION, SESSION_CPU_WEIGHT, SESSIONS_SLICE, } from './service-unit.js';
21
+ import { initSessionCage, listSessionScopes, sessionCage, sweepOrphanSessionScopes, SESSION_TASKS_MAX, } from './session-cage.js';
21
22
  import { SEARCH_GUARD_ENABLED, claudeSettingsPath, installSearchGuard, removeSearchGuard, searchGuardCommand, searchGuardHome, searchGuardHookPath, searchGuardStatus, } from './claude-settings.js';
22
23
  import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
23
24
  import { agentAuthStatuses } from './auth-relay.js';
24
25
  import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, systemdUserEnv, } from './environment.js';
25
26
  import { mcpConfigDir } from './paths.js';
27
+ import { readMemoryFactsFromSystemd } from './systemd-memory.js';
26
28
  const execFileAsync = promisify(execFile);
27
29
  /**
28
30
  * Identity of this process, and the note the previous one left.
@@ -405,6 +407,37 @@ function runnerCapabilities(apiUrlOverride) {
405
407
  // Facts passed on purpose: since 0.39.0 «current» also means the measured
406
408
  // ceiling still fits this machine, not just that the version matches.
407
409
  limitsCurrent: !limitsOverrideIsOutdated(undefined, undefined, readMemoryFacts()),
410
+ /**
411
+ * How the sessions on this machine are actually contained (plan §5.4.3).
412
+ *
413
+ * `scope` — a cgroup and a memory ceiling per session; `nice-only` — the
414
+ * machine cannot do that (cgroup v1, no user bus, a foreign supervisor)
415
+ * and only the CPU priority applies; `none` — not even that.
416
+ *
417
+ * Reported rather than assumed because the containment fails QUIETLY:
418
+ * `systemd-run` accepts every limit on a cgroup v1 machine and applies
419
+ * none of them. Without this field «it did not work at the client's» is
420
+ * something to be guessed at; with it the card can say so.
421
+ */
422
+ sessionCage: sessionCage().mode,
423
+ // Why not `scope`, in one sentence, for the card to show under it.
424
+ // Empty string when the cage is on.
425
+ sessionCageReason: sessionCage().reason,
426
+ // What ONE session may hold, in bytes. Null when there is no cage —
427
+ // then the only ceiling is the service's, over all of them together.
428
+ sessionMemoryMaxBytes: sessionCage().memoryMaxBytes,
429
+ /**
430
+ * The ceiling over ALL sessions, as systemd has it in force — not as the
431
+ * drop-in on disk says.
432
+ *
433
+ * The live probe proves the personal ceiling and nothing else: a
434
+ * `MemoryMax` on a scope applies whatever the parent slice holds. So a
435
+ * machine whose `daemon-reload` never landed reported `scope` and
436
+ * `limitsCurrent: true` while three sessions of 2.5 GB ran over a slice
437
+ * at `infinity`, and the card said «Sessions capped»
438
+ * (QA-2026-09-07 MAJOR-3). Null is that machine, and the card says so.
439
+ */
440
+ sessionsMemoryMaxBytes: sessionCage().sessionsSliceMemoryMaxBytes,
408
441
  },
409
442
  /**
410
443
  * Identity of THIS process, so the API can tell a network blink from a
@@ -482,6 +515,11 @@ async function cmdPair(args) {
482
515
  }
483
516
  const apiUrl = (argValue(args, '--api') ?? 'https://api.bridge4.dev').replace(/\/$/, '');
484
517
  const name = argValue(args, '--name') ?? os.hostname();
518
+ // Probed here as well as at daemon start, so the very first record of this
519
+ // server already carries the truth about its containment instead of the safe
520
+ // default. Costs one throwaway process, once.
521
+ await initSessionCage();
522
+ const capabilities = runnerCapabilities(apiUrl);
485
523
  const response = await fetch(`${apiUrl}/api/v1/dev/servers/claim`, {
486
524
  method: 'POST',
487
525
  headers: { 'content-type': 'application/json' },
@@ -490,7 +528,7 @@ async function cmdPair(args) {
490
528
  name,
491
529
  runnerVersion: RUNNER_VERSION,
492
530
  osInfo: `${os.type()} ${os.release()} ${os.arch()}`.slice(0, 200),
493
- capabilities: runnerCapabilities(apiUrl),
531
+ capabilities,
494
532
  }),
495
533
  });
496
534
  const body = (await response.json().catch(() => null));
@@ -553,8 +591,17 @@ const RESTART_DELAY_MS = 1_500;
553
591
  */
554
592
  async function repairResourceLimits() {
555
593
  try {
556
- const facts = readMemoryFacts();
557
- if (facts && memoryPolicy(facts).starved) {
594
+ const { facts, sessionsUsageBytes } = await readMemoryFactsFromSystemd();
595
+ if (facts === null) {
596
+ // Neither systemd nor the cgroup files could answer, which on the daemon's
597
+ // own path means the machine is in a shape this policy cannot reason about
598
+ // at all. Leaving what is in force in force is the safe half of the
599
+ // decision; the loud line is the other half, because the alternative is a
600
+ // machine that silently keeps an old ceiling forever.
601
+ log.warn('daemon: resource limits unchanged — memory usage could not be measured; retrying in an hour', { recheckMs: LIMITS_RECHECK_MS });
602
+ return;
603
+ }
604
+ if (memoryPolicy(facts).starved) {
558
605
  // The only signal the machine's owner will ever get that the neighbours
559
606
  // have taken the box: the honest headroom was below the 2 GB floor, so the
560
607
  // ceiling we are about to write sits above what is actually free.
@@ -563,12 +610,12 @@ async function repairResourceLimits() {
563
610
  totalMB: Math.round(facts.totalBytes / 1048576),
564
611
  });
565
612
  }
566
- if (!writeLimitsOverride(false, undefined, facts))
613
+ if (!writeLimitsOverride(false, undefined, facts, sessionsUsageBytes))
567
614
  return;
568
615
  log.warn('daemon: resource limits drop-in written — reloading systemd', {
569
616
  path: limitsOverridePath(),
570
617
  version: LIMITS_VERSION,
571
- ...(facts ? memoryPolicy(facts) : {}),
618
+ ...memoryPolicy(facts),
572
619
  });
573
620
  await execFileAsync('systemctl', ['--user', 'daemon-reload'], {
574
621
  timeout: 15_000,
@@ -657,33 +704,6 @@ function repairSearchGuard() {
657
704
  * check writes nothing when the version matches and the ceiling still fits.
658
705
  */
659
706
  const LIMITS_RECHECK_MS = 3_600_000;
660
- /**
661
- * What the SERVICE's cgroup holds, for the paths that are not the service.
662
- *
663
- * `readMemoryFacts()` defaults to reading `/proc/self`, which is right for the
664
- * daemon and wrong for everything a person types: `doctor --fix` and
665
- * `install-service` run in the operator's own `session-N.scope`, worth a few MB.
666
- * Measuring that and calling it «what the runner holds» made the CLI compute a
667
- * ceiling 34 % away from the daemon's answer on a real host — each path then saw
668
- * the other as drift and rewrote the file, forever.
669
- *
670
- * `MemoryCurrent` is `memory.current`, so it still counts page cache the way
671
- * `MemAvailable` does. Subtracting it is not worth a second systemd call here:
672
- * the CLI paths run once, by hand, and erring toward a LOWER ceiling is the safe
673
- * direction. Returns null when the service is not running or systemd cannot be
674
- * reached — the caller then has no facts, and writes nothing rather than writing
675
- * a ceiling with no floor under it.
676
- */
677
- async function serviceMemoryCurrent() {
678
- try {
679
- const { stdout } = await execFileAsync('systemctl', ['--user', 'show', 'devbridge-runner', '-p', 'MemoryCurrent', '--value'], { timeout: 10_000, env: systemdUserEnv() });
680
- const value = Number(stdout.trim());
681
- return Number.isFinite(value) && value > 0 ? value : null;
682
- }
683
- catch {
684
- return null;
685
- }
686
- }
687
707
  /**
688
708
  * Ticket #119. `ClaudeAdapter.stop()` removes each session's MCP config file,
689
709
  * but a kill -9, an OOM or a machine reboot leaves it behind — a live project
@@ -719,7 +739,17 @@ async function cmdDaemon() {
719
739
  const config = requireConfig();
720
740
  log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
721
741
  sweepOrphanedMcpConfigs();
742
+ // Nothing of ours is running yet, so every `devbridge-session-*.scope` on this
743
+ // machine belongs to a process that is gone. Stopping the scope takes the
744
+ // whole tree under it — which is the answer to the `ugrep` that outlived its
745
+ // session by 10 h 51 min on 16.08, and to every scope the OOM killer left in
746
+ // `failed` (a name systemd will otherwise refuse to reuse).
747
+ await sweepOrphanSessionScopes();
722
748
  await repairResourceLimits();
749
+ // After the drop-ins are on disk and reloaded, never before: the probe below
750
+ // creates `devbridge-sessions.slice`, and a slice first loaded without its
751
+ // policy would hold no ceiling until the next daemon-reload.
752
+ await initSessionCage();
723
753
  repairSearchGuard();
724
754
  // A Claude token this runner captured through the sign-in relay. Applied
725
755
  // BEFORE any adapter exists, because `scrubbedEnv()` copies it out of this
@@ -892,8 +922,29 @@ async function cmdInstallService() {
892
922
  // `buildLimitsOverride`. Forced here: a fresh install must have it even if a
893
923
  // file from an older runner is already sitting there. Facts come from the
894
924
  // service rather than from `/proc/self`, which here is the installing shell.
895
- writeLimitsOverride(true, undefined, readMemoryFacts(await serviceMemoryCurrent()));
896
- print(`Wrote ${limitsOverridePath()}`);
925
+ // The slice reading goes with them: re-installing over a machine that is
926
+ // running sessions right now must not write a ceiling under them.
927
+ //
928
+ // Unmeasurable is a WARNING here, not a refusal, and the order above is why:
929
+ // the unit is already on disk, so the machine gets a runner either way. The
930
+ // states that produce «unmeasurable» — a failed service, a service mid
931
+ // auto-restart, an unhappy user bus — are exactly the states someone runs
932
+ // `install.sh --repair` from, and a repair that installs nothing because it
933
+ // could not size a ceiling is a repair that does not repair.
934
+ const installMem = await readMemoryFactsFromSystemd();
935
+ if (installMem.facts === null) {
936
+ print('warning: could not measure this machine — resource limits were left as they are. ' +
937
+ 'Nothing was removed; the daemon re-measures hourly, or run `devbridge-runner doctor --fix`.');
938
+ }
939
+ else {
940
+ writeLimitsOverride(true, undefined, installMem.facts, installMem.sessionsUsageBytes);
941
+ print(`Wrote ${limitsOverridePath()}`);
942
+ // Two more files since 0.54.0: the sessions no longer live inside the
943
+ // service's cgroup, so the ceiling over all of them and the CPU share that
944
+ // keeps the daemon ahead of them belong to the slices (`session-cage.ts`).
945
+ print(`Wrote ${sessionsSliceOverridePath()}`);
946
+ print(`Wrote ${devbridgeSliceOverridePath()}`);
947
+ }
897
948
  // Привратник шаблонов поиска — та же логика, что и у политики ресурсов:
898
949
  // свежая установка должна получить его сразу. Пишем в ЧУЖОЙ файл настроек,
899
950
  // поэтому молча пропускаем, если тронуть его нельзя (`claude-settings.ts`).
@@ -1330,7 +1381,7 @@ async function cmdDoctor(args) {
1330
1381
  print(` fits sessions ~${advised} (at ~1.5 GB per session under load)`);
1331
1382
  print('');
1332
1383
  print('Service limits');
1333
- const memFacts = readMemoryFacts(await serviceMemoryCurrent());
1384
+ const { facts: memFacts, sessionsUsageBytes: memSessionsUsage } = await readMemoryFactsFromSystemd();
1334
1385
  const outdated = limitsOverrideIsOutdated(undefined, undefined, memFacts);
1335
1386
  print(` drop-in ${limitsOverridePath()}`);
1336
1387
  print(` version ${outdated ? `MISSING or OLD (want ${LIMITS_VERSION})` : LIMITS_VERSION}`);
@@ -1346,6 +1397,62 @@ async function cmdDoctor(args) {
1346
1397
  ? `${mb(memFacts.availableBytes)} available + ${mb(memFacts.ownUsageBytes)} ours`
1347
1398
  : `55% of ${mb(memFacts.totalBytes)} — still booting, remeasured on the next start`}`);
1348
1399
  }
1400
+ else {
1401
+ // Не молча: без фактов о машине политика не считается вовсе, и `--fix`
1402
+ // тогда НИЧЕГО не пишет — ни на сервис, ни на слайс. Строка про слайс
1403
+ // остаётся полезной сама по себе: она говорит, что стоит на кону, если
1404
+ // измерение так и не появится.
1405
+ print(" memory ceiling not computed — this machine's usage is unreadable");
1406
+ print(' --fix leaves every limits file exactly as it is');
1407
+ print(` sessions slice ${memSessionsUsage === null
1408
+ ? 'unreadable too'
1409
+ : memSessionsUsage === 0
1410
+ ? 'holds nothing right now'
1411
+ : `holds ${Math.round(memSessionsUsage / 1024 / 1024)} MB — a ceiling under that would kill it`}`);
1412
+ }
1413
+ // The cage, and the reason it matters more than the numbers above it: those
1414
+ // are the ceiling over the WHOLE service, and a session that has left the
1415
+ // service's cgroup is not under them any more. If this section says anything
1416
+ // but `scope`, the only containment on this machine is the CPU priority.
1417
+ const cage = await initSessionCage();
1418
+ print('');
1419
+ print('Session cage (a cgroup per session)');
1420
+ print(` mode ${cage.mode}`);
1421
+ if (cage.reason)
1422
+ print(` why ${cage.reason}`);
1423
+ print(` slice ${SESSIONS_SLICE} (under ${DEVBRIDGE_SLICE}, CPUWeight=${SESSION_CPU_WEIGHT})`);
1424
+ print(` drop-ins ${sessionsSliceOverridePath()}`);
1425
+ print(` ${devbridgeSliceOverridePath()}`);
1426
+ if (cage.mode === 'scope') {
1427
+ const mb = (bytes) => bytes === null ? 'infinity' : `${Math.round(bytes / 1024 / 1024)} MB`;
1428
+ print(` per session MemoryMax ${mb(cage.memoryMaxBytes)}, MemorySwapMax 0, TasksMax ${SESSION_TASKS_MAX}`);
1429
+ // What systemd has IN FORCE on the slice, beside the paths of the files
1430
+ // that were supposed to put it there. «В файле 7680M, действует 6.0G» is the
1431
+ // lesson of §5.5, and the drop-in of a slice can fail to land in exactly the
1432
+ // same way: a reload that never happened leaves every session personally
1433
+ // capped and collectively unlimited (QA-2026-09-07 MAJOR-3).
1434
+ print(` all sessions MemoryMax ${mb(cage.sessionsSliceMemoryMaxBytes)} in force on ${SESSIONS_SLICE}`);
1435
+ if (cage.sessionsSliceMemoryMaxBytes === null) {
1436
+ print(' NOT CAPPED — the drop-in above has not been applied.');
1437
+ print(` Apply with: ${systemctlHint('daemon-reload')}`);
1438
+ }
1439
+ print(` service ceiling ${mb(cage.serviceMemoryMaxBytes)} (the per-session figure is half of the containing ceiling, capped)`);
1440
+ print(` expand-env flag ${cage.expandEnvironmentFlag ? 'passed (systemd ≥ 254)' : 'not passed — this systemd does not know it, and --scope does not expand anyway'}`);
1441
+ }
1442
+ else {
1443
+ print(' per session none — only the CPU priority from `process-priority.ts` applies');
1444
+ }
1445
+ const scopes = await listSessionScopes();
1446
+ if (scopes.length === 0) {
1447
+ print(' live scopes none');
1448
+ }
1449
+ else {
1450
+ print(` live scopes ${scopes.length}`);
1451
+ for (const scope of scopes) {
1452
+ const mb = (bytes) => bytes === null ? '?' : `${Math.round(bytes / 1024 / 1024)} MB`;
1453
+ print(` ${scope.unit} ${scope.activeState ?? '?'} ${mb(scope.memoryCurrentBytes)} of ${mb(scope.memoryMaxBytes)} tasks ${scope.tasksCurrent ?? '?'}${scope.result && scope.result !== 'success' ? ` ${scope.result}` : ''}`);
1454
+ }
1455
+ }
1349
1456
  let effective;
1350
1457
  try {
1351
1458
  const { stdout } = await execFileAsync('systemctl', [
@@ -1372,6 +1479,19 @@ async function cmdDoctor(args) {
1372
1479
  print('Effective (systemd)');
1373
1480
  for (const line of effective)
1374
1481
  print(` ${line}`);
1482
+ // «В файле 7680M, действует 6.0G». Ровно это и получалось, пока самообновление
1483
+ // звало `systemctl --user daemon-reload` без `XDG_RUNTIME_DIR`: systemd отвечал
1484
+ // «Failed to connect to bus» и выходил с кодом 0, drop-in переписывался, а
1485
+ // systemd продолжал жить по старому юниту (план §5.5). Причину починили, но
1486
+ // расхождение может прийти и иначе — правкой юнита руками, оборванным
1487
+ // обновлением, — и без этой строки строки выше выглядят как то, что
1488
+ // действует, хотя это уже не так.
1489
+ if ((await systemctlProperty('NeedDaemonReload')) === 'yes') {
1490
+ print('');
1491
+ print('STALE — systemd has NOT re-read the unit since it changed on disk,');
1492
+ print(' so the numbers above are the OLD ones, not what the drop-in says.');
1493
+ print(` Apply with: ${systemctlHint('daemon-reload')}`);
1494
+ }
1375
1495
  // Привратник шаблонов поиска. Печатается всегда, в том числе когда он ВЫКЛЮЧЕН
1376
1496
  // в коде: «стоит, хотя выключен» — это и есть та машина, которую пропустило
1377
1497
  // обновление, и увидеть её больше неоткуда.
@@ -1438,10 +1558,24 @@ async function cmdDoctor(args) {
1438
1558
  process.exit(1);
1439
1559
  return;
1440
1560
  }
1441
- const fixFacts = readMemoryFacts(await serviceMemoryCurrent());
1442
- writeLimitsOverride(true, undefined, fixFacts);
1561
+ const { facts: fixFacts, sessionsUsageBytes: fixSessionsUsage } = await readMemoryFactsFromSystemd();
1443
1562
  print('');
1444
- print(`Wrote ${limitsOverridePath()}`);
1563
+ // Unmeasurable leaves the files exactly as they are and says so — the one
1564
+ // thing this must never do is replace a ceiling in force with a guess, or
1565
+ // rewrite the file without its `MemoryMax` line, which RESETS the ceiling.
1566
+ // The rest of the repair still runs: `doctor --fix` is also the search guard,
1567
+ // the unit file and the daemon-reload, and a machine that cannot be measured
1568
+ // this second still needs those. The non-zero exit at the end is what keeps
1569
+ // «could not do the main thing» from reading as success to automation.
1570
+ if (fixFacts === null) {
1571
+ print('Resource limits: LEFT UNCHANGED — this machine could not be measured.');
1572
+ print(' Nothing was removed. Re-run once `systemctl --user` answers again.');
1573
+ }
1574
+ else {
1575
+ // Keep the service and the sessions under the same measured policy.
1576
+ writeLimitsOverride(true, undefined, fixFacts, fixSessionsUsage);
1577
+ print(`Wrote ${limitsOverridePath()}`);
1578
+ }
1445
1579
  const guardOutcome = applySearchGuard(print);
1446
1580
  if (guardOutcome === 'installed') {
1447
1581
  print(`Installed search-pattern guard into ${claudeSettingsPath(searchGuardHome())}`);
@@ -1458,7 +1592,19 @@ async function cmdDoctor(args) {
1458
1592
  const applied = memoryPolicy(fixFacts);
1459
1593
  if (applied.maxBytes > honest.maxBytes) {
1460
1594
  const mb = (b) => Math.round(b / 1024 / 1024);
1461
- print(`note: ceiling raised to ${mb(applied.maxBytes)} MB — the service already holds ${mb(fixFacts.ownUsageBytes)} MB, and ${mb(honest.maxBytes)} MB would kill it on reload. Re-run when idle.`);
1595
+ // Which cgroup forced the ceiling up, not just «the service»: on a loaded
1596
+ // machine it is the sessions slice, and naming the service there sent the
1597
+ // reader looking at a daemon holding 400 MB (QA-2026-09-07 BLOCKER-1).
1598
+ // The SAME numbers the floor is computed from (`managedUsageFloorBytes`),
1599
+ // not the headroom ones: where the split is unknown those two differ, and
1600
+ // a note sourced from the wrong pair names the wrong cgroup and a smaller
1601
+ // number than the one that actually forced the ceiling up.
1602
+ const ownFloor = fixFacts.ownFloorBytes ?? fixFacts.ownUsageBytes;
1603
+ const sessionsFloor = fixFacts.sessionsFloorBytes ?? fixFacts.sessionsUsageBytes;
1604
+ const holder = sessionsFloor > ownFloor
1605
+ ? `the sessions on this machine already hold ${mb(sessionsFloor)} MB`
1606
+ : `the service already holds ${mb(ownFloor)} MB`;
1607
+ print(`note: ceiling raised to ${mb(applied.maxBytes)} MB — ${holder}, and ${mb(honest.maxBytes)} MB would kill it on reload. Re-run when idle.`);
1462
1608
  }
1463
1609
  }
1464
1610
  try {
@@ -1469,6 +1615,10 @@ async function cmdDoctor(args) {
1469
1615
  catch (error) {
1470
1616
  fail(`daemon-reload failed: ${String(error instanceof Error ? error.message : error)}`);
1471
1617
  }
1618
+ // Everything else was repaired; the limits were not. Said with the exit code
1619
+ // as well as with the line above, because `doctor --fix` is run by scripts.
1620
+ if (fixFacts === null)
1621
+ process.exit(1);
1472
1622
  }
1473
1623
  // ─── set-token ───────────────────────────────────────────────────────
1474
1624
  function cmdSetToken(args) {
package/dist/policy.d.ts CHANGED
@@ -92,10 +92,40 @@ export interface PolicyContext extends AgentGitPolicy {
92
92
  * what makes a change visible when this cannot prevent it.
93
93
  */
94
94
  agentPromptFile?: string;
95
+ /**
96
+ * Does this session work in the project folder itself, or in a worktree of
97
+ * its own? (#361 п. 5, ADR 0004)
98
+ *
99
+ * Only DIRECT sessions share a folder — and therefore share ONE current
100
+ * branch with every other session and person working in it. `git checkout
101
+ * <branch>` there is not a local move: it rewrites the working tree under
102
+ * everybody at once, which is the same class of act as `git reset --hard`.
103
+ *
104
+ * **`undefined` means «apply the rule».** Deliberately the opposite polarity
105
+ * to `SessionDescriptor.workMode` in `protocol.ts`, where a missing value
106
+ * means BRANCH when the folder is prepared. Different field, different
107
+ * question: there the safe reading is «build a worktree», here it is «ask
108
+ * first». Gotcha 193 — resolve «unknown» once, in the safe direction, and say
109
+ * out loud when two neighbours resolve it in opposite ways.
110
+ */
111
+ workMode?: 'DIRECT' | 'BRANCH';
95
112
  }
96
113
  export interface PolicyDecision {
97
114
  decision: 'allow' | 'deny' | 'ask';
98
115
  reason: string;
116
+ /**
117
+ * A sentence written FOR THE PERSON answering the card (#361 п. 5).
118
+ *
119
+ * `reason` is a label — «strict mode», «command needs approval» — and it goes
120
+ * to the log and, on a denial, to the feed. A card carries neither: it shows
121
+ * a title, a description and the input, and the reason is dropped. So a rule
122
+ * that asks a question the human cannot answer without knowing WHY has to say
123
+ * why here, and the adapters put it on the card.
124
+ *
125
+ * Only set where there is something worth reading; absent means «the card's
126
+ * own title says enough».
127
+ */
128
+ explain?: string;
99
129
  }
100
130
  export declare function maskString(value: string): string;
101
131
  /** Deep-mask every string in a JSON-ish structure (payloads leaving the server). */
@@ -125,6 +155,14 @@ export declare function isGitInternalPath(p: string): boolean;
125
155
  * DELIBERATELY switched something on — with the shipped defaults the first
126
156
  * check refuses every push and the rest never run, which is byte for byte the
127
157
  * behaviour of the four regexes it replaces.
158
+ *
159
+ * **A `deny` anywhere beats an `ask` anywhere.** Until #361 every decision here
160
+ * was a refusal, so returning the first one found in the first segment was the
161
+ * same thing as returning the worst one. With an `ask` in the set that stopped
162
+ * being true: `git checkout main && git push origin main` would have shown a
163
+ * card about the checkout, and confirming it would have run the push this
164
+ * function exists to refuse. So `deny` returns at once, `ask` is remembered,
165
+ * and the loop reads to the end.
128
166
  */
129
167
  export declare function evaluateGitPolicy(command: string, ctx: PolicyContext): PolicyDecision | null;
130
168
  export interface RecipeCommandContext {