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
@@ -141,7 +141,8 @@ For every `SPIRAL` or `FAILED` agent surfaced in the walk above, ALSO append a c
141
141
  ```js
142
142
  import { appendWhatNotToRetryOnDisk } from '${PLUGIN_ROOT}/scripts/lib/state-md.mjs';
143
143
 
144
- // `parsed` = parseStateMd(STATE.md); session id from the `session:` frontmatter field.
144
+ // `parsed` = parseStateMd(STATE.md); `session:` is an attribution/history label.
145
+ // It records this entry's provenance only and never authorizes lock ownership.
145
146
  const sessionId = parsed.frontmatter.session ?? 'unknown-session';
146
147
  const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
147
148
 
@@ -646,7 +647,7 @@ import { planTailPhases } from '${PLUGIN_ROOT}/scripts/lib/session-end/phase-ski
646
647
  const { plan, skippedReport } = await planTailPhases({
647
648
  repoRoot: process.cwd(),
648
649
  config, // parsed Session Config (from $CONFIG)
649
- sessionId, // session.lock `session_id` / STATE.md `session:` field (or null)
650
+ sessionId, // physical session.lock `session_id` only (or null), never STATE.md `session`
650
651
  platform, // 'claude' | 'codex' | 'cursor'
651
652
  });
652
653
  // plan: Array<{ phase, run, reason, inputSource }>, already in ascending phase order.
@@ -733,20 +734,21 @@ After STATE.md is finalized with `status: completed` (Phase 3.4) and Recommendat
733
734
 
734
735
  ```javascript
735
736
  import { release } from 'scripts/lib/session-lock.mjs';
736
- // sessionId = the session identifier established by session-start Phase 1.2 acquire()
737
- // and stored in .orchestrator/session.lock (session_id field); matches the
738
- // STATE.md frontmatter `session:` field written during Pre-Wave 1b initialization.
739
- const result = release({ sessionId, repoRoot: process.cwd() });
737
+ // sessionId is the physical raw value established by session-start Phase 1.2
738
+ // and stored in .orchestrator/session.lock `session_id`. It is not STATE.md
739
+ // `session:` or `semantic_session_id`, both of which are attribution labels.
740
+ const rawSessionId = sessionId;
741
+ const result = release({ sessionId: rawSessionId, repoRoot: process.cwd() });
740
742
  // result.ok is always true unless a filesystem error occurred.
741
743
  // result.deleted === true → lock file removed successfully.
742
- // result.deleted === false → lock was absent or belonged to a different session_id (silent-OK).
744
+ // result.deleted === false → lock was absent or had a different raw session_id.
743
745
  ```
744
746
 
745
- If `result.deleted === false`, log `info: session-lock not released — already absent or session_id mismatch (no action needed)` and continue. This is a non-error state.
747
+ If `result.deleted === false`, log `info: session-lock not released — already absent or raw session_id mismatch` and continue. An active lock whose raw id differs is ambiguous: do **not** retry release with an equal `semantic_session_id`, STATE.md `session`, or owner proof. Leave that live lock for its TTL/Reaper lifecycle.
746
748
 
747
749
  If `result.ok === false` (rare filesystem error), log `⚠ session-lock: release failed — <result.reason>` and continue. Do NOT block the close for a lock-release failure — the TTL provides automatic expiry for the next session.
748
750
 
749
- The lock is released here — AFTER all STATE.md writes are complete and BEFORE the commit is staged in Phase 4.1. This ordering ensures a clean handover: the lock file is absent from the working tree when the commit is assembled, so it is not accidentally staged.
751
+ The lock is released here — AFTER all STATE.md writes are complete and BEFORE the commit is staged in Phase 4.1. This ordering ensures a clean handover when the current raw owner releases it: the lock file is absent from the working tree when the commit is assembled, so it is not accidentally staged.
750
752
 
751
753
  ## Phase 4: Commit & Push
752
754
 
@@ -890,15 +892,24 @@ if (!promoted) {
890
892
  When the worktree is dirty (uncommitted, untracked, OR unpushed), render this AUQ via the coordinator's `AskUserQuestion` tool. The AUQ is coordinator-only — per `.claude/rules/ask-via-tool.md` AUQ-004, dispatched agents cannot call AUQ. Calling `git worktree remove --force` without explicit operator confirmation would violate PSA-003 (destructive action safeguards) — the dirty state may contain another session's work-in-progress or unmerged commits.
891
893
 
892
894
  ```js
895
+ // What is actually at stake, shown beside the options via `preview` (AUQ-006):
896
+ // the operator must see WHICH changes he would lose before he authorises the delete.
897
+ // Capped at 10 lines so the preview never outgrows the option list next to it.
898
+ const dirtyDetail = execFileSync('git', ['-C', promoted.wtPath, 'status', '--short', '--branch'], { encoding: 'utf8' })
899
+ .trim()
900
+ .split('\n')
901
+ .slice(0, 10)
902
+ .join('\n');
903
+
893
904
  AskUserQuestion({
894
905
  questions: [{
895
906
  question: `Auto-promoted worktree at ${promoted.wtPath} has uncommitted/untracked/unpushed changes. How should I proceed?`,
896
- header: "Worktree-Cleanup",
907
+ header: "Worktree",
897
908
  multiSelect: false,
898
909
  options: [
899
- { label: "Behalten (Recommended)", description: "Keep the worktree as-is. No cleanup. Review and remove manually later." },
900
- { label: "Löschen", description: "I confirm the changes are handled or expendable. Run 'git worktree remove --force' on the worktree." },
901
- { label: "Manuell", description: "Exit /close. I will inspect the worktree before re-running /close." },
910
+ { label: "Behalten (Recommended)", description: "Keeps the worktree exactly as it is nothing is deleted, and you can still remove it by hand later.", preview: `Stays on disk:\n${dirtyDetail}` },
911
+ { label: "Löschen", description: "I confirm the changes are handled or expendable. Run 'git worktree remove --force' on the worktree.", preview: `Deleted with the worktree:\n${dirtyDetail}` },
912
+ { label: "Manuell", description: "Exit /close. I will inspect the worktree before re-running /close.", preview: `You would inspect this first:\n${dirtyDetail}` },
902
913
  ],
903
914
  }],
904
915
  });
@@ -907,8 +918,8 @@ AskUserQuestion({
907
918
  **Codex CLI / Cursor IDE fallback** (numbered Markdown list):
908
919
 
909
920
  ```
910
- Worktree cleanup options:
911
- 1. **Behalten (Recommended)** — Keep the worktree as-is. No cleanup. Review and remove manually later.
921
+ Worktree cleanup options (the changes at stake are the `git status --short --branch` lines printed above):
922
+ 1. **Behalten (Recommended)** — Keeps the worktree exactly as it is; nothing is deleted, and you can still remove it by hand later.
912
923
  2. **Löschen** — I confirm the changes are handled or expendable. Run 'git worktree remove --force'.
913
924
  3. **Manuell** — Exit /close. I will inspect the worktree before re-running /close.
914
925
  Reply with the number of your choice.
@@ -1026,16 +1037,38 @@ if (sweep) {
1026
1037
  **Ordering (load-bearing):** run this as the LAST issue-creating action of Phase 5 — after step 3, after "Discovery Issue Creation", after step 4 — and re-read the counter file at that moment. Those steps can themselves push new entries into `overflow[]`; draining early would leave them unfiled.
1027
1038
 
1028
1039
  ```js
1029
- import { readBudgetState, budgetStatePath } from '${PLUGIN_ROOT}/scripts/lib/issue-budget.mjs';
1030
- const state = readBudgetState(repoRoot, sessionId); // { sessionId, count, exempt, overflow: [...] }
1040
+ import { readFileSync } from 'node:fs';
1041
+ import {
1042
+ readBudgetState,
1043
+ budgetStatePath,
1044
+ resolveIssueBudgetSessionId,
1045
+ } from '${PLUGIN_ROOT}/scripts/lib/issue-budget.mjs';
1046
+
1047
+ // `sessionId` is the physical raw lock/registry identity from session-start.
1048
+ const rawSessionId = sessionId;
1049
+ let currentSession = null;
1050
+ try {
1051
+ currentSession = JSON.parse(
1052
+ readFileSync(`${repoRoot}/.orchestrator/current-session.json`, 'utf8'),
1053
+ );
1054
+ } catch { /* no verified semantic accounting bridge */ }
1055
+ const accountingSessionId = resolveIssueBudgetSessionId(rawSessionId, currentSession);
1056
+ const state = readBudgetState(repoRoot, accountingSessionId);
1057
+ // { sessionId, count, exempt, overflow: [...] }
1031
1058
  ```
1032
1059
 
1060
+ `accountingSessionId` may be semantic only after
1061
+ `currentSession.session_id === rawSessionId`; this is budget accounting, not
1062
+ lock/registry ownership. When that proof is absent it remains the raw id.
1063
+ A host rotation that changes both raw and semantic values has no guaranteed
1064
+ budget continuity.
1065
+
1033
1066
  - **`issue-budget.overflow: collect-issue` (default)** — create exactly ONE issue:
1034
- - Title: `[Backlog-Sammel] <session-id>, <N> zurückgestellte Punkte`
1067
+ - Title: `[Backlog-Sammel] <accountingSessionId>, <N> zurückgestellte Punkte`
1035
1068
  - Labels: `type::backlog`, `priority::low`
1036
1069
  - Body: a Markdown checklist with one `- [ ]` line per `overflow[]` entry (`title` when present, otherwise the truncated `command`, plus its `at` timestamp).
1037
1070
  - This collector issue is itself EXEMPT from the cap (`[Backlog-Sammel]` is in the exemption list in `scripts/lib/issue-budget.mjs`), so it always lands even at count == max.
1038
- - **`issue-budget.overflow: vault-note`** — create NO issue. Write one Markdown file `vault/00-inbox/<session-id>-backlog-sammel.md` (path relative to `vault-integration.vault-dir`) with valid vault frontmatter and the same checklist body.
1071
+ - **`issue-budget.overflow: vault-note`** — create NO issue. Write one Markdown file `vault/00-inbox/<accountingSessionId>-backlog-sammel.md` (path relative to `vault-integration.vault-dir`) with valid vault frontmatter and the same checklist body.
1039
1072
  - After the artefact exists, reset `overflow` to `[]` in the counter file and record the collector issue ID / note path in the Phase 6 Final Report under `### Zurückgestellt (issue-budget)`.
1040
1073
  - **Never exempt-by-accident:** the cap never applied to `priority::critical`, the carryover class (`[Carryover]`, SPIRAL/FAILED, `type::carryover`), or `broken-window` closure issues, so nothing on the Phase 1.65 carry-list can ever appear in `overflow[]`. The promises at Phase 1.8 ("SPIRAL / FAILED agent carryover … non-deselectable") and the Critical Rule "ALWAYS create issues for unfinished PLANNED work" stay intact by construction.
1041
1074
  - Fail-open: a missing or malformed counter file means "no overflow" — log a WARN and continue the close.
@@ -1134,7 +1167,7 @@ Present to the user:
1134
1167
  | `phase-3-7a-recommendations.md` § 3.7b | Phase 3.7b full procedural body — `withDurableCommit` invocation for `sessions.jsonl` + `STATE.md` (#490 AC2), `enabled:false` local no-op, autopilot.jsonl exclusion note |
1135
1168
  | (inline) Phase 3.7c | Vault Board → Closed (#674) — `mirrorBoard({ explicitStatus: 'closed' })` transitions this repo's board row to `closed`; gated on `vault-integration.enabled`, generator-marked + idempotent, non-blocking, ordered after 3.7b and before 3.7d/3.4/3.8 |
1136
1169
  | (inline) Phase 3.7d | Session-Eval (opt-in — #803) — `node scripts/eval-session.mjs --json` scores the just-closed session; gated on `eval.enabled` + `eval.mode != off` (parsed by `scripts/lib/config/eval.mjs`), optional `eval-judge` dispatch + `writeEvalReport`, advisory/never-blocks-close, ordered after 3.7 (record must exist) and before 3.4/Phase 4 (record committed with the session). Full flow in `skills/eval/SKILL.md` |
1137
- | (inline) Phase 3.8 | Session Lock Release — `release()` call, silent-OK on mismatch/absent, non-fatal on fs-error, ordering note (after STATE.md writes, before Phase 4 commit staging) |
1170
+ | (inline) Phase 3.8 | Session Lock Release — `release()` uses the physical raw `session_id`; raw mismatch/absent is non-fatal but never repaired with semantic labels or proof (live ambiguity remains for TTL/Reaper); fs-errors are non-fatal; runs after STATE.md writes and before Phase 4 commit staging |
1138
1171
 
1139
1172
  ## Anti-Patterns
1140
1173
 
@@ -81,7 +81,25 @@ The proposals queue is populated mid-session by wave-executor agents calling `no
81
81
  }
82
82
  ```
83
83
 
84
- Then iterate `batches` and emit one `AskUserQuestion` per batch with `header: "Memory Confirm Proposals (Batch N of M)"`. Option label format: `[<type-12>] | <subject-40> | conf=X.XX`. Option description: `evidence: <first 60 chars of insight>`. `multiSelect: true`.
84
+ Then iterate `batches` and emit one `AskUserQuestion` per batch. The verbatim template is `agents/memory-proposal-collector.md` § AUQ Question Template keep the two in step:
85
+
86
+ ```javascript
87
+ AskUserQuestion({
88
+ questions: [{
89
+ header: "Memory",
90
+ question: "Batch <N> of <M> — which of these learnings should be stored permanently?",
91
+ options: [
92
+ // one entry per proposal in this batch (max 4)
93
+ // label + description formats are LOCKED by D3 — see that file, do not restate them here
94
+ { label: "[type ] | subject(40) | conf=X.XX", description: "evidence: <first 60 chars of insight>" },
95
+ ...
96
+ ],
97
+ multiSelect: true
98
+ }]
99
+ })
100
+ ```
101
+
102
+ The batch counter moved out of `header` and into the question because `header` is cut off after 12 characters — `Memory — Confirm Proposals (Batch N of M)` reached the operator as `Memory — Con`.
85
103
 
86
104
  5. After all batches answered, partition the queue into `approved` (any option selected across all batches) and `rejected` (all unselected).
87
105
 
@@ -337,7 +355,24 @@ After the auto-dialectic nudge decision is made (Phase 3.6.7), and when the reco
337
355
  }
338
356
  ```
339
357
 
340
- Iterate `batches` and emit one `AskUserQuestion` per batch with `header: "Reconciliation — Confirm Rule Proposals (Batch N of M)"`. Option label format: `<slug-40> | conf=<confidence>`. Option description: first 80 chars of the rendered `content` (the rule prose preview). `multiSelect: true`.
358
+ Iterate `batches` and emit one `AskUserQuestion` per batch:
359
+
360
+ ```javascript
361
+ AskUserQuestion({
362
+ questions: [{
363
+ header: "Regeln",
364
+ question: "Batch <N> of <M> — which rule proposals should be written into .claude/rules/?",
365
+ options: [
366
+ // one entry per proposal in this batch (max 4)
367
+ { label: "<slug-40>", description: "Confidence <confidence>. First 80 chars of the rendered rule text: <…>" },
368
+ ...
369
+ ],
370
+ multiSelect: true
371
+ }]
372
+ })
373
+ ```
374
+
375
+ The batch counter moved out of `header` and into the question because `header` is cut off after 12 characters — `Reconciliation — Confirm Rule Proposals (Batch N of M)` reached the operator as `Reconciliati`. The rendered `content` shown in the description is the rule prose that will land on disk.
341
376
 
342
377
  6. After all batches are answered, partition proposals into `approved` (any option selected across all batches) and `rejected` (all unselected). Proposals the operator rejected join the engine's `rejected` array for archival.
343
378
 
@@ -42,7 +42,7 @@ This runs BEFORE the local session-lock acquire in Phase 1.2 — the preamble's
42
42
  **Outcome handling:**
43
43
  - `PASS_THROUGH` → continue to Phase 1
44
44
  - `EXCLUSIVE_BLOCKED` → exit Phase 0 cleanly per the AUQ outcome (`Warten` / `Andere Session beenden` / `Abbrechen` — all three return without initializing STATE.md)
45
- - `PROMOTION_OFFER` with user picking "Worktree anlegen + starten" → call `enterWorktree({ basePath, sessionId, branch, repoRoot })` from `scripts/lib/autopilot/worktree-pipeline.mjs`. Compute params: `basePath = path.dirname(repoRoot)`, `sessionId` from resolveSemanticSessionId(), `branch` from current HEAD, `repoRoot = process.cwd()`. On success, exit Phase 0 immediately — the new worktree's own session-start runs from scratch (Phase 1 onwards), Phase 1.2 session-lock-acquire is the new worktree's responsibility. On enterWorktree failure (`WorktreeBoundaryError` or `git worktree add` non-zero exit), emit stderr WARN `parallel-aware: enterWorktree failed: <err>; falling back to Manuell` and proceed via the Manuell path.
45
+ - `PROMOTION_OFFER` with user picking "Worktree anlegen + starten" → call `enterWorktree({ basePath, sessionId, branch, repoRoot })` from `scripts/lib/autopilot/worktree-pipeline.mjs`. Compute params: `basePath = path.dirname(repoRoot)`, `sessionId` from resolveSemanticSessionId() **for the worktree-name attribution label only**, `branch` from current HEAD, `repoRoot = process.cwd()`. It is not a lock/registry ownership key; the new worktree's Phase 1.2 obtains its own physical raw `session_id`. On success, exit Phase 0 immediately — the new worktree's own session-start runs from scratch (Phase 1 onwards), Phase 1.2 session-lock-acquire is the new worktree's responsibility. On enterWorktree failure (`WorktreeBoundaryError` or `git worktree add` non-zero exit), emit stderr WARN `parallel-aware: enterWorktree failed: <err>; falling back to Manuell` and proceed via the Manuell path.
46
46
  - `PROMOTION_OFFER` with user picking "Manuell — in-place daneben" → append Deviation, continue to Phase 1
47
47
  - `PROMOTION_OFFER` with user picking "Abbrechen" → exit cleanly
48
48
 
@@ -112,14 +112,14 @@ if (content && !isDispatcherAutonomyBlockPresent(content)) {
112
112
 
113
113
  Acquire a distributed session-lock to detect parallel sessions in the same repo before initializing STATE.md. This prevents two concurrent Claude/Codex sessions from stomping each other's wave state and metrics writes.
114
114
 
115
- **Mechanical wiring (Epic #583, 2026-05-27):** The SessionStart hook (`hooks/on-session-start.mjs` → `hooks/_lib/lock-bootstrap.mjs`) now writes `.orchestrator/session.lock` mechanically BEFORE this skill's prose runs. The prose Phase 1.2 becomes confirmatory — it verifies the lock exists with the expected shape via `readLock({ repoRoot: process.cwd() })`. Re-call `acquire()` only if `readLock()` returns `null` (mechanical hook failed) OR the existing lock's `session_id` does not match the current session's id (a rare divergence — surface via AUQ before overwriting). The decision flow below still applies to all three outcomes (active / stale / fs-error) when the prose path needs to acquire.
115
+ **Mechanical wiring (Epic #583, 2026-05-27):** The SessionStart hook (`hooks/on-session-start.mjs` → `hooks/_lib/lock-bootstrap.mjs`) now writes `.orchestrator/session.lock` mechanically BEFORE this skill's prose runs. The prose Phase 1.2 becomes confirmatory — it verifies the lock exists with the expected shape via `readLock({ repoRoot: process.cwd() })`. Re-call `acquire()` only if `readLock()` returns `null` (mechanical hook failed) OR the existing lock's raw `session_id` does not exactly match the current session's raw id (a rare divergence — surface via AUQ before overwriting). A matching `semantic_session_id`, STATE.md `session`, or owner proof cannot repair that mismatch. The decision flow below still applies to all three outcomes (active / stale / fs-error) when the prose path needs to acquire.
116
116
 
117
117
  ```javascript
118
118
  import { acquire, forceAcquire } from 'scripts/lib/session-lock.mjs';
119
119
  const result = acquire({ sessionId, mode: sessionType, ttlHours: 4, repoRoot: process.cwd() });
120
120
  ```
121
121
 
122
- Where `sessionId` is the session identifier derived from the session type and timestamp (e.g. `main-2026-05-08-deep-1`), and `sessionType` is the session mode (`housekeeping`, `feature`, or `deep`).
122
+ Where `sessionId` is the physical raw identity for this invocation: the native harness-provided raw id, or a generated UUID when no trustworthy raw id exists. It is the only value passed to `acquire()` and the only live lock/registry ownership key. `semanticSessionId` may be recorded separately as an attribution/history label and may populate STATE.md `session`; neither label is a substitute for `sessionId`. `sessionType` is the session mode (`housekeeping`, `feature`, or `deep`).
123
123
 
124
124
  ### Decision flow
125
125
 
@@ -131,21 +131,21 @@ Where `sessionId` is the session identifier derived from the session type and ti
131
131
  ```js
132
132
  AskUserQuestion({
133
133
  questions: [{
134
- question: `Another session lock is active in this repo (started ${ageHours}h ago, mode=${existingLock.mode}, host=${existingLock.host}, pid=${existingLock.pid}). How should I proceed?`,
135
- header: "Session Lock Conflict",
134
+ question: `Another session holds the lock here started ${ageHours}h ago, mode=${existingLock.mode}, host=${existingLock.host}, pid=${existingLock.pid}. Wait, or take the lock?`,
135
+ header: "Session lock",
136
136
  multiSelect: false,
137
137
  options: [
138
- { label: "Abort (Recommended)", description: "Let the other session finish. Safe default prevents metrics and wave-state corruption." },
139
- { label: "Force-take the lock", description: "Overwrites the active lock. ONLY use if you are certain the other session is no longer running." },
138
+ { label: "Abort (Recommended)", description: "Stop here and let the other session finish, then start again. Nothing is written until it releases the lock, and two sessions sharing one wave state overwrite each other's metrics." },
139
+ { label: "Force-take the lock", description: "Overwrites the active lock and starts anyway. Only when that session is certainly gone — otherwise both keep writing the same wave state and one of them loses everything." },
140
140
  ],
141
141
  }],
142
142
  });
143
143
  ```
144
144
  - **Codex CLI / Cursor IDE fallback (numbered Markdown list):**
145
145
  ```
146
- Session lock conflict active lock detected (started <ageHours>h ago, mode=<mode>, host=<host>, pid=<pid>).
147
- 1. Abort (Recommended) — let the other session finish.
148
- 2. Force-take the lock — ONLY if the other session is known dead.
146
+ Another session holds the lock here started <ageHours>h ago, mode=<mode>, host=<host>, pid=<pid>. Wait, or take the lock?
147
+ 1. Abort (Recommended) — stop here and let the other session finish, then start again; nothing is written until it releases the lock.
148
+ 2. Force-take the lock — overwrites the active lock. Only when that session is certainly gone, otherwise both keep writing the same wave state and one loses everything.
149
149
  Reply with the number of your choice.
150
150
  ```
151
151
  - On **Abort**: exit session-start cleanly with a brief stderr note (`session-lock: aborted — active lock held by session_id=<id>`). Do NOT initialize STATE.md.
@@ -158,21 +158,21 @@ Where `sessionId` is the session identifier derived from the session type and ti
158
158
  ```js
159
159
  AskUserQuestion({
160
160
  questions: [{
161
- question: `Stale session lock found (started ${ageHours}h ago, ttl=${existingLock.ttl_hours}h). Process pid=${existingLock.pid} on host=${existingLock.host} is ${reason === 'stale-pid-dead' ? 'confirmed dead' : 'still running or status unknown'}. Reclaim the lock?`,
162
- header: "Stale Session Lock",
161
+ question: `A stale session lock is in the way — started ${ageHours}h ago, its ttl=${existingLock.ttl_hours}h has expired, and pid=${existingLock.pid} on host=${existingLock.host} is ${reason === 'stale-pid-dead' ? 'confirmed dead' : 'still running or status unknown'}. Reclaim it?`,
162
+ header: "Stale lock",
163
163
  multiSelect: false,
164
164
  options: [
165
- { label: "Reclaim (Recommended)", description: "Overwrite the stale lock and continue. Safe when the previous session is no longer active." },
166
- { label: "Abort — investigate manually", description: "Stop here. Inspect .orchestrator/session.lock before proceeding." },
165
+ { label: "Reclaim (Recommended)", description: "Overwrites the stale lock and continues, because its time-to-live has run out. When that process is really dead, nothing of the old session is lost." },
166
+ { label: "Abort — investigate manually", description: "Stops here and writes nothing. The lock file `.orchestrator/session.lock` (it names the process that wrote it) tells you whether that session is still alive." },
167
167
  ],
168
168
  }],
169
169
  });
170
170
  ```
171
171
  - **Codex CLI / Cursor IDE fallback (numbered Markdown list):**
172
172
  ```
173
- Stale session lock found (started <ageHours>h ago, ttl=<ttlHours>h, pid=<pid> on <host>).
174
- 1. Reclaim (Recommended) — overwrite stale lock and continue.
175
- 2. Abort — investigate .orchestrator/session.lock manually.
173
+ A stale session lock is in the way — started <ageHours>h ago, ttl=<ttlHours>h expired, pid=<pid> on <host>. Reclaim it?
174
+ 1. Reclaim (Recommended) — overwrites the stale lock and continues, because its time-to-live has run out and that process is no longer holding anything.
175
+ 2. Abort — stops here and writes nothing. The lock file `.orchestrator/session.lock` (it names the process that wrote it) tells you whether that session is still alive.
176
176
  Reply with the number of your choice.
177
177
  ```
178
178
  - On **Reclaim**: call `forceAcquire({ sessionId, mode: sessionType, ttlHours: 4, repoRoot: process.cwd() })`. After Phase 1.5 initializes STATE.md, append a deviation:
@@ -196,11 +196,14 @@ When `existingLock.host !== os.hostname()`, PID liveness cannot be checked (`pid
196
196
 
197
197
  > Skip this phase if `persistence` config is `false`.
198
198
 
199
- After Phase 1.2 acquires (or confirms) the lock, call `checkPeerStateMd(repoRoot, sessionId)` from `scripts/lib/state-md-peer-guard.mjs`. This catches the rare case where lock-based detection missed an active peer (e.g., the peer's `session.lock` was force-deleted by an out-of-band sweep but STATE.md is still `status: active`, OR the peer's registry write succeeded but the lock-bootstrap hook crashed before the lock landed).
199
+ After Phase 1.2 acquires (or confirms) the lock, use `findPeers(repoRoot, { mySessionId: callerSessionHint })` for the STATE.md peer guard. `callerSessionHint` is the original semantic attribution label when one exists, otherwise the raw `sessionId`: `findPeers` may translate the semantic hint for the discovered lock/registry surface only after the exact raw binding check in `parallel-aware-preamble.md`, while keeping the original hint for STATE.md. This catches the rare case where lock-based detection missed an active peer (e.g., the peer's `session.lock` was force-deleted by an out-of-band sweep but STATE.md is still `status: active`, OR the peer's registry write succeeded but the lock-bootstrap hook crashed before the lock landed).
200
200
 
201
201
  ```javascript
202
202
  import { findPeers } from '$PLUGIN_ROOT/scripts/lib/peer-discovery.mjs';
203
- const { peers } = await findPeers(process.cwd(), { mySessionId: sessionId });
203
+ // Keep the STATE.md comparison in its original attribution-label space.
204
+ // findPeers performs the guarded semantic→raw translation only for discovered peers.
205
+ const callerSessionHint = semanticSessionId ?? sessionId;
206
+ const { peers } = await findPeers(process.cwd(), { mySessionId: callerSessionHint });
204
207
  const peer = peers.find((p) => p.source === 'state-md') ?? null;
205
208
  // Phase 1.2.1 consumes only the 'state-md' subset (STATE.md surface only).
206
209
  if (peer) {
@@ -367,29 +370,60 @@ If `snaps.length >= 1` → present the following choice:
367
370
 
368
371
  **Claude Code (AskUserQuestion):**
369
372
 
373
+ Before asking, read what "Recover" would actually put back — the operator decides on that diff, not on the word:
374
+
375
+ ```js
376
+ import { execFileSync } from 'node:child_process';
377
+
378
+ // Read-only: `git stash show` prints a diffstat and never touches the working tree.
379
+ // Capped at 12 lines so the preview box stays shorter than the option list beside it.
380
+ const stat = execFileSync('git', ['stash', 'show', '--stat', snaps[0].sha], { encoding: 'utf8' })
381
+ .split('\n').slice(0, 12).join('\n');
382
+ const refs = snaps.map((s) => s.ref).join('\n');
383
+ ```
384
+
370
385
  ```js
371
386
  AskUserQuestion({
372
387
  questions: [{
373
- question: `Found ${snaps.length} coordinator snapshot(s) from the resumed session (latest from ${humanAgeOf(snaps[0].createdAt)}). Recover, keep as backup, or discard?`,
388
+ question: `${snaps.length} snapshot(s) from the resumed session, newest ${humanAgeOf(snaps[0].createdAt)}. Recover, keep, discard?`,
374
389
  header: "Snapshot",
375
390
  multiSelect: false,
376
391
  options: [
377
- { label: "Recover (diff vs current tree) (Recommended)", description: "Apply the latest snapshot back onto the working tree. You will see a diff and can unstage unwanted changes before committing." },
378
- { label: "Keep as backup", description: "Leave refs/so-snapshots/* in place untouched. You can recover manually later via `git stash apply $(git rev-parse <ref>)`." },
379
- { label: "Discard all", description: "Delete all refs/so-snapshots/<sessionId>/* immediately via deleteSnapshot." },
392
+ {
393
+ label: "Recover (Recommended)",
394
+ description: "Puts the newest saved state back into your working tree and commits nothing. You can drop any of those changes afterwards.",
395
+ preview: `These files come back:\n\n\`\`\`\n${stat}\n\`\`\``,
396
+ },
397
+ {
398
+ label: "Keep as backup",
399
+ description: "Nothing happens now: `refs/so-snapshots/*` (the saved states) stay, and `git stash apply $(git rev-parse <ref>)` (this puts one back) works later.",
400
+ },
401
+ {
402
+ label: "Discard all",
403
+ description: "Deletes every saved state of this session for good: `refs/so-snapshots/<sessionId>/*` (all of them) is gone, and there is no second copy.",
404
+ preview: `Deleted for good:\n\n\`\`\`\n${refs}\n\`\`\``,
405
+ },
380
406
  ],
381
407
  }],
382
408
  });
383
409
  ```
384
410
 
411
+ `preview` renders beside the option list and only works with `multiSelect: false`. It is used here because the answer decides which literal text lands in the working tree — "Recover" is a diff, "Discard all" is a list of refs that stop existing. "Keep as backup" carries none: keeping is exactly the state the operator already sees.
412
+
385
413
  **Codex CLI / Cursor IDE fallback (numbered Markdown list):**
386
414
 
415
+ These harnesses have no preview box, so the same diffstat is printed inline — it is the only place the operator ever sees it:
416
+
387
417
  ```markdown
388
- Snapshot recovery options:
418
+ "Recover" would put these files back:
419
+
420
+ <git stash show --stat <snaps[0].sha>, capped at 12 lines>
421
+
422
+ <N> snapshot(s) from the resumed session, newest <age>. Recover, keep, discard?
389
423
 
390
- 1. **Recover (Recommended)** — Apply the latest snapshot back onto the working tree. You will see a diff and can unstage unwanted changes before committing.
391
- 2. **Keep as backup** — Leave the refs in place untouched. You can recover manually later.
392
- 3. **Discard all** — Delete all refs/so-snapshots/<sessionId>/* immediately.
424
+ 1. **Recover (Recommended)** — puts the newest saved state back into your working tree and commits nothing. You can drop any of those changes afterwards.
425
+ 2. **Keep as backup** — nothing happens now: `refs/so-snapshots/*` (the saved states) stay, and `git stash apply $(git rev-parse <ref>)` (this puts one back) works later.
426
+ 3. **Discard all** — deletes every saved state of this session for good: `refs/so-snapshots/<sessionId>/*` (all of them) is gone, and there is no second copy.
393
427
 
394
428
  Reply with the number of your choice.
395
429
  ```
@@ -471,7 +505,7 @@ await sweepBoard({
471
505
 
472
506
  This single call does three things:
473
507
 
474
- 1. **Sets THIS repo's board row to `in-progress`** with the current semantic-session-id, branch, mode, and heartbeat (read off this repo's `session.lock` v2 lease + the host-wide registry — both already written by Phase 1.2's `acquire()`).
508
+ 1. **Sets THIS repo's board row to `in-progress`** with the current semantic-session-id **attribution label** (never a lock/registry ownership key), branch, mode, and heartbeat (read off this repo's `session.lock` v2 lease + the host-wide registry — both already written by Phase 1.2's `acquire()`).
475
509
  2. **Re-derives THIS repo's status from its live lease**, so a stale lease left by a prior crashed session in this same repo renders as `force-closed` (heartbeat older than the v2 ttl, default 4h — `DEFAULT_TTL_HOURS` in `scripts/lib/session-lock.mjs`, evaluated via `isLockLive`) and is **never silently dropped** — its fields are read straight off the dead lock.
476
510
  3. **Re-derives every OTHER busy repo's status host-wide** via `enumerateCandidates` — a dead lease in repo B renders `force-closed` on the board the next time ANY repo's session-start runs `sweepBoard`, closing the #676→#716 gap. `frei` (lock-less) repos are excluded from re-derivation to avoid board noise; their prior rows, and the prior rows of any repo `enumerateCandidates` did not surface, are preserved unchanged via the idempotent merge — never dropped.
477
511
 
@@ -1049,12 +1083,12 @@ if (!c.prompt) {
1049
1083
  ```js
1050
1084
  AskUserQuestion({
1051
1085
  questions: [{
1052
- question: "Anonyme Usage-Telemetrie aktivieren? Strikt opt-in, whitelist-projiziert (keine Repo-Namen/Pfade/Prompts), jederzeit abschaltbar — Details: docs/telemetry.md",
1053
- header: "Usage Telemetry",
1086
+ question: "Anonyme Usage-Telemetrie aktivieren? Strikt opt-in, jederzeit abschaltbar; was genau gesendet wird: docs/telemetry.md",
1087
+ header: "Telemetrie",
1054
1088
  multiSelect: false,
1055
1089
  options: [
1056
- { label: "Ja, aktivieren", description: "Anonymer Zähl-/Struktur-Datensatz (Skill-/Phasen-Nutzung, Erfolg/Abbruch) whitelist-projiziert, keine Pfade/Prompts/Repo-Namen. Details: docs/telemetry.md" },
1057
- { label: "Nein", description: "Keine Telemetrie senden. Jederzeit später aktivierbar via node scripts/telemetry.mjs." },
1090
+ { label: "Ja, aktivieren", description: "Sendet anonyme Zähl- und Strukturdaten (welche Phase lief, Erfolg oder Abbruch), whitelist-projiziert: keine Pfade, keine Prompts, keine Repo-Namen." },
1091
+ { label: "Nein", description: "Sendet nichts; die Frage kommt hier nicht wieder. Einschalten geht später mit `node scripts/telemetry.mjs` (das ist der Befehl dafür)." },
1058
1092
  ],
1059
1093
  }],
1060
1094
  });
@@ -1064,9 +1098,9 @@ AskUserQuestion({
1064
1098
 
1065
1099
  - **Codex CLI / Cursor IDE fallback (numbered Markdown list — AUQ-004 exception 1):**
1066
1100
  ```
1067
- Anonyme Usage-Telemetrie aktivieren? Strikt opt-in, whitelist-projiziert (keine Repo-Namen/Pfade/Prompts), jederzeit abschaltbar — Details: docs/telemetry.md
1068
- 1. Ja, aktivieren — anonymer Zähl-/Struktur-Datensatz, keine Pfade/Prompts/Repo-Namen.
1069
- 2. Nein — keine Telemetrie senden.
1101
+ Anonyme Usage-Telemetrie aktivieren? Strikt opt-in, jederzeit abschaltbar; was genau gesendet wird: docs/telemetry.md
1102
+ 1. Ja, aktivieren — sendet anonyme Zähl- und Strukturdaten (welche Phase lief, Erfolg oder Abbruch), whitelist-projiziert: keine Pfade, keine Prompts, keine Repo-Namen.
1103
+ 2. Nein — sendet nichts; die Frage kommt hier nicht wieder. Einschalten geht später mit `node scripts/telemetry.mjs` (das ist der Befehl dafür).
1070
1104
  Reply with the number of your choice. (No option is pre-recommended — the choice is yours.)
1071
1105
  ```
1072
1106
 
@@ -58,15 +58,15 @@ AskUserQuestion({
58
58
  options: [
59
59
  {
60
60
  label: "Dev (Recommended)", // add "(Recommended)" to each detected audience
61
- description: "Architektur-, Modul- oder Refactoring-Änderungen aktualisiert CLAUDE.md (oder AGENTS.md auf Codex CLI), docs/dev/**, docs/adr/**."
61
+ description: "Architektur-, Modul-, Refactoring-Änderungen. Dann ändern sich CLAUDE.md bzw. AGENTS.md und `docs/dev/**, docs/adr/**` (Handbuch und Entscheidungen)."
62
62
  },
63
63
  {
64
64
  label: "User",
65
- description: "Öffentlich sichtbare Änderungen aktualisiert README.md, docs/user/**, examples/**."
65
+ description: "Öffentlich sichtbare Änderungen. Dann werden README.md und `docs/user/**, examples/**` (was Nutzer davon lesen) nachgezogen."
66
66
  },
67
67
  {
68
68
  label: "Vault",
69
- description: "Strategische oder Status-Änderungen aktualisiert <vault>/01-projects/<slug>/context.md, decisions.md, people.md."
69
+ description: "Strategische oder Status-Änderungen. Dann wird `<vault>/01-projects/<slug>/context.md, decisions.md, people.md` (die Projektakte dazu) nachgezogen."
70
70
  }
71
71
  ]
72
72
  }]
@@ -76,13 +76,13 @@ AskUserQuestion({
76
76
  **Codex CLI / Cursor IDE fallback (numbered Markdown list):**
77
77
 
78
78
  ```markdown
79
- Welche Audiences berührt dieser Scope? (Mehrfachauswahl möglich)
80
-
81
79
  Auto-detected: [dev] ← list detected audiences here, or "none" if empty intersection
82
80
 
83
- 1. **Dev (Recommended)** Architektur-, Modul- oder Refactoring-Änderungen. Targets: CLAUDE.md (oder AGENTS.md auf Codex CLI), docs/dev/**, docs/adr/**.
84
- 2. **User** — Öffentlich sichtbare Änderungen. Targets: README.md, docs/user/**, examples/**.
85
- 3. **Vault** — Strategische oder Status-Änderungen. Targets: <vault>/01-projects/<slug>/context.md, decisions.md, people.md.
81
+ Welche Audiences berührt dieser Scope? Mehrfachauswahl möglich.
82
+
83
+ 1. **Dev (Recommended)** — Architektur-, Modul-, Refactoring-Änderungen. Dann ändern sich CLAUDE.md bzw. AGENTS.md und `docs/dev/**, docs/adr/**` (Handbuch und Entscheidungen).
84
+ 2. **User** — öffentlich sichtbare Änderungen. Dann werden README.md und `docs/user/**, examples/**` (was Nutzer davon lesen) nachgezogen.
85
+ 3. **Vault** — strategische oder Status-Änderungen. Dann wird `<vault>/01-projects/<slug>/context.md, decisions.md, people.md` (die Projektakte dazu) nachgezogen.
86
86
 
87
87
  Enter one or more numbers (comma-separated), or press Enter to accept the recommended default.
88
88
  ```
@@ -16,31 +16,88 @@ const verdict = evaluate(snapshot, config['resource-thresholds'], {
16
16
  });
17
17
  ```
18
18
 
19
- The `evaluate()` result has three fields:
20
- - `verdict`: `green` | `warn` | `critical`
21
- - `reasons`: array of human-readable explanations
19
+ The `evaluate()` result has four fields:
20
+ - `verdict`: `green` | `warn` | `critical` (the `degraded` tier is no longer produced)
21
+ - `reasons`: array of human-readable explanations, including `info:`-prefixed
22
+ lines for signals that were seen but deliberately not acted on
22
23
  - `recommended_agents_per_wave_cap`: integer cap (0 = coordinator-direct) or null
24
+ - `signals`: `{ hard: string[], soft: string[] }` — which axes fired (#1089)
23
25
 
24
26
  The third `options` argument is optional (HR-003/HR-004, baseline #60) — when `config['heavy-repo']` is `true`, the cap is forced to at most `config['agents-per-wave']` regardless of the live verdict (static preflight ceiling; more-restrictive-wins against whatever the resource signals already computed). Omitting `options` entirely preserves pre-#60 behaviour.
25
27
 
26
- ## Adaptive Rules (default thresholds; configurable via `resource-thresholds`)
27
-
28
- | Signal | Threshold | Action |
29
- |--------|-----------|--------|
30
- | RAM free below `ram-free-min-gb` (default 4) | warn | Cap `agents-per-wave` at 2 |
31
- | RAM free below `ram-free-critical-gb` (default 2) | critical | Recommend coordinator-direct (0 agents) |
32
- | CPU load above `cpu-load-max-pct` (default 80) sustained judged on **min(1m, 5m)** load average (#943) | warn | Cap `agents-per-wave` at 2 |
33
- | Claude processes ≥ `concurrent-sessions-warn` (default 5) | warn | Warn; suggest sequencing or waiting |
34
- | SSH session detected AND `ssh-no-docker: true` | info | Append note: host is SSH-attached, Docker-dependent steps should run on a local dev host |
35
-
36
- **CPU methodology (#943):** the gate/probe runs right after the coordinator's own CPU-saturating quality-gate run by construction, so the 1-minute load average systematically carries that decaying tail (observed: 96% 75% within 36s). `probe()` therefore also emits `cpu_load_5m` / `cpu_load_5m_pct`, and `evaluate()` + `evaluateWaveResourceGate()` judge the CPU threshold on **min(1m, 5m)**: only-1m-high is reported as an informational "decaying transient" reason without capping; both-high (genuine sustained load) still caps. When `cpu_load_5m_pct` is `null` (Windows, zero-load), judging falls back to the 1m-derived `cpu_load_pct` alone.
28
+ ## Adaptive Rules (rebuilt in #1089 see `.claude/rules/host-resources.md`)
29
+
30
+ The rule set is **signal precedence + the two-signal rule**, not a list of
31
+ independent thresholds ORed together. Measured 2026-08-21 over 1477
32
+ `orchestrator.session.started` events across 18 repos, the previous OR-of-three
33
+ produced warn-or-worse on **99.0%** of session starts a warning that fires
34
+ almost always changes no decision except how fast it gets ignored.
35
+
36
+ **Memory judge on the best signal present, never on a worse one:**
37
+
38
+ | Precedence | Signal | Hard (→ critical) | Soft |
39
+ |---|---|---|---|
40
+ | 1 | `memory_pressure_pct_free` (macOS) | `< 15%` | `< 30%` |
41
+ | 2 | `ram_available_gb` (macOS, vm_stat) | `< ram-free-critical-gb` | `< ram-free-min-gb` |
42
+ | 3 | `ram_free_gb` (`os.freemem`) | same | same |
43
+
44
+ Level 3 is reached only when neither better signal exists — i.e. on
45
+ Linux/Windows, where `os.freemem()` is accurate. On Darwin it reports `Pages
46
+ free` only (median **0.4 GB** across the corpus, on 24-128 GB hosts), so gating
47
+ on it there fired the *critical* threshold on 84.0% of starts.
48
+
49
+ **Other axes:**
50
+
51
+ | Signal | Threshold | Class |
52
+ |---|---|---|
53
+ | CPU, judged on **min(1m, 5m)** (#943) | above `cpu-load-max-pct` (default 90) | soft |
54
+ | Live peer **sessions** from the registry | ≥ `concurrent-sessions-warn` (default 5) | soft |
55
+ | Claude **processes** (fallback only, registry unreadable) | ≥ threshold × 6 | soft |
56
+ | Swap, **only while memory is unhealthy** | `> 3072 MB` hard / `> 1024 MB` soft | both |
57
+ | Zombie processes with a live peer/process context | ≥ 1 | **info** (reported, never counted — see HR-104) |
58
+ | SSH detected AND `ssh-no-docker: true` | — | info note |
59
+
60
+ **Verdict composition:**
61
+
62
+ - any **hard** signal → `critical`, recommend coordinator-direct (0 agents)
63
+ - **two or more independent soft** signals → `warn`, cap agents-per-wave at 2
64
+ - exactly **one soft** signal → `green`, reported in `reasons`, **no cap**
65
+ - none → `green`
66
+
67
+ `evaluate()` additionally returns `signals: { hard: [...], soft: [...] }` so a
68
+ caller can log which axes fired rather than re-deriving them from prose.
69
+
70
+ **Unit note (#1089):** `concurrent-sessions-warn` is denominated in SESSIONS. It
71
+ was compared against `claude_processes_count` until this rebuild — a measured 6x
72
+ unit error (median processes:sessions = 6.0 over 1461 paired samples) that made
73
+ the threshold fire on 93.6% of starts instead of 4.2%. `probe()` now supplies
74
+ `peer_sessions_count` from the session registry (`detectPeers()`, self excluded,
75
+ heartbeat-fresh); the rescaled process count is a fallback for hosts where the
76
+ registry is unreadable.
77
+
78
+ **CPU methodology (#943):** the gate/probe runs right after the coordinator's own
79
+ CPU-saturating quality-gate run by construction, so the 1-minute load average
80
+ systematically carries that decaying tail (observed: 96% → 75% within 36s).
81
+ `probe()` therefore also emits `cpu_load_5m` / `cpu_load_5m_pct`, and both
82
+ `evaluate()` and `evaluateWaveResourceGate()` judge CPU on **min(1m, 5m)**:
83
+ only-1m-high is reported as an informational "decaying transient" and is NOT
84
+ counted as a signal at all — so it cannot become the second signal that triggers
85
+ a cap. When `cpu_load_5m_pct` is `null` (Windows, zero-load), judging falls back
86
+ to `cpu_load_pct` alone.
37
87
 
38
88
  ## Presentation
39
89
 
40
90
  Print a one-line Resource Health verdict immediately after Phase 4's output:
41
91
 
42
92
  ```
43
- Resource Health: ⚠ warn — RAM free 3.1 GB below threshold 4 GB; capping agents-per-wave at 2.
93
+ Resource Health: ⚠ warn — two signals agree (cpu + concurrency); capping agents-per-wave at 2.
94
+ ```
95
+
96
+ On `green` with a reported signal, print the line but say plainly that nothing
97
+ was capped — a bare signal with no consequence reads as a suppressed warning:
98
+
99
+ ```
100
+ Resource Health: ✓ green — 6884 MB swap present but memory_pressure healthy (53% free); no cap.
44
101
  ```
45
102
 
46
103
  When `config['heavy-repo']` is `true` and the HR-004 preflight ceiling actually reduces `recommended_agents_per_wave_cap` below what the live verdict alone would have produced, print an additional banner line right after the verdict line:
@@ -49,10 +106,16 @@ When `config['heavy-repo']` is `true` and the HR-004 preflight ceiling actually
49
106
  ⚠ Heavy-repo mode active — agents-per-wave capped to 4 (Session Config heavy-repo: true)
50
107
  ```
51
108
 
52
- When verdict is `warn` or `critical`, use the AskUserQuestion tool to present:
53
- 1. **Proceed as recommended** (apply the cap) Recommended
54
- 2. **Proceed as originally planned** (user accepts the risk)
55
- 3. **Abort** (no wave planning runs; user closes or investigates)
109
+ **AUQ only on `critical`** (#1089). A `warn` applies its cap and reports it in
110
+ one line it does not interrupt. Under the previous rule set warn-or-worse was
111
+ the verdict on 99.0% of session starts, so an AUQ here was an operator interrupt
112
+ on essentially every session, which `.claude/rules/ask-via-tool.md` AUQ-005
113
+ names outright ("an AUQ that blocks nothing"). `critical` means coordinator-direct
114
+ — zero agents — which genuinely changes the plan, so it earns the prompt:
115
+
116
+ 1. **Proceed coordinator-direct** (0 agents) — Recommended
117
+ 2. **Proceed as originally planned** (operator accepts the risk)
118
+ 3. **Abort** (no wave planning runs; operator closes or investigates)
56
119
 
57
120
  When SSH is detected and the session type is `deep`, auto-append this note to the plan handoff to session-plan (no user prompt needed):
58
121
  > Host is SSH-attached — Docker-dependent wave steps should run on a local dev host.