session-orchestrator 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -15,11 +15,22 @@
15
15
  * 1 — source rules not found, or TARGET is not an existing directory
16
16
  */
17
17
 
18
- import { existsSync, mkdirSync, readdirSync, symlinkSync, statSync, lstatSync, writeFileSync } from 'node:fs';
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ readlinkSync,
23
+ symlinkSync,
24
+ statSync,
25
+ lstatSync,
26
+ unlinkSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
19
29
  import path from 'node:path';
20
30
  import { fileURLToPath } from 'node:url';
21
31
 
22
32
  import { CURSOR_TO_CANONICAL_EVENT } from './lib/cursor-hook-bridge.mjs';
33
+ import { shellQuote as shQuote } from './lib/sh-quote.mjs';
23
34
 
24
35
  const __filename = fileURLToPath(import.meta.url);
25
36
  const SCRIPT_DIR = path.dirname(__filename);
@@ -62,14 +73,12 @@ function _isDir(p) {
62
73
  try { return statSync(p).isDirectory(); } catch { return false; }
63
74
  }
64
75
 
65
- function shQuote(value) {
66
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
67
- }
68
-
69
76
  function linkPath(source, dest, label) {
70
77
  mkdirSync(path.dirname(dest), { recursive: true });
71
78
  if (_isSymlink(dest)) {
72
- process.stdout.write(` SKIP: ${label} (symlink exists)\n`);
79
+ // existsSync follows the link: false means it points at nothing.
80
+ const state = existsSync(dest) ? 'symlink exists' : 'dangling symlink — not replacing';
81
+ process.stdout.write(` SKIP: ${label} (${state})\n`);
73
82
  return 'skip';
74
83
  }
75
84
  if (existsSync(dest)) {
@@ -122,6 +131,38 @@ if (_isDir(SOURCE_SKILLS_DIR)) {
122
131
  }
123
132
  }
124
133
 
134
+ /**
135
+ * Remove the links an earlier install left for a wrapper this plugin no longer
136
+ * ships (#1501 item 6: 05c0264f dropped `commands/navigator.md` and
137
+ * `skills/navigator`, and every installed project kept a dead link). An entry is
138
+ * removed only when it is a symlink, dangles, AND resolves inside this plugin's
139
+ * `.cursor/<kind>/` — so it is provably ours. Regular files and live links are
140
+ * the project's own; a dangling link into another root is not ours to delete
141
+ * and gets a WARN — unless this plugin ships that name, where linkPath() has
142
+ * already reported it as dangling.
143
+ * @param {'rules'|'commands'|'skills'} kind
144
+ */
145
+ function pruneDeadLinks(kind) {
146
+ const sourceDir = path.join(SO_ROOT, '.cursor', kind);
147
+ const targetDir = path.join(TARGET, '.cursor', kind);
148
+ if (!_isDir(targetDir)) return;
149
+ for (const name of readdirSync(targetDir)) {
150
+ const dest = path.join(targetDir, name);
151
+ if (!_isSymlink(dest) || existsSync(dest)) continue;
152
+ const pointsTo = path.resolve(path.dirname(dest), readlinkSync(dest));
153
+ const rel = path.relative(sourceDir, pointsTo);
154
+ const ours = rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
155
+ if (ours) {
156
+ unlinkSync(dest);
157
+ process.stdout.write(` PRUNE: ${kind}/${name} (dangling link into ${sourceDir})\n`);
158
+ } else if (!existsSync(path.join(sourceDir, name))) {
159
+ process.stdout.write(` WARN: ${kind}/${name} (dangling link outside this plugin, left alone: ${pointsTo})\n`);
160
+ }
161
+ }
162
+ }
163
+
164
+ for (const kind of ['rules', 'commands', 'skills']) pruneDeadLinks(kind);
165
+
125
166
  function renderHooksJson(soRoot) {
126
167
  const runNode = shQuote(path.join(soRoot, 'hooks', 'run-node.sh'));
127
168
  const bridge = shQuote(path.join(soRoot, 'scripts', 'lib', 'cursor-hook-bridge.mjs'));
@@ -6,12 +6,15 @@
6
6
  * a single session record to `.orchestrator/metrics/sessions.jsonl` (or any
7
7
  * target path). Replaces the raw shell `>>` append with a validated path:
8
8
  *
9
- * node scripts/emit-session.mjs [--file PATH] [--entry JSON]
9
+ * node scripts/emit-session.mjs [--file PATH] [--entry JSON] [--session-uuid ID]
10
10
  *
11
11
  * Input modes:
12
12
  * --entry '<json>' pass the entry JSON literally (for shell pipelines)
13
13
  * (stdin) read the entry JSON from stdin (default when no --entry)
14
14
  *
15
+ * --session-uuid ID this session's raw harness UUID (overrides the
16
+ * current-session.json lookup; the test seam)
17
+ *
15
18
  * Defaults:
16
19
  * --file .orchestrator/metrics/sessions.jsonl
17
20
  *
@@ -33,6 +36,8 @@ import {
33
36
  stampMemoryCleanup,
34
37
  } from './lib/memory-cleanup-stamp.mjs';
35
38
  import { parseStateMd, readSessionProfile, resolveStateMdPath } from './lib/state-md.mjs';
39
+ import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
40
+ import { rollupSessionTokens } from './lib/session-token-rollup.mjs';
36
41
  import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
37
42
  import {
38
43
  validateSession,
@@ -50,17 +55,158 @@ export { serializeSessionLineChecked };
50
55
  // ref (`HEAD`, a branch) would resolve to a DIFFERENT commit when read later.
51
56
  const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
52
57
 
58
+ // Rollup fields merged into the record (#1436) — the totals plus the #1244
59
+ // cache buckets and contract marker the former prose recipe merged, and the
60
+ // #1475 cost-coverage counters, which say why `total_cost_usd` is absent when
61
+ // priced < total. `legacy_v1_records` stays in the rollup's own return value.
62
+ const ROLLUP_KEYS = Object.freeze([
63
+ 'total_tokens',
64
+ 'total_token_input',
65
+ 'total_token_output',
66
+ 'total_token_input_uncached',
67
+ 'total_token_cache_read',
68
+ 'total_token_cache_creation',
69
+ 'total_cost_usd',
70
+ 'subagents_with_tokens',
71
+ 'matched_records',
72
+ 'cost_records_priced',
73
+ 'cost_records_total',
74
+ '_token_schema',
75
+ ]);
76
+
77
+ // Rollup keys validateSession() requires to be non-negative integers.
78
+ const INTEGER_ROLLUP_KEYS = new Set([
79
+ 'total_tokens',
80
+ 'matched_records',
81
+ 'cost_records_priced',
82
+ 'cost_records_total',
83
+ ]);
84
+
85
+ /**
86
+ * Resolve THIS session's raw harness UUID — the join key `subagents.jsonl`
87
+ * (`parent_session_id`) and `events.jsonl` (`session_id`) carry, which the
88
+ * record itself does not (its `session_id` is the semantic slug).
89
+ *
90
+ * `--session-uuid` wins (an explicit assertion, and the test seam). Otherwise
91
+ * `.orchestrator/current-session.json` is read. That file is SHARED by the
92
+ * working copy — the last SessionStart hook wrote it — so its UUID is adopted
93
+ * ONLY when its `semantic_session_id` (or, for a legacy raw-id record, its
94
+ * `session_id`) equals this record's `session_id`: the same "named owner
95
+ * equals the record" gate as the STATE.md derivation in main(). When this
96
+ * process carries its own session id (`readProcessLocalSessionIds()` — the
97
+ * harness env, which a dispatched subagent inherits from the coordinator), that
98
+ * witness OUTRANKS the shared marker: a marker UUID the process does not carry
99
+ * was written by a peer and is refused even when the labels agree (two sessions
100
+ * can mint the same semantic label, #1066). Ranked, never unioned
101
+ * (.claude/rules/identity-and-locks.md). A foreign marker is WARNed and yields
102
+ * null; an absent marker yields null silently.
103
+ *
104
+ * CEILING: a resumed session that received a NEW raw id keeps its semantic id,
105
+ * so only the latest raw id resolves and subagents dispatched under the earlier
106
+ * one are not rolled up. Revisit if current-session.json gains a raw-id history.
107
+ *
108
+ * Never throws.
109
+ *
110
+ * @param {{ override: string|null, recordSessionId: unknown }} opts
111
+ * @returns {string|null}
112
+ */
113
+ function resolveOwnSessionUuid({ override, recordSessionId }) {
114
+ const trimmed = (v) => (typeof v === 'string' ? v.trim() : '');
115
+ const explicit = trimmed(override);
116
+ if (explicit) return explicit;
117
+ const markerPath = join(process.cwd(), '.orchestrator', 'current-session.json');
118
+ let marker;
119
+ try {
120
+ if (!existsSync(markerPath)) return null;
121
+ marker = JSON.parse(readFileSync(markerPath, 'utf8'));
122
+ } catch (err) {
123
+ process.stderr.write(
124
+ `emit-session: WARN could not read ${markerPath} (${err?.message ?? err}); ` +
125
+ `omitting raw_session_id and the token rollup\n`
126
+ );
127
+ return null;
128
+ }
129
+ const uuid = trimmed(marker?.session_id);
130
+ if (!uuid) return null;
131
+ const semantic = trimmed(marker?.semantic_session_id);
132
+ const namesRecord = (semantic !== '' && semantic === recordSessionId) || uuid === recordSessionId;
133
+ const processIds = readProcessLocalSessionIds();
134
+ const processAgrees = processIds.length === 0 || processIds.includes(uuid);
135
+ if (namesRecord && processAgrees) return uuid;
136
+ // The record's session_id is the semantic label (STATE.md `session`); name
137
+ // both sides of whichever comparison failed, so the operator sees which drifted.
138
+ const why = namesRecord
139
+ ? `session label ${recordSessionId} matches, but this process runs as ` +
140
+ `${processIds.join('/')}, not the marker's uuid ${uuid}`
141
+ : `session label mismatch: marker semantic_session_id=${semantic || '<absent>'} ` +
142
+ `vs record session_id=${recordSessionId ?? '<unknown>'}`;
143
+ process.stderr.write(
144
+ `emit-session: WARN current-session.json ${why}; omitting raw_session_id and the token rollup\n`
145
+ );
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * The `head_sha` of THIS session's own `orchestrator.session.started` event —
151
+ * matched on the raw UUID, so a parallel session's start event never supplies
152
+ * it. The FIRST own event decides: a resume or compact that re-emits the event
153
+ * under the same raw id is not the session's start, so its sha is never taken —
154
+ * not even when the first own event carries none. Returns null when there is no
155
+ * own event, or the first one's head_sha is absent or not a full sha.
156
+ *
157
+ * LIMIT — rotation (#1457 point 3): events.jsonl rotates at SessionStart once
158
+ * it exceeds `events-rotation.max-size-mb` (default 10), and when that start is
159
+ * a compact/resume of THIS session the first SURVIVING own event can be a
160
+ * compact/resume re-emit whose head_sha is later than the real start. When the
161
+ * first own event carries `native_source` 'compact' or 'resume' it is provably
162
+ * not the start, so this returns null (unknown) rather than a wrong ref. An
163
+ * event without `native_source` (Codex/Cursor, or a pre-#1091 writer) is still
164
+ * taken — so a rotation that leaves an unlabelled re-emit first stays wrong.
165
+ *
166
+ * Whole-file read, like deriveMemoryCleanupSignal() on the same file — fine at
167
+ * today's events.jsonl size; revisit if the file outgrows a single read.
168
+ *
169
+ * Never throws.
170
+ *
171
+ * @param {string} eventsFile
172
+ * @param {string} uuid
173
+ * @returns {string|null}
174
+ */
175
+ function readOwnStartHeadSha(eventsFile, uuid) {
176
+ let raw;
177
+ try {
178
+ raw = readFileSync(eventsFile, 'utf8');
179
+ } catch {
180
+ return null;
181
+ }
182
+ for (const line of raw.split('\n')) {
183
+ if (!line.includes(uuid)) continue;
184
+ let ev;
185
+ try {
186
+ ev = JSON.parse(line);
187
+ } catch {
188
+ continue;
189
+ }
190
+ if (ev?.event !== 'orchestrator.session.started' || ev.session_id !== uuid) continue;
191
+ if (ev.native_source === 'compact' || ev.native_source === 'resume') return null;
192
+ return typeof ev.head_sha === 'string' && FULL_SHA_RE.test(ev.head_sha) ? ev.head_sha : null;
193
+ }
194
+ return null;
195
+ }
196
+
53
197
  function parseArgs(argv) {
54
- const args = { file: '.orchestrator/metrics/sessions.jsonl', entry: null };
198
+ const args = { file: '.orchestrator/metrics/sessions.jsonl', entry: null, sessionUuid: null };
55
199
  for (let i = 0; i < argv.length; i++) {
56
200
  const a = argv[i];
57
201
  if (a === '--file') args.file = argv[++i];
58
202
  else if (a === '--entry') args.entry = argv[++i];
203
+ else if (a === '--session-uuid') args.sessionUuid = argv[++i];
59
204
  else if (a === '--help' || a === '-h') {
60
205
  process.stdout.write(
61
206
  'Usage: node scripts/emit-session.mjs [--file PATH] [--entry JSON]\n' +
62
207
  ' --file target JSONL file (default: .orchestrator/metrics/sessions.jsonl)\n' +
63
208
  ' --entry entry JSON (if omitted, read from stdin)\n' +
209
+ ' --session-uuid raw harness UUID (default: owned .orchestrator/current-session.json)\n' +
64
210
  'Exit codes: 0 append ok, 1 validation error, 2 I/O error\n'
65
211
  );
66
212
  process.exit(0);
@@ -131,6 +277,29 @@ async function main() {
131
277
  // contract deliberately does not.
132
278
  repaired = normalizeWaveKeys(repaired);
133
279
 
280
+ // Own raw UUID (#1436), resolved once for the three derivations further down:
281
+ // raw_session_id, the session_start_ref events fallback, the token rollup.
282
+ const ownUuid = resolveOwnSessionUuid({
283
+ override: args.sessionUuid,
284
+ recordSessionId: repaired.session_id,
285
+ });
286
+
287
+ // An explicit session_start_ref that is not a full sha (#1443) names a
288
+ // DIFFERENT commit once the short form becomes ambiguous. Dropped with a WARN
289
+ // rather than rejected: the record is the load-bearing artefact, and the
290
+ // derivations below may still supply the full sha. Explicit `null` stays.
291
+ // Enforced HERE, not in validateSession(): the ledger already holds records
292
+ // with 7-char refs, and the session-start integrity banner runs every one of
293
+ // them through validateSession().
294
+ if (typeof repaired.session_start_ref === 'string' && !FULL_SHA_RE.test(repaired.session_start_ref)) {
295
+ process.stderr.write(
296
+ `emit-session: WARN session_start_ref=${repaired.session_start_ref} is not a full hex sha; ` +
297
+ `dropping it\n`
298
+ );
299
+ const { session_start_ref: _dropped, ...rest } = repaired;
300
+ repaired = rest;
301
+ }
302
+
134
303
  // `memory_cleanup_at` derivation (#699 follow-up — Disziplin statt Mechanik).
135
304
  // The flag used to be a boolean the coordinator-LLM remembered to pass at
136
305
  // session-end; it was forgotten on 2026-08-14 and the cadence marker stalled
@@ -212,6 +381,9 @@ async function main() {
212
381
  const hasOwn = (key) => Object.prototype.hasOwnProperty.call(repaired, key);
213
382
  const needsProfile = !hasOwn('session_profile');
214
383
  const needsStartRef = !hasOwn('session_start_ref');
384
+ // What STATE.md contributed to session_start_ref, for the WARN below when no
385
+ // source supplies one (#1457 point 2): none, or a ref that was discarded.
386
+ let stateMdStartRefNote = 'STATE.md carried none';
215
387
  if (needsProfile || needsStartRef) {
216
388
  let stateMdContents = '';
217
389
  try {
@@ -223,6 +395,7 @@ async function main() {
223
395
  `derivation (${err?.message ?? err}); omitting both\n`
224
396
  );
225
397
  stateMdContents = '';
398
+ stateMdStartRefNote = 'STATE.md unreadable';
226
399
  }
227
400
  // No initialiser: both the try and the catch below assign `profile`.
228
401
  let profile;
@@ -241,6 +414,7 @@ async function main() {
241
414
  `emit-session: WARN STATE.md session-start-ref=${rawRef} is not a full hex sha; ` +
242
415
  `omitting session_start_ref\n`
243
416
  );
417
+ stateMdStartRefNote = 'STATE.md ref discarded (not a full hex sha)';
244
418
  }
245
419
  } catch (err) {
246
420
  process.stderr.write(
@@ -249,6 +423,7 @@ async function main() {
249
423
  );
250
424
  profile = null;
251
425
  startRef = null;
426
+ stateMdStartRefNote = 'STATE.md unparseable';
252
427
  }
253
428
  const derived = {};
254
429
  if (needsProfile && profile !== null) derived.session_profile = profile;
@@ -258,6 +433,9 @@ async function main() {
258
433
  if (owner !== null && owner === repaired.session_id) {
259
434
  repaired = { ...repaired, ...derived };
260
435
  } else {
436
+ if (derived.session_start_ref !== undefined) {
437
+ stateMdStartRefNote = `STATE.md ref discarded (belongs to session=${owner ?? '<absent>'})`;
438
+ }
261
439
  // A visible omission: silence here is indistinguishable from "STATE.md
262
440
  // carries no such field", and a foreign STATE.md in this working copy
263
441
  // is precisely what the operator wants to know about.
@@ -270,6 +448,124 @@ async function main() {
270
448
  }
271
449
  }
272
450
 
451
+ // session_start_ref fallback (#1443): STATE.md supplied none (absent key,
452
+ // foreign owner, unparseable) — take the head_sha the SessionStart hook
453
+ // recorded on this session's own start event. Absent there too → omitted,
454
+ // with a WARN whether or not an own UUID was available (#1457 point 2) —
455
+ // without one the fallback cannot run, which is exactly when silence hid it.
456
+ if (!hasOwn('session_start_ref')) {
457
+ const headSha = ownUuid !== null
458
+ ? readOwnStartHeadSha(join(dirname(args.file), 'events.jsonl'), ownUuid)
459
+ : null;
460
+ if (headSha !== null) {
461
+ repaired = { ...repaired, session_start_ref: headSha };
462
+ process.stderr.write(
463
+ `emit-session: derived session_start_ref=${headSha} from the own orchestrator.session.started event\n`
464
+ );
465
+ } else {
466
+ process.stderr.write(
467
+ `emit-session: WARN no session_start_ref — ${stateMdStartRefNote}, ` +
468
+ 'no own session.started head_sha available\n'
469
+ );
470
+ }
471
+ }
472
+
473
+ if (ownUuid !== null && !hasOwn('raw_session_id')) {
474
+ repaired = { ...repaired, raw_session_id: ownUuid };
475
+ }
476
+
477
+ // Token rollup (#1436) — mechanical, replacing the session-metrics-write.md
478
+ // prose step that had no production caller. Joins the sibling subagents.jsonl
479
+ // on the own raw UUID and fills ONLY keys the entry does not carry; an
480
+ // explicit `total_tokens` means the caller already rolled up, so nothing is
481
+ // merged. No matching records or a failed read OMITS the fields with a WARN —
482
+ // never a fabricated 0; a null total is omitted rather than written — for
483
+ // `total_cost_usd` that is any session with an unpriced cost candidate
484
+ // (unknown model, no model, no tokens, a started agent without a transcript,
485
+ // or a v1 agent — the complete list is the `session-token-rollup.mjs` module
486
+ // doc, #1498), which the persisted `cost_records_priced < cost_records_total`
487
+ // then explains.
488
+ // match_status explains the omission; diagnostic fields stay out of the record.
489
+ if (ownUuid !== null && !hasOwn('total_tokens')) {
490
+ let rollup = null;
491
+ try {
492
+ rollup = rollupSessionTokens({
493
+ parentSessionId: ownUuid,
494
+ subagentsPath: join(dirname(args.file), 'subagents.jsonl'),
495
+ });
496
+ } catch (err) {
497
+ process.stderr.write(
498
+ `emit-session: WARN token rollup failed (${err?.message ?? err}); omitting token fields\n`
499
+ );
500
+ }
501
+ if (rollup !== null && rollup.matched_records > 0) {
502
+ const merged = {};
503
+ for (const key of ROLLUP_KEYS) {
504
+ if (hasOwn(key) || !Number.isFinite(rollup[key])) continue;
505
+ // Mirrors validateSession(): these two are non-negative INTEGERS, while
506
+ // total_token_input/output may be fractional — one fractional token
507
+ // value upstream must cost the field, not the record.
508
+ if (INTEGER_ROLLUP_KEYS.has(key) && !Number.isInteger(rollup[key])) {
509
+ process.stderr.write(
510
+ `emit-session: WARN token rollup produced an invalid field (${key} must be a ` +
511
+ `non-negative integer, got: ${rollup[key]}); omitting ${key}\n`
512
+ );
513
+ continue;
514
+ }
515
+ merged[key] = rollup[key];
516
+ }
517
+ // Enrichment must never be the reason the ledger gains no line: validate
518
+ // the enriched record first and fall back to the un-enriched one. Blamed
519
+ // on the rollup only when the un-enriched record validates on its own —
520
+ // otherwise the validation below reports the record's own defect.
521
+ try {
522
+ validateSession({ ...repaired, ...merged });
523
+ repaired = { ...repaired, ...merged };
524
+ } catch (err) {
525
+ if (!(err instanceof ValidationError)) throw err;
526
+ let baseValid = true;
527
+ try {
528
+ validateSession(repaired);
529
+ } catch {
530
+ baseValid = false;
531
+ }
532
+ if (baseValid) {
533
+ process.stderr.write(
534
+ `emit-session: WARN token rollup produced an invalid field (${err.message}); ` +
535
+ `omitting token fields\n`
536
+ );
537
+ }
538
+ }
539
+ if (rollup.total_tokens === null) {
540
+ process.stderr.write(
541
+ `emit-session: WARN token rollup matched ${rollup.matched_records} subagents.jsonl record(s) ` +
542
+ `for ${ownUuid}, none token-bearing; total_tokens omitted\n`
543
+ );
544
+ }
545
+ } else if (rollup !== null) {
546
+ let warning = `token rollup found no subagents.jsonl records for ${ownUuid}`;
547
+ switch (rollup.match_status) {
548
+ case 'ledger-absent':
549
+ warning += ' (ledger absent)';
550
+ break;
551
+ case 'ledger-empty':
552
+ warning += ' (ledger holds no readable records)';
553
+ break;
554
+ case 'unmatched':
555
+ warning = `token rollup found ${rollup.ledger_records} subagents.jsonl record(s) present, ` +
556
+ `none with parent_session_id=${ownUuid} — not attributable via this UUID, not zero cost`;
557
+ break;
558
+ case 'invalid-key':
559
+ warning = `token rollup rejected invalid parent_session_id=${ownUuid}`;
560
+ break;
561
+ default:
562
+ // Unknown or missing diagnostics retain the generic omission warning.
563
+ break;
564
+ }
565
+ process.stderr.write(`emit-session: WARN ${warning}; omitting token fields\n`);
566
+ }
567
+ }
568
+
273
569
  let validated;
274
570
  try {
275
571
  validated = validateSession(repaired);
@@ -14,8 +14,9 @@
14
14
  * research/hardware-patterns.md`), via the same `vault-integration.vault-dir`
15
15
  * resolution `scripts/archive-closed-prds.mjs` uses (findProjectRoot →
16
16
  * CLAUDE.md/AGENTS.md → parseSessionConfig; host-resolved: SO_VAULT_DIR env >
17
- * owner.yaml paths.vault-dir > committed default). Pass `--output <path>` to
18
- * override.
17
+ * owner.yaml vault-dirs: cwd match > owner.yaml paths.vault-dir > committed
18
+ * default, see `resolveVaultDir` in scripts/lib/config.mjs). Pass `--output <path>`
19
+ * to override.
19
20
  *
20
21
  * Idempotent: running without new data rewrites the same file byte-for-byte
21
22
  * modulo the generated-at line.
@@ -58,7 +59,8 @@ import {
58
59
  rewriteLearnings,
59
60
  CURRENT_ANONYMIZATION_VERSION,
60
61
  } from './lib/learnings.mjs';
61
- import { findProjectRoot, resolveInstructionFile, expandTilde } from './lib/common.mjs';
62
+ import { withLearningsLock } from './lib/learnings/io.mjs';
63
+ import { findProjectRoot, resolveInstructionFile, expandTilde, redactHomeDir } from './lib/common.mjs';
62
64
  import { parseSessionConfig } from './lib/config.mjs';
63
65
  import { createSecretValueMasker } from './lib/secret-masker.mjs';
64
66
  import { stableHostname, readHostAliases } from './lib/host-identity.mjs';
@@ -74,7 +76,8 @@ const DEFAULT_VAULT_SUBPATH = path.join('01-projects', 'session-orchestrator', '
74
76
  * explicitly. Mirrors the vault-dir resolution `scripts/archive-closed-prds.mjs`
75
77
  * uses: findProjectRoot → CLAUDE.md/AGENTS.md → parseSessionConfig →
76
78
  * vault-integration.vault-dir (host-resolved: SO_VAULT_DIR env > owner.yaml
77
- * paths.vault-dir > committed default).
79
+ * vault-dirs: cwd match > owner.yaml paths.vault-dir > committed default, see
80
+ * `resolveVaultDir` in scripts/lib/config.mjs).
78
81
  *
79
82
  * @param {object} [opts]
80
83
  * @param {string} [opts.repoRoot] — override for findProjectRoot() (tests).
@@ -82,21 +85,28 @@ const DEFAULT_VAULT_SUBPATH = path.join('01-projects', 'session-orchestrator', '
82
85
  * — forwarded to parseSessionConfig (tests pass `{ env: {}, ownerConfig: undefined }`
83
86
  * for hermetic, owner.yaml-free resolution — issue #653 bleed guard).
84
87
  * @returns {string|null} the resolved output path, or null when vault-dir is
85
- * unconfigured/unresolvable.
88
+ * unconfigured/unresolvable or vault-integration is not enabled.
86
89
  */
87
90
  export function resolveDefaultOutput({ repoRoot, hostPaths } = {}) {
88
91
  const root = repoRoot ?? findProjectRoot();
89
92
  const instr = resolveInstructionFile(root);
90
93
  if (!instr) return null;
91
94
 
92
- let vaultDir;
95
+ let vi;
93
96
  try {
94
97
  const content = readFileSync(instr.path, 'utf8');
95
98
  const config = parseSessionConfig(content, hostPaths ? { hostPaths } : undefined);
96
- vaultDir = config?.['vault-integration']?.['vault-dir'];
99
+ vi = config?.['vault-integration'];
97
100
  } catch {
98
101
  return null;
99
102
  }
103
+ // #1506.4a: this default target IS the vault, so it obeys the off switch the
104
+ // other vault writers obey (vault-backfill, board-writer, narrative-mirror:
105
+ // `enabled !== true` → no vault write), already host-lowered by
106
+ // parseSessionConfig (env SO_VAULT_INTEGRATION > owner.yaml > committed). An
107
+ // explicit `--output` is the caller's own path and is not gated.
108
+ if (vi?.enabled !== true) return null;
109
+ const vaultDir = vi['vault-dir'];
100
110
  if (!vaultDir || typeof vaultDir !== 'string' || vaultDir.trim() === '') return null;
101
111
 
102
112
  // Expand a `~`-prefixed vault-dir (e.g. the committed Session Config default
@@ -601,7 +611,8 @@ function parseArgs(argv) {
601
611
  if (!resolved) {
602
612
  process.stderr.write(
603
613
  'export-hw-learnings: could not resolve vault-integration.vault-dir for the default ' +
604
- '--output path (no CLAUDE.md/AGENTS.md found, or vault-dir is unset). ' +
614
+ '--output path (no CLAUDE.md/AGENTS.md found, vault-integration.enabled is not true, ' +
615
+ 'or vault-dir is unset). ' +
605
616
  'Pass --output <path> explicitly.\n'
606
617
  );
607
618
  process.exit(1);
@@ -634,6 +645,22 @@ function parseArgs(argv) {
634
645
  * @returns {Promise<{promoted: number, skipped: number, flags: string[]}>}
635
646
  */
636
647
  export async function promoteHwLearnings(opts) {
648
+ // The read → rewrite below runs under the store lock (#1447 point 8): an
649
+ // append landing between them would vanish in the rename. A dry run writes
650
+ // nothing and takes no lock. A lock not acquired throws LearningsLockError
651
+ // (nothing written), which the CLI reports as `export failed: …`, exit 1.
652
+ if (opts.dryRun) return promoteHwLearningsUnlocked(opts);
653
+ return withLearningsLock(opts.input, () => promoteHwLearningsUnlocked(opts));
654
+ }
655
+
656
+ /**
657
+ * Body of {@link promoteHwLearnings}; the caller holds the store lock or runs
658
+ * a dry run.
659
+ *
660
+ * @param {Parameters<typeof promoteHwLearnings>[0]} opts
661
+ * @returns {Promise<{promoted: number, skipped: number, flags: string[]}>}
662
+ */
663
+ async function promoteHwLearningsUnlocked(opts) {
637
664
  const repoRoot = opts.repoRoot;
638
665
  const { entries, malformed } = await readLearnings(opts.input);
639
666
 
@@ -642,7 +669,9 @@ export async function promoteHwLearnings(opts) {
642
669
 
643
670
  const flags = [];
644
671
  if (malformed.length > 0) {
645
- flags.push(`${malformed.length} malformed line(s) in learnings.jsonl were skipped`);
672
+ // Not promoted, and kept verbatim by the rewrite below (rewriteLearnings
673
+ // re-reads them under the lock — #1489); a WARN, not a loss.
674
+ flags.push(`${malformed.length} malformed line(s) in learnings.jsonl were not read (kept verbatim)`);
646
675
  }
647
676
 
648
677
  if (hwPrivate.length === 0) {
@@ -744,7 +773,8 @@ if (isMain) {
744
773
  };
745
774
 
746
775
  run().catch((err) => {
747
- process.stderr.write(`export failed: ${err.message}\n`);
776
+ // The message can carry absolute store/output paths — never print the home dir.
777
+ process.stderr.write(`export failed: ${redactHomeDir(String(err?.message ?? err))}\n`);
748
778
  process.exit(1);
749
779
  });
750
780
  }