session-orchestrator 5.4.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +115 -3
  7. package/README.md +30 -15
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +19 -15
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +60 -6
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -22,26 +22,49 @@
22
22
  // agent's fence — i.e. another agent's intent to stage that path.
23
23
  //
24
24
  // Behavior summary
25
- // - No .orchestrator/wave-scope.json → exit 0 (no active wave; sub-mode B
26
- // short-circuits). Sub-mode C still runs if a fence directory exists.
27
- // - Allowed paths violation → exit 1 with restore hint.
25
+ // - No wave-scope.json → exit 0 (no active wave; sub-mode B short-circuits).
26
+ // Sub-mode C still runs if a fence directory exists.
27
+ // - Sub-mode B, in order (#1493.1): a manifest bound to ANOTHER session is
28
+ // not this commit's scope (stand down, one stderr note); `enforcement:
29
+ // "off"` skips.
30
+ // - Non-empty allowedPaths: a staged path outside it BLOCKS (exit 1) under
31
+ // every enforcement level, `warn` included. Deliberately not tied to
32
+ // `enforcement`: in a warn-mode wave enforce-scope lets out-of-scope writes
33
+ // through, so this guard is the only hard PSA-004 stop left for a
34
+ // lint-staged sweep — softening it would weaken detection for the very
35
+ // class it exists to catch.
36
+ // - EMPTY allowedPaths is no longer a silent pass: a Discovery (read-only)
37
+ // manifest means no agent is committing, i.e. a coordinator commit between
38
+ // waves → allow with a note; a manifest older than the newest session
39
+ // clock may be a leftover or a parallel session's live one → report,
40
+ // never block; any other role means the `--union` step never completed →
41
+ // `strict` (the default when the field is absent, as in enforce-scope)
42
+ // blocks, `warn` reports and lets the commit through. Only this new
43
+ // verdict follows `enforcement`: it judges the coordinator's own
44
+ // bookkeeping, not a staged foreign file.
28
45
  // - Cross-agent fence overlap → exit 1 with "staging-fence: cross-agent
29
46
  // overlap" stderr.
30
47
  // - --no-verify bypass: this hook is invoked as a git pre-commit hook;
31
48
  // `git commit --no-verify` skips it entirely (operator opts out by name).
32
49
 
33
50
  import { execSync } from 'node:child_process';
34
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
35
- import { join } from 'node:path';
51
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
52
+ import { isAbsolute, join, relative, sep } from 'node:path';
36
53
 
37
54
  // Re-use existing helpers from scripts/lib/hardening.mjs.
38
55
  // IMPORTANT: import is resolved relative to THIS file's location (the hook
39
56
  // ships with the session-orchestrator package), NOT relative to the git
40
57
  // repo it is protecting. The two diverge when the hook is invoked from a
41
58
  // consumer repo or a test tmp-dir.
42
- import { pathMatchesPattern, findScopeFile } from '../scripts/lib/hardening.mjs';
59
+ import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
43
60
  import { withStagingFenceLock } from '../scripts/lib/session-lock.mjs';
44
61
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
62
+ import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
63
+ import { classifyEmptyScope, findOwnScopeFile, sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
64
+ import {
65
+ classifyManifestSession,
66
+ readProcessLocalSessionIds,
67
+ } from '../scripts/lib/session-identity/own-session.mjs';
45
68
 
46
69
  // Resolve the "current" agent id from SO_WAVE_AGENT_ID (when set by the
47
70
  // caller) or fall back to a PID-derived marker. The fence files themselves
@@ -56,19 +79,6 @@ const ownAgentId = process.env.SO_WAVE_AGENT_ID ?? null;
56
79
  */
57
80
  const ALL_PATHS_MARKER = '*';
58
81
 
59
- /**
60
- * Build a regex that finds a staged path inside a `git add` command string.
61
- * Word-boundary on both sides so `src/foo.ts` does not match `src/foo.ts.bak`.
62
- * The path is escaped so glob metacharacters (`*`, `?`) and shell metas
63
- * cannot be reinterpreted.
64
- *
65
- * LEGACY PATH ONLY since #1404 — see the fallback branch in findOverlaps.
66
- */
67
- function pathRegex(p) {
68
- const escaped = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
- return new RegExp(`(?:^|[\\s'"])${escaped}(?:$|[\\s'"])`);
70
- }
71
-
72
82
  /**
73
83
  * Normalise a path token so reader and writer compare the same spelling.
74
84
  *
@@ -107,19 +117,13 @@ function pathsOverlap(fencePath, ourPath) {
107
117
  * Walk a single sibling fence file and return the staged paths a sibling
108
118
  * agent also recorded an intent to stage.
109
119
  *
110
- * Two entry shapes are accepted:
111
- * - #1404 shape `{ paths, command_hash, timestamp }` — path LISTS compared
112
- * against our staged set.
113
- * - LEGACY shape `{ command, timestamp }` — a fence file written by a
114
- * pre-#1404 hook version. Live sessions on this host may hold such files
115
- * RIGHT NOW, and dropping them would read as "no overlap" — silently
116
- * weaker than before the change. So the old raw-command regex still runs
117
- * for an entry that has `command` and no `paths`.
118
- * NAMED CEILING (BV-004): this fallback exists only to span the sessions
119
- * running across the upgrade. REVISIT TRIGGER — remove it one minor
120
- * release after 5.3.0 (i.e. in 5.4.0), by which point no process started
121
- * before the upgrade can still be writing fence files. The command text it
122
- * reads is never PRINTED (issue #1404) — only the fact of the match is.
120
+ * One entry shape is read: the #1404 `{ paths, command_hash, timestamp }`,
121
+ * whose path LIST is compared against our staged set. An entry without a
122
+ * `paths` array is skipped — that includes the pre-#1404 `{ command }` shape,
123
+ * whose raw-command regex fallback was removed in 5.4.x as its own revisit
124
+ * trigger demanded (#1487 point 16): the only fence writer,
125
+ * hooks/pre-bash-staging-fence.mjs, has written `paths` + `command_hash` and
126
+ * never `command` since 5.3.0, and no other hook or bridge writes fence files.
123
127
  */
124
128
  function findOverlaps(fenceJsonPath, ourStaged) {
125
129
  let body;
@@ -135,36 +139,159 @@ function findOverlaps(fenceJsonPath, ourStaged) {
135
139
  const siblingAgent = body.agent_id ?? '<unknown>';
136
140
  const matches = [];
137
141
  for (const entry of body.staged_paths) {
138
- if (Array.isArray(entry?.paths)) {
139
- const hash = typeof entry.command_hash === 'string' ? entry.command_hash : '<no-hash>';
140
- for (const raw of entry.paths) {
141
- if (typeof raw !== 'string') continue;
142
- const fencePath = normalizeStagedPath(raw);
143
- for (const ours of ourStaged) {
144
- if (pathsOverlap(fencePath, normalizeStagedPath(ours))) {
145
- matches.push({ ourPath: ours, siblingAgent, fencePath, hash });
146
- }
142
+ if (!Array.isArray(entry?.paths)) continue;
143
+ const hash = typeof entry.command_hash === 'string' ? entry.command_hash : '<no-hash>';
144
+ for (const raw of entry.paths) {
145
+ if (typeof raw !== 'string') continue;
146
+ const fencePath = normalizeStagedPath(raw);
147
+ for (const ours of ourStaged) {
148
+ if (pathsOverlap(fencePath, normalizeStagedPath(ours))) {
149
+ matches.push({ ourPath: ours, siblingAgent, fencePath, hash });
147
150
  }
148
151
  }
149
- continue;
150
- }
151
-
152
- const cmd = entry?.command;
153
- if (typeof cmd !== 'string') continue;
154
- for (const ours of ourStaged) {
155
- if (pathRegex(ours).test(cmd)) {
156
- matches.push({
157
- ourPath: ours,
158
- siblingAgent,
159
- fencePath: '<legacy entry, pre-#1404>',
160
- hash: '<legacy entry, pre-#1404>',
161
- });
162
- }
163
152
  }
164
153
  }
165
154
  return matches;
166
155
  }
167
156
 
157
+ /** mtime of a file in ms, or `null` when it cannot be stat'ed. */
158
+ function mtimeMsOf(file) {
159
+ try {
160
+ return statSync(file).mtimeMs;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Sub-mode B's decision, pure: which lines to print and whether to block.
168
+ *
169
+ * Until #1493.1 an empty `allowedPaths` skipped the check outright, the
170
+ * manifest's `role` and `enforcement: "off"` were never read, and a PEER
171
+ * session's manifest governed this session's commits. Each rung below closes
172
+ * one of those; the order is load-bearing — ownership first (a foreign
173
+ * manifest's enforcement level is not ours to honour), then `off`, then the
174
+ * verdict. `warn` softens ONLY the empty-union verdict; a non-empty violation
175
+ * blocks at every level (header § Behavior summary).
176
+ *
177
+ * @param {object} p
178
+ * @param {Record<string, unknown>} p.scope parsed manifest
179
+ * @param {string[]} p.stagedFiles repo-relative, as `git diff --cached` prints them
180
+ * @param {(f: string) => string|null} [p.scopePathOf] a staged path as the
181
+ * manifest names it — relative to the SESSION root; identity when that is the
182
+ * repo root. `null` = the path lies OUTSIDE the session root: a violation
183
+ * without pattern matching, since a `../`-prefixed path would match a glob
184
+ * like `**\/*.mjs` and enforce-scope denies every write outside its root
185
+ * (REQ-04).
186
+ * @param {string} p.scopeRel manifest path for messages, repo-relative
187
+ * @param {Set<string>} p.ownIds this process's session ids
188
+ * @param {number|null} p.scopeMtimeMs
189
+ * @param {number|null} p.sessionAge ms since the newest session clock
190
+ * @returns {{action: 'pass'|'report'|'block', lines: string[]}}
191
+ */
192
+ function scopeCommitVerdict({ scope, stagedFiles, scopePathOf = (f) => f, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
193
+ if (stagedFiles.length === 0) return { action: 'pass', lines: [] };
194
+
195
+ // Same classifier and same disposition as enforce-scope Gate 3b: only a
196
+ // PROVABLY foreign manifest stands down; unknown ownership enforces.
197
+ if (classifyManifestSession(scope, ownIds).verdict === 'foreign') {
198
+ return {
199
+ action: 'pass',
200
+ lines: [`wave-scope-commit-guard: ${scopeRel} belongs to another session — its allowedPaths do not apply to this commit.`],
201
+ };
202
+ }
203
+
204
+ const enforcement = scope.enforcement ?? 'strict';
205
+ if (enforcement === 'off') return { action: 'pass', lines: [] };
206
+
207
+ const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
208
+ if (allowedPaths.length > 0) {
209
+ const violations = stagedFiles.filter(
210
+ (f) => {
211
+ const scoped = scopePathOf(f);
212
+ return scoped === null || !allowedPaths.some((pattern) => pathMatchesPattern(scoped, pattern));
213
+ },
214
+ );
215
+ if (violations.length === 0) return { action: 'pass', lines: [] };
216
+ // Blocks under `warn` too — see the header: in a warn-mode wave this is
217
+ // the only hard stop a lint-staged sweep meets before the commit exists.
218
+ return {
219
+ action: 'block',
220
+ lines: [
221
+ '✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:',
222
+ ...violations.map((v) => ` - ${v}`),
223
+ '',
224
+ 'These files were likely added by lint-staged eslint --fix / prettier --write.',
225
+ 'To proceed:',
226
+ ' 1) git restore --staged <path> # for each foreign path',
227
+ ' 2) git commit # retry',
228
+ ],
229
+ };
230
+ }
231
+
232
+ // From here on the verdict judges the coordinator's own bookkeeping (an empty
233
+ // union), not a staged foreign file — only this part follows `enforcement`.
234
+ const failAction = enforcement === 'strict' ? 'block' : 'report';
235
+
236
+ const role = typeof scope.role === 'string' ? scope.role.trim() : '';
237
+ const reason = classifyEmptyScope({
238
+ role,
239
+ parseOk: true,
240
+ scopeMtimeMs,
241
+ sessionStartMs: typeof sessionAge === 'number' ? Date.now() - sessionAge : null,
242
+ });
243
+ if (reason === 'read-only-role') {
244
+ // No wave agent commits (PSA-007), so a commit under a read-only manifest
245
+ // is the coordinator's own, between waves.
246
+ return {
247
+ action: 'pass',
248
+ lines: [`wave-scope-commit-guard: ${scopeRel} is a ${role} (read-only) manifest — treated as a coordinator commit between waves; allowedPaths not checked.`],
249
+ };
250
+ }
251
+ if (reason === 'stale-manifest') {
252
+ // Older than the newest session clock of this working copy: a leftover OR a
253
+ // parallel session's live manifest, which the clocks cannot tell apart
254
+ // (scope-gate.mjs § "…and the reader may not be the OWNER") — so it is
255
+ // never grounds to block this commit.
256
+ return {
257
+ action: 'report',
258
+ lines: [`⚠ wave-scope-commit-guard: ${scopeRel} grants no paths and predates the newest session start in this working copy — a leftover or a parallel session's live manifest; allowedPaths not checked for this commit.`],
259
+ };
260
+ }
261
+ return {
262
+ action: failAction,
263
+ lines: [
264
+ `${failAction === 'block' ? '✗' : '⚠'} wave-scope-commit-guard: ${scopeRel} grants no paths${role ? ` for role \`${role}\`` : ''} — the coordinator's \`--union\` step did not complete, so none of the ${stagedFiles.length} staged path(s) can be checked against this wave's scope.`,
265
+ 'Re-run the union step (skills/wave-executor/references/wave-loop-scope-manifest.md § 3.3), then retry the commit; do not hand-edit allowedPaths.',
266
+ ...(failAction === 'block' ? [] : ['(enforcement: warn — reported, the commit proceeds)']),
267
+ ],
268
+ };
269
+ }
270
+
271
+ /**
272
+ * The session root a commit at `repoRoot` is judged against when it lies
273
+ * STRICTLY inside the repo, else `null` (judge at `repoRoot`, as before) — see
274
+ * the call site in `main()` (#1511 point d). Both sides realpath-resolved, so
275
+ * macOS `/var` vs `/private/var` cannot split one directory in two.
276
+ *
277
+ * @param {string} repoRoot `git rev-parse --show-toplevel`
278
+ * @returns {{sessionRoot: string, realRepoRoot: string}|null}
279
+ */
280
+ function subdirSessionRoot(repoRoot) {
281
+ const canon = (p) => {
282
+ // `.native` canonicalises letter case on case-insensitive APFS (as
283
+ // enforce-scope's clamp does since #1504 pt 8); the JS realpath keeps a
284
+ // case-different CLAUDE_PROJECT_DIR verbatim, which relativises to `../..`
285
+ // against the git toplevel and switches the subdirectory mode off silently.
286
+ try { return realpathSync.native(p); } catch { return p; }
287
+ };
288
+ const realRepoRoot = canon(repoRoot);
289
+ const sessionRoot = canon(resolveSessionRoot(repoRoot, repoRoot));
290
+ const rel = relative(realRepoRoot, sessionRoot);
291
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
292
+ return { sessionRoot, realRepoRoot };
293
+ }
294
+
168
295
  /**
169
296
  * Both sub-modes, in the order the pre-commit hook needs them. Every statement
170
297
  * here used to sit at module top level, which meant a bare `import()` of this
@@ -181,7 +308,42 @@ async function main() {
181
308
  // skills/wave-executor/wave-loop.md), so this guard never fired. findScopeFile
182
309
  // returns null when no scope file exists at any precedence dir, preserving
183
310
  // the "no active wave → exit 0" semantics below.
184
- const scopePath = findScopeFile(repoRoot);
311
+ //
312
+ // #1504 point 6: the first manifest that is not provably a peer's, so a
313
+ // peer's higher-precedence manifest no longer hides this session's own one;
314
+ // when every candidate is foreign the first comes back and
315
+ // `scopeCommitVerdict` passes it as before. The git process inherits the
316
+ // committing session's env; there is no hook payload here, so the env tier is
317
+ // the only process-local identity.
318
+ const ownIds = new Set(readProcessLocalSessionIds());
319
+
320
+ // #1511 point d — ALSO read the manifest at the SESSION root the scope guards
321
+ // resolve (`resolveSessionRoot`, enforce-scope.mjs): a session launched in a
322
+ // repo SUBDIRECTORY keeps its manifest there, so the repo root held none and
323
+ // sub-mode B never ran for it. git runs this hook at the worktree top level
324
+ // and the committing session's env (the launch dir) is inherited, so the
325
+ // toplevel stands in for the payload `cwd` the Edit/Write guards get. Only a
326
+ // root STRICTLY inside the repo is adopted: the one rung that answers above it
327
+ // (a harness `agent-<hex>` worktree lifted to its launch checkout) would judge
328
+ // this repo's staged paths against another tree's manifest.
329
+ //
330
+ // The repo-root manifest is ALWAYS judged exactly as before #1511 d, and the
331
+ // subdirectory manifest is judged IN ADDITION, each against its own path
332
+ // mapping; either one blocking blocks. So the verdict is never laxer than the
333
+ // repo-root-only guard it extends — choosing ONE of the two manifests let an
334
+ // unbound or `enforcement: off` subdirectory manifest silence a repo-root wave
335
+ // that blocked the same commit (second review, MED-1).
336
+ const subdir = subdirSessionRoot(repoRoot);
337
+ const judgements = [{ root: repoRoot, scopePathOf: (f) => f }];
338
+ if (subdir !== null) {
339
+ judgements.push({
340
+ root: subdir.sessionRoot,
341
+ scopePathOf: (f) => {
342
+ const rel = relative(subdir.sessionRoot, join(subdir.realRepoRoot, f));
343
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? null : rel;
344
+ },
345
+ });
346
+ }
185
347
  const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
186
348
 
187
349
  // -------------------------------------------------------------------------
@@ -191,7 +353,11 @@ async function main() {
191
353
  const stagedOutput = execSync('git diff --cached --name-only', { encoding: 'utf8' });
192
354
  const stagedFiles = stagedOutput.split('\n').filter(Boolean);
193
355
 
194
- if (scopePath) {
356
+ let blocked = false;
357
+ for (const { root, scopePathOf } of judgements) {
358
+ const located = findOwnScopeFile(root, ownIds, classifyManifestSession);
359
+ const scopePath = located.path ?? located.foreignPath;
360
+ if (!scopePath) continue;
195
361
  let scope;
196
362
  try {
197
363
  scope = JSON.parse(readFileSync(scopePath, 'utf8'));
@@ -200,23 +366,19 @@ async function main() {
200
366
  process.exit(1);
201
367
  }
202
368
 
203
- const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
204
- if (allowedPaths.length > 0) {
205
- const violations = stagedFiles.filter(
206
- (f) => !allowedPaths.some((pattern) => pathMatchesPattern(f, pattern)),
207
- );
208
-
209
- if (violations.length > 0) {
210
- process.stderr.write('✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:\n');
211
- for (const v of violations) process.stderr.write(` - ${v}\n`);
212
- process.stderr.write('\nThese files were likely added by lint-staged eslint --fix / prettier --write.\n');
213
- process.stderr.write('To proceed:\n');
214
- process.stderr.write(' 1) git restore --staged <path> # for each foreign path\n');
215
- process.stderr.write(' 2) git commit # retry\n');
216
- process.exit(1);
217
- }
218
- }
369
+ const verdict = scopeCommitVerdict({
370
+ scope: scope !== null && typeof scope === 'object' ? scope : {},
371
+ stagedFiles,
372
+ scopePathOf,
373
+ scopeRel: relative(repoRoot, scopePath) || scopePath,
374
+ ownIds,
375
+ scopeMtimeMs: mtimeMsOf(scopePath),
376
+ sessionAge: sessionAgeMs(root),
377
+ });
378
+ for (const line of verdict.lines) process.stderr.write(`${line}\n`);
379
+ if (verdict.action === 'block') blocked = true;
219
380
  }
381
+ if (blocked) process.exit(1);
220
382
 
221
383
  // -------------------------------------------------------------------------
222
384
  // Sub-mode C — cross-agent staging-fence reconciliation (issue #552)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.4.0",
4
- "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
3
+ "version": "5.6.0",
4
+ "description": "Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. Plan, Go, Close: /session reads your repo and agrees the scope, /go runs the work in waves with your tests and typecheck after each implementation wave, /close runs the full gate, verifies, commits and files the rest as issues. Scope and destructive-command guards, crash-safe resume, learnings you approve. For Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
7
7
  "keywords": [
@@ -16,7 +16,12 @@
16
16
  "orchestration",
17
17
  "developer-tools",
18
18
  "mcp",
19
- "llm"
19
+ "llm",
20
+ "workflow",
21
+ "quality-gates",
22
+ "ai-coding",
23
+ "gitlab",
24
+ "github"
20
25
  ],
21
26
  "engines": {
22
27
  "node": ">=24.0.0"
@@ -54,6 +59,7 @@
54
59
  "!**/.DS_Store"
55
60
  ],
56
61
  "scripts": {
62
+ "update:local": "node scripts/self-update.mjs",
57
63
  "test": "node scripts/check-package-manager.mjs && vitest --run",
58
64
  "test:coverage": "node scripts/check-package-manager.mjs && vitest --run --coverage",
59
65
  "test:watch": "node scripts/check-package-manager.mjs && vitest",
@@ -92,11 +98,6 @@
92
98
  "prettier": "^3.0.0",
93
99
  "vitest": "^4.1.11"
94
100
  },
95
- "overrides": {
96
- "vite": "^8.0.16",
97
- "fast-uri": "^3.1.8",
98
- "brace-expansion": "^5.0.12"
99
- },
100
101
  "license": "MIT",
101
102
  "repository": {
102
103
  "type": "git",
@@ -62,18 +62,22 @@
62
62
  * Telemetry: `--apply` emits `orchestrator.learnings.session_write_applied`
63
63
  * after the write — its presence is the proof the write ran. Best-effort: a
64
64
  * telemetry failure never changes the exit code. Skipped (stderr WARN) when
65
- * `--file` lies outside `--repo-root`, so the record is never pinned to a repo
65
+ * `--file` lies outside `--repo-root` after resolving symlinks on both sides,
66
+ * so the record is never pinned to a repo
66
67
  * whose store was not written.
67
68
  *
68
69
  * Exit codes:
69
70
  * 0 Success (including a no-op)
70
71
  * 1 Usage/input/validation error — nothing written
71
- * 2 Library/IO error (unreadable store, malformed store lines on --apply,
72
- * a failure inside the prune/rewrite, store lock not acquired)
72
+ * 2 Library/IO error (unreadable store, a failure inside the
73
+ * prune/rewrite, store lock not acquired). Unparseable store lines are
74
+ * NOT an error: they are kept verbatim, counted as `malformed` in the
75
+ * summary, and WARNed on stderr with their line numbers.
73
76
  */
74
77
 
75
- import { readFileSync } from 'node:fs';
78
+ import { readFileSync, realpathSync } from 'node:fs';
76
79
  import path from 'node:path';
80
+ import { validatePathInsideProject } from './lib/path-utils.mjs';
77
81
  import { LearningsLockError, readLearnings, withLearningsLock } from './lib/learnings/io.mjs';
78
82
  import { pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
79
83
  import {
@@ -310,9 +314,29 @@ export function buildNextGeneration({ current, updates, newLearnings, decayRate,
310
314
  return { next, confirmed, contradicted, appended: newIds.size, decayed, undecayable };
311
315
  }
312
316
 
317
+ /**
318
+ * A path resolved through symlinks when it exists, else absolute as given.
319
+ *
320
+ * @param {string} p
321
+ * @returns {string}
322
+ */
323
+ function canonicalPath(p) {
324
+ try {
325
+ return realpathSync(p);
326
+ } catch {
327
+ return path.resolve(p);
328
+ }
329
+ }
330
+
313
331
  async function emitSessionWriteApplied({ repoRoot, filePath, payload }) {
314
- const rel = path.relative(repoRoot, filePath);
315
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
332
+ // Canonical, not lexical (#1506.4b; same check as sweep-expired-learnings
333
+ // --drop-malformed, #1500 F4): a store reached through a symlinked directory
334
+ // inside the repo but living outside it is refused, and a root spelled
335
+ // through a symlink (macOS /var → /private/var) still accepts the store
336
+ // inside it. Runs after the write, so the store exists and resolves.
337
+ const file = canonicalPath(filePath);
338
+ const rel = path.relative(canonicalPath(repoRoot), file);
339
+ if (!validatePathInsideProject(file, repoRoot, { canonicalizeRoot: true }).ok) {
316
340
  process.stderr.write(
317
341
  `apply-session-learnings: skipped ${SESSION_WRITE_EVENT} — ${filePath} lies outside ` +
318
342
  `--repo-root ${repoRoot}\n`,
@@ -377,14 +401,11 @@ async function main(argv) {
377
401
  process.stderr.write(`apply-session-learnings: cannot read ${filePath}: ${err.message}\n`);
378
402
  return { code: 2 };
379
403
  }
380
- if (opts.apply && read.malformed.length > 0) {
381
- // pruneLearnings rewrites from parsed entries only — a malformed line
382
- // would vanish without an archive record.
383
- process.stderr.write(
384
- `apply-session-learnings: refusing to rewrite — ${read.malformed.length} malformed line(s) in ${filePath}\n`,
385
- );
386
- return { code: 2 };
387
- }
404
+ // A malformed store line is no reason to refuse (#1489): pruneLearnings()
405
+ // keeps it verbatim at the end of the rewritten store, never archives it,
406
+ // and prints the WARN; the count rides on the summary as `malformed`.
407
+ // Refusing here blocked the session-end learnings write on every close,
408
+ // because nothing on the sanctioned write path ever removes such a line.
388
409
 
389
410
  let built;
390
411
  try {
@@ -44,7 +44,7 @@ import { readFileSync } from 'node:fs';
44
44
  import { spawnSync } from 'node:child_process';
45
45
  import { join } from 'node:path';
46
46
 
47
- import { findProjectRoot, resolveInstructionFile, warn } from './lib/common.mjs';
47
+ import { findProjectRoot, redactHomeDir, resolveInstructionFile, warn } from './lib/common.mjs';
48
48
  import { parseSessionConfig } from './lib/config.mjs';
49
49
  import { glabRun as defaultGlabRun } from './lib/vault-backfill/glab.mjs';
50
50
  import { archiveFileToVault, titleFromMarkdown } from './lib/vault-archive.mjs';
@@ -381,8 +381,13 @@ EXIT CODES
381
381
  }
382
382
 
383
383
  function printHuman(archived, skipped, isDryRun, vaultDir, vaultSubdir) {
384
+ // Runs as a custom phase at every session start and end, so this line lands
385
+ // in every transcript — home dir as `~` (#1490). `--json` keeps the real
386
+ // `vaultDir`: that field is a machine contract, not a log line. A
387
+ // `skipped[].reason` is redacted in BOTH outputs — it carries fs error text,
388
+ // and main() redacts it before it enters the array (8273d97a).
384
389
  process.stdout.write(
385
- `Doc archive ${isDryRun ? '(dry-run)' : '(apply)'} → ${vaultDir}/${vaultSubdir}\n`,
390
+ `Doc archive ${isDryRun ? '(dry-run)' : '(apply)'} → ${redactHomeDir(`${vaultDir}/${vaultSubdir}`)}\n`,
386
391
  );
387
392
  if (archived.length > 0) {
388
393
  process.stdout.write(` ${isDryRun ? 'WOULD ARCHIVE' : 'ARCHIVED'} (${archived.length}):\n`);
@@ -488,19 +493,38 @@ export function main({
488
493
  const root = repoRoot ?? findProjectRoot();
489
494
  const instr = resolveInstructionFile(root);
490
495
  if (!instr) {
491
- process.stderr.write(`archive-closed-prds: no CLAUDE.md/AGENTS.md at ${root}.\n`);
496
+ process.stderr.write(`archive-closed-prds: no CLAUDE.md/AGENTS.md at ${redactHomeDir(root)}.\n`);
492
497
  return { code: 1, archived: [], skipped: [] };
493
498
  }
494
499
 
495
500
  let vaultDir;
501
+ let vaultIntegration;
496
502
  try {
497
503
  const content = readFileSync(instr.path, 'utf8');
498
504
  const config = parseSessionConfig(content, hostPaths ? { hostPaths } : undefined);
499
- vaultDir = config?.['vault-integration']?.['vault-dir'];
505
+ vaultIntegration = config?.['vault-integration'];
506
+ vaultDir = vaultIntegration?.['vault-dir'];
500
507
  } catch (err) {
501
- process.stderr.write(`archive-closed-prds: failed to parse Session Config: ${err.message}\n`);
508
+ process.stderr.write(`archive-closed-prds: failed to parse Session Config: ${redactHomeDir(err.message)}\n`);
502
509
  return { code: 2, archived: [], skipped: [] };
503
510
  }
511
+ // The switch every vault writer obeys (board-writer, narrative-mirror): off
512
+ // → nothing archived, exit 0. parseSessionConfig has already applied the
513
+ // host-local lowering (resolveVaultIntegrationHost: env SO_VAULT_INTEGRATION
514
+ // > owner.yaml > committed). This runs with --apply at every session start
515
+ // and end, so ignoring it wrote PRDs into the vault and git-rm'd them (#1496).
516
+ if (vaultIntegration?.enabled !== true) {
517
+ const override = vaultIntegration?.['host-override'];
518
+ const disabled = `vault-integration is ${override ? `switched off by ${override}` : 'not enabled'} — nothing archived`;
519
+ if (json) {
520
+ process.stdout.write(
521
+ JSON.stringify({ dryRun: isDryRun, vaultDir: null, vaultSubdir, archived: [], skipped: [], disabled }, null, 2) + '\n',
522
+ );
523
+ } else {
524
+ process.stdout.write(`Doc archive: ${disabled}\n`);
525
+ }
526
+ return { code: 0, archived: [], skipped: [] };
527
+ }
504
528
  if (!vaultDir || typeof vaultDir !== 'string' || vaultDir.trim() === '') {
505
529
  process.stderr.write(
506
530
  'archive-closed-prds: vault-integration.vault-dir is not configured — cannot archive.\n',
@@ -534,8 +558,10 @@ export function main({
534
558
  try {
535
559
  header = readHeaderRegion(abs);
536
560
  } catch (err) {
537
- skipped.push({ source: rel, reason: `unreadable: ${err.message}` });
538
- warn(`archive-closed-prds: cannot read ${rel}: ${err.message}`);
561
+ // fs errors name the ABSOLUTE path they failed on (#1490).
562
+ const msg = redactHomeDir(err.message);
563
+ skipped.push({ source: rel, reason: `unreadable: ${msg}` });
564
+ warn(`archive-closed-prds: cannot read ${rel}: ${msg}`);
539
565
  continue;
540
566
  }
541
567
 
@@ -603,8 +629,9 @@ export function main({
603
629
  title: titleFromMarkdown(header),
604
630
  });
605
631
  } catch (err) {
606
- skipped.push({ source: rel, reason: `archive-failed: ${err.message}`, iid, via, ownership });
607
- warn(`archive-closed-prds: failed to archive ${rel}: ${err.message}`);
632
+ const msg = redactHomeDir(err.message);
633
+ skipped.push({ source: rel, reason: `archive-failed: ${msg}`, iid, via, ownership });
634
+ warn(`archive-closed-prds: failed to archive ${rel}: ${msg}`);
608
635
  continue;
609
636
  }
610
637
  entry.iid = iid;
@@ -614,7 +641,7 @@ export function main({
614
641
  if (!isDryRun) {
615
642
  const rm = gitRunFn(['-C', root, 'rm', '--', rel]);
616
643
  entry.removed = rm.ok;
617
- if (!rm.ok) warn(`archive-closed-prds: 'git rm ${rel}' failed: ${rm.stderr.trim()}`);
644
+ if (!rm.ok) warn(`archive-closed-prds: 'git rm ${rel}' failed: ${redactHomeDir(rm.stderr.trim())}`);
618
645
  }
619
646
 
620
647
  archived.push(entry);
@@ -641,7 +668,7 @@ if (isMainModule(import.meta.url)) {
641
668
  const { code } = main();
642
669
  process.exit(code ?? 0);
643
670
  } catch (err) {
644
- process.stderr.write(`archive-closed-prds: unexpected error: ${err?.stack ?? err}\n`);
671
+ process.stderr.write(`archive-closed-prds: unexpected error: ${redactHomeDir(String(err?.stack ?? err))}\n`);
645
672
  process.exit(2);
646
673
  }
647
674
  }
@@ -400,12 +400,19 @@ async function main() {
400
400
  });
401
401
 
402
402
  const sessionIds = state.sessions.join(', ') || '(none)';
403
+ // #1498: `tokens_unknown_sessions` (#1487 Pkt 4) reached state, return value
404
+ // and autopilot.jsonl, but no report showed it — so the token total read as
405
+ // the whole sum. Printed only when non-zero: then the total is a lower bound.
406
+ const unknownTokens = state.tokens_unknown_sessions > 0
407
+ ? ` tokens_unknown_sessions=${state.tokens_unknown_sessions} (the token total is a lower bound)`
408
+ : '';
403
409
  const summary =
404
410
  `${state.autopilot_run_id} ` +
405
411
  `kill_switch=${state.kill_switch ?? 'none'} ` +
406
412
  `iterations=${state.iterations_completed} ` +
407
413
  `sessions=[${sessionIds}] ` +
408
- `duration=${state.duration_seconds}s`;
414
+ `duration=${state.duration_seconds}s ` +
415
+ `tokens=${state.total_tokens_used}${unknownTokens}`;
409
416
  process.stdout.write(summary + '\n');
410
417
 
411
418
  if (state.kill_switch === KILL_SWITCHES.FAILED_WAVE) {