session-orchestrator 3.22.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 (268) 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 +8 -0
  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/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -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`