@sabaiway/agent-workflow-kit 10.4.0 → 10.6.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +1 -1
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +7 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +69 -17
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +73 -2
  7. package/bridges/antigravity-cli-bridge/capability.json +2 -2
  8. package/bridges/antigravity-cli-bridge/references/review-prompt.md +3 -0
  9. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  10. package/bridges/codex-cli-bridge/bin/codex-exec.sh +1 -1
  11. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +1 -1
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +89 -18
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +55 -2
  14. package/bridges/codex-cli-bridge/capability.json +2 -2
  15. package/capability.json +1 -1
  16. package/package.json +1 -1
  17. package/references/agents/review-lens.md +5 -3
  18. package/references/modes/agents.md +1 -1
  19. package/references/modes/core-evidence.md +1 -1
  20. package/references/modes/procedures.md +11 -5
  21. package/references/modes/recipes.md +2 -2
  22. package/references/modes/review-state.md +3 -3
  23. package/references/modes/set-recipe.md +4 -4
  24. package/references/modes/status.md +1 -1
  25. package/references/modes/velocity.md +1 -0
  26. package/references/templates/orchestration.json +1 -1
  27. package/tools/bridge-posture.mjs +48 -0
  28. package/tools/carriers.mjs +21 -9
  29. package/tools/cheap-agents-read.mjs +86 -24
  30. package/tools/cheap-agents.mjs +47 -7
  31. package/tools/commit-guard.mjs +4 -3
  32. package/tools/detect-backends.mjs +2 -2
  33. package/tools/direct-run.mjs +3 -0
  34. package/tools/dispatch-store-read.mjs +269 -0
  35. package/tools/dispatch-store.mjs +21 -357
  36. package/tools/fold-scope.mjs +5 -60
  37. package/tools/grounding.mjs +2 -2
  38. package/tools/held-session.mjs +165 -0
  39. package/tools/orchestration-config.mjs +19 -78
  40. package/tools/orchestration-readme.mjs +70 -0
  41. package/tools/plan-shape-cli.mjs +112 -0
  42. package/tools/plan-shape-facts.mjs +204 -0
  43. package/tools/plan-shape.mjs +348 -0
  44. package/tools/procedures.mjs +161 -31
  45. package/tools/recipes.mjs +60 -79
  46. package/tools/repo-lex.mjs +40 -0
  47. package/tools/review-roster-resolve.mjs +104 -0
  48. package/tools/review-roster.mjs +128 -0
  49. package/tools/review-rounds-cli.mjs +92 -0
  50. package/tools/review-rounds.mjs +115 -0
  51. package/tools/review-state.mjs +85 -6
  52. package/tools/set-recipe-roster.mjs +167 -0
  53. package/tools/set-recipe.mjs +80 -23
  54. package/tools/velocity-profile.mjs +8 -22
@@ -35,7 +35,7 @@ import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from '.
35
35
  import { plansInFlight, PLANS_REL } from './plan-files.mjs';
36
36
  // The family's ONE shell quoter for a RENDERED command operand (bare when the value is already safe,
37
37
  // single-quoted otherwise) — the same leaf eight other command renderers here read through.
38
- import { shellQuoteArg } from './repo-lex.mjs';
38
+ import { shellQuoteArg, isSeedablePathToken, isRenderableLine, escapeForDisplay, isArtifactPathCarriable } from './repo-lex.mjs';
39
39
  // The config schema/read core (orchestration-config.mjs, the single config contract): the reader +
40
40
  // the SHARED slot/recipe validity, never the fs-writer (orchestration-write.mjs) DIRECTLY — the
41
41
  // import-split test pins the direct-import rule.
@@ -46,7 +46,10 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from
46
46
  // orchestration-write (the import-split test pins both direct rules; the TRANSITIVE claim is
47
47
  // structural — test/read-graph-purity.test.mjs pins it).
48
48
  import { resolveFlowStorePath, readFlowStore } from './flow-store-read.mjs';
49
+ import { readDelegationLedger } from './dispatch-store-read.mjs';
50
+ import { foldLaneLines, HELD_RECEIPT_BACKEND, judgeLedger } from './held-session.mjs';
49
51
  import { CHAIN_KIND } from './flow-record.mjs';
52
+ import { readRegistration } from './mcp-registration.mjs';
50
53
  // The declared source-size practice (D-17 U1), read through the practice's PURE READ core — never
51
54
  // source-size-check.mjs, which owns the writer half: this advisor is a read root of
52
55
  // test/read-graph-purity.test.mjs, and the core exists so a surface can ask without reaching a writer.
@@ -166,7 +169,7 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) => {
166
169
  // planRecipe's drift-guarded dispatch for WHICH backends, then resolve each (backend, role) to its
167
170
  // manifest wrapper cmd via the bridge registry — no wrapper name is hand-composed here. A vehicle
168
171
  // step is NOT a bridge: it carries its own state and is never looked up in a manifest.
169
- const { dispatch } = planRecipe(resolved.recipe, detection);
172
+ const { dispatch } = resolved.roster ? { dispatch: [] } : planRecipe(resolved.recipe, detection);
170
173
  const vehicles = dispatch.filter((d) => d.vehicle != null).map((d) => ({ backend: d.backend, state: d.vehicle }));
171
174
  const bridged = dispatch.filter((d) => d.vehicle == null);
172
175
  const backends = bridged.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
@@ -189,8 +192,9 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) => {
189
192
  // carrier-typed slot (registry-driven — no slot name is spelled here), solo when it has none.
190
193
  const effectiveCarrier = (slots) => slots.find((s) => s.slotType === 'carrier')?.recipe ?? 'solo';
191
194
 
192
- // An unsatisfiable EXPLICIT override is the only "warning" (loud, flagged for the agent to relay). A
193
- // graceful config/default degradation is reported as a per-slot reason, not a warning.
195
+ // An unsatisfiable EXPLICIT override is a warning (loud, flagged for the agent to relay); a graceful
196
+ // config/default degradation remains a per-slot reason. Canon/registry slot-skew warnings are
197
+ // collected separately, after the live section is read.
194
198
  const collectWarnings = (slots) =>
195
199
  slots
196
200
  .filter((s) => s.overrideUnsatisfied)
@@ -216,24 +220,34 @@ const backendSetLabel = (backends) =>
216
220
  : ` → ${backends[0]}`;
217
221
 
218
222
  // The review-loop economics block (M1 + M6's firing half) — printed when the activity engages a review
219
- // backend (a slot resolving reviewed | council) and OMITTED for solo. It paraphrases the procedures.md
223
+ // member: a slot RESOLVING reviewed | council, a roster, or one whose config or override REQUESTED a
224
+ // review recipe and degraded (readiness removes no configured obligation, and the table still judges
225
+ // it); omitted only for a solo nobody asked to be otherwise. It paraphrases the procedures.md
220
226
  // Fold + loop step + orchestration §4 canon (no rival rule): the ≤2-round architecture cap, the bar met by RAISING a
221
227
  // surviving major to an acceptance invariant (not exhausting prose), backend divergence = the crossover
222
228
  // stop, the thin-plan/diff-review carve-out, a self-consistency read before every re-review, and the
223
229
  // REQUIRED per-round structured emission {round N · finding-origin tally · per-backend verdict}. Only a
224
- // review slot can resolve reviewed|council (execute floors at solo|delegated), so gate on the recipe.
230
+ // review slot can resolve or request reviewed|council (execute floors at solo|delegated), so the gate
231
+ // reads the recipe, the requested recipe and the roster.
225
232
  const REVIEW_RECIPES = new Set(['reviewed', 'council']);
233
+ const CONSULT_LINE = ' • Before every fold of a finding raised by a review member (a bridge backend or a placed lens): ASK that member whether the proposed fold solves it and adds no new problem; WAIT for its answer, READ it, then edit only as accepted or corrected — agy: agy-review --continue --decided @f --focus "Finding: <finding>. Proposed fold: <exact fold>. Does this proposed fold solve the finding and add no new problem? Reply accept, or correct with exact replacement text."; codex: fresh codex-review plan <consult-brief> written before the working tree changes; a placed lens: re-dispatch the same lens vehicle with the finding and the proposed fold. A self-review finding, or any finding when no review member ran, is folded directly — the exemption is the finding\'s ORIGIN, never the recipe word on the slot line.';
234
+ const ARMED_CONSULT_LINE = ' • ARMED pre-fold sequence for a bridge-raised finding: the round is open → dispatch that bridge\'s consult with a nonce → WAIT and READ → fold accepted or corrected → flow-writer consult-attestation <planId> --backend <id> --nonce <n> --proposed-fix-digest <the-sha256-of-the-fold-text> → then edit. A lens-raised finding re-dispatches the lens without a nonce, WAIT and READ, then edit as accepted or corrected — it mints no manifest and no attestation — only its per-round participation rides its internal-attestation.';
226
235
  // activity-aware (AD-046): the triage classification vocabulary rides EVERY review-backed activity;
227
236
  // the LEDGER pointer renders ONLY for plan-execution — the ledger is plan-execution-scoped (AD-045),
228
237
  // and pointing plan-authoring at it would send rounds of the wrong activity into the code loop's gate.
229
- const reviewLoopAdvice = (slots, activity) =>
230
- slots.some((s) => REVIEW_RECIPES.has(s.recipe))
238
+ // A lens-only roster resolves to `solo` and a fully degraded request resolves to `solo`: both still run
239
+ // a review round, which is why the gate never reads the effective recipe alone.
240
+ const reviewLoopAdvice = (slots, activity, flowArmed = false, plans = []) =>
241
+ slots.some((s) => s.roster != null || REVIEW_RECIPES.has(s.recipe) || REVIEW_RECIPES.has(s.degradedFrom))
231
242
  ? [
232
243
  'Review-loop economics (procedures.md Fold + loop · orchestration.md §4) — the review this recipe runs:',
244
+ CONSULT_LINE,
245
+ ...(activity === 'plan-execution' && flowArmed ? [ARMED_CONSULT_LINE] : []),
233
246
  ' • Cap architecture plan-review at ≤2 rounds; the bar is met by RAISING a surviving major to an acceptance invariant (or handing it to Execute/diff-review), never by exhausting the strictest backend.',
234
247
  ' • Backend divergence (one backend grounded-ships while another keeps revising mechanics) IS the crossover stop.',
235
248
  ' • Route an all-mechanics/CI or prose-only artifact to a thin plan + diff-review; run a self-consistency read before every re-review.',
236
249
  ' • Each round MUST emit {round N · finding-origin tally (first-draft / fold-induced / mechanics) · per-backend verdict} so the crossover is a computed signal.',
250
+ ...(activity === 'plan-authoring' ? roundRenderAdvice(slots, plans) : []),
237
251
  ' • At the cap, classify every surviving blocking finding: fixable-bug (fold ONCE as a red→green test, re-review) / inherent-layer-residual (document + raise to an acceptance criterion) / escalate (the maintainer decides); a minor never forces triage.',
238
252
  ...(activity === 'plan-execution'
239
253
  ? [
@@ -246,38 +260,119 @@ const reviewLoopAdvice = (slots, activity) =>
246
260
  // The grounding pre-step (AD-038, extending the AD-033 verbatim-contract rendering): whenever the
247
261
  // resolved dispatch includes agy-review, print the CONCRETE facts-assembly invocation + the
248
262
  // --facts form as a copy-paste pre-step — population, not placeholders. Plan-path population rule
249
- // (the review-state plan-in-flight detector): exactly ONE plan in flight → render it populated;
250
- // zero or several → the explicit `--plan <path>` placeholder + a one-line discovery caveat. The
263
+ // (the review-state plan-in-flight detector): exactly ONE renderable plan in flight → render it
264
+ // populated; zero, several, or a name a one-line render cannot carry → the explicit placeholder + a
265
+ // one-line discovery caveat; a name the receipt encoder refuses falls back on the receipt-minting
266
+ // or receipt-matching command only (`agy-review plan`, the round table). The
251
267
  // suggested --out lives OUTSIDE the repo (/tmp) — grounding.mjs refuses a non-scratch destination.
252
268
  // Exported for the bridge-tier byte-parity pin (AD-044 Plan 4): the velocity tier seeds the
253
269
  // grounding rule in EXACTLY this rendered spelling — `node "${GROUNDING_TOOL}"` — so seeded and
254
270
  // rendered forms can never drift apart.
255
271
  export const GROUNDING_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'grounding.mjs');
272
+ export const REVIEW_ROUNDS_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'review-rounds-cli.mjs');
273
+ export const REPO_SEARCH_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'repo-search.mjs');
256
274
  const GROUNDING_FACTS_OUT = '/tmp/review-facts.md';
275
+ const MINTS_RECEIPT = Object.freeze({ mintsReceipt: true });
276
+
277
+ // The round table judges what review-rounds-cli resolves from the config (S27), never this render's
278
+ // override or degradation, so the advisor states that fact beneath the command instead of predicting
279
+ // when the two agree; a bridge-less roster gets the fact that no receipt can exist in the command's place.
280
+ // A requested review recipe renders the command even when every bridge is unavailable here: readiness
281
+ // degradation removes no configured obligation, so the table still has something to say.
282
+ const ROUND_RENDER_FACT_LINE = " ↳ the table judges the obligation review-rounds-cli resolves from docs/ai/orchestration.json (S27) — the configured recipe, or the computed default for a silent slot — never this run's --override or a degraded recipe; it reads receipts only: a backend that did not run shows as missing (no receipts when none ran), and its degrade record is judged by review-state and core-evidence summary, not here.";
283
+ const ROUND_RENDER_NO_BRIDGE_LINE = " • Round render: a roster with no bridge mints no receipt, so review-rounds-cli cannot supply this round's verdicts — emit the finding-origin tally plus each lens member's verdict (or silent) directly.";
284
+ const roundRenderAdvice = (slots, plans) => {
285
+ const review = slots.find((s) => s.slot === 'review');
286
+ if (review === undefined) return [];
287
+ const roster = review.roster ?? null;
288
+ const shouldRenderCommand = roster === null
289
+ ? (review.backends ?? []).length > 0 || REVIEW_RECIPES.has(review.degradedFrom)
290
+ : roster.some((row) => row.kind === 'bridge');
291
+ if (!shouldRenderCommand) return [ROUND_RENDER_NO_BRIDGE_LINE];
292
+ const lensMembers = (roster ?? []).filter((row) => row.kind === 'lens').map((row) => row.member);
293
+ const operand = populatedPlan(plans, MINTS_RECEIPT);
294
+ return [
295
+ ` • Round render (verdict half of the per-round emission; the finding-origin tally stays the orchestrator's): node ${renderToolPath(REVIEW_ROUNDS_TOOL)} --artifact ${operand ?? '<plan-file>'}`,
296
+ ...planDiscoveryCaveat(plans, '--artifact', 'populate --artifact with the plan file under review.', operand === null ? ['--artifact'] : []),
297
+ ROUND_RENDER_FACT_LINE,
298
+ ...(lensMembers.length > 0 ? [` ↳ the table carries the bridge verdicts only — add each lens member's verdict (or silent) by hand: ${lensMembers.join(', ')}`] : []),
299
+ ];
300
+ };
301
+
302
+ // The ONE plan in flight as a pasteable operand: shell-significant bytes ride shellQuoteArg; a name a
303
+ // one-line render cannot carry is never populated, nor one the receipt encoder refuses (S21) for a
304
+ // command that MINTS or MATCHES a plan receipt: agy-review plan would be refused pre-spend by name, the
305
+ // round table would match no receipt.
306
+ const populatedPlan = (plans, { mintsReceipt = false } = {}) => {
307
+ if (plans.length !== 1 || !isRenderableLine(plans[0])) return null;
308
+ if (mintsReceipt && !isArtifactPathCarriable(plans[0])) return null;
309
+ return shellQuoteArg(`${PLANS_REL}/${plans[0]}`);
310
+ };
311
+ const planDiscoveryCaveat = (plans, flag, noPlanAction, fellBack = []) => {
312
+ if (plans.length === 0) return [` ↳ plan discovery: no plan in flight under ${PLANS_REL} — ${noPlanAction}`];
313
+ if (plans.length > 1) return [` ↳ plan discovery: ${plans.length} plans in flight under ${PLANS_REL} (${plans.map(escapeForDisplay).join(', ')}) — populate ${flag} with the one under review.`];
314
+ return fellBack.length === 0
315
+ ? []
316
+ : [` ↳ plan discovery: the plan in flight ${escapeForDisplay(plans[0])} carries a character that either a review receipt or a rendered command cannot carry — ${fellBack.join(' and ')} ${fellBack.length === 1 ? 'stays a placeholder' : 'stay placeholders'}; rename the plan.`];
317
+ };
318
+
319
+ // The kit-tools tier seeds `Bash(node <abs>/tools/repo-search.mjs:*)` BARE (velocity-profile.mjs
320
+ // deriveKitToolsAllowlist, the same predicate), so a seedable path renders bare — a quoted path is
321
+ // a different prefix and the allow rule is dead. A path the tier could never seed has no rule to
322
+ // match and is single-quoted for a safe paste (double quotes would still expand `$` and backticks).
323
+ const renderToolPath = (abs) => (isSeedablePathToken(abs) ? abs : shellQuoteArg(abs));
324
+ // `registered` is the registration on disk, never the tool's availability in this session — the
325
+ // command line stays as the fallback under the typed form. `toolPath` is a test seam (an unseedable
326
+ // kit path is not constructible from a test against the real checkout).
327
+ const readersSweepAdvice = (activity, registered, toolPath = REPO_SEARCH_TOOL) => {
328
+ if (activity !== 'plan-authoring') return [];
329
+ return [
330
+ 'Readers sweep (before the first review) — for every changed config key, registry entry, exported constant, receipt field or canon sentence:',
331
+ ...(registered ? [' use: repo_search {"pattern": "<the literal>"} — the kit\'s read-only MCP tool (registration complete; if it is not loaded in this session, the command below)'] : []),
332
+ ` run: node ${renderToolPath(toolPath)} --pattern <the literal> (a pattern carrying a shell-significant byte goes through --pattern-file <f> instead)`,
333
+ ' classify every reader as a ledger row, a stated non-goal, or unchanged with its proof',
334
+ ];
335
+ };
336
+
337
+ // The canon's own claim — the kit "parses only each section's `Slots:` line" — made true: an installed
338
+ // engine older (or newer) than this kit's registry prints its section verbatim, so the skew is SAID.
339
+ // A section without a Slots line is a customized canon, not a skew: silent. Never the exit code.
340
+ const SLOTS_LINE = /^Slots:[ \t]*(.+?)[ \t]*$/mu;
341
+ const slotSkewWarning = (section, activity) => {
342
+ const listed = section.match(SLOTS_LINE)?.[1].split(',').map((s) => s.trim()).filter(Boolean);
343
+ const registry = Object.keys(ACTIVITIES[activity].slots);
344
+ if (listed == null || listed.join(',') === registry.join(',')) return [];
345
+ return [`the installed engine's canon lists slots (${listed.join(', ')}) for ${activity} while this kit's registry names (${registry.join(', ')}) — the resolved lines follow the registry; the engine and this kit are out of step — /agent-workflow-kit status names which member is behind: upgrade that one. Tell the user.`];
346
+ };
257
347
  const groundingPreStepAdvice = (activity, slots, plans) => {
258
348
  if (!slots.some((s) => (s.backends ?? []).includes('agy-review'))) return [];
259
- const planArg = plans.length === 1 ? `--plan "${PLANS_REL}/${plans[0]}"` : '--plan <path>';
260
- // plan-authoring reviews the plan FILE — when exactly one plan is in flight, the review command
261
- // is populated with the same discovered path (a known path never renders a placeholder).
349
+ const operand = populatedPlan(plans);
350
+ const planArg = operand === null ? '--plan <path>' : `--plan ${operand}`;
351
+ const reviewOperand = populatedPlan(plans, MINTS_RECEIPT);
352
+ // plan-authoring reviews the plan FILE — a plain name in flight renders the review command populated;
353
+ // the renderability and receipt-carriability fallbacks are the only placeholders a known path produces.
262
354
  const reviewForm =
263
355
  activity === 'plan-authoring'
264
- ? plans.length === 1
265
- ? `agy-review plan "${PLANS_REL}/${plans[0]}"`
266
- : 'agy-review plan <plan-file>'
356
+ ? reviewOperand === null
357
+ ? 'agy-review plan <plan-file>'
358
+ : `agy-review plan ${reviewOperand}`
267
359
  : 'agy-review code';
268
360
  // `run:`/`then:` prefixes keep these POPULATED command lines machine-distinguishable from the
269
361
  // verbatim contract DESCRIPTORS above (the descriptor drift guard set-equals bare wrapper lines).
270
- // Path arguments are double-quoted — a skill dir or plan name with a space must stay copy-pasteable.
362
+ // The TOOL path stays double-quoted (the bridge tier seeds that exact byte-form); the plan operand
363
+ // rides shellQuoteArg — bare when safe, single-quoted otherwise.
271
364
  const lines = [
272
365
  'Grounding pre-step (agy is dispatched — assemble the verified facts BEFORE the review; grounding.mjs slices verbatim, judgment additions stay yours):',
273
366
  ` run: node "${GROUNDING_TOOL}" --constraints --autonomy ${planArg} --out ${GROUNDING_FACTS_OUT}`,
274
367
  ` then: ${reviewForm} --facts @${GROUNDING_FACTS_OUT}`,
275
368
  ];
276
- if (plans.length === 0) {
277
- lines.push(` ↳ plan discovery: no plan in flight under ${PLANS_REL} — substitute the plan file you are reviewing against, or drop --plan for constraints+autonomy facts.`);
278
- } else if (plans.length > 1) {
279
- lines.push(` ↳ plan discovery: ${plans.length} plans in flight under ${PLANS_REL} (${plans.join(', ')}) — populate --plan with the one under review.`);
280
- }
369
+ const fellBack = [operand === null ? '--plan' : null, activity === 'plan-authoring' && reviewOperand === null ? 'agy-review plan' : null].filter(Boolean);
370
+ lines.push(...planDiscoveryCaveat(
371
+ plans,
372
+ '--plan',
373
+ 'substitute the plan file you are reviewing against, or drop --plan for constraints+autonomy facts.',
374
+ fellBack,
375
+ ));
281
376
  return lines;
282
377
  };
283
378
 
@@ -486,7 +581,22 @@ const contractLines = ({ cmd, contract, settings }) => {
486
581
  return lines;
487
582
  };
488
583
 
489
- const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }) => {
584
+ const foldLaneAdvice = ({
585
+ activity,
586
+ slots,
587
+ cwd,
588
+ env,
589
+ resolveStore,
590
+ readStore,
591
+ audit,
592
+ readHead,
593
+ }) => {
594
+ if (activity !== 'plan-execution' || slots.find((slot) => slot.slot === 'execute')?.recipe !== 'delegated') return [];
595
+ const ledger = readDelegationLedger(cwd, env, { resolveStore, readStore, audit, readHead });
596
+ return foldLaneLines(judgeLedger(ledger, { backend: HELD_RECEIPT_BACKEND, degrades: [] }));
597
+ };
598
+
599
+ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, flowArmed, readersSweep, declaredPractice, foldScope, specCheck, foldLane }) => {
490
600
  const lines = [
491
601
  section,
492
602
  '',
@@ -506,13 +616,15 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
506
616
  for (const line of dispatchForm({ activity, slot: s.slot, state: v.state })) lines.push(` ${line}`);
507
617
  }
508
618
  for (const c of s.contracts ?? []) lines.push(...contractLines(c));
619
+ if (s.slot === 'execute' && foldLane.length) lines.push('', ...foldLane);
509
620
  }
510
621
  if ((flowHalves ?? []).length) lines.push('', ...flowHalves);
511
622
  const autonomyBlock = autonomyAdvice(activity, autonomy);
512
623
  if (autonomyBlock.length) lines.push('', ...autonomyBlock);
624
+ if (readersSweep.length) lines.push('', ...readersSweep);
513
625
  const grounding = groundingPreStepAdvice(activity, slots, plans);
514
626
  if (grounding.length) lines.push('', ...grounding);
515
- const advice = reviewLoopAdvice(slots, activity);
627
+ const advice = reviewLoopAdvice(slots, activity, flowArmed, plans);
516
628
  if (advice.length) lines.push('', ...advice);
517
629
  if (foldScope.length) lines.push('', ...foldScope);
518
630
  if (specCheck.length) lines.push('', ...specCheck);
@@ -525,15 +637,19 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
525
637
  return lines.join('\n');
526
638
  };
527
639
 
528
- const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }) => ({
640
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, flowArmed, readersSweep, declaredPractice, foldScope, specCheck, foldLane }) => ({
529
641
  activity,
530
642
  section,
531
643
  slots: Object.fromEntries(
532
644
  // `backends: string[]` is the STABLE pre-existing shape (wrapper names) — never repurposed.
533
645
  // `contracts` is the ADDITIVE per-dispatch driving-contract field (empty for solo).
534
- slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends, contracts: s.contracts }]),
646
+ slots.map((s) => [s.slot, {
647
+ recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends, contracts: s.contracts,
648
+ ...(s.roster ? { roster: s.roster } : {}),
649
+ }]),
535
650
  ),
536
- reviewLoop: reviewLoopAdvice(slots, activity),
651
+ reviewLoop: reviewLoopAdvice(slots, activity, flowArmed, plans),
652
+ readersSweep,
537
653
  // ADDITIVE (AD-038): the populated grounding pre-step, structured (empty when agy is not dispatched).
538
654
  groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
539
655
  // ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
@@ -542,6 +658,7 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
542
658
  foldScope,
543
659
  // ADDITIVE (spec layer 2b): the spec-store block, structured (empty outside plan-execution).
544
660
  specCheck,
661
+ foldLane,
545
662
  // ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
546
663
  autonomy: autonomyAdvice(activity, autonomy),
547
664
  // ADDITIVE (D-17 U1): the SAME composed lines the human render prints — one array, two renders, so
@@ -608,7 +725,7 @@ export const main = (argv, ctx = {}) => {
608
725
  ),
609
726
  });
610
727
  const slots = resolveAllSlots({ activity, config, detection, overrides });
611
- const warnings = [...detectWarnings, ...collectWarnings(slots)];
728
+ const warnings = [...detectWarnings, ...collectWarnings(slots), ...slotSkewWarning(section, activity)];
612
729
  const plans = plansInFlight(cwd);
613
730
  // The autonomy facts (AD-044 Plan 4): resolved levels + red-lines from the policy file. A
614
731
  // malformed policy renders LOUDLY in the block AND flips the exit to 1 (config error) — the
@@ -628,13 +745,26 @@ export const main = (argv, ctx = {}) => {
628
745
  // The flow armed-halves block (P8): probed ONLY when the config carries a flow block — an
629
746
  // unarmed project keeps byte-identical output (human AND JSON) and never pays the store probe.
630
747
  const flowProbe = ctx.flowProbe ?? defaultFlowProbe;
631
- const flowHalves = config?.flow == null ? null : flowHalvesAdvice(config.flow, flowProbe(cwd));
748
+ const flowState = config?.flow == null ? null : flowProbe(cwd);
749
+ const flowHalves = flowState == null ? null : flowHalvesAdvice(config.flow, flowState);
750
+ const registrationReader = ctx.readRegistration ?? readRegistration;
751
+ const readersSweep = readersSweepAdvice(activity, activity === 'plan-authoring' && registrationReader(cwd).registered, ctx.repoSearchTool);
632
752
  const declaredPractice = declaredPracticeAdvice(cwd, readFile, lstat);
633
753
  const foldScope = foldScopeAdvice(activity, config, plans);
634
754
  const specCheck = specCheckAdvice(activity);
755
+ const foldLane = foldLaneAdvice({
756
+ activity,
757
+ slots,
758
+ cwd,
759
+ env,
760
+ resolveStore: ctx.resolveDelegationStorePath,
761
+ readStore: ctx.readDelegationStore,
762
+ audit: ctx.auditDelegationStoreSemantics,
763
+ readHead: ctx.readHeadInstant,
764
+ });
635
765
  const stdout = json
636
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }), null, 2)
637
- : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck });
766
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, flowArmed: flowState?.armed === true, readersSweep, declaredPractice, foldScope, specCheck, foldLane }), null, 2)
767
+ : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, flowArmed: flowState?.armed === true, readersSweep, declaredPractice, foldScope, specCheck, foldLane });
638
768
  if (autonomy?.error) {
639
769
  return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
640
770
  }
package/tools/recipes.mjs CHANGED
@@ -1,19 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // Recipe planner — the pure brain behind the read-only `/agent-workflow-kit recipes` advisor.
3
- //
4
- // A "recipe" is a NAMED orchestration pattern over the family's optional carriers: the
5
- // subscription-CLI bridges (codex-cli-bridge → `codex`, antigravity-cli-bridge → `agy`) and the
6
- // executor subagent vehicle. The ENGINE owns the canonical narrative
7
- // (agent-workflow-engine/references/orchestration.md, kept in lockstep by the recipe-name parity
8
- // guard in recipes.test.mjs); this module owns the EXECUTABLE dispatch. The activity/slot registry
9
- // lives in carriers.mjs, re-exported here so every importer keeps its './recipes.mjs' path.
10
- // Invariants: pure (no fs/network/CLI in planRecipe/recommendRecipe), read-only, NEVER runs a
11
- // subscription CLI. The orchestrator executes a recipe and always makes the single commit; a
12
- // backend or a subagent is advisory or delegated, never autonomous. Dependency-free, Node >= 22.
2
+ // Read-only recipe planner. The engine owns the narrative; carriers.mjs owns the activity/slot
3
+ // registry re-exported here. Pure planning functions never run a subscription CLI.
13
4
 
14
5
  import { existsSync, readFileSync } from 'node:fs';
15
6
  import { dirname, join, resolve } from 'node:path';
16
- // The READ-ONLY settings core, never the writer: this advisor never pulls in the atomic-write core.
17
7
  import { settingsSnapshot, DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
18
8
  import { isDirectRun } from './direct-run.mjs';
19
9
  import {
@@ -29,10 +19,13 @@ import {
29
19
  vehicleDegradeReason,
30
20
  EXECUTOR_APPLY,
31
21
  safeLine,
22
+ DISPLAY_ALIASES,
23
+ BACKEND_PRIORITY,
32
24
  } from './carriers.mjs';
33
- // The READ core of the vehicle surface, never the writer: this advisor can never reach a module
34
- // that creates `.claude/agents/`.
35
- import { surveyExecutorVehicle } from './cheap-agents-read.mjs';
25
+ import { surveyExecutorVehicle, surveyVehicle } from './cheap-agents-read.mjs';
26
+ import { obligationsOf } from './review-roster.mjs';
27
+ import { resolveRoster, activeLineCell, remedyFor } from './review-roster-resolve.mjs';
28
+ import { posturesByBackend } from './bridge-posture.mjs';
36
29
  import {
37
30
  detectBackends,
38
31
  wrapperCmdFor,
@@ -43,32 +36,32 @@ import {
43
36
  DEGRADED,
44
37
  } from './detect-backends.mjs';
45
38
 
46
- export { ACTIVITIES, POLICY_ACTIVITIES, SLOT_RECIPES, isSwitchSlot, EXECUTOR_APPLY, safeLine };
39
+ export { ACTIVITIES, POLICY_ACTIVITIES, SLOT_RECIPES, isSwitchSlot, EXECUTOR_APPLY, safeLine, DISPLAY_ALIASES };
47
40
 
48
- const CODEX = 'codex-cli-bridge';
49
- const AGY = 'antigravity-cli-bridge';
41
+ const [CODEX, AGY] = BACKEND_PRIORITY;
50
42
 
51
- // The manifest-name → human-alias map (the detector emits manifest names; humans say codex/agy).
52
- export const DISPLAY_ALIASES = { [CODEX]: 'codex', [AGY]: 'agy' };
53
-
54
- // The provider → role table, keyed by the name the readiness array carries, NOT the display alias.
55
- // The bridge rows are drift-guarded against each capability.json `provides[]`; the executor row is
56
- // the vehicle withVehicle appends, and the ONLY provider of `carry`.
43
+ // Keyed by readiness-array provider name; executor is the only `carry` provider.
57
44
  export const BACKEND_ROLES = {
58
45
  [CODEX]: ['execute', 'review'],
59
46
  [AGY]: ['review', 'probe'],
60
47
  [EXECUTOR_PROVIDER]: [CARRY_ROLE],
61
48
  };
62
49
 
63
- // Review obligations from the CONFIGURED plan-execution.review recipe — the RAW config value, never
64
- // the readiness-degraded one. Homed HERE so review-state and flow-check share one backend set (#42).
65
- export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], detectionFailed = false } = {}) => {
66
- const configured = config?.['plan-execution']?.review;
50
+ // Review obligations from the CONFIGURED activity's review recipe — the RAW config value, never
51
+ // the readiness-degraded one. The default preserves review-state and flow-check's backend set (#42).
52
+ export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], detectionFailed = false, activity = 'plan-execution' } = {}) => {
53
+ if (!Object.hasOwn(ACTIVITIES, activity) || !Object.hasOwn(ACTIVITIES[activity].slots, 'review')) {
54
+ throw new Error(`activity "${activity}" has no review slot`);
55
+ }
56
+ const configured = config?.[activity]?.review;
67
57
  const providers = Object.values(DISPLAY_ALIASES); // every review-capable backend, codex first
68
58
  if (configured == null && detectionFailed) {
69
59
  // No config + no readiness signal: the computed default is UNKNOWABLE — fail closed upstream.
70
60
  return { recipe: null, source: 'default', backends: [], minShip: 0, perBackend: false, unknowable: true };
71
61
  }
62
+ if (Array.isArray(configured)) {
63
+ return { ...obligationsOf(configured), source: 'config', unknowable: false };
64
+ }
72
65
  // Role-filtered: a ready EXECUTOR vehicle is a carry provider, never a reviewer, so it must not
73
66
  // turn a silent review config into `reviewed`.
74
67
  const anyReady = readyProvidersOf('review', readiness).length >= 1;
@@ -90,8 +83,6 @@ export const BACKEND_META = {
90
83
  },
91
84
  };
92
85
 
93
- // Deterministic tie-break: codex before agy — the more reliable default for substantive reviews.
94
- const BACKEND_PRIORITY = [CODEX, AGY];
95
86
  const priorityIndex = (name) => {
96
87
  const i = BACKEND_PRIORITY.indexOf(name);
97
88
  return i === -1 ? BACKEND_PRIORITY.length : i;
@@ -137,14 +128,6 @@ export const RECIPES = [
137
128
 
138
129
  const recipeById = (id) => RECIPES.find((r) => r.id === id);
139
130
 
140
- // Read-only file-presence remedies — never a claim about whether a backend's service is responsive.
141
- const READINESS_REASON = {
142
- [NEEDS_SKILL]: 'bridge skill not installed — run /agent-workflow-kit setup',
143
- [NEEDS_CLI]: 'the CLI is not installed',
144
- [NEEDS_CREDENTIALS]: 'not signed in (credentials missing)',
145
- [DEGRADED]: 'wrapper not on PATH — run /agent-workflow-kit setup',
146
- };
147
-
148
131
  // ── pure planner ───────────────────────────────────────────────────────────────
149
132
 
150
133
  const providersOf = (role, detection) => detection.filter((b) => (BACKEND_ROLES[b.name] ?? []).includes(role));
@@ -171,7 +154,7 @@ const degradeReason = (recipe, detection) => {
171
154
  .filter((b) => b.readiness !== READY)
172
155
  .map((b) => (b.name === EXECUTOR_PROVIDER
173
156
  ? vehicleDegradeReason(b.vehicle, EXECUTOR_APPLY)
174
- : `${DISPLAY_ALIASES[b.name] ?? b.name}: ${READINESS_REASON[b.readiness] ?? b.readiness}`))
157
+ : `${DISPLAY_ALIASES[b.name] ?? b.name}: ${remedyFor({ readiness: b.readiness })}`))
175
158
  .join('; ');
176
159
  return `${recipe.title} needs ${recipe.minBackends} provider(s) providing ${recipe.role}, but only ${ready.length} ready${detail ? ` — ${detail}` : ''}`;
177
160
  };
@@ -268,7 +251,7 @@ const computedDefaultForSlot = (slotType, detection) => {
268
251
  // The effective recipe for ONE slot. Precedence: an explicit `override` (degrades LOUDLY, so the
269
252
  // agent tells the user) > the `config` entry (graceful) > the computed default. Satisfiability and
270
253
  // the lattice REUSE planRecipe — one source. Pure; never mutates.
271
- export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, slot, override } = {}) => {
254
+ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, slot, override, surveyLens, postures } = {}) => {
272
255
  const activityDef = ACTIVITIES[activity];
273
256
  if (!activityDef) throw new Error(`unknown activity: ${activity}`);
274
257
  const slotType = activityDef.slots[slot];
@@ -278,6 +261,21 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
278
261
  const requested = override ?? configured ?? computedDefaultForSlot(slotType, readiness);
279
262
  const source = override != null ? 'override' : configured != null ? 'config' : 'default';
280
263
 
264
+ if (Array.isArray(requested)) {
265
+ if (slotType !== 'review' || source !== 'config') {
266
+ throw new Error(`invalid roster for ${slotType} slot of "${activity}"`);
267
+ }
268
+ const obligations = obligationsOf(requested);
269
+ return {
270
+ recipe: obligations.recipe,
271
+ source,
272
+ degradedFrom: null,
273
+ reason: null,
274
+ overrideUnsatisfied: false,
275
+ roster: resolveRoster({ value: requested, readiness, surveyLens, postures }),
276
+ };
277
+ }
278
+
281
279
  // Defensive: the IO shell and the CLI validate first, so a stray value here is a programmer error
282
280
  // — surfaced loudly rather than silently coerced into a neighbour recipe.
283
281
  if (!(SLOT_RECIPES[slotType] ?? []).includes(requested)) {
@@ -300,42 +298,14 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
300
298
  };
301
299
  };
302
300
 
303
- // ── the one-line backend status (the tool speaks, the agent pastes) ───────────────────────────────
304
- // Machine-composed so the agent composes NOTHING factual (a session once echoed SKILL.md's canonical
305
- // example while the detector said otherwise). Always exactly one line.
306
-
307
- // The CONFIGURED dispatch posture: the bundled manifests' pins overlaid with the active
308
- // bridge-settings. No pins or a corrupt bundle → null; the posture segment is a fact, never a gate.
301
+ // Configured posture from bundled manifest pins plus bridge settings; corruption returns null.
309
302
  export const composeConfiguredPosture = (ctx = {}) => {
310
303
  try {
311
- const bundleRoot = ctx.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
312
- const read = ctx.readFile ?? readFileSync;
313
- const parts = [];
314
- for (const name of BACKEND_PRIORITY) {
315
- let manifest;
316
- try {
317
- manifest = JSON.parse(String(read(join(bundleRoot, name, 'capability.json'), 'utf8')));
318
- } catch {
319
- return null; // an unreadable/corrupt manifest is CORRUPTION — a partial posture would lie
320
- }
321
- if (!Object.hasOwn(manifest, 'posture')) continue; // a pre-D5 bridge — legitimate absence
322
- const posture = manifest.posture;
323
- // A present-but-invalid block nulls the WHOLE render — never a partial/mangled line.
324
- const invalid =
325
- posture === null || typeof posture !== 'object' || Array.isArray(posture) ||
326
- typeof posture.model !== 'string' || posture.model.length === 0 ||
327
- (Object.hasOwn(posture, 'effort') && (typeof posture.effort !== 'string' || posture.effort.length === 0)) ||
328
- (Object.hasOwn(posture, 'tier') && posture.tier !== null && (typeof posture.tier !== 'string' || posture.tier.length === 0)) ||
329
- Object.keys(posture).some((k) => !['model', 'effort', 'tier'].includes(k));
330
- if (invalid) return null;
331
- const seg = [`model=${posture.model}`];
332
- if (Object.hasOwn(posture, 'effort')) seg.push(`effort=${posture.effort}`);
333
- if (Object.hasOwn(posture, 'tier')) {
334
- const knob = (ctx.settings?.active ?? []).find((s) => s.key === 'CODEX_SERVICE_TIER' && s.bridge === name);
335
- seg.push(knob ? `tier=${knob.value} (bridge-settings)` : `tier=${posture.tier ?? 'standard'}`);
336
- }
337
- parts.push(`${DISPLAY_ALIASES[name] ?? name} ${seg.join(' ')}`);
338
- }
304
+ const rows = posturesByBackend({ bundleRoot: ctx.bundleRoot ?? DEFAULT_BUNDLE_ROOT, settings: ctx.settings ?? { active: [] }, readFile: ctx.readFile ?? readFileSync });
305
+ if (Object.values(rows).some((row) => row.state === 'unreadable')) return null;
306
+ const parts = Object.entries(rows)
307
+ .filter(([, row]) => row.state === 'valid')
308
+ .map(([receiptId, row]) => `${receiptId} ${row.posture}`);
339
309
  return parts.length ? parts.join(' · ') : null;
340
310
  } catch {
341
311
  return null;
@@ -422,19 +392,25 @@ export const composeAutonomyFacts = async (cwd, deps = {}) => {
422
392
  // ONE line rendering the CONFIGURED recipe of every activity/slot with its source, degradation and
423
393
  // dispatched wrappers — contrasted with the readiness-RECOMMENDED recipe, which is NOT what runs.
424
394
  // The session-start checklist and the handover "Active recipes:" slot paste it verbatim.
425
- export const composeActiveRecipeLine = ({ config, source } = {}, detection, autonomy = null) => {
395
+ export const composeActiveRecipeLine = ({ config, source } = {}, detection, autonomy = null, rosterDeps = null) => {
426
396
  const cells = [];
427
397
  for (const [activity, def] of Object.entries(ACTIVITIES)) {
428
398
  const level = autonomy?.activities?.[activity]?.autonomy;
429
399
  const auto = level ? `; autonomy ${level}` : '';
430
400
  for (const [slot, slotType] of Object.entries(def.slots)) {
431
- const r = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot });
432
- const dispatch = isSwitchSlot(slotType) ? [] : planRecipe(r.recipe, detection).dispatch;
401
+ const r = resolveActivityRecipe({
402
+ config: config ?? {}, readiness: detection, activity, slot,
403
+ surveyLens: rosterDeps?.surveyLens, postures: rosterDeps?.postures,
404
+ });
405
+ const dispatch = isSwitchSlot(slotType) || r.roster ? [] : planRecipe(r.recipe, detection).dispatch;
433
406
  const wrappers = dispatch.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
434
407
  const srcLabel = r.source === 'config' ? 'configured' : 'computed default';
408
+ const renderedValue = r.roster
409
+ ? activeLineCell(r.roster, { states: rosterDeps !== null })
410
+ : r.recipe;
435
411
  const head = r.degradedFrom
436
412
  ? `${activity}.${slot} = ${r.degradedFrom} (${srcLabel}; degrades here to ${r.recipe} — ${r.reason}${auto})`
437
- : `${activity}.${slot} = ${r.recipe} (${srcLabel}${isSwitchSlot(slotType) ? '; switch' : ''}${auto})`;
413
+ : `${activity}.${slot} = ${renderedValue} (${srcLabel}${isSwitchSlot(slotType) ? '; switch' : ''}${auto})`;
438
414
  const suffix =
439
415
  wrappers.length >= 2
440
416
  ? ` → every backend every round: ${wrappers.join(' + ')}`
@@ -553,7 +529,12 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
553
529
  // Lazy: orchestration-config.mjs statically imports this module — no static cycle.
554
530
  const { loadConfig } = await import('./orchestration-config.mjs');
555
531
  try {
556
- console.log(composeActiveRecipeLine(loadConfig(cwd), readiness, await composeAutonomyFacts(cwd)));
532
+ const snapshot = settingsSnapshot();
533
+ const surveyLens = deps.surveyLens ?? ((spec) => surveyVehicle(cwd, spec, deps));
534
+ console.log(composeActiveRecipeLine(
535
+ loadConfig(cwd), readiness, await composeAutonomyFacts(cwd),
536
+ { surveyLens, postures: posturesByBackend({ settings: snapshot }) },
537
+ ));
557
538
  } catch (err) {
558
539
  console.error(`[agent-workflow-kit] ${err.message}`);
559
540
  return err.exitCode ?? 1;
@@ -20,3 +20,43 @@ export const lexicalRepoRelative = (rel) => {
20
20
 
21
21
  // POSIX single-quote for pasteable command rendering (display only — never an execution boundary).
22
22
  export const shellQuoteArg = (s) => (/^[A-Za-z0-9_/.\-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
23
+
24
+ // The bytes a settings-level allow rule cannot see past: command separators, redirections,
25
+ // expansions and globs. ONE home for the seeder (velocity-profile.mjs, the allow-rule writer) and
26
+ // the renders that must spell a path in the seeded byte-form (procedures.mjs) — two predicates for
27
+ // one rule drift apart, and a drifted render is a dead allow rule that simply prompts.
28
+ export const SHELL_METACHARACTERS = Object.freeze([
29
+ '&', '|', ';', '<', '>', '$', '`', '(', ')',
30
+ '\n', '\r', '\t', '\\', '{', '}', '*', '?', '#', '~', '!',
31
+ ]);
32
+ export const hasShellMetacharacter = (cmd) => SHELL_METACHARACTERS.some((ch) => cmd.includes(ch));
33
+
34
+ // A string a one-line render can carry: no control character (C0 or C1) and no Unicode line or
35
+ // paragraph separator. The receipt-derived fields are REFUSED on it; a plan name is escaped for display.
36
+ const LINE_BREAKING_SOURCE = '[\\p{Cc}\\p{Zl}\\p{Zp}]';
37
+ const LINE_BREAKING = new RegExp(LINE_BREAKING_SOURCE, 'u');
38
+ const LINE_BREAKING_ALL = new RegExp(LINE_BREAKING_SOURCE, 'gu');
39
+ export const isRenderableLine = (value) => typeof value === 'string' && !LINE_BREAKING.test(value);
40
+ export const escapeForDisplay = (value) => String(value).replace(LINE_BREAKING_ALL, (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`);
41
+
42
+ // The receipt encoder's carriability rule for an artifact path (S21), the JS twin of the wrappers'
43
+ // refuse_uncarriable_artifact_byte: a quote, a backslash, a C0 control or DEL — deliberately NOT
44
+ // \p{Cc} (C1 is the declared residual, and the two normalizations are parity-pinned on this set).
45
+ // ONE home: the round table's refusal names the byte, the advisor's fallback reads the boolean.
46
+ export const uncarriableArtifactByte = (value) =>
47
+ value.includes('"') ? 'a double quote' : value.includes('\\') ? 'a backslash' : /[\u0000-\u001f\u007f]/u.test(value) ? 'a control' : null;
48
+ export const isArtifactPathCarriable = (value) => typeof value === 'string' && uncarriableArtifactByte(value) === null;
49
+
50
+ // Characters that survive whitespace tokenization but break an UNQUOTED byte-exact path rule:
51
+ // shell quoting syntax and glob brackets (SHELL_METACHARACTERS owns the command-level separators/
52
+ // redirections/expansions — `*`/`?` globs included — but not these four).
53
+ const PATH_BREAKING_CHARACTERS = Object.freeze(["'", '"', '[', ']']);
54
+
55
+ // A path token that can be seeded UNQUOTED into a byte-exact allow rule: POSIX-absolute, no
56
+ // whitespace, no shell metacharacter, no quoting/glob syntax.
57
+ export const isSeedablePathToken = (token) =>
58
+ typeof token === 'string' &&
59
+ token.startsWith('/') &&
60
+ !/\s/u.test(token) &&
61
+ !hasShellMetacharacter(token) &&
62
+ !PATH_BREAKING_CHARACTERS.some((ch) => token.includes(ch));