session-orchestrator 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) 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 +95 -3
  7. package/README.md +11 -11
  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 +15 -13
  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 +70 -170
  26. package/hooks/post-bash-write-verify.mjs +98 -17
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +32 -154
  29. package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +584 -207
  32. package/hooks/wave-scope-commit-guard.mjs +168 -72
  33. package/package.json +2 -6
  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 +194 -27
  75. package/scripts/lib/session-close-backfill.mjs +129 -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 +21 -2
  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/state-ownership.md +38 -14
  110. package/skills/autopilot/SKILL.md +1 -1
  111. package/skills/bootstrap/SKILL.md +5 -3
  112. package/skills/bootstrap/_shared-template.md +96 -1
  113. package/skills/bootstrap/fast-template.md +3 -0
  114. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  115. package/skills/bootstrap/standard-template.md +2 -0
  116. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  117. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  118. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  119. package/skills/docs-orchestrator/SKILL.md +12 -5
  120. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  121. package/skills/evolve/SKILL.md +11 -2
  122. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  123. package/skills/session-end/learning-patterns.md +3 -3
  124. package/skills/session-end/metrics-collection.md +1 -1
  125. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  126. package/skills/session-end/plan-verification.md +25 -9
  127. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  128. package/skills/session-end/session-metrics-write.md +45 -10
  129. package/skills/session-start/SKILL.md +3 -3
  130. package/skills/session-start/references/operations-contract.md +5 -3
  131. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  132. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  133. package/skills/vault-mirror/SKILL.md +21 -11
  134. package/skills/wave-executor/SKILL.md +1 -1
  135. package/skills/wave-executor/circuit-breaker.md +1 -1
  136. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  137. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  138. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  139. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  140. package/.agents/skills/navigator/SKILL.md +0 -22
  141. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  142. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  143. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  144. package/.cursor/commands/navigator.md +0 -15
  145. package/.cursor/skills/navigator/SKILL.md +0 -13
  146. package/pi/prompts/navigator.md +0 -12
  147. package/skills/navigator/SKILL.md +0 -193
  148. package/skills/navigator/references/account-check.sh +0 -87
  149. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  150. package/skills/navigator/references/slot-check.sh +0 -114
@@ -22,26 +22,48 @@
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, statSync } from 'node:fs';
52
+ import { join, relative } 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 { classifyEmptyScope, findOwnScopeFile, sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
63
+ import {
64
+ classifyManifestSession,
65
+ readProcessLocalSessionIds,
66
+ } from '../scripts/lib/session-identity/own-session.mjs';
45
67
 
46
68
  // Resolve the "current" agent id from SO_WAVE_AGENT_ID (when set by the
47
69
  // caller) or fall back to a PID-derived marker. The fence files themselves
@@ -56,19 +78,6 @@ const ownAgentId = process.env.SO_WAVE_AGENT_ID ?? null;
56
78
  */
57
79
  const ALL_PATHS_MARKER = '*';
58
80
 
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
81
  /**
73
82
  * Normalise a path token so reader and writer compare the same spelling.
74
83
  *
@@ -107,19 +116,13 @@ function pathsOverlap(fencePath, ourPath) {
107
116
  * Walk a single sibling fence file and return the staged paths a sibling
108
117
  * agent also recorded an intent to stage.
109
118
  *
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.
119
+ * One entry shape is read: the #1404 `{ paths, command_hash, timestamp }`,
120
+ * whose path LIST is compared against our staged set. An entry without a
121
+ * `paths` array is skipped — that includes the pre-#1404 `{ command }` shape,
122
+ * whose raw-command regex fallback was removed in 5.4.x as its own revisit
123
+ * trigger demanded (#1487 point 16): the only fence writer,
124
+ * hooks/pre-bash-staging-fence.mjs, has written `paths` + `command_hash` and
125
+ * never `command` since 5.3.0, and no other hook or bridge writes fence files.
123
126
  */
124
127
  function findOverlaps(fenceJsonPath, ourStaged) {
125
128
  let body;
@@ -135,36 +138,126 @@ function findOverlaps(fenceJsonPath, ourStaged) {
135
138
  const siblingAgent = body.agent_id ?? '<unknown>';
136
139
  const matches = [];
137
140
  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
- }
141
+ if (!Array.isArray(entry?.paths)) continue;
142
+ const hash = typeof entry.command_hash === 'string' ? entry.command_hash : '<no-hash>';
143
+ for (const raw of entry.paths) {
144
+ if (typeof raw !== 'string') continue;
145
+ const fencePath = normalizeStagedPath(raw);
146
+ for (const ours of ourStaged) {
147
+ if (pathsOverlap(fencePath, normalizeStagedPath(ours))) {
148
+ matches.push({ ourPath: ours, siblingAgent, fencePath, hash });
147
149
  }
148
150
  }
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
151
  }
164
152
  }
165
153
  return matches;
166
154
  }
167
155
 
156
+ /** mtime of a file in ms, or `null` when it cannot be stat'ed. */
157
+ function mtimeMsOf(file) {
158
+ try {
159
+ return statSync(file).mtimeMs;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Sub-mode B's decision, pure: which lines to print and whether to block.
167
+ *
168
+ * Until #1493.1 an empty `allowedPaths` skipped the check outright, the
169
+ * manifest's `role` and `enforcement: "off"` were never read, and a PEER
170
+ * session's manifest governed this session's commits. Each rung below closes
171
+ * one of those; the order is load-bearing — ownership first (a foreign
172
+ * manifest's enforcement level is not ours to honour), then `off`, then the
173
+ * verdict. `warn` softens ONLY the empty-union verdict; a non-empty violation
174
+ * blocks at every level (header § Behavior summary).
175
+ *
176
+ * @param {object} p
177
+ * @param {Record<string, unknown>} p.scope parsed manifest
178
+ * @param {string[]} p.stagedFiles
179
+ * @param {string} p.scopeRel manifest path for messages, repo-relative
180
+ * @param {Set<string>} p.ownIds this process's session ids
181
+ * @param {number|null} p.scopeMtimeMs
182
+ * @param {number|null} p.sessionAge ms since the newest session clock
183
+ * @returns {{action: 'pass'|'report'|'block', lines: string[]}}
184
+ */
185
+ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
186
+ if (stagedFiles.length === 0) return { action: 'pass', lines: [] };
187
+
188
+ // Same classifier and same disposition as enforce-scope Gate 3b: only a
189
+ // PROVABLY foreign manifest stands down; unknown ownership enforces.
190
+ if (classifyManifestSession(scope, ownIds).verdict === 'foreign') {
191
+ return {
192
+ action: 'pass',
193
+ lines: [`wave-scope-commit-guard: ${scopeRel} belongs to another session — its allowedPaths do not apply to this commit.`],
194
+ };
195
+ }
196
+
197
+ const enforcement = scope.enforcement ?? 'strict';
198
+ if (enforcement === 'off') return { action: 'pass', lines: [] };
199
+
200
+ const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
201
+ if (allowedPaths.length > 0) {
202
+ const violations = stagedFiles.filter(
203
+ (f) => !allowedPaths.some((pattern) => pathMatchesPattern(f, pattern)),
204
+ );
205
+ if (violations.length === 0) return { action: 'pass', lines: [] };
206
+ // Blocks under `warn` too — see the header: in a warn-mode wave this is
207
+ // the only hard stop a lint-staged sweep meets before the commit exists.
208
+ return {
209
+ action: 'block',
210
+ lines: [
211
+ '✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:',
212
+ ...violations.map((v) => ` - ${v}`),
213
+ '',
214
+ 'These files were likely added by lint-staged eslint --fix / prettier --write.',
215
+ 'To proceed:',
216
+ ' 1) git restore --staged <path> # for each foreign path',
217
+ ' 2) git commit # retry',
218
+ ],
219
+ };
220
+ }
221
+
222
+ // From here on the verdict judges the coordinator's own bookkeeping (an empty
223
+ // union), not a staged foreign file — only this part follows `enforcement`.
224
+ const failAction = enforcement === 'strict' ? 'block' : 'report';
225
+
226
+ const role = typeof scope.role === 'string' ? scope.role.trim() : '';
227
+ const reason = classifyEmptyScope({
228
+ role,
229
+ parseOk: true,
230
+ scopeMtimeMs,
231
+ sessionStartMs: typeof sessionAge === 'number' ? Date.now() - sessionAge : null,
232
+ });
233
+ if (reason === 'read-only-role') {
234
+ // No wave agent commits (PSA-007), so a commit under a read-only manifest
235
+ // is the coordinator's own, between waves.
236
+ return {
237
+ action: 'pass',
238
+ lines: [`wave-scope-commit-guard: ${scopeRel} is a ${role} (read-only) manifest — treated as a coordinator commit between waves; allowedPaths not checked.`],
239
+ };
240
+ }
241
+ if (reason === 'stale-manifest') {
242
+ // Older than the newest session clock of this working copy: a leftover OR a
243
+ // parallel session's live manifest, which the clocks cannot tell apart
244
+ // (scope-gate.mjs § "…and the reader may not be the OWNER") — so it is
245
+ // never grounds to block this commit.
246
+ return {
247
+ action: 'report',
248
+ 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.`],
249
+ };
250
+ }
251
+ return {
252
+ action: failAction,
253
+ lines: [
254
+ `${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.`,
255
+ '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.',
256
+ ...(failAction === 'block' ? [] : ['(enforcement: warn — reported, the commit proceeds)']),
257
+ ],
258
+ };
259
+ }
260
+
168
261
  /**
169
262
  * Both sub-modes, in the order the pre-commit hook needs them. Every statement
170
263
  * here used to sit at module top level, which meant a bare `import()` of this
@@ -181,7 +274,16 @@ async function main() {
181
274
  // skills/wave-executor/wave-loop.md), so this guard never fired. findScopeFile
182
275
  // returns null when no scope file exists at any precedence dir, preserving
183
276
  // the "no active wave → exit 0" semantics below.
184
- const scopePath = findScopeFile(repoRoot);
277
+ //
278
+ // #1504 point 6: the first manifest that is not provably a peer's, so a
279
+ // peer's higher-precedence manifest no longer hides this session's own one;
280
+ // when every candidate is foreign the first comes back and
281
+ // `scopeCommitVerdict` passes it as before. The git process inherits the
282
+ // committing session's env; there is no hook payload here, so the env tier is
283
+ // the only process-local identity.
284
+ const ownIds = new Set(readProcessLocalSessionIds());
285
+ const located = findOwnScopeFile(repoRoot, ownIds, classifyManifestSession);
286
+ const scopePath = located.path ?? located.foreignPath;
185
287
  const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
186
288
 
187
289
  // -------------------------------------------------------------------------
@@ -200,22 +302,16 @@ async function main() {
200
302
  process.exit(1);
201
303
  }
202
304
 
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
- }
305
+ const verdict = scopeCommitVerdict({
306
+ scope: scope !== null && typeof scope === 'object' ? scope : {},
307
+ stagedFiles,
308
+ scopeRel: relative(repoRoot, scopePath) || scopePath,
309
+ ownIds,
310
+ scopeMtimeMs: mtimeMsOf(scopePath),
311
+ sessionAge: sessionAgeMs(repoRoot),
312
+ });
313
+ for (const line of verdict.lines) process.stderr.write(`${line}\n`);
314
+ if (verdict.action === 'block') process.exit(1);
219
315
  }
220
316
 
221
317
  // -------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
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.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
@@ -54,6 +54,7 @@
54
54
  "!**/.DS_Store"
55
55
  ],
56
56
  "scripts": {
57
+ "update:local": "node scripts/self-update.mjs",
57
58
  "test": "node scripts/check-package-manager.mjs && vitest --run",
58
59
  "test:coverage": "node scripts/check-package-manager.mjs && vitest --run --coverage",
59
60
  "test:watch": "node scripts/check-package-manager.mjs && vitest",
@@ -92,11 +93,6 @@
92
93
  "prettier": "^3.0.0",
93
94
  "vitest": "^4.1.11"
94
95
  },
95
- "overrides": {
96
- "vite": "^8.0.16",
97
- "fast-uri": "^3.1.8",
98
- "brace-expansion": "^5.0.12"
99
- },
100
96
  "license": "MIT",
101
97
  "repository": {
102
98
  "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) {