session-orchestrator 3.20.0 → 3.21.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 (114) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +414 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/docs/migration-v3.md +9 -6
  23. package/docs/persona-panel.md +3 -1
  24. package/docs/scope-collision-guard.md +167 -0
  25. package/hooks/enforce-scope.mjs +103 -3
  26. package/hooks/hooks-codex.json +1 -1
  27. package/hooks/hooks.json +11 -1
  28. package/hooks/on-session-end.mjs +52 -5
  29. package/hooks/on-stop.mjs +127 -12
  30. package/hooks/post-bash-write-verify.mjs +8 -32
  31. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  32. package/package.json +1 -1
  33. package/pi/prompts/release.md +12 -0
  34. package/scripts/autopilot.mjs +3 -1
  35. package/scripts/emit-session.mjs +42 -0
  36. package/scripts/export-hw-learnings.mjs +61 -2
  37. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  38. package/scripts/lib/backlog-scan.mjs +106 -15
  39. package/scripts/lib/build-live-signals.mjs +7 -3
  40. package/scripts/lib/ci-status-banner.mjs +207 -23
  41. package/scripts/lib/git-config-drift.mjs +471 -0
  42. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  43. package/scripts/lib/io.mjs +193 -7
  44. package/scripts/lib/learnings/select.mjs +157 -3
  45. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  46. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  47. package/scripts/lib/named-vault-resolver.mjs +105 -16
  48. package/scripts/lib/peer-cards/schema.mjs +6 -2
  49. package/scripts/lib/reconcile/writer.mjs +69 -30
  50. package/scripts/lib/redact-spans.mjs +89 -0
  51. package/scripts/lib/scope-baseline.mjs +77 -17
  52. package/scripts/lib/scope-gate.mjs +658 -0
  53. package/scripts/lib/secret-masker.mjs +262 -0
  54. package/scripts/lib/session-lock.mjs +34 -10
  55. package/scripts/lib/session-registry.mjs +9 -1
  56. package/scripts/lib/state-md/mission-status.mjs +21 -12
  57. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  58. package/scripts/lib/validate/check-agents.mjs +77 -5
  59. package/scripts/lib/validate/check-commands.mjs +2 -20
  60. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  61. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  62. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  63. package/scripts/lib/validate/check-skills.mjs +191 -0
  64. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  65. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  66. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  67. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  68. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  69. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  70. package/scripts/lib/validate/tier-inference.mjs +46 -8
  71. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  72. package/scripts/lib/vault-mirror/process.mjs +264 -31
  73. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  74. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  75. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  76. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  77. package/scripts/print-learnings-index.mjs +30 -3
  78. package/scripts/release.mjs +616 -61
  79. package/scripts/site-numbers.mjs +1049 -0
  80. package/scripts/validate-plugin.mjs +61 -0
  81. package/scripts/validate-wave-scope.mjs +281 -12
  82. package/scripts/vault-mirror.mjs +26 -1
  83. package/skills/_shared/monitor-patterns.md +24 -4
  84. package/skills/_shared/state-ownership.md +17 -0
  85. package/skills/claude-md-drift-check/SKILL.md +9 -1
  86. package/skills/debug/SKILL.md +4 -1
  87. package/skills/discovery/issue-templates.md +4 -4
  88. package/skills/discovery/probes-code.md +2 -2
  89. package/skills/discovery/probes-feature.md +6 -6
  90. package/skills/discovery/probes-infra.md +2 -2
  91. package/skills/discovery/probes-session.md +5 -5
  92. package/skills/dispatcher/SKILL.md +10 -1
  93. package/skills/frontmatter-guard/SKILL.md +9 -1
  94. package/skills/gitlab-ops/SKILL.md +49 -39
  95. package/skills/gitlab-portfolio/SKILL.md +10 -1
  96. package/skills/memory-cleanup/SKILL.md +18 -5
  97. package/skills/npm-publish/SKILL.md +22 -50
  98. package/skills/persona-panel/SKILL.md +3 -1
  99. package/skills/plan/mode-new.md +23 -5
  100. package/skills/repo-audit/SKILL.md +10 -1
  101. package/skills/session-end/SKILL.md +44 -2
  102. package/skills/session-end/metrics-collection.md +1 -1
  103. package/skills/session-end/session-metrics-write.md +4 -10
  104. package/skills/session-plan/SKILL.md +2 -2
  105. package/skills/session-plan/wave-template.md +1 -1
  106. package/skills/session-start/SKILL.md +13 -1
  107. package/skills/spinout/SKILL.md +5 -1
  108. package/skills/sunset-review/SKILL.md +11 -1
  109. package/skills/tmux-layout/SKILL.md +7 -2
  110. package/skills/vault-mirror/SKILL.md +10 -1
  111. package/skills/vault-sync/SKILL.md +10 -1
  112. package/skills/vault-sync/validator.mjs +55 -6
  113. package/skills/wave-executor/wave-loop.md +42 -12
  114. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -20,6 +20,12 @@
20
20
  * G7 relative path matches an allowedPaths pattern
21
21
  * G8 (all passed) → allow
22
22
  *
23
+ * Empty-allowedPaths reasoning (#1057): the VERDICT for an empty allowlist is
24
+ * unchanged (deny-all, the #256 contract), but the deny REASON is now classified
25
+ * — Discovery's read-only contract, an unreadable manifest, a crashed session's
26
+ * leftover, an incomplete `--union`, or undecidable. See
27
+ * `scripts/lib/scope-gate.mjs` § Empty-`allowedPaths` classification.
28
+ *
23
29
  * Exit codes: 0 = allow 2 = deny
24
30
  *
25
31
  * SECURITY notes (inline refs):
@@ -88,6 +94,10 @@ let findScopeFile;
88
94
  let pathMatchesPattern;
89
95
  let suggestForScopeViolation;
90
96
  let readJson;
97
+ // #1057 — empty-`allowedPaths` classification + the session clock it needs.
98
+ let classifyEmptyScope;
99
+ let suggestForEmptyScope;
100
+ let sessionStartedAtMs;
91
101
 
92
102
  const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
93
103
 
@@ -137,6 +147,13 @@ async function bootstrap() {
137
147
  platform: { specifier: lib('platform.mjs') },
138
148
  hardening: { specifier: lib('hardening.mjs') },
139
149
  common: { specifier: lib('common.mjs') },
150
+ // #1057. Bound DIRECTLY rather than through the `hardening.mjs` barrel:
151
+ // that barrel re-exports an explicit, frozen symbol list shared by six
152
+ // hooks, and widening it for one hook's need would drag three symbols
153
+ // into five unrelated import surfaces. `hardening` already imports
154
+ // `scope-gate` transitively, so this adds no new failure mode — a broken
155
+ // scope-gate already banners GUARD INACTIVE through that edge.
156
+ scopeGate: { specifier: lib('scope-gate.mjs') },
140
157
  },
141
158
  {
142
159
  hookName: HOOK_NAME,
@@ -151,6 +168,7 @@ async function bootstrap() {
151
168
  ({ resolveProjectDir } = modules.platform);
152
169
  ({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
153
170
  ({ readJson } = modules.common);
171
+ ({ classifyEmptyScope, suggestForEmptyScope, sessionStartedAtMs } = modules.scopeGate);
154
172
  }
155
173
 
156
174
  async function main() {
@@ -183,15 +201,31 @@ async function main() {
183
201
  if (!scopePath) return emitAllow();
184
202
 
185
203
  // SECURITY-REQ-08: read scope file once; pass parsed object to all subsequent checks
204
+ //
205
+ // #1057: `parseOk` records WHETHER that read produced a usable scope RECORD.
206
+ // Both fold-to-`{}` / fold-to-`[]` paths (corrupt JSON #794 GAP-5, malformed
207
+ // shapes #558) already deny and CONTINUE to deny — the flag exists so the deny
208
+ // REASON can say "the manifest is broken" instead of "update the session plan
209
+ // and restart the wave", which is the one instruction that cannot help here.
186
210
  let scope;
211
+ let parseOk = true;
187
212
  try {
188
213
  scope = await readJson(scopePath);
214
+ if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) {
215
+ parseOk = false;
216
+ scope = {};
217
+ }
189
218
  } catch {
219
+ parseOk = false;
190
220
  scope = {};
191
221
  }
192
222
 
193
223
  const enforcement = scope.enforcement ?? 'strict';
194
224
  const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
225
+ // A PRESENT-but-non-array `allowedPaths` (#558: null / string / object) is a
226
+ // MALFORMED record, not an empty grant — same class as unparseable JSON, so it
227
+ // earns the same reason. Absent is different and stays `parseOk`.
228
+ if (scope.allowedPaths !== undefined && !Array.isArray(scope.allowedPaths)) parseOk = false;
195
229
  const gatesEnabled = scope.gates?.['path-guard'] !== false;
196
230
 
197
231
  // Gate 4: path-guard gate explicitly disabled
@@ -200,6 +234,48 @@ async function main() {
200
234
  // Gate 5: enforcement is turned off
201
235
  if (enforcement === 'off') return emitAllow();
202
236
 
237
+ // -------------------------------------------------------------------------
238
+ // #1057 — WHY is the allowlist empty?
239
+ //
240
+ // FIVE repo states produce `allowedPaths.length === 0` and the DENY IS RIGHT
241
+ // IN ALL FIVE; only the instruction differs (Discovery's read-only contract,
242
+ // a corrupt manifest, a crashed session's leftover, an incomplete `--union`,
243
+ // or genuinely undecidable). This block is therefore VERDICT-NEUTRAL: it
244
+ // selects a sentence, never a decision — `suggest()` below is the only
245
+ // consumer, and its `'unknown'` branch is byte-identical to the pre-#1057 text.
246
+ //
247
+ // Computed AFTER the early-exit gates (a disabled or `off` wave pays no
248
+ // fs.stat) and BEFORE the first deny site, so all three deny sites share one
249
+ // explanation instead of drifting apart.
250
+ // -------------------------------------------------------------------------
251
+ const emptyScopeReason =
252
+ allowedPaths.length === 0
253
+ ? classifyEmptyScope({
254
+ role: scope.role,
255
+ parseOk,
256
+ scopeMtimeMs: await mtimeMsOf(scopePath),
257
+ sessionStartMs: sessionStartedAtMs(projectRoot),
258
+ })
259
+ : null;
260
+ const scopeRelRaw = relativeFromRoot(projectRoot, scopePath);
261
+ const scopeHint = (scopeRelRaw ?? scopePath).split(path.sep).join('/');
262
+
263
+ /**
264
+ * The suggestion half of every deny below.
265
+ *
266
+ * With a NON-empty allowlist this is byte-identical to the pre-#1057 call.
267
+ * With an empty one it routes through the classifier — whose `'unknown'`
268
+ * branch delegates back to `suggestForScopeViolation(target, '')`, i.e. the
269
+ * same sentence as before. Strictly an addition.
270
+ *
271
+ * @param {string} target
272
+ * @returns {string}
273
+ */
274
+ const suggest = (target) =>
275
+ emptyScopeReason === null
276
+ ? suggestForScopeViolation(target, allowedPaths.join(', '))
277
+ : suggestForEmptyScope(target, emptyScopeReason, { role: scope.role, scopePath: scopeHint });
278
+
203
279
  // SECURITY-REQ-06: resolve relative file_path against projectRoot, not process.cwd()
204
280
  const absPathInput = path.isAbsolute(filePath)
205
281
  ? filePath
@@ -264,7 +340,7 @@ async function main() {
264
340
  // Gate 6: path must be inside the project root
265
341
  if (!isPathInside(resolvedPath, projectRoot)) {
266
342
  const reason = `Scope violation: path outside project root`;
267
- const suggestion = suggestForScopeViolation(filePath, allowedPaths.join(', '));
343
+ const suggestion = suggest(filePath);
268
344
  return enforcement === 'strict'
269
345
  ? emitDeny(reason, suggestion)
270
346
  : emitWarn(`${reason} — ${suggestion}`);
@@ -276,7 +352,7 @@ async function main() {
276
352
  // SECURITY-REQ-04: null return means outside root — deny rather than pass null to pathMatchesPattern
277
353
  if (relPath === null) {
278
354
  const reason = `Scope violation: '${filePath}' outside project root`;
279
- const suggestion = suggestForScopeViolation(filePath, allowedPaths.join(', '));
355
+ const suggestion = suggest(filePath);
280
356
  return enforcement === 'strict'
281
357
  ? emitDeny(reason, suggestion)
282
358
  : emitWarn(`${reason} — ${suggestion}`);
@@ -297,7 +373,7 @@ async function main() {
297
373
 
298
374
  if (!matched) {
299
375
  const reason = `Scope violation: '${normalizedRel}' not in allowed paths [${allowedPaths.join(', ')}]`;
300
- const suggestion = suggestForScopeViolation(normalizedRel, allowedPaths.join(', '));
376
+ const suggestion = suggest(normalizedRel);
301
377
  return enforcement === 'strict'
302
378
  ? emitDeny(reason, suggestion)
303
379
  : emitWarn(`${reason} — ${suggestion}`);
@@ -307,6 +383,30 @@ async function main() {
307
383
  return emitAllow();
308
384
  }
309
385
 
386
+ /**
387
+ * `mtimeMs` of a file, or `null` when it cannot be stat'ed.
388
+ *
389
+ * The PROVENANCE half of the #1057 staleness comparison. It is fed into a
390
+ * SUBTRACTION against this session's start time — deliberately NOT compared to a
391
+ * TTL: an absolute age cap blinds the check in exactly the regime it exists for
392
+ * (a legitimate long deep session ages into the blind spot with nothing having
393
+ * gone wrong). Same argument `hooks/post-bash-write-verify.mjs` makes for
394
+ * `sessionAgeMs` under "Why the minimum, and why NOT a staleness cap".
395
+ *
396
+ * Never throws — an unstat-able manifest simply yields an undecidable clock, and
397
+ * {@link classifyEmptyScope} degrades to `'unknown'`, never to an allow.
398
+ *
399
+ * @param {string} file
400
+ * @returns {Promise<number|null>}
401
+ */
402
+ async function mtimeMsOf(file) {
403
+ try {
404
+ return (await fs.stat(file)).mtimeMs;
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
310
410
  const COORDINATOR_CARVEOUT_PATHS = Object.freeze([
311
411
  '.claude/STATE.md',
312
412
  '.codex/STATE.md',
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v3.20.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v3.21.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v3.20.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v3.21.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -95,6 +95,16 @@
95
95
  "timeout": 5
96
96
  }
97
97
  ]
98
+ },
99
+ {
100
+ "matcher": "Agent",
101
+ "hooks": [
102
+ {
103
+ "type": "command",
104
+ "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-task-scope-disjoint.mjs\"",
105
+ "timeout": 5
106
+ }
107
+ ]
98
108
  }
99
109
  ],
100
110
  "PostToolUse": [
@@ -39,6 +39,7 @@ import {
39
39
  isLockOwnedByProof,
40
40
  OWNER_PROOF_RELPATH,
41
41
  } from '../scripts/lib/session-lock.mjs';
42
+ import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
42
43
  import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
43
44
 
44
45
  // ---------------------------------------------------------------------------
@@ -275,16 +276,17 @@ async function main() {
275
276
  if (releaseEligible) {
276
277
  // Defense-in-depth (#987): when a persisted proof exists, hand it to
277
278
  // release() so the delete is double-gated (session_id match AND
278
- // proof match) at the fs layer too. TRAP release()'s proof gate
279
- // triggers on `proof !== undefined`, so a null proof MUST be
280
- // spread-guarded out: passing `proof: null` would fail
281
- // isLockOwnedByProof() unconditionally and refuse EVERY release.
279
+ // proof match) at the fs layer too. `proof` is `null` whenever
280
+ // loadOwnerProof() could not prove ownership (pre-#987 sessions,
281
+ // failed proof write) — release() gates on `proof != null` (#989) and
282
+ // degrades to the session_id-only path for that case, so passing it
283
+ // through unguarded is correct.
282
284
  // A 'proof-mismatch' result flows into the existing release_failed
283
285
  // breadcrumb below (releaseResult.reason surfaces verbatim).
284
286
  const releaseResult = release({
285
287
  sessionId: lock.session_id,
286
288
  repoRoot: projectRoot,
287
- ...(proof ? { proof } : {}),
289
+ proof,
288
290
  });
289
291
  // release() has a no-throw contract (always returns a structured
290
292
  // result). A matched ownership that still fails to delete — an
@@ -369,6 +371,51 @@ async function main() {
369
371
  // 'absent' — no lock file at all; nothing to release or reconcile
370
372
  // (mirrors the pre-existing `if (lock)` guard's false branch).
371
373
  } catch { /* best-effort — never block teardown */ }
374
+
375
+ // (c) #1047 — host-registry deregistration, the symmetric partner of
376
+ // on-session-start.mjs's registerSelf(). This used to live in
377
+ // hooks/on-stop.mjs, which fires at TURN end, so every assistant turn
378
+ // deleted the entry of a still-live session; on-stop.mjs now refreshes
379
+ // the entry (heartbeat) and teardown happens here, at the real end.
380
+ //
381
+ // Keyed by `sessionId` ONLY, never by `semanticSessionId`: the registry
382
+ // file is named after the id registerSelf() was called with, and in the
383
+ // `generated-uuid-fallback-collision` path (on-session-start.mjs) the
384
+ // semantic candidate names ANOTHER session's entry — deregistering by it
385
+ // would delete a foreign live session. Ownership is therefore structural
386
+ // here, not a check.
387
+ //
388
+ // CONSEQUENCE, accepted deliberately — and the affected platform is
389
+ // CODEX ALONE. Measured 2026-08-17 across the three bridge manifests:
390
+ // hooks.json SessionStart + SessionEnd -> registers, deregisters
391
+ // hooks-pi.json session_start + session_shutdown -> registers, deregisters
392
+ // (session_shutdown maps to THIS file)
393
+ // hooks-codex.json SessionStart + Stop, no SessionEnd
394
+ // -> registers, never deregisters <- the gap
395
+ // hooks-cursor.json afterFileEdit + beforeShellExecution only
396
+ // -> never registers, so nothing to leak
397
+ // On Codex an entry therefore persists until sweepZombies() removes it at
398
+ // the next SessionStart — up to the sweep threshold (`thresholdMin`,
399
+ // default 60 min) after the session ended. That is the same path crash
400
+ // and Ctrl-C already rely on for EVERY platform; no platform-detecting
401
+ // second teardown branch exists by design.
402
+ //
403
+ // Note where this is written: THIS file does not run on Codex, so the
404
+ // consequence is also pointed at from hooks/on-stop.mjs, which does.
405
+ //
406
+ // The `sessionId` guard is not decoration: deregisterSelf() throws
407
+ // TypeError on a null/empty id, and "no id resolvable" is a normal
408
+ // degraded state (no stdin id, no current-session.json), not a failure
409
+ // worth a sweep.log breadcrumb.
410
+ if (sessionId) {
411
+ try {
412
+ await deregisterSelf(sessionId);
413
+ } catch (err) {
414
+ // Deregistration failed — observability breadcrumb to sweep.log, never a
415
+ // throw and never stderr: the hook must not block teardown.
416
+ logSweepEvent({ event: 'deregister-failed', session_id: sessionId, error: err?.message ?? String(err) });
417
+ }
418
+ }
372
419
  }
373
420
 
374
421
  // Exit 0 always — informational hook must never block session teardown.
package/hooks/on-stop.mjs CHANGED
@@ -8,7 +8,10 @@
8
8
  *
9
9
  * Part of v3.0.0 Windows-native migration. Issue #141.
10
10
  *
11
- * Exit codes: 0 always (informational hooks must never block).
11
+ * Exit codes: 0 always (informational hooks must never block) — including when
12
+ * node_modules is absent: zx is imported lazily and a missing package degrades
13
+ * to one rate-limited stderr line instead of an ERR_MODULE_NOT_FOUND stack on
14
+ * every turn end (GH Kanevry/session-orchestrator#63).
12
15
  *
13
16
  * JSONL format (`.orchestrator/metrics/events.jsonl`) — emitted via the canonical
14
17
  * `emitEvent()` so the JSONL record and the optional Clank webhook always carry the
@@ -18,8 +21,7 @@
18
21
  */
19
22
 
20
23
  import path from 'node:path';
21
- import { promises as fs } from 'node:fs';
22
- import { $ } from 'zx';
24
+ import { promises as fs, statSync, writeFileSync } from 'node:fs';
23
25
 
24
26
  import { shouldRunHook } from './_lib/profile-gate.mjs';
25
27
  // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
@@ -27,7 +29,7 @@ if (!shouldRunHook('on-stop')) process.exit(0);
27
29
 
28
30
  import { emitEvent } from '../scripts/lib/events.mjs';
29
31
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
30
- import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
32
+ import { heartbeat, logSweepEvent } from '../scripts/lib/session-registry.mjs';
31
33
  import { updateHeartbeat } from '../scripts/lib/session-lock.mjs';
32
34
 
33
35
  // ---------------------------------------------------------------------------
@@ -87,17 +89,87 @@ function discriminate(input) {
87
89
  return 'stop';
88
90
  }
89
91
 
92
+ // ---------------------------------------------------------------------------
93
+ // dependency degradation (GH Kanevry/session-orchestrator#63)
94
+ // ---------------------------------------------------------------------------
95
+ //
96
+ // zx is loaded LAZILY. A static `import { $ } from 'zx'` fails at MODULE LOAD
97
+ // time when node_modules is absent (interrupted install, EPERM sandbox, half-
98
+ // synced plugin cache), so the harness prints a 10-frame ERR_MODULE_NOT_FOUND
99
+ // stack on EVERY turn end with no hint that `npm install` is the fix. This
100
+ // mirrors the missing-`node` degradation in hooks/run-node.sh (§5): one
101
+ // actionable stderr line per 6h window, then carry on with reduced features.
102
+
103
+ /** Rate-limit window for the dependencies-missing warning — mirrors run-node.sh's 6h TTL. */
104
+ const DEP_WARN_TTL_MS = 6 * 60 * 60 * 1000;
105
+
106
+ /** Plugin root (the directory that owns package.json / node_modules). */
107
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
108
+
109
+ /**
110
+ * Marker path for the dependencies-missing warning. Deliberately a DIFFERENT
111
+ * name from run-node.sh's `session-orchestrator-node-missing-*`: sharing one
112
+ * marker would let a missing-node warning mask a missing-deps warning (and
113
+ * vice versa), leaving the operator with half a diagnostic.
114
+ * @returns {string}
115
+ */
116
+ function depWarnMarkerPath() {
117
+ // `||` alone falls back only on falsy values — a whitespace-only TMPDIR is
118
+ // truthy and would yield a garbage path (see .claude/rules/development.md).
119
+ const tmpDir = (process.env.TMPDIR || '').trim() || '/tmp';
120
+ const user = (process.env.USER || '').trim() || 'uid';
121
+ return path.join(tmpDir, `session-orchestrator-deps-missing-${user}`);
122
+ }
123
+
124
+ /**
125
+ * Print ONE actionable stderr line telling the operator to run `npm install`,
126
+ * at most once per DEP_WARN_TTL_MS. Marker mtime is the clock, exactly like
127
+ * run-node.sh's `find -mmin +360` check. Best-effort throughout: a marker we
128
+ * cannot stat is treated as expired (warn), a marker we cannot write means the
129
+ * next invocation warns again — noisier, never silent-broken.
130
+ */
131
+ function warnDependenciesMissingOnce() {
132
+ const marker = depWarnMarkerPath();
133
+ try {
134
+ if (Date.now() - statSync(marker).mtimeMs < DEP_WARN_TTL_MS) return;
135
+ } catch { /* missing / unreadable marker → treat as expired */ }
136
+ try { writeFileSync(marker, ''); } catch { /* best-effort */ }
137
+ process.stderr.write(
138
+ `session-orchestrator: dependencies missing — run 'npm install' in ${PLUGIN_ROOT}. `
139
+ + 'Hook features degraded (this warning is rate-limited to once per 6h).\n',
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Load zx's `$` lazily. Returns null when the package is not installed (after
145
+ * emitting the rate-limited advisory). Any OTHER import failure is re-thrown —
146
+ * a corrupt zx install is not a missing-dependency problem and must not be
147
+ * mislabelled as one.
148
+ * @returns {Promise<Function|null>}
149
+ */
150
+ async function loadZx() {
151
+ try {
152
+ return (await import('zx')).$;
153
+ } catch (err) {
154
+ if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err;
155
+ warnDependenciesMissingOnce();
156
+ return null;
157
+ }
158
+ }
159
+
90
160
  // ---------------------------------------------------------------------------
91
161
  // git helpers
92
162
  // ---------------------------------------------------------------------------
93
163
 
94
164
  /**
95
165
  * Returns { commit, branch } from the git repo at projectRoot, or null values
96
- * if git is unavailable or the directory is not a git repo.
166
+ * if git is unavailable, zx is not installed, or the directory is not a git repo.
97
167
  * @param {string} projectRoot — working directory for git commands
98
168
  * @returns {Promise<{commit:string|null, branch:string|null}>}
99
169
  */
100
170
  async function gitInfo(projectRoot) {
171
+ const $ = await loadZx();
172
+ if ($ === null) return { commit: null, branch: null };
101
173
  $.verbose = false;
102
174
  $.quiet = true;
103
175
  const opts = projectRoot ? { cwd: projectRoot } : {};
@@ -180,16 +252,59 @@ async function handleStop(input) {
180
252
 
181
253
  const sessionId = await resolveSessionId(input, projectRoot);
182
254
 
183
- // v3.1.0 multi-session registry (#169) — best-effort deregister. Missing
184
- // entry is fine (zombie sweep handles crashed sessions). Failures are logged
185
- // to sweep.log for observability but never re-thrown (hook must remain silent).
255
+ // v3.1.0 multi-session registry (#169), corrected in #1047 REFRESH the
256
+ // registry entry here; never remove it.
257
+ //
258
+ // Stop fires at TURN end, not session end (see the file docblock). The
259
+ // original #169 wiring called deregisterSelf() here, so every assistant turn
260
+ // deleted this session's registry entry while the session was still live:
261
+ // measured on this host as 1 surviving entry (dead PID) against 12 live
262
+ // sockets, with sweep.log recording deletions of sessions aged 72/335/351/369
263
+ // minutes. Epic #583 fixed exactly this class for `.orchestrator/session.lock`
264
+ // (release → updateHeartbeat, below); the host registry never got the same
265
+ // correction. Deregistration now lives in hooks/on-session-end.mjs, which
266
+ // fires at the real end of the session.
267
+ //
268
+ // CODEX CAVEAT — stated here because THIS file runs on Codex and the file
269
+ // that owns deregistration does not. `hooks-codex.json` wires SessionStart +
270
+ // Stop but no SessionEnd (the Codex contract rejects the event), so on that
271
+ // bridge a session registers and never deregisters: its entry persists until
272
+ // sweepZombies() reaps it at the next SessionStart, up to `thresholdMin`
273
+ // (default 60 min). Accepted deliberately — it is the same path crash and
274
+ // Ctrl-C already take on every platform, and it is safe precisely BECAUSE
275
+ // the heartbeat below now advances, so a live session never ages into the
276
+ // sweep. Do NOT add a platform-detecting deregister branch here; that is the
277
+ // two-teardown-paths shape Epic #583 removed from the lock.
278
+ // (pi is unaffected: hooks-pi.json maps session_shutdown to on-session-end.mjs.
279
+ // Cursor is unaffected: it wires no SessionStart, so it never registers.)
280
+ //
281
+ // Failures are logged to sweep.log for observability but never re-thrown
282
+ // (hook must remain silent and non-blocking).
186
283
  if (sessionId) {
187
284
  try {
188
- await deregisterSelf(sessionId);
285
+ // heartbeat() returns null when no entry exists — a SILENT no-op that
286
+ // would otherwise make the loss permanent for the rest of the session
287
+ // (e.g. after a zombie sweep, or a harness UUID rotation with no fresh
288
+ // SessionStart). We do NOT re-register here: this hook has no access to
289
+ // the entry's platform / mode / host_class, and a re-registration would
290
+ // reset started_at to now — fabricating a session age instead of
291
+ // reporting one. Emit an observability breadcrumb instead, so the miss
292
+ // is visible in sweep.log rather than invisible. One line per turn while
293
+ // the entry is absent; that volume IS the signal, and the next
294
+ // SessionStart's registerSelf() ends it.
295
+ const refreshed = await heartbeat(sessionId);
296
+ if (refreshed === null) {
297
+ logSweepEvent({
298
+ event: 'heartbeat-missing',
299
+ session_id: sessionId,
300
+ error: 'no registry entry to refresh at turn end',
301
+ });
302
+ }
189
303
  } catch (err) {
190
- // Deregistration failed — emit an observability breadcrumb to sweep.log.
191
- // Do NOT throw, do NOT write to stderr: the hook is informational-only.
192
- logSweepEvent({ event: 'deregister-failed', session_id: sessionId, error: err?.message ?? String(err) });
304
+ // Refresh failed at the fs layer — emit an observability breadcrumb to
305
+ // sweep.log. Do NOT throw, do NOT write to stderr: the hook is
306
+ // informational-only.
307
+ logSweepEvent({ event: 'heartbeat-failed', session_id: sessionId, error: err?.message ?? String(err) });
193
308
  }
194
309
 
195
310
  // Epic #583 W5-F1c — refresh session.lock heartbeat on every turn-end.
@@ -154,6 +154,13 @@ import { readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statS
154
154
  import { readStdin, writeStdoutLineSync } from '../scripts/lib/io.mjs';
155
155
  import { resolveProjectDir } from '../scripts/lib/platform.mjs';
156
156
  import { findScopeFile, pathMatchesPattern } from '../scripts/lib/hardening.mjs';
157
+ // #1057 — `sessionAgeMs` and its private `clockAgeMs` helper MOVED to the lib so
158
+ // hooks/enforce-scope.mjs can read the same session clock without a hook->hook
159
+ // import. Re-exported below, so this hook's public surface — and
160
+ // tests/hooks/post-bash-write-verify.test.mjs, which imports the named export —
161
+ // is unchanged. Two byte-identical copies of a clock is exactly the one-fact-two-
162
+ // copies class this repo keeps paying for.
163
+ import { sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
157
164
 
158
165
  // ---------------------------------------------------------------------------
159
166
  // Constants
@@ -538,30 +545,6 @@ function readDirtyPaths(repoRoot) {
538
545
  }
539
546
  }
540
547
 
541
- /**
542
- * Age in ms of one session clock: a JSON file carrying an ISO start timestamp.
543
- *
544
- * Never throws. Absent / unparseable / non-string / non-ISO ⇒ null. A NEGATIVE
545
- * age (timestamp in the future) is also null rather than a negative number —
546
- * see `sessionAgeMs` for why that matters once two clocks are combined.
547
- *
548
- * @param {string} file absolute path to the JSON file
549
- * @param {string} field name of the ISO-timestamp property
550
- * @param {number} now
551
- * @returns {number|null}
552
- */
553
- function clockAgeMs(file, field, now) {
554
- try {
555
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
556
- const startedAt = Date.parse(parsed?.[field]);
557
- if (!Number.isFinite(startedAt)) return null;
558
- const age = now - startedAt;
559
- return age >= 0 ? age : null;
560
- } catch {
561
- return null;
562
- }
563
- }
564
-
565
548
  /**
566
549
  * Milliseconds since the current session started, or null when unknown — the
567
550
  * MINIMUM of two independently-written session clocks (#957 finding 2):
@@ -634,14 +617,7 @@ function clockAgeMs(file, field, now) {
634
617
  * @param {number} [now]
635
618
  * @returns {number|null}
636
619
  */
637
- export function sessionAgeMs(repoRoot, now = Date.now()) {
638
- const dir = path.join(repoRoot, '.orchestrator');
639
- const ages = [
640
- clockAgeMs(path.join(dir, 'current-session.json'), 'timestamp', now),
641
- clockAgeMs(path.join(dir, 'session.lock'), 'started_at', now),
642
- ].filter((age) => age !== null);
643
- return ages.length > 0 ? Math.min(...ages) : null;
644
- }
620
+ export { sessionAgeMs };
645
621
 
646
622
  /** @returns {object|null} */
647
623
  function readSnapshot(file) {