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
@@ -6,7 +6,7 @@
6
6
  // Required env: TYPECHECK_CMD, TEST_CMD
7
7
  // Optional env: FILES (comma-separated), SESSION_START_REF
8
8
 
9
- import { runCheck, resolveTestFiles, extractErrorLinesJson } from './gate-helpers.mjs';
9
+ import { runCheck, resolveTestFiles, extractErrorLinesJson, commandKillFields } from './gate-helpers.mjs';
10
10
 
11
11
  const typecheckCmd = process.env.TYPECHECK_CMD;
12
12
  const testCmd = process.env.TEST_CMD;
@@ -49,41 +49,32 @@ if (!testCmd) {
49
49
 
50
50
  const start = Date.now();
51
51
 
52
- let testStatus;
52
+ // A skipped test run spawned nothing: `{ status: 'skip' }`, no kill keys.
53
+ let testResult = { status: 'skip' };
53
54
  let errors = [];
54
55
 
55
56
  // --- typecheck (always runs unless cmd is "skip") ---
56
57
  const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
57
- const tcStatus = tcResult.status;
58
- if (tcStatus === 'fail') {
58
+ if (tcResult.status === 'fail') {
59
59
  errors = errors.concat(extractErrorLinesJson(tcResult.output, /error TS\d+/));
60
60
  }
61
61
 
62
62
  // --- test (scoped to changed/specified files) ---
63
- if (testCmd === 'skip') {
64
- testStatus = 'skip';
65
- } else {
63
+ if (testCmd !== 'skip') {
66
64
  const testFiles = await resolveTestFiles(files, sessionStartRef);
67
65
  if (testFiles.length > 0) {
68
66
  const fileArgs = testFiles.join(' ');
69
- const testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
70
- testStatus = testResult.status;
71
- if (testStatus === 'fail') {
72
- const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
73
- errors = errors.concat(testErrors);
74
- }
67
+ testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
75
68
  } else if (!files && !sessionStartRef) {
76
69
  // No FILES or SESSION_START_REF supplied: run the full test suite
77
- const testResult = await runCheck(testCmd, CHECK_OPTS);
78
- testStatus = testResult.status;
79
- if (testStatus === 'fail') {
80
- const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
81
- errors = errors.concat(testErrors);
82
- }
70
+ testResult = await runCheck(testCmd, CHECK_OPTS);
83
71
  } else {
84
72
  // Files/ref supplied but no test files found — skip
85
73
  process.stderr.write('warn: No test files found for incremental run; skipping tests\n');
86
- testStatus = 'skip';
74
+ }
75
+ if (testResult.status === 'fail') {
76
+ const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
77
+ errors = errors.concat(testErrors);
87
78
  }
88
79
  }
89
80
 
@@ -92,8 +83,18 @@ const duration_seconds = Math.round((Date.now() - start) / 1000);
92
83
  const result = {
93
84
  variant: 'incremental',
94
85
  duration_seconds,
95
- typecheck: tcStatus,
96
- test: testStatus,
86
+ // Each command object also carries its INNER kill ladder (`timed_out`,
87
+ // `kill_signals`, `survivors` pids) when `runCheck` spawned it — omitted for
88
+ // skip/stub (#1457). `run-quality-gate.mjs` joins them into its event.
89
+ typecheck: { status: tcResult.status, ...commandKillFields(tcResult) },
90
+ test: { status: testResult.status, ...commandKillFields(testResult) },
91
+ // Stub map, same shape as gate-full: a command `runCheck` recognised as a
92
+ // stub (`echo …`/`true`) spawned nothing, so `joinGateKillFields` may count
93
+ // it as measured instead of declaring the whole inner ladder unknown.
94
+ stubbed: {
95
+ ...(tcResult.stubbed ? { typecheck: tcResult.stubbed } : {}),
96
+ ...(testResult.stubbed ? { test: testResult.stubbed } : {}),
97
+ },
97
98
  errors,
98
99
  };
99
100
 
@@ -9,7 +9,7 @@
9
9
  * Part of v3.2 shell-helper port migration (issue #218 / #317).
10
10
  */
11
11
 
12
- import { runCheck, csvToJsonArray } from './gate-helpers.mjs';
12
+ import { runCheck, csvToJsonArray, commandKillFields } from './gate-helpers.mjs';
13
13
 
14
14
  const typecheckCmd = process.env.TYPECHECK_CMD;
15
15
  const testCmd = process.env.TEST_CMD;
@@ -59,20 +59,29 @@ if (files.length === 0) {
59
59
 
60
60
  // Typecheck runs on the whole project, not per-file
61
61
  const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
62
- const tcStatus = tcResult.status;
63
62
 
64
- let testStatus = 'skip';
63
+ // A skipped test run spawned nothing: `{ status: 'skip' }`, no kill keys.
64
+ let testResult = { status: 'skip' };
65
65
 
66
66
  if (testCmd !== 'skip' && files.length > 0) {
67
67
  const fileArgs = files.join(' ');
68
- const testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
69
- testStatus = testResult.status;
68
+ testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
70
69
  }
71
70
 
72
71
  const result = {
73
72
  variant: 'per-file',
74
- typecheck: tcStatus,
75
- test: testStatus,
73
+ // Each command object also carries its INNER kill ladder (`timed_out`,
74
+ // `kill_signals`, `survivors` pids) when `runCheck` spawned it — omitted for
75
+ // skip/stub (#1457). `run-quality-gate.mjs` joins them into its event.
76
+ typecheck: { status: tcResult.status, ...commandKillFields(tcResult) },
77
+ test: { status: testResult.status, ...commandKillFields(testResult) },
78
+ // Stub map, same shape as gate-full: a command `runCheck` recognised as a
79
+ // stub (`echo …`/`true`) spawned nothing, so `joinGateKillFields` may count
80
+ // it as measured instead of declaring the whole inner ladder unknown.
81
+ stubbed: {
82
+ ...(tcResult.stubbed ? { typecheck: tcResult.stubbed } : {}),
83
+ ...(testResult.stubbed ? { test: testResult.stubbed } : {}),
84
+ },
76
85
  files,
77
86
  };
78
87
 
@@ -47,6 +47,16 @@
47
47
  * tier-gate conditionals `applyGates` already implements (the `context`
48
48
  * param below is the exact mechanism rule-loader exposes for this).
49
49
  *
50
+ * #1449: that membership now matches what Claude Code actually loads. The
51
+ * loader used to skip a leading provenance comment before `---`, so a rule
52
+ * Claude Code loads always-on (it reads frontmatter only from line 1) counted
53
+ * here as path-scoped, and every consumer's always-on total was short by the
54
+ * vendored rules carrying that header. Such a file now counts as always-on —
55
+ * its displaced frontmatter as body bytes, since that is how Claude Code
56
+ * delivers it — and is listed in `defects` and on its own banner line. The
57
+ * list is report-only: it is not an `overBudget` term, so a defect never turns
58
+ * a budget verdict red.
59
+ *
50
60
  * Cross-references:
51
61
  * - "2026-06-20 instruction-budget audit" (#668 / #687; archived in the private Meta-Vault)
52
62
  * - scripts/lib/rule-loader.mjs (always-on classification + tier-gate SSOT)
@@ -79,7 +89,11 @@
79
89
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
80
90
  import { basename, join } from 'node:path';
81
91
  import { scanEventsBackwards } from './events.mjs';
82
- import { loadApplicableRules, parseGlobsFrontmatter } from './rule-loader.mjs';
92
+ import {
93
+ FRONTMATTER_NOT_AT_TOP,
94
+ loadApplicableRules,
95
+ parseGlobsFrontmatter,
96
+ } from './rule-loader.mjs';
83
97
 
84
98
  /** Default directive ceiling (operator-chosen growth ratchet just above the ~457 baseline). */
85
99
  export const DEFAULT_CEILING = 480;
@@ -1055,12 +1069,18 @@ function measureRuleCorpora(rulesDir) {
1055
1069
  * bySurface: { coordinator: number, wave: number, always: number,
1056
1070
  * generated: {bytes: number, files: number},
1057
1071
  * pathScoped: {bytes: number, files: number} },
1072
+ * defects: string[],
1058
1073
  * }}
1059
- * perFile is sorted DESC by count. On missing/unreadable dir →
1074
+ * perFile is sorted DESC by count. `defects` (#1449) lists, sorted by name,
1075
+ * the rule files whose frontmatter does not start on line 1 — Claude Code
1076
+ * ignores it, so they load always-on and are counted as such. It is
1077
+ * independent of `context` (such a file has no tier to gate on) and never
1078
+ * feeds `overBudget`. On missing/unreadable dir →
1060
1079
  * { totalDirectives: 0, totalBytes: 0, perFile: [], ceiling, byteCeiling,
1061
1080
  * overDirectiveBudget: false, overByteBudget: false, overBudget: false,
1062
1081
  * severity: 'ok', bySurface: { coordinator: 0, wave: 0, always: 0,
1063
- * generated: {bytes:0,files:0}, pathScoped: {bytes:0,files:0} } }.
1082
+ * generated: {bytes:0,files:0}, pathScoped: {bytes:0,files:0} },
1083
+ * defects: [] }.
1064
1084
  *
1065
1085
  * #931a verdict rule — `overBudget` is the OR of the two axes
1066
1086
  * (`overDirectiveBudget || overByteBudget`), NOT a per-axis severity split:
@@ -1165,6 +1185,7 @@ export function computeInstructionBudget(opts = {}) {
1165
1185
  generated: { bytes: 0, files: 0 },
1166
1186
  pathScoped: { bytes: 0, files: 0 },
1167
1187
  },
1188
+ defects: [],
1168
1189
  };
1169
1190
 
1170
1191
  let allEntries;
@@ -1256,6 +1277,13 @@ export function computeInstructionBudget(opts = {}) {
1256
1277
  const overPathScopedBudget = bySurface.pathScoped.bytes > pathScopedByteCeiling;
1257
1278
  const overBudget = overDirectiveBudget || overByteBudget || overGeneratedBudget || overPathScopedBudget;
1258
1279
 
1280
+ // #1449: read off the untiered list — a defective file carries no meta, so
1281
+ // no tier gate drops it and `alwaysOnAll` holds every one of them.
1282
+ const defects = alwaysOnAll
1283
+ .filter((e) => e.defect === FRONTMATTER_NOT_AT_TOP)
1284
+ .map((e) => basename(e.path))
1285
+ .sort((a, b) => a.localeCompare(b));
1286
+
1259
1287
  return {
1260
1288
  totalDirectives,
1261
1289
  totalBytes,
@@ -1271,6 +1299,7 @@ export function computeInstructionBudget(opts = {}) {
1271
1299
  overBudget,
1272
1300
  severity: overBudget ? 'warn' : 'ok',
1273
1301
  bySurface,
1302
+ defects,
1274
1303
  };
1275
1304
  }
1276
1305
 
@@ -1351,6 +1380,21 @@ function formatNearPathScopedLine(budget, repoRoot) {
1351
1380
  );
1352
1381
  }
1353
1382
 
1383
+ /**
1384
+ * The #1449 defect line: rule files whose frontmatter Claude Code never reads.
1385
+ * Names every file — the fix is per file (move the text above `---` below the
1386
+ * closing fence), so a count alone would send the operator hunting.
1387
+ *
1388
+ * @param {string[]} defects - basenames from `computeInstructionBudget().defects`
1389
+ * @returns {string}
1390
+ */
1391
+ function formatFrontmatterDefectLine(defects) {
1392
+ return (
1393
+ `⚠ ${defects.length} rule(s) carry frontmatter that Claude Code ignores ` +
1394
+ `(text before the opening ---): ${defects.join(', ')} — they load always-on.`
1395
+ );
1396
+ }
1397
+
1354
1398
  /**
1355
1399
  * Banner wrapper — session-start Phase 4 convention.
1356
1400
  *
@@ -1367,7 +1411,9 @@ function formatNearPathScopedLine(budget, repoRoot) {
1367
1411
  *
1368
1412
  * The message names WHICH axis breached (#931a) — a banner that only said
1369
1413
  * "over budget" would leave the operator guessing whether to prune bullets or
1370
- * prose. It stays at three lines because it renders at every session start.
1414
+ * prose. It stays short because it renders at every session start: the breach
1415
+ * block is three lines, plus one line per extra finding (near-threshold,
1416
+ * frontmatter defect).
1371
1417
  *
1372
1418
  * @param {object} [opts] forwarded to computeInstructionBudget.
1373
1419
  * @param {string} [opts.repoRoot] project root for the config read.
@@ -1382,8 +1428,10 @@ function formatNearPathScopedLine(budget, repoRoot) {
1382
1428
  * raises it at {@link PATH_SCOPED_NEAR_THRESHOLD} of its ceiling, before the
1383
1429
  * breach: that band used to be silent, and the ceiling then surfaced only
1384
1430
  * inside `writeApprovedRules` — after the operator had approved rules it
1385
- * refused to write. The RETURN SHAPE is unchanged (`{severity:'warn',
1386
- * message}` | null), so `session-start-probes.mjs` needs no new vocabulary.
1431
+ * refused to write. Since #1449 a rule whose frontmatter is not on line 1
1432
+ * raises it too (one line naming the files), whether or not any axis is
1433
+ * close. The RETURN SHAPE is unchanged (`{severity:'warn', message}` |
1434
+ * null), so `session-start-probes.mjs` needs no new vocabulary.
1387
1435
  */
1388
1436
  export function checkInstructionBudget(opts = {}) {
1389
1437
  let cfg;
@@ -1455,18 +1503,27 @@ export function checkInstructionBudget(opts = {}) {
1455
1503
  budget.pathScopedByteCeiling > 0 &&
1456
1504
  budget.bySurface.pathScoped.bytes >= budget.pathScopedByteCeiling * PATH_SCOPED_NEAR_THRESHOLD;
1457
1505
 
1458
- if (!budget.overBudget && !nearPathScoped) return null;
1506
+ // #1449 — reported on its own line, never folded into `overBudget`.
1507
+ const defectLine =
1508
+ budget.defects.length > 0 ? formatFrontmatterDefectLine(budget.defects) : null;
1509
+
1510
+ if (!budget.overBudget && !nearPathScoped && !defectLine) return null;
1459
1511
 
1460
1512
  const nearLine = nearPathScoped ? formatNearPathScopedLine(budget, opts.repoRoot) : null;
1461
1513
 
1462
- // Near-threshold and nothing breached: a one-finding banner, carrying the
1463
- // same remedy as the breach clause below (consolidate — never raise).
1514
+ // Nothing breached: one block per finding — the near-threshold line carries
1515
+ // the same remedy as the breach clause below (consolidate — never raise).
1464
1516
  if (!budget.overBudget) {
1465
1517
  return {
1466
1518
  severity: 'warn',
1467
1519
  message: [
1468
- `⚠ ${nearLine}.`,
1469
- ' Consolidate generated rules per docs/rule-authoring.md § Consolidated rules; never raise the ceiling.',
1520
+ ...(nearLine
1521
+ ? [
1522
+ `⚠ ${nearLine}.`,
1523
+ ' Consolidate generated rules per docs/rule-authoring.md § Consolidated rules; never raise the ceiling.',
1524
+ ]
1525
+ : []),
1526
+ ...(defectLine ? [defectLine] : []),
1470
1527
  ].join('\n'),
1471
1528
  };
1472
1529
  }
@@ -1523,6 +1580,7 @@ export function checkInstructionBudget(opts = {}) {
1523
1580
  ...(nearLine ? [` ${nearLine}.`] : []),
1524
1581
  ` Top files: ${top}`,
1525
1582
  ' See the instruction-budget audit (#687; archived in the private Meta-Vault) for the prune/demote list.',
1583
+ ...(defectLine ? [defectLine] : []),
1526
1584
  ].join('\n');
1527
1585
 
1528
1586
  return { severity: 'warn', message };
@@ -62,7 +62,7 @@
62
62
  import { existsSync } from 'node:fs';
63
63
  import { mkdir, appendFile } from 'node:fs/promises';
64
64
  import path from 'node:path';
65
- import { readLearnings, rewriteLearnings } from './io.mjs';
65
+ import { readLearnings, rewriteLearnings, withLearningsLock } from './io.mjs';
66
66
 
67
67
  /**
68
68
  * Composite-key separator for the in-memory consolidation Map.
@@ -284,7 +284,22 @@ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, n
284
284
  * `filePath` and `archivePath` are required; a missing/invalid `filePath`
285
285
  * throws a plain `Error` (programmer error, not a runtime data condition).
286
286
  */
287
- export async function sweepExpiredLearnings({
287
+ export async function sweepExpiredLearnings(opts = {}) {
288
+ const { filePath, dryRun = true } = opts;
289
+ // The read → rewrite runs under the store lock (#1447 point 8). A run that
290
+ // cannot write takes no lock: a dry run, an absent store (zeroed shape, see
291
+ // below), or an unusable `filePath` (the body owns that error).
292
+ if (dryRun || typeof filePath !== 'string' || !existsSync(filePath)) {
293
+ return sweepExpiredLearningsUnlocked(opts);
294
+ }
295
+ return withLearningsLock(filePath, () => sweepExpiredLearningsUnlocked(opts));
296
+ }
297
+
298
+ /**
299
+ * Body of {@link sweepExpiredLearnings}; the caller holds the store lock or
300
+ * runs a pass that cannot write.
301
+ */
302
+ async function sweepExpiredLearningsUnlocked({
288
303
  filePath,
289
304
  archivePath,
290
305
  now,
@@ -399,7 +414,23 @@ export async function sweepExpiredLearnings({
399
414
  * @returns {Promise<{scanned: number, kept: number, archived: number,
400
415
  * byReason: Record<string, number>, dryRun: boolean, archivePath: string}>}
401
416
  */
402
- export async function pruneLearnings({
417
+ export async function pruneLearnings(opts = {}) {
418
+ const { filePath, dryRun = true } = opts;
419
+ // The read → archive → rewrite runs under the store lock (#1447 point 8): an
420
+ // append landing after the read below would otherwise vanish in the rename
421
+ // with no archive line. A dry run writes nothing and takes no lock; an
422
+ // unusable `filePath` goes straight to the body, which owns that error.
423
+ if (dryRun || typeof filePath !== 'string' || filePath.length === 0) {
424
+ return pruneLearningsUnlocked(opts);
425
+ }
426
+ return withLearningsLock(filePath, () => pruneLearningsUnlocked(opts));
427
+ }
428
+
429
+ /**
430
+ * Body of {@link pruneLearnings}; the caller holds the store lock or runs a
431
+ * dry run.
432
+ */
433
+ async function pruneLearningsUnlocked({
403
434
  filePath,
404
435
  archivePath,
405
436
  entries,
@@ -8,14 +8,18 @@
8
8
  import {
9
9
  readFile,
10
10
  writeFile,
11
+ appendFile,
11
12
  mkdir,
12
13
  rename,
13
14
  copyFile,
14
15
  readdir,
15
16
  unlink,
16
17
  } from 'node:fs/promises';
17
- import { existsSync } from 'node:fs';
18
+ import { existsSync, realpathSync } from 'node:fs';
19
+ import { AsyncLocalStorage } from 'node:async_hooks';
20
+ import { randomUUID } from 'node:crypto';
18
21
  import path from 'node:path';
22
+ import { withFileLock } from '../file-lock.mjs';
19
23
  import {
20
24
  validateLearning,
21
25
  normalizeLearning,
@@ -24,6 +28,117 @@ import {
24
28
  ValidationError,
25
29
  } from './schema.mjs';
26
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Store lock (GitLab #1447 point 8)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * How long a writer waits for the store lock before giving up — the
37
+ * `withFileLock` default. Every critical section here is one read plus one
38
+ * append or rewrite of a JSONL file (milliseconds), so ten seconds of
39
+ * contention means a stuck holder, not a slow one.
40
+ */
41
+ export const LEARNINGS_LOCK_TIMEOUT_MS = 10000;
42
+
43
+ /**
44
+ * Thrown when the learnings store lock cannot be acquired. Every writer takes
45
+ * the lock BEFORE it touches the store, so this error always means nothing was
46
+ * written. The message is one line and names the lock file.
47
+ */
48
+ export class LearningsLockError extends Error {
49
+ /**
50
+ * @param {string} lockPath
51
+ * @param {'timeout'|'fs-error'} reason
52
+ * @param {number} timeoutMs
53
+ * @param {string} [detail]
54
+ */
55
+ constructor(lockPath, reason, timeoutMs, detail) {
56
+ const why =
57
+ reason === 'timeout'
58
+ ? `held by another writer, waited ${timeoutMs} ms`
59
+ : `${reason}${detail ? `: ${detail}` : ''}`;
60
+ super(`learnings store lock ${lockPath} not acquired (${why}) — nothing written`);
61
+ this.name = 'LearningsLockError';
62
+ this.lockPath = lockPath;
63
+ this.reason = reason;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Store locks held by the CURRENT async call chain, so a nested writer inside a
69
+ * critical section (apply → prune → rewrite, sweep → rewrite, promote →
70
+ * rewrite) runs straight through instead of waiting on its own lock until the
71
+ * timeout. Keyed per async context, deliberately NOT per process: a concurrent
72
+ * call chain in the same process does not inherit the entry and waits like a
73
+ * second process would — which is exactly the interleaving the lock exists for.
74
+ * @type {AsyncLocalStorage<Map<string, {live: boolean}>>}
75
+ */
76
+ const heldStoreLocks = new AsyncLocalStorage();
77
+
78
+ /**
79
+ * `<store>.lock` beside the store. The directory is canonicalised so two
80
+ * spellings of one store (`/var` vs `/private/var`, a realpath'd repo root as
81
+ * `memory-proposals/sink.mjs` passes it) contend on ONE lock file. Creates the
82
+ * directory: the lock file needs it, and every caller is about to write there.
83
+ *
84
+ * @param {string} filePath
85
+ * @returns {Promise<string>}
86
+ */
87
+ async function resolveStoreLockPath(filePath) {
88
+ const dir = path.dirname(path.resolve(filePath));
89
+ await mkdir(dir, { recursive: true });
90
+ return path.join(realpathSync(dir), `${path.basename(filePath)}.lock`);
91
+ }
92
+
93
+ /**
94
+ * Run `fn` while holding the exclusive lock of the learnings store at
95
+ * `filePath`. Every read-modify-write of the store must run its WHOLE
96
+ * read → write sequence inside one call: a write outside the lock is silently
97
+ * lost when a rewriter that read before it renames its next generation over
98
+ * the file. `appendLearning()` and `rewriteLearnings()` take the lock
99
+ * themselves.
100
+ *
101
+ * Reentrant within one async call chain (see `heldStoreLocks`). Reentrancy
102
+ * prevents a deadlock, not a lost write: an `appendLearning()` issued INSIDE a
103
+ * read → rewrite section lands in the file that section is about to replace —
104
+ * fold such a record into the rewrite batch instead.
105
+ *
106
+ * Crash safety comes from `withFileLock`'s default `staleCheck: 'pid'`: a lock
107
+ * whose holder process is gone is overridden (WARN on stderr); a live holder or
108
+ * one on another host is waited for until `timeoutMs`.
109
+ *
110
+ * @template T
111
+ * @param {string} filePath — the learnings store (the lock file sits beside it)
112
+ * @param {() => (T | Promise<T>)} fn
113
+ * @param {{ timeoutMs?: number }} [opts]
114
+ * @returns {Promise<T>}
115
+ * @throws {LearningsLockError} when the lock is not acquired — `fn` never ran
116
+ */
117
+ export async function withLearningsLock(filePath, fn, { timeoutMs = LEARNINGS_LOCK_TIMEOUT_MS } = {}) {
118
+ const lockPath = await resolveStoreLockPath(filePath);
119
+ const held = heldStoreLocks.getStore();
120
+ if (held?.get(lockPath)?.live === true) return fn();
121
+
122
+ const token = { live: true };
123
+ const scope = new Map(held ?? []);
124
+ scope.set(lockPath, token);
125
+ let result;
126
+ try {
127
+ result = await withFileLock(lockPath, () => heldStoreLocks.run(scope, fn), {
128
+ timeoutMs,
129
+ // A per-acquisition holder makes the owner-guarded release delete only
130
+ // THIS acquisition's lock, never a successor's (#1285).
131
+ holder: `learnings-store:${process.pid}:${randomUUID()}`,
132
+ });
133
+ } finally {
134
+ // A continuation `fn` left running past its own return must not keep
135
+ // treating the released lock as held.
136
+ token.live = false;
137
+ }
138
+ if (!result.ok) throw new LearningsLockError(lockPath, result.reason, timeoutMs, result.error);
139
+ return result.value;
140
+ }
141
+
27
142
  // ---------------------------------------------------------------------------
28
143
  // Pre-write self-validation seam (issue #662)
29
144
  // ---------------------------------------------------------------------------
@@ -42,7 +157,7 @@ import {
42
157
  * append touches disk, so a bad write can never reach the file.
43
158
  *
44
159
  * @param {object} validated — already validated+normalized learning entry
45
- * @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`,
160
+ * @param {{ legacyTolerant?: boolean }} [opts] — EventDrop #386. When `true`,
46
161
  * a field that was ALREADY ABSENT on `validated` (e.g. a legacy record with
47
162
  * no `source_session`, tolerated by `readLearnings()`) stays tolerated after
48
163
  * the round-trip too — the re-validation call below runs in the same
@@ -80,8 +195,9 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
80
195
  // A key that existed on `validated` (present, even as `undefined`) but
81
196
  // vanished from `reparsed` was DROPPED by JSON.stringify — the exact
82
197
  // undefined/NaN/etc. corruption #662 exists to catch. A key that was
83
- // never on `validated` in the first place (the #386 legacy-field case)
84
- // cannot appear here, because we only iterate `validated`'s own keys.
198
+ // never on `validated` in the first place (the EventDrop #386
199
+ // legacy-field case) cannot appear here, because we only iterate
200
+ // `validated`'s own keys.
85
201
  const droppedKeys = Object.keys(validated).filter((k) => !(k in reparsed));
86
202
  if (droppedKeys.length > 0) {
87
203
  throw new ValidationError(
@@ -143,11 +259,18 @@ export async function readLearnings(filePath) {
143
259
  * atomic on POSIX append. For very large insight/evidence fields that
144
260
  * might exceed that boundary, use rewriteLearnings() instead.
145
261
  *
262
+ * The append runs under the store lock ({@link withLearningsLock}): an append
263
+ * that landed between a rewriter's read and its rename was silently lost.
264
+ * Validation runs first, outside the lock — a rejected record never waits.
265
+ *
146
266
  * @param {string} filePath
147
267
  * @param {object} entry
268
+ * @param {{ lockTimeoutMs?: number }} [opts] — store-lock wait, default
269
+ * {@link LEARNINGS_LOCK_TIMEOUT_MS}
148
270
  * @returns {Promise<object>} validated entry
271
+ * @throws {LearningsLockError} when the store lock is not acquired (nothing appended)
149
272
  */
150
- export async function appendLearning(filePath, entry) {
273
+ export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
151
274
  // Ensure created_at is set first — many writers omit it, and expires_at
152
275
  // derivation depends on it. Use ISO 8601 UTC.
153
276
  const createdAt =
@@ -173,9 +296,10 @@ export async function appendLearning(filePath, entry) {
173
296
  // parses back AND re-validates before any append touches disk. Throws
174
297
  // ValidationError on a non-round-tripping record — file is left untouched.
175
298
  const line = serializeLearningLineChecked(validated);
176
- await mkdir(path.dirname(filePath), { recursive: true });
177
- const { appendFile } = await import('node:fs/promises');
178
- await appendFile(filePath, line, 'utf8');
299
+ // The lock path resolution creates the parent directory.
300
+ await withLearningsLock(filePath, () => appendFile(filePath, line, 'utf8'), {
301
+ timeoutMs: lockTimeoutMs,
302
+ });
179
303
  return validated;
180
304
  }
181
305
 
@@ -281,7 +405,7 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
281
405
  * - `backup` (default `true`): before the destructive rename, copy the current
282
406
  * file to `${filePath}.bak-<ISO>`, then rotate to keep only the newest
283
407
  * {@link BACKUP_KEEP}. Rotation is best-effort and never blocks the rewrite.
284
- * - `legacyTolerant` (default `true`, GitLab #386): this function is a
408
+ * - `legacyTolerant` (default `true`, EventDrop #386): this function is a
285
409
  * ROUND-TRIP writer — its usual caller (`sweepExpiredLearnings` /
286
410
  * `pruneLearnings` in `expiry-sweep.mjs`) reads the store with
287
411
  * `readLearnings()` first, and that reader already tolerates a legacy
@@ -298,7 +422,7 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
298
422
  * round-trip (a value JSON.stringify drops or coerces, e.g. `undefined`/
299
423
  * `NaN`) is still caught by the #662 checked serializer regardless of this
300
424
  * flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
301
- * the pre-#386 fully-strict behaviour.
425
+ * the fully-strict behaviour from before EventDrop #386.
302
426
  *
303
427
  * @param {string} filePath
304
428
  * @param {object[]} entries
@@ -331,24 +455,28 @@ export async function rewriteLearnings(
331
455
  if (dryRun) return validated;
332
456
 
333
457
  const body = lines.join('');
334
- await mkdir(path.dirname(filePath), { recursive: true });
335
458
 
336
- // Backup-before-rewrite (#721): snapshot the current store to a timestamped
337
- // sidecar BEFORE the destructive rename, then prune to keep-N. Only meaningful
338
- // when the target already exists (a first-time write has nothing to lose).
339
- if (backup && existsSync(filePath)) {
340
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
341
- await copyFile(filePath, `${filePath}.bak-${ts}`);
342
- try {
343
- await rotateBackups(path.dirname(filePath), path.basename(filePath));
344
- } catch {
345
- // Rotation is best-effort — a stale/undeletable sibling must never abort
346
- // the rewrite. The fresh backup above is already safely on disk.
459
+ // Under the store lock (#1447 point 8) so no append lands between the backup
460
+ // and the rename. The CALLER's read must sit inside the same lock for a
461
+ // read-modify-write to be safe — this call joins it reentrantly.
462
+ await withLearningsLock(filePath, async () => {
463
+ // Backup-before-rewrite (#721): snapshot the current store to a timestamped
464
+ // sidecar BEFORE the destructive rename, then prune to keep-N. Only meaningful
465
+ // when the target already exists (a first-time write has nothing to lose).
466
+ if (backup && existsSync(filePath)) {
467
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
468
+ await copyFile(filePath, `${filePath}.bak-${ts}`);
469
+ try {
470
+ await rotateBackups(path.dirname(filePath), path.basename(filePath));
471
+ } catch {
472
+ // Rotation is best-effort — a stale/undeletable sibling must never abort
473
+ // the rewrite. The fresh backup above is already safely on disk.
474
+ }
347
475
  }
348
- }
349
476
 
350
- const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
351
- await writeFile(tmp, body, 'utf8');
352
- await rename(tmp, filePath);
477
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
478
+ await writeFile(tmp, body, 'utf8');
479
+ await rename(tmp, filePath);
480
+ });
353
481
  return validated;
354
482
  }