session-orchestrator 5.3.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v5.5.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -53,6 +53,11 @@
|
|
|
53
53
|
"type": "command",
|
|
54
54
|
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/config-protection.mjs\"",
|
|
55
55
|
"timeout": 5
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"type": "command",
|
|
59
|
+
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-edit-sessions-ledger-guard.mjs\"",
|
|
60
|
+
"timeout": 5
|
|
56
61
|
}
|
|
57
62
|
]
|
|
58
63
|
},
|
|
@@ -38,6 +38,7 @@ import path from 'node:path';
|
|
|
38
38
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
39
39
|
|
|
40
40
|
import { emitEvent, eventsFilePath } from '../scripts/lib/events.mjs';
|
|
41
|
+
import { readNavigatorLease } from '../scripts/lib/fleet-protocol.mjs';
|
|
41
42
|
import { maybeRotate } from '../scripts/lib/events-rotation.mjs';
|
|
42
43
|
import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
|
|
43
44
|
import { getPlatform, resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
@@ -98,6 +99,38 @@ const bannerLines = [];
|
|
|
98
99
|
*/
|
|
99
100
|
let pendingAdditionalContext = null;
|
|
100
101
|
|
|
102
|
+
/** Byte budget of the navigator line (#1462, PRD 4.5 row "Bannerzeile B6"). */
|
|
103
|
+
const NAVIGATOR_BANNER_MAX_BYTES = 120;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Render the one-line navigator status (#1462, fleet protocol v1). The verdict
|
|
107
|
+
* comes from `readNavigatorLease()`: only a VALID lease is "aktiv"; a missing or
|
|
108
|
+
* expired one is "nicht nachweisbar" (ADR-004: absence is no proof of a free
|
|
109
|
+
* seat), an unreadable one says so instead of guessing. A long id is cut from
|
|
110
|
+
* the label — never from the fixed text — so the whole line stays ≤ 120 bytes.
|
|
111
|
+
* Same wording on every harness (Codex runs this hook too).
|
|
112
|
+
*/
|
|
113
|
+
export function navigatorBannerLine(verdict) {
|
|
114
|
+
if (verdict?.state === 'active') {
|
|
115
|
+
const lease = verdict.lease ?? {};
|
|
116
|
+
const prefix = '🧭 Navigator aktiv: ';
|
|
117
|
+
const suffix = ', Check-in nach ~/.config/navigator/checkin/';
|
|
118
|
+
const room = NAVIGATOR_BANNER_MAX_BYTES - Buffer.byteLength(prefix + suffix, 'utf8');
|
|
119
|
+
// Defence in depth: the lease reader already refuses control bytes in these
|
|
120
|
+
// two fields; a newline / ANSI / bidi byte must still never reach systemMessage.
|
|
121
|
+
let shown = String(lease.adresse ?? lease.session_id ?? 'unknown')
|
|
122
|
+
.replace(/[\p{Cc}\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu, '?');
|
|
123
|
+
if (Buffer.byteLength(shown, 'utf8') > room) {
|
|
124
|
+
const chars = [...shown];
|
|
125
|
+
while (chars.length > 0 && Buffer.byteLength(`${chars.join('')}…`, 'utf8') > room) chars.pop();
|
|
126
|
+
shown = `${chars.join('')}…`;
|
|
127
|
+
}
|
|
128
|
+
return `${prefix}${shown}${suffix}`;
|
|
129
|
+
}
|
|
130
|
+
if (verdict?.state === 'unreadable') return '🧭 Navigator: Lease unlesbar, Status nicht nachweisbar';
|
|
131
|
+
return '🧭 Navigator: keiner nachweisbar (keine gültige Lease)';
|
|
132
|
+
}
|
|
133
|
+
|
|
101
134
|
/**
|
|
102
135
|
* Queue one or more banner lines for the single end-of-hook flush.
|
|
103
136
|
* @param {string|null|undefined} line — multi-line strings are pushed verbatim
|
|
@@ -762,6 +795,13 @@ async function main() {
|
|
|
762
795
|
// Resolve current branch; fall back to "unknown" when detached HEAD or no git.
|
|
763
796
|
const branch = (await gitOutput(['branch', '--show-current'], projectRoot)) ?? 'unknown';
|
|
764
797
|
|
|
798
|
+
// #1443 — the commit this session started on, the fallback source for
|
|
799
|
+
// `session_start_ref` (STATE.md's `session-start-ref` had no writer). Kept
|
|
800
|
+
// only when it is a full object name; no repo, an unborn HEAD or a missing
|
|
801
|
+
// git leaves it null and the payload key is OMITTED, never fabricated.
|
|
802
|
+
const rawHeadSha = await gitOutput(['rev-parse', 'HEAD'], projectRoot);
|
|
803
|
+
const headSha = rawHeadSha && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(rawHeadSha) ? rawHeadSha : null;
|
|
804
|
+
|
|
765
805
|
// v3.1.0 env-aware banner (opt-out via enable-host-banner: false in Session Config).
|
|
766
806
|
// The ask-via-tool nudge rides the same opt-out flag — both are coordinator
|
|
767
807
|
// reminders shown at session start; users who silence one expect silence.
|
|
@@ -987,6 +1027,18 @@ async function main() {
|
|
|
987
1027
|
pushBanner(' (registry view: repo/branch/wave. Liveness (busy/idle) is model-side — not available in this hook.)');
|
|
988
1028
|
}
|
|
989
1029
|
|
|
1030
|
+
// #1462 — Navigator line (fleet protocol v1). Exactly one line, read from the
|
|
1031
|
+
// lease file only (`~/.config/navigator/leases/navigator.json`, override
|
|
1032
|
+
// NAVIGATOR_CONFIG_DIR); registry `role` and ListAgents are hints, never proof.
|
|
1033
|
+
// Gated like the peer lines above: no host banner → no navigator line. A read
|
|
1034
|
+
// failure must never block the hook — the helper is fail-closed itself, the
|
|
1035
|
+
// try/catch only guards against the unexpected.
|
|
1036
|
+
if (bannerData) {
|
|
1037
|
+
try {
|
|
1038
|
+
pushBanner(navigatorBannerLine(await readNavigatorLease()));
|
|
1039
|
+
} catch { /* swallow — hook must remain non-blocking */ }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
990
1042
|
// Epic #583 W3-P3 — Mechanical peer-detection banner (independent of the
|
|
991
1043
|
// v3.1.0 registry-driven detectPeers() output). discoverActiveSessions()
|
|
992
1044
|
// reads every worktree's session.lock and the host registry; this is the
|
|
@@ -1046,8 +1098,10 @@ async function main() {
|
|
|
1046
1098
|
|
|
1047
1099
|
// Phase 4 measurement probes — the mechanical caller (#1128).
|
|
1048
1100
|
//
|
|
1049
|
-
// `skills/session-start/SKILL.md` § Phase 4 names
|
|
1050
|
-
// and entry functions
|
|
1101
|
+
// `skills/session-start/SKILL.md` § Phase 4 names 20 probes with module paths
|
|
1102
|
+
// and entry functions (2026-10-02; the `PROBES` registry in
|
|
1103
|
+
// `scripts/lib/session-start-probes.mjs` is the count's source of truth).
|
|
1104
|
+
// Measured 2026-08-23 at `4f6404e` (a smaller family then), not one of them had
|
|
1051
1105
|
// a caller anywhere in hooks/, npm scripts, .gitlab-ci.yml or .husky/ — the
|
|
1052
1106
|
// only caller was the prose itself, and across 336 recorded session starts
|
|
1053
1107
|
// there was no event proving any of them had ever run. Built, documented,
|
|
@@ -1126,6 +1180,7 @@ async function main() {
|
|
|
1126
1180
|
// makes the supersession rate measurable instead of inferred (HR-105).
|
|
1127
1181
|
peers_superseded: mechanicalPeersSuperseded,
|
|
1128
1182
|
};
|
|
1183
|
+
if (headSha) payload.head_sha = headSha;
|
|
1129
1184
|
|
|
1130
1185
|
// #1091 — record the native SessionStart source so "does the same raw
|
|
1131
1186
|
// session_id repeat under source=resume?" becomes answerable from
|
package/hooks/on-stop.mjs
CHANGED
|
@@ -27,11 +27,12 @@
|
|
|
27
27
|
* "duration_ms":<int>,"duration_source":"meta-birthtime","status":"done"}
|
|
28
28
|
* (#1190 — every key after `event` is OPTIONAL and OMITTED when the
|
|
29
29
|
* measurement could not be made; `agent` too. See docs/events-schema.md.)
|
|
30
|
+
* Both records also carry `"reaper_trigger":"<reason>"` whenever `reaper.enabled`
|
|
31
|
+
* is true — the orphan-reaper trigger outcome of this fire (#1489, see
|
|
32
|
+
* `reaperTriggerField`); omitted when the reaper is disabled.
|
|
30
33
|
*/
|
|
31
34
|
|
|
32
35
|
import path from 'node:path';
|
|
33
|
-
import { spawn } from 'node:child_process';
|
|
34
|
-
import { performance } from 'node:perf_hooks';
|
|
35
36
|
import {
|
|
36
37
|
promises as fs,
|
|
37
38
|
existsSync,
|
|
@@ -39,19 +40,23 @@ import {
|
|
|
39
40
|
statSync,
|
|
40
41
|
writeFileSync,
|
|
41
42
|
} from 'node:fs';
|
|
42
|
-
import { fileURLToPath } from 'node:url';
|
|
43
43
|
|
|
44
44
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
45
45
|
import { readTailWindow } from '../scripts/lib/tail-window.mjs';
|
|
46
46
|
import { AGENT_ID_RE, resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
|
|
47
47
|
|
|
48
48
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
49
|
-
import { detectPlatform, getProjectDir } from '../scripts/lib/platform.mjs';
|
|
49
|
+
import { detectPlatform, getProjectDir, resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
50
|
+
import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
|
|
51
|
+
import {
|
|
52
|
+
classifyManifestSession,
|
|
53
|
+
readProcessLocalSessionIds,
|
|
54
|
+
} from '../scripts/lib/session-identity/own-session.mjs';
|
|
50
55
|
import { parseSessionId } from '../scripts/lib/session-id.mjs';
|
|
51
56
|
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
52
57
|
import { heartbeat, logSweepEvent } from '../scripts/lib/session-registry.mjs';
|
|
53
58
|
import { readLock, updateHeartbeat } from '../scripts/lib/session-lock.mjs';
|
|
54
|
-
import {
|
|
59
|
+
import { maybeTriggerOrphanScan } from '../scripts/lib/orphan-reaper/trigger.mjs';
|
|
55
60
|
|
|
56
61
|
// ---------------------------------------------------------------------------
|
|
57
62
|
// stdin reading (inline — no io.mjs because Stop hooks exit 0 always, never deny)
|
|
@@ -211,25 +216,26 @@ async function gitInfo(projectRoot) {
|
|
|
211
216
|
// ---------------------------------------------------------------------------
|
|
212
217
|
|
|
213
218
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
219
|
+
* Read the wave number from THIS session's wave-scope manifest at the session
|
|
220
|
+
* root — the same root and the same `.pi` > `.cursor` > `.codex` > `.claude`
|
|
221
|
+
* precedence the scope guards use (#1504 point 4). A peer's manifest in the
|
|
222
|
+
* same working copy is skipped; a peer-only result is "no own wave" → 0.
|
|
223
|
+
* Returns 0 too when the manifest is absent or cannot be parsed.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} sessionRoot `resolveSessionRoot(input.cwd)`
|
|
226
|
+
* @param {object|null} input the hook payload (its `session_id` is ours)
|
|
217
227
|
* @returns {Promise<number>}
|
|
218
228
|
*/
|
|
219
|
-
async function readWaveNumber(
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
} catch {
|
|
229
|
-
// file missing or unparseable — try next
|
|
230
|
-
}
|
|
229
|
+
async function readWaveNumber(sessionRoot, input) {
|
|
230
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
231
|
+
const { path: scopePath } = findOwnScopeFile(sessionRoot, ownIds, classifyManifestSession);
|
|
232
|
+
if (scopePath === null) return 0;
|
|
233
|
+
try {
|
|
234
|
+
const obj = JSON.parse(await fs.readFile(scopePath, 'utf8'));
|
|
235
|
+
return typeof obj?.wave === 'number' ? obj.wave : 0;
|
|
236
|
+
} catch {
|
|
237
|
+
return 0; // unreadable or unparseable — no wave known
|
|
231
238
|
}
|
|
232
|
-
return 0;
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
// ---------------------------------------------------------------------------
|
|
@@ -363,14 +369,40 @@ function resolveStopDuration(input, projectRoot) {
|
|
|
363
369
|
}
|
|
364
370
|
}
|
|
365
371
|
|
|
372
|
+
/**
|
|
373
|
+
* The orphan-reaper trigger outcome as an optional record key (#1489).
|
|
374
|
+
*
|
|
375
|
+
* `reaper_trigger` carries the reason `maybeTriggerOrphanScan()` returned for
|
|
376
|
+
* THIS hook fire — `throttled` | `spawned` | `spawned-unthrottled` | `error` —
|
|
377
|
+
* on the record this hook emits anyway, so no new record type and no per-fire
|
|
378
|
+
* event exist (HR-101). `spawned-unthrottled` is the case it is here for: a
|
|
379
|
+
* throttle marker the trigger cannot stamp (a planted link or directory, a
|
|
380
|
+
* read-only tmp dir) makes every fire spawn a scan, and until now that outcome
|
|
381
|
+
* was discarded in `main()` — a defeated throttle nothing recorded (HR-105).
|
|
382
|
+
* Recording the healthy reasons too gives that count its denominator: the
|
|
383
|
+
* share of armed fires that ran unthrottled, from events.jsonl alone (HR-106).
|
|
384
|
+
*
|
|
385
|
+
* OMITTED when the reaper is disabled (the default): that is configuration,
|
|
386
|
+
* not a measurement, and stamping it would touch every record on every host.
|
|
387
|
+
*
|
|
388
|
+
* @param {{reason?: unknown}|null|undefined} trigger
|
|
389
|
+
* @returns {{reaper_trigger?: string}}
|
|
390
|
+
*/
|
|
391
|
+
function reaperTriggerField(trigger) {
|
|
392
|
+
const reason = trigger?.reason;
|
|
393
|
+
if (typeof reason !== 'string' || reason === 'disabled') return {};
|
|
394
|
+
return { reaper_trigger: reason };
|
|
395
|
+
}
|
|
396
|
+
|
|
366
397
|
/**
|
|
367
398
|
* Handle a Stop event. Reads wave from scope file + git info, appends JSONL.
|
|
368
399
|
* @param {object|null} input
|
|
400
|
+
* @param {{reason?: string}|null} [reaperTrigger] `maybeTriggerOrphanScan()` result.
|
|
369
401
|
*/
|
|
370
|
-
async function handleStop(input) {
|
|
402
|
+
async function handleStop(input, reaperTrigger = null) {
|
|
371
403
|
const projectRoot = getProjectDir();
|
|
372
404
|
|
|
373
|
-
const wave = await readWaveNumber(
|
|
405
|
+
const wave = await readWaveNumber(resolveSessionRoot(input?.cwd), input);
|
|
374
406
|
const { commit, branch } = await gitInfo(projectRoot);
|
|
375
407
|
|
|
376
408
|
const { sessionId, semanticSessionId } = await resolveSessionId(input, projectRoot);
|
|
@@ -483,6 +515,7 @@ async function handleStop(input) {
|
|
|
483
515
|
...(branch !== null ? { branch } : {}),
|
|
484
516
|
...(commit !== null ? { commit } : {}),
|
|
485
517
|
...duration,
|
|
518
|
+
...reaperTriggerField(reaperTrigger),
|
|
486
519
|
};
|
|
487
520
|
|
|
488
521
|
try {
|
|
@@ -500,9 +533,10 @@ async function handleStop(input) {
|
|
|
500
533
|
* coordinator turn. Currently always returns null (no inline warning from this
|
|
501
534
|
* handler) — the slot is reserved for future SubagentStop feedback.
|
|
502
535
|
* @param {object|null} input
|
|
536
|
+
* @param {{reason?: string}|null} [reaperTrigger] `maybeTriggerOrphanScan()` result.
|
|
503
537
|
* @returns {Promise<string|null>} additionalContext or null
|
|
504
538
|
*/
|
|
505
|
-
async function handleSubagentStop(input) {
|
|
539
|
+
async function handleSubagentStop(input, reaperTrigger = null) {
|
|
506
540
|
/** @type {Record<string, unknown>} */
|
|
507
541
|
const payload = {};
|
|
508
542
|
|
|
@@ -633,8 +667,12 @@ async function handleSubagentStop(input) {
|
|
|
633
667
|
// #1183 — a malformed record throws EventValidationError BEFORE any side
|
|
634
668
|
// effect (scripts/lib/events.mjs); this hook must never abort on that, so
|
|
635
669
|
// the emit is wrapped rather than left to propagate.
|
|
670
|
+
// A fresh object via spread (like the Stop record above), never
|
|
671
|
+
// Object.assign onto a parsed payload: spread defines keys, so no `__proto__`
|
|
672
|
+
// key can reach the prototype setter (CWE-1321, semgrep
|
|
673
|
+
// prototype-pollution-object-assign).
|
|
636
674
|
try {
|
|
637
|
-
await emitEvent('orchestrator.agent.stopped', payload);
|
|
675
|
+
await emitEvent('orchestrator.agent.stopped', { ...payload, ...reaperTriggerField(reaperTrigger) });
|
|
638
676
|
} catch { /* telemetry never blocks the hook (#1183) */ }
|
|
639
677
|
return null;
|
|
640
678
|
}
|
|
@@ -775,145 +813,6 @@ function readStatusFromTranscriptTail(transcriptPath) {
|
|
|
775
813
|
// main
|
|
776
814
|
// ---------------------------------------------------------------------------
|
|
777
815
|
|
|
778
|
-
// ---------------------------------------------------------------------------
|
|
779
|
-
// orphan-reaper trigger (#1432 B4)
|
|
780
|
-
// ---------------------------------------------------------------------------
|
|
781
|
-
//
|
|
782
|
-
// DUPLICATED VERBATIM between the two trigger hooks —
|
|
783
|
-
// hooks/post-tool-batch-wave-signal.mjs <-> hooks/on-stop.mjs. Deliberate, with
|
|
784
|
-
// a named revisit trigger (BV-004): they are the only two trigger points the
|
|
785
|
-
// PRD declares, and a shared `hooks/_lib/reaper-trigger.mjs` would be a third
|
|
786
|
-
// file in the hook import graph. SIZE, measured 2026-09-22 by diffing the two
|
|
787
|
-
// blocks: 138 identical lines — this note said "~40 lines of glue" until then,
|
|
788
|
-
// which is what made the duplicate look cheaper than it is. Extract it the
|
|
789
|
-
// moment a THIRD hook needs the trigger, or the moment the copies must differ.
|
|
790
|
-
|
|
791
|
-
/**
|
|
792
|
-
* Filesystem path of the scan CLI, spawned as a PLAIN argv call.
|
|
793
|
-
*
|
|
794
|
-
* It used to be a `file://` URL handed to `node --input-type=module -e
|
|
795
|
-
* <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
|
|
796
|
-
* has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
|
|
797
|
-
* contract lives in that module instead of as source text duplicated here —
|
|
798
|
-
* and nothing this hook builds is a program any more: every variable part is
|
|
799
|
-
* an argv value, which cannot become code whatever the checkout path contains.
|
|
800
|
-
*
|
|
801
|
-
* `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
|
|
802
|
-
* percent-encoded path for any checkout directory containing a space.
|
|
803
|
-
*/
|
|
804
|
-
const ORPHAN_REAPER_SCRIPT = fileURLToPath(
|
|
805
|
-
new URL('../scripts/lib/orphan-reaper.mjs', import.meta.url),
|
|
806
|
-
);
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Read the `reaper:` block from the project's Session Config host file.
|
|
810
|
-
* Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
|
|
811
|
-
* path must not import the full config orchestrator. A missing or unreadable
|
|
812
|
-
* file yields the parser defaults, i.e. DISABLED.
|
|
813
|
-
*
|
|
814
|
-
* @param {string} projectDir
|
|
815
|
-
* @returns {ReturnType<typeof _parseReaper>}
|
|
816
|
-
*/
|
|
817
|
-
function loadReaperConfig(projectDir) {
|
|
818
|
-
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
819
|
-
try {
|
|
820
|
-
return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
|
|
821
|
-
} catch {
|
|
822
|
-
// missing or unreadable — try the next candidate
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
return _parseReaper('');
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/**
|
|
829
|
-
* Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
|
|
830
|
-
*
|
|
831
|
-
* The hook itself does exactly two pieces of I/O — one config read and one
|
|
832
|
-
* `stat` of the throttle marker — and then hands the work to a DETACHED,
|
|
833
|
-
* unref'd child. The scan is never run inline: one `ps` round-trip out of Node
|
|
834
|
-
* was measured at ~47 ms over 287 KB of output, which alone would blow the
|
|
835
|
-
* 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
|
|
836
|
-
*
|
|
837
|
-
* The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
|
|
838
|
-
* the throttle window — otherwise a broken spawn would be retried on every
|
|
839
|
-
* single tool batch.
|
|
840
|
-
*
|
|
841
|
-
* `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
|
|
842
|
-
* with `performance.now()` and reported as ONE stderr WARN line when exceeded.
|
|
843
|
-
* A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
|
|
844
|
-
* per-fire telemetry record is exactly the always-on signal
|
|
845
|
-
* `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
|
|
846
|
-
* measurement covers preparation only — the scan itself runs detached, which is
|
|
847
|
-
* the whole reason the budget can be held.
|
|
848
|
-
*
|
|
849
|
-
* Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
|
|
850
|
-
*
|
|
851
|
-
* @param {object} [opts]
|
|
852
|
-
* @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
|
|
853
|
-
* @param {number} [opts.now] Injected clock (ms).
|
|
854
|
-
* @param {Function} [opts.spawnFn] Injected `spawn` (tests).
|
|
855
|
-
* @param {Function} [opts.statFn] Injected `statSync` (tests).
|
|
856
|
-
* @param {Function} [opts.writeFn] Injected marker writer (tests).
|
|
857
|
-
* @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
|
|
858
|
-
* budget (tests); defaults to `performance.now`.
|
|
859
|
-
* @returns {Promise<{spawned: boolean, reason: string}>}
|
|
860
|
-
*/
|
|
861
|
-
export async function maybeTriggerOrphanScan({
|
|
862
|
-
projectDir,
|
|
863
|
-
now,
|
|
864
|
-
spawnFn = spawn,
|
|
865
|
-
statFn,
|
|
866
|
-
writeFn,
|
|
867
|
-
clockFn = () => performance.now(),
|
|
868
|
-
} = {}) {
|
|
869
|
-
const startedAt = clockFn();
|
|
870
|
-
/** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
|
|
871
|
-
const checkLatency = (budgetMs) => {
|
|
872
|
-
const elapsed = clockFn() - startedAt;
|
|
873
|
-
if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
|
|
874
|
-
process.stderr.write(
|
|
875
|
-
`orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
|
|
876
|
-
+ `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
|
|
877
|
-
);
|
|
878
|
-
}
|
|
879
|
-
};
|
|
880
|
-
try {
|
|
881
|
-
const root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
|
|
882
|
-
const cfg = loadReaperConfig(root);
|
|
883
|
-
// Cheapest gate first: disabled means no stat, no spawn, no module load.
|
|
884
|
-
if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
|
|
885
|
-
|
|
886
|
-
const reaper = await import('../scripts/lib/orphan-reaper.mjs');
|
|
887
|
-
const markerPath = reaper.scanMarkerPath(root);
|
|
888
|
-
const nowMs = typeof now === 'number' ? now : Date.now();
|
|
889
|
-
|
|
890
|
-
if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
|
|
891
|
-
checkLatency(cfg['max-hook-latency-ms']);
|
|
892
|
-
return { spawned: false, reason: 'throttled' };
|
|
893
|
-
}
|
|
894
|
-
reaper.touchScanMarker(markerPath, { writeFn });
|
|
895
|
-
|
|
896
|
-
const child = spawnFn(
|
|
897
|
-
process.execPath,
|
|
898
|
-
[
|
|
899
|
-
ORPHAN_REAPER_SCRIPT,
|
|
900
|
-
'--repo-root', root,
|
|
901
|
-
'--mode', cfg.mode,
|
|
902
|
-
'--min-age-seconds', String(cfg['min-age-seconds']),
|
|
903
|
-
'--kill-grace-ms', String(cfg['kill-grace-ms']),
|
|
904
|
-
'--verify-wait-ms', String(cfg['verify-wait-ms']),
|
|
905
|
-
'--false-alarm-window', String(cfg['false-alarm-window']),
|
|
906
|
-
],
|
|
907
|
-
{ detached: true, stdio: 'ignore' },
|
|
908
|
-
);
|
|
909
|
-
if (child && typeof child.unref === 'function') child.unref();
|
|
910
|
-
checkLatency(cfg['max-hook-latency-ms']);
|
|
911
|
-
return { spawned: true, reason: 'spawned' };
|
|
912
|
-
} catch {
|
|
913
|
-
return { spawned: false, reason: 'error' };
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
816
|
async function main() {
|
|
918
817
|
const input = await readStdinJson();
|
|
919
818
|
const eventType = discriminate(input);
|
|
@@ -924,11 +823,14 @@ async function main() {
|
|
|
924
823
|
// hook, which triggers last) because both branches below may throw — a
|
|
925
824
|
// `handleStop` failure must not silently disarm the watchdog. The cost is one
|
|
926
825
|
// config read plus one stat, bounded by `reaper.max-hook-latency-ms` (50 ms),
|
|
927
|
-
// and the scan itself runs in a detached child.
|
|
928
|
-
|
|
826
|
+
// and the scan itself runs in a detached child. Its outcome rides on the
|
|
827
|
+
// record either branch emits anyway (`reaper_trigger`, see reaperTriggerField).
|
|
828
|
+
// Rooted at the SESSION root (#1492 point 2): the gate ledger the scan reads
|
|
829
|
+
// lives in the tree the gate ran in — an entered worktree, not the launch dir.
|
|
830
|
+
const reaperTrigger = await maybeTriggerOrphanScan({ projectDir: resolveSessionRoot(input?.cwd) });
|
|
929
831
|
|
|
930
832
|
if (eventType === 'subagent_stop') {
|
|
931
|
-
const additionalContext = await handleSubagentStop(input);
|
|
833
|
+
const additionalContext = await handleSubagentStop(input, reaperTrigger);
|
|
932
834
|
// v2.1.163+: emit hookSpecificOutput for SubagentStop path.
|
|
933
835
|
// If handleSubagentStop returns a non-empty context string, feed it back
|
|
934
836
|
// to the coordinator turn. Currently returns null (no inline warning from
|
|
@@ -949,7 +851,7 @@ async function main() {
|
|
|
949
851
|
// SubagentStop is intentionally outside this block — it must never emit
|
|
950
852
|
// terminalSequence.
|
|
951
853
|
try {
|
|
952
|
-
await handleStop(input);
|
|
854
|
+
await handleStop(input, reaperTrigger);
|
|
953
855
|
} finally {
|
|
954
856
|
// terminalSequence is only meaningful for Stop (session-level) events.
|
|
955
857
|
process.stdout.write(buildTerminalSequenceJson(detectPlatform()));
|