session-orchestrator 5.2.0 → 5.3.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/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -37,6 +37,15 @@
|
|
|
37
37
|
* 1. **Unresolvable id → the entry is KEPT** and counted in
|
|
38
38
|
* `unresolvedPairIds`. A sweep that guessed would delete prose on no
|
|
39
39
|
* evidence, and a file with any unresolved pair is never deleted.
|
|
40
|
+
* 1b. **Unrecognised counter sentence → `action: 'keep'` + a `skipped`
|
|
41
|
+
* record (`no-counter-sentence`), and NO write of any kind** — not a
|
|
42
|
+
* rewrite, not a raise, not a delete. Every write here moves the
|
|
43
|
+
* frontmatter `expires-at` and the body sentence that restates it; when
|
|
44
|
+
* only the header moves, the file ships a header contradicting a body
|
|
45
|
+
* sentence that forbids exactly that correction (GH#70, reported from a
|
|
46
|
+
* German-language consumer corpus 2026-09-20). Recognising a second
|
|
47
|
+
* spelling ({@link COUNTER_FORMS}) is the narrow half of that fix; the
|
|
48
|
+
* refusal is the half that holds for the third language too.
|
|
40
49
|
* 2. **Ambiguous file → `action: 'keep'` + a `skipped` record.** The
|
|
41
50
|
* entry↔pair mapping is POSITIONAL: the k-th `### ` heading belongs to the
|
|
42
51
|
* k-th non-`markers only` pair. That 1:1 mapping holds in only 3 of the 7
|
|
@@ -48,28 +57,54 @@
|
|
|
48
57
|
* `no-1to1-mapping` and touches nothing. It never guesses which paragraph
|
|
49
58
|
* belongs to which learning.
|
|
50
59
|
*
|
|
51
|
-
* ## Header recompute
|
|
60
|
+
* ## Header recompute is ONE-DIRECTIONAL: raised, never lowered
|
|
52
61
|
*
|
|
53
62
|
* A consolidated file's frontmatter `expires-at` plus the body sentence
|
|
54
63
|
* ``**`expires-at` <D> = the EARLIEST of the <N> absorbed dates**`` must not
|
|
55
|
-
* outlive its shortest-lived content.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
64
|
+
* outlive its shortest-lived content. The two directions of a
|
|
65
|
+
* header-vs-content discrepancy are NOT symmetric, so they are handled
|
|
66
|
+
* differently:
|
|
67
|
+
*
|
|
68
|
+
* - **Header EARLIER than the earliest absorbed date → RAISED, and that raise
|
|
69
|
+
* is itself a rewrite trigger** (`action: 'rewrite'`,
|
|
70
|
+
* `reason: 'header-raise'`). Left alone, the cliff has no way back:
|
|
71
|
+
* `scripts/lib/rule-loader.mjs` drops a file whose header has passed out of
|
|
72
|
+
* injection even though not one absorbed entry expired, while the surviving
|
|
73
|
+
* `learning-key`/`learning-id` markers keep `/reconcile` treating those
|
|
74
|
+
* learnings as materialized — so the substance goes dark silently and is
|
|
75
|
+
* never re-proposed. Measured 2026-09-18 (`--json`, live corpus): 3 of 7
|
|
76
|
+
* files are in that state (`identity-and-locks` 2026-10-01 vs 2026-10-02,
|
|
77
|
+
* `process-contracts` 2026-10-04 vs 2026-10-27, `toolchain-and-build`
|
|
78
|
+
* 2026-10-01 vs 2026-10-16), i.e. 31.5 kB of corpus that would fall dark
|
|
79
|
+
* with nothing expired.
|
|
80
|
+
* - **Header LATER than the earliest absorbed date → NEVER lowered**, only
|
|
81
|
+
* reported via the per-plan `advisory` field. Lowering on every run would
|
|
82
|
+
* shorten a healthy file's TTL — killing an entry early — on the very first
|
|
83
|
+
* live invocation, which is the direction that destroys substance rather
|
|
84
|
+
* than preserving it.
|
|
85
|
+
*
|
|
86
|
+
* The raise is MINIMAL: exactly the frontmatter `expires-at:` line and the
|
|
87
|
+
* `COUNTER_RE` body sentence change; every other byte of the file is left
|
|
88
|
+
* untouched (see {@link rewriteHeaderOnly}). It needs no entry↔pair mapping —
|
|
89
|
+
* it compares the frontmatter date against dates read out of `learnings.jsonl`
|
|
90
|
+
* by id — so a `no-1to1-mapping` file gets it too, exactly like the advisory
|
|
91
|
+
* (see {@link headerAdvisory}); that file's `skipped` record refers to the
|
|
92
|
+
* PROSE sweep, which still touches nothing there.
|
|
93
|
+
*
|
|
94
|
+
* `reason` rather than a novel `action` value, deliberately: the CLI
|
|
95
|
+
* (`scripts/sweep-expired-rules.mjs`) aggregates its human summary line and its
|
|
96
|
+
* `applied` counters over the three actions `rewrite`/`delete`/`keep` only, so a
|
|
97
|
+
* fourth action name would make a header raise invisible in exactly the preview
|
|
98
|
+
* an operator reads before `--apply`. A raise IS a write; it is counted as one.
|
|
99
|
+
*
|
|
100
|
+
* Measured 2026-09-18 (`node scripts/sweep-expired-rules.mjs --json`, 7 files
|
|
101
|
+
* scanned, 0 expired, 0 skipped): **3 of the 7 carry a discrepancy**, all three
|
|
102
|
+
* in the header-EARLIER direction and therefore all three raises —
|
|
103
|
+
* `identity-and-locks` 2026-10-01 → 2026-10-02, `process-contracts` 2026-10-04
|
|
104
|
+
* → 2026-10-27, `toolchain-and-build` 2026-10-01 → 2026-10-16. The other four
|
|
105
|
+
* agree with their content. (An earlier reading on 2026-09-17 listed six
|
|
106
|
+
* discrepancies including three header-LATER ones; the corpus has been
|
|
107
|
+
* hand-consolidated since, so re-measure rather than trusting either list.)
|
|
73
108
|
*
|
|
74
109
|
* Before the advisory moved above the skip branch it reached only the 3
|
|
75
110
|
* 1:1-mappable files, which structurally excluded the four merged-prose files —
|
|
@@ -134,11 +169,77 @@ const MS_PER_DAY = 86_400_000;
|
|
|
134
169
|
|
|
135
170
|
const PAIR_KEY_RE = /^- learning-key:\s*`([^`]+)`/;
|
|
136
171
|
const PAIR_ID_RE = /^- learning-id:\s*`([^`]+)`(.*)$/;
|
|
137
|
-
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The recognised spellings of the body counter sentence, each with the date as
|
|
175
|
+
* capture 1 and the absorbed-pair count as capture 2, in that order.
|
|
176
|
+
*
|
|
177
|
+
* The `d` flag is load-bearing: {@link spliceCounterLine} rewrites the line by
|
|
178
|
+
* replacing exactly the two captured SPANS, so every other byte — the wording,
|
|
179
|
+
* the language, any trailing clause — survives a rewrite untouched. That is why
|
|
180
|
+
* a second language costs one entry here and no re-templating anywhere: a
|
|
181
|
+
* template would silently translate a German sentence into English on the first
|
|
182
|
+
* header raise.
|
|
183
|
+
*
|
|
184
|
+
* English is this repo's own form (all 7 live consolidated files, measured
|
|
185
|
+
* 2026-09-20 @ 9b118cf6: `grep -n 'EARLIEST of the' .claude/rules/*.md` → 7
|
|
186
|
+
* hits, `grep -rn 'FRUEHESTE\|FRÜHESTE' .claude/rules/` → 0). German is the
|
|
187
|
+
* consumer-side form reported from EventDrop.at (GH#70, 2026-09-20), where a
|
|
188
|
+
* raise lifted the frontmatter and left the body sentence claiming the older
|
|
189
|
+
* date — the sentence that literally forbids correcting it upward.
|
|
190
|
+
*
|
|
191
|
+
* @type {readonly RegExp[]}
|
|
192
|
+
*/
|
|
193
|
+
const COUNTER_FORMS = Object.freeze([
|
|
194
|
+
/^\*\*`expires-at` (\S+) = the EARLIEST of the (\d+) absorbed dates\*\*/d,
|
|
195
|
+
/^\*\*`expires-at` (\S+) = das FR(?:UE|Ü)HESTE der (\d+) aufgenommenen Daten\*\*/d,
|
|
196
|
+
]);
|
|
197
|
+
|
|
138
198
|
const HEADING_PREFIX = '### ';
|
|
139
199
|
const PROVENANCE_HEADING = '## Provenance';
|
|
140
200
|
const UNTRUSTED_END = '<!-- untrusted-content:end -->';
|
|
141
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Match one line against every recognised counter-sentence form.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} line
|
|
206
|
+
* @returns {{date: string, count: number, match: RegExpExecArray}|null}
|
|
207
|
+
*/
|
|
208
|
+
function matchCounterLine(line) {
|
|
209
|
+
for (const form of COUNTER_FORMS) {
|
|
210
|
+
const m = form.exec(line);
|
|
211
|
+
if (m) return { date: m[1], count: Number(m[2]), match: m };
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Rewrite a counter-sentence line to a new date and count by splicing the two
|
|
218
|
+
* captured spans, leaving every other byte — wording, language, trailing text —
|
|
219
|
+
* exactly as authored.
|
|
220
|
+
*
|
|
221
|
+
* Splices BACK-TO-FRONT (count before date) so the earlier capture's indices
|
|
222
|
+
* stay valid while the later text is replaced.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} line the line as it stands in the file
|
|
225
|
+
* @param {string} date `YYYY-MM-DD` to write
|
|
226
|
+
* @param {number} count the absorbed-pair count to write
|
|
227
|
+
* @returns {string} the rewritten line, or `line` unchanged when no form matches
|
|
228
|
+
*/
|
|
229
|
+
function spliceCounterLine(line, date, count) {
|
|
230
|
+
const hit = matchCounterLine(line);
|
|
231
|
+
if (!hit) return line;
|
|
232
|
+
const [dateStart, dateEnd] = hit.match.indices[1];
|
|
233
|
+
const [countStart, countEnd] = hit.match.indices[2];
|
|
234
|
+
return (
|
|
235
|
+
line.slice(0, dateStart) +
|
|
236
|
+
date +
|
|
237
|
+
line.slice(dateEnd, countStart) +
|
|
238
|
+
String(count) +
|
|
239
|
+
line.slice(countEnd)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
142
243
|
/**
|
|
143
244
|
* Parse a consolidated machine-generated rule file into its editable parts.
|
|
144
245
|
*
|
|
@@ -193,11 +294,11 @@ export function parseConsolidatedRule(content) {
|
|
|
193
294
|
const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
|
|
194
295
|
for (let i = bodyStart; i < lines.length; i += 1) {
|
|
195
296
|
if (counterLine === -1) {
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
297
|
+
const hit = matchCounterLine(lines[i]);
|
|
298
|
+
if (hit) {
|
|
198
299
|
counterLine = i;
|
|
199
|
-
counterDate =
|
|
200
|
-
counterCount =
|
|
300
|
+
counterDate = hit.date;
|
|
301
|
+
counterCount = hit.count;
|
|
201
302
|
continue;
|
|
202
303
|
}
|
|
203
304
|
}
|
|
@@ -292,6 +393,51 @@ function dateOnly(iso) {
|
|
|
292
393
|
return String(iso).slice(0, 10);
|
|
293
394
|
}
|
|
294
395
|
|
|
396
|
+
/**
|
|
397
|
+
* The earliest resolvable absorbed date on the file, as `YYYY-MM-DD`.
|
|
398
|
+
*
|
|
399
|
+
* Population: EVERY provenance pair whose `learning-id` resolves to a parseable
|
|
400
|
+
* `expires_at`, markers-only pairs included — an absorbed date stays absorbed
|
|
401
|
+
* after its prose is gone, which is exactly what the body sentence's `N` counts.
|
|
402
|
+
* Unresolvable ids contribute nothing (they are reported separately as
|
|
403
|
+
* `unresolvedPairIds`), so a file whose ids all fail to resolve yields `null`
|
|
404
|
+
* rather than a guess.
|
|
405
|
+
*
|
|
406
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
407
|
+
* @param {Map<string, string>} expiryById
|
|
408
|
+
* @returns {string|null}
|
|
409
|
+
*/
|
|
410
|
+
function earliestResolvableDate(parsed, expiryById) {
|
|
411
|
+
const dates = parsed.pairs
|
|
412
|
+
.map((p) => expiryById.get(p.id))
|
|
413
|
+
.filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
|
|
414
|
+
.sort();
|
|
415
|
+
return dates.length > 0 ? dateOnly(dates[0]) : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The date to RAISE a too-low header to, or `null` when there is nothing to do.
|
|
420
|
+
*
|
|
421
|
+
* One-directional by contract: returns a date only when the header expires
|
|
422
|
+
* STRICTLY EARLIER than the earliest resolvable absorbed date. A header that
|
|
423
|
+
* outlives its content is never lowered here — see the module header for why
|
|
424
|
+
* the two directions are not symmetric.
|
|
425
|
+
*
|
|
426
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
427
|
+
* @param {Map<string, string>} expiryById
|
|
428
|
+
* @returns {string|null}
|
|
429
|
+
*/
|
|
430
|
+
function headerRaiseTarget(parsed, expiryById) {
|
|
431
|
+
if (!parsed.expiresAt || parsed.expiresAtLine < 0) return null;
|
|
432
|
+
const headerMs = Date.parse(parsed.expiresAt);
|
|
433
|
+
if (!Number.isFinite(headerMs)) return null;
|
|
434
|
+
const earliest = earliestResolvableDate(parsed, expiryById);
|
|
435
|
+
if (earliest === null) return null;
|
|
436
|
+
const earliestMs = Date.parse(earliest);
|
|
437
|
+
if (!Number.isFinite(earliestMs) || earliestMs <= headerMs) return null;
|
|
438
|
+
return earliest;
|
|
439
|
+
}
|
|
440
|
+
|
|
295
441
|
/**
|
|
296
442
|
* The header-vs-content discrepancy, as a one-line advisory string.
|
|
297
443
|
*
|
|
@@ -312,16 +458,71 @@ function dateOnly(iso) {
|
|
|
312
458
|
*/
|
|
313
459
|
function headerAdvisory(parsed, expiryById) {
|
|
314
460
|
if (!parsed.expiresAt) return undefined;
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
.filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
|
|
318
|
-
.sort();
|
|
319
|
-
if (dates.length === 0) return undefined;
|
|
320
|
-
const earliest = dateOnly(dates[0]);
|
|
321
|
-
if (earliest === parsed.expiresAt) return undefined;
|
|
461
|
+
const earliest = earliestResolvableDate(parsed, expiryById);
|
|
462
|
+
if (earliest === null || earliest === parsed.expiresAt) return undefined;
|
|
322
463
|
return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
|
|
323
464
|
}
|
|
324
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Rewrite ONLY the two dated header lines — the frontmatter `expires-at:` and
|
|
468
|
+
* the `COUNTER_RE` body sentence. Every other byte of the file is carried over
|
|
469
|
+
* untouched; that byte-identity is the whole safety argument for making a raise
|
|
470
|
+
* a rewrite trigger, and it is pinned by a test.
|
|
471
|
+
*
|
|
472
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
473
|
+
* @param {string} target `YYYY-MM-DD` to raise the header to
|
|
474
|
+
* @returns {string}
|
|
475
|
+
*/
|
|
476
|
+
function rewriteHeaderOnly(parsed, target) {
|
|
477
|
+
const lines = [...parsed.lines];
|
|
478
|
+
if (parsed.expiresAtLine >= 0) lines[parsed.expiresAtLine] = `expires-at: ${target}`;
|
|
479
|
+
if (parsed.counterLine >= 0) {
|
|
480
|
+
lines[parsed.counterLine] = spliceCounterLine(
|
|
481
|
+
lines[parsed.counterLine],
|
|
482
|
+
target,
|
|
483
|
+
parsed.pairs.length,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
return lines.join('\n');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The plan for a file with NO expired entry: a byte-identical `keep`, or — when
|
|
491
|
+
* the header sits below the earliest absorbed date — a minimal `header-raise`
|
|
492
|
+
* rewrite. Shared by both no-expiry branches (the `no-1to1-mapping` skip and
|
|
493
|
+
* the ordinary nothing-expired case) because the raise needs no entry↔pair
|
|
494
|
+
* mapping.
|
|
495
|
+
*
|
|
496
|
+
* @param {object} base the common plan fields
|
|
497
|
+
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
498
|
+
* @param {string|undefined} advisory
|
|
499
|
+
* @param {string|null} raiseTo
|
|
500
|
+
* @returns {object}
|
|
501
|
+
*/
|
|
502
|
+
function keepOrRaisePlan(base, parsed, advisory, raiseTo) {
|
|
503
|
+
if (raiseTo === null) {
|
|
504
|
+
return {
|
|
505
|
+
...base,
|
|
506
|
+
action: 'keep',
|
|
507
|
+
newExpiresAt: null,
|
|
508
|
+
newAbsorbedCount: parsed.pairs.length,
|
|
509
|
+
bytesAfter: base.bytesBefore,
|
|
510
|
+
...(advisory ? { advisory } : {}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const nextContent = rewriteHeaderOnly(parsed, raiseTo);
|
|
514
|
+
return {
|
|
515
|
+
...base,
|
|
516
|
+
action: 'rewrite',
|
|
517
|
+
reason: 'header-raise',
|
|
518
|
+
newExpiresAt: raiseTo,
|
|
519
|
+
newAbsorbedCount: parsed.pairs.length,
|
|
520
|
+
bytesAfter: Buffer.byteLength(nextContent, 'utf8'),
|
|
521
|
+
nextContent,
|
|
522
|
+
...(advisory ? { advisory } : {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
325
526
|
/**
|
|
326
527
|
* Plan the sweep over every machine-generated rule file. Reads only.
|
|
327
528
|
*
|
|
@@ -387,25 +588,65 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
387
588
|
// never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
|
|
388
589
|
// where 6 of 7 have the discrepancy.
|
|
389
590
|
const advisory = headerAdvisory(parsed, expiryById);
|
|
591
|
+
const raiseTo = headerRaiseTarget(parsed, expiryById);
|
|
592
|
+
|
|
593
|
+
if (parsed.counterLine === -1) {
|
|
594
|
+
// FAIL-CLOSED (GH#70). Every write this module performs moves the
|
|
595
|
+
// frontmatter `expires-at` AND the body sentence that restates it. When
|
|
596
|
+
// the sentence is in a spelling {@link COUNTER_FORMS} does not recognise,
|
|
597
|
+
// the header still moves and the sentence does not — and that sentence
|
|
598
|
+
// literally says the date must not be corrected upward, so the file ships
|
|
599
|
+
// a header contradicting its own body to every agent that loads it
|
|
600
|
+
// (measured on a consumer's corpus: header 2026-11-01 beside a body
|
|
601
|
+
// claiming 2026-10-19). A second regex alone only moves the defect to the
|
|
602
|
+
// next language; refusing to write is what holds for ALL of them.
|
|
603
|
+
//
|
|
604
|
+
// The refusal covers `delete` too, not only the two rewrite triggers: the
|
|
605
|
+
// counter sentence is part of the consolidated-rule contract
|
|
606
|
+
// (`docs/rule-authoring.md` § Consolidated rules point 2), so a file
|
|
607
|
+
// without a recognised one is a shape this sweep does not understand, and
|
|
608
|
+
// `delete` is its most destructive action. Report it, touch nothing.
|
|
609
|
+
skipped.push({ file: rule.file, reason: 'no-counter-sentence' });
|
|
610
|
+
plans.push(
|
|
611
|
+
keepOrRaisePlan(
|
|
612
|
+
{
|
|
613
|
+
file: rule.file,
|
|
614
|
+
expiredPairIds: [],
|
|
615
|
+
keptPairIds: substantive.map((p) => p.id),
|
|
616
|
+
unresolvedPairIds: substantive.filter((p) => !expiryById.has(p.id)).map((p) => p.id),
|
|
617
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
618
|
+
headings: parsed.entries.length,
|
|
619
|
+
substantivePairs: substantive.length,
|
|
620
|
+
},
|
|
621
|
+
parsed,
|
|
622
|
+
advisory,
|
|
623
|
+
null, // never raise: the body sentence cannot be kept in agreement
|
|
624
|
+
),
|
|
625
|
+
);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
390
628
|
|
|
391
629
|
if (parsed.entries.length !== substantive.length) {
|
|
392
|
-
// FAIL-OPEN (decision 3): report, never guess
|
|
393
|
-
//
|
|
630
|
+
// FAIL-OPEN (decision 3): report, never guess — the PROSE sweep touches
|
|
631
|
+
// nothing here. A skipped file still carries its advisory, and still gets
|
|
632
|
+
// a too-low header raised: neither needs an entry↔pair mapping.
|
|
394
633
|
skipped.push({ file: rule.file, reason: 'no-1to1-mapping' });
|
|
395
|
-
plans.push(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
634
|
+
plans.push(
|
|
635
|
+
keepOrRaisePlan(
|
|
636
|
+
{
|
|
637
|
+
file: rule.file,
|
|
638
|
+
expiredPairIds: [],
|
|
639
|
+
keptPairIds: substantive.map((p) => p.id),
|
|
640
|
+
unresolvedPairIds: substantive.filter((p) => !expiryById.has(p.id)).map((p) => p.id),
|
|
641
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
642
|
+
headings: parsed.entries.length,
|
|
643
|
+
substantivePairs: substantive.length,
|
|
644
|
+
},
|
|
645
|
+
parsed,
|
|
646
|
+
advisory,
|
|
647
|
+
raiseTo,
|
|
648
|
+
),
|
|
649
|
+
);
|
|
409
650
|
continue;
|
|
410
651
|
}
|
|
411
652
|
|
|
@@ -443,16 +684,11 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
443
684
|
};
|
|
444
685
|
|
|
445
686
|
if (expired.length === 0) {
|
|
446
|
-
// Byte-identical
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
newExpiresAt: null,
|
|
452
|
-
newAbsorbedCount: parsed.pairs.length,
|
|
453
|
-
bytesAfter: base.bytesBefore,
|
|
454
|
-
...(advisory ? { advisory } : {}),
|
|
455
|
-
});
|
|
687
|
+
// Byte-identical, UNLESS the header sits below the earliest absorbed date
|
|
688
|
+
// — that one direction is repaired (raised), never merely reported, or the
|
|
689
|
+
// whole file falls out of injection with nothing expired. The opposite
|
|
690
|
+
// direction stays an advisory. See the module header.
|
|
691
|
+
plans.push(keepOrRaisePlan(base, parsed, advisory, raiseTo));
|
|
456
692
|
continue;
|
|
457
693
|
}
|
|
458
694
|
|
|
@@ -474,6 +710,7 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
474
710
|
plans.push({
|
|
475
711
|
...base,
|
|
476
712
|
action: 'rewrite',
|
|
713
|
+
reason: 'expired-entries',
|
|
477
714
|
newExpiresAt: earliest,
|
|
478
715
|
newAbsorbedCount: parsed.pairs.length,
|
|
479
716
|
bytesAfter: Buffer.byteLength(nextContent, 'utf8'),
|
|
@@ -505,9 +742,10 @@ function rewriteContent({ parsed, expired, earliest, nowMs }) {
|
|
|
505
742
|
if (earliest !== null) {
|
|
506
743
|
if (parsed.expiresAtLine >= 0) lines[parsed.expiresAtLine] = `expires-at: ${earliest}`;
|
|
507
744
|
if (parsed.counterLine >= 0) {
|
|
508
|
-
lines[parsed.counterLine] =
|
|
509
|
-
|
|
510
|
-
|
|
745
|
+
lines[parsed.counterLine] = spliceCounterLine(
|
|
746
|
+
lines[parsed.counterLine],
|
|
747
|
+
earliest,
|
|
748
|
+
parsed.pairs.length,
|
|
511
749
|
);
|
|
512
750
|
}
|
|
513
751
|
}
|
|
@@ -576,6 +814,10 @@ function assertSweepable(abs, rulesDir, file) {
|
|
|
576
814
|
* file still dedupes) rather than an unstamped learning with no file, which
|
|
577
815
|
* `/reconcile` would re-propose.
|
|
578
816
|
*
|
|
817
|
+
* A plan carrying `reason: 'header-raise'` is an ordinary `action: 'rewrite'`
|
|
818
|
+
* here — same guards, same atomic write, counted in `rewritten` — because it IS
|
|
819
|
+
* a write to a tracked rule file; only its `nextContent` is narrower.
|
|
820
|
+
*
|
|
579
821
|
* Every rewrite and every delete passes {@link assertSweepable} first (regular
|
|
580
822
|
* file, inside the rules directory, not a symlink), and every rewrite goes
|
|
581
823
|
* through `atomicWriteWithBackup` rather than `writeFileSync`. A refusal is a
|
|
@@ -198,8 +198,27 @@ export const LEARNING_KEY_RE = /^[a-z0-9/-]+$/;
|
|
|
198
198
|
/** `expires-at:` — YYYY-MM-DD. A garbage value makes the loader's expiry gate FAIL OPEN. */
|
|
199
199
|
export const EXPIRES_AT_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
200
200
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
201
|
+
/**
|
|
202
|
+
* `learning-id` / `source-session` — rendered inside backticks, which an
|
|
203
|
+
* interior backtick would close.
|
|
204
|
+
*
|
|
205
|
+
* `/` is admitted since GH#71. A semantic session id is DERIVED FROM A BRANCH
|
|
206
|
+
* NAME, and branch names carry slashes — so the narrower class did not reject
|
|
207
|
+
* hostile text, it rejected a whole legitimate id family. The damage is silent
|
|
208
|
+
* and unbounded: `engine.mjs` degrades the throw to an audited rejection, so
|
|
209
|
+
* every learning from such a session is proposed on every run and rejected on
|
|
210
|
+
* every run, forever. Measured in THIS repo 2026-09-20 @ `9b118cf6`:
|
|
211
|
+
* `jq -r 'select(.session_id != null) | .session_id'
|
|
212
|
+
* .orchestrator/metrics/sessions.jsonl | grep -c '/'` → **3**, all of the
|
|
213
|
+
* `feat/operator-surface-2026-08-08-…` family; `git branch -a --format=…
|
|
214
|
+
* | grep -c '/'` → **6**. It hit two usable learnings in the reporting
|
|
215
|
+
* consumer's run (source: peer session eventdrop-at, 2026-09-20).
|
|
216
|
+
*
|
|
217
|
+
* Widening a SANITISER is only safe against a census of what the new character
|
|
218
|
+
* makes expressible at the call sites — see {@link assertNoPathEscape} for the
|
|
219
|
+
* one hazard `/` adds and for the four it does not.
|
|
220
|
+
*/
|
|
221
|
+
export const PROVENANCE_TOKEN_RE = /^[A-Za-z0-9._:/-]+$/;
|
|
203
222
|
|
|
204
223
|
// The Unicode Control category (C0 + DEL + C1). Deliberately expressed as
|
|
205
224
|
// `\p{Cc}` rather than a hand-written character class: an escape-written class
|
|
@@ -275,6 +294,51 @@ function rejection(field, why, value) {
|
|
|
275
294
|
return new Error(`reconcile-sanitize: rejecting record — ${field} ${why} (got ${shown})`);
|
|
276
295
|
}
|
|
277
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Reject the ONE hazard a `/` adds to a machine token: a path shape.
|
|
299
|
+
*
|
|
300
|
+
* Derived from the call sites, not from caution. Both slash-bearing patterns —
|
|
301
|
+
* {@link PROVENANCE_TOKEN_RE} and {@link LEARNING_KEY_RE} — route through
|
|
302
|
+
* {@link assertMachineToken}, so the guard lives in that ONE shared function
|
|
303
|
+
* rather than at each caller: one guard beats one-guard-per-call-site, and it
|
|
304
|
+
* covers the next caller of an EXPORTED assert by construction
|
|
305
|
+
* (`.claude/rules/build-value.md` BV-003).
|
|
306
|
+
*
|
|
307
|
+
* **What `/` does NOT make expressible**, so this guard deliberately does not
|
|
308
|
+
* re-check it (each is already structurally impossible in every pattern that
|
|
309
|
+
* admits a slash):
|
|
310
|
+
*
|
|
311
|
+
* 1. *Inline-code break-out.* Both provenance values render inside a backtick
|
|
312
|
+
* span (`renderer.mjs:469-470`); a backtick is in no slash-bearing class.
|
|
313
|
+
* 2. *Frontmatter escape.* A newline would open a sibling top-level key — but
|
|
314
|
+
* JS `$` without the `m` flag does not match before a trailing newline
|
|
315
|
+
* (measured 2026-09-20: `/^[A-Za-z0-9._:-]+$/.test("abc\n")` → `false`),
|
|
316
|
+
* so a trailing newline was and stays rejected.
|
|
317
|
+
* 3. *Wrapper forgery.* Every literal in {@link WRAPPER_FORGERY_LITERALS}
|
|
318
|
+
* needs `<`, `>`, `#`, a space or `(` — none of them in these classes.
|
|
319
|
+
* 4. *Dangerous invisibles.* Cf code points are outside these classes too.
|
|
320
|
+
*
|
|
321
|
+
* Gated on a literal `/` being present, which makes the guard a provable no-op
|
|
322
|
+
* for every token shape accepted BEFORE the widening: without a separator there
|
|
323
|
+
* is no traversal to express, and `..` alone in a backtick span is inert text.
|
|
324
|
+
* Census over the live store 2026-09-20 @ `9b118cf6`: 0 of 263 learning-keys and
|
|
325
|
+
* 0 learning ids carry `..` or a leading `/`, so nothing legitimate is caught.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} value a value that already matched its pattern
|
|
328
|
+
* @param {string} field
|
|
329
|
+
* @returns {void}
|
|
330
|
+
* @throws {Error} on a leading `/` (absolute path) or any `..` (traversal)
|
|
331
|
+
*/
|
|
332
|
+
function assertNoPathEscape(value, field) {
|
|
333
|
+
if (!value.includes('/')) return;
|
|
334
|
+
if (value.startsWith('/')) {
|
|
335
|
+
throw rejection(field, 'must not start with "/" (absolute path)', value);
|
|
336
|
+
}
|
|
337
|
+
if (value.includes('..')) {
|
|
338
|
+
throw rejection(field, 'must not contain ".." (path traversal)', value);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
278
342
|
/**
|
|
279
343
|
* Assert that a machine value is a non-empty string matching `pattern`.
|
|
280
344
|
* REJECTS (throws) rather than repairing — see the module doc's dividing line.
|
|
@@ -282,7 +346,8 @@ function rejection(field, why, value) {
|
|
|
282
346
|
* @param {unknown} value
|
|
283
347
|
* @param {{ field: string, pattern: RegExp }} opts
|
|
284
348
|
* @returns {string} the value, unchanged, when it passes
|
|
285
|
-
* @throws {Error} when the value is not a string
|
|
349
|
+
* @throws {Error} when the value is not a string, does not match `pattern`, or
|
|
350
|
+
* carries a path escape (see {@link assertNoPathEscape})
|
|
286
351
|
*/
|
|
287
352
|
export function assertMachineToken(value, { field, pattern }) {
|
|
288
353
|
if (typeof value !== 'string' || value === '') {
|
|
@@ -291,6 +356,7 @@ export function assertMachineToken(value, { field, pattern }) {
|
|
|
291
356
|
if (!pattern.test(value)) {
|
|
292
357
|
throw rejection(field, `must match ${pattern.source}`, value);
|
|
293
358
|
}
|
|
359
|
+
assertNoPathEscape(value, field);
|
|
294
360
|
return value;
|
|
295
361
|
}
|
|
296
362
|
|