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,214 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Check: no instruction prose tells an agent to write `learnings.jsonl` with a
4
+ * shell redirect (GitLab #1446 / GitHub #69). BLOCKING.
5
+ *
6
+ * ## Why
7
+ *
8
+ * The store is gitignored, so a bad write has no VCS restore. The sanctioned
9
+ * write paths — `scripts/apply-session-learnings.mjs` (session-end, retro) and
10
+ * `scripts/sweep-expired-learnings.mjs --prune` (`/evolve`) — validate every
11
+ * record, snapshot `.bak-<ISO>`, and archive what leaves the store. A shell
12
+ * `>` / `>>` does none of that. The prose producers this change closed, all
13
+ * measured at ebf63bd1:
14
+ *
15
+ * - `skills/session-end/learning-patterns.md` Phase 3.6 step 4g ("atomic
16
+ * rewrite with `>`") — the path that wrote a file PATH into `scope` (GH#69);
17
+ * - `skills/plan/mode-retro.md` § 3.3 — an unnamed "Append new patterns to
18
+ * `learnings.jsonl`" with no mechanism at all;
19
+ * - `.cursor/rules/060-evolve.mdc` — three sites ("Write entire result back
20
+ * with `>`", "write all with `>`" twice). None of the three names
21
+ * `learnings.jsonl` on its own line, which is why R2 is windowed.
22
+ *
23
+ * ## Rules
24
+ *
25
+ * R1 shell redirect — `>`/`>>` directly in front of a `…learnings.jsonl`
26
+ * target on the same line. The leading boundary (start,
27
+ * whitespace, `|`, `;`, `&`, `(`) is load-bearing:
28
+ * without it 9 of 12 hits were `<state-dir>/…`
29
+ * placeholders. A Markdown blockquote marker (`> ` at
30
+ * line start, outside a fence) is not a redirect and is
31
+ * stripped first; inside a fence it IS a truncation.
32
+ * R3 tee — `tee [-a] …learnings.jsonl` on the same line.
33
+ * R2 prose operator — a backticked `` `>` `` / `` `>>` `` within ±3 lines
34
+ * (WINDOW) of an anchor: `learnings.jsonl`, "the store",
35
+ * "learnings store", "result back", "atomic rewrite".
36
+ * The last two are the retired producers' own wording.
37
+ * An operator inside an ASCII double-quoted span is a
38
+ * quotation of former prose, not an instruction.
39
+ *
40
+ * Exempt (per operator): a prohibition keyword (NEVER / do not / FORBIDDEN /
41
+ * verboten …) BEFORE the operator in the same sentence — a keyword after it
42
+ * (`… > learnings.jsonl # never run this twice`) or in an earlier sentence
43
+ * (`Never skip review. Then run: … >> learnings.jsonl`) does not count.
44
+ * Exempt (whole line): the last sentence of the previous non-empty line states
45
+ * a prohibition before any operator, an empty-file create (`: > file`), or the
46
+ * marker `<!-- learnings-write-check: prohibition -->` on the line or the
47
+ * previous non-empty line. Fenced blocks ARE scanned — a fenced shell snippet
48
+ * is exactly a shell write.
49
+ *
50
+ * @module scripts/lib/validate/check-learnings-shell-writes
51
+ */
52
+
53
+ import { readFileSync } from 'node:fs';
54
+ import path from 'node:path';
55
+ import { enumerateRepoFiles } from './enumerate-repo-files.mjs';
56
+ import { isMainModule } from '../is-main-module.mjs';
57
+
58
+ /** Instruction corpora that tell an agent what to run. */
59
+ export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', 'docs', '.claude/rules', '.cursor/rules']);
60
+
61
+ /** Lines on either side of an R2 operator searched for an anchor. */
62
+ const WINDOW = 3;
63
+
64
+ const MENTION_RE = /learnings\.jsonl/;
65
+ const ANCHOR_RE = /learnings\.jsonl|\b(?:the|learnings) store\b|\bresult back\b|\batomic rewrite\b/i;
66
+ const SHELL_REDIRECT_RE = /(?:^|[\s|;&(])(>{1,2})[ \t]*["'`]?[^\s"'`<>]*learnings\.jsonl\b/dg;
67
+ const TEE_RE = /\btee\b(?:[ \t]+-{1,2}[\w-]+)*[ \t]+["'`]?[^\s"'`|]*learnings\.jsonl\b/g;
68
+ const PROSE_OPERATOR_RE = /`>{1,2}`/g;
69
+ const ANY_OPERATOR_RE = /`>{1,2}`|(?:^|[\s|;&(])>{1,2}|\btee\b/;
70
+ const PROHIBITION_RE = /\b(NEVER|[Nn]ever|DO NOT|[Dd]o not|[Dd]on't|FORBIDDEN|forbidden|verboten)\b/;
71
+ // A sentence ends at `.`/`!`/`?` + whitespace + a capital (markup allowed):
72
+ // `jq . next` and `jq | ... >` are not sentence breaks.
73
+ const SENTENCE_BREAK_RE = /[.!?]\s+(?=[*_`"']*[A-Z])/;
74
+ const EMPTY_CREATE_RE = /^\s*:\s*>/;
75
+ const BLOCKQUOTE_RE = /^\s*(?:>[ \t])+/;
76
+ const FENCE_RE = /^\s*(?:```|~~~)/;
77
+ const MARKER = '<!-- learnings-write-check: prohibition -->';
78
+
79
+ /** The sentence a position sits in, cut off at that position. */
80
+ function clauseBefore(text, pos) {
81
+ return text.slice(0, pos).split(SENTENCE_BREAK_RE).pop();
82
+ }
83
+
84
+ /** True when a prohibition keyword precedes `pos` within its own sentence. */
85
+ function prohibitedBefore(text, pos) {
86
+ return PROHIBITION_RE.test(clauseBefore(text, pos));
87
+ }
88
+
89
+ /** True when the last sentence of `line` states a prohibition before any operator. */
90
+ function isProhibitionSentence(line) {
91
+ const last = line.replace(BLOCKQUOTE_RE, '').trimEnd().split(SENTENCE_BREAK_RE).pop();
92
+ const k = last.search(PROHIBITION_RE);
93
+ if (k < 0) return false;
94
+ const o = last.search(ANY_OPERATOR_RE);
95
+ return o < 0 || k < o;
96
+ }
97
+
98
+ /** True when `pos` sits inside an ASCII double-quoted span of `text`. */
99
+ function insideDoubleQuotes(text, pos) {
100
+ return (text.slice(0, pos).match(/"/g) ?? []).length % 2 === 1;
101
+ }
102
+
103
+ /**
104
+ * Every operator on the line as `{rule, pos}`, positions relative to `body`.
105
+ * R2 candidates are returned only when the ±WINDOW neighbourhood holds an anchor.
106
+ */
107
+ function operatorsOf(body, lines, i) {
108
+ const ops = [];
109
+ if (MENTION_RE.test(body)) {
110
+ for (const m of body.matchAll(SHELL_REDIRECT_RE)) ops.push({ rule: 'R1', pos: m.indices[1][0] });
111
+ for (const m of body.matchAll(TEE_RE)) ops.push({ rule: 'R3', pos: m.index });
112
+ }
113
+ const prose = [...body.matchAll(PROSE_OPERATOR_RE)];
114
+ if (prose.length > 0) {
115
+ const from = Math.max(0, i - WINDOW);
116
+ const anchored = lines.slice(from, i + WINDOW + 1).some((l) => ANCHOR_RE.test(l));
117
+ if (anchored) {
118
+ for (const m of prose) {
119
+ if (!insideDoubleQuotes(body, m.index)) ops.push({ rule: 'R2', pos: m.index });
120
+ }
121
+ }
122
+ }
123
+ return ops;
124
+ }
125
+
126
+ /**
127
+ * Scan the instruction corpora for shell writes to `learnings.jsonl`.
128
+ *
129
+ * @param {{ pluginRoot: string, dirs?: readonly string[] }} opts
130
+ * @returns {{ ok: boolean, findings: {file: string, line: number, rule: string, text: string}[],
131
+ * summary: { filesScanned: number, mentions: number, exempt: number } }}
132
+ */
133
+ export function scanLearningsShellWrites({ pluginRoot, dirs = SCAN_DIRS }) {
134
+ const files = enumerateRepoFiles({ repoRoot: pluginRoot, dirs: [...dirs], exts: ['.md', '.mdc'] });
135
+ const findings = [];
136
+ let mentions = 0;
137
+ let exempt = 0;
138
+ for (const abs of files) {
139
+ const lines = readFileSync(abs, 'utf8').split('\n');
140
+ const rel = path.relative(pluginRoot, abs);
141
+ let inFence = false;
142
+ let prev = '';
143
+ lines.forEach((text, i) => {
144
+ const prevNonEmpty = prev;
145
+ if (text.trim().length > 0) prev = text;
146
+ if (FENCE_RE.test(text)) {
147
+ inFence = !inFence;
148
+ return;
149
+ }
150
+ if (MENTION_RE.test(text)) mentions += 1;
151
+ const body = inFence ? text : text.replace(BLOCKQUOTE_RE, '');
152
+ const ops = operatorsOf(body, lines, i);
153
+ if (ops.length === 0) return;
154
+ const lineExempt =
155
+ EMPTY_CREATE_RE.test(body) ||
156
+ text.includes(MARKER) ||
157
+ prevNonEmpty.includes(MARKER) ||
158
+ isProhibitionSentence(prevNonEmpty);
159
+ const failing = lineExempt ? [] : ops.filter((op) => !prohibitedBefore(body, op.pos));
160
+ if (failing.length === 0) {
161
+ exempt += 1;
162
+ return;
163
+ }
164
+ const rule = ['R1', 'R3', 'R2'].find((r) => failing.some((op) => op.rule === r));
165
+ findings.push({ file: rel, line: i + 1, rule, text: text.trim() });
166
+ });
167
+ }
168
+ return { ok: findings.length === 0, findings, summary: { filesScanned: files.length, mentions, exempt } };
169
+ }
170
+
171
+ const RULE_TEXT = Object.freeze({
172
+ R1: 'shell redirect into learnings.jsonl',
173
+ R2: 'redirect operator near a learnings.jsonl / store-write mention',
174
+ R3: 'tee into learnings.jsonl',
175
+ });
176
+
177
+ /**
178
+ * Run the human-readable validator CLI.
179
+ *
180
+ * @param {string} pluginRoot absolute plugin root
181
+ * @returns {number} 0 = clean, 1 = findings
182
+ */
183
+ export function runCheckLearningsShellWrites(pluginRoot) {
184
+ console.log('--- Check: no shell-redirect writes to learnings.jsonl in instruction prose ---');
185
+ const inspection = scanLearningsShellWrites({ pluginRoot });
186
+ for (const f of inspection.findings) {
187
+ console.log(
188
+ ` FAIL: [${f.rule}] ${f.file}:${f.line} ${RULE_TEXT[f.rule]} — route the write through ` +
189
+ '`scripts/apply-session-learnings.mjs` or `sweep-expired-learnings.mjs --prune`',
190
+ );
191
+ }
192
+ const s = inspection.summary;
193
+ if (inspection.ok) {
194
+ console.log(
195
+ ` PASS: ${s.mentions} learnings.jsonl mention(s) in ${s.filesScanned} file(s), ` +
196
+ `${s.exempt} exempt as prohibition`,
197
+ );
198
+ }
199
+ console.log('');
200
+ console.log(`Results: ${inspection.ok ? 1 : 0} passed, ${inspection.findings.length} failed`);
201
+ return inspection.ok ? 0 : 1;
202
+ }
203
+
204
+ if (isMainModule(import.meta.url)) {
205
+ const args = process.argv.slice(2).filter((arg) => arg !== '--json');
206
+ const root = path.resolve(args[0] || process.cwd());
207
+ if (process.argv.includes('--json')) {
208
+ const inspection = scanLearningsShellWrites({ pluginRoot: root });
209
+ process.stdout.write(`${JSON.stringify(inspection, null, 2)}\n`);
210
+ process.exitCode = inspection.ok ? 0 : 1;
211
+ } else {
212
+ process.exitCode = runCheckLearningsShellWrites(root);
213
+ }
214
+ }
@@ -93,7 +93,8 @@
93
93
  * pure input→output date function is out of scope by
94
94
  * construction rather than by exception list.
95
95
  *
96
- * NOT flagged: blocks that control the clock (`now:` arg,
96
+ * NOT flagged: blocks that control the clock (`now:`, `{ now }`
97
+ * or a positional `now` — wider than the seam proof, #1478 —
97
98
  * `vi.useFakeTimers` / `vi.setSystemTime`); date literals in
98
99
  * INPUT position (only `.toBe`/`.toEqual`/`.toStrictEqual`
99
100
  * expected values are read), which leaves the passthrough
@@ -335,18 +336,68 @@ const keyLiteralRe = (key) => new RegExp(`["'\\\\]+${key}["'\\\\]+\\s*:`);
335
336
  * An absolute date pinned as the EXPECTED value of an equality assertion.
336
337
  * Input-position date literals (`created_at: '2026-06-21T…'`) do not match —
337
338
  * that is what keeps the passthrough class (input date === output date) out.
339
+ *
340
+ * CEILING (#1473): a fixed INPUT date that a production TTL ages out later is
341
+ * not flagged either, on purpose. Measured 2026-09-29 with a shim that moves
342
+ * `Date.now()` and `new Date()` forward in every vitest worker: the 34 test
343
+ * files (plus one fixture module) holding a fixed `created_at: '2026-05…09'`
344
+ * (1,211 tests) stayed green at +120, +365 and +3,650 days, so a rule keyed on
345
+ * fixed input dates would have reported false positives only. The one real
346
+ * bomb of that shape, the phase-skip fixture before 6670149c, was green at
347
+ * -60 days and red at +0 and +120 days under the same shim — and
348
+ * scanClockBombs() reaches that file since #1476 (alias imports are read) but
349
+ * stays silent on it: the file hands no clock argument to anything. REVISIT when a
350
+ * second fixed-input-date bomb lands on main: the class then recurs, and a
351
+ * clock-shift run of the suite is the check that finds it without guessing.
338
352
  */
339
353
  const DATE_EXPECTATION =
340
354
  /\.(?:toBe|toEqual|toStrictEqual)\(\s*(['"`])(\d{4}-\d{2}-\d{2}(?:[T ][^'"`]*)?)\1\s*\)/;
341
355
 
342
- /** An explicit clock handed to a callee — the seam this ban asks tests to use. */
343
- const CLOCK_ARG = /\b(?:now|nowMs|nowIso|clock|currentDate)\s*:/;
356
+ /** A clock-named token not read off an object (`Date.now`, `opts.now`) — shared head of the two below. */
357
+ const CLOCK_NAME = String.raw`(?<![.\w$])(?:now|nowMs|nowIso|clock|currentDate)\s*`;
358
+
359
+ /**
360
+ * Pass 1, the seam PROOF: a NAMED clock key (`{ now: … }`) — only that shape says
361
+ * the callee's API takes a clock. Every line it matches also matches
362
+ * CONTROL_CLOCK_ARG, so a block that proves a seam is never flagged itself.
363
+ */
364
+ const SEAM_CLOCK_ARG = new RegExp(`${CLOCK_NAME}:`);
365
+
366
+ /**
367
+ * Pass 2, is this block clock-CONTROLLED: any clock handover. Property shorthand
368
+ * (`{ now }`, `{ repoRoot, now }`) and a positional `now` count too; `Date.now()`
369
+ * and `const now =` do not. Wider is the safe direction here — it can only
370
+ * remove findings.
371
+ *
372
+ * CEILING (#1478): pass 1 reads only the named form, because the wide form also
373
+ * turns helpers into seams — +20 seamed (file, id) pairs in 15 files
374
+ * (172 → 192, measured 2026-10-01 @ ffc5929f), e.g. `expectDeny` via a
375
+ * `{ manifestMtimeMs: now }` fixture. Cost: a file whose ONLY clock handover is
376
+ * `{ now }` or a positional `now` loses its seam proof and its bombs go unseen.
377
+ * REVISIT on the first B5 finding whose subject is a helper, or the first CI
378
+ * `test-value-bans` B5 finding the ±days clock-shift probe shows to be false.
379
+ */
380
+ const CONTROL_CLOCK_ARG = new RegExp(String.raw`${CLOCK_NAME}(?::|,|\}|\))`);
344
381
 
345
382
  /** Freezing the global clock — equally valid control, but not a seam PROOF. */
346
383
  const FAKE_TIMER = /\b(?:useFakeTimers|setSystemTime|advanceTimersByTime|runAllTimers)\b/;
347
384
 
348
- /** `import { a, b as c } from './rel.mjs'` — SUT candidates live behind these. */
349
- const RELATIVE_IMPORT = /import\s+([^;]+?)\s+from\s+['"](\.[^'"]+)['"]/g;
385
+ /**
386
+ * Vitest aliases that resolve to the repo's own modules — source of truth is
387
+ * `resolve.alias` in vitest.config.mjs (today only `@lib`). CEILING: one entry
388
+ * per alias, kept by hand; REVISIT when vitest.config.mjs gains an alias — the
389
+ * parametrised B5 test reads that config and goes red until the key is listed
390
+ * here. Dynamic imports (`await import('@lib/…')`) are not read in either form.
391
+ */
392
+ const ALIAS_PREFIXES = ['@lib/'];
393
+
394
+ /** `import { a, b as c } from './rel.mjs'` or `'@lib/x.mjs'` — SUT candidates live behind these. */
395
+ const LOCAL_IMPORT = new RegExp(
396
+ String.raw`import\s+([^;]+?)\s+from\s+['"]((?:\.|` +
397
+ ALIAS_PREFIXES.map((p) => p.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|') +
398
+ String.raw`)[^'"]+)['"]`,
399
+ 'g',
400
+ );
350
401
 
351
402
  // ---------------------------------------------------------------------------
352
403
  // File enumeration
@@ -588,17 +639,17 @@ function testBlocks(lines) {
588
639
  }
589
640
 
590
641
  /**
591
- * Identifiers this test file imports from the repo's OWN modules (relative
592
- * specifiers). These are the subject-under-test candidates for B5; framework
593
- * (`vitest`) and stdlib (`node:*`) imports are structurally excluded because
594
- * their specifiers are not relative.
642
+ * Identifiers this test file imports from the repo's OWN modules (relative or
643
+ * vitest-alias specifiers). These are the subject-under-test candidates for B5;
644
+ * framework (`vitest`) and stdlib (`node:*`) imports are structurally excluded
645
+ * because their specifiers match neither form.
595
646
  * @param {string} content
596
647
  * @returns {string[]}
597
648
  */
598
649
  function importedLocalIdentifiers(content) {
599
650
  /** @type {Set<string>} */
600
651
  const ids = new Set();
601
- for (const m of content.matchAll(RELATIVE_IMPORT)) {
652
+ for (const m of content.matchAll(LOCAL_IMPORT)) {
602
653
  const clause = m[1];
603
654
  // `{ a, b as c }` → c ; `x` / `* as ns` → x / ns
604
655
  for (const part of clause.replace(/[{}]/g, ',').split(',')) {
@@ -623,8 +674,9 @@ function callsIdentifier(lines, id) {
623
674
  *
624
675
  * Two passes over the file's `it`/`test` blocks:
625
676
  * 1. PROVE the seam — an imported id called from a block that also hands over
626
- * an explicit clock argument is clock-seamed. A public API only grows a
627
- * `now` parameter because the function reads the clock on its main path.
677
+ * a NAMED clock key (SEAM_CLOCK_ARG) is clock-seamed. A public API only
678
+ * grows a `now` parameter because the function reads the clock on its
679
+ * main path.
628
680
  * 2. FLAG — in blocks with NO clock control at all, any equality assertion
629
681
  * pinning an absolute date against such a subject is a time bomb.
630
682
  *
@@ -648,7 +700,8 @@ function scanClockBombs(relPath, content, lines) {
648
700
  return {
649
701
  start,
650
702
  body,
651
- hasClockArg: live.some((l) => CLOCK_ARG.test(l)),
703
+ provesSeam: live.some((l) => SEAM_CLOCK_ARG.test(l)),
704
+ hasClockArg: live.some((l) => CONTROL_CLOCK_ARG.test(l)),
652
705
  hasFakeTimer: live.some((l) => FAKE_TIMER.test(l)),
653
706
  };
654
707
  });
@@ -656,19 +709,39 @@ function scanClockBombs(relPath, content, lines) {
656
709
  /** @type {Set<string>} */
657
710
  const seamed = new Set();
658
711
  for (const b of blocks) {
659
- if (!b.hasClockArg) continue;
712
+ if (!b.provesSeam) continue;
660
713
  for (const id of sutIds) if (callsIdentifier(b.body, id)) seamed.add(id);
661
714
  }
662
715
  if (seamed.size === 0) return findings;
663
716
 
717
+ // Where an echo may come from: the finding's own block, or the setup every
718
+ // block shares (helpers, constants, beforeEach). Never a sibling test — its
719
+ // assertions, titles and clock literals say nothing about this block's inputs.
720
+ // A block without its own `});` (a one-line it) runs on to the next sibling
721
+ // and takes the setup lines in between out of sharedSetup — that direction can
722
+ // only add a finding, never hide one (1.4% of blocks, measured 2026-09-30).
723
+ /** @type {Set<number>} */
724
+ const inBlock = new Set();
725
+ for (const b of blocks) for (let q = b.start; q < b.start + b.body.length; q++) inBlock.add(q);
726
+ const sharedSetup = lines.filter((_, q) => !inBlock.has(q));
727
+
664
728
  for (const b of blocks) {
665
729
  if (b.hasClockArg || b.hasFakeTimer) continue;
666
730
  const subject = [...seamed].find((id) => callsIdentifier(b.body, id));
667
731
  if (!subject) continue;
732
+ const echoSources = [...b.body, ...sharedSetup].filter(
733
+ (l) => !isCommentLine(l) && !DATE_EXPECTATION.test(l),
734
+ );
668
735
  b.body.forEach((line, k) => {
669
736
  if (isCommentLine(line)) return;
670
737
  const m = DATE_EXPECTATION.exec(line);
671
738
  if (!m) return;
739
+ // Echo: the pinned value is also an input of this block or its shared setup,
740
+ // not a clock reading (the CEILING above keeps fixed INPUT dates unflagged on
741
+ // purpose). CEILING: a real bomb whose date also stands in its own block or
742
+ // the shared setup (its title, a trailing comment, a helper fixture) is
743
+ // swallowed; REVISIT when a clock-shift run finds a bomb this scan missed.
744
+ if (echoSources.some((l) => l.includes(m[2]))) return;
672
745
  findings.push({
673
746
  file: relPath,
674
747
  line: b.start + k + 1,
@@ -204,6 +204,7 @@ const ALLOWED_EMOJI_CODEPOINTS = new Set([
204
204
  0x1f5a5, // 🖥
205
205
  0x1f6a6, // 🚦
206
206
  0x1f6a8, // 🚨
207
+ 0x1f9ed, // 🧭 (navigator banner line in hooks/on-session-start.mjs — #1462)
207
208
  0x2139, // ℹ
208
209
  0x2194, // ↔
209
210
  0x26a0, // ⚠
@@ -74,7 +74,7 @@
74
74
  * a backlog, printed as an aggregate like S4 and enumerated under `--list`.
75
75
  *
76
76
  * S6 carries no camelCase fallback ON PURPOSE: adding one re-hides both reaper
77
- * keys, whose camelCase names exist in `orphan-reaper.mjs` as that module's own
77
+ * keys, whose camelCase names exist in `orphan-reaper/defaults.mjs` as that module's own
78
78
  * defaults. A name is not a read.
79
79
  *
80
80
  * S2 applies to TOP-LEVEL keys only — a nested key reaches code through its