session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fleet-checkin.mjs — write this session's fleet check-in (#1462, PRD !53 § 3.2).
4
+ *
5
+ * Thin CLI over `scripts/lib/fleet-protocol.mjs`, called by session-start
6
+ * Phase 7.6 and the operations-contract peer preflight BEFORE the first writing
7
+ * action of a session.
8
+ *
9
+ * Usage:
10
+ * echo '<check-in JSON>' | node scripts/fleet-checkin.mjs
11
+ *
12
+ * Reads one JSON object from stdin, stamps `zeit` (always overwritten), validates
13
+ * it with `validateCheckin()`, and writes it atomically (tmp file + rename in
14
+ * the target directory, mode 0600) to `<navigatorDir>/checkin/<session>.json`.
15
+ * Then reads the navigator lease and emits `orchestrator.fleet.checkin` with the
16
+ * payload `{ session, repo, modus, kandidaten, navigator_state }` — no paths, no
17
+ * quota, no `auftrag_ref`.
18
+ *
19
+ * stdout: one JSON line
20
+ * `{ ok, path, navigator_state, navigator_adresse, fallback_due_at, event }`.
21
+ * `navigator_adresse` is the active lease's `adresse` exactly as
22
+ * `readNavigatorLease()` validated it (a ListAgents peer name), else `null` — no
23
+ * active lease, no address in it, or one the validator refused. It is the only
24
+ * value the caller may address a hint to; an unvalidated string never reaches it.
25
+ * `fallback_due_at` is `fallbackDueAt(zeit, navigator_state)`: when the
26
+ * Standard-Auflagen apply if no `auflagen/` file has appeared by then. It is
27
+ * stdout only — the check-in FILE schema, which the navigator reads, is unchanged.
28
+ *
29
+ * Exit codes:
30
+ * 0 — check-in written (an event failure only WARNs; `event: false` on stdout)
31
+ * 1 — write error
32
+ * 2 — invalid input (not JSON, not an object, or validation errors; no file)
33
+ */
34
+
35
+ import { randomBytes } from 'node:crypto';
36
+ import { promises as fs } from 'node:fs';
37
+ import os from 'node:os';
38
+ import path from 'node:path';
39
+ import { emitEvent } from './lib/events.mjs';
40
+ import {
41
+ checkinPath,
42
+ fallbackDueAt,
43
+ navigatorDir,
44
+ readNavigatorLease,
45
+ utcSecondsTimestamp,
46
+ validateCheckin,
47
+ } from './lib/fleet-protocol.mjs';
48
+ import { isMainModule } from './lib/is-main-module.mjs';
49
+
50
+ const PREFIX = 'fleet-checkin:';
51
+
52
+ /** @returns {Promise<string>} */
53
+ async function readStdin() {
54
+ let data = '';
55
+ process.stdin.setEncoding('utf8');
56
+ for await (const chunk of process.stdin) data += chunk;
57
+ return data;
58
+ }
59
+
60
+ /**
61
+ * Create `<navigatorDir>/checkin/` (0700) and write `record` atomically to
62
+ * `target` (0600). Refuses a `checkin/` that is not a real directory, so a
63
+ * planted symlink cannot redirect the chmod or the write.
64
+ * @param {string} target
65
+ * @param {object} record
66
+ */
67
+ async function writeCheckinFile(target, record) {
68
+ const checkinDir = path.dirname(target);
69
+ await fs.mkdir(navigatorDir(), { recursive: true, mode: 0o700 });
70
+ await fs.mkdir(checkinDir, { recursive: true, mode: 0o700 });
71
+ const st = await fs.lstat(checkinDir);
72
+ if (!st.isDirectory()) throw new Error(`${checkinDir} is not a directory`);
73
+ // mkdir's mode does not apply to a directory that already existed.
74
+ await fs.chmod(checkinDir, 0o700);
75
+
76
+ const tmp = path.join(checkinDir, `.${path.basename(target)}.${randomBytes(6).toString('hex')}.tmp`);
77
+ try {
78
+ // `wx`: never follow or reuse a pre-existing path at the tmp name.
79
+ await fs.writeFile(tmp, JSON.stringify(record, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
80
+ // The creation mode is filtered through the umask; pin it explicitly.
81
+ await fs.chmod(tmp, 0o600);
82
+ await fs.rename(tmp, target);
83
+ } catch (err) {
84
+ await fs.rm(tmp, { force: true }).catch(() => {});
85
+ throw err;
86
+ }
87
+ }
88
+
89
+ /** @returns {Promise<number>} exit code */
90
+ async function main() {
91
+ let input;
92
+ try {
93
+ input = JSON.parse(await readStdin());
94
+ } catch {
95
+ process.stderr.write(`${PREFIX} stdin is not valid JSON\n`);
96
+ return 2;
97
+ }
98
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
99
+ process.stderr.write(`${PREFIX} stdin must be a JSON object\n`);
100
+ return 2;
101
+ }
102
+
103
+ const record = { ...input, zeit: utcSecondsTimestamp() };
104
+ const errors = validateCheckin(record);
105
+ if (errors.length > 0) {
106
+ for (const e of errors) process.stderr.write(`${PREFIX} ${e}\n`);
107
+ return 2;
108
+ }
109
+
110
+ const target = checkinPath(record.session);
111
+ try {
112
+ await writeCheckinFile(target, record);
113
+ } catch (err) {
114
+ process.stderr.write(`${PREFIX} write failed: ${err instanceof Error ? err.message : String(err)}\n`);
115
+ return 1;
116
+ }
117
+
118
+ const navigator = await readNavigatorLease();
119
+ const navigatorState = navigator.state;
120
+ // readNavigatorLease() returns a lease only when it passed validation, its
121
+ // `adresse` included (an invalid one makes the whole lease `unreadable`).
122
+ const navigatorAdresse =
123
+ navigator.state === 'active' && typeof navigator.lease.adresse === 'string' ? navigator.lease.adresse : null;
124
+
125
+ let event = true;
126
+ try {
127
+ const repoRoot = (process.env.CLAUDE_PROJECT_DIR || '').trim() || process.cwd();
128
+ await emitEvent(
129
+ 'orchestrator.fleet.checkin',
130
+ {
131
+ session: record.session,
132
+ repo: record.repo,
133
+ modus: record.modus,
134
+ kandidaten: record.kandidaten,
135
+ navigator_state: navigatorState,
136
+ },
137
+ { repoRoot },
138
+ );
139
+ } catch (err) {
140
+ event = false;
141
+ process.stderr.write(
142
+ `${PREFIX} WARN: check-in written but event emission failed: ${err instanceof Error ? err.message : String(err)}\n`,
143
+ );
144
+ }
145
+
146
+ // Report the path with `~` for the home prefix: stdout lands in transcripts.
147
+ const home = os.homedir();
148
+ const shownPath = target.startsWith(`${home}${path.sep}`) ? `~${target.slice(home.length)}` : target;
149
+ process.stdout.write(
150
+ JSON.stringify({
151
+ ok: true,
152
+ path: shownPath,
153
+ navigator_state: navigatorState,
154
+ navigator_adresse: navigatorAdresse,
155
+ fallback_due_at: fallbackDueAt(record.zeit, navigatorState),
156
+ event,
157
+ }) + '\n',
158
+ );
159
+ return 0;
160
+ }
161
+
162
+ if (isMainModule(import.meta.url)) {
163
+ main().then(
164
+ (code) => { process.exitCode = code; },
165
+ (err) => {
166
+ process.stderr.write(`${PREFIX} ${err instanceof Error ? err.message : String(err)}\n`);
167
+ process.exitCode = 1;
168
+ },
169
+ );
170
+ }
@@ -21,11 +21,11 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
21
21
 
22
22
  // Aliases per https://code.claude.com/docs/en/sub-agents § model resolution.
23
23
  // Full model IDs (claude-{opus|sonnet|haiku|fable}-N[-N][-YYYYMMDD], e.g.
24
- // "claude-opus-4-7", "claude-sonnet-5", "claude-fable-5-20260101") also
24
+ // "claude-opus-5-5", "claude-sonnet-5", "claude-fable-5-20260101") also
25
25
  // accepted via MODEL_ID_RE. `fable` is a valid upstream model family
26
26
  // alongside opus/sonnet/haiku (#768) — the trailing numeric group is
27
27
  // optional so both the two-part ("claude-sonnet-5") and three-part
28
- // ("claude-opus-4-7") ID shapes validate.
28
+ // ("claude-opus-5-5") ID shapes validate.
29
29
  // Exported so persona-panel modules (catalog-loader.mjs, persona-gate-wave.mjs) can
30
30
  // validate against the SSOT instead of re-declaring (W4 architect MED-1, 2026-05-19).
31
31
  export const ALLOWED_MODEL_ALIASES = new Set(['inherit', 'sonnet', 'opus', 'haiku', 'fable']);
@@ -187,7 +187,7 @@ export function validateAgentFrontmatter(frontmatter) {
187
187
  errors.push({
188
188
  path: 'model',
189
189
  rule: 'enum',
190
- message: `model must be one of ${[...ALLOWED_MODEL_ALIASES].map((m) => JSON.stringify(m)).join('|')} or a full model ID like "claude-opus-4-7" or "claude-sonnet-5" (got ${JSON.stringify(model)})`,
190
+ message: `model must be one of ${[...ALLOWED_MODEL_ALIASES].map((m) => JSON.stringify(m)).join('|')} or a full model ID like "claude-opus-5-5" or "claude-sonnet-5" (got ${JSON.stringify(model)})`,
191
191
  });
192
192
  }
193
193
 
@@ -1,10 +1,11 @@
1
1
  /**
2
- * auto-dream.mjs — Auto-Dream Post-Session Hook helper (issue #502, PRD F2.2).
2
+ * auto-dream.mjs — Memory-maintenance signals and proposal I/O (issue #502).
3
3
  *
4
- * Provides the decision + I/O surface used by session-end Phase 3.6.5 and by
5
- * /memory-cleanup --dry-run / --apply-pending. Decides whether the post-session
6
- * dream should fire, writes the proposed diff to `.orchestrator/pending-dream.md`
7
- * atomically, and applies it in a follow-up session.
4
+ * Provides the due signal used by session-start's maintenance-due probe and
5
+ * the I/O used by /memory-cleanup --dry-run / --apply-pending. The former
6
+ * session-end Phase 3.6.5 nudge was retired in #1288. The manual skill writes
7
+ * `.orchestrator/pending-dream.md` atomically and applies a reviewed proposal;
8
+ * the maintenance probe does not generate proposals or dispatch a cleanup.
8
9
  *
9
10
  * Decision inputs (PRD F2.2 acceptance criteria):
10
11
  * - memory-cleanup-threshold (default 5) — sessions since last cleanup
@@ -118,7 +119,7 @@ export async function readDreamSignals({ repoRoot, memoryDir }) {
118
119
  // ---------------------------------------------------------------------------
119
120
 
120
121
  /**
121
- * Decide whether session-end Phase 3.6.5 should dispatch /memory-cleanup --dry-run.
122
+ * Decide whether session-start should report that memory cleanup is due.
122
123
  *
123
124
  * Rules (PRD F2.2):
124
125
  * - threshold === 0 → never trigger (kill-switch).
@@ -20,7 +20,12 @@ export const FLAG_BOUNDS = Object.freeze({
20
20
  maxSessions: { min: 1, max: 50, default: 5 },
21
21
  maxHours: { min: 0.5, max: 24.0, default: 4.0 },
22
22
  confidenceThreshold: { min: 0.0, max: 1.0, default: 0.85 },
23
- maxTokens: { min: 0, max: 10_000_000, default: 500_000 },
23
+ // Default 0 = token-budget switch OFF until calibrated (HR-101): the budget
24
+ // counts subagent OUTPUT tokens only, and one measured deep session's
25
+ // subagents alone produced 450 499 — a 500 000 default stopped autopilot after
26
+ // about one deep session. Revisit once sessions.jsonl carries enough
27
+ // total_token_output values to pick a threshold that fires rarely.
28
+ maxTokens: { min: 0, max: 10_000_000, default: 0 },
24
29
  });
25
30
 
26
31
  /** Default peer Claude-process count above which `resource-overload` fires when verdict is critical. */
@@ -38,6 +38,7 @@ function clampNumber(value, { min, max, fallback }) {
38
38
  * @property {string|null} kill_switch @property {string|null} kill_switch_detail @property {string[]} sessions
39
39
  * @property {string|null} host_class @property {string|null} resource_verdict_at_start
40
40
  * @property {boolean} fallback_to_manual @property {boolean} dry_run @property {number} total_tokens_used
41
+ * @property {number} tokens_unknown_sessions — #1487 Pkt 4: iterations whose own record carried no token figure; > 0 makes total_tokens_used a lower bound
41
42
  * @property {string|null} worktree_path @property {string|null} parent_run_id @property {number} stall_recovery_count
42
43
  * @property {number|null} blocked_by_issue — Phase D (#341): issue number this loop was waiting on (OPEN-4 commit-deps); null when not blocked
43
44
  * @property {boolean} aborted_by_cohort — Phase D (#341): set by cohort-abort policy (OPEN-5); mutated by W3 P5 orchestration-glue, not by runLoop itself
@@ -131,11 +132,19 @@ export async function runLoop(opts = {}) {
131
132
  confidence_threshold: confidenceThreshold,
132
133
  // Persisted for the same reason as the three above (HR-105): a switch whose
133
134
  // input is never recorded cannot be falsified afterwards. `0` = disabled.
134
- // CEILING: reaching a NON-zero value here does not make TOKEN_BUDGET_EXCEEDED
135
- // live under the headless driver — `readTailSession()` in scripts/autopilot.mjs
136
- // returns no `usage`, and sessions.jsonl carries no token field to build one
137
- // from, so `total_tokens_used` stays 0. Revisit when a session record gains
138
- // token counts.
135
+ // Live path (#1436): scripts/emit-session.mjs merges the subagents.jsonl
136
+ // rollup (`total_token_output`, `total_tokens`) into each session record,
137
+ // scripts/autopilot.mjs selects the unique canonical record whose
138
+ // raw_session_id equals the child's --session-id UUID and projects its
139
+ // totals as `usage`; the accumulator sums them into `total_tokens_used`.
140
+ // Zero or multiple matches throw → FAILED_WAVE, never foreign token usage.
141
+ // CEILING: an own record without token totals adds 0 — the budget
142
+ // undercounts, it never over-fires. Such sessions are counted in
143
+ // `tokens_unknown_sessions` (#1487 Pkt 4), and the switch still fires on the
144
+ // known lower bound: every fire stays correct, none is lost. Failing closed on
145
+ // an unknown session instead would stop an armed run on every
146
+ // coordinator-direct session (no subagents → no rollup totals); revisit if
147
+ // the session record ever carries the coordinator's own usage.
139
148
  max_tokens: opts.maxTokens ?? 0,
140
149
  iterations_completed: 0,
141
150
  kill_switch: null,
@@ -146,6 +155,9 @@ export async function runLoop(opts = {}) {
146
155
  fallback_to_manual: false,
147
156
  dry_run: dryRun,
148
157
  total_tokens_used: 0,
158
+ // #1487 Pkt 4 — completed iterations whose own record carried no token
159
+ // figure; non-zero means `total_tokens_used` is a lower bound.
160
+ tokens_unknown_sessions: 0,
149
161
  // ADR-364 additive fields — forward-compat; callers may omit, defaults are null/0.
150
162
  worktree_path: opts.worktreePath ?? null,
151
163
  parent_run_id: opts.parentRunId ?? null,
@@ -271,14 +283,21 @@ export async function runLoop(opts = {}) {
271
283
  }
272
284
  state.iterations_completed += 1;
273
285
 
274
- // Accumulate output tokens; stays 0 when sessionRunner omits `usage` (forward-compat).
275
- if (sessionResult && typeof sessionResult.usage === 'object' && sessionResult.usage !== null) {
276
- const outTokens = Number(
277
- sessionResult.usage.output_tokens ?? sessionResult.usage.total_tokens
278
- );
279
- if (Number.isFinite(outTokens) && outTokens > 0) {
280
- cumulativeTokens += outTokens;
281
- }
286
+ // Accumulate own-record output tokens (total_tokens fallback). An unassignable
287
+ // child record already threw → FAILED_WAVE above. An own record carrying
288
+ // neither figure has UNKNOWN usage, not 0 (#1487 Pkt 4): it adds nothing, so
289
+ // `total_tokens_used` is a lower bound, and it is counted in
290
+ // `tokens_unknown_sessions` so the record says how much the sum cannot see.
291
+ const usage = sessionResult?.usage;
292
+ const rawTokens = usage !== null && typeof usage === 'object'
293
+ ? (usage.output_tokens ?? usage.total_tokens)
294
+ : undefined;
295
+ // `Number(null)` is 0 — a null figure is a missing one, never a measured 0.
296
+ const outTokens = rawTokens === null || rawTokens === undefined ? NaN : Number(rawTokens);
297
+ if (Number.isFinite(outTokens) && outTokens >= 0) {
298
+ cumulativeTokens += outTokens;
299
+ } else {
300
+ state.tokens_unknown_sessions += 1;
282
301
  }
283
302
  state.total_tokens_used = cumulativeTokens;
284
303
 
@@ -8,7 +8,8 @@
8
8
  * `hooks/on-stop.mjs` refreshes it at every turn-end and
9
9
  * `hooks/post-tool-batch-wave-signal.mjs` at every tool batch.
10
10
  * 2. `autopilot.jsonl` mtime — the legacy marker, used only when no lock path
11
- * is supplied or the lock is absent/corrupt/timestamp-less.
11
+ * is supplied or the lock is absent/corrupt/heartbeat-less (`started_at`
12
+ * is never a stand-in — it does not advance during a run).
12
13
  *
13
14
  * The header of this module used to claim `autopilot.jsonl` carries "one record
14
15
  * per session". It does not: `telemetry.mjs` writes ONE record per /autopilot
@@ -74,11 +75,13 @@ function readHeartbeatMs(sessionLockPath) {
74
75
  return null;
75
76
  }
76
77
  if (typeof lock !== 'object' || lock === null) return null;
77
- // Same precedence `session-lock.mjs` uses on read: a v1 lock has no
78
- // `last_heartbeat`, and its `started_at` is the best available stand-in.
79
- const raw = typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0
80
- ? lock.last_heartbeat
81
- : lock.started_at;
78
+ // `last_heartbeat` only — no `started_at` stand-in, matching `isLockLive()`
79
+ // since the #595 sunset (2026-10-02). `started_at` never advances during a
80
+ // run, so a lock without a heartbeat would read as a stall once 600 s passed
81
+ // (or as fresh within 30 s of its genesis) — a measurement the lock never
82
+ // made. No heartbeat → `null` → the mtime marker, exactly as for a lock
83
+ // with no timestamp at all.
84
+ const raw = lock.last_heartbeat;
82
85
  if (typeof raw !== 'string' || raw.length === 0) return null;
83
86
  const ms = Date.parse(raw);
84
87
  return Number.isFinite(ms) ? ms : null;
@@ -95,9 +98,10 @@ function readHeartbeatMs(sessionLockPath) {
95
98
  * @param {string} [opts.autopilotJsonlPath] — path to autopilot.jsonl (default
96
99
  * '.orchestrator/metrics/autopilot.jsonl').
97
100
  * @param {string} [opts.sessionLockPath] — path to `session.lock`. When supplied
98
- * and readable, its `last_heartbeat` (falling back to `started_at`, the schema
99
- * v1 normalisation `session-lock.mjs` applies on read) REPLACES the mtime
100
- * marker. Omitted → mtime only, which is what every pre-existing caller gets.
101
+ * and readable, its `last_heartbeat` REPLACES the mtime marker. A lock
102
+ * without a parsable `last_heartbeat` contributes nothing (no `started_at`
103
+ * fallback, as in `session-lock.mjs` `isLockLive()`) and the mtime marker is
104
+ * used. Omitted → mtime only, which is what every pre-existing caller gets.
101
105
  * @param {number} [opts.stallTimeoutSeconds] — kill-switch threshold (default
102
106
  * 600). NOT range-clamped here; caller is responsible for bounds.
103
107
  * @param {() => number} [opts.nowMs] — wall-clock supplier (default Date.now).
@@ -19,10 +19,11 @@ const BASENAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.md$/;
19
19
  const unique = (items) => [...new Set(items)].sort();
20
20
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
21
21
 
22
+ /** `message` must never interpolate a baseline root or any other host path. */
22
23
  export class BaselineContractError extends Error {
23
- constructor(reason) { super(`Baseline archetype lookup failed (${reason}).`); this.name = 'BaselineContractError'; this.reason = reason; }
24
+ constructor(reason, message = `Baseline archetype lookup failed (${reason}).`) { super(message); this.name = 'BaselineContractError'; this.reason = reason; }
24
25
  }
25
- function fail(reason = 'invalid-contract') { throw new BaselineContractError(reason); }
26
+ function fail(reason = 'invalid-contract', message) { throw new BaselineContractError(reason, message); }
26
27
  function check(condition) { if (!condition) fail(); }
27
28
  function shape(value, required, optional = []) {
28
29
  check(object(value) && required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => [...required, ...optional].includes(key)));
@@ -122,24 +123,38 @@ process.stdout.write(JSON.stringify(resolveConfiguredBaselinePath(JSON.parse(rea
122
123
  return realpathSync(root);
123
124
  }
124
125
 
125
- /** Resolve a canonical relative source and reject symlinks at every component. */
126
- export function baselineSourcePath(root, source, directory = false) {
126
+ /** Resolve a canonical relative source and reject symlinks at every component.
127
+ * Only an ENOENT thrown by the per-component `lstatSync` itself maps to
128
+ * `missingReason`; every other failure is `unsafe-source`. Existence is never
129
+ * probed with a link-following call (existsSync/statSync/accessSync/realpathSync):
130
+ * a dangling symlink would then read as "missing" instead of `unsafe-source`.
131
+ */
132
+ export function baselineSourcePath(root, source, directory = false, { missingReason = 'unsafe-source' } = {}) {
127
133
  relative(source);
134
+ // Resolved first: a vanished root is unsafe, never a missing source.
135
+ let realRoot;
136
+ try { realRoot = realpathSync(root); } catch { fail('unsafe-source'); }
128
137
  let current = root;
129
- try {
130
- for (const component of source.split('/')) {
131
- current = path.join(current, component);
132
- if (lstatSync(current).isSymbolicLink()) fail('unsafe-source');
133
- }
134
- const stat = lstatSync(current);
135
- if (directory ? !stat.isDirectory() : !stat.isFile()) fail('unsafe-source');
136
- if (!realpathSync(current).startsWith(`${realpathSync(root)}${path.sep}`)) fail('unsafe-source');
137
- return current;
138
- } catch { fail('unsafe-source'); }
138
+ let stat;
139
+ for (const component of source.split('/')) {
140
+ current = path.join(current, component);
141
+ try { stat = lstatSync(current); } catch (error) { fail(error?.code === 'ENOENT' ? missingReason : 'unsafe-source'); }
142
+ if (stat.isSymbolicLink()) fail('unsafe-source');
143
+ }
144
+ if (directory ? !stat.isDirectory() : !stat.isFile()) fail('unsafe-source');
145
+ let real;
146
+ try { real = realpathSync(current); } catch { fail('unsafe-source'); }
147
+ if (!real.startsWith(`${realRoot}${path.sep}`)) fail('unsafe-source');
148
+ return current;
139
149
  }
140
150
 
151
+ const PRODUCER = 'scripts/archetype-manifest.mjs';
141
152
  function projection(root, args, { timeoutMs = 5000, maxBuffer = MAX_BYTES } = {}) {
142
- const script = baselineSourcePath(root, 'scripts/archetype-manifest.mjs');
153
+ let script;
154
+ try { script = baselineSourcePath(root, PRODUCER, false, { missingReason: 'producer-missing' }); } catch (error) {
155
+ if (error?.reason !== 'producer-missing') throw error;
156
+ fail('producer-missing', `Baseline archetype lookup failed (producer-missing): the configured baseline has no ${PRODUCER} exporter — see docs/baseline.md § How bootstrap finds it.`);
157
+ }
143
158
  const result = spawnSync(process.execPath, [script, ...args], {
144
159
  cwd: root, encoding: 'utf8', timeout: Math.min(timeoutMs, 5000), maxBuffer: Math.min(maxBuffer, MAX_BYTES),
145
160
  shell: false, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'],
@@ -195,7 +210,8 @@ export async function loadBaselineArchetypes(options = {}) {
195
210
  let manifest;
196
211
  try { manifest = validateBaselineContract(JSON.parse(projection(root, ['export'], options))); }
197
212
  catch (error) { if (error instanceof BaselineContractError) throw error; fail(); }
198
- for (const item of manifest.archetypes) baselineSourcePath(root, item.templatePath, true);
213
+ // An absent declared source is an incomplete baseline, not a security refusal (#1447 3a).
214
+ for (const item of manifest.archetypes) baselineSourcePath(root, item.templatePath, true, { missingReason: 'invalid-contract' });
199
215
  const facts = collectFacts(repoRoot);
200
216
  const ordered = [...manifest.archetypes].sort((a, b) => a.order - b.order);
201
217
  const archetypes = ordered.map(({ id, order, runtimes, packageManagers, ui, api, deploy }) => ({ id, order, runtimes, packageManagers, ui, api, deploy }));
@@ -220,7 +236,7 @@ export async function loadBaselineArchetypes(options = {}) {
220
236
  if (!ruleTargets.includes(name) || seen.has(name)) fail('invalid-rule-projection');
221
237
  seen.add(name);
222
238
  if (owned.includes(name)) continue;
223
- baselineSourcePath(root, source);
239
+ baselineSourcePath(root, source, false, { missingReason: 'invalid-rule-projection' });
224
240
  baselineRules.push({ source, target: `.claude/rules/${name}` });
225
241
  }
226
242
  if (ruleTargets.some((name) => !seen.has(name))) fail('invalid-rule-projection');
@@ -230,7 +246,9 @@ export async function loadBaselineArchetypes(options = {}) {
230
246
  pluginRuleTargets: ruleTargets.filter((name) => owned.includes(name)), baselineRules,
231
247
  } };
232
248
  } catch (error) {
233
- return { status: 'error', reason: error instanceof BaselineContractError ? error.reason : 'lookup-failed', archetypes: [], selected: null };
249
+ // Only a BaselineContractError message is path-free by construction; others stay opaque.
250
+ const contract = error instanceof BaselineContractError;
251
+ return { status: 'error', reason: contract ? error.reason : 'lookup-failed', ...(contract && { message: error.message }), archetypes: [], selected: null };
234
252
  }
235
253
  }
236
254
 
@@ -290,11 +308,12 @@ function applyCopies(plan) {
290
308
  return { status: 'applied', created, preserved };
291
309
  }
292
310
  function applyFailure(error) {
293
- return { status: 'error', reason: error instanceof BaselineContractError ? error.reason : 'apply-failed', created: [], preserved: [] };
311
+ const contract = error instanceof BaselineContractError;
312
+ return { status: 'error', reason: contract ? error.reason : 'apply-failed', ...(contract && { message: error.message }), created: [], preserved: [] };
294
313
  }
295
314
  async function selectedContext(options) {
296
315
  const result = await loadBaselineArchetypes(options);
297
- if (result.status !== 'private' || !result.selected) fail(result.reason);
316
+ if (result.status !== 'private' || !result.selected) fail(result.reason, result.message);
298
317
  const root = await resolveBaselineLocation(options);
299
318
  if (!root) fail('baseline-unavailable');
300
319
  return { root, selected: result.selected };
@@ -340,7 +359,7 @@ export async function scaffoldBaselineArchetype(options = {}) {
340
359
  const { repoRoot = process.cwd(), projectName } = options;
341
360
  if (typeof projectName !== 'string' || !ID.test(projectName)) fail('invalid-project-name');
342
361
  const { root, selected } = await selectedContext(options);
343
- const template = baselineSourcePath(root, selected.templatePath, true);
362
+ const template = baselineSourcePath(root, selected.templatePath, true, { missingReason: 'invalid-contract' });
344
363
  sourceFiles(template);
345
364
  const shared = baselineSourcePath(root, 'templates/shared', true);
346
365
  sourceFiles(shared);
@@ -401,7 +420,7 @@ export async function syncBootstrapRules(options = {}) {
401
420
  // ordinary plugin rules, just as its minimal scaffold does.
402
421
  const contract = options.minimal ? { status: 'public', selected: null }
403
422
  : await loadBaselineArchetypes({ ...options, repoRoot, pluginRoot, archetype });
404
- if (contract.status === 'error') fail(contract.reason);
423
+ if (contract.status === 'error') fail(contract.reason, contract.message);
405
424
  if (contract.status === 'private' && !contract.selected && !hasUnselectedFastLock(repoRoot)) fail(contract.reason);
406
425
  const before = new Set();
407
426
  for (const name of pluginRuleTargets(pluginRoot)) {
@@ -198,6 +198,46 @@ export function mergePolicies(floor, overlay) {
198
198
  return { rules: merged, warnings };
199
199
  }
200
200
 
201
+ // ---------------------------------------------------------------------------
202
+ // Wave-manifest projection
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * The patterns a wave manifest's `blockedCommands` may carry (#1509): the
207
+ * `pattern` of every `severity: 'block'` rule that has NO `type`.
208
+ *
209
+ * A TYPED rule's `pattern` is not a command pattern. `redirect-truncate` rules
210
+ * carry `pattern: ">"` and `path-delete` rules a free-text label; both are
211
+ * decided by their TARGET, type-aware, in hooks/pre-bash-destructive-guard.mjs.
212
+ * `hooks/enforce-commands.mjs` reads every `blockedCommands` entry as a command
213
+ * pattern, so the flattened `">"` denied interpreter payloads such as
214
+ * `bash -c "echo hi > /tmp/x"` (measured 2026-10-03 @ 8292050e). An untyped
215
+ * rule is the one class whose pattern means the same thing to both hooks.
216
+ *
217
+ * An UNKNOWN `type` is excluded as well: its pattern's meaning is unknown, and
218
+ * the destructive guard still evaluates the rule on every Bash call, so leaving
219
+ * it out of the manifest drops no protection — admitting it repeats #1509 for
220
+ * the next rule type.
221
+ *
222
+ * Total: a non-array `rules` (e.g. `loadEffectivePolicy()`'s `null`) yields `[]`.
223
+ *
224
+ * @param {unknown} rules - the `rules` of {@link loadEffectivePolicy}
225
+ * @returns {string[]}
226
+ */
227
+ export function blockedCommandPatterns(rules) {
228
+ if (!Array.isArray(rules)) return [];
229
+ return rules
230
+ .filter(
231
+ (r) =>
232
+ r !== null &&
233
+ typeof r === 'object' &&
234
+ r.severity === 'block' &&
235
+ (r.type ?? null) === null &&
236
+ typeof r.pattern === 'string',
237
+ )
238
+ .map((r) => r.pattern);
239
+ }
240
+
201
241
  // ---------------------------------------------------------------------------
202
242
  // Load (with per-path mtime cache)
203
243
  // ---------------------------------------------------------------------------
@@ -54,6 +54,47 @@ export function expandTilde(p) {
54
54
  return p;
55
55
  }
56
56
 
57
+ /**
58
+ * Replace the current user's home directory with `~` wherever it occurs in
59
+ * `text` — the inverse of {@link expandTilde}, for free text as well as a bare
60
+ * path: an `err.message` from `node:fs` (`ENOENT: …, open '/Users/bob/v/x.md'`),
61
+ * git stderr, a resolved vault dir. Every occurrence is replaced.
62
+ *
63
+ * Log lines of the vault writers and the session-end shell land in CI logs and
64
+ * session transcripts, where the home dir is an owner-privacy leak (#1490,
65
+ * `.claude/rules/security.md` § Owner-Privacy).
66
+ *
67
+ * Boundary-aware on both sides. On the right the home dir must end at a path
68
+ * separator, the end of the text, whitespace, a quote/bracket/list delimiter
69
+ * (`'"\`)]},;:`) or a sentence-final `.`, so `/Users/bob` never rewrites
70
+ * `/Users/bobby/x` or `/Users/bob.old`. On the left it must not continue a
71
+ * path segment, so `/data/Users/bob` stays. A home dir that is empty, the
72
+ * filesystem root or a bare drive root is a no-op — collapsing it would
73
+ * rewrite every absolute path. Non-strings pass through unchanged.
74
+ *
75
+ * @param {string} text
76
+ * @param {string} [home] — defaults to `os.homedir()`; an unresolvable home
77
+ * leaves `text` unchanged (a log redactor must never turn an error path into
78
+ * a crash)
79
+ * @returns {string}
80
+ */
81
+ export function redactHomeDir(text, home) {
82
+ if (typeof text !== 'string' || text.length === 0) return text;
83
+ let h = home;
84
+ if (h === undefined) {
85
+ try {
86
+ h = os.homedir();
87
+ } catch {
88
+ return text;
89
+ }
90
+ }
91
+ h = String(h ?? '').replace(/[/\\]+$/, '');
92
+ if (h.length === 0 || /^[a-z]:$/i.test(h)) return text;
93
+ const escaped = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
+ const re = new RegExp(`(?<![\\w.~-])${escaped}(?=$|[/\\\\\\s'"\`)\\]},;:]|\\.(?:$|\\s))`, 'g');
95
+ return text.replace(re, '~');
96
+ }
97
+
57
98
  // ---------------------------------------------------------------------------
58
99
  // Timestamp helpers
59
100
  // ---------------------------------------------------------------------------