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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
package/docs/cursor-setup.md
CHANGED
|
@@ -5,32 +5,31 @@ Guide for using Session Orchestrator with Cursor IDE.
|
|
|
5
5
|
## Prerequisites
|
|
6
6
|
|
|
7
7
|
- [Cursor IDE](https://cursor.com) installed (version 1.7+ recommended for hooks support)
|
|
8
|
-
-
|
|
8
|
+
- Node.js 24+
|
|
9
9
|
- A project repository with git initialized
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
|
-
### Option 1: Clone and symlink (recommended)
|
|
14
13
|
```bash
|
|
15
|
-
# 1. Clone the session-orchestrator repo and install its dependencies
|
|
16
14
|
git clone https://github.com/Kanevry/session-orchestrator.git ~/Projects/session-orchestrator
|
|
17
15
|
cd ~/Projects/session-orchestrator && npm install
|
|
18
|
-
|
|
19
|
-
# 2. Symlink Cursor rules into your project
|
|
20
|
-
node ~/Projects/session-orchestrator/scripts/cursor-install.mjs /path/to/your/project
|
|
16
|
+
node ~/Projects/session-orchestrator/scripts/cursor-install.mjs /path/to/your-project
|
|
21
17
|
```
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
# Copy .cursor/rules/ into your project
|
|
26
|
-
cp -r path/to/session-orchestrator/.cursor/rules/ .cursor/rules/
|
|
27
|
-
```
|
|
19
|
+
The installer links four Cursor-native surfaces into the target project:
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+
| Surface | Path | What Cursor does with it |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| Rules | `.cursor/rules/*.mdc` | Always-on + intelligent-apply guidance |
|
|
24
|
+
| Commands | `.cursor/commands/*.md` | Slash commands (`/session`, `/go`, `/close`, …) |
|
|
25
|
+
| Skills | `.cursor/skills/<name>/SKILL.md` | Skill discovery; wrappers point at canonical `skills/` |
|
|
26
|
+
| Hooks | `.cursor/hooks.json` | Native hooks via `scripts/lib/cursor-hook-bridge.mjs` |
|
|
27
|
+
|
|
28
|
+
Working **in this repo** does not need the installer — those paths are committed. Reload Cursor (or start a new agent chat) after a pull so `/session` appears.
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
## Configuration
|
|
32
31
|
|
|
33
|
-
Cursor reads CLAUDE.md natively
|
|
32
|
+
Cursor reads CLAUDE.md natively. Add a `## Session Config` section:
|
|
34
33
|
|
|
35
34
|
```markdown
|
|
36
35
|
## Session Config
|
|
@@ -45,33 +44,10 @@ enforcement: warn
|
|
|
45
44
|
vcs: github
|
|
46
45
|
```
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
### Hooks (Optional — not yet enforcing)
|
|
51
|
-
|
|
52
|
-
> **The hook handlers do not currently fire on Cursor (#919).** Both are written
|
|
53
|
-
> against Claude Code's `PreToolUse` payload — they require `tool_name === "Bash"`
|
|
54
|
-
> plus `tool_input.command`, and they signal a decision with a Claude Code
|
|
55
|
-
> `hookSpecificOutput` envelope. Fed a Cursor `beforeShellExecution` payload,
|
|
56
|
-
> `hooks/enforce-commands.mjs` short-circuits at its first gate and writes **0
|
|
57
|
-
> bytes to stdout and 0 bytes to stderr, exit 0** — so the harness sees no
|
|
58
|
-
> decision and **the command runs**. It is a silent no-op, not a block and not
|
|
59
|
-
> even a warning. Making this real needs a Cursor input/output adapter, the way
|
|
60
|
-
> Pi has `scripts/lib/pi-hook-bridge.mjs`; no such adapter exists yet.
|
|
61
|
-
>
|
|
62
|
-
> Until then, treat `hooks/hooks-cursor.json` as the *intended* mapping and do
|
|
63
|
-
> not rely on it for command or scope enforcement on Cursor.
|
|
64
|
-
|
|
65
|
-
Cursor supports hooks via Settings > Hooks. The intended mapping is:
|
|
66
|
-
|
|
67
|
-
- **afterFileEdit**: `hooks/enforce-scope.mjs` for scope enforcement (post-hoc warning)
|
|
68
|
-
- **beforeShellExecution**: `hooks/enforce-commands.mjs` for dangerous-command enforcement
|
|
69
|
-
|
|
70
|
-
See `hooks/hooks-cursor.json` for the hook mapping reference.
|
|
47
|
+
`agents-per-wave` is ignored on Cursor — tasks execute sequentially (no parallel Agent dispatch).
|
|
71
48
|
|
|
72
49
|
## Usage
|
|
73
50
|
|
|
74
|
-
Commands work the same as in Claude Code:
|
|
75
51
|
- `/session [housekeeping|feature|deep]` — Start a session
|
|
76
52
|
- `/go` — Execute the agreed plan
|
|
77
53
|
- `/close` — End session with verification
|
|
@@ -79,6 +55,8 @@ Commands work the same as in Claude Code:
|
|
|
79
55
|
- `/discovery [scope]` — Run quality probes
|
|
80
56
|
- `/evolve [analyze|review|list]` — Manage learnings
|
|
81
57
|
|
|
58
|
+
Cursor has no Skill tool. Commands tell the agent to **Read** `skills/<name>/SKILL.md` and follow it.
|
|
59
|
+
|
|
82
60
|
## Key Differences from Claude Code
|
|
83
61
|
|
|
84
62
|
| Aspect | Claude Code | Cursor IDE |
|
|
@@ -88,17 +66,18 @@ Commands work the same as in Claude Code:
|
|
|
88
66
|
| State directory | .claude/ | .cursor/ |
|
|
89
67
|
| Config file | CLAUDE.md | CLAUDE.md (same!) |
|
|
90
68
|
| Task tracking | TaskCreate/TaskUpdate | Text-based checklists |
|
|
91
|
-
| Pre-edit enforcement | PreToolUse hook (blocks) |
|
|
92
|
-
| Command enforcement | PreToolUse hook (blocks) |
|
|
69
|
+
| Pre-edit enforcement | PreToolUse hook (blocks) | `preToolUse` via cursor-hook-bridge (blocks) |
|
|
70
|
+
| Command enforcement | PreToolUse hook (blocks) | `beforeShellExecution` via cursor-hook-bridge (blocks) |
|
|
71
|
+
| Post-edit | PostToolUse | `afterFileEdit` is post-hoc (cannot unwrite) |
|
|
93
72
|
| Model | Claude via API | Model selected in Cursor settings |
|
|
94
73
|
| Plan mode | EnterPlanMode tool | Instruction-based |
|
|
95
74
|
|
|
96
75
|
## Limitations
|
|
97
76
|
|
|
98
77
|
1. **No parallel agents** — All wave tasks execute sequentially. Sessions take longer than in Claude Code.
|
|
99
|
-
2. **
|
|
78
|
+
2. **afterFileEdit cannot prevent an edit** — it fires after the write. Scope blocking belongs on `preToolUse`.
|
|
100
79
|
3. **Model preference advisory** — The `model-preference-cursor` frontmatter in skills is advisory only. Select your model in Cursor settings.
|
|
101
|
-
4. **No
|
|
80
|
+
4. **No Agent/Skill tools** — `pre-task-scope-disjoint` and `skill-invocation-telemetry` are not wired; commands Read skill files directly.
|
|
102
81
|
|
|
103
82
|
## Shared Knowledge
|
|
104
83
|
|
|
@@ -110,8 +89,8 @@ Switch freely between Claude Code, Codex, and Cursor on the same project — all
|
|
|
110
89
|
|
|
111
90
|
## Troubleshooting
|
|
112
91
|
|
|
113
|
-
-
|
|
114
|
-
- **
|
|
115
|
-
- **Hooks not firing**:
|
|
116
|
-
-
|
|
117
|
-
- **State files not created**: Check `.cursor/`
|
|
92
|
+
- **`/session` missing**: Confirm `.cursor/commands/session.md` exists. Reload Cursor. If this is another project, re-run `node scripts/cursor-install.mjs .`
|
|
93
|
+
- **Rules not loading**: Ensure `.cursor/rules/` exists with `.mdc` files; `000-session-orchestrator.mdc` has `alwaysApply: true`
|
|
94
|
+
- **Hooks not firing**: Confirm `.cursor/hooks.json` exists. Open Cursor Settings → Hooks. Restart Cursor if it was open during install.
|
|
95
|
+
- **`'node' not found` in hook PATH**: See README troubleshooting. `hooks/run-node.sh` resolves Homebrew/nvm Node.
|
|
96
|
+
- **State files not created**: Check `.cursor/` is writable
|
package/docs/events-schema.md
CHANGED
|
@@ -58,8 +58,10 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
|
|
|
58
58
|
| `orchestrator.session.started` | `hooks/on-session-start.mjs` | SessionStart |
|
|
59
59
|
| `orchestrator.session.ended` | `hooks/on-session-end.mjs` | SessionEnd |
|
|
60
60
|
| `orchestrator.session.stopped` | `hooks/on-stop.mjs` | Stop |
|
|
61
|
+
| `orchestrator.session.backfill_completed` | `hooks/on-session-end.mjs` (`emitBackfillOutcome`) | SessionEnd, once per backfill call (#1068 AC2 — the backfill outcome is canonically queryable, the side-log is no longer the only result source). Payload: `kind` (`abandoned`\|`state-md-completed`), `action` (the backfill result action, e.g. `appended`\|`superseded`\|`skipped-already-recorded`\|`unknown`), plus `session_id`, `semantic_session_id`, `record_id`, `supersedes`, `reason` — each OMITTED when unknown, never `null` |
|
|
62
|
+
| `orchestrator.session.root_left` | `scripts/lib/session-transition.mjs` (`leaveSourceRoot`) | a session left a repo root for good — the process-boundary teardown of Worktree-Auto-Promotion (#1069), emitted into the OLD root's stream after `deregisterSelf()` + `release()`, whether or not either found anything. NOT emitted when the teardown ABORTED (invalid args, a lock owned by another session, an unparseable lock): the event asserts a root was left, and on those branches none was. Payload: `session_id`, `semantic_session_id` (OMITTED when unknown, never `null`), `from_root_hash` (`repoPathHash()` of the abandoned root — the SAME hash the session registry keys its entries by, so a departure joins to the entry it removed), `from_root_basename` (matching the registry's `repo_name`), `reason` (e.g. `worktree-promotion`). **Never the absolute root:** this payload also travels over the optional Clank webhook with no redaction, and an absolute root on a developer host is `/Users/<operator>/…` — same rule as `board_written` / `mirror_completed` and `relativeWorktreePath` in `worktree-pipeline.mjs`. Its absence beside a live-looking registry entry is the phantom-peer signature the event exists to make visible |
|
|
61
63
|
| `orchestrator.session.lock.acquired` | `hooks/_lib/lock-bootstrap.mjs` | SessionStart |
|
|
62
|
-
| `orchestrator.session.lock.released` | `hooks/on-session-end.mjs` · `scripts/lib/autopilot/worktree-pipeline.mjs` (`teardownWorktree`) | after a `release()` that matched ownership (#952). Payload: `session_id`, `caller` (`on-session-end`\|`worktree-pipeline`), `outcome` (`deleted`\|`already-gone`), `verified`; hook-side additionally `lock_session_id`, `semantic_session_id`, `end_reason` (the SessionEnd reason — deliberately NOT `reason`, which the sibling `release_failed` uses for the failure reason); pipeline-side additionally `worktree_path`, `issue_iid`. `outcome: 'already-gone'` means the lock had ALREADY vanished between `readLock()` and `release()` — the forensically interesting case, since a successful release previously left no trace at all and a missing lock was therefore indistinguishable from a lock someone else deleted (#914 residual 3) |
|
|
64
|
+
| `orchestrator.session.lock.released` | `hooks/on-session-end.mjs` · `scripts/lib/autopilot/worktree-pipeline.mjs` (`teardownWorktree`) · `scripts/lib/session-transition.mjs` (`leaveSourceRoot`) | after a `release()` that matched ownership (#952). Payload: `session_id`, `caller` (`on-session-end`\|`worktree-pipeline`\|`session-transition`), `outcome` (`deleted`\|`already-gone`), `verified`; hook-side additionally `lock_session_id`, `semantic_session_id`, `end_reason` (the SessionEnd reason — deliberately NOT `reason`, which the sibling `release_failed` uses for the failure reason); pipeline-side additionally `worktree_path`, `issue_iid`. The `session-transition` caller emits only this event and no `release_failed` sibling — a failed release there is reported to its caller as `{ ok: false, reason: 'lock-<reason>' }`, which the promotion prose must WARN on, so the stream is not the only witness. `outcome: 'already-gone'` means the lock had ALREADY vanished between `readLock()` and `release()` — the forensically interesting case, since a successful release previously left no trace at all and a missing lock was therefore indistinguishable from a lock someone else deleted (#914 residual 3) |
|
|
63
65
|
| `orchestrator.session.lock.release_failed` | `hooks/on-session-end.mjs` · `scripts/lib/autopilot/worktree-pipeline.mjs` (`teardownWorktree`) | ownership matched but `release()` did NOT delete the lock (#724). Payload: `session_id`, `reason` (`fs-error`\|`session-mismatch`\|`not-deleted`\|`threw`), `caller`; pipeline-side additionally `worktree_path`, `issue_iid` |
|
|
64
66
|
| `orchestrator.session.lock.reconcile_attempted` | `hooks/_lib/lock-reconcile.mjs` | SessionEnd, when NEITHER ownership check matched the recorded lock (#748). Payload: `session_id`, `action` (`reaped`\|`skipped`\|`unknown`), `reason` (e.g. `own-host-pid-alive`) |
|
|
65
67
|
| `orchestrator.session.lock.reaped` | `scripts/lib/lock-reaper.mjs` | a dead lease was reaped. Payload: `session_id`, `semantic_session_id`, `host`, `pid`, `age_hours`, `reap_mode`, `current_session` |
|
|
@@ -67,19 +69,89 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
|
|
|
67
69
|
| `orchestrator.memory.propose_invoked` | `hooks/pre-bash-memory-propose-audit.mjs` | PreToolUse(Bash) |
|
|
68
70
|
| `orchestrator.wave.started` / `.completed` | `hooks/post-tool-batch-wave-signal.mjs` | PostToolBatch — fires live via `.claude/wave-scope.json` `.wave` increase (mechanical fallback, #612); an explicit injected `wave_signal` still takes precedence |
|
|
69
71
|
| `orchestrator.quality_gate.passed` / `.failed` | `scripts/run-quality-gate.mjs` (the gate CLI — live between waves) · `scripts/lib/quality-gate.mjs` (`emitGateEvent` inside `runQualityGateWithRetry`, reached ONLY under `verification-auto-fix.enabled: true` — default `false`, and `false` in this repo) | CLI: once per gate-CLI run. Library: once per `runQualityGateWithRetry` **call**, never per retry attempt (`attempts` carries that detail). The two paths never nest, so one run passes through exactly one emitter. **Payload (both):** `variant`, `exit_code`, plus `session_id` / `semantic_session_id` when `sessionAttribution()` finds a session lock (both omitted when it does not). CLI `variant` is the `--variant` value (`baseline`\|`incremental`\|`full-gate`\|`per-file`); the library pins `variant: 'auto-fix-loop'`. **CLI only:** `wave_number`. **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Optional on both:** `counts: {passed, failed, total}`. **Absent is not zero — for both optional fields.** `counts` is admitted by the ONE shared policy `admitSuiteCounts()` (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2), which returns `null` — never a zero triple — for an unmeasured or inconsistent input (test gate skipped/stubbed, fail-fast on lint or typecheck before the test step, no parseable `<N> passed` marker, or `passed + failed !== total`); both callers spread `...(counts ? { counts } : {})`, so the KEY is missing in those runs. A present `counts.failed: 0` therefore means "measured, zero failures", while an absent `counts` means "not measured" — reading a missing field as `0` mis-analyses the ledger in both directions. Same contract for `wave_number` (CLI, #966 step 1): resolved from the `.{pi,cursor,codex,claude}/wave-scope.json` sidecar, **omitted** — never `0` — when there is no sidecar or its `wave` is non-numeric/non-positive; a human running `npm run quality-gate` from a `git push` has no wave at all, so an invented wave 0 would have to be special-cased by every consumer. Note `total` is `passed + failed` and EXCLUDES skipped/todo (see `extractTestCounts`) |
|
|
72
|
+
| `orchestrator.scope.foreign_session_ignored` | `hooks/enforce-scope.mjs` (Gate 3b, #1123) | PreToolUse Edit/Write — exactly once per gated tool call while a FOREIGN-session `wave-scope.json` is live: the manifest's `session`/`semantic_session` provably name another session, so the hook allows instead of enforcing. **Payload:** `hook`, `manifest` (path), `manifest_session` (string[]), `own_session` (string[]), `wave`, `file_path`. Legacy manifests without a `session` field never emit this — they stay enforced |
|
|
70
73
|
| `orchestrator.grounding.injected` | `scripts/compute-grounding-injection.sh` (via `scripts/emit-event.mjs`) | grounding injection, when `PERSISTENCE=true` |
|
|
71
74
|
| `orchestrator.handover.gated` | `skills/session-end/SKILL.md` Phase 1.65 (skill-prose, via `scripts/emit-event.mjs`) | Handover-Alignment-Gate outcome (#773). Payload: `candidates_total`, `auto_carry`, `asked`, `dropped`, `questions_asked`, `questions_answered`, `questions_deferred`, `path` (`fast_path`\|`triage`\|`weiterarbeiten`\|`fail_open`). Emitted exactly once per close — including the fail-open skip and the "Weiterarbeiten" abort — so never-measured paths become observable |
|
|
75
|
+
| `orchestrator.vault.board_written` | `scripts/lib/vault-status/board-writer.mjs` (`emitBoardEvent`; name const `BOARD_EVENT`) | exactly ONE record per `mirrorBoard()` call, and therefore per `sweepBoard()` call — the sweep never double-emits. Call sites: the `mirrorBoard` wrapper through which all six inner return points funnel, and `sweepBoard`'s two paths (happy + enumeration-failure fallback). **Payload:** `action` (always — including every no-op: the five `skipped-vault-disabled` guards at `:803/:812/:817/:821/:829`, plus `skipped-handwritten`, `skipped-noop`, `skipped-write-failed`, `dry-run`, `written`), `caller` (always, `mirrorBoard`\|`sweepBoard`). **Optional, absent-is-not-zero:** `path_tail` (the BASENAME only — never the full path: under `01-projects/` the parent directory is the private project slug, and this payload also travels over the optional Clank webhook with no redaction), `rows`, `repos_swept`, `duration_ms`, plus `session_id` / `semantic_session_id` via `sessionAttribution(repoRoot)` (#1147 — the SAME root the record is pinned to, so attribution can never name a different tree than the ledger line; both keys omitted, never fabricated, when no `session.lock` is readable) — the numeric three admitted via `Number.isFinite(...)` / `typeof === 'string'`, NOT truthiness, so a measured `repos_swept: 0` survives while an unmeasured field is omitted. The enumeration-failure fallback deliberately omits `repos_swept`. **A throw from the inner function emits nothing** — `action` is mandatory and a throw has no action the code knows; inventing one would put a fictional state in the ledger (#1073) |
|
|
76
|
+
| `orchestrator.vault.narrative_mirrored` | `scripts/lib/vault-status/narrative-mirror.mjs` (`emitNarrativeEvent`; name const `NARRATIVE_EVENT`) | one record per `mirrorNarrative()` call, from the thin wrapper — every outcome plus the throw path (`action: 'error'`, then re-throws). The old body became `runNarrativeMirror()`, so an early return added later is telemetered by construction. Covered: `skipped-vault-disabled` ×4, `skipped-invalid-path`, `skipped-no-statemd`, `written`, `skipped-noop`, `skipped-handwritten`, `dry-run`. **Payload:** `action` (always); optional `path_tail` (BASENAME only, same reason as `board_written`), `chars`, `session_id`, `semantic_session_id` (via `sessionAttribution`), `error_code` (throw path only — the error MESSAGE is deliberately not recorded, it can quote a path or STATE.md prose). **Named gap with a revisit trigger:** when `repoRoot` is absent, NOTHING is emitted — `emitEvent` would fall back to `SO_PROJECT_DIR` and the two rootless unit tests would append synthetic records to this repo's real ledger on every suite run. This deliberately diverges from `board-writer.mjs`, which emits there (#1073) |
|
|
77
|
+
| `orchestrator.vault.mirror_completed` | `scripts/lib/vault-mirror/telemetry.mjs` (`emitMirrorEvent`; name const `MIRROR_EVENT`), called from `scripts/lib/vault-mirror/process.mjs` (`emitAction`, reached from all **18** of its call sites — census `grep -n 'emitEntryAction' scripts/lib/vault-mirror/process.mjs`, 2026-08-23) and from the two `skipped-invalid` branches in `scripts/vault-mirror.mjs` | **ONE record per JSONL entry processed, EXCEPT `skipped-noop`** (#1151: noop dominates a steady-state run; its count survives in the run-event's `skipped` total + `action_breakdown`) — `created`, `updated`, every other `skipped-*`, and both invalid paths. Until #1147 it was **failure-only**: only the two `skipped-invalid` branches emitted, which is why this repo's ledger held **0** records of it against 1272 `orchestrator.secret_masker.applied` from the same CLI (measured 2026-08-23). A healthy run was therefore indistinguishable from a broken emitter — the gap the sibling `orchestrator.vault.mirror_run_completed` row below closes. **Payload:** `action` (the SAME string the entry wrote to stdout), `kind`, `line` (1-based JSONL line — the only locator when a record has no id). **Optional, absent-is-not-zero:** `record_id` (the record's `id` / `session_id`), `path` (**vault-RELATIVE**, never absolute: this payload also travels over the optional Clank webhook with no redaction — omitted on `skipped-invalid` and on the pre-path quality skips, which are reached before a target path exists), `skip_class` (`validation` | `mapper-crash`, invalid branches only — mirrors the stdout `reason` verbatim so the failure class stays groupable without string-matching), `reason` (the renderer's message on the invalid branches, or the existing `meta.reason` string on a quality skip — `confidence:X < min:Y` / `narrative:N < min:M` / `status:…` — REUSED from the stdout payload rather than recomputed; clamped to 300 chars), `dry_run`, `session_id` / `semantic_session_id`. `record_id` / `path` / `skip_class` / `reason` treat **`null` as not-measured** and are omitted: a `record_id: null` would read as "measured, empty id" rather than "this record had none". **Attribution is read at `SO_PROJECT_DIR`, explicitly** — `readLock()` defaults to `process.cwd()`, so a bare `sessionAttribution()` would attribute the record to whatever tree the process happens to run in while the ledger line lands under `CLAUDE_PROJECT_DIR`. Same root for both halves or neither. The ledger destination stays the 2-arg `emitEvent` default so every event of one run shares it: this CLI has no repo-root flag, and deriving one from `--source` would split a single run's telemetry across two ledgers |
|
|
78
|
+
| `orchestrator.vault.mirror_run_completed` | `scripts/lib/vault-mirror/telemetry.mjs` (`emitMirrorRunEvent`; name const `MIRROR_RUN_EVENT`), called from `finishRun()` in `scripts/vault-mirror.mjs` — the ONE close-out function every exit routes through (the happy tail, the malformed-JSON abort, the filesystem-error abort, and the top-level `main().catch`), latched so it can only fire once | exactly ONE record per CLI run, **unconditionally** — beside the `orchestrator.secret_masker.applied` emit and BEFORE the `--strict-schema` abort, so a failing run still reports its denominator. **This event is the denominator the per-entry row above lacks:** a healthy run over an empty source emits zero per-entry records, and so does a run whose emitter is broken — from the ledger the two are identical (`.claude/rules/host-resources.md` § HR-105). **Payload:** `kind`, `total` (non-blank JSONL entries attempted), `created`, `updated`, `skipped` (every non-failure `skipped-*` class), `failed` (`skipped-invalid` — validation error or mapper crash; split out because those are the entries whose session silently ends up WITHOUT a vault note), `dry_run`. **These five counters are ALWAYS present, including as `0`** — this is the one place a written zero is the payload rather than a violation of "absent is not zero", because each was measured over the whole run; `total: 0` is a measured empty run and the record's ABSENCE is the broken-emitter signal. `created + updated + skipped + failed === total` for any run that does not abort (pinned by a test). **Optional, and the discriminator that keeps the counters honest:** `aborted` (`malformed-json` | `filesystem-error` | `unexpected-error` | `missing-vault-dir` | `vault-not-canonical` | `missing-source` (#1151: the three pre-loop exit-2 aborts now close the run out through finishRun())) — present ONLY when the run exited before its tail, absent means "ran to the end" and never "unknown". Its presence says the five counters are PARTIAL (every line after the abort was never attempted), so the classes stop partitioning `total` and that gap must be read as an abort, not as producer/consumer drift. Until it existed, the two `process.exit` calls inside the entry loop and the `main().catch` jumped straight over this emit: the runs an operator most wants counted were the ones that vanished from the ledger, in the exact shape ("no record") the paragraph above reserves for a broken emitter. **Optional:** `action_breakdown` (per-`action` counts, keyed by the same strings the entries wrote to stdout — enumerates only actions that OCCURRED, so a missing key there means zero occurrences; the always-present `total` makes that reading unambiguous, and the key itself is omitted when nothing was processed), plus `session_id` / `semantic_session_id` via the same `SO_PROJECT_DIR`-pinned `sessionAttribution` as the per-entry event |
|
|
79
|
+
| `orchestrator.secret_masker.applied` | `scripts/vault-mirror.mjs` (beside the vault-mirror run) | once per vault-mirror run, unconditionally. Payload carries `channel: 'vault-mirror'`. **Undocumented until 2026-08-23, at which point it had over a thousand records** — and that omission actively misled: a census grepping event NAMES for `board\|mirror` returns 0 and reads as "the mirror emits nothing", while run-level presence was in fact already observable through THIS event's payload. Grep the `channel`, not the name |
|
|
80
|
+
| `orchestrator.probes.completed` | `scripts/lib/session-start-probes.mjs` (`runSessionStartProbes`), called from `hooks/on-session-start.mjs` | once per SessionStart, after the Phase-4 measurement probes run. **This event is the whole point of #1073:** the 18 module-backed probes had **zero** mechanical callers across `hooks/`, npm scripts, CI and husky — their only caller was prose in `skills/session-start/SKILL.md` — and across the 336 session starts recorded up to 2026-08-23 there was **no banner event at all**, so whether they ever ran was unfalsifiable (`.claude/rules/host-resources.md` § HR-105). **Payload:** `total`, `ran`, `warned`, `skipped`, `errored`, `timed_out`, `duration_ms`, and `probes` — one `{id, outcome, reason?}` per probe — `reason` travels whenever one was recorded, because `module-absent` (a permanently dead entry) must be distinguishable from `network-probe-opt-in` (the intended default); `outcome` ∈ `ran-clean`\|`ran-warn`\|`ran-alert`\|`skipped`\|`timeout`\|`error`. Two invariants are asserted by tests: `total === probes.length` and `ran + skipped + errored + timed_out === total`. **The count is 18, not the 19 Phase 4 appears to list:** four Phase-4 items are prose-only measurements with no module and no entry function (SSOT freshness, quality baseline, Pencil design status, plugin freshness) — 22 measurements, 18 wireable probes. **Network probes (`ci-status`, `mirror-issues`) are excluded by default** and appear as `outcome: 'skipped', reason: 'network-probe-opt-in'` — never omitted, because omitting them would rebuild the defect one layer down. Opt in with `SO_PROBES_INCLUDE_NETWORK=1`. The grounds are measured, not assumed: `hooks/hooks.json` gives the WHOLE SessionStart hook `timeout: 5` seconds while each network probe carries its own 8 s CLI timeout, so one slow network probe alone exceeds the hook's entire budget and takes the started-event and the banner down with it; warm-and-authenticated best case measured 520 ms / 498 ms, paid on every start of every repo. **Budget:** `PROBE_BUDGET_MS = 2000`; measured median against this repo **968 ms** (5 runs, 855–1104), 130–229 ms in a fresh tmp repo. Revisit trigger: median past HALF the budget, or any single probe past the budget → move the slow probes off the hook's critical path, do NOT raise the number. **Named ceiling:** the deadline is enforced at await points, so it is hard for async/network probes and advisory for the two that shell out synchronously (`project-hygiene`, `tests-src-ratio`). Escape hatch: `SO_DISABLE_STARTUP_PROBES=1`. **Deliberately NOT gated on `enable-host-banner: false`** — that preference governs DISPLAY; gating the RUN on it would rebuild exactly the unfalsifiable blind spot this event removes |
|
|
81
|
+
| `orchestrator.express_path.evaluated` | `scripts/lib/express-path.mjs` (`evaluateExpressPath`, emit in `_emitEvaluated`; name const `EXPRESS_PATH_EVENT`) | once per Phase-8.5 evaluation — **on refusal as well as activation**. Until #1119 this was unrecordable twice over: `scripts/lib/config.mjs` discarded the `express-path` key **even when the block was present** (synthetic probe: 88 keys emitted, none of them this one), and the decision lived only in `skills/session-start/phase-8-5-express-path.md` prose, so it fired only when a coordinator read that prose. Ledger evidence, measured 2026-08-23 @ `34321bc` (a count, so read it as history, not as state): **0** express events at that point, against 22 of the last 30 sessions running with no wave at all — every one of them `housekeeping`, the exact population the path targets. **Payload:** `activated` (always, boolean), `reasons` (always — the BLOCKING codes on refusal, the satisfied ones on activation; nothing short-circuits, so a refusal names every blocker and a reader can tell whether trimming the issue list alone would have helped). **Optional, absent-is-not-zero:** `enabled`, `session_type`, `task_count`, `parallel_agents_required`, plus `session_id`/`semantic_session_id` via `sessionAttribution`. An unmeasured `sessionType` or `taskCount` fails CLOSED (`reasons: ['session-type-unknown','task-count-unknown']`) — defaulting unknown scope to 0 would activate a gate-skipping path on data nobody supplied. **Four inputs, not three:** activation condition 3 carries two clauses (`≤ 3 issues` AND no parallel agents), which both condition matrices list as a non-activating row. **A missing `repoRoot` SKIPS the emit with a stderr WARN** rather than falling through to `SO_PROJECT_DIR` — that is the wave-1 incident of this session (a probe with an unexported var wrote a synthetic record into the real fleet ledger) made structurally impossible; a regression test reproduces it. `events.mjs` is imported lazily so `config.mjs`'s 48-file import graph does not gain `platform.mjs`, which runs filesystem walk-ups at module load |
|
|
82
|
+
| `orchestrator.foreign_dispatch.completed` | `scripts/lib/wave-executor/foreign-dispatch.mjs` (`dispatchForeign`, via `emitEvent(..., {repoRoot})` + `sessionAttribution(repoRoot)`) | once per foreign-model dispatch (#1150) — the replacement for `SubagentStop` telemetry, which cannot fire for a Bash-spawned `cursor-agent` child (no hook in the chain sees it). **Payload:** `model`, `role`, `ok`, `exit_code`, `timed_out`, `duration_s`, `changed_files` (count, tracked-modified ∪ untracked-new — `git diff` alone is blind to new files), `reason` (present on every refusal — `never-foreign-role`, `empty-diff`, `channel-unavailable`, `unsafe-*` — and on the failure classes of a completed run, so no failure class is reasonless), `hook_tampering` (tri-state: `true` = the child repointed/rewrote the shared `.git` hooks path, invalidates the run regardless of `ok`; `false` = fingerprint matched; absent/`null` = not measured, never read as clean), plus `session_id`/`semantic_session_id` via `sessionAttribution` (omitted, never fabricated, without a readable `session.lock`). Emitted on refusals too (`ok:false`), so a blocked dispatch is a record, not a silence |
|
|
83
|
+
| `orchestrator.wave_dispatch.scope_checked` | `hooks/pre-task-scope-disjoint.mjs` (name const `SCOPE_EVENT`; built by `decide()` as `verdict.telemetry`, emitted in `main()` via `emitEvent(..., {repoRoot: projectDir})` + `sessionAttribution(projectDir)`) | PreToolUse `Agent` — **once per dispatch DECISION** (#1092), awaited BEFORE the terminal `emitAllow`/`emitDeny`/`emitWarn`, all of which `process.exit()` and would discard a pending append. **Payload:** `hook`, `agent_id` (the coordinator's `description` + `subagent_type`, clamped to 120 chars), `declared_path_count`, `injected` (a `FILE-SCOPE` declaration was found AND at least one path survived parsing), `shape` (`fenced` \| `inline` \| `none` — which PARSER won, deliberately not a second spelling of `signal`: a fenced block whose lines are prose is `signal: 'unparseable', shape: 'none'`), `signal` (`marker-absent` \| `unparseable` \| `extracted` — the row-5-vs-row-6 distinction of the hook's error-class matrix), `ledger_result` (`no-scope` \| `allow` \| `allow-finished` \| `deny` \| `warn-ledger-corrupt` \| `warn-not-evaluable`), `collision_count` (collisions involving THIS dispatch, live or already-finished — `ledger_result` says which). **Optional, absent-is-not-zero:** `wave` (the number out of `waveKeyOf()`'s `w<N>` segment — **omitted, never `0`**, under the `<session>|w?|?` fallback, same contract as `quality_gate`'s `wave_number`), plus `session_id` / `semantic_session_id` via `sessionAttribution` — omitted, never fabricated, without a readable `session.lock`. **What it proves and what it does not:** that the hook SAW (or did not see) a declaration in the prompt the coordinator handed to the dispatch tool, and what the guard decided — the SEND side. It proves nothing about the block reaching the agent's context or the agent reading it; that receive-side half of #1092 stays open for want of a platform prompt-assembly boundary (`docs/scope-collision-guard.md` § 4.2). **No prompt body and no declared path is in the payload** (issue #1092 acceptance criterion 3) — counts and closed enums only, because this record also travels over the optional Clank webhook with no redaction. Rows 1–4 of the matrix emit nothing (no decision was made) and neither do the two crash rows 2/12 — a hook that fell over cannot describe itself, which is what the `GUARD INACTIVE` stderr banner is for |
|
|
72
84
|
|
|
73
85
|
Non-orchestrator names still present in the stream: `tmux-layout.{invoked,completed,degraded}`
|
|
74
|
-
(tmux-layout skill) and `stagnation_detected`.
|
|
75
|
-
the
|
|
86
|
+
(tmux-layout skill) and `stagnation_detected`. The latter keeps its legacy bare name
|
|
87
|
+
deliberately — it predates the `orchestrator.` convention, and renaming it in the same change
|
|
88
|
+
that wired its first producer would have stacked a breaking stream change on top of a behaviour
|
|
89
|
+
change (#1114). (`grounding_injected` WAS migrated, to the dotted
|
|
90
|
+
`orchestrator.grounding.injected` in #611 — see the catalog above.)
|
|
91
|
+
|
|
92
|
+
### `stagnation_detected` — two producers, one schema (#1114)
|
|
93
|
+
|
|
94
|
+
| Producer | `source` | Trigger |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| Coordinator prose, `skills/wave-executor/wave-loop.md` § "Review Agent Outputs" | `"coordinator"` | post-wave review, per agent, when a stagnation pattern fires. Routed through `scripts/emit-event.mjs` / `emitEvent()` like every other event — the hand-rolled `>>` append it used to prescribe is gone |
|
|
97
|
+
| `scripts/lib/wave-transcript-tail.mjs`, run as the `wave-transcript-tail` monitor (`monitors/monitors.json`, `when: on-skill-invoke:wave-executor`) | `"tail"` | live, while the wave runs: tails the own session's subagent transcripts and matches three executable regexes |
|
|
98
|
+
|
|
99
|
+
**Payload:** `session` (the **SEMANTIC** session id — the value that matches
|
|
100
|
+
`sessions.jsonl.session_id`, e.g. `main-2026-08-24-session-1`, measured 2026-08-25; this is the
|
|
101
|
+
join key `skills/session-end/metrics-collection.md` filters on, `.session == $sid` where `$sid` is
|
|
102
|
+
`$SESSION_ID`. A raw UUID here would join to nothing. The `session_id` / `semantic_session_id` pair
|
|
103
|
+
that `sessionAttribution()` contributes is additive provenance beside it, and is OMITTED rather
|
|
104
|
+
than fabricated when no `session.lock` is readable — so `session` is the field consumers rely on),
|
|
105
|
+
`wave`, `agent`,
|
|
106
|
+
`pattern`, `source`, `file` (project-relative path, or `null` when not applicable), `occurrences`.
|
|
107
|
+
`pattern` ∈ `pagination-spiral` | `turn-key-repetition` | `error-echo` | `psa007-git-write` |
|
|
108
|
+
`status-partial` — the first two are coordinator-only, the last two tail-only, and `error-echo` is
|
|
109
|
+
produced by BOTH, so `source` is the only thing that tells those two records apart. `source` ∈
|
|
110
|
+
`coordinator` | `tail` and is **additive**: a consumer that does not read it behaves unchanged.
|
|
111
|
+
**Optional, absent-is-not-zero:** `error_class` (`edit-format-friction` | `scope-denied` |
|
|
112
|
+
`command-blocked` | `other`) is present on `error-echo` ONLY and omitted for every other pattern —
|
|
113
|
+
an absent field means "no class applies", never `"other"`. **`occurrences` is an
|
|
114
|
+
AGGREGATION-WINDOW counter, not a fixed value.** Monitor output is rate-limited, so a key emits
|
|
115
|
+
once when it reaches its threshold (`3` for `error-echo` and the two coordinator threshold
|
|
116
|
+
patterns, `1` for the single-shot `psa007-git-write` / `status-partial`) and then RE-EMITS only
|
|
117
|
+
every further `RE_EMIT_EVERY = 10` hits, carrying the running total. A single-shot detector that
|
|
118
|
+
fires 21 times therefore produces `occurrences` `1`, `11`, `21` — three records, pinned by the
|
|
119
|
+
`toEqual([1, 11, 21])` assertion in `tests/lib/wave-transcript-tail.test.mjs`. Read a value above the threshold as "this many hits
|
|
120
|
+
so far in this window", never as "this many new hits". `agent_id` (tail-produced
|
|
121
|
+
records only, additive): the per-dispatch agent id from the transcript filename — needed because
|
|
122
|
+
`agent` carries the agent TYPE and a wave routinely runs several agents of one type (measured
|
|
123
|
+
2026-08-25: 2 `Explore` in one wave), so restart-dedup keyed on type alone would suppress a
|
|
124
|
+
sibling's genuine first finding. Coordinator-produced records omit it.
|
|
125
|
+
|
|
126
|
+
**Measured before wiring — read as history, not as state.** 0 records in this repo's 30 785-line
|
|
127
|
+
ledger (`grep -c '"stagnation_detected"' .orchestrator/metrics/events.jsonl` → 0, exit 1;
|
|
128
|
+
2026-08-25 @ `ebce189`), and 0 across 97 816 event lines in 19 repos (asserted, source W1/D3
|
|
129
|
+
2026-08-25 — re-measure before citing it further). That zero is the denominator the wiring is
|
|
130
|
+
judged against (`.claude/rules/host-resources.md` § HR-105: a class at 0% is either genuinely rare
|
|
131
|
+
or silently broken, and the two look identical from outside). Until #1114 the event had no
|
|
132
|
+
producer at all — its only mechanical reference across `scripts/ hooks/ commands/` was a READER,
|
|
133
|
+
`scripts/compute-grounding-injection.sh:88`, which filters on `error_class` + `file` and degrades
|
|
134
|
+
correctly on records carrying neither. If the ledger still reads 0 after the tailer has observed
|
|
135
|
+
five waves, that is a finding about the repo, not about the emitter.
|
|
136
|
+
|
|
137
|
+
**Dated caveat — records written before `FIXTURE_CONTEXT_RE` are not reproducible.** The
|
|
138
|
+
`psa007-git-write` record emitted 2026-08-25 ~05:01 was a false positive: a sibling agent seeding a
|
|
139
|
+
test fixture in a `mktemp -d` scratch repo, whose index is not the shared one PSA-007 protects.
|
|
140
|
+
`isGitWrite()` gained the fixture-context exclusion (`scripts/lib/wave-transcript-tail.mjs`) after
|
|
141
|
+
that record was written, so replaying the same transcript against today's detector yields no hit.
|
|
142
|
+
Read pre-exclusion `psa007-git-write` records as artefacts of the older detector — the ledger line
|
|
143
|
+
stands as history and is not retracted, but it is not evidence about current behaviour. The same
|
|
144
|
+
five 2026-08-25 04:59–05:01 records also carry the RAW session uuid in `session` (a bare
|
|
145
|
+
`06461b1a-…` where the semantic `main-2026-08-25-session-N` belongs), which is the pre-fix shape —
|
|
146
|
+
they join to no `sessions.jsonl` row and will not appear in a per-session roll-up.
|
|
76
147
|
|
|
77
148
|
## Consumers
|
|
78
149
|
|
|
79
150
|
- `scripts/lib/convergence-monitor.mjs` — tails events.jsonl; reads `event_type ?? event`.
|
|
80
151
|
- `scripts/lib/tmux-layout/telemetry-stats.mjs` — filters `event.startsWith('tmux-layout.')`.
|
|
81
152
|
- `scripts/lib/events-rotation.mjs` — size-based archival.
|
|
82
|
-
- `skills/session-end/metrics-collection.md` — jq roll-ups (incl. `orchestrator.grounding.injected`).
|
|
153
|
+
- `skills/session-end/metrics-collection.md` — jq roll-ups (incl. `orchestrator.grounding.injected` and `stagnation_detected` → `stagnation_events`).
|
|
154
|
+
- `scripts/compute-grounding-injection.sh` — filters `stagnation_detected` on `error_class` + `file` to build each agent's edit-friction history; ignores records carrying neither (which is every `psa007-git-write` / `status-partial` record).
|
|
83
155
|
|
|
84
156
|
Renaming an existing event name is a breaking change to the stream + these
|
|
85
157
|
consumers — verify with `grep` (PSA-006) and update consumers in lockstep.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# GitHub-Mirror Branch Protection — Runbook
|
|
2
|
+
|
|
3
|
+
GitLab issue #1079.
|
|
4
|
+
|
|
5
|
+
## Why this matters — a mirror push IS a deploy here
|
|
6
|
+
|
|
7
|
+
`.claude/rules/security.md` § "Session Config Command Trust" argues that
|
|
8
|
+
commit-gated files (`test-command`, `custom-phases[].command`, …) are
|
|
9
|
+
trustworthy **because** every change lands in `git log` and passes review
|
|
10
|
+
before it can run. That argument depends on one premise: the path that
|
|
11
|
+
**deploys** the change is the same path that **reviews** it.
|
|
12
|
+
|
|
13
|
+
For this repo, that premise is broken on the GitHub mirror:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
$ git remote -v
|
|
17
|
+
origin git@<gitlab-host>:<group>/session-orchestrator.git # reviewed via MR
|
|
18
|
+
github https://github.com/Kanevry/session-orchestrator.git # pushed directly
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- `origin` (GitLab) is the review path: every change lands via a Merge Request.
|
|
22
|
+
- `github` (the mirror) is pushed **directly** by `git push github HEAD` in
|
|
23
|
+
`skills/session-end/SKILL.md` (see the `github-mirror-push` block, roughly
|
|
24
|
+
lines 781–825) — no MR, no review, no gate. Any session's `/close` does this
|
|
25
|
+
the moment a `github` remote exists (the block does not actually re-check the
|
|
26
|
+
`mirror: github` Session Config key at runtime, only the remote's presence).
|
|
27
|
+
- The GitHub mirror's `main` branch is what a **Vercel Git integration**
|
|
28
|
+
deploys from. `vercel.json` in this repo sets security headers (CSP, HSTS)
|
|
29
|
+
and redirects. So a push to `github`'s `main` is not "just a mirror update"
|
|
30
|
+
— it is a production deploy of the public website, with no review gate in
|
|
31
|
+
between.
|
|
32
|
+
|
|
33
|
+
A compromised mirror token, or any admin-scoped push, can therefore:
|
|
34
|
+
- Remove `default-src 'self'` from the CSP — the mechanical guard behind the
|
|
35
|
+
"we make zero external requests" claim in the privacy policy, itself hosted
|
|
36
|
+
on the same domain.
|
|
37
|
+
- Remove HSTS.
|
|
38
|
+
- Add a wildcard redirect (`/(.*)`) to an arbitrary foreign domain.
|
|
39
|
+
- Serve arbitrary HTML under the domain that also hosts the Impressum and
|
|
40
|
+
Datenschutzerklärung (German legal-notice pages).
|
|
41
|
+
|
|
42
|
+
That is an escalation surface, not "just some stale mirror content".
|
|
43
|
+
|
|
44
|
+
## Current measured state (2026-08-28 @ `70a1ca9`, read-only `gh api`)
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"repo": "Kanevry/session-orchestrator",
|
|
49
|
+
"branch": "main",
|
|
50
|
+
"enforce_admins": false,
|
|
51
|
+
"required_status_checks": { "strict": true, "contexts": ["test (ubuntu-latest)", "test (macos-latest)", "security"] },
|
|
52
|
+
"required_pull_request_reviews": false,
|
|
53
|
+
"allow_force_pushes": false,
|
|
54
|
+
"token_scopes": ["admin:public_key", "gist", "read:org", "repo", "workflow"],
|
|
55
|
+
"findings": [
|
|
56
|
+
{ "id": "enforce-admins-disabled", "severity": "high", "message": "enforce_admins is false — an admin-scoped push (or token) bypasses required_status_checks entirely." },
|
|
57
|
+
{ "id": "token-scope-too-broad", "severity": "medium", "message": "Token scope(s) admin:public_key, repo exceed what a mirror push needs (fine-grained contents:write on this one repo)." }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Reproduce with `node scripts/github-protection-audit.mjs` (see § Running the audit below).
|
|
63
|
+
|
|
64
|
+
Reading this: required status checks ARE configured (3 contexts, `strict:
|
|
65
|
+
true`) and force pushes are already disallowed — those two controls are
|
|
66
|
+
sound. The gap is `enforce_admins: false`: **the checks above only bind
|
|
67
|
+
non-admin pushes.** The mirror push in `skills/session-end/SKILL.md` runs
|
|
68
|
+
under the same `gh`/git identity that authenticated the CLI — whatever scope
|
|
69
|
+
that identity's token carries, an admin-scoped credential sails straight past
|
|
70
|
+
`required_status_checks` because `enforce_admins` is off. Combined with the
|
|
71
|
+
current token scopes (`repo` is full read/write across every repo the
|
|
72
|
+
account can see, not scoped to this one), the blast radius of a leaked token
|
|
73
|
+
is "push anything to `main`, unreviewed, checks or no checks."
|
|
74
|
+
|
|
75
|
+
**Operator decision for this session (2026-08-28):** audit + runbook only.
|
|
76
|
+
`enforce_admins` stays `false` and the token is not rotated or narrowed in
|
|
77
|
+
this session — see § Required order below for why flipping it first would be
|
|
78
|
+
actively harmful, and do these steps in order, not this one alone.
|
|
79
|
+
|
|
80
|
+
## Required order — do not skip ahead
|
|
81
|
+
|
|
82
|
+
Flipping `enforce_admins: true` **before** fixing the push path is a
|
|
83
|
+
self-lockout: the mirror push in `skills/session-end/SKILL.md` runs
|
|
84
|
+
`git push github HEAD` directly against `main` under an admin-scoped
|
|
85
|
+
identity. With `enforce_admins: true` and `required_status_checks` set, that
|
|
86
|
+
push would need `main`'s HEAD to already carry a green status check for the
|
|
87
|
+
exact SHA being pushed — which a bare `git push` from a local mirror step can
|
|
88
|
+
never satisfy (there is no PR, so no check run is ever attached to that SHA
|
|
89
|
+
before the push happens). The next `/close` would fail outright.
|
|
90
|
+
|
|
91
|
+
So the order is load-bearing, not a suggestion:
|
|
92
|
+
|
|
93
|
+
### Step 1 (repo change, reviewable via MR) — stop pushing straight to protected `main`
|
|
94
|
+
|
|
95
|
+
Change the `github-mirror-push` block in `skills/session-end/SKILL.md` so it
|
|
96
|
+
no longer writes `main` directly. Two options, either is acceptable:
|
|
97
|
+
|
|
98
|
+
- **Option A — branch + PR.** Push to `mirror/<date>` (or `mirror/<session-id>`)
|
|
99
|
+
instead of `main`, then open (or auto-merge, if the repo's protection allows
|
|
100
|
+
it later) a PR. Keeps the mirror push mechanism simple; adds a PR per close.
|
|
101
|
+
- **Option B — deploy-key / bot identity with an explicit admin bypass.** Use a
|
|
102
|
+
dedicated deploy key or GitHub App installation scoped to `contents:write`
|
|
103
|
+
on this one repo, and add that bot identity to the branch protection's
|
|
104
|
+
"allow specified actors to bypass required pull requests" list (GitHub
|
|
105
|
+
Enterprise/Team feature — verify plan tier supports it before committing to
|
|
106
|
+
this path). Keeps the direct-push mechanism but makes the bypass an
|
|
107
|
+
explicit, auditable allowlist entry instead of "any admin token".
|
|
108
|
+
|
|
109
|
+
This step is a normal code change — it goes through the GitLab MR review path
|
|
110
|
+
like everything else in this repo. It is **not** a `gh api` operator action.
|
|
111
|
+
|
|
112
|
+
### Step 2 (operator action) — narrow the token
|
|
113
|
+
|
|
114
|
+
Once Step 1 lands (the push path no longer needs a broad admin-capable
|
|
115
|
+
token), narrow what actually authenticates the mirror push:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# operator action — create a fine-grained PAT
|
|
119
|
+
# 1. https://github.com/settings/tokens?type=beta
|
|
120
|
+
# 2. Resource owner: Kanevry (or the account that owns the mirror)
|
|
121
|
+
# 3. Repository access: "Only select repositories" → session-orchestrator
|
|
122
|
+
# 4. Permissions: Contents → Read and write (this is the ONLY permission the
|
|
123
|
+
# mirror push needs). Leave every other permission at "No access".
|
|
124
|
+
# 5. Set an expiration (this repo's Vercel-deploy blast radius argues for a
|
|
125
|
+
# short one, e.g. 90 days, with a calendar reminder to rotate).
|
|
126
|
+
|
|
127
|
+
# operator action — verify the new token's actual scope before swapping it in
|
|
128
|
+
gh auth status --hostname github.com
|
|
129
|
+
|
|
130
|
+
# operator action — re-point the local git credential helper / gh auth login
|
|
131
|
+
# at the new token, then re-run the audit script to confirm the scope finding
|
|
132
|
+
# clears:
|
|
133
|
+
node scripts/github-protection-audit.mjs
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A fine-grained PAT reports differently from a classic PAT under
|
|
137
|
+
`gh auth status` (no bracketed scope list — fine-grained tokens carry their
|
|
138
|
+
permissions server-side, not as a local scope string). If `token_scopes`
|
|
139
|
+
comes back empty for a fine-grained token, treat that as expected, not as a
|
|
140
|
+
`parse-error` — the audit script's `token-scope-too-broad` finding is
|
|
141
|
+
evaluated against the classic-scope list (`UNSAFE_TOKEN_SCOPES` in
|
|
142
|
+
`scripts/github-protection-audit.mjs`) and does not currently classify
|
|
143
|
+
fine-grained-token permissions; a human check of the token's configured
|
|
144
|
+
permissions (step 4 above) is still required after this step.
|
|
145
|
+
|
|
146
|
+
### Step 3 (operator action) — only now enable `enforce_admins`
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# operator action — flip enforce_admins on, now that step 1 removed the
|
|
150
|
+
# only path that needed to bypass it and step 2 has already narrowed the
|
|
151
|
+
# credential that authenticates any remaining admin-scoped access.
|
|
152
|
+
gh api -X POST repos/Kanevry/session-orchestrator/branches/main/protection/enforce_admins
|
|
153
|
+
|
|
154
|
+
# operator action — verify
|
|
155
|
+
node scripts/github-protection-audit.mjs
|
|
156
|
+
# expect: "enforce_admins": true, and the "enforce-admins-disabled" finding
|
|
157
|
+
# gone from the findings array.
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Do not run this command until Steps 1 and 2 are both confirmed — that is the
|
|
161
|
+
entire point of the ordering above.
|
|
162
|
+
|
|
163
|
+
## Running the audit
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
node scripts/github-protection-audit.mjs
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Read-only. Never calls `gh api -X PUT/PATCH/DELETE`, never touches the token.
|
|
170
|
+
Prints exactly one JSON object on stdout:
|
|
171
|
+
|
|
172
|
+
- **Success** (branch protection was measured, whether or not it found
|
|
173
|
+
anything to flag): the full envelope — `repo`, `branch`, `enforce_admins`,
|
|
174
|
+
`required_status_checks`, `required_pull_request_reviews`,
|
|
175
|
+
`allow_force_pushes`, `token_scopes`, and a `findings` array (may be empty
|
|
176
|
+
— an empty array means "measured and clean", not "did not check"). Exit
|
|
177
|
+
code `0`.
|
|
178
|
+
- **Degraded** (the measurement itself could not be taken — `gh` missing, not
|
|
179
|
+
authenticated, timed out, or a query error): `{ "degraded": "<reason>",
|
|
180
|
+
"message": "..." }`. Exit code `2`. **Never read a degraded result as
|
|
181
|
+
"clean" — it means the audit could not run, not that nothing was found.**
|
|
182
|
+
`degraded` reasons: `no-github-remote`, `cli-missing`, `timeout`,
|
|
183
|
+
`auth-error`, `parse-error`, `query-failed`.
|
|
184
|
+
|
|
185
|
+
Findings the script currently emits:
|
|
186
|
+
|
|
187
|
+
| id | severity | fires when |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `branch-not-protected` | critical | the branch has NO protection configured at all (HTTP 404 from the protection endpoint) |
|
|
190
|
+
| `enforce-admins-disabled` | high | `enforce_admins.enabled` is `false` |
|
|
191
|
+
| `token-scope-too-broad` | medium | the authenticated token carries a classic scope broader than a mirror push needs (`repo`, `admin:org`, `admin:repo_hook`, `admin:public_key`, `admin:enterprise`, `delete_repo`, `site_admin`) |
|
|
192
|
+
| `no-required-status-checks` | medium | `required_status_checks` is absent or configured with zero contexts |
|
|
193
|
+
|
|
194
|
+
Run it any time you change branch protection, rotate the mirror token, or
|
|
195
|
+
touch the `github-mirror-push` block in `skills/session-end/SKILL.md` — it is
|
|
196
|
+
the fastest way to confirm a change had the intended effect without hand
|
|
197
|
+
re-deriving the `gh api` output.
|
package/docs/pi-setup.md
CHANGED
|
@@ -18,6 +18,8 @@ pi install npm:session-orchestrator
|
|
|
18
18
|
|
|
19
19
|
This is the primary install path — the package is published to npm (since v3.16.0, 2026-07-19) and carries the `pi-package` keyword for the [Pi packages gallery](https://pi.dev/packages) index; no local checkout required. The dev-fallback options below remain available for working against a local checkout.
|
|
20
20
|
|
|
21
|
+
The short git form documented for Pi packages (`pi install git:github.com/user/repo`) is **unverified against this repo as of 2026-08-28** — the `pi` CLI was not installed on the verifying host (`which pi` → not found), so no install attempt could be made. The npm form above remains the only measured, documented install path; do not elevate the `git:` short form to this guide or to README until it has been run against this repo and the result dated.
|
|
22
|
+
|
|
21
23
|
### Option 2: Project-local registration (dev fallback)
|
|
22
24
|
|
|
23
25
|
```bash
|
package/docs/rule-authoring.md
CHANGED
|
@@ -118,7 +118,9 @@ Before `syncRules()` writes a source file into a consumer repo's `.claude/rules/
|
|
|
118
118
|
| `zero-match-globs` | warn | A `globs:` pattern matching 0 files in the target repo's tracked file list (`git ls-files`, falling back to a directory walk). Legitimately possible in a freshly-scaffolded repo. |
|
|
119
119
|
| `foreign-glob` | warn | A glob segment carrying a PascalCase, product-like token (regex `[A-Z][a-z]+[A-Z]`, e.g. `WalkAITalkieTests`) — a likely copy-paste leftover from another project's rule scope. |
|
|
120
120
|
|
|
121
|
-
Error-severity violations skip the write for that file (recorded in `syncRules()`'s `errors[]`); warn-severity violations do not block the write and are recorded additively in `warnings[]`.
|
|
121
|
+
Error-severity violations skip the write for that file (recorded in `syncRules()`'s `errors[]`); warn-severity violations do not block the write and are recorded additively in `warnings[]`. Since #1098 the envelope also carries `sanitizer[]` — `{file, line, kind, text}` records from `scanVendoringLeaks()` for plugin-internal citations that resolve to a real file under the plugin root (`scripts/…`, `hooks/…`, `skills/…`, `docs/…`, `tests/…`) and for `See Also` references that name no entry in `rules/_index.md`. The sanitizer is report-only: it never rewrites content and never contributes to `errors[]`, so a finding changes neither the write decision nor the exit code — a human decides whether the citation is a leak. `rules-sync.mjs`'s CLI additionally prints each finding to **stderr** as `rules-sync: sanitizer <kind> <file>:<line> — <text>`, so the report reaches an operator who never parses the JSON envelope.
|
|
122
|
+
|
|
123
|
+
`scanVendoringLeaks()` itself lives in `scripts/lib/validate-vendored-rules.mjs` (it has the validator's shape — judge one rule file, return findings); `rules-sync.mjs` re-exports it for importers that predate the move. The standalone CLI (`node scripts/lib/validate-vendored-rules.mjs --dir <rulesDir> [--target-root <repo>] [--plugin-root <dir>] [--require-provenance] [--json] [--mode hard|warn]`) exits `0` (no errors, or errors under `--mode warn`), `1` (errors present under `--mode hard`), or `2` (invocation error). Passing `--plugin-root <dir>` turns on the same sanitizer scan there: findings appear under a `sanitizer` key in `--json` mode and as `validate-vendored-rules: sanitizer <kind> <file>:<line> — <text>` stderr lines otherwise. The `unresolvable-see-also` half needs `<pluginRoot>/rules/_index.md` to be readable; when it is not, only the `repo-local-path` check runs. Report-only there too — `sanitizer[]` never moves the exit code.
|
|
122
124
|
|
|
123
125
|
### Archetype-scoped manifest tags (issue #722 Epic A Wave 3)
|
|
124
126
|
|
|
@@ -37,6 +37,31 @@ Steps 1, 2, 3, 4 and the `--assert-subset` assertion all read the *same* file, a
|
|
|
37
37
|
|
|
38
38
|
The coordinator's **own** planned direct edits belong in `coordinator.json` in the identical form. They are not dispatches, so the hook can never see them (§ 6); the CLI check is the only gate that covers them.
|
|
39
39
|
|
|
40
|
+
### 2.3 The manifest names its writer (#1123)
|
|
41
|
+
|
|
42
|
+
`wave-scope.json` is written into the WORKING COPY, not into the session — so until #1123 one session's manifest governed every session sharing that checkout. Measured 2026-08-22 (#1082): a Discovery wave's `allowedPaths: []` — which `wave-loop.md` prescribes for *every* Discovery wave — denied every write of an unrelated parallel session, with a deny reason that could only tell it to fix a wave plan it does not own.
|
|
43
|
+
|
|
44
|
+
Two OPTIONAL manifest fields close that: `session` (the raw `session_id`) and its human-readable twin `semantic_session`. Both come from ONE `sessionAttribution(repoRoot)` call (`scripts/lib/events.mjs`, reads `.orchestrator/session.lock` once) in the same coordinator step that writes the rest of the manifest — `skills/wave-executor/wave-loop.md` § Scope Manifest 1.
|
|
45
|
+
|
|
46
|
+
The reader is `hooks/enforce-scope.mjs` **Gate 3b**, between the manifest parse (G3) and the path-guard gate (G4). It resolves identity via `readOwnSessionIds(projectRoot, { hookInput: input })` and classifies via `classifyManifestSession(scope, ownIds)`, both from [`scripts/lib/session-identity/own-session.mjs`](../scripts/lib/session-identity/own-session.mjs):
|
|
47
|
+
|
|
48
|
+
| Manifest state | `classifyManifestSession` verdict | Gate 3b disposition |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| no `session` / `semantic_session` (legacy, pre-#1123) | `unknown` | ENFORCE — falls through unchanged |
|
|
51
|
+
| an id present and matching one of our own | `own` | ENFORCE — falls through unchanged |
|
|
52
|
+
| ids present, none matching, own identity resolvable | `foreign` | **ALLOW** + one `orchestrator.scope.foreign_session_ignored` event |
|
|
53
|
+
| ids present, own identity unresolvable (empty id set) | `unknown` | ENFORCE |
|
|
54
|
+
|
|
55
|
+
Five properties are choices, not omissions — and every one of them points the fail-**closed** way, the deliberate inverse of § 4.1's posture for the dispatch hook:
|
|
56
|
+
|
|
57
|
+
- **Only what is PROVABLY foreign is foreign.** `readOwnSessionIds()` returns the **UNION** of three sources — hook input (`session_id`/`sessionId`/`parent_session_id`) ∪ `CLAUDE_CODE_SESSION_ID` ∪ `session.lock` (`session_id`/`semantic_session_id`) — and an EMPTY set when none yields an id, which can only produce `unknown`. A gate that guessed would turn "cannot tell" into a silent enforcement-off on every harness exporting no session id. Every value is trimmed on the way in, so a whitespace-only env var cannot enter as a phantom id that matches nothing (`.claude/rules/development.md` § env-var whitespace trap).
|
|
58
|
+
- **Union, not first-tier-wins.** Any id the process can legitimately claim names this session; only an id in NO source is somebody else's. Gating the sources made the READER's identity a strict subset of the WRITER's — the manifest's `session` comes from `sessionAttribution()` = the same repo-global lock — and three divergences all produced the same silent failure, the OWN manifest read `foreign` and the write gate switched itself off for the whole wave, logging an event indistinguishable from correct behaviour: (a) a nested harness where payload `session_id` ≠ `CLAUDE_CODE_SESSION_ID` (measured in `hooks/pre-bash-issue-budget.mjs` `resolveSessionId`); (b) a sub-agent invocation, whose own id is the subagent's while the manifest names the coordinator; (c) a session that lost the lock race and therefore wrote a PEER's id into its own manifest (see the writer guard below). The union only ADDS ids the process actually carries, so the security direction is unchanged: a manifest whose id appears in no source still classifies `foreign`. Its cost is named and points fail-**closed** — a peer-owned lock makes us enforce a peer's wave plan, which is a visible deny rather than a silent enforcement-off.
|
|
59
|
+
- **The writer verifies the binding names itself.** Because the lock is repo-global, `skills/wave-executor/wave-loop.md` § Scope Manifest 1 requires the coordinator to compare `sessionAttribution()`'s ids against its own session (STATE.md `session`) and to OMIT the `session`/`semantic_session` keys when they diverge — unbound = ENFORCE. The reader's union covers the case anyway; the writer guard keeps the manifest readable as an audit record instead of publishing a foreign name.
|
|
60
|
+
- **Gate 3b runs after the parse, never on the raw bytes.** A corrupt manifest yields `{}`, hence no ids, hence `unknown` — and keeps failing closed. A gate that peeked at the bytes first would let a truncated manifest disarm the guard.
|
|
61
|
+
- **The empty string is a validator ERROR, not a third flavour of absent.** `validateSession()` → `validateOptionalSessionId()` in `scripts/validate-wave-scope.mjs` rejects `"session": ""` with *"an empty id attributes to nothing; omit the key entirely to declare the manifest unbound"*. An empty id satisfies a truthiness check while matching nobody, so every reader would classify the manifest FOREIGN where the writer meant UNBOUND — opposite dispositions, not a cosmetic ambiguity. An ABSENT key only WARNS, because the § 3.3 pre-union skeleton is itself an unbound manifest and so is every manifest written before #1123.
|
|
62
|
+
|
|
63
|
+
The event is what keeps the skip countable rather than silent: `orchestrator.scope.foreign_session_ignored` carries `hook`, `manifest`, `manifest_session`, `own_session`, `wave` and `file_path`. It is deliberately an event and not an `emitWarn` — the branch is hit on *every* Edit of the non-owning session, so a stderr line per write would be noise the operator learns to ignore.
|
|
64
|
+
|
|
40
65
|
## 3. The collision algorithm
|
|
41
66
|
|
|
42
67
|
`findScopeCollisions(agentScopes, { knownFiles })` compares every cross-agent entry pair through `classifyEntryCollision()`, in three binding stages:
|
|
@@ -104,6 +129,24 @@ Two structural rules keep this matrix honest, both recorded in the hook header:
|
|
|
104
129
|
- `decide()` is a **pure function returning a verdict**; the module emits exactly once, at the end. `emitWarn`/`emitDeny` call `process.exit(0)` and never return, so a warn emitted from inside the checking flow would terminate the process before a later collision could be denied — and would skip the lock's release `finally`.
|
|
105
130
|
- Row 9's discriminator is **not** `ok !== true`. `ok` means *disjoint*, so `ok === false` is the normal result of a real collision; "not evaluable" is `ok === false` with BOTH result arrays empty. Reading `ok` as evaluability would turn every genuine collision into a warn, i.e. an allow — the exact fail-open the hook exists to prevent.
|
|
106
131
|
|
|
132
|
+
### 4.2 Observability — one record per dispatch decision (#1092)
|
|
133
|
+
|
|
134
|
+
Every dispatch that reaches a verdict also appends one `orchestrator.wave_dispatch.scope_checked` record to `<projectDir>/.orchestrator/metrics/events.jsonl` (payload contract: [`docs/events-schema.md`](events-schema.md)). It is built inside `decide()` as `verdict.telemetry` and emitted by `main()` — awaited **before** the terminal `emitAllow`/`emitDeny`/`emitWarn`, each of which calls `process.exit()` and would discard a pending append — inside a `try/catch`, so a failed write can never change the verdict or the exit code.
|
|
135
|
+
|
|
136
|
+
Why it exists: the in-ledger `scopeSignals` counter (§ 4.1, rows 5/6) is a **wave tally**. It answers "how many dispatches of this wave carried a scope", never "which agent was dispatched unscoped" — and a wave in which the injection step was skipped entirely still produces a plausible-looking tally. The per-dispatch record makes the individual dispatch falsifiable (`.claude/rules/host-resources.md` § HR-105).
|
|
137
|
+
|
|
138
|
+
**What the event proves:** the hook SAW — or did not see — a `FILE-SCOPE` declaration in the prompt the coordinator handed to the dispatch tool, which declaration shape parsed it (`shape`), whether any path survived (`injected`, `declared_path_count`), and which matrix row decided (`ledger_result`, `collision_count`). That is the **send** side, measured at the PreToolUse boundary.
|
|
139
|
+
|
|
140
|
+
**What it does not prove:** that the block reached the agent's context, or that the model read it. Those are **receive**-side facts, and this hook observes the tool payload, not the assembled prompt. A record reading `injected: true` is therefore evidence about the coordinator's dispatch, never about the agent's obedience — reading it as the latter rebuilds exactly the false confidence § 2.2 warns about. The counterpart signal on the receive side remains the agent's own behaviour (`enforce-scope` at write time) and the W5 verification pass.
|
|
141
|
+
|
|
142
|
+
**Payload discipline.** Counts and closed enums only, plus `agent_id` (the coordinator's own dispatch description, clamped) and the optional session attribution: no prompt body, no declared path, no glob. Issue #1092's acceptance criterion 3 is the rule, and the reason is concrete — this record also travels over the optional Clank Event-Bus webhook with no redaction, and paths under `01-projects/` carry private project slugs.
|
|
143
|
+
|
|
144
|
+
**Revisit-Trigger** (verbatim from issue #1092, for the transport half this section deliberately does NOT close):
|
|
145
|
+
|
|
146
|
+
> Implement when the platform exposes a stable prompt-assembly hook or when a coordinator-owned digest event can be proven against the real dispatched transcript.
|
|
147
|
+
|
|
148
|
+
Until then the issue's remaining acceptance criteria — a scope-file **digest** proven to have reached the prompt assembler, and an omitted/malformed injection turning an end-to-end probe red while materializer, disjointness, union and subset checks stay green — are not satisfiable by any mechanism inside this repo, because no observable boundary carries the final prompt.
|
|
149
|
+
|
|
107
150
|
## 5. The liveness probe
|
|
108
151
|
|
|
109
152
|
A ledger with no notion of completion denies the wrong thing. Measured over 38 archived transcripts of this repo (346 `Agent` dispatch blocks; hook header, 2026-08-14): 0 of 4 same-batch overlaps and **2 of 2 cross-dispatch overlaps** would have been denied — and both cross-dispatch pairs were legitimate **sequential repair passes** (a dispatch and its later fix). Because a deny deliberately does not persist the ledger, the re-dispatch would have met the same stale record: a permanent block.
|
|
@@ -129,6 +172,9 @@ Complete list of what this guard does **not** see, or sees only approximately:
|
|
|
129
172
|
6. **Glob ∩ glob without witnesses.** Stage 3a needs tracked files; with git unavailable (row 8) or for files not yet on disk, only stage 3b's prefix fallback carries the load — and it requires at least one recursive entry, so two non-recursive globs that intersect only in an unborn file are not detected.
|
|
130
173
|
7. **Only collisions involving the current dispatch are actionable.** A pair among already-dispatched agents was either denied at its own dispatch or predates the guard; re-denying it would block an innocent third agent.
|
|
131
174
|
8. **Lock loss reopens the race.** On lock timeout the cycle runs unlocked (row 14) — two dispatches starting together can then read the same ledger state and one record is lost. That is the pre-lock behaviour, chosen over denying on a lock-file problem.
|
|
175
|
+
9. **The session binding is self-declared** (§ 2.3). `session` is a plain field in a file any process in this working copy can write, so writing a foreign id into it turns the write gate off for that manifest. Named rather than hidden: it is the SAME power `enforcement: "off"` already grants in the same file, so Gate 3b adds no new authority — the manifest is the coordinator's own artefact either way.
|
|
176
|
+
10. **Only the WRITE gate is session-bound.** The dispatch ledger of § 4 takes its session component from the harness's own `input.session_id` (`waveKeyOf(projectDir, sessionId, …)`), and reads only `wave` and `role` out of `wave-scope.json` — the `session` field is not consulted there at all. So a peer session's manifest cannot bind this session's writes since #1123, but the two hooks reach that property by different routes, and a change to one does not carry to the other.
|
|
177
|
+
11. **A peer-owned lock costs enforcement precision, deliberately.** `readOwnSessionIds()` unions the `session.lock` ids in (§ 2.3), and the lock is repo-global — so while a peer holds it, that peer's manifest classifies `own` here and its `allowedPaths` bind this session's writes: the #1082 shape, but now as a visible deny naming a wave plan we do not own. The alternative (gating the lock behind the payload) fails the other way and silently, because the manifest's own `session` is written from that same lock. Bounded by the writer guard in `skills/wave-executor/wave-loop.md` § Scope Manifest 1 (omit rather than write a foreign id) and by `session.lock` heartbeat-liveness. Revisit if a manifest ever carries the writer's PROCESS identity alongside the lock-derived one, which would let the reader distinguish the two cases instead of unioning them.
|
|
132
178
|
|
|
133
179
|
## 7. Debugging
|
|
134
180
|
|
|
@@ -138,7 +184,7 @@ Complete list of what this guard does **not** see, or sees only approximately:
|
|
|
138
184
|
- The other agent has finished, but the ledger still binds it → the transcript carried no evidence (§ 5) and you are inside the TTL window. Delete `.orchestrator/wave-dispatch-scopes.json`; the next dispatch rebuilds it.
|
|
139
185
|
- The `waveKey` names an older wave → the `<session>|w?|?` fallback (limit 5). Check that `<state-dir>/wave-scope.json` exists and is readable, then delete the ledger.
|
|
140
186
|
|
|
141
|
-
**A dispatch was NOT denied and should have been.**
|
|
187
|
+
**A dispatch was NOT denied and should have been.** Start at the ledger line, which now says which row fired without re-running anything: `jq -c 'select(.event=="orchestrator.wave_dispatch.scope_checked")' .orchestrator/metrics/events.jsonl | tail` — `injected:false` with `signal:"marker-absent"` is row 5 (no declaration in the prompt at all, i.e. the injection step, not the guard), `signal:"unparseable"` is row 6 (a declaration the parser could not use), `ledger_result:"allow-finished"` is row 10a (the collision was real but its partner had finished), and NO record at all for a dispatch you watched happen means the hook never ran or crashed before deciding (§ 4.2). Then work down the allow rows: is `FILE-SCOPE` present in the prompt with a fenced block right after it (rows 5/6)? Is the hook armed at all (`GUARD INACTIVE` on stderr = row 2)? Did a `systemMessage` warning appear (rows 7/9)? Cross-check the same scopes through the CLI, which does not depend on prose:
|
|
142
188
|
|
|
143
189
|
```bash
|
|
144
190
|
node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" < <state-dir>/wave-scope.json
|
|
@@ -163,5 +209,6 @@ Re-measure before citing any of these downstream. A count re-briefed later is a
|
|
|
163
209
|
|
|
164
210
|
- `skills/wave-executor/wave-loop.md` § Scope Manifest — the coordinator runbook (steps 3.1–3.3) and § Pre-Dispatch: File-Scope Injection (the prompt block shape).
|
|
165
211
|
- `.claude/rules/parallel-sessions.md` § Decision Tree (why a file in two declared scopes is never a benign sibling signal), § PSA-006 (measurement discipline).
|
|
166
|
-
- `hooks/enforce-scope.mjs` — the write-time gate, fail-**closed**; the deliberate inversion of this hook's posture.
|
|
212
|
+
- `hooks/enforce-scope.mjs` — the write-time gate, fail-**closed**; the deliberate inversion of this hook's posture. Its Gate 3b is the reader of the § 2.3 session binding; `scripts/lib/session-identity/own-session.mjs` holds the identity half.
|
|
213
|
+
- `skills/_shared/state-ownership.md` § `wave-scope.json` Session Binding — the same contract from the ownership side (which shared working-copy artefact belongs to which session).
|
|
167
214
|
- [`docs/adr/0011-guard-degradation-semantics.md`](adr/0011-guard-degradation-semantics.md) — the exit-0 hook protocol (#906) and why a truncated stdout envelope reads as no-decision, i.e. as ALLOW.
|