@sabaiway/agent-workflow-kit 1.47.0 → 1.48.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.
@@ -19,10 +19,11 @@
19
19
  // below) and VERDICT-FIRST (D1): every non-optimal state opens with ONE composed verdict line.
20
20
  // Registry strings are frozen tool DATA, fact-true, one line under the shape cap (D2); posture/
21
21
  // risk prose lives in the mode doc at the consent moment (D3). A probe failure is a stated
22
- // skipped-item line — never a crash, never a fabricated item. The sandbox-lane item is HAND-APPLY
23
- // by design (bridge council 2026-07-11, both backends concur): the kit never seeds
24
- // sandbox.network.allowedDomains / filesystem.allowWrite; its convergence is a NEUTRAL
25
- // fingerprint-bound acknowledgement, never a security key (D4).
22
+ // skipped-item line — never a crash, never a fabricated item. The kit never seeds
23
+ // sandbox.network.allowedDomains / filesystem.allowWrite (HAND-APPLY territory; bridge council
24
+ // 2026-07-11, both backends concur); the sandbox-lane item's convergence is a NEUTRAL
25
+ // fingerprint-bound acknowledgement recorded by the consent-gated ack writer into the family-owned
26
+ // docs/ai/acks.json (AD-055 relocated it off the host settings schema), never a security key (D4).
26
27
  //
27
28
  // Read-only: never writes, never commits, never runs a subscription CLI. The reused probes are all
28
29
  // exported read-only surfaces of their owning tools (velocity/autonomy/doctor/backends/recipes/
@@ -55,6 +56,7 @@ import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
55
56
  import { shellQuoteArg } from './review-state.mjs';
56
57
  import { loadConfig } from './orchestration-config.mjs';
57
58
  import { DEFAULT_BUNDLE_ROOT, SETTINGS_FILENAME, settingsPath, parseSettings, duplicateKeys } from './bridge-settings-read.mjs';
59
+ import { assertContainedRealPath } from './fs-safe.mjs';
58
60
 
59
61
  const HERE = dirname(fileURLToPath(import.meta.url));
60
62
  const toolPath = (rel) => join(HERE, rel);
@@ -95,6 +97,9 @@ export const SEVERITIES = Object.freeze({
95
97
  'review-recipe': SEVERITY_ATTENTION,
96
98
  'gates-declaration': SEVERITY_OPTIONAL,
97
99
  'gate-hook': SEVERITY_OPTIONAL,
100
+ 'read-lane': SEVERITY_OPTIONAL,
101
+ 'read-lane.stale': SEVERITY_ATTENTION,
102
+ 'read-lane.missing': SEVERITY_ATTENTION,
98
103
  'family-freshness': SEVERITY_ATTENTION,
99
104
  'sandbox-masks': SEVERITY_OPTIONAL,
100
105
  'agy-adddir': SEVERITY_OPTIONAL,
@@ -147,6 +152,9 @@ export const WHATS = Object.freeze({
147
152
  'review-recipe': '{degraded}',
148
153
  'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
149
154
  'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
155
+ 'read-lane': 'the gate hook is wired but the read-only compound lane is off — pipes/chains of seeded reads still prompt one by one',
156
+ 'read-lane.stale': 'the read-lane is ON but the placed gate hook is stale — an old hook never reads lanes.json, so the lane is silently dark; reseed it',
157
+ 'read-lane.missing': 'the gate hook is wired but its placed file is missing — every Bash call errors and the read-lane is dark; re-place it',
150
158
  'family-freshness': '{parts}',
151
159
  'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
152
160
  'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
@@ -197,6 +205,7 @@ export const BENEFITS = Object.freeze({
197
205
  'review-recipe': 'review coverage — the review recipe you configured actually runs instead of silently degrading',
198
206
  'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
199
207
  'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
208
+ 'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
200
209
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
201
210
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
202
211
  'agy-adddir': 'large reviews — an oversized agy code review offloads to a staging dir instead of refusing',
@@ -334,6 +343,43 @@ const probeGates = ({ root, deps, add, skip }) => {
334
343
  }
335
344
  };
336
345
 
346
+ // The read-lane offer (AD-055 Part II, Help-through-Recommendations): once the gate hook is PLACED
347
+ // and WIRED, offer to enable the opt-in read-only compound lane. Mutually exclusive with the
348
+ // gate-hook item BY CONSTRUCTION — that item keys on `!wired`, this one on `wired` — so an unplaced
349
+ // or un-wired hook is covered there, never double-offered here. The apply is the gate-hook
350
+ // --read-lane PREVIEW one-liner (its own currency check + posture note fire at the writer; it may
351
+ // prompt once — it IS a consent flow). Converges once lanes.json enables the lane.
352
+ const probeReadLane = ({ root, deps, add, skip }) => {
353
+ try {
354
+ const sg = surveyGateHook(root, deps);
355
+ if (sg.error) throw new Error(sg.error);
356
+ if (!sg.wired) return; // not wired → the gate-hook item covers (no double-fire)
357
+ if (!sg.filePlaced) {
358
+ // Wired but the placed hook FILE is missing — the hook errors on every Bash call and the lane
359
+ // is silently dark; surface it as attention with a place-first recovery (council R2-M2).
360
+ add('read-lane', fillTemplate(WHATS['read-lane.missing'], {}), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`, 'read-lane.missing');
361
+ return;
362
+ }
363
+ if (readReadLaneToggle(root, deps)) {
364
+ // The lane is ON: converged IFF the placed hook is byte-current — a stale (pre-1.48) hook never
365
+ // reads lanes.json, so the enabled lane is a silent no-op the user must reseed (council B7). The
366
+ // rm target is ABSOLUTE (council R2-M3) so running the recovery from any cwd can only delete this
367
+ // repo's hook.
368
+ if (isPlacedHookCurrent(root, deps)) return; // converged
369
+ add(
370
+ 'read-lane',
371
+ fillTemplate(WHATS['read-lane.stale'], {}),
372
+ `HAND-APPLY: rm ${q(join(root, GATE_HOOK_REL))}, then node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`,
373
+ 'read-lane.stale',
374
+ );
375
+ return;
376
+ }
377
+ add('read-lane', fillTemplate(WHATS['read-lane'], {}), `node ${q(toolPath('gate-hook.mjs'))} --read-lane --cwd ${q(root)}`);
378
+ } catch (err) {
379
+ skip('read-lane', err);
380
+ }
381
+ };
382
+
337
383
  const probeFamilyFreshness = ({ deps, add, skip }) => {
338
384
  try {
339
385
  const survey = deps.surveyFamily ?? surveyFamily;
@@ -485,15 +531,102 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
485
531
  return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
486
532
  };
487
533
 
488
- // The kit-owned neutral ack namespace (D4): project-scoped, hand-applicable as one line, read from
489
- // BOTH settings scopes; the sandbox/permissions security keys are NEVER consulted as an ack.
534
+ // The kit-owned neutral ack store (D4; AD-055 Part I): a FAMILY-OWNED strict-JSON file no host
535
+ // validator guards top-level key `sandboxLaneAck` (+ optional `_README`), unknown keys tolerated
536
+ // on read (future acks are siblings). This is the PRIMARY ack channel; the legacy settings-scope
537
+ // keys below are read for one deprecation window. The sandbox/permissions security keys are NEVER
538
+ // consulted as an ack.
539
+ export const ACKS_FILE = 'docs/ai/acks.json';
540
+ export const ACKS_LANE_KEY = 'sandboxLaneAck';
541
+
542
+ // The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
543
+ // placed hook reads live. The read-lane item offers to enable it once the hook is placed+wired.
544
+ export const LANES_FILE = 'docs/ai/lanes.json';
545
+ export const READ_LANE_KEY = 'readLane';
546
+
547
+ // The placed gate hook + the bundled runtime the read-lane item byte-compares for currency (AD-055
548
+ // Part II, council B7): an enabled lane over a STALE hook is a silent no-op — the pre-1.48 hook never
549
+ // reads lanes.json — so it must surface as an ATTENTION reseed, not a silent convergence.
550
+ const GATE_HOOK_REL = '.claude/hooks/agent-workflow-gates.mjs';
551
+ const BUNDLED_HOOK_ABS = join(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
552
+
553
+ // Byte-compare the placed gate hook against the bundled runtime. A read error (an unreadable placed
554
+ // hook, a broken kit bundle) propagates → the probe states a skip, never a wrong currency verdict.
555
+ const isPlacedHookCurrent = (root, deps) => {
556
+ const readFile = deps.readFile ?? readFileSync;
557
+ const placed = readFile(join(root, GATE_HOOK_REL), 'utf8');
558
+ const bundle = readFile(deps.bundledHookPath ?? BUNDLED_HOOK_ABS, 'utf8');
559
+ return placed === bundle;
560
+ };
561
+
562
+ // The LEGACY neutral ack namespace (pre-AD-055): read from BOTH settings scopes until the next kit
563
+ // MAJOR (2.0.0) so a never-migrated host stays converged across the deprecation window (Decisions 3).
490
564
  export const SANDBOX_LANE_ACK_PARENT = 'agentWorkflow';
491
565
  export const SANDBOX_LANE_ACK_KEY = 'sandboxLaneAck';
492
566
 
567
+ // Read the family-owned ack store. An ABSENT file (or absent docs/ai) is the NORMAL not-yet-acked
568
+ // state → null (plain fall-through, never a skip). A parse/IO error on an EXISTING file THROWS — the
569
+ // probe's catch turns it into a stated skip line (Decisions 2). A non-object root is a malformed
570
+ // store (fail-closed skip); a non-string value at the key is tolerated → null (the item re-fires).
571
+ // The WHOLE path chain (root / docs / ai / acks.json) is guarded WITHOUT following symlinks
572
+ // BEFORE any read: a symlinked ANCESTOR could otherwise read an ack from OUTSIDE the project (the
573
+ // writer refuses such a deployment — the reader must too), a symlinked/dangling LEAF must not read as
574
+ // not-yet-acked, and a non-regular target (FIFO/dir/device) is a fail-closed SKIP — never read it (a
575
+ // FIFO would BLOCK the advisor). ENOENT-safe: an absent file/dir is the NORMAL not-yet-acked null.
576
+ const readAcksLane = (root, deps) => {
577
+ const readFile = deps.readFile ?? readFileSync;
578
+ const lstat = deps.lstat ?? lstatSync;
579
+ const absPath = join(root, ACKS_FILE);
580
+ let st;
581
+ try {
582
+ assertContainedRealPath(root, absPath, { lstat }); // symlinked root/ancestor/leaf or escape → throws
583
+ st = lstat(absPath);
584
+ } catch (err) {
585
+ if (err?.code === 'ENOENT') return null; // genuinely absent (file or docs/ai) — normal not-yet-acked
586
+ throw err; // a symlinked ancestor/leaf, an escape, or a real IO error — stated skip
587
+ }
588
+ if (!st.isFile()) {
589
+ throw new Error(`${ACKS_FILE} is not a regular file — refusing to read it`);
590
+ }
591
+ const parsed = JSON.parse(readFile(absPath, 'utf8'));
592
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
593
+ throw new Error(`${ACKS_FILE}: expected a JSON object`);
594
+ }
595
+ const value = parsed[ACKS_LANE_KEY];
596
+ return typeof value === 'string' ? value : null;
597
+ };
598
+
599
+ // Read the opt-in read-lane toggle for the read-lane item. An ABSENT file (or absent docs/ai) →
600
+ // false (the lane is off — offer it). `readLane === true` → enabled (converged). A parse/IO error on
601
+ // an EXISTING file, a symlinked ancestor/leaf, an escape, or a non-object root THROWS — the probe
602
+ // turns it into a stated skip (a BROKEN toggle the writer would refuse to overwrite is not "off").
603
+ // A present-but-non-boolean `readLane` is a valid store the writer merges → false (offer), never a skip.
604
+ const readReadLaneToggle = (root, deps) => {
605
+ const readFile = deps.readFile ?? readFileSync;
606
+ const lstat = deps.lstat ?? lstatSync;
607
+ const absPath = join(root, LANES_FILE);
608
+ let st;
609
+ try {
610
+ assertContainedRealPath(root, absPath, { lstat }); // symlinked root/ancestor/leaf or escape → throws
611
+ st = lstat(absPath);
612
+ } catch (err) {
613
+ if (err?.code === 'ENOENT') return false; // genuinely absent — the lane is off, offer it
614
+ throw err; // a symlinked ancestor/leaf, an escape, or a real IO error — stated skip
615
+ }
616
+ if (!st.isFile()) {
617
+ throw new Error(`${LANES_FILE} is not a regular file — refusing to read it`);
618
+ }
619
+ const parsed = JSON.parse(readFile(absPath, 'utf8'));
620
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
621
+ throw new Error(`${LANES_FILE}: expected a JSON object`);
622
+ }
623
+ return parsed[READ_LANE_KEY] === true;
624
+ };
625
+
493
626
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
494
627
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
495
628
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
496
- export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane']);
629
+ export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane', 'read-lane']);
497
630
 
498
631
  const probeSandboxLane = ({ root, deps, add, skip }) => {
499
632
  try {
@@ -522,14 +655,28 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
522
655
  if (!dirs.includes(resolved)) dirs.push(resolved);
523
656
  }
524
657
  const fingerprint = recipeFingerprint({ hosts, dirs, home });
525
- // Convergence is the NEUTRAL fingerprint-bound acknowledgement, read from BOTH scopes — a
526
- // changed recipe (hosts, dirs, or an env override) re-fires the item (D4).
527
- const acks = [settings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY], localSettings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY]];
658
+ // Convergence is the NEUTRAL fingerprint-bound acknowledgement: the item converges iff the
659
+ // CURRENT fingerprint equals the ack in ANY consulted store the family-owned acks.json FIRST,
660
+ // then the legacy settings scopes; a stale value in one store is ignored when another matches
661
+ // (Decisions 2). A changed recipe (hosts, dirs, or an env override) re-fires the item (D4).
662
+ const acks = [
663
+ readAcksLane(root, deps),
664
+ settings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY],
665
+ localSettings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY],
666
+ ];
528
667
  if (acks.includes(fingerprint)) return; // the acknowledged recipe — the item converged
668
+ // The item joins the CONSENT-GATED WRITER class (Decisions 4): the apply is the ack writer's
669
+ // PREVIEW one-liner (pure executable, cwd-independent), carrying the neutral fingerprint — never
670
+ // a security key. The LIVE recipe (egress hosts + resolved writable dirs) rides a separate
671
+ // rendered `recipe:` line (the fill source for the mode doc's lane-(2) hand-apply block); the
672
+ // fingerprint encodes it, so a changed recipe re-fires with a fresh command.
673
+ const recipe = `egress hosts [${hosts.join(', ')}]; writable state dirs [${dirs.join(', ')}] (observed-minimal; a blocked host names itself at run time)`;
529
674
  add(
530
675
  'sandbox-lane',
531
676
  fillTemplate(WHATS['sandbox-lane'], {}),
532
- `HAND-APPLY (a neutral acknowledgement, never a security key): recipe — egress hosts [${hosts.join(', ')}]; writable state dirs [${dirs.join(', ')}] (observed-minimal; a blocked host names itself at run time); what to DO with it per host class: the mode doc's sandbox-lanes section; once handled, record the ack: set "${SANDBOX_LANE_ACK_PARENT}"."${SANDBOX_LANE_ACK_KEY}" to "${fingerprint}" in .claude/settings.json or settings.local.json — a MERGE into the existing ${SANDBOX_LANE_ACK_PARENT} object (keep its other keys; create it only if absent)`,
677
+ `node ${q(toolPath('ack-write.mjs'))} --fingerprint ${fingerprint} --cwd ${q(root)}`,
678
+ 'sandbox-lane',
679
+ recipe,
533
680
  );
534
681
  } catch (err) {
535
682
  skip('sandbox-lane', err);
@@ -543,6 +690,7 @@ const PROBES = Object.freeze([
543
690
  probeSandboxProvision,
544
691
  probeReviewRecipe,
545
692
  probeGates,
693
+ probeReadLane,
546
694
  probeFamilyFreshness,
547
695
  probeMasksItem,
548
696
  probeAgyAdddir,
@@ -560,18 +708,22 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
560
708
  // violation surfaces through the stated-skip lane, never a crash, never a rendered violation.
561
709
  // severityKey defaults to the item key; a per-site arm passes its `<key>.<variant>` entry when
562
710
  // its class differs from the base (the invalid-env attention arm).
563
- const add = (key, what, apply, severityKey = key) => {
711
+ // `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
712
+ // the capped WHAT and does NOT belong in the pure-command apply (the sandbox-lane live recipe:
713
+ // egress hosts + resolved writable dirs). Single-line like apply; absent for every other item.
714
+ const add = (key, what, apply, severityKey = key, detail = null) => {
564
715
  const problems = [];
565
716
  if (!(key in BENEFITS)) problems.push(`unregistered item key ${JSON.stringify(key)}`);
566
717
  if (!(severityKey in SEVERITIES)) problems.push(`unregistered severity key ${JSON.stringify(severityKey)}`);
567
718
  if (/[\r\n]/.test(what)) problems.push('WHAT is not a single line');
568
719
  else if (what.length > ITEM_LINE_CAP) problems.push(`WHAT exceeds the ${ITEM_LINE_CAP}-char cap (${what.length})`);
569
720
  if (/[\r\n]/.test(apply)) problems.push('apply is not a single line');
721
+ if (detail != null && /[\r\n]/.test(detail)) problems.push('recipe detail is not a single line');
570
722
  if (problems.length > 0) {
571
723
  skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
572
724
  return;
573
725
  }
574
- items.push({ key, severity: SEVERITIES[severityKey], what, benefit: BENEFITS[key], apply });
726
+ items.push({ key, severity: SEVERITIES[severityKey], what, benefit: BENEFITS[key], apply, detail });
575
727
  };
576
728
  for (const probe of deps.probes ?? PROBES) probe({ root, deps, add, skip });
577
729
  return { root, items, skips };
@@ -599,6 +751,7 @@ export const formatRecommendations = ({ items, skips }) => {
599
751
  ordered.forEach((item, i) => {
600
752
  lines.push(`${i + 1}. ${SEVERITY_LABELS[item.severity] ?? SEVERITY_LABELS[SEVERITY_OPTIONAL]}: ${item.what}`);
601
753
  lines.push(` benefit: ${item.benefit}`);
754
+ if (item.detail) lines.push(` recipe: ${item.detail}`);
602
755
  lines.push(` apply: ${item.apply}`);
603
756
  });
604
757
  }
@@ -615,15 +768,16 @@ Usage:
615
768
 
616
769
  Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
617
770
  one composed verdict line opens every non-optimal render, then per item {severity · what is
618
- sub-optimal · the benefit in one plain line · the exact consent-gated apply one-liner}. --cwd is
771
+ sub-optimal · the benefit in one plain line · an optional \`recipe:\` line (the sandbox-lane live
772
+ recipe only) · the exact consent-gated apply one-liner}. --cwd is
619
773
  REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
620
774
  section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
621
775
  stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;
622
776
  the doctor item pins via a cd prefix; the ONE exception is the set-autonomy item — a
623
777
  conversational skill invocation labeled "run IN the target project") and preserve each writer's
624
- own consent semantics; the sandbox-lane item is HAND-APPLY by design — this tool and the kit
625
- writers never seed sandbox network/filesystem allowances, and its convergence is a neutral
626
- fingerprint acknowledgement, never a security key.
778
+ own consent semantics; the kit never seeds sandbox network/filesystem allowances (HAND-APPLY
779
+ territory), and the sandbox-lane convergence is a neutral fingerprint acknowledgement recorded by
780
+ the consent-gated ack writer into docs/ai/acks.json (never a security key).
627
781
 
628
782
  Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report
629
783
  rendered (items or empty); 1 error; 2 usage.`;
@@ -255,8 +255,19 @@ export const RUNTIME_RESIDUAL_FORMS = Object.freeze({
255
255
  writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
256
256
  // `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
257
257
  // the `>` redirection scan). Bare `<` is input redirection (reads a file — read-only commands may
258
- // already do that), so it is deliberately NOT here.
259
- commandSubstitutions: Object.freeze(['$(', '`', '<(']),
258
+ // already do that), so it is deliberately NOT here. The bash-5.3 function substitutions `${ cmd; }`
259
+ // (a blank — space/tab/newline/CR — right after `${`) and `${| cmd; }` (runs a command, assigns
260
+ // REPLY) also execute a nested command — matched as the literal openers `${ ` / `${\t` / `${\n` /
261
+ // `${\r` / `${|` (AD-055 Part II; the `\r` opener guards CRLF payloads — council agy nit). An
262
+ // ordinary `${VAR}` parameter expansion has NO blank after `${`, so it trips none of these — kept
263
+ // rung-(b)-silent on a settings-allowed single (rung (c) excludes all `$`).
264
+ commandSubstitutions: Object.freeze(['$(', '`', '<(', '${ ', '${\t', '${\n', '${\r', '${|']),
265
+ // A backslash immediately before a newline/CR is a bash LINE CONTINUATION: bash removes it and
266
+ // splices the two lines into ONE word, which can reconstruct a residual token (`--output`, `$(`,
267
+ // `${ …; }`) that a raw substring scan on the pre-splice string misses (`--outp\<newline>ut=f` →
268
+ // `--output=f`). Guards a settings-allowed SINGLE (rung c already forbids backslash in every
269
+ // segment). AD-055 Part II council fold (codex B3).
270
+ lineContinuations: Object.freeze(['\\\n', '\\\r']),
260
271
  // The `--output` write-flag family, matched as a raw SUBSTRING of the whole command (never a
261
272
  // whitespace-token check): the hook sees the pre-shell command string, so `--output=f`,
262
273
  // `"--output=f"`, `'--output' f`, and `\--output` must all trip it — over-asking on a benign