session-orchestrator 3.17.0 → 3.20.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* learnings/select.mjs — choose which learnings enter ONE dispatched agent's
|
|
3
|
+
* compact index, given that agent's declared file scope (#1014).
|
|
4
|
+
*
|
|
5
|
+
* ## The problem
|
|
6
|
+
*
|
|
7
|
+
* ~10² learnings accumulated across 233 sessions and a wave-agent receives ZERO
|
|
8
|
+
* of them: the only read paths are a coordinator banner, an autopilot call, and
|
|
9
|
+
* a nudge banner — none reaches a dispatched agent. This module is the selection
|
|
10
|
+
* half of closing that loop; a sibling CLI renders/injects the result.
|
|
11
|
+
*
|
|
12
|
+
* ## Why two tiers (the measured reason)
|
|
13
|
+
*
|
|
14
|
+
* Only a SMALL MINORITY of live learnings carry a non-empty `file_paths`. A
|
|
15
|
+
* purely scope-matched index is therefore EMPTY for most agents — the feature
|
|
16
|
+
* would ship and deliver nothing. So selection runs in two tiers with SPLIT
|
|
17
|
+
* budgets:
|
|
18
|
+
*
|
|
19
|
+
* (a) SCOPED — learnings whose `file_paths` relate to the agent's scope
|
|
20
|
+
* (see {@link SCOPE_MATCH_MIN_PATH_SCORE}), capped at `maxScoped`.
|
|
21
|
+
* (b) GLOBAL — top-scoring remaining learnings (the path-less majority),
|
|
22
|
+
* capped separately at `maxGlobal`.
|
|
23
|
+
*
|
|
24
|
+
* Snapshot behind that shape — a MEASURED-AT figure, not a standing fact; the
|
|
25
|
+
* corpus grows every session, so re-measure before citing it (PSA-006):
|
|
26
|
+
* **17 of 100 records carry `file_paths`; 17 of the 94 that pass the active gate
|
|
27
|
+
* = 18.1%, leaving ~82% path-less.** Measured 2026-08-13 @5d59e62 via
|
|
28
|
+
* `jq -s '[.[]|select(((.file_paths // .files // [])|length)>0)]|length'
|
|
29
|
+
* .orchestrator/metrics/learnings.jsonl`. The tier-(b) argument depends only on
|
|
30
|
+
* the minority/majority split, which has held across every re-measurement so
|
|
31
|
+
* far — not on the exact percentage.
|
|
32
|
+
*
|
|
33
|
+
* The caps are SPLIT, never shared: a single shared cap lets the global tier
|
|
34
|
+
* crowd out the per-agent signal that is #1014's whole point. The split is
|
|
35
|
+
* observable in the return value (`scopeMatched` / `globalCount`) so the ratio
|
|
36
|
+
* stays measurable in production.
|
|
37
|
+
*
|
|
38
|
+
* The irony that motivates tier (b): the single most relevant learning for
|
|
39
|
+
* building this very feature carries no `file_paths` — tier (a) alone drops it.
|
|
40
|
+
*
|
|
41
|
+
* ## Composition (this module re-implements nothing)
|
|
42
|
+
*
|
|
43
|
+
* - `affinity()` from `./affinity.mjs` — the frozen relatedness surface. The
|
|
44
|
+
* agent's scope descriptor `{file_paths, text}` and a learning record are
|
|
45
|
+
* the same shape to it.
|
|
46
|
+
* - `effectiveScore()` / `surfaceTopN()` from `./surface.mjs` — the reader,
|
|
47
|
+
* the active-filter, and the #670 time-decay ranking. There is no second
|
|
48
|
+
* reader here and no second decay implementation.
|
|
49
|
+
* - `sanitizeProse()` from `../reconcile/sanitize.mjs` — untrusted-text
|
|
50
|
+
* containment. Every line this module renders is AGENT-AUTHORED text bound
|
|
51
|
+
* for a dispatched agent's prompt, which is the identical threat model
|
|
52
|
+
* #1015 hardened for `.claude/rules/`. The primitives are imported, never
|
|
53
|
+
* re-implemented: a second copy is how this channel shipped raw beside the
|
|
54
|
+
* hardened one in the first place.
|
|
55
|
+
*
|
|
56
|
+
* NOT used: `filterByScope()` from `./filters.mjs`. Despite the name it filters
|
|
57
|
+
* the PRIVACY enum `['local','private','public']` (schema.mjs), not file scope.
|
|
58
|
+
* The file-scope axis lives in `file_paths[]`. This trap has misled readers
|
|
59
|
+
* before — do not "fix" it here.
|
|
60
|
+
*
|
|
61
|
+
* ## What this module owns
|
|
62
|
+
*
|
|
63
|
+
* Policy: thresholds, split caps, the char budget, tie-breaking, and the
|
|
64
|
+
* one-line rendering the budget is measured against. Ranking is ours precisely
|
|
65
|
+
* because `affinity()` reports `typeMatch` without folding it into its score.
|
|
66
|
+
* We deliberately apply NO same-type boost: a scope descriptor carries no
|
|
67
|
+
* `type`, so `typeMatch` is structurally always false on this axis.
|
|
68
|
+
*
|
|
69
|
+
* ## Budget
|
|
70
|
+
*
|
|
71
|
+
* {@link LEARNINGS_INDEX_MAX_CHARS} is a CODE CONSTANT with no
|
|
72
|
+
* `0 = unlimited` sentinel — that sentinel is the explicit upstream mistake
|
|
73
|
+
* #1014 exists to avoid. 2000 chars is 1.12% of the 178,095-byte per-agent
|
|
74
|
+
* prompt baseline measured during #1014 (a prompt measurement, not derivable
|
|
75
|
+
* from the tree — re-measure it before re-citing), and 0.92× the median
|
|
76
|
+
* `.claude/rules/` file: median 2,167 B over 29 files, re-verified
|
|
77
|
+
* 2026-08-13 @5d59e62 via
|
|
78
|
+
* `find .claude/rules -maxdepth 1 -name '*.md' -exec wc -c {} \; | sort -n`.
|
|
79
|
+
* Repo precedent for literal caps: `LOOP_MD_MAX_BYTES = 25_000`,
|
|
80
|
+
* `DEFAULT_MAX_LINE_CHARS = 400`, `MAX_TEXT_LEN = 256`.
|
|
81
|
+
*
|
|
82
|
+
* ## Contract
|
|
83
|
+
*
|
|
84
|
+
* 1. {@link selectLearnings} never throws. Hostile input yields
|
|
85
|
+
* {@link emptySelection} — this runs on the dispatch hot path and must
|
|
86
|
+
* never abort a wave (same posture as `affinity()` and `surfaceTopN()`).
|
|
87
|
+
* A record whose text forges the delivery wrapper is DROPPED and counted in
|
|
88
|
+
* `selection.rejected`, never rendered: the sanitiser's throw is caught
|
|
89
|
+
* per-entry so one hostile record costs one entry, not the whole index.
|
|
90
|
+
* 2. Zero matches yield an EMPTY selection: `text === ''`, no placeholder
|
|
91
|
+
* line. Callers rely on empty-means-inject-nothing.
|
|
92
|
+
* 3. `selection.text.length <= maxChars` always. An entry that does not fit
|
|
93
|
+
* is DROPPED (and `truncated` set), never emitted half-rendered.
|
|
94
|
+
* 4. Deterministic: same inputs → same ordering. Ties break by
|
|
95
|
+
* **score DESC, then `created_at` DESC, then `id` ASC**.
|
|
96
|
+
* 5. Expired and sub-floor entries are never selected.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
import { affinity } from './affinity.mjs';
|
|
100
|
+
import {
|
|
101
|
+
INSIGHT_MAX_BYTES,
|
|
102
|
+
TITLE_MAX_BYTES,
|
|
103
|
+
sanitizeProse,
|
|
104
|
+
} from '../reconcile/sanitize.mjs';
|
|
105
|
+
import { DECAY_DEFAULTS, effectiveScore, surfaceTopN } from './surface.mjs';
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Constants — exported so a later wave can wire Session Config keys onto them
|
|
109
|
+
// without touching the logic below (config lookups are deliberately absent).
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Hard character cap on the rendered index. NO `0 = unlimited` sentinel.
|
|
114
|
+
* 2000 = 1.12% of the measured 178,095 B per-agent prompt baseline.
|
|
115
|
+
*/
|
|
116
|
+
export const LEARNINGS_INDEX_MAX_CHARS = 2000;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Per-entry line cap. 160 leaves room for a long subject without letting one
|
|
120
|
+
* entry eat the budget, and the split caps make the fit ARITHMETIC rather than
|
|
121
|
+
* empirical: a full index is at most `(8 + 4) × 160 + 11` newlines = 1,931 chars
|
|
122
|
+
* < {@link LEARNINGS_INDEX_MAX_CHARS}, so the two constants can never disagree.
|
|
123
|
+
* That bound is derived and cannot go stale; the observed mean line is the part
|
|
124
|
+
* that drifts — 163 B/entry, measured 2026-08-13 @5d59e62 over the live corpus
|
|
125
|
+
* (`selectLearningsFromFile` on `.orchestrator/metrics/learnings.jsonl`), up
|
|
126
|
+
* from the ~122 B seen when this cap was first set.
|
|
127
|
+
*/
|
|
128
|
+
export const LEARNINGS_INDEX_MAX_LINE_CHARS = 160;
|
|
129
|
+
|
|
130
|
+
/** Split budgets — scoped signal can never be crowded out by the global tier. */
|
|
131
|
+
export const DEFAULT_MAX_SCOPED = 8;
|
|
132
|
+
export const DEFAULT_MAX_GLOBAL = 4;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Minimum `pathScore` for tier (a) membership.
|
|
136
|
+
*
|
|
137
|
+
* Calibrated against `affinity`'s segment-aware pair scores:
|
|
138
|
+
* - exact path → 1.0 (in)
|
|
139
|
+
* - directory prefix → 0.75 (in)
|
|
140
|
+
* - sibling in the same dir, depth 4 → 0.375 (in)
|
|
141
|
+
* - sibling in the same dir, depth 2 → 0.25 (in, exactly on the boundary)
|
|
142
|
+
* - cousin dirs (`scripts/lib/a` vs `scripts/hooks/b`) → 0.167 (out)
|
|
143
|
+
*
|
|
144
|
+
* Only dyadic ratios land exactly on the boundary, so `>=` is safe here; the
|
|
145
|
+
* excluded cases sit an order of magnitude below it.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately NOT `sharedPaths.length === 0`: `sharedPaths` lists EXACT
|
|
148
|
+
* overlaps only, so a directory-prefix match scores 0.75 without appearing
|
|
149
|
+
* there. Using it as a proxy would silently drop the strongest partial matches.
|
|
150
|
+
*/
|
|
151
|
+
export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Blend of relevance (affinity to this agent's scope) against quality
|
|
155
|
+
* (recency-decayed confidence). Relevance dominates — per-agent differentiation
|
|
156
|
+
* IS the acceptance criterion. Weight-normalized like `affinity()`, so the
|
|
157
|
+
* result stays in [0,1] for any non-negative pair.
|
|
158
|
+
*/
|
|
159
|
+
export const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How many active entries the file entry-point pulls before ranking.
|
|
163
|
+
* Ceiling: the live corpus is ~10² entries and scoring is O(pool × scopePaths);
|
|
164
|
+
* revisit if the corpus passes ~1,000 entries, where a pre-filter would pay off.
|
|
165
|
+
*/
|
|
166
|
+
export const CANDIDATE_POOL_SIZE = 200;
|
|
167
|
+
|
|
168
|
+
/** Mirrors `surfaceTopN`'s default — entries at or below this are dropped. */
|
|
169
|
+
export const DEFAULT_CONFIDENCE_FLOOR = 0.3;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @typedef {{file_paths?: string[], text?: string}} AgentScope
|
|
173
|
+
* A dispatched agent's declared file scope plus its task text.
|
|
174
|
+
*
|
|
175
|
+
* @typedef {{entry: object, score: number, relevance: number, quality: number,
|
|
176
|
+
* pathScore: number, scoped: boolean, line: string}} SelectedLearning
|
|
177
|
+
*
|
|
178
|
+
* @typedef {{entries: object[], selected: SelectedLearning[], lines: string[],
|
|
179
|
+
* text: string, chars: number, scopeMatched: number,
|
|
180
|
+
* globalCount: number, candidates: number, truncated: boolean,
|
|
181
|
+
* rejected: number}} Selection
|
|
182
|
+
* `rejected` counts records dropped by the untrusted-text guard — surfaced so
|
|
183
|
+
* a drop is observable in the injection event rather than silent.
|
|
184
|
+
*/
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Internals
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/** True for a plain-ish object we may read properties off. */
|
|
191
|
+
function _isRecord(v) {
|
|
192
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Positive integer option, else the fallback. `0` is a legal cap (select none). */
|
|
196
|
+
function _capOpt(v, fallback) {
|
|
197
|
+
return Number.isInteger(v) && v >= 0 ? v : fallback;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The active gate: confidence strictly above the floor, and not expired.
|
|
202
|
+
*
|
|
203
|
+
* Deliberately re-stated rather than imported: `surfaceTopN` inlines this filter
|
|
204
|
+
* and exports no predicate, and {@link selectLearnings} must hold contract
|
|
205
|
+
* point 5 for callers that hand it raw entries. Idempotent on the
|
|
206
|
+
* {@link selectLearningsFromFile} path, where `surfaceTopN` already applied it.
|
|
207
|
+
*/
|
|
208
|
+
function _isActive(entry, nowMs, confidenceFloor) {
|
|
209
|
+
if (typeof entry.confidence !== 'number' || entry.confidence <= confidenceFloor) return false;
|
|
210
|
+
if (typeof entry.expires_at === 'string') {
|
|
211
|
+
const expiresMs = Date.parse(entry.expires_at);
|
|
212
|
+
if (Number.isFinite(expiresMs) && expiresMs <= nowMs) return false;
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Epoch ms from a Date | number | undefined clock option. */
|
|
218
|
+
function _resolveNowMs(now) {
|
|
219
|
+
if (now instanceof Date) return now.getTime();
|
|
220
|
+
if (typeof now === 'number' && Number.isFinite(now)) return now;
|
|
221
|
+
return Date.now();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Merge caller decay overrides over the conservative #670 defaults. */
|
|
225
|
+
function _resolveDecay(decayOpt) {
|
|
226
|
+
return {
|
|
227
|
+
enabled: decayOpt?.enabled ?? DECAY_DEFAULTS.enabled,
|
|
228
|
+
halfLifeDays: decayOpt?.halfLifeDays ?? DECAY_DEFAULTS.halfLifeDays,
|
|
229
|
+
floorFactor: decayOpt?.floorFactor ?? DECAY_DEFAULTS.floorFactor,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Date.parse or 0 — used only as a tiebreaker, never as a filter. */
|
|
234
|
+
function _createdMs(entry) {
|
|
235
|
+
const v = entry?.created_at;
|
|
236
|
+
if (typeof v !== 'string') return 0;
|
|
237
|
+
const ms = Date.parse(v);
|
|
238
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Total order over scored candidates (contract point 4):
|
|
243
|
+
* score DESC → created_at DESC → id ASC. Array.prototype.sort is stable
|
|
244
|
+
* (ES2019+), so fully-equal records keep input order.
|
|
245
|
+
*/
|
|
246
|
+
function _compareCandidates(a, b) {
|
|
247
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
248
|
+
const timeDiff = _createdMs(b.entry) - _createdMs(a.entry);
|
|
249
|
+
if (timeDiff !== 0) return timeDiff;
|
|
250
|
+
const aId = typeof a.entry.id === 'string' ? a.entry.id : '';
|
|
251
|
+
const bId = typeof b.entry.id === 'string' ? b.entry.id : '';
|
|
252
|
+
return aId < bId ? -1 : aId > bId ? 1 : 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Public surface
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The zero-value selection. Built fresh per call so no consumer can mutate a
|
|
261
|
+
* shared singleton. `text` is `''` — never a "no learnings found" placeholder.
|
|
262
|
+
*
|
|
263
|
+
* @returns {Selection}
|
|
264
|
+
*/
|
|
265
|
+
export function emptySelection() {
|
|
266
|
+
return {
|
|
267
|
+
entries: [],
|
|
268
|
+
selected: [],
|
|
269
|
+
lines: [],
|
|
270
|
+
text: '',
|
|
271
|
+
chars: 0,
|
|
272
|
+
scopeMatched: 0,
|
|
273
|
+
globalCount: 0,
|
|
274
|
+
candidates: 0,
|
|
275
|
+
truncated: false,
|
|
276
|
+
rejected: 0,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Truncate to at most `maxUnits` UTF-16 code units, cutting on a CODE-POINT
|
|
282
|
+
* boundary.
|
|
283
|
+
*
|
|
284
|
+
* A plain `str.slice(0, n)` cuts between the two halves of a surrogate pair and
|
|
285
|
+
* emits a LONE SURROGATE (U+D800–U+DFFF) — an unpaired code unit that is not a
|
|
286
|
+
* valid character, renders as U+FFFD, and is delivered straight into an agent
|
|
287
|
+
* prompt. Any entry whose text carries an emoji or an astral-plane character can
|
|
288
|
+
* land exactly on that boundary. The sibling `sanitize.mjs` already cuts on a
|
|
289
|
+
* code-point boundary (`truncateToBytes`); that one measures BYTES, while this
|
|
290
|
+
* budget is measured in UTF-16 chars (`Selection.chars` vs `maxChars`), so the
|
|
291
|
+
* unit differs and the function cannot simply be reused.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} str
|
|
294
|
+
* @param {number} maxUnits
|
|
295
|
+
* @returns {string}
|
|
296
|
+
*/
|
|
297
|
+
function _sliceCodePoints(str, maxUnits) {
|
|
298
|
+
if (str.length <= maxUnits) return str;
|
|
299
|
+
let out = '';
|
|
300
|
+
for (const ch of str) {
|
|
301
|
+
if (out.length + ch.length > maxUnits) break;
|
|
302
|
+
out += ch;
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Render ONE learning as a single index line: sanitised, whitespace-collapsed
|
|
309
|
+
* and capped.
|
|
310
|
+
*
|
|
311
|
+
* Collapsing whitespace is load-bearing, not cosmetic: a multi-line `insight`
|
|
312
|
+
* would otherwise break the one-line-per-entry shape the char budget is
|
|
313
|
+
* measured against — and, since the block's boundary recovery is line-based, a
|
|
314
|
+
* smuggled newline would also fabricate an extra entry.
|
|
315
|
+
*
|
|
316
|
+
* Untrusted (#1015): `type`, `subject` and `insight` are AGENT-AUTHORED and this
|
|
317
|
+
* line is delivered verbatim into a dispatched agent's prompt, so each field
|
|
318
|
+
* passes through {@link sanitizeProse} — dangerous invisibles (Unicode Tag
|
|
319
|
+
* block, bidi overrides, zero-width) and control characters stripped, the
|
|
320
|
+
* envelope marker neutralised, delivery-wrapper forgery REJECTED. There is
|
|
321
|
+
* deliberately no phrase blocklist: the corpus is full of legitimate imperative
|
|
322
|
+
* prose ("parse both readings and judge both, never pick one"), so a blocklist
|
|
323
|
+
* would be the guard that looks green and does not bite. Framing is the
|
|
324
|
+
* containment, and the block wrapper supplies it.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} entry
|
|
327
|
+
* @param {{maxLineChars?: number}} [opts]
|
|
328
|
+
* @returns {string} the line, or '' when the entry carries no renderable text
|
|
329
|
+
* @throws {Error} (`reconcile-sanitize: …`) when a field forges the delivery
|
|
330
|
+
* wrapper. {@link selectLearnings} catches this per entry and drops the record;
|
|
331
|
+
* a direct caller must decide for itself.
|
|
332
|
+
*/
|
|
333
|
+
export function renderIndexLine(entry, opts = {}) {
|
|
334
|
+
if (!_isRecord(entry)) return '';
|
|
335
|
+
const maxLineChars = _capOpt(opts.maxLineChars, LEARNINGS_INDEX_MAX_LINE_CHARS);
|
|
336
|
+
if (maxLineChars <= 0) return '';
|
|
337
|
+
|
|
338
|
+
// Sanitise BEFORE collapsing whitespace: stripping a zero-width character can
|
|
339
|
+
// leave adjacent spaces, and the collapse then normalises them away.
|
|
340
|
+
const clean = (v, maxBytes) =>
|
|
341
|
+
typeof v === 'string' && v !== ''
|
|
342
|
+
? sanitizeProse(v, { field: 'learnings-index', maxBytes })
|
|
343
|
+
.replace(/\s+/g, ' ')
|
|
344
|
+
.trim()
|
|
345
|
+
: '';
|
|
346
|
+
|
|
347
|
+
const type = clean(entry.type, TITLE_MAX_BYTES);
|
|
348
|
+
const subject = clean(entry.subject, TITLE_MAX_BYTES);
|
|
349
|
+
const insight = clean(entry.insight, INSIGHT_MAX_BYTES);
|
|
350
|
+
|
|
351
|
+
const head = [type, subject].filter(Boolean).join('/');
|
|
352
|
+
if (!head && !insight) return '';
|
|
353
|
+
|
|
354
|
+
let line = `- ${head}${head && insight ? ': ' : ''}${insight}`;
|
|
355
|
+
if (line.length > maxLineChars) line = `${_sliceCodePoints(line, maxLineChars - 1)}…`;
|
|
356
|
+
return line;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Score one learning against an agent scope.
|
|
361
|
+
*
|
|
362
|
+
* `score` = weight-normalized blend of relevance (`affinity().score`) and
|
|
363
|
+
* quality (`effectiveScore()` — recency-decayed confidence). `typeMatch` is
|
|
364
|
+
* deliberately not folded in; see the module header.
|
|
365
|
+
*
|
|
366
|
+
* @param {object} entry
|
|
367
|
+
* @param {AgentScope} scope
|
|
368
|
+
* @param {{now?: Date|number, decay?: object, affinityOpts?: object}} [opts]
|
|
369
|
+
* @returns {{score: number, relevance: number, quality: number, pathScore: number}}
|
|
370
|
+
*/
|
|
371
|
+
export function scoreLearning(entry, scope, opts = {}) {
|
|
372
|
+
const zero = { score: 0, relevance: 0, quality: 0, pathScore: 0 };
|
|
373
|
+
if (!_isRecord(entry)) return zero;
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const nowMs = _resolveNowMs(opts.now);
|
|
377
|
+
const decay = _resolveDecay(opts.decay);
|
|
378
|
+
const aff = affinity(scope, entry, opts.affinityOpts);
|
|
379
|
+
const quality = effectiveScore(entry, nowMs, decay);
|
|
380
|
+
const q = Number.isFinite(quality) ? Math.min(Math.max(quality, 0), 1) : 0;
|
|
381
|
+
|
|
382
|
+
const { relevanceWeight, qualityWeight } = SELECT_WEIGHTS;
|
|
383
|
+
const total = relevanceWeight + qualityWeight;
|
|
384
|
+
const score = total > 0 ? (relevanceWeight * aff.score + qualityWeight * q) / total : 0;
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
score: Number.isFinite(score) ? score : 0,
|
|
388
|
+
relevance: aff.score,
|
|
389
|
+
quality: q,
|
|
390
|
+
pathScore: aff.pathScore,
|
|
391
|
+
};
|
|
392
|
+
} catch {
|
|
393
|
+
return zero;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Select the learnings that go into ONE agent's compact index.
|
|
399
|
+
*
|
|
400
|
+
* Two tiers with SPLIT budgets (see module header), then a greedy fill against
|
|
401
|
+
* the char cap in render order (scoped first, then global). An entry whose line
|
|
402
|
+
* does not fit is dropped and `truncated` is set — never emitted partially.
|
|
403
|
+
*
|
|
404
|
+
* @param {object[]} entries — candidate learnings (already read from disk)
|
|
405
|
+
* @param {AgentScope} scope — the agent's declared file scope + task text
|
|
406
|
+
* @param {object} [opts]
|
|
407
|
+
* @param {number} [opts.maxScoped=DEFAULT_MAX_SCOPED]
|
|
408
|
+
* @param {number} [opts.maxGlobal=DEFAULT_MAX_GLOBAL]
|
|
409
|
+
* @param {number} [opts.maxChars=LEARNINGS_INDEX_MAX_CHARS]
|
|
410
|
+
* @param {number} [opts.maxLineChars=LEARNINGS_INDEX_MAX_LINE_CHARS]
|
|
411
|
+
* @param {number} [opts.minPathScore=SCOPE_MATCH_MIN_PATH_SCORE]
|
|
412
|
+
* @param {number} [opts.confidenceFloor=DEFAULT_CONFIDENCE_FLOOR]
|
|
413
|
+
* @param {Date|number} [opts.now] — injectable clock
|
|
414
|
+
* @param {object} [opts.decay] — #670 decay tuning, forwarded to effectiveScore
|
|
415
|
+
* @param {object} [opts.affinityOpts] — forwarded to affinity()
|
|
416
|
+
* @returns {Selection}
|
|
417
|
+
*/
|
|
418
|
+
export function selectLearnings(entries, scope, opts = {}) {
|
|
419
|
+
try {
|
|
420
|
+
if (!Array.isArray(entries) || entries.length === 0) return emptySelection();
|
|
421
|
+
|
|
422
|
+
const o = _isRecord(opts) ? opts : {};
|
|
423
|
+
const maxScoped = _capOpt(o.maxScoped, DEFAULT_MAX_SCOPED);
|
|
424
|
+
const maxGlobal = _capOpt(o.maxGlobal, DEFAULT_MAX_GLOBAL);
|
|
425
|
+
const maxChars = _capOpt(o.maxChars, LEARNINGS_INDEX_MAX_CHARS);
|
|
426
|
+
const maxLineChars = _capOpt(o.maxLineChars, LEARNINGS_INDEX_MAX_LINE_CHARS);
|
|
427
|
+
const minPathScore =
|
|
428
|
+
typeof o.minPathScore === 'number' && Number.isFinite(o.minPathScore)
|
|
429
|
+
? o.minPathScore
|
|
430
|
+
: SCOPE_MATCH_MIN_PATH_SCORE;
|
|
431
|
+
const confidenceFloor =
|
|
432
|
+
typeof o.confidenceFloor === 'number' && Number.isFinite(o.confidenceFloor)
|
|
433
|
+
? o.confidenceFloor
|
|
434
|
+
: DEFAULT_CONFIDENCE_FLOOR;
|
|
435
|
+
const nowMs = _resolveNowMs(o.now);
|
|
436
|
+
const scoreOpts = { now: nowMs, decay: o.decay, affinityOpts: o.affinityOpts };
|
|
437
|
+
|
|
438
|
+
/** @type {SelectedLearning[]} */
|
|
439
|
+
const scoped = [];
|
|
440
|
+
/** @type {SelectedLearning[]} */
|
|
441
|
+
const global = [];
|
|
442
|
+
let candidates = 0;
|
|
443
|
+
let rejected = 0;
|
|
444
|
+
|
|
445
|
+
for (const entry of entries) {
|
|
446
|
+
if (!_isRecord(entry)) continue;
|
|
447
|
+
if (!_isActive(entry, nowMs, confidenceFloor)) continue;
|
|
448
|
+
candidates++;
|
|
449
|
+
|
|
450
|
+
const s = scoreLearning(entry, scope, scoreOpts);
|
|
451
|
+
// Fail CLOSED per entry: a record whose text forges the delivery wrapper
|
|
452
|
+
// is dropped, not neutralised in place — with 100 candidates competing for
|
|
453
|
+
// 12 slots, dropping one costs nothing, while a partially-neutralised line
|
|
454
|
+
// would leave a forged boundary that a "the literal is gone" assertion
|
|
455
|
+
// reads as clean. The drop is counted, never silent (`selection.rejected`).
|
|
456
|
+
let line;
|
|
457
|
+
try {
|
|
458
|
+
line = renderIndexLine(entry, { maxLineChars });
|
|
459
|
+
} catch {
|
|
460
|
+
rejected++;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (line.length === 0) continue;
|
|
464
|
+
|
|
465
|
+
const isScoped = s.pathScore >= minPathScore;
|
|
466
|
+
const cand = {
|
|
467
|
+
entry,
|
|
468
|
+
score: s.score,
|
|
469
|
+
relevance: s.relevance,
|
|
470
|
+
quality: s.quality,
|
|
471
|
+
pathScore: s.pathScore,
|
|
472
|
+
scoped: isScoped,
|
|
473
|
+
line,
|
|
474
|
+
};
|
|
475
|
+
(isScoped ? scoped : global).push(cand);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
scoped.sort(_compareCandidates);
|
|
479
|
+
global.sort(_compareCandidates);
|
|
480
|
+
|
|
481
|
+
// Split caps: the global tier can never displace scoped signal.
|
|
482
|
+
const ordered = [...scoped.slice(0, maxScoped), ...global.slice(0, maxGlobal)];
|
|
483
|
+
|
|
484
|
+
/** @type {SelectedLearning[]} */
|
|
485
|
+
const selected = [];
|
|
486
|
+
const lines = [];
|
|
487
|
+
let chars = 0;
|
|
488
|
+
let truncated = scoped.length > maxScoped || global.length > maxGlobal;
|
|
489
|
+
|
|
490
|
+
for (const cand of ordered) {
|
|
491
|
+
const next = chars === 0 ? cand.line.length : chars + 1 + cand.line.length;
|
|
492
|
+
if (next > maxChars) {
|
|
493
|
+
truncated = true;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
chars = next;
|
|
497
|
+
selected.push(cand);
|
|
498
|
+
lines.push(cand.line);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const text = lines.join('\n');
|
|
502
|
+
return {
|
|
503
|
+
entries: selected.map((c) => c.entry),
|
|
504
|
+
selected,
|
|
505
|
+
lines,
|
|
506
|
+
text,
|
|
507
|
+
chars: text.length,
|
|
508
|
+
scopeMatched: selected.filter((c) => c.scoped).length,
|
|
509
|
+
globalCount: selected.filter((c) => !c.scoped).length,
|
|
510
|
+
candidates,
|
|
511
|
+
truncated,
|
|
512
|
+
rejected,
|
|
513
|
+
};
|
|
514
|
+
} catch {
|
|
515
|
+
// Contract point 1 — a ranking primitive on the dispatch hot path must
|
|
516
|
+
// never abort a wave. Every reachable path above is already total.
|
|
517
|
+
return emptySelection();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* File entry-point: read active learnings via `surfaceTopN` (the ONE reader —
|
|
523
|
+
* it owns the active-filter and the #670 decay ranking), then select.
|
|
524
|
+
*
|
|
525
|
+
* @param {string} filePath — absolute path to learnings.jsonl
|
|
526
|
+
* @param {AgentScope} scope
|
|
527
|
+
* @param {object} [opts] — everything {@link selectLearnings} accepts, plus
|
|
528
|
+
* `poolSize` (how many active entries to pull before ranking).
|
|
529
|
+
* @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
|
|
530
|
+
*/
|
|
531
|
+
export async function selectLearningsFromFile(filePath, scope, opts = {}) {
|
|
532
|
+
try {
|
|
533
|
+
const o = _isRecord(opts) ? opts : {};
|
|
534
|
+
const poolSize = _capOpt(o.poolSize, CANDIDATE_POOL_SIZE);
|
|
535
|
+
const nowMs = _resolveNowMs(o.now);
|
|
536
|
+
const confidenceFloor =
|
|
537
|
+
typeof o.confidenceFloor === 'number' && Number.isFinite(o.confidenceFloor)
|
|
538
|
+
? o.confidenceFloor
|
|
539
|
+
: DEFAULT_CONFIDENCE_FLOOR;
|
|
540
|
+
|
|
541
|
+
const entries = await surfaceTopN(filePath, poolSize, {
|
|
542
|
+
now: nowMs,
|
|
543
|
+
confidenceFloor,
|
|
544
|
+
decay: o.decay,
|
|
545
|
+
});
|
|
546
|
+
return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor });
|
|
547
|
+
} catch {
|
|
548
|
+
return emptySelection();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
@@ -70,7 +70,6 @@ import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS }
|
|
|
70
70
|
import { emitEvent } from './events.mjs';
|
|
71
71
|
|
|
72
72
|
const REAPED_ARCHIVE_SUBDIR = '.orchestrator/tmp/reaped-locks';
|
|
73
|
-
const EVENTS_RELPATH = '.orchestrator/metrics/events.jsonl';
|
|
74
73
|
const REAPED_EVENT = 'orchestrator.session.lock.reaped';
|
|
75
74
|
const CURRENT_SESSION_RELPATH = '.orchestrator/current-session.json';
|
|
76
75
|
|
|
@@ -550,7 +549,7 @@ async function evaluateRepo(repoRoot, { nowMs, dryRun, currentSessionId, reapMod
|
|
|
550
549
|
reap_mode: reapMode,
|
|
551
550
|
current_session: currentSession,
|
|
552
551
|
},
|
|
553
|
-
{
|
|
552
|
+
{ repoRoot },
|
|
554
553
|
);
|
|
555
554
|
} catch {
|
|
556
555
|
// Observability is best-effort — the archive-move already succeeded.
|
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
* schema_version 1
|
|
23
23
|
*
|
|
24
24
|
* Optional fields:
|
|
25
|
-
* proposed_by_agent string | undefined
|
|
25
|
+
* proposed_by_agent string | undefined — identifier of the submitting agent
|
|
26
|
+
* file_paths string[] | undefined — repo-relative path(s) this
|
|
27
|
+
* proposal applies to (issue #900 C). Omitted entirely when empty/absent
|
|
28
|
+
* — never set to `[]`. Required, downstream, for a promoted learning to
|
|
29
|
+
* ever become /reconcile-eligible (see `reconcile/eligibility.mjs`).
|
|
26
30
|
*/
|
|
27
31
|
|
|
28
32
|
import { randomUUID } from 'node:crypto';
|
|
@@ -86,6 +90,9 @@ const EVIDENCE_MAX = 5000;
|
|
|
86
90
|
* @param {number} opts.confidence — [0, 1]
|
|
87
91
|
* @param {string} opts.waveId — e.g. 'W2'
|
|
88
92
|
* @param {string} [opts.proposedByAgent] — optional agent identifier
|
|
93
|
+
* @param {string[]} [opts.filePaths] — optional repo-relative path(s) this
|
|
94
|
+
* proposal applies to (issue #900 C). Set on the record ONLY when a
|
|
95
|
+
* non-empty array is supplied — mirrors the `proposedByAgent` pattern.
|
|
89
96
|
* @returns {object} complete proposal record
|
|
90
97
|
*/
|
|
91
98
|
export function createProposalRecord({
|
|
@@ -96,6 +103,7 @@ export function createProposalRecord({
|
|
|
96
103
|
confidence,
|
|
97
104
|
waveId,
|
|
98
105
|
proposedByAgent,
|
|
106
|
+
filePaths,
|
|
99
107
|
}) {
|
|
100
108
|
const record = {
|
|
101
109
|
id: randomUUID(),
|
|
@@ -113,6 +121,10 @@ export function createProposalRecord({
|
|
|
113
121
|
record.proposed_by_agent = proposedByAgent;
|
|
114
122
|
}
|
|
115
123
|
|
|
124
|
+
if (Array.isArray(filePaths) && filePaths.length > 0) {
|
|
125
|
+
record.file_paths = filePaths;
|
|
126
|
+
}
|
|
127
|
+
|
|
116
128
|
return record;
|
|
117
129
|
}
|
|
118
130
|
|
|
@@ -238,6 +250,29 @@ export function validateProposalRecord(record) {
|
|
|
238
250
|
errors.push('proposed_by_agent must be a string when present');
|
|
239
251
|
}
|
|
240
252
|
|
|
253
|
+
// file_paths (optional, but if present must be a non-empty array of
|
|
254
|
+
// non-empty, newline-free, glob-metacharacter-free strings) — issue #900 C,
|
|
255
|
+
// Q3-MED fix pass: a glob metacharacter (* ? [ ] { }) surviving this schema
|
|
256
|
+
// gate would later reach emitter.mjs::globsFromFilePaths verbatim for a
|
|
257
|
+
// top-level (dirname==='.') entry, effectively producing an always-on rule
|
|
258
|
+
// glob (e.g. `file_paths: ['**']`). `createProposalRecord` never sets an
|
|
259
|
+
// empty array (it omits the key), so a present-but-empty `[]` is itself a
|
|
260
|
+
// signal of a malformed/tampered record — reject it explicitly.
|
|
261
|
+
if ('file_paths' in record) {
|
|
262
|
+
const fp = record.file_paths;
|
|
263
|
+
const isValidArray =
|
|
264
|
+
Array.isArray(fp) &&
|
|
265
|
+
fp.length > 0 &&
|
|
266
|
+
fp.every(
|
|
267
|
+
(p) => typeof p === 'string' && p.length > 0 && !/[\r\n]/.test(p) && !/[*?[\]{}]/.test(p),
|
|
268
|
+
);
|
|
269
|
+
if (!isValidArray) {
|
|
270
|
+
errors.push(
|
|
271
|
+
'file_paths must be a non-empty array of non-empty, newline-free, glob-metacharacter-free strings when present',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
241
276
|
if (errors.length > 0) {
|
|
242
277
|
return { ok: false, errors };
|
|
243
278
|
}
|