session-orchestrator 3.21.0 → 3.23.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 (345) 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/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +11 -2
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -41,17 +41,28 @@ function isTestFile(filePath) {
41
41
  /**
42
42
  * Execute a shell command and return a structured result.
43
43
  *
44
+ * `output` is a bounded TAIL for humans. `fullOutput` is the complete captured
45
+ * text and is what every COUNT parse must read.
46
+ *
47
+ * Why both: the tail was 5 lines and was also the parser's input. Vitest prints
48
+ * its `Test Files` / `Tests` summary and THEN the per-failure detail, so on any
49
+ * real failure the summary sits hundreds of lines above the tail — measured
50
+ * 2026-08-22: line 174 of 936. `extractTestCounts` then found nothing and the
51
+ * gate reported `test: fail, total 0, passed 0, failed 0`, which reads as
52
+ * "the runner never produced results" and hides WHICH test failed. An hour was
53
+ * spent chasing that phantom before the real cause (one red test) was found.
54
+ *
44
55
  * @param {string} cmd - Shell command to run, or `"skip"` / empty to skip.
45
- * @returns {{ status: 'pass'|'fail'|'skip', output: string, exitCode: number, stubbed?: { kind: 'echo'|'noop' } }}
56
+ * @returns {{ status: 'pass'|'fail'|'skip', output: string, fullOutput: string, exitCode: number, stubbed?: { kind: 'echo'|'noop' } }}
46
57
  */
47
58
  export function runCheck(cmd) {
48
59
  if (!cmd || cmd === 'skip') {
49
- return { status: 'skip', output: '', exitCode: 0 };
60
+ return { status: 'skip', output: '', fullOutput: '', exitCode: 0 };
50
61
  }
51
62
 
52
63
  const stub = detectStubCommand(cmd);
53
64
  if (stub.isStub) {
54
- return { status: 'pass', output: `(stubbed: ${stub.kind})`, exitCode: 0, stubbed: { kind: stub.kind } };
65
+ return { status: 'pass', output: `(stubbed: ${stub.kind})`, fullOutput: `(stubbed: ${stub.kind})`, exitCode: 0, stubbed: { kind: stub.kind } };
55
66
  }
56
67
 
57
68
  try {
@@ -61,18 +72,18 @@ export function runCheck(cmd) {
61
72
  maxBuffer: RUN_CHECK_MAX_BUFFER_BYTES,
62
73
  });
63
74
  const output = raw.split('\n').slice(-5).join('\n').trim();
64
- return { status: 'pass', output, exitCode: 0 };
75
+ return { status: 'pass', output, fullOutput: raw, exitCode: 0 };
65
76
  } catch (err) {
66
77
  const exitCode = typeof err.status === 'number' ? err.status : 1;
67
78
 
68
79
  // Exit code 127 means command not found — treat as skip.
69
80
  if (exitCode === 127) {
70
- return { status: 'skip', output: 'command not found', exitCode };
81
+ return { status: 'skip', output: 'command not found', fullOutput: '', exitCode };
71
82
  }
72
83
 
73
84
  const combined = [err.stdout ?? '', err.stderr ?? ''].join('\n');
74
85
  const output = combined.split('\n').slice(-5).join('\n').trim();
75
- return { status: 'fail', output, exitCode };
86
+ return { status: 'fail', output, fullOutput: combined, exitCode };
76
87
  }
77
88
  }
78
89
 
@@ -143,7 +154,35 @@ export function extractCount(output, pattern) {
143
154
  const TEST_SUMMARY_LINE = /^\s*Tests\b/;
144
155
 
145
156
  /**
146
- * Parse test-runner output for pass/fail/total TEST-CASE counts.
157
+ * Matches vitest's FILE-level summary line (`Test Files 8 failed | 595 passed (603)`).
158
+ *
159
+ * Distinct from {@link TEST_SUMMARY_LINE} (the TEST-CASE line, `Tests ...`): a
160
+ * suite that dies at import time is counted HERE, and the `Tests` line then
161
+ * carries no `N failed` segment at all (#1149) — vitest omits it whenever zero
162
+ * individual test cases ran. Last matching line wins (the final summary after
163
+ * any rerun), mirroring `TEST_SUMMARY_LINE`'s own convention.
164
+ */
165
+ const TEST_FILES_SUMMARY_LINE = /^\s*Test Files\b/;
166
+
167
+ /**
168
+ * Parse a `<N> passed` / `<N> failed` pair out of one summary line/scope.
169
+ * `total` is their sum (skipped excluded) — the same rule the top-level
170
+ * test-case triple in {@link extractTestCounts} publishes.
171
+ *
172
+ * @param {string} scope
173
+ * @returns {{ passed: number, failed: number, total: number }}
174
+ */
175
+ function parseCountTriple(scope) {
176
+ const passMatch = scope.match(/(\d+)\s+passed/);
177
+ const failMatch = scope.match(/(\d+)\s+failed/);
178
+ const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
179
+ const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
180
+ return { passed, failed, total: passed + failed };
181
+ }
182
+
183
+ /**
184
+ * Parse test-runner output for pass/fail/total TEST-CASE counts, plus the
185
+ * vitest FILE-level triple when a `Test Files` summary line is present.
147
186
  *
148
187
  * ## Which line is parsed
149
188
  *
@@ -157,10 +196,38 @@ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
157
196
  * A naive whole-output scan hits the FILE count (550) and publishes it as the
158
197
  * test count — the number then rides `gate-full.mjs`'s `test.passed` into the
159
198
  * `orchestrator.quality_gate.*` event stream looking authoritative. So: when a
160
- * `Tests`-anchored summary line exists, ONLY that line is parsed (the LAST one,
161
- * which is the final summary after any rerun). Terse or non-vitest output with
162
- * no such line falls back to scanning the whole string, which preserves the
163
- * bare `"42 passed"` / `"10 passed, 5 failed"` forms.
199
+ * `Tests`-anchored summary line exists, ONLY that line is parsed for the
200
+ * test-case triple (the LAST one, which is the final summary after any
201
+ * rerun). Terse or non-vitest output with no such line falls back to scanning
202
+ * the whole string, which preserves the bare `"42 passed"` / `"10 passed, 5
203
+ * failed"` forms.
204
+ *
205
+ * The `Test Files` line is parsed the same way, into a SEPARATE nested `files`
206
+ * triple — never folded into `failed`/`total`. `total === passed + failed` is
207
+ * load-bearing for the test-case triple (`admitSuiteCounts`, and historically
208
+ * `failed = total - passed` in `run-quality-gate.mjs`); mixing file counts in
209
+ * would either break that invariant or invent phantom test-case failures. A
210
+ * suite that dies at import is invisible on the `Tests` line (vitest omits
211
+ * `N failed` there when zero test cases ran) and visible only on `Test
212
+ * Files` — #1149, measured as
213
+ * `{"test":{"status":"fail","total":14671,"passed":14671,"failed":0}}`, with
214
+ * no field anywhere naming the 8 files that actually died.
215
+ * `gate-full.mjs` derives a `suite_died` boolean from `files.failed` for
216
+ * exactly this shape.
217
+ *
218
+ * ## `files` is NULL when no `Test Files` line was seen — never a zero triple
219
+ *
220
+ * Only vitest prints that line. A non-vitest runner, a terse reporter, or a
221
+ * fail-fast crash produces output with no file-level summary at all, and the
222
+ * function used to publish `files_total/passed/failed: 0` there — an UNMEASURED
223
+ * zero, byte-identical in the envelope to a measured one, from which
224
+ * `gate-full.mjs` then derived `suite_died: false` as if it had checked. That is
225
+ * the same "absent is not zero" defect `admitSuiteCounts` exists to prevent one
226
+ * field over, so it uses the same channel: `null` means "no file-level
227
+ * measurement", and the caller OMITS the keys rather than zero-filling them.
228
+ * The test-case triple keeps its zeros — those are the caller's fallback, not a
229
+ * parse result, and `total: 0` is already the "no counts" signal
230
+ * `admitSuiteCounts` rejects.
164
231
  *
165
232
  * ## What `total` means — passed + failed, NOT vitest's parenthesised number
166
233
  *
@@ -178,22 +245,27 @@ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
178
245
  * see. Add it together with a consumer, never ahead of one.
179
246
  *
180
247
  * @param {string} output - Captured test-runner stdout/stderr (or a tail of it).
181
- * @returns {{ passed: number, failed: number, total: number }} `total === passed + failed`.
248
+ * @returns {{ passed: number, failed: number, total: number, files: {passed: number, failed: number, total: number}|null }}
249
+ * Test-case `total === passed + failed`. `files` is `null` when the output
250
+ * carried no `Test Files` summary line; when present,
251
+ * `files.total === files.passed + files.failed`.
182
252
  */
183
253
  export function extractTestCounts(output) {
184
- if (!output) return { passed: 0, failed: 0, total: 0 };
254
+ const EMPTY = { passed: 0, failed: 0, total: 0, files: null };
255
+ if (!output) return EMPTY;
185
256
 
186
- const summaryLines = output.split('\n').filter((line) => TEST_SUMMARY_LINE.test(line));
257
+ const lines = output.split('\n');
258
+ const summaryLines = lines.filter((line) => TEST_SUMMARY_LINE.test(line));
187
259
  const scope = summaryLines.length > 0 ? summaryLines[summaryLines.length - 1] : output;
260
+ const { passed, failed, total } = parseCountTriple(scope);
188
261
 
189
- const passMatch = scope.match(/(\d+)\s+passed/);
190
- const failMatch = scope.match(/(\d+)\s+failed/);
191
-
192
- const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
193
- const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
194
- const total = passed + failed;
262
+ const fileSummaryLines = lines.filter((line) => TEST_FILES_SUMMARY_LINE.test(line));
263
+ const fileScope = fileSummaryLines.length > 0 ? fileSummaryLines[fileSummaryLines.length - 1] : '';
264
+ // No `Test Files` line → NOT MEASURED. See the docstring section above: a zero
265
+ // triple here would be indistinguishable from a real all-passed-zero-files run.
266
+ const files = fileScope ? parseCountTriple(fileScope) : null;
195
267
 
196
- return { passed, failed, total };
268
+ return { passed, failed, total, files };
197
269
  }
198
270
 
199
271
  /**
@@ -47,11 +47,35 @@
47
47
  * - a `.git/config` written AFTER this probe ran. It is a session-start
48
48
  * snapshot, not a watcher.
49
49
  *
50
- * **Revisit trigger:** if an operator reports a standing false positive on a
51
- * deliberate local override, add an explicit allow-list (a `[sessionOrchestrator]`
50
+ * **Revisit trigger (identity / remote / gpgsign only):** if an operator
51
+ * reports a standing false positive on a deliberate local override of one of
52
+ * those three, add an explicit allow-list (a `[sessionOrchestrator]`
52
53
  * local-config key naming the accepted keys) — do NOT widen or delete a rule,
53
54
  * which would re-open the exact hole the probe was built to close.
54
55
  *
56
+ * `hooks-path` is no longer in that bucket (#1158, 2026-08-28): a
57
+ * `core.hooksPath` outside `.husky/_` is accepted when the repo DECLARES it —
58
+ * `git ls-files -- <hooksPath>` returns at least one TRACKED file DIRECTLY
59
+ * under the hooksPath whose BASENAME is a real git hook name (see
60
+ * `GIT_HOOK_NAMES`, sourced from `git help hooks`), the same "declared beats
61
+ * guessed" shape `isFixtureHost` already applies to the remote finding. A
62
+ * tracked `.githooks/pre-commit` with `core.hooksPath=.githooks` is a
63
+ * deliberate setup, not drift, and stops flagging without any config key. An
64
+ * untracked or out-of-repo hooksPath still flags exactly as before — that is
65
+ * still the incident class this probe exists for (a fixture that rewrote
66
+ * hooksPath to somewhere nothing is tracked).
67
+ *
68
+ * **Narrowed 2026-08-28 (review, #1158/#1159 N1):** the first cut of this
69
+ * acceptance rule asked only "does `git ls-files` return anything at all
70
+ * under the hooksPath" — which accepted `core.hooksPath=scripts` the moment
71
+ * ANY file under `scripts/` was tracked, including an UNTRACKED executable
72
+ * literally named `pre-commit` planted alongside ordinary tracked source
73
+ * (measured exploit: `git ls-files -- scripts` returns 456 tracked files in
74
+ * this repo, none of them a hook, and the old check accepted it on count
75
+ * alone). `hooksPathIsTracked` now requires a tracked file DIRECTLY under the
76
+ * hooksPath whose basename git itself would invoke as a hook — a tracked
77
+ * directory of unrelated source no longer counts as a declaration.
78
+ *
55
79
  * ## Fail-open is forbidden here
56
80
  *
57
81
  * A failed query is NOT a clean repo. That fold — `null` meaning both "asked
@@ -77,6 +101,7 @@
77
101
  */
78
102
 
79
103
  import { spawnSync } from 'node:child_process';
104
+ import { isAbsolute, relative } from 'node:path';
80
105
 
81
106
  /** Default timeout in ms for each `git config` invocation. */
82
107
  export const DEFAULT_TIMEOUT_MS = 5000;
@@ -112,7 +137,9 @@ export const DEGRADED_REASONS = Object.freeze([
112
137
  * global identity. The costliest entry of the incident.
113
138
  * - `local-gpgsign` — a local `commit.gpgsign` differing from the global.
114
139
  * - `fixture-remote` — a remote URL on a reserved/fixture host.
115
- * - `hooks-path` — a local `core.hooksPath` not pointing at `.husky/_`.
140
+ * - `hooks-path` — a local `core.hooksPath` not pointing at `.husky/_`
141
+ * and not DECLARED by the repo — no tracked file directly under it is a
142
+ * real git hook name; see `hooksPathIsTracked` / `GIT_HOOK_NAMES`.
116
143
  *
117
144
  * @type {readonly ['ambient-git-env','local-identity','local-gpgsign','fixture-remote','hooks-path']}
118
145
  */
@@ -181,6 +208,45 @@ const FIXTURE_HOSTS = Object.freeze([
181
208
  /** The only `core.hooksPath` this repo expects, matched as a path tail. */
182
209
  const EXPECTED_HOOKS_PATH_TAIL = '.husky/_';
183
210
 
211
+ /**
212
+ * Every hook name git itself recognizes under a `core.hooksPath` directory.
213
+ * Source: `git help hooks`. A tracked file below a declared hooksPath whose
214
+ * BASENAME is not one of these is not a hook git will ever invoke — accepting
215
+ * it as a "declaration" would accept an arbitrary tracked file, which is the
216
+ * #1158 review finding this list closes (see the module header's Narrowed
217
+ * note).
218
+ */
219
+ const GIT_HOOK_NAMES = Object.freeze([
220
+ 'applypatch-msg',
221
+ 'pre-applypatch',
222
+ 'post-applypatch',
223
+ 'pre-commit',
224
+ 'pre-merge-commit',
225
+ 'prepare-commit-msg',
226
+ 'commit-msg',
227
+ 'post-commit',
228
+ 'pre-rebase',
229
+ 'post-checkout',
230
+ 'post-merge',
231
+ 'pre-push',
232
+ 'pre-receive',
233
+ 'update',
234
+ 'proc-receive',
235
+ 'post-receive',
236
+ 'post-update',
237
+ 'reference-transaction',
238
+ 'push-to-checkout',
239
+ 'pre-auto-gc',
240
+ 'post-rewrite',
241
+ 'sendemail-validate',
242
+ 'fsmonitor-watchman',
243
+ 'p4-changelist',
244
+ 'p4-prepare-changelist',
245
+ 'p4-post-changelist',
246
+ 'p4-pre-submit',
247
+ 'post-index-change',
248
+ ]);
249
+
184
250
  /**
185
251
  * Build the filtered child environment. See {@link GIT_ENV_ALLOWLIST}.
186
252
  *
@@ -310,6 +376,67 @@ function hooksPathIsExpected(value) {
310
376
  return normalized === EXPECTED_HOOKS_PATH_TAIL || normalized.endsWith(`/${EXPECTED_HOOKS_PATH_TAIL}`);
311
377
  }
312
378
 
379
+ /**
380
+ * @param {string} pathspec repo-relative hooksPath (posix separators, no
381
+ * trailing slash) already resolved to sit inside `repoRoot`
382
+ * @param {string} trackedLine one line of `git ls-files` output (repo-relative;
383
+ * git prints `/`-separated paths even on Windows, but this normalizes `\`
384
+ * too in case a future caller feeds it something else)
385
+ * @returns {boolean} true when `trackedLine` sits DIRECTLY under `pathspec`
386
+ * (not in a nested subdirectory) and its basename is a recognized git hook
387
+ * name — see {@link GIT_HOOK_NAMES}. A tracked file two levels deep, or a
388
+ * tracked file with an unrelated name, is deliberately NOT a declaration.
389
+ */
390
+ function isDeclaredHookFile(pathspec, trackedLine) {
391
+ const line = String(trackedLine ?? '').replace(/\\/g, '/');
392
+ const slash = line.lastIndexOf('/');
393
+ const dir = slash === -1 ? '' : line.slice(0, slash);
394
+ const base = slash === -1 ? line : line.slice(slash + 1);
395
+ return dir === pathspec && GIT_HOOK_NAMES.includes(base);
396
+ }
397
+
398
+ /**
399
+ * @param {string} value a `core.hooksPath` value that already failed
400
+ * {@link hooksPathIsExpected}
401
+ * @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
402
+ * the same context {@link runGitConfig} uses — same filtered env, same `-C`
403
+ * seam, so this query cannot be redirected any more than the config read can
404
+ * @returns {boolean} true when the path is DECLARED as a hook by the repo:
405
+ * `git ls-files -- <path>` returns at least one TRACKED file DIRECTLY under
406
+ * it whose basename is a real git hook name (#1158 review — a tracked
407
+ * directory of ordinary source files is not itself a declaration; see
408
+ * {@link GIT_HOOK_NAMES} / {@link isDeclaredHookFile}). An absolute value is
409
+ * resolved relative to `repoRoot` first; a value outside the repo root can
410
+ * never be tracked and returns `false` without spawning git. Any spawn
411
+ * failure (missing git, timeout, non-zero exit) also returns `false` — this
412
+ * helper only ever WIDENS acceptance, so a query it cannot answer must fall
413
+ * back to "not declared", never the reverse.
414
+ */
415
+ function hooksPathIsTracked(value, ctx) {
416
+ const raw = String(value ?? '').trim();
417
+ if (raw === '') return false;
418
+
419
+ let pathspec = raw.replace(/\\/g, '/').replace(/\/+$/, '');
420
+ if (isAbsolute(raw)) {
421
+ const rel = relative(ctx.repoRoot, raw);
422
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return false; // outside repo root
423
+ pathspec = rel.replace(/\\/g, '/').replace(/\/+$/, '');
424
+ }
425
+
426
+ const res = ctx.spawn('git', ['-C', ctx.repoRoot, 'ls-files', '--', pathspec], {
427
+ encoding: 'utf8',
428
+ timeout: ctx.timeoutMs,
429
+ env: filteredGitEnv(ctx.env),
430
+ stdio: ['ignore', 'pipe', 'pipe'],
431
+ });
432
+
433
+ if (res?.error || res?.signal) return false;
434
+ if (typeof res?.status !== 'number' || res.status !== 0) return false;
435
+
436
+ const lines = String(res?.stdout ?? '').split('\n').filter((l) => l.length > 0);
437
+ return lines.some((line) => isDeclaredHookFile(pathspec, line));
438
+ }
439
+
313
440
  /**
314
441
  * Build the degraded result. Distinct from `null` on purpose — see the header.
315
442
  *
@@ -439,12 +566,14 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
439
566
  continue;
440
567
  }
441
568
 
442
- if (lower === 'core.hookspath' && !hooksPathIsExpected(value)) {
569
+ if (lower === 'core.hookspath' && !hooksPathIsExpected(value) && !hooksPathIsTracked(value, ctx)) {
443
570
  findings.push({
444
571
  kind: 'hooks-path',
445
572
  key,
446
573
  value,
447
- detail: `core.hooksPath=${value} zeigt nicht auf ${EXPECTED_HOOKS_PATH_TAIL} — Husky-Hooks laufen nicht.`,
574
+ detail:
575
+ `core.hooksPath=${value} zeigt nicht auf ${EXPECTED_HOOKS_PATH_TAIL} und ist im Repo ` +
576
+ `nicht als getrackter Pfad deklariert — Hooks laufen möglicherweise nicht.`,
448
577
  });
449
578
  }
450
579
  }
@@ -30,11 +30,26 @@
30
30
  */
31
31
 
32
32
  import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
33
- import { existsSync } from 'node:fs';
33
+ import {
34
+ existsSync,
35
+ readFileSync,
36
+ writeFileSync,
37
+ renameSync,
38
+ chmodSync,
39
+ mkdirSync,
40
+ unlinkSync,
41
+ } from 'node:fs';
34
42
  import { digestSha256WithSalt } from './crypto-digest-utils.mjs';
35
43
  import path from 'node:path';
36
44
  import os from 'node:os';
37
- import { SO_OS, SO_PLATFORM } from './platform.mjs';
45
+ // NOTE (#1072 gate fix): platform.mjs is deliberately NOT imported statically.
46
+ // Its module level computes SO_PLATFORM/SO_PLUGIN_ROOT via detectPlatform()/
47
+ // resolvePluginRoot(), which walk the filesystem (existsSync + readFileSync of
48
+ // package.json up the tree). session-lock.mjs now imports this module, which
49
+ // put those import-time reads into every graph that imports session-lock —
50
+ // including fs-mocked test graphs (tests/lib/vault-mirror/process.test.mjs went
51
+ // red: the walk consumed the mocks' sequenced return values). The only two
52
+ // consumers (SO_OS/SO_PLATFORM in collectFingerprint) are async — load lazily.
38
53
 
39
54
  const FINGERPRINT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
40
55
  const PLACEHOLDER_SALT = 'env-aware-v1-default-salt-replaced-by-owner-yaml';
@@ -97,6 +112,235 @@ export function hashHostname(hostname, salt) {
97
112
  return digestSha256WithSalt(hostname, { salt: String(salt) });
98
113
  }
99
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Stable host identity (GitLab #1072)
117
+ // ---------------------------------------------------------------------------
118
+ //
119
+ // `os.hostname()` is NOT stable on a single machine. Measured on the reference
120
+ // host 2026-08-24: two readings ten minutes apart returned `Mac.home` and
121
+ // `Ferdinands-MacBook-Pro.local`, and `.orchestrator/metrics/events.jsonl`
122
+ // carries both spellings (106× / 27×). Every `lock.host === os.hostname()`
123
+ // comparison in the lock family therefore fails against the machine's OWN lock
124
+ // after a flip: stale detection reports `cross-host` (and cross-host locks are
125
+ // never reaped or overridden by design, PSA-003), the reaper refuses with
126
+ // `cross-host-requires-operator`, and release owner-match returns `not-owner`.
127
+ //
128
+ // Two independent layers, because one is not enough:
129
+ // 1. `stableHostname()` normalises the SUFFIX difference (`Mac.home` vs
130
+ // `Mac.local` → `mac`). It cannot bridge `mac` vs `ferdinands-macbook-pro`
131
+ // — those differ before the suffix.
132
+ // 2. The self-alias ledger records every spelling THIS machine has presented
133
+ // itself under, so `hostnamesMatch()` can bridge the remaining gap. Only
134
+ // names the local machine wrote about itself ever enter it, which is what
135
+ // keeps a genuinely foreign host from ever matching.
136
+
137
+ /**
138
+ * Suffixes stripped by {@link stableHostname}. mDNS (`.local`), the common
139
+ * router-assigned search domains (`.home`, `.lan`), and the POSIX default
140
+ * (`.localdomain`). Deliberately NOT a generic "strip the last label" rule:
141
+ * `a.b.example.com` is a real FQDN whose labels carry meaning.
142
+ */
143
+ const LOCAL_HOST_SUFFIXES = ['.local', '.home', '.lan', '.localdomain'];
144
+
145
+ /**
146
+ * Normalise a hostname into a comparable form: trimmed, lowercased, with ONE
147
+ * trailing local-network suffix removed.
148
+ *
149
+ * Never throws. A non-string, empty, or whitespace-only input yields `''`
150
+ * (which {@link hostnamesMatch} treats as "no identity" — it never matches).
151
+ *
152
+ * NOTE on the default: `stableHostname()` and `stableHostname(undefined)` both
153
+ * normalise `os.hostname()`, because that is JavaScript default-parameter
154
+ * semantics. Pass `''` or `null` to get the empty result.
155
+ *
156
+ * @param {string} [raw=os.hostname()]
157
+ * @returns {string} normalised hostname, or '' when there is nothing to normalise.
158
+ */
159
+ export function stableHostname(raw = os.hostname()) {
160
+ if (typeof raw !== 'string') return '';
161
+ let name = raw.trim().toLowerCase();
162
+ if (name === '') return '';
163
+ for (const suffix of LOCAL_HOST_SUFFIXES) {
164
+ // `name.length > suffix.length` keeps a bare `.local` from normalising to ''.
165
+ if (name.endsWith(suffix) && name.length > suffix.length) {
166
+ name = name.slice(0, -suffix.length);
167
+ break; // ONE suffix only — `foo.lan.local` keeps its inner label.
168
+ }
169
+ }
170
+ return name;
171
+ }
172
+
173
+ /**
174
+ * Host-alias ledger path. `SO_HOST_ALIASES_FILE` overrides it — MANDATORY for
175
+ * tests, which must never write into the operator's real `~/.config`.
176
+ *
177
+ * The `.trim() || fallback` shape is deliberate: a whitespace-only env var is
178
+ * truthy and would otherwise short-circuit the `||` (see `.claude/rules/
179
+ * development.md` § Error Handling, env-var fallback whitespace trap).
180
+ *
181
+ * @returns {string}
182
+ */
183
+ function _hostAliasesFile() {
184
+ const override = (process.env.SO_HOST_ALIASES_FILE || '').trim();
185
+ if (override) return override;
186
+ return path.join(_privateDir(), 'host-aliases.json');
187
+ }
188
+
189
+ /**
190
+ * Named ceiling (BV-004): a machine realistically presents 2–4 spellings of
191
+ * itself (`os.hostname()`, the mDNS variant, a DHCP-assigned name). 16 bounds
192
+ * the pathological DHCP-churn case while leaving ample headroom. Revisit if a
193
+ * real host is ever observed writing more than a handful of distinct names —
194
+ * beyond that the ledger stops being an identity record and becomes a history.
195
+ */
196
+ const HOST_ALIASES_MAX = 16;
197
+
198
+ /**
199
+ * Synchronous atomic JSON write with an explicit mode. The async twin
200
+ * `_writeJsonAtomic` below cannot serve the alias ledger: every caller of
201
+ * {@link recordHostAlias} sits on a SYNCHRONOUS lock path. `io.mjs`'s
202
+ * `writeJsonAtomicSync` is not reused because it offers no mode argument, and
203
+ * 0o600 is the whole point of a file that lives beside `host-private.json`.
204
+ *
205
+ * @param {string} filePath
206
+ * @param {*} data
207
+ * @param {number} mode
208
+ */
209
+ function _writeJsonAtomicSync(filePath, data, mode) {
210
+ mkdirSync(path.dirname(filePath), { recursive: true });
211
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
212
+ try {
213
+ // `flag: 'wx'` + `mode` on the CREATE, never a chmod after the rename.
214
+ // Two distinct holes closed: `wx` fails with EEXIST rather than following a
215
+ // pre-planted symlink at the (predictable) tmp path, so an attacker cannot
216
+ // redirect the write; and the mode is applied at creation, so the file is
217
+ // never briefly world-readable between `writeFileSync` and `chmodSync` —
218
+ // the window in which the ledger beside `host-private.json` was readable.
219
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode, flag: 'wx' });
220
+ renameSync(tmp, filePath);
221
+ } catch (err) {
222
+ try { unlinkSync(tmp); } catch { /* best effort */ }
223
+ throw err;
224
+ }
225
+ // Belt-and-braces for the pre-existing-target case: `rename` keeps the
226
+ // SOURCE inode, so the mode above already governs — this only repairs a
227
+ // umask-widened mode on platforms that ignore the create mode.
228
+ try { chmodSync(filePath, mode); } catch { /* best effort */ }
229
+ }
230
+
231
+ /**
232
+ * Read the self-alias ledger. Returns normalised names.
233
+ *
234
+ * Fail-CLOSED by construction: any failure (absent file, unreadable, malformed
235
+ * JSON, wrong shape) yields `[]`, which reduces {@link hostnamesMatch} to plain
236
+ * normalised equality. A broken ledger can therefore only ever REFUSE a match
237
+ * that would otherwise have been made — it can never manufacture one.
238
+ *
239
+ * @returns {string[]} normalised alias names; `[]` on any failure.
240
+ */
241
+ export function readHostAliases() {
242
+ try {
243
+ const parsed = JSON.parse(readFileSync(_hostAliasesFile(), 'utf8'));
244
+ if (!Array.isArray(parsed)) return [];
245
+ const seen = new Set();
246
+ for (const entry of parsed) {
247
+ const norm = typeof entry === 'string' ? stableHostname(entry) : '';
248
+ if (norm) seen.add(norm);
249
+ }
250
+ return [...seen];
251
+ } catch {
252
+ return [];
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Record the name this machine currently presents itself under, so a later
258
+ * reading under a DIFFERENT spelling can still be recognised as the same host.
259
+ *
260
+ * Best-effort and idempotent: never throws, never duplicates, and writes
261
+ * nothing when the name is already present or normalises away to ''. Callers
262
+ * invoke it once per session-lock acquisition — the one moment we know for a
263
+ * fact that the current `os.hostname()` belongs to THIS machine.
264
+ *
265
+ * @param {string} [name=stableHostname()] — raw or normalised hostname.
266
+ * @returns {string[]} the ledger contents after the call (`[]` on failure).
267
+ */
268
+ export function recordHostAlias(name = stableHostname()) {
269
+ try {
270
+ const norm = stableHostname(typeof name === 'string' ? name : '');
271
+ if (!norm) return readHostAliases();
272
+ const current = readHostAliases();
273
+ if (current.includes(norm)) return current;
274
+ // Keep the MOST RECENT names when the cap bites — an old spelling this
275
+ // machine has not used in 16 renames is the safest one to forget.
276
+ const next = [...current, norm].slice(-HOST_ALIASES_MAX);
277
+ _writeJsonAtomicSync(_hostAliasesFile(), next, 0o600);
278
+ return next;
279
+ } catch {
280
+ return [];
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Decide whether two hostnames name the SAME machine.
286
+ *
287
+ * Match when the normalised forms are equal, or when BOTH appear in the
288
+ * self-alias set (see {@link recordHostAlias}). Requiring both sides to be in
289
+ * the set is what preserves the cross-host invariants: a foreign host's name
290
+ * was never written by this machine, so it cannot be in the ledger, so it can
291
+ * never match — `cross-host` stays `cross-host` (PSA-003).
292
+ *
293
+ * The ledger is read ONLY when normalised equality already failed, so the
294
+ * common same-host path costs no filesystem access even inside a poll loop.
295
+ *
296
+ * @param {string} a
297
+ * @param {string} b
298
+ * @param {{ aliases?: string[] }} [opts] — inject the alias set (tests, and
299
+ * callers that already hold it); omitted → read the ledger.
300
+ * @returns {boolean}
301
+ */
302
+ export function hostnamesMatch(a, b, { aliases } = {}) {
303
+ const normA = stableHostname(typeof a === 'string' ? a : '');
304
+ const normB = stableHostname(typeof b === 'string' ? b : '');
305
+ if (!normA || !normB) return false;
306
+ if (normA === normB) return true;
307
+
308
+ const set = Array.isArray(aliases)
309
+ ? aliases.map((x) => (typeof x === 'string' ? stableHostname(x) : '')).filter(Boolean)
310
+ : readHostAliases();
311
+ return set.includes(normA) && set.includes(normB);
312
+ }
313
+
314
+ /**
315
+ * Pick the host identity to compare a lock body against: the normalised
316
+ * `host_id` when it carries one, else the raw pre-#1072 `host`.
317
+ *
318
+ * `||`, not `??`, and that is the whole point. `??` falls back on `null` and
319
+ * `undefined` only, so a lock whose `host_id` is the EMPTY STRING — what a
320
+ * writer produces when `os.hostname()` momentarily returns '' or whitespace —
321
+ * short-circuits to `''`, and `hostnamesMatch('', host)` is false by contract
322
+ * ("no identity never matches"). The machine then reads its OWN lock as
323
+ * cross-host: stale detection is disabled, the reaper refuses with
324
+ * `cross-host-requires-operator`, and release returns `not-owner` — the exact
325
+ * #1072 failure the `host_id` field was added to prevent, re-entered through
326
+ * the fallback operator. Falling back on '' costs nothing: an empty `host` too
327
+ * yields '' here, and `hostnamesMatch` still refuses it.
328
+ *
329
+ * The `.trim()` extends the same fix one step: a whitespace-only `host_id` is
330
+ * TRUTHY, so a bare `||` would short-circuit on it exactly as `??` does on `''`
331
+ * (`.claude/rules/development.md` § Error Handling, env-var fallback whitespace
332
+ * trap — the same shape, one domain over).
333
+ *
334
+ * @param {{ host_id?: unknown, host?: unknown }|null|undefined} lock
335
+ * @returns {string} raw (un-normalised, but trimmed) candidate name; '' when
336
+ * neither field carries a usable value. Never throws.
337
+ */
338
+ export function lockHostCandidate(lock) {
339
+ const id = typeof lock?.host_id === 'string' ? lock.host_id.trim() : '';
340
+ if (id) return id;
341
+ return typeof lock?.host === 'string' ? lock.host.trim() : '';
342
+ }
343
+
100
344
  // ---------------------------------------------------------------------------
101
345
  // Paths
102
346
  // ---------------------------------------------------------------------------
@@ -149,6 +393,7 @@ export async function resolveSalt() {
149
393
  * @param {string} [opts.salt] — override salt (tests)
150
394
  */
151
395
  export async function collectFingerprint(opts = {}) {
396
+ const { SO_OS, SO_PLATFORM } = await import('./platform.mjs');
152
397
  const osName = SO_OS;
153
398
  const arch = process.arch;
154
399
  const cpus = os.cpus() || [];
@@ -107,8 +107,38 @@ export const DEFAULT_CEILING = 480;
107
107
  * the live repo sits at 471/480 directives (98.1% of ceiling) but 108589/114000
108
108
  * bytes (95.3%). That asymmetry is inherited from the pre-existing directive
109
109
  * ratchet, not introduced here.
110
+ *
111
+ * RE-BASELINED 2026-08-22: 114000 -> 121000. The ratchet fired for exactly the
112
+ * reason stated above — "a genuinely NEW always-on surface is added". Since the
113
+ * 2026-07-30 calibration this repo's corpus grew 108589 -> 115730 B, and the
114
+ * largest single cause is a rule file the operator adopted:
115
+ * .claude/rules/host-resources.md (7457 B, #1089, live 2026-08-21). Headroom
116
+ * unchanged at +5%: 115730 x 1.05 = 121516, rounded DOWN to 121000. Same
117
+ * relative slack, measured against the corpus that actually exists.
118
+ *
119
+ * Consumer impact was CHECKED, not assumed. This module ships inside the npm
120
+ * package, so the first instinct was to keep the shared default fixed and set a
121
+ * repo-local `instruction-budget.byte-ceiling` override instead. Two
122
+ * measurements killed that plan: (a) the package ships THREE always-on rules
123
+ * totalling 9261 B — both ceilings sit ~12x above any consumer's inherited
124
+ * corpus, so the delta is numerically inert downstream; and (b) the number was
125
+ * never a shipped-corpus figure in the first place — the calibration above says
126
+ * "measured against this repo's own always-on corpus". A repo-local override
127
+ * also could not have worked: tests/rules/receiving-review.test.mjs calls
128
+ * computeInstructionBudget({repoRoot}), which does not read Session Config, so
129
+ * it pins this constant by construction.
130
+ *
131
+ * Not a licence to raise the number whenever it is hit — a ratchet that yields
132
+ * on contact measures nothing, and the NEXT breach belongs to a diet. 4499 B of
133
+ * the always-on security.md still describes surfaces this repo does not have
134
+ * (SSRF via an http-client package, an OWASP table, RLS, bcrypt/JWT), vendored
135
+ * from the baseline and never adapted — the same class as the Semgrep claim
136
+ * fixed today. That cut is real and nameable and is tracked in #1126; it was
137
+ * deliberately NOT taken in the same pass that raised the ceiling, because
138
+ * deleting security prose to hit a number is the failure build-value.md BV-002
139
+ * names.
110
140
  */
111
- export const DEFAULT_BYTE_CEILING = 114000;
141
+ export const DEFAULT_BYTE_CEILING = 121000;
112
142
 
113
143
  /**
114
144
  * Read the `instruction-budget:` nested block from the `## Session Config`