opencode-swarm 7.130.2 → 7.132.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/{config-doctor-489wej17.js → config-doctor-3rssa871.js} +2 -2
  3. package/dist/cli/{curation-policy-aaz0hap6.js → curation-policy-3914h3g9.js} +2 -2
  4. package/dist/cli/{curator-s2ynewsr.js → curator-1e77dq6x.js} +13 -13
  5. package/dist/cli/{curator-llm-factory-mxjcctn8.js → curator-llm-factory-41c17ybs.js} +13 -13
  6. package/dist/cli/{evidence-summary-service-hf7qq777.js → evidence-summary-service-jfagmvrh.js} +1 -1
  7. package/dist/cli/{guardrail-explain-s85kv81r.js → guardrail-explain-624brj1y.js} +14 -14
  8. package/dist/cli/{guardrail-log-2cejc33f.js → guardrail-log-vfka4tcn.js} +3 -3
  9. package/dist/cli/{hive-promoter-qdgxajht.js → hive-promoter-mw5prfy2.js} +13 -13
  10. package/dist/cli/{index-kzvwb2se.js → index-03zyn94g.js} +110 -85
  11. package/dist/cli/{index-xa29259t.js → index-2dtwjx39.js} +1 -1
  12. package/dist/cli/{index-5xxgeksy.js → index-5x3g1q91.js} +50 -2
  13. package/dist/cli/{index-zft4hp35.js → index-64z5dvam.js} +1 -1
  14. package/dist/cli/{index-ts9wh1ba.js → index-6mcx76j9.js} +60 -8
  15. package/dist/cli/{index-etdgtk3m.js → index-8w3sp149.js} +3502 -1947
  16. package/dist/cli/{index-6fqj8fcp.js → index-99tq1zd5.js} +2 -2
  17. package/dist/cli/{index-jvyn76xe.js → index-e630fw43.js} +3 -3
  18. package/dist/cli/{index-nr5pr54q.js → index-exs5bt14.js} +1 -1
  19. package/dist/cli/{index-hjar4crm.js → index-fegng5pv.js} +1 -1
  20. package/dist/cli/{index-jxha73tm.js → index-nsxchdx0.js} +1 -1
  21. package/dist/cli/{index-3rnkyb60.js → index-ppkp20fp.js} +5 -5
  22. package/dist/cli/{index-p6qgjkbt.js → index-q0ysv66x.js} +9 -1
  23. package/dist/cli/{index-jvwkxz8q.js → index-qg1cj1ev.js} +15 -15
  24. package/dist/cli/{index-s8h2cedv.js → index-sbq3gbjs.js} +1 -1
  25. package/dist/cli/{index-wy3q73j2.js → index-zah4qws8.js} +18 -14
  26. package/dist/cli/{index-agte9w1e.js → index-zrh07ykv.js} +2 -2
  27. package/dist/cli/index.js +13 -13
  28. package/dist/cli/{knowledge-escalator-1ypz0266.js → knowledge-escalator-5hv2dwhz.js} +3 -3
  29. package/dist/cli/{knowledge-events-yvcczmja.js → knowledge-events-434663dp.js} +1 -1
  30. package/dist/cli/{knowledge-store-h8bp9eje.js → knowledge-store-73ykq5j3.js} +3 -1
  31. package/dist/cli/{knowledge-validator-sz9v1fdq.js → knowledge-validator-tnadtt1h.js} +4 -4
  32. package/dist/cli/{scan-cursor-k9zyt374.js → scan-cursor-e33n9pej.js} +2 -2
  33. package/dist/cli/{schema-fkpn2r1s.js → schema-1kwmcayk.js} +5 -1
  34. package/dist/cli/{skill-generator-a6a9jxfd.js → skill-generator-k111qfdw.js} +5 -5
  35. package/dist/commands/consolidate.d.ts +14 -0
  36. package/dist/commands/curate.d.ts +2 -0
  37. package/dist/commands/promote.d.ts +25 -0
  38. package/dist/commands/registry.d.ts +2 -2
  39. package/dist/config/schema.d.ts +101 -0
  40. package/dist/consensus/contracts.d.ts +479 -0
  41. package/dist/consensus/corpus.d.ts +261 -0
  42. package/dist/consensus/miner.d.ts +200 -0
  43. package/dist/consensus/public-api.d.ts +42 -0
  44. package/dist/consensus/store.d.ts +119 -0
  45. package/dist/evidence/immutable-store.d.ts +86 -0
  46. package/dist/evidence/manager.d.ts +31 -1
  47. package/dist/hooks/actionability-predicate.d.ts +36 -0
  48. package/dist/hooks/curator.d.ts +3 -0
  49. package/dist/hooks/hive-policy.d.ts +11 -0
  50. package/dist/hooks/hive-promoter.d.ts +27 -1
  51. package/dist/hooks/knowledge-dedup-sweep.d.ts +145 -0
  52. package/dist/hooks/knowledge-injector.d.ts +4 -0
  53. package/dist/hooks/knowledge-store.d.ts +26 -0
  54. package/dist/hooks/knowledge-types.d.ts +6 -0
  55. package/dist/hooks/knowledge-validator.d.ts +10 -14
  56. package/dist/hooks/micro-reflector.d.ts +106 -2
  57. package/dist/hooks/realtime-learning-nudge.d.ts +19 -0
  58. package/dist/index.js +464 -456
  59. package/dist/knowledge/entry-merge.d.ts +112 -0
  60. package/dist/knowledge/family-migration.d.ts +6 -2
  61. package/dist/learning/admission.d.ts +154 -0
  62. package/dist/learning/candidate-queue.d.ts +178 -0
  63. package/dist/learning/fingerprint.d.ts +56 -0
  64. package/dist/learning/prm-pattern-support.d.ts +124 -0
  65. package/dist/learning/provenance.d.ts +86 -0
  66. package/dist/parallel/index.d.ts +0 -2
  67. package/dist/plan/parallel-verdict.d.ts +94 -0
  68. package/dist/prm/index.d.ts +46 -1
  69. package/dist/services/recommendation-ledger.d.ts +266 -0
  70. package/dist/services/skill-improver.d.ts +4 -0
  71. package/dist/services/status-service.d.ts +27 -0
  72. package/dist/services/trajectory-cluster.d.ts +65 -2
  73. package/dist/tools/consensus-mine.d.ts +172 -0
  74. package/dist/tools/index.d.ts +2 -0
  75. package/dist/tools/knowledge-add.d.ts +34 -0
  76. package/dist/tools/lean-turbo-run-phase.d.ts +7 -0
  77. package/dist/tools/manifest.d.ts +2 -0
  78. package/dist/tools/plan-conflict-check.d.ts +61 -0
  79. package/dist/tools/tool-metadata.d.ts +8 -0
  80. package/dist/turbo/lean/recovery.d.ts +90 -0
  81. package/dist/turbo/lean/runner.d.ts +16 -1
  82. package/dist/worktree/merge.d.ts +5 -0
  83. package/package.json +1 -1
  84. package/dist/parallel/dependency-graph.d.ts +0 -34
  85. package/dist/parallel/meta-indexer.d.ts +0 -32
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Field-level merge helpers for near-duplicate knowledge entries (issue #1821
3
+ * Lane A).
4
+ *
5
+ * Extracted verbatim-in-shape from `family-migration.ts` so the SAME merge
6
+ * semantics back both consumers:
7
+ * - the cohort family migration (`/swarm link` / `/swarm unlink`), via
8
+ * `mergeStoreEntries`, and
9
+ * - the active-store near-duplicate sweep (`hooks/knowledge-dedup-sweep.ts`).
10
+ *
11
+ * A near-duplicate merge is PROVENANCE-PRESERVING, not lossless: the losing
12
+ * entry's id survives in `merged_from`, and every field that represents
13
+ * independently-earned evidence is unioned rather than overwritten. Both sides
14
+ * of a merge are genuine near-duplicates, so a predicate present on only one
15
+ * side was still earned and must survive.
16
+ *
17
+ * ## What this module fixes relative to the pre-#1821 implementation
18
+ *
19
+ * 1. **Actionability carry.** `required_actions`, `forbidden_actions`,
20
+ * `verification_checks`, `applies_to_agents`, `applies_to_tools`,
21
+ * `triggers`, and `source_knowledge_ids` were dropped entirely — a merge
22
+ * could silently turn an actionable directive into an inert lesson. They are
23
+ * now unioned through `dedupeCapped`.
24
+ * 2. **CAS integrity.** Swapping in the loser's longer lesson without
25
+ * recomputing `content_hash` left the CAS token describing the DISCARDED
26
+ * text, so every later authorized curation of a merged entry failed the
27
+ * `transactKnowledgeWithCas` comparison. The hash is now recomputed and
28
+ * `revision` bumped on any lesson swap.
29
+ * 3. **No shared arrays.** `unionConfirmedBy` returned a source array BY
30
+ * REFERENCE when one side was absent, so a surviving winner and an archived
31
+ * loser shared one mutable array. It always copies now.
32
+ * 4. **Symmetric guards.** `source_refs` and `retrieval_outcomes` required BOTH
33
+ * sides to be populated before merging, so a sweep over mixed
34
+ * legacy/enriched entries silently dropped the loser's refs and counters.
35
+ * The one-sided cases are handled.
36
+ * 5. **Tags are unioned under the SAME bounded rule as their five siblings.**
37
+ * See `TAG_UNION_RULE` below.
38
+ *
39
+ * ## TAG_UNION_RULE — the one place tags can still be lost, stated explicitly
40
+ *
41
+ * `tags` is a bounded field: the store's write boundary
42
+ * (`normalizeEntryArraysForWrite` in `knowledge-store.ts`) runs `dedupeCapped`
43
+ * over it at `WRITE_FIELD_CAP` = 20 on every transaction, and `knowledge_add`
44
+ * caps its producer at 20 as well. A merge therefore CANNOT promise to preserve
45
+ * every tag from both sides — two entries at the cap carry up to 40 distinct
46
+ * tags and only 20 can survive. Pretending otherwise is what the pre-fix code
47
+ * did: it unioned with a bare, uncapped, case-SENSITIVE `new Set(...)`, and the
48
+ * write boundary then silently truncated the result to the first 20 — which are
49
+ * the WINNER's, because the winner's tags come first in the union. The loser was
50
+ * archived in the same transaction, so the discarded tags were unrecoverable.
51
+ *
52
+ * The rule this module now implements and pins is:
53
+ *
54
+ * 1. Winner tags are retained FIRST, in their existing order.
55
+ * 2. Loser tags fill the remaining slots, in their existing order.
56
+ * 3. Dedup is CASE-INSENSITIVE and first-spelling-wins (`dedupeCapped`), the
57
+ * same comparison the write boundary uses — so a merge no longer emits a
58
+ * list the very next write would rewrite.
59
+ * 4. Anything past `MERGE_FIELD_CAP` (20) is DROPPED at merge time, visibly,
60
+ * instead of being handed to the write boundary to drop invisibly.
61
+ *
62
+ * Consequence, stated so nobody has to rediscover it: a winner already holding
63
+ * 20 tags absorbs ZERO tags from the loser. That is not new data loss — it is
64
+ * the pre-existing store cap, now applied where it can be reasoned about and
65
+ * asserted. Order is the whole lever: if tag retention priority ever needs to
66
+ * change (e.g. prefer a rarer loser tag over a generic winner tag), it changes
67
+ * HERE, in the argument order of the union, not at the write boundary.
68
+ *
69
+ * Pinned by `tests/unit/hooks/knowledge-dedup-sweep-tag-cap.test.ts`.
70
+ *
71
+ * ## What this module deliberately does NOT change
72
+ *
73
+ * `weightedConfidence` reads POST-merge target state (it runs after
74
+ * `unionConfirmedBy` and `sumRetrievalOutcomes` have already mutated `target`),
75
+ * so the source's evidence is counted on both sides of the ratio. That is a
76
+ * real wart, but the formula defines `/swarm link` cohort-merge semantics and
77
+ * changing it is out of scope for #1821 Lane A. It is pinned by
78
+ * `tests/unit/knowledge/entry-merge-characterization-confidence.test.ts`.
79
+ */
80
+ import type { KnowledgeEntryBase } from '../hooks/knowledge-types.js';
81
+ /** Field-level union of `src` into `target` (mutates target). */
82
+ export declare function mergeEntryFields(target: KnowledgeEntryBase, src: KnowledgeEntryBase): void;
83
+ /**
84
+ * Union two `confirmed_by` lists, deduping on the identifying triple
85
+ * `phase_number|project_name|confirmed_at`.
86
+ *
87
+ * ALWAYS returns a fresh array. The pre-#1821 short-circuits returned an input
88
+ * array by reference, so a surviving winner and the archived loser it absorbed
89
+ * shared one mutable list — a later `push` on either side corrupted the other.
90
+ */
91
+ export declare function unionConfirmedBy(a: KnowledgeEntryBase['confirmed_by'], b: KnowledgeEntryBase['confirmed_by']): KnowledgeEntryBase['confirmed_by'];
92
+ /**
93
+ * Sum `src`'s retrieval counters into `target`'s (mutates target).
94
+ *
95
+ * One-sided cases are real on mixed legacy/enriched stores and are handled:
96
+ * a target with no counters ADOPTS a copy of the source's (a copy, so the
97
+ * winner and the archived loser never share one mutable record), and a source
98
+ * with no counters simply contributes nothing. The pre-#1821 guard bailed
99
+ * unless BOTH sides had counters, silently discarding the loser's evidence.
100
+ */
101
+ export declare function sumRetrievalOutcomes(target: KnowledgeEntryBase, src: KnowledgeEntryBase): void;
102
+ /**
103
+ * Evidence-weighted average of the two confidences.
104
+ *
105
+ * INTENTIONALLY UNCHANGED by #1821 Lane A. It runs AFTER `unionConfirmedBy`
106
+ * and `sumRetrievalOutcomes` have mutated `target`, so `wT` reflects POST-merge
107
+ * state and the source's evidence is counted on both sides of the ratio.
108
+ * Correcting that would change `/swarm link` cohort-merge results, which is out
109
+ * of scope for this issue; the behavior is pinned by the characterization
110
+ * suite.
111
+ */
112
+ export declare function weightedConfidence(target: KnowledgeEntryBase, src: KnowledgeEntryBase): number;
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import type { CounterRollup } from '../hooks/knowledge-events.js';
29
29
  import type { KnowledgeEntryBase } from '../hooks/knowledge-types.js';
30
+ import { mergeEntryFields } from './entry-merge.js';
30
31
  import { type KnowledgeFamilyMember } from './family-manifest.js';
31
32
  /**
32
33
  * Lock config for the migration critical section. Re-exported from
@@ -58,14 +59,17 @@ declare function keySelectorFor(member: KnowledgeFamilyMember): (obj: unknown) =
58
59
  * confidence becomes an evidence-weighted average; the losing entry's `id` is
59
60
  * preserved in `merged_from` for retraction traceability. A `merge` is NOT
60
61
  * silent. (Critic C8: this is "provenance-preserving", not "lossless".)
62
+ *
63
+ * The field-level merge itself lives in `./entry-merge.ts` (issue #1821 Lane A)
64
+ * so the active-store near-duplicate sweep in `hooks/knowledge-dedup-sweep.ts`
65
+ * shares EXACTLY these semantics. `DEDUP_THRESHOLD` and this function stay here
66
+ * because they are migration policy, not merge mechanics.
61
67
  */
62
68
  declare function mergeStoreEntries(destination: KnowledgeEntryBase[], source: KnowledgeEntryBase[]): {
63
69
  merged: KnowledgeEntryBase[];
64
70
  added: number;
65
71
  skipped: number;
66
72
  };
67
- /** Field-level union of `src` into `target` (mutates target). */
68
- declare function mergeEntryFields(target: KnowledgeEntryBase, src: KnowledgeEntryBase): void;
69
73
  /**
70
74
  * Append-union / append-concat: append source lines whose key is not already
71
75
  * present on the destination. The key is extracted by `keyOf` — the member's id
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Real-time knowledge admission (issue #1821, Workstream B).
3
+ *
4
+ * `admitCandidate` takes ONE insight candidate all the way into the swarm
5
+ * knowledge store; `drainSessionQueue` runs a bounded batch of them for a live
6
+ * session. Together they close the loop that previously required a phase
7
+ * boundary: a lesson learned at step 3 becomes retrievable at step 40 of the
8
+ * same session.
9
+ *
10
+ * ## The bounding rule (read before touching the await graph)
11
+ *
12
+ * `withTimeout` (`src/utils/timeout.ts`) is a `Promise.race`. A race does NOT
13
+ * cancel the loser. Racing the knowledge-store transaction would therefore be
14
+ * actively harmful: the "timed out" admission keeps running, keeps holding the
15
+ * `.swarm/` directory lock, and the next drain blocks on that lock while the
16
+ * caller has already been told the work finished. So:
17
+ *
18
+ * - the `transactKnowledge` call is NEVER raced or timed out;
19
+ * - only genuinely cancellable work (the optional LLM screening call) is
20
+ * bounded, via a manual `AbortController` + `setTimeout` (see
21
+ * `timeoutSignal` below) + `isAbortError`. This deliberately does NOT use
22
+ * `AbortSignal.timeout(...)` (unlike `micro-reflector.ts`): Bun on Windows
23
+ * has a native bug where that signal's `abort` event never fires when
24
+ * awaited by a plain JS Promise (oven-sh/bun#29546) — exactly the shape
25
+ * `screenCandidate`'s caller uses — which hung this file's own tests for
26
+ * the full CI wall-clock kill on Windows;
27
+ * - `max_drain_wall_time_ms` is enforced BETWEEN candidates — it stops the
28
+ * drain from STARTING new work, and never interrupts work in flight.
29
+ *
30
+ * Concurrency uses `p-limit` (an existing dependency, see
31
+ * `src/evaluation/runner.ts`) rather than a hand-rolled limiter.
32
+ */
33
+ import type { KnowledgeConfig } from '../config/schema.js';
34
+ import type { CuratorLLMDelegate } from '../hooks/curator.js';
35
+ import type { InsightCandidate } from '../hooks/micro-reflector.js';
36
+ import type { LearningProvenanceV1 } from './provenance.js';
37
+ /** Outcome of a single admission attempt. */
38
+ export type AdmissionOutcome = 'admitted' | 'reinforced' | 'rejected';
39
+ export interface AdmissionResult {
40
+ outcome: AdmissionOutcome;
41
+ /** Present only for `rejected`, and on the two non-rejected paths for tracing. */
42
+ reason?: string;
43
+ /** Id of the entry created or reinforced. */
44
+ entryId?: string;
45
+ /** The `insight:<id>` marker this candidate is identified by. */
46
+ marker: string;
47
+ /** Validated provenance record for the write. */
48
+ provenance?: LearningProvenanceV1;
49
+ }
50
+ /** Everything `admitCandidate` needs that is not the candidate itself. */
51
+ export interface AdmissionDeps {
52
+ knowledgeConfig: KnowledgeConfig;
53
+ projectName: string;
54
+ /**
55
+ * Phase recorded in the entry's `confirmed_by`. NOTE: this value is NOT used
56
+ * for idempotency — the fold-in resolves phase numbers differently across its
57
+ * five callers, which is exactly why D1 keys on candidate identity instead.
58
+ */
59
+ phaseNumber: number;
60
+ sessionID?: string;
61
+ /** Optional screening delegate. Absent → admission runs with no LLM call. */
62
+ llmDelegate?: CuratorLLMDelegate;
63
+ /** Per-candidate LLM deadline. 0 or absent disables the bound (and the call). */
64
+ llmTimeoutMs?: number;
65
+ /** Per-session LLM ceilings. Absent → no screening call is made. */
66
+ llmBudget?: {
67
+ maxLlmCallsPerSession: number;
68
+ maxTokensPerSession: number;
69
+ };
70
+ /**
71
+ * Milliseconds left in the enclosing drain's wall-clock budget. Supplied by
72
+ * `drainSessionQueue` so a screening call can never outlive the budget that
73
+ * is supposed to bound the whole drain. Absent → unbounded by the drain.
74
+ */
75
+ remainingBudgetMs?: () => number;
76
+ /** Called after a write actually changed the store — bumps injector caches. */
77
+ onKnowledgeChanged?: () => void;
78
+ /** Injectable clock/path seams for tests. */
79
+ now?: () => number;
80
+ resolveKnowledgePath?: (directory: string) => string;
81
+ }
82
+ /**
83
+ * Admit one candidate into the swarm knowledge store.
84
+ *
85
+ * Order: shape gate → actionability gate → near-duplicate lookup → reinforce or
86
+ * append, all inside ONE `transactKnowledge` so the dedup decision and the
87
+ * write cannot be separated by a concurrent writer.
88
+ */
89
+ export declare function admitCandidate(directory: string, candidate: InsightCandidate, deps: AdmissionDeps): Promise<AdmissionResult>;
90
+ /** The subset of a `tool.execute.after` input the drain adapter reads. */
91
+ export interface RealtimeAdmissionInput {
92
+ tool: unknown;
93
+ sessionID?: unknown;
94
+ }
95
+ /**
96
+ * `tool.execute.after` adapter for real-time admission.
97
+ *
98
+ * Called UNCONDITIONALLY from the plugin hook chain and SELF-GATES here, in
99
+ * this order:
100
+ *
101
+ * 1. non-`Task` tool → return immediately (mirrors `micro-reflector.ts`);
102
+ * 2. feature disabled → return;
103
+ * 3. no session id → return;
104
+ * 4. empty queue → return (an O(1) `Map.get(...).length` probe).
105
+ *
106
+ * Only past gate 4 does anything touch the filesystem. This ordering is the
107
+ * contract that keeps the hook off the hot path: it is awaited on EVERY tool
108
+ * call, so the non-`Task` path must cost a single string comparison and
109
+ * perform NO knowledge-store read, write, or lock acquisition.
110
+ *
111
+ * `resolveDeps` is a FACTORY, not a value, so resolving the plan (project name
112
+ * and phase number) happens only after all four gates pass.
113
+ */
114
+ export declare function realtimeAdmissionAfter(directory: string, input: RealtimeAdmissionInput, config: RealtimeAdmissionConfig | undefined, resolveDeps: () => AdmissionDeps | Promise<AdmissionDeps>): Promise<DrainSummary | undefined>;
115
+ /** Per-drain tallies. */
116
+ export interface DrainSummary {
117
+ attempted: number;
118
+ admitted: number;
119
+ reinforced: number;
120
+ rejected: number;
121
+ /** Candidates put back because the wall-clock budget ran out first. */
122
+ deferred: number;
123
+ /** Candidates abandoned after exhausting their retry budget. */
124
+ failed: number;
125
+ retries: number;
126
+ }
127
+ /** `learning.realtime_admission`, as consumed by the drain. */
128
+ export type RealtimeAdmissionConfig = {
129
+ enabled: boolean;
130
+ max_queue_size: number;
131
+ min_drain: number;
132
+ max_drain: number;
133
+ drain_depth_factor: number;
134
+ drain_velocity_factor: number;
135
+ max_llm_calls_per_session: number;
136
+ max_tokens_per_session: number;
137
+ max_concurrent_admissions: number;
138
+ max_retries_per_candidate: number;
139
+ per_candidate_llm_timeout_ms: number;
140
+ max_drain_wall_time_ms: number;
141
+ supersede_nudge: boolean;
142
+ };
143
+ /**
144
+ * Drain a bounded, adaptively-sized batch of a session's pending candidates.
145
+ *
146
+ * Bounds, in the order they apply:
147
+ * 1. `computeDrainSize` caps how many candidates are claimed at all;
148
+ * 2. `p-limit` caps how many run concurrently;
149
+ * 3. `max_retries_per_candidate` caps rework per candidate;
150
+ * 4. `max_drain_wall_time_ms` stops NEW candidates from starting.
151
+ *
152
+ * Never throws: a drain is best-effort background work behind a live tool call.
153
+ */
154
+ export declare function drainSessionQueue(directory: string, sessionID: string, config: RealtimeAdmissionConfig, deps: AdmissionDeps): Promise<DrainSummary>;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Session-keyed bounded candidate queue (issue #1821, Workstream B).
3
+ *
4
+ * The micro-reflector emits insight candidates while a session is live. Before
5
+ * this module they were only readable at a phase boundary, so a lesson learned
6
+ * in step 3 could not help step 40 of the same session. This queue is the
7
+ * in-memory hand-off that makes same-session admission possible: the reflector
8
+ * enqueues, and the `Task`-tool drain adapter admits.
9
+ *
10
+ * Everything here is bounded on purpose (AGENTS.md invariant 8):
11
+ *
12
+ * - the KEY count is capped at `MAX_TRACKED_SESSIONS` with FIFO eviction,
13
+ * mirroring `recentToolCallsBySession` in `src/hooks/adversarial-detector.ts`;
14
+ * - each session's item list is capped at `max_queue_size` with drop-oldest
15
+ * plus a `dropped` counter, so a flood is observable rather than silent;
16
+ * - the LLM-call, token, and retry budgets are per-session ceilings that only
17
+ * ever count up within a session and are released wholesale on reset.
18
+ *
19
+ * This module performs NO I/O and holds no clock dependency beyond `Date.now`
20
+ * (injectable through `_internals` for deterministic tests). The admission side
21
+ * effects live in `./admission.ts`; keeping them apart lets the queue be tested
22
+ * without touching the knowledge store.
23
+ */
24
+ import type { InsightCandidate } from '../hooks/micro-reflector.js';
25
+ /**
26
+ * Hard ceiling on how many drain cycles a single candidate may be claimed by.
27
+ *
28
+ * A candidate deferred by the wall-clock budget is requeued for the next drain.
29
+ * Without this bound a session whose budget always expires would bounce the same
30
+ * candidate forever, so the per-candidate retry cap would be satisfied while the
31
+ * queue still never made progress (AGENTS.md invariant 8).
32
+ */
33
+ export declare const MAX_CANDIDATE_DRAIN_ATTEMPTS = 5;
34
+ /** Per-session ceilings applied when a candidate is enqueued or budget is spent. */
35
+ export interface CandidateQueueLimits {
36
+ /** Hard cap on pending candidates; the OLDEST is dropped past this. */
37
+ maxQueueSize: number;
38
+ /** Per-session ceiling on admission LLM calls. 0 disables LLM admission. */
39
+ maxLlmCallsPerSession: number;
40
+ /** Per-session ceiling on admission tokens. */
41
+ maxTokensPerSession: number;
42
+ }
43
+ /** Inputs to the adaptive drain sizer. Mirrors `learning.realtime_admission`. */
44
+ export interface DrainSizeLimits {
45
+ /** Minimum candidates drained per cycle. */
46
+ minDrain: number;
47
+ /** Maximum candidates drained per cycle. */
48
+ maxDrain: number;
49
+ /** Weight of queue depth when sizing a drain (0 = ignore depth). */
50
+ drainDepthFactor: number;
51
+ /** Weight of arrival velocity when sizing a drain (0 = ignore rate). */
52
+ drainVelocityFactor: number;
53
+ }
54
+ /** One queued candidate plus the bookkeeping the drain needs. */
55
+ export interface QueuedCandidate {
56
+ candidate: InsightCandidate;
57
+ /** Epoch millis the candidate entered the queue. */
58
+ enqueuedAt: number;
59
+ /** How many drain attempts this candidate has already survived. */
60
+ attempts: number;
61
+ }
62
+ /** Read-only projection of a session's queue state. Test/observability seam. */
63
+ export interface QueueStats {
64
+ depth: number;
65
+ dropped: number;
66
+ llmCallsUsed: number;
67
+ tokensUsed: number;
68
+ retriesUsed: number;
69
+ lastDrainAt: number;
70
+ enqueuedSinceLastDrain: number;
71
+ }
72
+ export interface EnqueueResult {
73
+ /** False only when the candidate was rejected outright (invalid session id). */
74
+ enqueued: boolean;
75
+ /**
76
+ * Cumulative drop count for the session.
77
+ *
78
+ * There is deliberately no `depth` here. It was returned and read by nobody —
79
+ * not by `micro-reflector.ts`, not by `prm/index.ts` (both discard this whole
80
+ * value), and not by a test. The drain path that genuinely needs a depth
81
+ * already asks for it at the moment it drains, via `getQueueStats(...).depth`;
82
+ * a depth captured at enqueue time is stale by then. Reporting a number
83
+ * nothing consumes is unwired code (issue #1821).
84
+ */
85
+ dropped: number;
86
+ /** True when this enqueue evicted the oldest pending candidate. */
87
+ evictedOldest: boolean;
88
+ }
89
+ /** Injectable clock — tests pin time instead of sleeping. */
90
+ export declare const _internals: {
91
+ now: () => number;
92
+ };
93
+ /**
94
+ * Enqueue one candidate for same-session admission.
95
+ *
96
+ * Drop-oldest (not drop-newest) on overflow: a flood of candidates is far more
97
+ * likely to be a repeating failure mode, and the NEWEST observation is the one
98
+ * most likely to reflect the agent's current state. The `dropped` counter makes
99
+ * the loss observable to `getQueueStats` rather than silent.
100
+ */
101
+ export declare function enqueueCandidate(sessionID: string, candidate: InsightCandidate, limits: Pick<CandidateQueueLimits, 'maxQueueSize'>): EnqueueResult;
102
+ /**
103
+ * Adaptive drain size (AC9). Pure: no module state is read, so it is unit
104
+ * testable in isolation.
105
+ *
106
+ * size = ceil(minDrain + depthFactor * depth + velocityFactor * velocity)
107
+ *
108
+ * then clamped into `[minDrain, maxDrain]` and finally capped by the actual
109
+ * depth — a drain never claims more than exists. `ceil` (rather than `round`)
110
+ * biases marginally toward draining, because a candidate left in the queue is
111
+ * a lesson the current session cannot use yet.
112
+ *
113
+ * @param depth Pending candidates in the queue.
114
+ * @param velocity Arrivals per second since the last drain.
115
+ */
116
+ export declare function computeDrainSize(depth: number, velocity: number, limits: DrainSizeLimits): number;
117
+ declare function clampUnit(value: unknown): number;
118
+ /**
119
+ * Arrivals per second since the session's last drain. Returns 0 for a session
120
+ * that has never been drained within the same millisecond as its first
121
+ * enqueue, so velocity can never be infinite.
122
+ */
123
+ export declare function computeArrivalVelocity(stats: QueueStats, now: number): number;
124
+ /**
125
+ * Remove and return up to `size` oldest candidates, marking the drain point.
126
+ * Resets the velocity numerator so the next cycle measures a fresh interval.
127
+ */
128
+ export declare function takeDrainBatch(sessionID: string, size: number): QueuedCandidate[];
129
+ /**
130
+ * Return an unfinished candidate to the FRONT of the queue so the next drain
131
+ * retries it before newer arrivals. Respects `maxQueueSize` by dropping the
132
+ * newest tail entry rather than the requeued item — the item already consumed
133
+ * budget, so discarding it would waste that spend.
134
+ */
135
+ export declare function requeueCandidate(sessionID: string, item: QueuedCandidate, limits: Pick<CandidateQueueLimits, 'maxQueueSize'>): void;
136
+ /** O(1) pending-work probe used by the hot-path drain adapter. */
137
+ export declare function getQueueDepth(sessionID?: string): number;
138
+ /**
139
+ * Reserve LLM budget for one admission call. Returns false when either the
140
+ * call ceiling or the token ceiling would be exceeded; the caller then admits
141
+ * without LLM assistance rather than failing the candidate.
142
+ */
143
+ export declare function reserveLlmBudget(sessionID: string, estimatedTokens: number, limits: Pick<CandidateQueueLimits, 'maxLlmCallsPerSession' | 'maxTokensPerSession'>): boolean;
144
+ /**
145
+ * Record that a retry was spent, returning the session's running total.
146
+ *
147
+ * The BOUND lives on the admission side, per candidate
148
+ * (`max_retries_per_candidate`); this counter is the session-wide observability
149
+ * view. Total retries per drain is therefore bounded by
150
+ * `max_retries_per_candidate * batchSize`, and `batchSize` is itself bounded by
151
+ * `max_drain`, so the product is bounded without a second cap here.
152
+ */
153
+ export declare function recordRetry(sessionID: string): number;
154
+ /** Read-only stats for a session. Returns a zeroed record for unknown ids. */
155
+ export declare function getQueueStats(sessionID?: string): QueueStats;
156
+ /** Drop one session's queue, or every session when `sessionID` is omitted. */
157
+ export declare function resetSessionQueue(sessionID?: string): void;
158
+ /** Number of distinct sessions currently tracked. Bound-eviction test seam. */
159
+ declare function getTrackedSessionCount(): number;
160
+ /**
161
+ * Tier-0 pure-function seam (see the writing-tests skill).
162
+ *
163
+ * `clampUnit` is defensive: `computeDrainSize` caps its result by the actual
164
+ * depth, so an un-clamped factor is not observable through the public function
165
+ * and could be deleted with the whole suite green. Exporting it here lets the
166
+ * clamp be asserted directly instead of being untestable dead defence.
167
+ *
168
+ * `MAX_TRACKED_SESSIONS` and `getTrackedSessionCount` live here for the same
169
+ * reason and were moved behind the seam by the issue #1821 dead-export pass: the
170
+ * eviction bound has no production reader and the count probe has no production
171
+ * caller, so as bare exports they were indistinguishable from public API.
172
+ */
173
+ export declare const _test_exports: {
174
+ clampUnit: typeof clampUnit;
175
+ getTrackedSessionCount: typeof getTrackedSessionCount;
176
+ MAX_TRACKED_SESSIONS: number;
177
+ };
178
+ export {};
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Stable recommendation fingerprints (issue #1821, Lane 0a).
3
+ *
4
+ * The curator sweep, the skill improver, and the consensus miner all emit
5
+ * learning recommendations. Without a shared identity function the three
6
+ * producers happily re-propose the same lesson under three different ids and
7
+ * the knowledge store accumulates triplicates. `computeRecommendationFingerprint`
8
+ * gives every producer one canonical id so downstream dedup/supersede logic can
9
+ * compare recommendations across mechanisms.
10
+ *
11
+ * This module is intentionally pure: no filesystem, no network, no clock, no
12
+ * `process.cwd()`, no module-level mutable state (AGENTS.md invariants 1 and 8).
13
+ * The only dependency is the canonical JSON hasher shared with the evaluation
14
+ * subsystem, so a fingerprint computed in a hook matches one computed in a tool.
15
+ */
16
+ /** Which learning mechanism produced a recommendation. */
17
+ export type RecommendationKind = 'curator' | 'improver' | 'miner';
18
+ export interface RecommendationFingerprintInput {
19
+ /** Producing mechanism. Two mechanisms proposing the same statement about the
20
+ * same target intentionally fingerprint differently — dedup across mechanisms
21
+ * is a policy decision made by the caller, not baked into the identity. */
22
+ kind: RecommendationKind;
23
+ /** What the recommendation is about (skill slug, knowledge id, file path, …). */
24
+ target: string;
25
+ /** The human-readable recommendation body. Normalized before hashing. */
26
+ statement: string;
27
+ /** Optional scope keys (e.g. `stableScopeKey` outputs). Order-insensitive. */
28
+ scopeKeys?: string[];
29
+ }
30
+ /**
31
+ * Normalize a recommendation statement for identity purposes.
32
+ *
33
+ * Mirrors `normalizeMemoryText` in `src/memory/schema.ts` (whitespace-run
34
+ * collapse + trim) and the `.toLowerCase()` that `computeMemoryContentHash`
35
+ * applies on top of it, then additionally strips trailing sentence punctuation
36
+ * so "Prefer DI over mock.module." and "prefer DI over mock.module" collapse to
37
+ * one identity.
38
+ */
39
+ export declare function normalizeRecommendationStatement(statement: string): string;
40
+ /**
41
+ * Deduplicate and sort scope keys so caller-side ordering can never change the
42
+ * fingerprint. Empty/whitespace-only keys are dropped: they carry no scope
43
+ * information and would otherwise split identity for two equivalent inputs.
44
+ */
45
+ export declare function normalizeScopeKeys(scopeKeys?: string[]): string[];
46
+ /**
47
+ * Compute the stable fingerprint for a learning recommendation.
48
+ *
49
+ * Returns `lrec_` followed by the first 16 hex characters of the canonical
50
+ * sha256 of `{ kind, target, normalizedStatement, sortedScopeKeys }`. Because
51
+ * `canonicalHash` sorts object keys, the shape of the input literal cannot
52
+ * change the result.
53
+ */
54
+ export declare function computeRecommendationFingerprint(input: RecommendationFingerprintInput): string;
55
+ /** Shape check for a value that claims to be a recommendation fingerprint. */
56
+ export declare function isRecommendationFingerprint(value: string): boolean;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PRM pattern persistence support tracking (issue #1821, AC10).
3
+ *
4
+ * PRM already detects recurring failure patterns (repetition loops, ping-pong,
5
+ * context thrash, …) and injects a course correction. Those detections were
6
+ * purely ephemeral: the same crew rediscovered the same pattern next session.
7
+ * This module decides WHEN a detected pattern has earned a durable knowledge
8
+ * entry, and turns it into an insight candidate for the normal bounded
9
+ * admission path.
10
+ *
11
+ * Three properties matter, and each one is a defence against a specific way
12
+ * this could go wrong:
13
+ *
14
+ * 1. **Identity** is `pattern|sortedAgents|sortedTargets`. Two different
15
+ * repetition loops in different files are different lessons.
16
+ * 2. **Support counts DISTINCT OCCURRENCE STARTS.** PRM re-reports a live
17
+ * pattern on every subsequent tool call, and `pattern-detector.ts` emits
18
+ * `stepRange: [startStep, endStep]` with an ADVANCING `endStep` — so
19
+ * [1,2], [1,3], [1,4] are all the SAME incident. Counting whole ranges would
20
+ * let one continuous loop self-confirm to the threshold in seconds, so
21
+ * support is keyed on `stepRange[0]` instead.
22
+ * 3. **Evidence is POINTERS ONLY** — `prm:<sid>:<pattern>:<start>-<end>`.
23
+ * Transcript text and model reasoning are NEVER copied into a durable
24
+ * record; a knowledge entry is shared across sessions and must not carry
25
+ * another session's content.
26
+ *
27
+ * Module state follows the same bounded shape as `./candidate-queue.ts`: FIFO
28
+ * key eviction at `MAX_TRACKED_SESSIONS` plus a per-session identity cap
29
+ * (AGENTS.md invariant 8). No I/O happens here — this runs on the PRM
30
+ * `toolAfter` hot path.
31
+ */
32
+ import type { InsightCandidate } from '../hooks/micro-reflector.js';
33
+ import type { PatternMatch } from '../prm/types.js';
34
+ /** Tuning knobs, mirroring `learning.prm_persistence`. */
35
+ export interface PrmSupportLimits {
36
+ /** Distinct observations required before a pattern is persistable. */
37
+ minSupport: number;
38
+ /** Cooldown between persists for one identity, in milliseconds. */
39
+ cooldownMs: number;
40
+ }
41
+ export interface PrmObservationResult {
42
+ identity: string;
43
+ /** Number of DISTINCT occurrence starts observed for this identity. */
44
+ support: number;
45
+ /** True when support met the threshold AND the cooldown has elapsed. */
46
+ persistable: boolean;
47
+ /** Why `persistable` is false. Absent when it is true. */
48
+ reason?: 'below_support' | 'cooling_down' | 'unactionable';
49
+ /** Bounded evidence pointers. Never contains transcript or reasoning text. */
50
+ evidenceRefs: string[];
51
+ /** Present only when `persistable` — ready for `enqueueCandidate`. */
52
+ candidate?: InsightCandidate;
53
+ }
54
+ /** Injectable clock — tests pin time instead of sleeping. */
55
+ export declare const _internals: {
56
+ now: () => number;
57
+ };
58
+ /**
59
+ * Stable identity for a detected pattern.
60
+ *
61
+ * Agents and targets are sorted so detection order cannot mint a second
62
+ * identity for the same underlying situation.
63
+ */
64
+ declare function computePatternIdentity(match: Pick<PatternMatch, 'pattern' | 'affectedAgents' | 'affectedTargets'>): string;
65
+ /**
66
+ * Build ONE evidence pointer. Deliberately carries only the session id, the
67
+ * pattern name, and the step window — enough to re-derive the evidence from
68
+ * that session's own trajectory, and nothing that leaks its content.
69
+ */
70
+ declare function buildPrmEvidenceRef(sessionID: string, pattern: string, stepRange: [number, number]): string;
71
+ /**
72
+ * Build the durable lesson for a supported pattern.
73
+ *
74
+ * Returns undefined when the pattern yields no usable scope — an entry with no
75
+ * `applies_to_agents` / `applies_to_tools` fails the Layer-5 actionability gate
76
+ * and would only be quarantined, so it is better not to emit it at all.
77
+ */
78
+ declare function buildPrmPatternCandidate(match: PatternMatch, evidenceRefs: string[], createdAt: string): InsightCandidate | undefined;
79
+ /**
80
+ * Record one PRM pattern detection and report whether it now warrants a durable
81
+ * knowledge entry.
82
+ *
83
+ * MUST be called only after PRM's `matches.length === 0` early return, so the
84
+ * overwhelmingly common no-match path costs nothing.
85
+ */
86
+ export declare function recordPatternObservation(sessionID: string, match: PatternMatch, limits: PrmSupportLimits): PrmObservationResult;
87
+ /** Read-only support view for one identity. Test/observability seam. */
88
+ declare function getPatternSupport(sessionID: string, identity: string): {
89
+ support: number;
90
+ lastPersistedAt: number;
91
+ occurrenceStarts: number[];
92
+ };
93
+ /** Drop one session's support state, or every session when omitted. */
94
+ export declare function resetPrmPatternSupport(sessionID?: string): void;
95
+ /** Number of distinct sessions tracked. Bound-eviction test seam. */
96
+ declare function getTrackedPrmSessionCount(): number;
97
+ /** Number of distinct identities tracked for a session. */
98
+ declare function getTrackedPrmIdentityCount(sessionID: string): number;
99
+ /**
100
+ * Tier-0 pure-function and observability seam (see the writing-tests skill, and
101
+ * the sibling seam in `candidate-queue.ts`).
102
+ *
103
+ * Everything here was a bare `export` with no importer outside the tests — the
104
+ * three caps, the three pure builders, and the three read-only probes. The issue
105
+ * #1821 dead-export pass moved them behind this seam rather than deleting them:
106
+ * each is genuinely needed to assert a bound or a pure mapping that the public
107
+ * `recordPatternObservation` path only exercises indirectly, but as bare exports
108
+ * they were indistinguishable from public API and read as unwired code.
109
+ *
110
+ * `_internals` stays separate and stays small: it is the DI seam tests
111
+ * SUBSTITUTE (the clock). Nothing below is substitutable.
112
+ */
113
+ export declare const _test_exports: {
114
+ buildPrmEvidenceRef: typeof buildPrmEvidenceRef;
115
+ buildPrmPatternCandidate: typeof buildPrmPatternCandidate;
116
+ computePatternIdentity: typeof computePatternIdentity;
117
+ getPatternSupport: typeof getPatternSupport;
118
+ getTrackedPrmIdentityCount: typeof getTrackedPrmIdentityCount;
119
+ getTrackedPrmSessionCount: typeof getTrackedPrmSessionCount;
120
+ MAX_IDENTITIES_PER_SESSION: number;
121
+ MAX_OCCURRENCES_PER_IDENTITY: number;
122
+ MAX_TRACKED_SESSIONS: number;
123
+ };
124
+ export {};