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.
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/navigator/SKILL.md +22 -0
- package/.agents/skills/navigator/agents/openai.yaml +5 -0
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/navigator/SKILL.md +22 -0
- package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/navigator.md +15 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +27 -12
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/navigator/SKILL.md +13 -0
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +70 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +57 -4
- package/docs/components.md +12 -12
- package/docs/events-schema.md +11 -8
- package/docs/persona-panel.md +5 -4
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +26 -4
- package/docs/session-config-reference.md +55 -6
- package/docs/session-config-template.md +11 -2
- package/hooks/_lib/hook-import-set.json +27 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +53 -0
- package/hooks/on-stop.mjs +3 -1
- package/hooks/post-tool-batch-wave-signal.mjs +3 -1
- package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +559 -72
- package/hooks/subagent-telemetry.mjs +96 -19
- package/package.json +3 -3
- package/pi/prompts/navigator.md +12 -0
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +492 -0
- package/scripts/archive-closed-prds.mjs +2 -1
- package/scripts/autopilot.mjs +49 -53
- package/scripts/backfill-abandoned-sessions.mjs +1 -1
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/cursor-install.mjs +1 -4
- package/scripts/emit-session.mjs +296 -2
- package/scripts/export-hw-learnings.mjs +22 -3
- package/scripts/fleet-checkin.mjs +147 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +10 -6
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +71 -10
- package/scripts/lib/events.mjs +21 -2
- package/scripts/lib/fleet-protocol.mjs +270 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/instruction-budget-guard.mjs +69 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
- package/scripts/lib/learnings/io.mjs +154 -26
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +104 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
- package/scripts/lib/orphan-reaper/scan.mjs +506 -0
- package/scripts/lib/orphan-reaper.mjs +43 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/process-group.mjs +62 -11
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +3 -4
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +27 -2
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-token-rollup.mjs +245 -52
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/telemetry/pricing.mjs +118 -71
- package/scripts/lib/tests-src-ratio.mjs +140 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
- package/scripts/lib/vault-status/board-writer.mjs +48 -11
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +10 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
- package/scripts/run-quality-gate.mjs +323 -302
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +56 -5
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +72 -46
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +131 -0
- package/skills/_shared/state-ownership.md +5 -0
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +1 -1
- package/skills/bootstrap/_shared-template.md +1 -1
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/evolve/SKILL.md +2 -2
- package/skills/evolve/references/evolve-analyze-mode.md +10 -6
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/navigator/SKILL.md +193 -0
- package/skills/navigator/references/account-check.sh +87 -0
- package/skills/navigator/references/fleet-decisions.mjs +231 -0
- package/skills/navigator/references/slot-check.sh +114 -0
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +22 -12
- package/skills/session-end/metrics-collection.md +5 -6
- package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +22 -60
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +6 -0
- package/skills/session-start/references/operations-contract.md +4 -0
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- package/skills/wave-executor/references/wave-loop-review.md +3 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- 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
|
|
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
|
-
/**
|
|
343
|
-
const
|
|
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
|
-
/**
|
|
349
|
-
|
|
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;
|
|
593
|
-
* (`vitest`) and stdlib (`node:*`) imports are structurally excluded
|
|
594
|
-
* their specifiers
|
|
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(
|
|
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
|
-
*
|
|
627
|
-
* `now` parameter because the function reads the clock on its
|
|
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
|
-
|
|
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.
|
|
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,
|
|
@@ -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
|