session-orchestrator 5.3.0 → 5.4.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 (206) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/navigator/SKILL.md +22 -0
  3. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  4. package/.agents/skills/test-audit/SKILL.md +21 -0
  5. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  6. package/.claude-plugin/marketplace.json +1 -1
  7. package/.claude-plugin/plugin.json +1 -1
  8. package/.codex-plugin/plugin.json +1 -1
  9. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  10. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  11. package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
  12. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  13. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  14. package/.cursor/commands/navigator.md +15 -0
  15. package/.cursor/commands/test-audit.md +14 -0
  16. package/.cursor/rules/060-evolve.mdc +27 -12
  17. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  18. package/.cursor/skills/navigator/SKILL.md +13 -0
  19. package/.cursor/skills/test-audit/SKILL.md +12 -0
  20. package/.cursor-plugin/plugin.json +1 -1
  21. package/.orchestrator/policy/blocked-commands.json +1 -1
  22. package/AGENTS.md +9 -31
  23. package/CHANGELOG.md +70 -0
  24. package/README.md +14 -10
  25. package/docs/agent-authoring.md +2 -2
  26. package/docs/baseline.md +10 -1
  27. package/docs/ci-setup.md +57 -4
  28. package/docs/components.md +12 -12
  29. package/docs/events-schema.md +11 -8
  30. package/docs/persona-panel.md +5 -4
  31. package/docs/rule-authoring.md +27 -12
  32. package/docs/scope-collision-guard.md +26 -4
  33. package/docs/session-config-reference.md +55 -6
  34. package/docs/session-config-template.md +11 -2
  35. package/hooks/_lib/hook-import-set.json +27 -23
  36. package/hooks/hooks-codex.json +1 -1
  37. package/hooks/hooks-cursor.json +5 -0
  38. package/hooks/hooks-pi.json +5 -0
  39. package/hooks/hooks.json +6 -1
  40. package/hooks/on-session-start.mjs +53 -0
  41. package/hooks/on-stop.mjs +3 -1
  42. package/hooks/post-tool-batch-wave-signal.mjs +3 -1
  43. package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
  44. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  45. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  46. package/hooks/pre-task-scope-disjoint.mjs +559 -72
  47. package/hooks/subagent-telemetry.mjs +96 -19
  48. package/package.json +3 -3
  49. package/pi/prompts/navigator.md +12 -0
  50. package/pi/prompts/test-audit.md +12 -0
  51. package/rules/README.md +6 -1
  52. package/rules/_index.md +3 -3
  53. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  54. package/rules/opt-in-domain/prompt-caching.md +5 -1
  55. package/rules/opt-in-stack/backend-data.md +9 -1
  56. package/rules/opt-in-stack/backend.md +7 -1
  57. package/rules/opt-in-stack/frontend.md +6 -1
  58. package/rules/opt-in-stack/security-web.md +7 -1
  59. package/rules/opt-in-stack/swift.md +5 -1
  60. package/scripts/apply-session-learnings.mjs +492 -0
  61. package/scripts/archive-closed-prds.mjs +2 -1
  62. package/scripts/autopilot.mjs +49 -53
  63. package/scripts/backfill-abandoned-sessions.mjs +1 -1
  64. package/scripts/baseline-archetypes.mjs +1 -1
  65. package/scripts/check-package-manager.mjs +62 -5
  66. package/scripts/cursor-install.mjs +1 -4
  67. package/scripts/emit-session.mjs +296 -2
  68. package/scripts/export-hw-learnings.mjs +22 -3
  69. package/scripts/fleet-checkin.mjs +147 -0
  70. package/scripts/lib/agent-frontmatter.mjs +3 -3
  71. package/scripts/lib/auto-dream.mjs +7 -6
  72. package/scripts/lib/autopilot/flags.mjs +6 -1
  73. package/scripts/lib/autopilot/loop.mjs +10 -6
  74. package/scripts/lib/baseline-archetypes.mjs +41 -22
  75. package/scripts/lib/config/host-paths.mjs +79 -0
  76. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  77. package/scripts/lib/config/reaper.mjs +7 -6
  78. package/scripts/lib/config-schema.mjs +13 -0
  79. package/scripts/lib/config.mjs +71 -10
  80. package/scripts/lib/events.mjs +21 -2
  81. package/scripts/lib/fleet-protocol.mjs +270 -0
  82. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  83. package/scripts/lib/gates/gate-full.mjs +7 -2
  84. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  85. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  86. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  87. package/scripts/lib/instruction-budget-guard.mjs +69 -11
  88. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  89. package/scripts/lib/learnings/io.mjs +154 -26
  90. package/scripts/lib/learnings/schema.mjs +163 -15
  91. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  92. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  93. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  94. package/scripts/lib/named-vault-resolver.mjs +104 -5
  95. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  96. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  97. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  98. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  99. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  100. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  101. package/scripts/lib/orphan-reaper.mjs +43 -1315
  102. package/scripts/lib/owner-yaml.mjs +104 -6
  103. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  104. package/scripts/lib/process-group.mjs +62 -11
  105. package/scripts/lib/project-hygiene.mjs +21 -1
  106. package/scripts/lib/quality-gate.mjs +45 -5
  107. package/scripts/lib/resource-probe.mjs +1 -1
  108. package/scripts/lib/rule-loader.mjs +100 -62
  109. package/scripts/lib/rules-sync.mjs +27 -12
  110. package/scripts/lib/scope-baseline.mjs +3 -4
  111. package/scripts/lib/session-record-repair.mjs +183 -26
  112. package/scripts/lib/session-registry.mjs +27 -2
  113. package/scripts/lib/session-schema/constants.mjs +30 -4
  114. package/scripts/lib/session-schema/validator.mjs +28 -1
  115. package/scripts/lib/session-token-rollup.mjs +245 -52
  116. package/scripts/lib/sessions-canonical.mjs +100 -9
  117. package/scripts/lib/sh-quote.mjs +29 -0
  118. package/scripts/lib/subagents-schema.mjs +30 -3
  119. package/scripts/lib/telemetry/pricing.mjs +118 -71
  120. package/scripts/lib/tests-src-ratio.mjs +140 -23
  121. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  122. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  123. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  124. package/scripts/lib/validate/check-agents.mjs +1 -1
  125. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  126. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  127. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  128. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  129. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  130. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  131. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  132. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  133. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  134. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  135. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  136. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  137. package/scripts/lib/wave-resource-gate.mjs +34 -8
  138. package/scripts/memory-propose.mjs +18 -5
  139. package/scripts/release-session-lock.mjs +2 -1
  140. package/scripts/release.mjs +10 -3
  141. package/scripts/repair-invalid-sessions.mjs +37 -4
  142. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  143. package/scripts/run-quality-gate.mjs +323 -302
  144. package/scripts/site-numbers.mjs +163 -5
  145. package/scripts/sweep-expired-learnings.mjs +56 -5
  146. package/scripts/sweep-expired-rules.mjs +2 -1
  147. package/scripts/validate-plugin.mjs +7 -0
  148. package/scripts/vault-integration-watcher.mjs +2 -1
  149. package/scripts/vault-mirror.mjs +72 -46
  150. package/scripts/wave-scope-binding.mjs +2 -1
  151. package/skills/_shared/config-reading.md +3 -3
  152. package/skills/_shared/fleet-protocol.md +131 -0
  153. package/skills/_shared/state-ownership.md +5 -0
  154. package/skills/autopilot/SKILL.md +25 -11
  155. package/skills/bootstrap/SKILL.md +1 -1
  156. package/skills/bootstrap/_shared-template.md +1 -1
  157. package/skills/bootstrap/fast-template.md +1 -1
  158. package/skills/bootstrap/private-contract.md +2 -1
  159. package/skills/bootstrap/public-fallback.md +6 -3
  160. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  161. package/skills/claude-md-drift-check/SKILL.md +10 -10
  162. package/skills/claude-md-drift-check/checker.mjs +26 -18
  163. package/skills/evolve/SKILL.md +2 -2
  164. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  165. package/skills/memory-cleanup/SKILL.md +1 -1
  166. package/skills/navigator/SKILL.md +193 -0
  167. package/skills/navigator/references/account-check.sh +87 -0
  168. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  169. package/skills/navigator/references/slot-check.sh +114 -0
  170. package/skills/persona-panel/SKILL.md +2 -2
  171. package/skills/persona-panel/persona-format.md +2 -2
  172. package/skills/persona-panel/presets/designer-lens.md +1 -1
  173. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  174. package/skills/persona-panel/presets/pm-lens.md +1 -1
  175. package/skills/plan/mode-retro.md +19 -4
  176. package/skills/quality-gates/SKILL.md +6 -4
  177. package/skills/remote-offload/SKILL.md +4 -2
  178. package/skills/session-end/learning-patterns.md +22 -12
  179. package/skills/session-end/metrics-collection.md +5 -6
  180. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  181. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  182. package/skills/session-end/references/session-summary-template.md +1 -1
  183. package/skills/session-end/session-metrics-write.md +22 -60
  184. package/skills/session-plan/SKILL.md +0 -3
  185. package/skills/session-start/SKILL.md +6 -0
  186. package/skills/session-start/references/operations-contract.md +4 -0
  187. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  188. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
  189. package/skills/test-audit/SKILL.md +152 -0
  190. package/skills/test-audit/references/campaign.md +111 -0
  191. package/skills/test-audit/references/mutate.sh +50 -0
  192. package/skills/wave-executor/SKILL.md +1 -1
  193. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  194. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  195. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  196. package/templates/personas/README.md +5 -5
  197. package/templates/personas/accounting-compliance.v1.md +1 -1
  198. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  199. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  200. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  201. package/templates/personas/buyer-p3-build.v1.md +1 -1
  202. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  203. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  204. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  205. package/templates/personas/klima-ai-expert.v1.md +1 -1
  206. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -10,7 +10,7 @@ export async function main(argv = process.argv.slice(2)) {
10
10
  for (let index = 0; index < argv.length; index++) {
11
11
  const arg = argv[index];
12
12
  if (arg === '--help' || arg === '-h') {
13
- process.stdout.write('Usage: node scripts/baseline-archetypes.mjs [--repo PATH] [--archetype ID]\nOffline, read-only JSON lookup. Exit 0: public/private; exit 2: invalid configuration or contract.\n');
13
+ process.stdout.write('Usage: node scripts/baseline-archetypes.mjs [--repo PATH] [--archetype ID]\nOffline, read-only JSON lookup. Exit 0: public/private; exit 2: invalid configuration or contract.\nExit 2 reason producer-missing: the baseline directory exists but has no scripts/archetype-manifest.mjs exporter.\n');
14
14
  return 0;
15
15
  }
16
16
  if (!['--repo', '--archetype'].includes(arg) || !argv[index + 1] || argv[index + 1].startsWith('--')) {
@@ -25,6 +25,11 @@
25
25
  // (warn locally; upgraded to a hard failure under CI)
26
26
  // c1 pnpm-lock.yaml must NOT be tracked in git
27
27
  // c2 package-lock.json MUST be tracked in git
28
+ // 3. Husky activation (#1458, advisory only — never affects `ok`/exit code):
29
+ // h1 `.husky/` exists but git core.hooksPath is not `.husky/_` — the
30
+ // `prepare: husky` script never ran because ignore-scripts=true,
31
+ // so commit-msg/pre-commit/pre-push are inactive. Warns locally
32
+ // and recommends `npx husky`; silent under CI.
28
33
  //
29
34
  // SCOPE GUARD:
30
35
  // This script only guards the REPOSITORY ROOT package.json. If invoked
@@ -64,8 +69,8 @@
64
69
  // on any dependency install).
65
70
 
66
71
  import { existsSync, lstatSync, readFileSync } from 'node:fs';
67
- import { dirname, join, resolve } from 'node:path';
68
- import { spawnSync } from 'node:child_process';
72
+ import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
73
+ import { execFileSync, spawnSync } from 'node:child_process';
69
74
  import { fileURLToPath } from 'node:url';
70
75
  import { isMainModule } from './lib/is-main-module.mjs';
71
76
 
@@ -266,11 +271,53 @@ export function parseCiEnv(value) {
266
271
  return !['', '0', 'false', 'no'].includes(normalized);
267
272
  }
268
273
 
274
+ /** One-line stderr WARN for an inactive Husky install (#1458). */
275
+ export const HUSKY_INACTIVE_WARNING =
276
+ '⚠ check-package-manager: git core.hooksPath is not set to .husky/_ — Husky hooks (commit-msg, pre-commit, pre-push) are INACTIVE in this clone because .npmrc ignore-scripts=true skips the prepare script (#1458). Run: npx husky';
277
+
278
+ /**
279
+ * Advisory check (#1458): `.npmrc` `ignore-scripts=true` (SEC-020) means the
280
+ * `prepare: husky` script never runs on `npm ci`, so a fresh clone has no
281
+ * `core.hooksPath` and every Husky hook is silently inactive. This only
282
+ * REPORTS the state — it never runs `npx husky`, never writes git config and
283
+ * never affects the guard's `ok`/exit code. Silent under CI.
284
+ * @param {string} repoRoot
285
+ * @param {{ env?: NodeJS.ProcessEnv, ci?: boolean }} [opts] — `env` is also
286
+ * passed to the git subprocess (lets tests isolate from global git config).
287
+ * @returns {{ hooksPath: string, active: boolean, skipped: 'ci'|'no-husky-dir'|null }}
288
+ */
289
+ export function checkHuskyHooksPath(repoRoot, { env = process.env, ci } = {}) {
290
+ if (ci ?? parseCiEnv(env.CI)) return { hooksPath: '', active: false, skipped: 'ci' };
291
+ if (!existsSync(join(repoRoot, '.husky'))) {
292
+ return { hooksPath: '', active: false, skipped: 'no-husky-dir' };
293
+ }
294
+
295
+ let hooksPath;
296
+ try {
297
+ hooksPath = execFileSync('git', ['-C', repoRoot, 'config', '--get', 'core.hooksPath'], {
298
+ encoding: 'utf8',
299
+ env,
300
+ stdio: ['ignore', 'pipe', 'ignore'],
301
+ }).trim();
302
+ } catch {
303
+ // Non-zero exit (key unset) or git unavailable — treat as unset.
304
+ hooksPath = '';
305
+ }
306
+
307
+ const norm = hooksPath.replace(/\\/g, '/').replace(/\/+$/, '');
308
+ const active =
309
+ norm !== '' &&
310
+ (isAbsolute(hooksPath) ? norm.endsWith('/.husky/_') : posix.normalize(norm) === '.husky/_');
311
+
312
+ return { hooksPath, active, skipped: null };
313
+ }
314
+
269
315
  /**
270
316
  * Runs the full guard and returns a plain-object result. Pure(ish) — the
271
317
  * only side effects are read-only fs/git calls. Never throws or exits.
272
- * @param {{ cwd?: string, ci?: boolean, allowMissingNodeModules?: boolean, repoRoot?: string }} [opts]
273
- * @returns {{ ok: boolean, ci: boolean, skipped: boolean, repoRoot: string, findings: Array<{code: string, severity: string, effectiveSeverity: string, message: string}> }}
318
+ * `hooks` carries the advisory Husky check (#1458); it never affects `ok`.
319
+ * @param {{ cwd?: string, ci?: boolean, allowMissingNodeModules?: boolean, repoRoot?: string, env?: NodeJS.ProcessEnv }} [opts]
320
+ * @returns {{ ok: boolean, ci: boolean, skipped: boolean, repoRoot: string, findings: Array<{code: string, severity: string, effectiveSeverity: string, message: string}>, hooks?: { hooksPath: string, active: boolean, skipped: 'ci'|'no-husky-dir'|null } }}
274
321
  */
275
322
  export function runPackageManagerGuard(opts = {}) {
276
323
  const cwd = opts.cwd ?? process.cwd();
@@ -302,7 +349,9 @@ export function runPackageManagerGuard(opts = {}) {
302
349
 
303
350
  const ok = !findings.some((f) => f.effectiveSeverity === 'error');
304
351
 
305
- return { ok, ci, skipped: false, repoRoot, findings };
352
+ const hooks = checkHuskyHooksPath(repoRoot, { env: opts.env ?? process.env, ci });
353
+
354
+ return { ok, ci, skipped: false, repoRoot, findings, hooks };
306
355
  }
307
356
 
308
357
  // ---------------------------------------------------------------------------
@@ -332,6 +381,9 @@ CHECKS
332
381
  (warn locally; upgraded to a hard failure under CI, fail-closed)
333
382
  c1 pnpm-lock.yaml must NOT be tracked in git
334
383
  c2 package-lock.json MUST be tracked in git
384
+ Husky activation (advisory, never changes the exit code; silent under CI):
385
+ h1 .husky/ exists but git core.hooksPath is not .husky/_ (#1458) —
386
+ run \`npx husky\` once (ignore-scripts=true skips \`prepare\`)
335
387
 
336
388
  OPTIONS
337
389
  --allow-missing-node-modules Document that node_modules may not exist yet
@@ -412,6 +464,11 @@ if (isMain) {
412
464
 
413
465
  const result = runPackageManagerGuard({ allowMissingNodeModules: opts.allowMissingNodeModules });
414
466
 
467
+ // Advisory only (#1458) — never changes the exit code.
468
+ if (result.hooks && !result.hooks.active && result.hooks.skipped === null) {
469
+ console.error(HUSKY_INACTIVE_WARNING);
470
+ }
471
+
415
472
  if (opts.json) {
416
473
  console.log(JSON.stringify(result));
417
474
  process.exit(result.ok ? 0 : 1);
@@ -20,6 +20,7 @@ import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  import { CURSOR_TO_CANONICAL_EVENT } from './lib/cursor-hook-bridge.mjs';
23
+ import { shellQuote as shQuote } from './lib/sh-quote.mjs';
23
24
 
24
25
  const __filename = fileURLToPath(import.meta.url);
25
26
  const SCRIPT_DIR = path.dirname(__filename);
@@ -62,10 +63,6 @@ function _isDir(p) {
62
63
  try { return statSync(p).isDirectory(); } catch { return false; }
63
64
  }
64
65
 
65
- function shQuote(value) {
66
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
67
- }
68
-
69
66
  function linkPath(source, dest, label) {
70
67
  mkdirSync(path.dirname(dest), { recursive: true });
71
68
  if (_isSymlink(dest)) {
@@ -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,122 @@ 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 subagent record
484
+ // (#1475: unknown model, no model, or no tokens), which the persisted
485
+ // `cost_records_priced < cost_records_total` then explains.
486
+ // match_status explains the omission; diagnostic fields stay out of the record.
487
+ if (ownUuid !== null && !hasOwn('total_tokens')) {
488
+ let rollup = null;
489
+ try {
490
+ rollup = rollupSessionTokens({
491
+ parentSessionId: ownUuid,
492
+ subagentsPath: join(dirname(args.file), 'subagents.jsonl'),
493
+ });
494
+ } catch (err) {
495
+ process.stderr.write(
496
+ `emit-session: WARN token rollup failed (${err?.message ?? err}); omitting token fields\n`
497
+ );
498
+ }
499
+ if (rollup !== null && rollup.matched_records > 0) {
500
+ const merged = {};
501
+ for (const key of ROLLUP_KEYS) {
502
+ if (hasOwn(key) || !Number.isFinite(rollup[key])) continue;
503
+ // Mirrors validateSession(): these two are non-negative INTEGERS, while
504
+ // total_token_input/output may be fractional — one fractional token
505
+ // value upstream must cost the field, not the record.
506
+ if (INTEGER_ROLLUP_KEYS.has(key) && !Number.isInteger(rollup[key])) {
507
+ process.stderr.write(
508
+ `emit-session: WARN token rollup produced an invalid field (${key} must be a ` +
509
+ `non-negative integer, got: ${rollup[key]}); omitting ${key}\n`
510
+ );
511
+ continue;
512
+ }
513
+ merged[key] = rollup[key];
514
+ }
515
+ // Enrichment must never be the reason the ledger gains no line: validate
516
+ // the enriched record first and fall back to the un-enriched one. Blamed
517
+ // on the rollup only when the un-enriched record validates on its own —
518
+ // otherwise the validation below reports the record's own defect.
519
+ try {
520
+ validateSession({ ...repaired, ...merged });
521
+ repaired = { ...repaired, ...merged };
522
+ } catch (err) {
523
+ if (!(err instanceof ValidationError)) throw err;
524
+ let baseValid = true;
525
+ try {
526
+ validateSession(repaired);
527
+ } catch {
528
+ baseValid = false;
529
+ }
530
+ if (baseValid) {
531
+ process.stderr.write(
532
+ `emit-session: WARN token rollup produced an invalid field (${err.message}); ` +
533
+ `omitting token fields\n`
534
+ );
535
+ }
536
+ }
537
+ if (rollup.total_tokens === null) {
538
+ process.stderr.write(
539
+ `emit-session: WARN token rollup matched ${rollup.matched_records} subagents.jsonl record(s) ` +
540
+ `for ${ownUuid}, none token-bearing; total_tokens omitted\n`
541
+ );
542
+ }
543
+ } else if (rollup !== null) {
544
+ let warning = `token rollup found no subagents.jsonl records for ${ownUuid}`;
545
+ switch (rollup.match_status) {
546
+ case 'ledger-absent':
547
+ warning += ' (ledger absent)';
548
+ break;
549
+ case 'ledger-empty':
550
+ warning += ' (ledger holds no readable records)';
551
+ break;
552
+ case 'unmatched':
553
+ warning = `token rollup found ${rollup.ledger_records} subagents.jsonl record(s) present, ` +
554
+ `none with parent_session_id=${ownUuid} — not attributable via this UUID, not zero cost`;
555
+ break;
556
+ case 'invalid-key':
557
+ warning = `token rollup rejected invalid parent_session_id=${ownUuid}`;
558
+ break;
559
+ default:
560
+ // Unknown or missing diagnostics retain the generic omission warning.
561
+ break;
562
+ }
563
+ process.stderr.write(`emit-session: WARN ${warning}; omitting token fields\n`);
564
+ }
565
+ }
566
+
273
567
  let validated;
274
568
  try {
275
569
  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,6 +59,7 @@ import {
58
59
  rewriteLearnings,
59
60
  CURRENT_ANONYMIZATION_VERSION,
60
61
  } from './lib/learnings.mjs';
62
+ import { withLearningsLock } from './lib/learnings/io.mjs';
61
63
  import { findProjectRoot, resolveInstructionFile, expandTilde } from './lib/common.mjs';
62
64
  import { parseSessionConfig } from './lib/config.mjs';
63
65
  import { createSecretValueMasker } from './lib/secret-masker.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).
@@ -634,6 +637,22 @@ function parseArgs(argv) {
634
637
  * @returns {Promise<{promoted: number, skipped: number, flags: string[]}>}
635
638
  */
636
639
  export async function promoteHwLearnings(opts) {
640
+ // The read → rewrite below runs under the store lock (#1447 point 8): an
641
+ // append landing between them would vanish in the rename. A dry run writes
642
+ // nothing and takes no lock. A lock not acquired throws LearningsLockError
643
+ // (nothing written), which the CLI reports as `export failed: …`, exit 1.
644
+ if (opts.dryRun) return promoteHwLearningsUnlocked(opts);
645
+ return withLearningsLock(opts.input, () => promoteHwLearningsUnlocked(opts));
646
+ }
647
+
648
+ /**
649
+ * Body of {@link promoteHwLearnings}; the caller holds the store lock or runs
650
+ * a dry run.
651
+ *
652
+ * @param {Parameters<typeof promoteHwLearnings>[0]} opts
653
+ * @returns {Promise<{promoted: number, skipped: number, flags: string[]}>}
654
+ */
655
+ async function promoteHwLearningsUnlocked(opts) {
637
656
  const repoRoot = opts.repoRoot;
638
657
  const { entries, malformed } = await readLearnings(opts.input);
639
658