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
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * apply-session-learnings.mjs — the ONLY sanctioned session-end write path for
4
+ * `learnings.jsonl` (GitLab #1446 / GitHub #69). session-end Phase 3.6 and the
5
+ * `/plan retro` §3.3 learnings update hand their collected changes to this CLI;
6
+ * neither may rewrite the store with a shell `>` or append to it with `>>`
7
+ * (`scripts/lib/validate/check-learnings-shell-writes.mjs` gates the prose).
8
+ *
9
+ * Why a CLI and not prose: until #1446, Phase 3.6 step 4g told the coordinator
10
+ * to "write the entire result back (atomic rewrite with `>`)" from a template
11
+ * that carried no `scope` and no `file_paths`. A shell rewrite bypasses
12
+ * `validateLearning`, the `.bak-<ISO>` snapshot and the archive sidecar — and
13
+ * the producer that wrote a file PATH into the `scope` column (GH#69) was
14
+ * never rejected by anything.
15
+ *
16
+ * Input (stdin, or `--input PATH`), one JSON object:
17
+ * {
18
+ * "confidence_updates": [{ "id": "<existing id>", "operation": "confirm"|"contradict" }],
19
+ * "new_learnings": [ <complete learning objects> ]
20
+ * }
21
+ * Both keys are optional (absent = empty list).
22
+ *
23
+ * Steps — every one delegates to an existing helper:
24
+ * 1. read the store `readLearnings()` (io.mjs)
25
+ * 2. confirm → +0.15, cap 1.0, `expires_at` reset via `deriveExpiresAt(now, type)`
26
+ * contradict → −0.2, floor 0, `expires_at` untouched
27
+ * 3. new learnings → stamped like `appendLearning()` (`created_at`,
28
+ * `expires_at`, `schema_version: 1` when absent), then STRICT
29
+ * `validateLearning()` — one invalid record exits 1 with nothing written
30
+ * 4. passive decay (#89) → `learning-decay-rate` subtracted from every
31
+ * existing learning NOT confirmed/contradicted this run, clamped ≥ 0,
32
+ * `expires_at` untouched
33
+ * 5. prune (expired, confidence ≤ 0) + consolidate (type+subject, highest
34
+ * confidence wins) + archive the losers + atomic rewrite with `.bak-<ISO>`
35
+ * → `pruneLearnings()` (expiry-sweep.mjs). Archived records carry this
36
+ * run's DECAYED confidence, a value never stored in the live file: decay
37
+ * (step 4) runs before prune by design, so a record pruned at
38
+ * `confidence <= 0` is archived with the value that pruned it.
39
+ *
40
+ * On `--apply`, steps 1-5 run as ONE critical section under the store lock
41
+ * (`withLearningsLock()`, io.mjs; lock file `<store>.lock`): a parallel
42
+ * `--apply` or an `appendLearning()` cannot land between the read and the
43
+ * rewrite and be lost. A lock not acquired within 10 s exits 2, nothing written.
44
+ *
45
+ * Usage:
46
+ * node scripts/apply-session-learnings.mjs [--input PATH] [--file PATH]
47
+ * [--repo-root PATH] [--dry-run|--apply] [--json] [--decay-rate N]
48
+ *
49
+ * Flags:
50
+ * --input PATH JSON input file (default: read stdin)
51
+ * --repo-root PATH Repo root (default: cwd). Resolves a relative `--file`,
52
+ * supplies the Session Config for the decay rate, and pins
53
+ * the telemetry record.
54
+ * --file PATH Learnings store (default: .orchestrator/metrics/learnings.jsonl).
55
+ * The archive is its sibling `learnings-archive.jsonl`.
56
+ * --dry-run Preview the summary; write nothing, no `.bak` (DEFAULT)
57
+ * --apply Perform the archive append + store rewrite
58
+ * --json Emit one machine-parseable JSON summary line
59
+ * --decay-rate N Passive decay per run, 0..1 (default: Session Config
60
+ * `learning-decay-rate`, else 0.05). 0 disables decay.
61
+ *
62
+ * Telemetry: `--apply` emits `orchestrator.learnings.session_write_applied`
63
+ * after the write — its presence is the proof the write ran. Best-effort: a
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
66
+ * whose store was not written.
67
+ *
68
+ * Exit codes:
69
+ * 0 Success (including a no-op)
70
+ * 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)
73
+ */
74
+
75
+ import { readFileSync } from 'node:fs';
76
+ import path from 'node:path';
77
+ import { LearningsLockError, readLearnings, withLearningsLock } from './lib/learnings/io.mjs';
78
+ import { pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
79
+ import {
80
+ CURRENT_SCHEMA_VERSION,
81
+ deriveExpiresAt,
82
+ validateLearning,
83
+ } from './lib/learnings/schema.mjs';
84
+ import { parseSessionConfig, readConfigFile } from './lib/config.mjs';
85
+ import { isMainModule } from './lib/is-main-module.mjs';
86
+
87
+ const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
88
+ const ARCHIVE_BASENAME = 'learnings-archive.jsonl';
89
+ const DEFAULT_DECAY_RATE = 0.05;
90
+ const CONFIRM_DELTA = 0.15;
91
+ const CONTRADICT_DELTA = 0.2;
92
+ const OPERATIONS = new Set(['confirm', 'contradict']);
93
+
94
+ /** Event name — registered in docs/events-schema.md. */
95
+ export const SESSION_WRITE_EVENT = 'orchestrator.learnings.session_write_applied';
96
+
97
+ class UsageError extends Error {}
98
+
99
+ /**
100
+ * Round to 6 decimals. Without it, ten 0.05 decay steps from 0.5 leave a
101
+ * float residue > 0 that the `confidence <= 0` prune never catches.
102
+ *
103
+ * @param {number} n
104
+ * @returns {number}
105
+ */
106
+ function round6(n) {
107
+ return Math.round(n * 1e6) / 1e6;
108
+ }
109
+
110
+ function printHelp() {
111
+ process.stdout.write(
112
+ 'Usage: node scripts/apply-session-learnings.mjs [--input PATH] [--file PATH] ' +
113
+ '[--repo-root PATH] [--dry-run|--apply] [--json] [--decay-rate N]\n',
114
+ );
115
+ }
116
+
117
+ function parseArgs(argv) {
118
+ const opts = { input: null, file: null, repoRoot: null, apply: false, json: false, decayRate: null };
119
+ const valueOf = (i, flag) => {
120
+ const v = argv[i + 1];
121
+ if (v === undefined || v.startsWith('--')) throw new UsageError(`${flag} requires a value`);
122
+ return v;
123
+ };
124
+ for (let i = 0; i < argv.length; i += 1) {
125
+ const a = argv[i];
126
+ switch (a) {
127
+ case '--help':
128
+ case '-h':
129
+ opts.help = true;
130
+ break;
131
+ case '--apply':
132
+ opts.apply = true;
133
+ break;
134
+ case '--dry-run':
135
+ opts.apply = false;
136
+ break;
137
+ case '--json':
138
+ opts.json = true;
139
+ break;
140
+ case '--input':
141
+ opts.input = valueOf(i, a);
142
+ i += 1;
143
+ break;
144
+ case '--file':
145
+ opts.file = valueOf(i, a);
146
+ i += 1;
147
+ break;
148
+ case '--repo-root':
149
+ opts.repoRoot = valueOf(i, a);
150
+ i += 1;
151
+ break;
152
+ case '--decay-rate': {
153
+ const raw = valueOf(i, a);
154
+ const n = Number(raw);
155
+ if (raw.trim() === '' || !Number.isFinite(n) || n < 0 || n > 1) {
156
+ throw new UsageError(`--decay-rate must be a number in [0, 1], got: ${raw}`);
157
+ }
158
+ opts.decayRate = n;
159
+ i += 1;
160
+ break;
161
+ }
162
+ default:
163
+ throw new UsageError(`unknown argument: ${a}`);
164
+ }
165
+ }
166
+ return opts;
167
+ }
168
+
169
+ /**
170
+ * Parse and shape-check the input document. Throws UsageError.
171
+ *
172
+ * @param {string} raw
173
+ * @returns {{ updates: {id: string, operation: string}[], newLearnings: object[] }}
174
+ */
175
+ function parseInput(raw) {
176
+ let doc;
177
+ try {
178
+ doc = JSON.parse(raw);
179
+ } catch (err) {
180
+ throw new UsageError(`input is not valid JSON: ${err.message}`);
181
+ }
182
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
183
+ throw new UsageError('input must be a JSON object {confidence_updates, new_learnings}');
184
+ }
185
+ const updates = doc.confidence_updates ?? [];
186
+ const newLearnings = doc.new_learnings ?? [];
187
+ if (!Array.isArray(updates)) throw new UsageError('confidence_updates must be an array');
188
+ if (!Array.isArray(newLearnings)) throw new UsageError('new_learnings must be an array');
189
+ const seen = new Set();
190
+ updates.forEach((u, i) => {
191
+ if (!u || typeof u.id !== 'string' || u.id.length === 0) {
192
+ throw new UsageError(`confidence_updates[${i}]: id must be a non-empty string`);
193
+ }
194
+ if (!OPERATIONS.has(u.operation)) {
195
+ throw new UsageError(
196
+ `confidence_updates[${i}] (id=${u.id}): operation must be confirm|contradict, got: ${u.operation}`,
197
+ );
198
+ }
199
+ if (seen.has(u.id)) throw new UsageError(`confidence_updates[${i}]: id ${u.id} listed twice`);
200
+ seen.add(u.id);
201
+ });
202
+ return { updates, newLearnings };
203
+ }
204
+
205
+ /**
206
+ * Session Config `learning-decay-rate`, or the default when no config is
207
+ * readable. Never throws.
208
+ *
209
+ * @param {string} repoRoot
210
+ * @returns {Promise<number>}
211
+ */
212
+ async function resolveDecayRate(repoRoot) {
213
+ try {
214
+ const cfg = parseSessionConfig(await readConfigFile(repoRoot));
215
+ const rate = cfg?.['learning-decay-rate'];
216
+ return typeof rate === 'number' && Number.isFinite(rate) ? rate : DEFAULT_DECAY_RATE;
217
+ } catch {
218
+ return DEFAULT_DECAY_RATE;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Build the next store generation from the current one plus the input.
224
+ * Pure apart from the clock. Throws UsageError on an unknown/duplicate id or
225
+ * an invalid new learning.
226
+ *
227
+ * @returns {{ next: object[], confirmed: number, contradicted: number, appended: number,
228
+ * decayed: number, undecayable: number }}
229
+ */
230
+ export function buildNextGeneration({ current, updates, newLearnings, decayRate, nowIso }) {
231
+ const byId = new Map();
232
+ for (const entry of current) {
233
+ if (typeof entry?.id === 'string' && entry.id.length > 0) byId.set(entry.id, entry);
234
+ }
235
+
236
+ const updateOf = new Map();
237
+ for (const u of updates) {
238
+ if (!byId.has(u.id)) {
239
+ throw new UsageError(`confidence_updates: id ${u.id} not found in the store`);
240
+ }
241
+ updateOf.set(u.id, u.operation);
242
+ }
243
+
244
+ let confirmed = 0;
245
+ let contradicted = 0;
246
+ let decayed = 0;
247
+ let undecayable = 0;
248
+ const next = current.map((entry) => {
249
+ const op = typeof entry?.id === 'string' ? updateOf.get(entry.id) : undefined;
250
+ const c = entry?.confidence;
251
+ if (op === 'confirm') {
252
+ confirmed += 1;
253
+ return {
254
+ ...entry,
255
+ confidence: round6(Math.min(1, (typeof c === 'number' ? c : 0) + CONFIRM_DELTA)),
256
+ expires_at: deriveExpiresAt(nowIso, entry.type),
257
+ };
258
+ }
259
+ if (op === 'contradict') {
260
+ contradicted += 1;
261
+ return { ...entry, confidence: round6(Math.max(0, (typeof c === 'number' ? c : 0) - CONTRADICT_DELTA)) };
262
+ }
263
+ if (decayRate > 0 && typeof c === 'number' && c > 0) {
264
+ // An id-less record is keyed by a content fingerprint in pruneLearnings'
265
+ // reconcile pass, so changing its confidence would archive the old
266
+ // version as a spurious "pruned" duplicate every run. It is left to age
267
+ // out via `expires_at` instead — revisit once the legacy id-less records
268
+ // are gone (`migrateLegacyLearning` backfill).
269
+ if (typeof entry?.id !== 'string' || entry.id.length === 0) {
270
+ undecayable += 1;
271
+ return entry;
272
+ }
273
+ decayed += 1;
274
+ return { ...entry, confidence: round6(Math.max(0, c - decayRate)) };
275
+ }
276
+ return entry;
277
+ });
278
+
279
+ const newIds = new Set();
280
+ newLearnings.forEach((raw, i) => {
281
+ const label = `new_learnings[${i}] (id=${raw?.id ?? '<missing>'})`;
282
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
283
+ throw new UsageError(`${label}: must be an object`);
284
+ }
285
+ // Stamping mirrors appendLearning() (io.mjs) so both write paths agree.
286
+ const createdAt =
287
+ typeof raw.created_at === 'string' && raw.created_at.length > 0 ? raw.created_at : nowIso;
288
+ const stamped = {
289
+ ...raw,
290
+ created_at: createdAt,
291
+ expires_at:
292
+ typeof raw.expires_at === 'string' && raw.expires_at.length > 0
293
+ ? raw.expires_at
294
+ : deriveExpiresAt(createdAt, raw.type),
295
+ schema_version: raw.schema_version ?? CURRENT_SCHEMA_VERSION,
296
+ };
297
+ let validated;
298
+ try {
299
+ validated = validateLearning(stamped);
300
+ } catch (err) {
301
+ throw new UsageError(`${label}: ${err.message}`);
302
+ }
303
+ if (byId.has(validated.id) || newIds.has(validated.id)) {
304
+ throw new UsageError(`${label}: id already exists — use confidence_updates to reinforce it`);
305
+ }
306
+ newIds.add(validated.id);
307
+ next.push(validated);
308
+ });
309
+
310
+ return { next, confirmed, contradicted, appended: newIds.size, decayed, undecayable };
311
+ }
312
+
313
+ async function emitSessionWriteApplied({ repoRoot, filePath, payload }) {
314
+ const rel = path.relative(repoRoot, filePath);
315
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
316
+ process.stderr.write(
317
+ `apply-session-learnings: skipped ${SESSION_WRITE_EVENT} — ${filePath} lies outside ` +
318
+ `--repo-root ${repoRoot}\n`,
319
+ );
320
+ return;
321
+ }
322
+ try {
323
+ const { emitEvent, sessionAttribution } = await import('./lib/events.mjs');
324
+ await emitEvent(
325
+ SESSION_WRITE_EVENT,
326
+ { file: rel, ...payload, ...sessionAttribution(repoRoot) },
327
+ { repoRoot },
328
+ );
329
+ } catch (err) {
330
+ // Best-effort telemetry — the write already happened and is authoritative.
331
+ process.stderr.write(`apply-session-learnings: ${SESSION_WRITE_EVENT} not recorded: ${err?.message ?? err}\n`);
332
+ }
333
+ }
334
+
335
+ async function main(argv) {
336
+ let opts;
337
+ let input;
338
+ try {
339
+ opts = parseArgs(argv);
340
+ if (opts.help) {
341
+ printHelp();
342
+ return 0;
343
+ }
344
+ if (opts.input === null && process.stdin.isTTY) {
345
+ throw new UsageError('no input — pipe JSON on stdin or pass --input PATH');
346
+ }
347
+ let raw;
348
+ try {
349
+ raw = readFileSync(opts.input ?? 0, 'utf8');
350
+ } catch (err) {
351
+ throw new UsageError(`cannot read input: ${err.message}`);
352
+ }
353
+ input = parseInput(raw);
354
+ } catch (err) {
355
+ if (err instanceof UsageError) {
356
+ process.stderr.write(`apply-session-learnings: ${err.message}\n`);
357
+ printHelp();
358
+ return 1;
359
+ }
360
+ throw err;
361
+ }
362
+
363
+ const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
364
+ const filePath = path.resolve(repoRoot, opts.file ?? DEFAULT_FILE);
365
+ const archivePath = path.join(path.dirname(filePath), ARCHIVE_BASENAME);
366
+ const decayRate = opts.decayRate ?? (await resolveDecayRate(repoRoot));
367
+ const now = new Date();
368
+ const nowIso = now.toISOString();
369
+
370
+ // Read → build → prune/rewrite: one critical section on --apply (see header).
371
+ // Returns `{ code }` for an early exit, else the three results.
372
+ const readBuildPrune = async () => {
373
+ let read;
374
+ try {
375
+ read = await readLearnings(filePath);
376
+ } catch (err) {
377
+ process.stderr.write(`apply-session-learnings: cannot read ${filePath}: ${err.message}\n`);
378
+ return { code: 2 };
379
+ }
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
+ }
388
+
389
+ let built;
390
+ try {
391
+ built = buildNextGeneration({
392
+ current: read.entries,
393
+ updates: input.updates,
394
+ newLearnings: input.newLearnings,
395
+ decayRate,
396
+ nowIso,
397
+ });
398
+ } catch (err) {
399
+ if (err instanceof UsageError) {
400
+ process.stderr.write(`apply-session-learnings: ${err.message} — nothing written\n`);
401
+ return { code: 1 };
402
+ }
403
+ throw err;
404
+ }
405
+
406
+ let res;
407
+ try {
408
+ // Joins the lock held below reentrantly; takes none on a dry run.
409
+ res = await pruneLearnings({ filePath, archivePath, entries: built.next, now, dryRun: !opts.apply });
410
+ } catch (err) {
411
+ process.stderr.write(`apply-session-learnings: prune/rewrite failed: ${err.message}\n`);
412
+ return { code: 2 };
413
+ }
414
+ return { read, built, res };
415
+ };
416
+
417
+ let outcome;
418
+ try {
419
+ outcome = opts.apply ? await withLearningsLock(filePath, readBuildPrune) : await readBuildPrune();
420
+ } catch (err) {
421
+ if (err instanceof LearningsLockError) {
422
+ process.stderr.write(`apply-session-learnings: ${err.message}\n`);
423
+ return 2;
424
+ }
425
+ throw err;
426
+ }
427
+ if ('code' in outcome) return outcome.code;
428
+ const { read, built, res } = outcome;
429
+
430
+ const byReason = res.byReason ?? {};
431
+ const pruned = (byReason.expired ?? 0) + (byReason.pruned ?? 0);
432
+ const summary = {
433
+ dry_run: !opts.apply,
434
+ file: filePath,
435
+ read: read.entries.length,
436
+ malformed: read.malformed.length,
437
+ confirmed: built.confirmed,
438
+ contradicted: built.contradicted,
439
+ appended: built.appended,
440
+ decay_rate: decayRate,
441
+ decayed: built.decayed,
442
+ undecayable: built.undecayable,
443
+ pruned,
444
+ consolidated: byReason.superseded ?? 0,
445
+ archived: res.archived,
446
+ kept: res.kept,
447
+ by_reason: byReason,
448
+ written: opts.apply ? res.kept : 0,
449
+ };
450
+
451
+ // The event's presence is documented as proof the write ran
452
+ // (docs/events-schema.md). pruneLearnings() returns early WITHOUT writing
453
+ // exactly when the store and the next generation are both empty — the only
454
+ // path that reports `scanned: 0` — so no store + empty input emits nothing.
455
+ if (opts.apply && res.scanned > 0) {
456
+ await emitSessionWriteApplied({
457
+ repoRoot,
458
+ filePath,
459
+ payload: {
460
+ appended: built.appended,
461
+ confirmed: built.confirmed,
462
+ contradicted: built.contradicted,
463
+ decayed: built.decayed,
464
+ pruned,
465
+ },
466
+ });
467
+ }
468
+
469
+ if (opts.json) {
470
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
471
+ } else {
472
+ process.stdout.write(
473
+ `${summary.dry_run ? '[dry-run] ' : ''}learnings: read ${summary.read}, confirmed ${summary.confirmed}, ` +
474
+ `contradicted ${summary.contradicted}, appended ${summary.appended}, decayed ${summary.decayed} ` +
475
+ `(rate ${decayRate}), pruned ${pruned}, consolidated ${summary.consolidated}, kept ${summary.kept}` +
476
+ `${summary.dry_run ? ' — nothing written' : ` — wrote ${summary.written}`}\n`,
477
+ );
478
+ }
479
+ return 0;
480
+ }
481
+
482
+ if (isMainModule(import.meta.url)) {
483
+ main(process.argv.slice(2)).then(
484
+ (code) => {
485
+ process.exitCode = code;
486
+ },
487
+ (err) => {
488
+ process.stderr.write(`apply-session-learnings: ${err?.stack ?? err}\n`);
489
+ process.exitCode = 2;
490
+ },
491
+ );
492
+ }
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * Flow:
14
14
  * 1. findProjectRoot → resolve CLAUDE.md → parseSessionConfig → vault-integration.vault-dir
15
- * (host-resolved: SO_VAULT_DIR env > owner.yaml paths.vault-dir > committed).
15
+ * (host-resolved: SO_VAULT_DIR env > owner.yaml vault-dirs: cwd match >
16
+ * owner.yaml paths.vault-dir > committed — `resolveVaultDir` in lib/config.mjs).
16
17
  * 2. Enumerate tracked .md files under --prd-dir (git ls-files), excluding
17
18
  * *.original-uncommitted.md. A missing/empty --prd-dir yields [] → clean
18
19
  * report, exit 0 (no crash) — see listTrackedPrds.
@@ -14,7 +14,10 @@
14
14
  * --max-sessions=N Max iterations (1..50, default 5).
15
15
  * --max-hours=H Max wall-clock hours (0.5..24.0, default 4.0).
16
16
  * --confidence-threshold=0.X Mode confidence gate (0.0..1.0, default 0.85).
17
- * --max-tokens=N Cumulative output-token budget (0..10000000, default 500000; 0 disables).
17
+ * --max-tokens=N Cumulative token budget (0..10000000, default 0 = off). Counts
18
+ * SUBAGENT output tokens only (sessions.jsonl total_token_output,
19
+ * else total_tokens — both subagent-only; coordinator tokens are
20
+ * not counted). One deep session measured ~450k.
18
21
  * --dry-run Emit a single record without spawning sessions.
19
22
  * --verbose Pipe child process stdio (instead of inherit).
20
23
  *
@@ -33,7 +36,7 @@
33
36
  * "/autopilot Loop Command (Phase C)" (#277/#271; archived in the private Meta-Vault)
34
37
  */
35
38
 
36
- import { readFileSync, existsSync } from 'node:fs';
39
+ import { randomUUID } from 'node:crypto';
37
40
  import { spawnSync, spawn } from 'node:child_process';
38
41
  import { resolve } from 'node:path';
39
42
  import { fileURLToPath } from 'node:url';
@@ -49,7 +52,7 @@ import { selectMode } from './lib/mode-selector.mjs';
49
52
  import { probe, evaluate, DEFAULT_RESOURCE_THRESHOLDS } from './lib/resource-probe.mjs';
50
53
  import { detectPeers } from './lib/session-registry.mjs';
51
54
  import { normalizeSession } from './lib/session-schema.mjs';
52
- import { readCanonicalSessions } from './lib/sessions-canonical.mjs';
55
+ import { isBackfillStub, readCanonicalSessions } from './lib/sessions-canonical.mjs';
53
56
 
54
57
  // ---------------------------------------------------------------------------
55
58
  // CLI-level flag extraction
@@ -94,10 +97,7 @@ const { maxSessions, maxHours, confidenceThreshold, maxTokens, dryRun } = parseF
94
97
  */
95
98
  function loadConfig() {
96
99
  try {
97
- const scriptPath = resolve(
98
- new URL('.', import.meta.url).pathname,
99
- 'parse-config.mjs'
100
- );
100
+ const scriptPath = fileURLToPath(new URL('parse-config.mjs', import.meta.url));
101
101
  const result = spawnSync(
102
102
  process.execPath,
103
103
  [scriptPath],
@@ -185,50 +185,49 @@ const branch = detectBranch();
185
185
  const SESSIONS_JSONL_PATH = resolve('.orchestrator/metrics/sessions.jsonl');
186
186
 
187
187
  /**
188
- * Set of the DISTINCT session identities currently recorded in sessions.jsonl.
189
- * Empty set when the file is missing or unreadable.
190
- *
191
- * Identities, not lines (#1167): the ledger is append-only, so one physical
192
- * session can occupy two lines (an abandoned stub plus the authoritative
193
- * record that supersedes it, or the systemic double-stub pair). The
194
- * pre/post comparison below asks "did the session I just spawned record
195
- * itself?", and a raw line count answers that question wrong in BOTH
196
- * directions — a duplicate pair looks like two sessions, while a supersede
197
- * append (+1 line, −1 stub) leaves a canonical COUNT unchanged. Comparing the
198
- * id SETS is immune to both.
188
+ * Read the child's canonical record by its raw harness UUID, independent of
189
+ * semantic session IDs and peer append order. Zero or multiple matches throw;
190
+ * only the unique record supplies normalized fields and finite token usage.
199
191
  *
200
- * @returns {Set<string>}
192
+ * A unique record that is a BACKFILL STUB throws too (#1457 F2): a stub is a
193
+ * placeholder some backfill wrote because the child never closed — no waves,
194
+ * agents or token totals. Its usage is UNKNOWN, not 0, and its empty
195
+ * agent_summary/effectiveness would blind the post-session kill-switches as
196
+ * well, so it fails closed like the zero-match case above: the loop records
197
+ * kill_switch=failed-wave with a detail naming the stub, instead of counting a
198
+ * healthy 0-token iteration that silently undercounts --max-tokens.
199
+ * @returns {{session_id: string, agent_summary?: object, effectiveness?: object,
200
+ * usage?: {output_tokens?: number, total_tokens?: number}}}
201
201
  */
202
- function readSessionIds() {
203
- if (!existsSync(SESSIONS_JSONL_PATH)) return new Set();
204
- const ids = new Set();
205
- for (const rec of readCanonicalSessions({ filePath: SESSIONS_JSONL_PATH })) {
206
- if (typeof rec.session_id === 'string' && rec.session_id.length > 0) {
207
- ids.add(rec.session_id);
208
- }
202
+ function readOwnSession(childSessionId) {
203
+ const own = readCanonicalSessions({ filePath: SESSIONS_JSONL_PATH }).filter((r) => r.raw_session_id === childSessionId);
204
+ if (own.length === 0) {
205
+ throw new Error(
206
+ `no session record carries raw_session_id=${childSessionId} (the child wrote none, or none stamped ` +
207
+ 'with its raw id) — refusing to fall back to the ledger tail (#1457)'
208
+ );
209
209
  }
210
- return ids;
211
- }
212
-
213
- /**
214
- * Read the last non-empty line from sessions.jsonl, parse it, run it through
215
- * normalizeSession, and project to the sessionRunner return shape.
216
- *
217
- * @returns {{session_id: string, agent_summary?: object, effectiveness?: object}}
218
- */
219
- function readTailSession() {
220
- const raw = readFileSync(SESSIONS_JSONL_PATH, 'utf8');
221
- const lines = raw.split('\n').filter((l) => l.trim().length > 0);
222
- if (lines.length === 0) {
223
- throw new Error('sessions.jsonl is empty after session completed');
210
+ if (own.length > 1) {
211
+ throw new Error(`ambiguous: ${own.length} canonical records carry raw_session_id=${childSessionId} (#1457)`);
212
+ }
213
+ if (isBackfillStub(own[0])) {
214
+ throw new Error(
215
+ `the only record with raw_session_id=${childSessionId} is a backfill stub ` +
216
+ `(_backfill_source=${own[0]._backfill_source}, no waves/agents/token totals) — token usage unknown, ` +
217
+ 'refusing to count it as a 0-token iteration (#1457 F2)'
218
+ );
224
219
  }
225
- const parsed = JSON.parse(lines[lines.length - 1]);
226
- const normalized = normalizeSession(parsed);
227
- return {
220
+ const normalized = normalizeSession(own[0]);
221
+ const result = {
228
222
  session_id: normalized.session_id,
229
223
  agent_summary: normalized.agent_summary,
230
224
  effectiveness: normalized.effectiveness,
231
225
  };
226
+ const usage = {};
227
+ if (Number.isFinite(normalized.total_token_output)) usage.output_tokens = normalized.total_token_output;
228
+ if (Number.isFinite(normalized.total_tokens)) usage.total_tokens = normalized.total_tokens;
229
+ if (Object.keys(usage).length > 0) result.usage = usage;
230
+ return result;
232
231
  }
233
232
 
234
233
  // ---------------------------------------------------------------------------
@@ -266,13 +265,16 @@ async function modeSelector() {
266
265
 
267
266
  /**
268
267
  * sessionRunner — spawns `claude` with the recommended mode and awaits exit.
269
- * Verifies that a new sessions.jsonl record was appended before returning.
268
+ * Sets a fresh raw harness UUID so a peer's later ledger append is never read
269
+ * as the child's record (emit-session stamps that UUID as `raw_session_id`).
270
+ * After exit 0, returns its unique canonical record; zero or multiple matches
271
+ * throw into the loop's failed-wave channel.
270
272
  *
271
273
  * @param {{mode: string, autopilotRunId: string}} args
272
274
  * @returns {Promise<{session_id: string, agent_summary?: object, effectiveness?: object}>}
273
275
  */
274
276
  async function sessionRunner({ mode, autopilotRunId }) {
275
- const preIds = readSessionIds();
277
+ const childSessionId = randomUUID();
276
278
 
277
279
  await new Promise((res, rej) => {
278
280
  const childStdio = hasVerbose
@@ -286,7 +288,7 @@ async function sessionRunner({ mode, autopilotRunId }) {
286
288
  // resolves, and only when the plugin that defines it is on `--plugin-dir`.
287
289
  const child = spawn(
288
290
  'claude',
289
- ['-p', `/session-orchestrator:session ${mode}`, '--plugin-dir', PLUGIN_ROOT],
291
+ ['-p', `/session-orchestrator:session ${mode}`, '--plugin-dir', PLUGIN_ROOT, '--session-id', childSessionId],
290
292
  {
291
293
  env: { ...process.env, AUTOPILOT_RUN_ID: autopilotRunId },
292
294
  stdio: childStdio,
@@ -309,13 +311,7 @@ async function sessionRunner({ mode, autopilotRunId }) {
309
311
  });
310
312
  });
311
313
 
312
- const postIds = readSessionIds();
313
- const appeared = [...postIds].some((id) => !preIds.has(id));
314
- if (!appeared) {
315
- throw new Error('no session record appended');
316
- }
317
-
318
- return readTailSession();
314
+ return readOwnSession(childSessionId);
319
315
  }
320
316
 
321
317
  /**
@@ -653,7 +653,7 @@ async function main() {
653
653
 
654
654
  // --apply is an explicit opt-in; absent it (or with --dry-run) we never write.
655
655
  const apply = values.apply === true && values['dry-run'] !== true;
656
- const repoRoot = values['repo-root'] || getProjectDir();
656
+ const repoRoot = path.resolve(values['repo-root'] || getProjectDir());
657
657
 
658
658
  let assumeDeadBeforeMs = null;
659
659
  if (typeof values['assume-dead-before'] === 'string' && values['assume-dead-before'].length > 0) {