session-orchestrator 3.21.0 → 3.22.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/040-discovery.mdc +6 -6
  6. package/.cursor/rules/050-plan.mdc +8 -8
  7. package/CHANGELOG.md +101 -0
  8. package/README.md +10 -10
  9. package/agents/memory-proposal-collector.md +6 -4
  10. package/commands/eli5.md +33 -0
  11. package/commands/release.md +5 -3
  12. package/commands/test.md +2 -2
  13. package/docs/components.md +6 -5
  14. package/docs/scope-collision-guard.md +3 -3
  15. package/docs/session-config-reference.md +31 -8
  16. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  17. package/hooks/hooks-codex.json +1 -1
  18. package/hooks/hooks.json +11 -1
  19. package/hooks/on-session-end.mjs +24 -92
  20. package/hooks/on-session-start.mjs +195 -104
  21. package/hooks/pre-auq-clarity.mjs +787 -0
  22. package/hooks/pre-bash-issue-budget.mjs +17 -18
  23. package/package.json +3 -1
  24. package/pi/prompts/eli5.md +12 -0
  25. package/scripts/auq-audit.mjs +825 -0
  26. package/scripts/autopilot.mjs +7 -8
  27. package/scripts/lib/auq/clarity.mjs +1314 -0
  28. package/scripts/lib/auq/parse.mjs +1006 -0
  29. package/scripts/lib/auq/schema.mjs +1457 -0
  30. package/scripts/lib/ci-status-banner.mjs +63 -57
  31. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  32. package/scripts/lib/config/vault-integration.mjs +12 -1
  33. package/scripts/lib/dispatcher/rank.mjs +4 -7
  34. package/scripts/lib/gates/gate-full.mjs +3 -3
  35. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  36. package/scripts/lib/io.mjs +239 -0
  37. package/scripts/lib/issue-budget.mjs +63 -9
  38. package/scripts/lib/owner-interview.mjs +78 -32
  39. package/scripts/lib/peer-discovery.mjs +73 -22
  40. package/scripts/lib/project-hygiene.mjs +64 -4
  41. package/scripts/lib/reconcile/renderer.mjs +17 -4
  42. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  43. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  44. package/scripts/lib/resource-probe.mjs +18 -2
  45. package/scripts/lib/spiral-carryover.mjs +23 -2
  46. package/scripts/lib/state-md/mission-status.mjs +147 -50
  47. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  48. package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
  49. package/scripts/lib/validate/check-rules.mjs +153 -9
  50. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  51. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  52. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  53. package/scripts/lib/wave-resource-gate.mjs +67 -73
  54. package/scripts/materialize-wave-scope.mjs +281 -0
  55. package/scripts/release.mjs +443 -122
  56. package/scripts/run-quality-gate.mjs +14 -0
  57. package/scripts/validate-plugin.mjs +3 -0
  58. package/scripts/validate-wave-scope.mjs +6 -1
  59. package/scripts/vault-backfill.mjs +32 -5
  60. package/skills/_shared/parallel-aware-auq.md +30 -24
  61. package/skills/_shared/parallel-aware-preamble.md +31 -2
  62. package/skills/_shared/state-ownership.md +32 -6
  63. package/skills/bootstrap/SKILL.md +2 -1
  64. package/skills/brainstorm/SKILL.md +18 -18
  65. package/skills/brainstorm/soul.md +12 -0
  66. package/skills/discovery/SKILL.md +28 -24
  67. package/skills/eli5/SKILL.md +43 -0
  68. package/skills/evolve/SKILL.md +8 -9
  69. package/skills/gitlab-ops/SKILL.md +30 -26
  70. package/skills/grill/SKILL.md +6 -6
  71. package/skills/grill/soul.md +16 -0
  72. package/skills/memory-cleanup/SKILL.md +2 -2
  73. package/skills/npm-publish/SKILL.md +4 -4
  74. package/skills/peekaboo-driver/SKILL.md +3 -3
  75. package/skills/plan/SKILL.md +18 -16
  76. package/skills/plan/mode-feature.md +1 -1
  77. package/skills/plan/mode-new.md +35 -23
  78. package/skills/plan/soul.md +12 -0
  79. package/skills/reconcile/SKILL.md +3 -3
  80. package/skills/session-end/SKILL.md +53 -20
  81. package/skills/session-end/phase-3-6-tail.md +37 -2
  82. package/skills/session-start/SKILL.md +69 -35
  83. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  84. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  85. package/skills/session-start/soul.md +110 -0
  86. package/skills/test-runner/SKILL.md +2 -2
  87. package/skills/using-orchestrator/SKILL.md +1 -1
  88. package/skills/wave-executor/wave-loop.md +27 -5
  89. package/skills/write-executable-plan/SKILL.md +6 -6
  90. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  91. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  92. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  93. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  94. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  95. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  96. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  97. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  98. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  99. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  100. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  101. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  102. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  103. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  104. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  105. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  106. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  107. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  108. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  109. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  110. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  111. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  112. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  113. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  114. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  115. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  116. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  117. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -98,6 +98,45 @@ const DENY_REASON_MAX = 16_000;
98
98
  */
99
99
  const WARN_MESSAGE_MAX = 16_000;
100
100
 
101
+ /**
102
+ * Hard ceiling (in BYTES, including the trailing newline) for the whole
103
+ * {@link emitRewrite} envelope.
104
+ *
105
+ * Denominated in bytes, not characters, unlike {@link DENY_REASON_MAX} — the
106
+ * quantity that actually matters here is what the kernel pipe buffer holds, and
107
+ * a character count only approximates that for ASCII. `Buffer.byteLength` makes
108
+ * the bound exact for any input.
109
+ *
110
+ * Derivation, measured 2026-08-22 against the repo's own AUQ corpus
111
+ * (`node scripts/auq-audit.mjs --json`, 70 real question blocks, HEAD clean):
112
+ *
113
+ * | quantity | measured |
114
+ * |-----------------------------------------------------------|----------|
115
+ * | largest real tool-input in the corpus (plan/SKILL.md:136) | 1 369 B |
116
+ * | median / mean over all 70 blocks | 549 / 547 B |
117
+ * | max question text · header · label · description · preview | 269 · 12 · 48 · 180 · 252 |
118
+ * | protocol-cap worst case: 4 questions × 4 options, each field at its measured max | 9 785 B |
119
+ *
120
+ * The protocol caps come from `.claude/rules/ask-via-tool.md` § AUQ-003 (1–4
121
+ * questions, 2–4 options); the corpus tops out at 3 questions and 4 options, so
122
+ * the 9 785 B row is already an over-estimate of anything observed. 32 768 sits
123
+ * **3.35× above that worst case** and **23.9× above the largest real payload**,
124
+ * while being exactly **half** the 65 536-byte kernel pipe buffer — so a
125
+ * cap-sized envelope fits one buffer with the whole second half to spare, and a
126
+ * rewrite that expands its source text several-fold is still nowhere near the
127
+ * ceiling.
128
+ *
129
+ * ## Why this REJECTS where {@link DENY_REASON_MAX} CLAMPS
130
+ *
131
+ * A deny reason is prose: clipping it leaves a valid deny with a shorter
132
+ * explanation. `updatedInput` is structure: clipping it mid-object yields
133
+ * unparseable JSON, which the harness reads as no-decision — the truncated
134
+ * envelope would silently discard the rewrite while looking like a successful
135
+ * emit. So an over-ceiling payload is never sliced; it is refused whole, and the
136
+ * tool call proceeds with its original input (see {@link emitRewrite}).
137
+ */
138
+ const REWRITE_ENVELOPE_MAX_BYTES = 32_768;
139
+
101
140
  /**
102
141
  * Reason substituted when a caller denies without supplying one.
103
142
  *
@@ -489,6 +528,206 @@ export function emitWarn(message) {
489
528
  process.exit(0);
490
529
  }
491
530
 
531
+ /**
532
+ * Rewrite the **tool input** of the current PreToolUse invocation and let the
533
+ * normal flow continue: emit exactly one JSON object on stdout, then exit **0**.
534
+ *
535
+ * This is the third PreToolUse verb, alongside {@link emitDeny} ("block") and
536
+ * {@link emitWarn} ("allow, with a notice"). It decides nothing — it hands the
537
+ * harness a replacement input and steps out of the way.
538
+ *
539
+ * ## Emitted payload (single stdout line, nothing else on stdout)
540
+ *
541
+ * ```json
542
+ * {"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{…}}}
543
+ * ```
544
+ *
545
+ * ## THE TRAP: there is deliberately no `permissionDecision`, and no way to add one
546
+ *
547
+ * The shipped Claude Code binary (2.1.239) routes the rewrite through a branch
548
+ * that is guarded on the ABSENCE of a permission decision:
549
+ *
550
+ * ```js
551
+ * if (p.updatedInput && p.permissionBehavior === void 0)
552
+ * yield { type: "hookUpdatedInput", updatedInput: p.updatedInput };
553
+ * ```
554
+ *
555
+ * Pair `updatedInput` with `permissionDecision: "allow"` and a different branch
556
+ * takes it: the input is still replaced, but the **permission stage is skipped**.
557
+ * For most tools that is merely a lost prompt. For `AskUserQuestion` it is fatal,
558
+ * because the permission stage IS the question card — `checkPermissions` returns
559
+ * `behavior: "ask"`, and that "ask" is what renders the options to the operator.
560
+ * An `allow` there does not approve the question; it routes the question PAST the
561
+ * human. The operator is never asked, nothing errors, and the omission is
562
+ * invisible from both ends.
563
+ *
564
+ * The prevention is structural, not advisory. This function takes **one**
565
+ * parameter — the tool input — and builds `hookSpecificOutput` here as a
566
+ * two-key object literal with no spread, no `opts` bag, and no caller-reachable
567
+ * key. There is no argument a caller can pass that lands as a sibling of
568
+ * `updatedInput`; a `permissionDecision` key inside the caller's own object
569
+ * nests one level deeper (`updatedInput.permissionDecision`), where the harness
570
+ * ignores it. Adding a spread or an options parameter here would re-open the
571
+ * trap — that is what `tests/lib/io.test.mjs` pins, since the structure cannot
572
+ * defend itself against a future edit.
573
+ *
574
+ * A hook that needs BOTH a permission decision and a rewrite cannot have both:
575
+ * choose the decision, because a rewrite that skips the operator's question is
576
+ * the exact failure this function is shaped to avoid.
577
+ *
578
+ * ## `updatedInput` is the COMPLETE tool input, never a patch
579
+ *
580
+ * The bundle types it as a map, not a diff (`updatedInput: oo(H(), Pn())`), and
581
+ * the harness substitutes it wholesale. A caller that passes only the fields it
582
+ * changed **deletes every field it omitted** — for `AskUserQuestion`, passing
583
+ * `{questions: [{question: '…'}]}` drops the options and the header along with
584
+ * them. Read the original input from the hook payload, modify it, and pass the
585
+ * whole object back.
586
+ *
587
+ * ## Fail-closed on an invalid rewrite is the HARNESS's behaviour, not ours
588
+ *
589
+ * A syntactically fine but schema-invalid `updatedInput` (5 questions where 4 is
590
+ * the cap, 1 option where 2 is the minimum) does not slip through as a silent
591
+ * pass — the bundle turns it into a deny:
592
+ *
593
+ * ```js
594
+ * if (!f.success && m.length > 0) { … u = { behavior: "deny", message: g, … }; continue }
595
+ * ```
596
+ *
597
+ * So a malformed rewrite costs the tool call, not the operator's trust. Callers
598
+ * are still responsible for emitting a schema-valid input; this note only
599
+ * records that the failure direction is safe.
600
+ *
601
+ * ## EVIDENCE STATUS — code-evidence, not runtime-evidence
602
+ *
603
+ * Everything above is read out of the shipped 2.1.239 bundle. **No hook with a
604
+ * matcher of `AskUserQuestion` has ever run in this repo**, so the rewrite
605
+ * branch has not been observed executing. The runtime proof needs a session
606
+ * restart and is outstanding. Treat the branch conditions as verified source and
607
+ * the end-to-end behaviour as expected-but-unconfirmed.
608
+ *
609
+ * ## When emitRewrite is the WRONG verb
610
+ *
611
+ * 1. **You need to block.** A rewrite carries no denial; the tool call proceeds
612
+ * whatever the new input says. Use {@link emitDeny}.
613
+ * 2. **You need to pre-approve.** Skipping the permission prompt is
614
+ * `permissionDecision: "allow"`, and combining it with a rewrite is the trap
615
+ * above. Emit one or the other, never a hand-rolled envelope carrying both.
616
+ * 3. **The event is not PreToolUse.** `hookEventName` is hardcoded; PostToolUse
617
+ * / Stop / SubagentStop have no `updatedInput` at all and signal through a
618
+ * top-level `decision` (same precondition as {@link emitDeny}).
619
+ * 4. **You cannot reconstruct the whole input.** A partial object deletes the
620
+ * rest — if the original input is not in hand, do nothing ({@link emitAllow})
621
+ * rather than emit a lossy replacement.
622
+ *
623
+ * ## Delivery, and why a failed write still exits 0
624
+ *
625
+ * The envelope goes out through {@link writeStdoutLineSync}, never
626
+ * `console.log`: on macOS a piped stdout is asynchronous, so `console.log` +
627
+ * `process.exit(0)` drops everything past the 65 536-byte kernel pipe buffer and
628
+ * the rewrite vanishes without a trace. The payload is additionally bounded by
629
+ * {@link REWRITE_ENVELOPE_MAX_BYTES} — refused whole rather than clipped, since
630
+ * a clipped JSON object is not a smaller rewrite but an unparseable one.
631
+ *
632
+ * ── BV-004: at TODAY'S ceiling the two bounds are not equally load-bearing ────
633
+ * Measured 2026-08-22 (`console.log` of an N-byte line + `process.exit(0)`,
634
+ * piped into a reader that sleeps 300 ms before draining, so the buffer really
635
+ * fills): N=200 000 → 65 536 delivered, N=70 000 → 65 536, **N=32 768 → 32 768
636
+ * delivered intact**. Since the ceiling refuses anything larger, no payload this
637
+ * function can emit is big enough for `console.log` to lose — the clamp alone
638
+ * carries the guarantee today, and the synchronous write is the redundant half.
639
+ * It stays anyway, and this is the trigger to re-read before touching either:
640
+ * **raise {@link REWRITE_ENVELOPE_MAX_BYTES} above 65 536 and the sync write
641
+ * becomes the ONLY thing standing between a large rewrite and silent
642
+ * truncation.** Swapping in `console.log` "because the cap already protects us"
643
+ * is safe only for as long as nobody moves the cap — which is precisely the kind
644
+ * of coupling that rots unremarked. Note also that no behavioural test can
645
+ * currently distinguish the two writers here (see `tests/lib/io.test.mjs`).
646
+ *
647
+ * {@link emitDeny} exits **2** when stdout is unwritable, because its decision is
648
+ * "block" and with the structured channel gone the exit code is the only
649
+ * blocking signal left. **This function must not copy that.** `emitRewrite`
650
+ * holds no decision to preserve: its degraded state is "the tool call runs with
651
+ * its original input", which is exactly what happens when no envelope is
652
+ * emitted. Exit 2 would convert that harmless loss into a block —
653
+ * `scripts/lib/pi-hook-bridge.mjs:389` treats the status unconditionally
654
+ * (`const blocked = result.status === 2 || …`, evaluated before stdout is
655
+ * consulted), and the documented contract says exit 2 discards stdout and feeds
656
+ * stderr back to Claude as an error. On the `AskUserQuestion` path that means
657
+ * the operator's question is destroyed to protect a wording improvement. Every
658
+ * failure here therefore degrades to "no stdout, exit 0, loud stderr" — the same
659
+ * asymmetry {@link emitWarn} documents, for the same reason.
660
+ *
661
+ * Never throws, for the reason {@link emitDeny} spells out: a throw unwinds into
662
+ * the `main().catch(() => emitAllow())` that four hooks install. Here that
663
+ * catch would be harmless by luck rather than by design, and a hook whose catch
664
+ * routes to `emitDeny` instead would turn a failed rewrite into a block.
665
+ *
666
+ * @param {object} updatedInput The COMPLETE replacement tool input. A non-object,
667
+ * `null`, an array, or a value `JSON.stringify` rejects degrades to a
668
+ * no-op plus a stderr diagnostic — never a throw, never a partial emit.
669
+ * @returns {never}
670
+ */
671
+ export function emitRewrite(updatedInput) {
672
+ /**
673
+ * Abandon the rewrite: say why on stderr, emit nothing, exit 0. The tool call
674
+ * then runs with its original input — the intended degraded state, not a
675
+ * fail-open, because this helper never held a decision to lose.
676
+ *
677
+ * @param {string} diagnostic
678
+ * @returns {never}
679
+ */
680
+ const bail = (diagnostic) => {
681
+ try {
682
+ process.stderr.write(
683
+ `⚠ io.mjs: emitRewrite ${diagnostic} — leaving the tool input unchanged\n`,
684
+ );
685
+ } catch { /* stderr may be closed; the silent no-op below is what matters */ }
686
+ process.exit(0);
687
+ };
688
+
689
+ if (updatedInput === null || typeof updatedInput !== 'object' || Array.isArray(updatedInput)) {
690
+ // `updatedInput` is a MAP in the bundle schema, so an array is as wrong as a
691
+ // string — and an array would serialize into a shape the harness cannot use.
692
+ bail(
693
+ `was called with ${Array.isArray(updatedInput) ? 'an array' : String(updatedInput === null ? 'null' : typeof updatedInput)}, not a tool-input object`,
694
+ );
695
+ }
696
+
697
+ let line;
698
+ try {
699
+ // The whole trap-closure: a two-key object literal, built here. No spread,
700
+ // no caller-supplied opts — nothing a caller passes can become a sibling of
701
+ // `updatedInput`, and `permissionDecision` therefore cannot appear.
702
+ line = JSON.stringify({
703
+ hookSpecificOutput: {
704
+ hookEventName: 'PreToolUse',
705
+ updatedInput,
706
+ },
707
+ });
708
+ } catch (err) {
709
+ // A cycle or a BigInt in the caller's object. JSON.stringify can also return
710
+ // undefined (a toJSON that yields undefined) — caught by the same guard.
711
+ bail(`could not serialize the tool input (${err?.message ?? String(err)})`);
712
+ }
713
+
714
+ if (typeof line !== 'string') {
715
+ bail('serialized the tool input to undefined (a toJSON returning undefined?)');
716
+ }
717
+
718
+ const bytes = Buffer.byteLength(`${line}\n`, 'utf8');
719
+ if (bytes > REWRITE_ENVELOPE_MAX_BYTES) {
720
+ // Refused whole, never sliced — see REWRITE_ENVELOPE_MAX_BYTES for why a
721
+ // clipped structure is worse than no structure.
722
+ bail(`envelope is ${bytes} bytes, over the ${REWRITE_ENVELOPE_MAX_BYTES}-byte ceiling`);
723
+ }
724
+
725
+ // Return value deliberately ignored: a rewrite that cannot be delivered costs
726
+ // the improvement, never the tool call. See the asymmetry note above.
727
+ writeStdoutLineSync(line);
728
+ process.exit(0);
729
+ }
730
+
492
731
  /**
493
732
  * Inject a system message into the hook response without exiting.
494
733
  * @param {string} msg Message text wrapped in a systemMessage JSON envelope on stdout.
@@ -37,6 +37,36 @@ import { _parseIssueBudget } from './config/issue-budget.mjs';
37
37
  /** Runtime counter file, relative to the repo root. */
38
38
  export const BUDGET_STATE_REL = '.orchestrator/runtime/issue-budget.json';
39
39
 
40
+ /**
41
+ * Resolve the accounting key for a native session id.
42
+ *
43
+ * A semantic id is an accounting continuity bridge only after the native id
44
+ * proves that `current-session.json` belongs to this invocation. This neither
45
+ * establishes lock ownership nor bridges a host rotation that changes both ids.
46
+ *
47
+ * @param {string|null|undefined} candidateRawSessionId native hook/env session id
48
+ * @param {unknown} currentSession parsed `.orchestrator/current-session.json`
49
+ * @returns {string|null} semantic key for a verified pair, otherwise raw key
50
+ */
51
+ export function resolveIssueBudgetSessionId(candidateRawSessionId, currentSession) {
52
+ const rawSessionId =
53
+ typeof candidateRawSessionId === 'string' && candidateRawSessionId.length > 0
54
+ ? candidateRawSessionId
55
+ : null;
56
+ if (
57
+ rawSessionId === null ||
58
+ !currentSession ||
59
+ typeof currentSession !== 'object' ||
60
+ Array.isArray(currentSession) ||
61
+ currentSession.session_id !== rawSessionId ||
62
+ typeof currentSession.semantic_session_id !== 'string' ||
63
+ currentSession.semantic_session_id.length === 0
64
+ ) {
65
+ return rawSessionId;
66
+ }
67
+ return currentSession.semantic_session_id;
68
+ }
69
+
40
70
  /**
41
71
  * Commands whose issue creation MUST NOT be blocked, with the reason recorded
42
72
  * for the stderr trace and for the overflow bookkeeping.
@@ -109,20 +139,34 @@ export function budgetStatePath(repoRoot) {
109
139
  * a fresh zeroed state for `sessionId` — the counter is per session by
110
140
  * construction, so a new session never inherits the previous session's spend.
111
141
  *
142
+ * An identity-less invocation always gets a fresh state and never reads a
143
+ * persisted budget. It therefore cannot provide durable per-session continuity,
144
+ * but avoiding cross-session budget and overflow attribution wins over a
145
+ * continuity guess without a verified native identity.
146
+ *
147
+ * The counter file is SHARED across invocations, so this read-side isolation is
148
+ * only half the contract: an identity-less charge must also never PERSIST its
149
+ * fresh state, or it silently zeroes a live session's count and deletes its
150
+ * parked overflow records. `chargeIssueBudget` enforces that write-side half.
151
+ *
112
152
  * @param {string} repoRoot
113
153
  * @param {string|null} sessionId
114
154
  * @returns {{ sessionId: string|null, count: number, exempt: number, overflow: object[] }}
115
155
  */
116
156
  export function readBudgetState(repoRoot, sessionId) {
117
- const fresh = { sessionId: sessionId ?? null, count: 0, exempt: 0, overflow: [] };
157
+ const accountingSessionId =
158
+ typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
159
+ const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
160
+ if (accountingSessionId === null) return fresh;
161
+
118
162
  const file = budgetStatePath(repoRoot);
119
163
  if (!existsSync(file)) return fresh;
120
164
  try {
121
165
  const data = JSON.parse(readFileSync(file, 'utf8'));
122
166
  if (!data || typeof data !== 'object') return fresh;
123
- if (sessionId && data.sessionId && data.sessionId !== sessionId) return fresh;
167
+ if (data.sessionId !== accountingSessionId) return fresh;
124
168
  return {
125
- sessionId: data.sessionId ?? sessionId ?? null,
169
+ sessionId: accountingSessionId,
126
170
  count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
127
171
  exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
128
172
  overflow: Array.isArray(data.overflow) ? data.overflow : [],
@@ -200,13 +244,23 @@ export function chargeIssueBudget({
200
244
  return { ...base, decision: 'off', count: 0, overflowCount: 0, reason: null };
201
245
  }
202
246
 
203
- const state = readBudgetState(repoRoot, sessionId);
204
- state.sessionId = sessionId ?? state.sessionId;
247
+ const accountingSessionId =
248
+ typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
249
+ const state = readBudgetState(repoRoot, accountingSessionId);
250
+ state.sessionId = accountingSessionId;
251
+
252
+ // An identity-less charge has no key to account under, so it must not touch
253
+ // the SHARED counter file at all. Writing its fresh state would reset a live
254
+ // session's count to 0 AND drop its parked overflow[] entries — breaking the
255
+ // strict cap (a single identity-less call clears it) and session-end's
256
+ // "nothing is lost" promise. Read-side isolation alone does not cover this.
257
+ const persist = (next) =>
258
+ accountingSessionId === null ? false : writeBudgetState(repoRoot, next);
205
259
 
206
260
  const { exempt, reason } = classifyExemption(command);
207
261
  if (exempt) {
208
262
  state.exempt += 1;
209
- writeBudgetState(repoRoot, state);
263
+ persist(state);
210
264
  return {
211
265
  ...base,
212
266
  decision: 'exempt',
@@ -218,13 +272,13 @@ export function chargeIssueBudget({
218
272
 
219
273
  if (state.count < max) {
220
274
  state.count += 1;
221
- writeBudgetState(repoRoot, state);
275
+ persist(state);
222
276
  return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
223
277
  }
224
278
 
225
279
  if (mode === 'warn') {
226
280
  state.count += 1;
227
- writeBudgetState(repoRoot, state);
281
+ persist(state);
228
282
  return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
229
283
  }
230
284
 
@@ -234,7 +288,7 @@ export function chargeIssueBudget({
234
288
  command: String(command).slice(0, 500),
235
289
  at: now,
236
290
  });
237
- writeBudgetState(repoRoot, state);
291
+ persist(state);
238
292
  return {
239
293
  ...base,
240
294
  decision: 'block',
@@ -13,10 +13,16 @@
13
13
  * to AskUserQuestion in the coordinator. Each object has the shape:
14
14
  * { question, header, options: [{ label, description }], multiSelect }
15
15
  *
16
+ * optionValue(label)
17
+ * Maps a displayed option label to the value stored in owner.yaml by
18
+ * stripping the trailing `(Recommended)` marker. The label and the stored
19
+ * value are deliberately NOT the same string — see the function comment.
20
+ *
16
21
  * applyInterviewAnswers(answers, { path? } = {})
17
22
  * Accepts an array of selected option labels (one per question, same order
18
- * as getInterviewQuestions()), validates the result against validateOwnerConfig,
19
- * and writes owner.yaml via writeOwnerConfig.
23
+ * as getInterviewQuestions()), resolves each through optionValue(), validates
24
+ * the result against validateOwnerConfig, and writes owner.yaml via
25
+ * writeOwnerConfig.
20
26
  * Returns { ok, path, errors }.
21
27
  *
22
28
  * runOwnerInterview({ skipIfExists?, force?, path? } = {})
@@ -37,6 +43,40 @@ import {
37
43
  getDefaults,
38
44
  } from './owner-yaml.mjs';
39
45
 
46
+ // ---------------------------------------------------------------------------
47
+ // Label ←→ stored value
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * A trailing recommendation marker on an option LABEL — the display half of a
52
+ * label, never part of the stored value.
53
+ *
54
+ * Anchored at the end and non-greedy about whitespace so it can only ever strip
55
+ * a suffix; a value that merely CONTAINS the word (there is none today) survives.
56
+ */
57
+ const RECOMMENDED_SUFFIX = /\s*\((?:Recommended|Empfohlen|Default)\)\s*$/u;
58
+
59
+ /**
60
+ * Map an AUQ option label back to the value that is written to owner.yaml.
61
+ *
62
+ * The label and the stored value used to be the SAME string, which made the
63
+ * label unchangeable: `applyInterviewAnswers()` matches the answer against
64
+ * closed enums (`['direct','neutral','friendly']` and friends) and falls back to
65
+ * a default on any miss. Adding the `(Recommended)` marker AUQ-003 requires
66
+ * would therefore have turned a picked `direct` into a silently-stored
67
+ * `neutral` — a wrong answer written to disk with no error anywhere.
68
+ *
69
+ * Splitting the two keeps the label free for the operator and the value pinned
70
+ * to the enum. Everything before the marker is the value, verbatim.
71
+ *
72
+ * @param {unknown} label — the option label as selected by the operator
73
+ * @returns {string} the enum value to store, or '' for a non-string input
74
+ */
75
+ export function optionValue(label) {
76
+ if (typeof label !== 'string') return '';
77
+ return label.replace(RECOMMENDED_SUFFIX, '').trim();
78
+ }
79
+
40
80
  // ---------------------------------------------------------------------------
41
81
  // Question definitions
42
82
  // ---------------------------------------------------------------------------
@@ -50,51 +90,51 @@ import {
50
90
  export function getInterviewQuestions() {
51
91
  return [
52
92
  {
53
- question: 'Which language should the assistant use for its responses?',
54
- header: 'Owner Interview — Language (1/5)',
93
+ question: 'Which language should the assistant answer in?',
94
+ header: 'Language 1/5',
55
95
  options: [
56
- { label: 'de', description: 'German — responses, narration, and questions in Deutsch.' },
57
- { label: 'en', description: 'English — responses, narration, and questions in English.' },
58
- { label: 'other', description: 'Otherwrite your ISO-639-1 code when prompted.' },
96
+ { label: 'de', description: 'German — answers, narration and questions all in Deutsch.' },
97
+ { label: 'en', description: 'English — answers, narration and questions all in English.' },
98
+ { label: 'other', description: 'Stored as English either way only de and en are accepted. Change it later in owner.yaml (your settings file).' },
59
99
  ],
60
100
  multiSelect: false,
61
101
  },
62
102
  {
63
- question: 'What communication tone style do you prefer?',
64
- header: 'Owner Interview — Tone Style (2/5)',
103
+ question: 'How should the assistant talk to you?',
104
+ header: 'Tone 2/5',
65
105
  options: [
66
- { label: 'direct', description: '(Recommended for pros) No filler phrases, straight to the point.' },
67
- { label: 'neutral', description: 'Balanced: professional without being terse.' },
68
- { label: 'friendly', description: 'Warm and conversational good for exploratory sessions.' },
106
+ { label: 'direct (Recommended)', description: 'No filler, no praise, straight to the point — fastest to read once you know the project.' },
107
+ { label: 'neutral', description: 'Professional without being terse. Pick this if direct reads too blunt.' },
108
+ { label: 'friendly', description: 'Warm and conversational. Costs a few lines per answer, and suits open-ended exploration.' },
69
109
  ],
70
110
  multiSelect: false,
71
111
  },
72
112
  {
73
- question: 'How much output should the assistant produce by default?',
74
- header: 'Owner Interview — Output Level (3/5)',
113
+ question: 'How much should the assistant write by default?',
114
+ header: 'Output 3/5',
75
115
  options: [
76
- { label: 'lite', description: 'Verbose mode articles, explanations, and context kept. Good for learning.' },
77
- { label: 'full', description: '(Default) Terse but complete. Narration trimmed, data preserved.' },
78
- { label: 'ultra', description: 'Telegraphic — code and decisions only, no narration.' },
116
+ { label: 'full (Recommended)', description: 'Terse but complete: narration trimmed, every fact kept. Safe default — you lose words, never data.' },
117
+ { label: 'lite', description: 'Keeps the explanations and background too. Slower to read, better while the codebase is still new to you.' },
118
+ { label: 'ultra', description: 'Code and decisions only, no narration. You will have to ask why more often.' },
79
119
  ],
80
120
  multiSelect: false,
81
121
  },
82
122
  {
83
- question: 'How should the assistant handle preamble before taking actions?',
84
- header: 'Owner Interview — Preamble (4/5)',
123
+ question: 'How much should the assistant explain before it starts working?',
124
+ header: 'Preamble 4/5',
85
125
  options: [
86
- { label: 'minimal', description: 'One-line status updates only. Jump straight to execution.' },
87
- { label: 'verbose', description: 'Explain plan + rationale before each major action.' },
126
+ { label: 'minimal (Recommended)', description: 'One line of status, then it works. Safe default you can still ask for the reasoning afterwards.' },
127
+ { label: 'verbose', description: 'Plan and reasoning before each major action. Costs a few lines every step.' },
88
128
  ],
89
129
  multiSelect: false,
90
130
  },
91
131
  {
92
- question: 'Hardware-sharing consent: may the plugin share anonymized hardware patterns to improve resource defaults? (Issue #173 C4)',
93
- header: 'Owner Interview — Hardware Sharing Consent (5/5)',
132
+ question: 'May the plugin share anonymized hardware data to improve its resource defaults?',
133
+ header: 'Sharing 5/5',
94
134
  options: [
95
- { label: 'No', description: '(Default) No data is shared. Fully private.' },
96
- { label: 'Yes', description: 'Share anonymized patterns (hashed, no PII). Helps tune wave/session defaults.' },
97
- { label: 'Preview', description: 'Show exactly what would be shared before deciding.' },
135
+ { label: 'No (Recommended)', description: 'Nothing leaves this machine. Safe default — you can switch it on later without redoing this interview.' },
136
+ { label: 'Yes', description: 'Shares hashed hardware patterns — never file names, paths or content. Helps tune the wave and session defaults.' },
137
+ { label: 'Preview', description: 'Shows exactly what would be sent, then asks again. Costs one extra step.' },
98
138
  ],
99
139
  multiSelect: false,
100
140
  },
@@ -108,12 +148,15 @@ export function getInterviewQuestions() {
108
148
  /**
109
149
  * Map interview answer labels to an owner.yaml config object and write it.
110
150
  *
151
+ * Each answer is the LABEL the operator picked, which may carry a trailing
152
+ * `(Recommended)` marker; optionValue() strips it before the enum match below.
153
+ *
111
154
  * @param {string[]} answers - Array of selected option labels, one per question (5 total).
112
- * answers[0] = language label ('de' | 'en' | free-text)
113
- * answers[1] = tone style label
114
- * answers[2] = output level label
115
- * answers[3] = preamble label
116
- * answers[4] = hardware-sharing label ('Yes' | 'No' | 'Preview')
155
+ * answers[0] = language label ('de' | 'en' | 'other')
156
+ * answers[1] = tone style label ('direct' | 'neutral' | 'friendly', ± marker)
157
+ * answers[2] = output level label ('full' | 'lite' | 'ultra', ± marker)
158
+ * answers[3] = preamble label ('minimal' | 'verbose', ± marker)
159
+ * answers[4] = hardware-sharing label ('Yes' | 'No' | 'Preview', ± marker)
117
160
  * @param {{ path?: string }} [opts]
118
161
  * @returns {{ ok: boolean, path: string, errors: string[] }}
119
162
  */
@@ -124,7 +167,10 @@ export function applyInterviewAnswers(answers, opts = {}) {
124
167
  return { ok: false, path: filePath, errors: ['applyInterviewAnswers requires exactly 5 answers'] };
125
168
  }
126
169
 
127
- const [langRaw, toneRaw, outputLevelRaw, preambleRaw, hwConsentRaw] = answers;
170
+ // Strip the display-only `(Recommended)` marker before matching against the
171
+ // enums below — see optionValue(). A non-string answer becomes '' and falls
172
+ // through to the same default it always did.
173
+ const [langRaw, toneRaw, outputLevelRaw, preambleRaw, hwConsentRaw] = answers.map(optionValue);
128
174
 
129
175
  // --- Language ---
130
176
  // Accept 'de', 'en', or treat anything else as a free-text language code.