@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.20
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/LICENSE +14 -0
- package/README.md +76 -2
- package/cordis.patch.yml +363 -0
- package/docs/architecture.md +692 -0
- package/docs/autorun-guide.md +248 -0
- package/docs/backlog.md +2022 -0
- package/docs/contract.md +938 -0
- package/docs/development.md +990 -0
- package/docs/host-compaction-seam.md +225 -0
- package/docs/host-protected-head-ask.md +80 -0
- package/docs/knowledge-repo.md +1332 -0
- package/docs/plans-carriage.md +571 -0
- package/docs/project-state.md +642 -0
- package/docs/provider.md +98 -0
- package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
- package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
- package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
- package/docs/verify.md +561 -0
- package/lib/archive.d.ts +161 -0
- package/lib/archive.js +229 -0
- package/lib/archive.js.map +1 -0
- package/lib/arrival.d.ts +23 -0
- package/lib/arrival.js +125 -0
- package/lib/arrival.js.map +1 -0
- package/lib/artifact-query.d.ts +62 -0
- package/lib/artifact-query.js +187 -0
- package/lib/artifact-query.js.map +1 -0
- package/lib/attach.d.ts +46 -0
- package/lib/attach.js +120 -0
- package/lib/attach.js.map +1 -0
- package/lib/auto-save.d.ts +145 -0
- package/lib/auto-save.js +334 -0
- package/lib/auto-save.js.map +1 -0
- package/lib/aux-message.d.ts +17 -0
- package/lib/aux-message.js +20 -0
- package/lib/aux-message.js.map +1 -0
- package/lib/client.js +1419 -0
- package/lib/client.js.map +1 -0
- package/lib/codegraph.d.ts +76 -0
- package/lib/codegraph.js +190 -0
- package/lib/codegraph.js.map +1 -0
- package/lib/commands.d.ts +52 -0
- package/lib/commands.js +946 -0
- package/lib/commands.js.map +1 -0
- package/lib/compose.d.ts +30 -0
- package/lib/compose.js +185 -0
- package/lib/compose.js.map +1 -0
- package/lib/continue-core.d.ts +245 -0
- package/lib/continue-core.js +405 -0
- package/lib/continue-core.js.map +1 -0
- package/lib/diag.d.ts +55 -0
- package/lib/diag.js +186 -0
- package/lib/diag.js.map +1 -0
- package/lib/engine-core.d.ts +301 -0
- package/lib/engine-core.js +531 -0
- package/lib/engine-core.js.map +1 -0
- package/lib/engine-note.d.ts +35 -0
- package/lib/engine-note.js +32 -0
- package/lib/engine-note.js.map +1 -0
- package/lib/engine.d.ts +329 -0
- package/lib/engine.js +1179 -0
- package/lib/engine.js.map +1 -0
- package/lib/enrich-queue.d.ts +66 -0
- package/lib/enrich-queue.js +126 -0
- package/lib/enrich-queue.js.map +1 -0
- package/lib/enrich-store.d.ts +31 -0
- package/lib/enrich-store.js +103 -0
- package/lib/enrich-store.js.map +1 -0
- package/lib/enrich-wire.d.ts +163 -0
- package/lib/enrich-wire.js +353 -0
- package/lib/enrich-wire.js.map +1 -0
- package/lib/enrich.d.ts +102 -0
- package/lib/enrich.js +267 -0
- package/lib/enrich.js.map +1 -0
- package/lib/gitops.d.ts +107 -0
- package/lib/gitops.js +539 -0
- package/lib/gitops.js.map +1 -0
- package/lib/index.d.ts +241 -0
- package/lib/index.js +1162 -0
- package/lib/index.js.map +1 -0
- package/lib/indexing.d.ts +98 -0
- package/lib/indexing.js +223 -0
- package/lib/indexing.js.map +1 -0
- package/lib/injections.d.ts +90 -0
- package/lib/injections.js +165 -0
- package/lib/injections.js.map +1 -0
- package/lib/last-resort.d.ts +24 -0
- package/lib/last-resort.js +27 -0
- package/lib/last-resort.js.map +1 -0
- package/lib/log-shape.d.ts +38 -0
- package/lib/log-shape.js +93 -0
- package/lib/log-shape.js.map +1 -0
- package/lib/models-command.d.ts +74 -0
- package/lib/models-command.js +100 -0
- package/lib/models-command.js.map +1 -0
- package/lib/notice.d.ts +74 -0
- package/lib/notice.js +92 -0
- package/lib/notice.js.map +1 -0
- package/lib/plans.d.ts +154 -0
- package/lib/plans.js +315 -0
- package/lib/plans.js.map +1 -0
- package/lib/preset.d.ts +186 -0
- package/lib/preset.js +212 -0
- package/lib/preset.js.map +1 -0
- package/lib/prompt-keys.d.ts +7 -0
- package/lib/prompt-keys.js +8 -0
- package/lib/prompt-keys.js.map +1 -0
- package/lib/prompt-loop.d.ts +73 -0
- package/lib/prompt-loop.js +147 -0
- package/lib/prompt-loop.js.map +1 -0
- package/lib/prompt-push.d.ts +47 -0
- package/lib/prompt-push.js +115 -0
- package/lib/prompt-push.js.map +1 -0
- package/lib/prompt-runner.d.ts +48 -0
- package/lib/prompt-runner.js +90 -0
- package/lib/prompt-runner.js.map +1 -0
- package/lib/prompt-state.d.ts +142 -0
- package/lib/prompt-state.js +101 -0
- package/lib/prompt-state.js.map +1 -0
- package/lib/prompt.d.ts +270 -0
- package/lib/prompt.js +408 -0
- package/lib/prompt.js.map +1 -0
- package/lib/provider.d.ts +44 -0
- package/lib/provider.js +66 -0
- package/lib/provider.js.map +1 -0
- package/lib/redact.d.ts +24 -0
- package/lib/redact.js +85 -0
- package/lib/redact.js.map +1 -0
- package/lib/registry.d.ts +187 -0
- package/lib/registry.js +235 -0
- package/lib/registry.js.map +1 -0
- package/lib/removal.d.ts +159 -0
- package/lib/removal.js +556 -0
- package/lib/removal.js.map +1 -0
- package/lib/render.d.ts +97 -0
- package/lib/render.js +530 -0
- package/lib/render.js.map +1 -0
- package/lib/repair-run.d.ts +76 -0
- package/lib/repair-run.js +506 -0
- package/lib/repair-run.js.map +1 -0
- package/lib/repair.d.ts +208 -0
- package/lib/repair.js +357 -0
- package/lib/repair.js.map +1 -0
- package/lib/repo.d.ts +78 -0
- package/lib/repo.js +184 -0
- package/lib/repo.js.map +1 -0
- package/lib/rules-commands.d.ts +46 -0
- package/lib/rules-commands.js +379 -0
- package/lib/rules-commands.js.map +1 -0
- package/lib/rules.d.ts +267 -0
- package/lib/rules.js +478 -0
- package/lib/rules.js.map +1 -0
- package/lib/save-core.d.ts +72 -0
- package/lib/save-core.js +220 -0
- package/lib/save-core.js.map +1 -0
- package/lib/search.d.ts +112 -0
- package/lib/search.js +282 -0
- package/lib/search.js.map +1 -0
- package/lib/session-rules.d.ts +222 -0
- package/lib/session-rules.js +454 -0
- package/lib/session-rules.js.map +1 -0
- package/lib/signature.d.ts +45 -0
- package/lib/signature.js +199 -0
- package/lib/signature.js.map +1 -0
- package/lib/store.d.ts +156 -0
- package/lib/store.js +368 -0
- package/lib/store.js.map +1 -0
- package/lib/sync.d.ts +220 -0
- package/lib/sync.js +810 -0
- package/lib/sync.js.map +1 -0
- package/lib/tools.d.ts +113 -0
- package/lib/tools.js +1090 -0
- package/lib/tools.js.map +1 -0
- package/lib/treedx/client.d.ts +57 -0
- package/lib/treedx/client.js +82 -0
- package/lib/treedx/client.js.map +1 -0
- package/lib/treedx/provider.d.ts +39 -0
- package/lib/treedx/provider.js +479 -0
- package/lib/treedx/provider.js.map +1 -0
- package/lib/treedx/state.d.ts +27 -0
- package/lib/treedx/state.js +65 -0
- package/lib/treedx/state.js.map +1 -0
- package/lib/types.d.ts +210 -0
- package/lib/types.js +31 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +9 -0
- package/lib/version.js +10 -0
- package/lib/version.js.map +1 -0
- package/lib/vocabulary.d.ts +41 -0
- package/lib/vocabulary.js +118 -0
- package/lib/vocabulary.js.map +1 -0
- package/lib/warnings.d.ts +10 -0
- package/lib/warnings.js +18 -0
- package/lib/warnings.js.map +1 -0
- package/package.json +151 -4
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { type RuleRecord, type RulesSectionResult } from './rules.ts';
|
|
2
|
+
import type { SessionEventLike } from './types.ts';
|
|
3
|
+
/** One captured rule: the human's body, anchored to the seq it came from. */
|
|
4
|
+
export interface CapturedRule {
|
|
5
|
+
body: string;
|
|
6
|
+
seq: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The markers a turn's HUMAN messages contain. Two screens, both load-bearing:
|
|
10
|
+
*
|
|
11
|
+
* - `isHumanAuthored` — the same shared predicate signatures use (r28: injected
|
|
12
|
+
* context moves chapter boundaries and would here too). A host-injected
|
|
13
|
+
* instruction file that happens to contain a line starting with the marker is
|
|
14
|
+
* NOT a rule request.
|
|
15
|
+
* - `stripReminderSpans` — `<system-reminder>` spans ride INSIDE human
|
|
16
|
+
* messages. The human's own lines survive; the injected span does not (§15
|
|
17
|
+
* amendment, "host-injected context is not conversation").
|
|
18
|
+
*/
|
|
19
|
+
export declare function humanRuleMarkers(events: readonly SessionEventLike[], prefix: string): CapturedRule[];
|
|
20
|
+
/**
|
|
21
|
+
* The next free number in the SHARED per-machine sequence. Both tiers author
|
|
22
|
+
* into `rules/<harness>/`, and ids are `<harness>/<NNN>`, so a session rule must
|
|
23
|
+
* not take a number a project rule already holds — neither the registry's (pre-
|
|
24
|
+
* sync) records' nor the disk's (a killed write leaves a file without a record;
|
|
25
|
+
* the r39 walk-past rule). Numbers are allocated, never scanned-for-content.
|
|
26
|
+
*/
|
|
27
|
+
export declare function nextRuleNumber(opts: {
|
|
28
|
+
records: Iterable<[string, RuleRecord]>;
|
|
29
|
+
harnessId: string;
|
|
30
|
+
rulesDir: string;
|
|
31
|
+
}): number;
|
|
32
|
+
export interface CaptureDeps {
|
|
33
|
+
/** workspace store root, e.g. `<cwd>/.dsh-chapters` */
|
|
34
|
+
storeDir: string;
|
|
35
|
+
harnessId: string;
|
|
36
|
+
/** the session whose tree these rules bind (§7.5 activation) */
|
|
37
|
+
sessionId: string;
|
|
38
|
+
/** for id-collision avoidance with the shared tier's pre-sync records */
|
|
39
|
+
records: Iterable<[string, RuleRecord]>;
|
|
40
|
+
prefix: string;
|
|
41
|
+
/** the completed turn's events */
|
|
42
|
+
events: readonly SessionEventLike[];
|
|
43
|
+
now?: () => string;
|
|
44
|
+
}
|
|
45
|
+
export interface CaptureResult {
|
|
46
|
+
written: string[];
|
|
47
|
+
skipped: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Capture this turn's markers. Returns the rule ids written (empty when nothing
|
|
51
|
+
* matched), and never throws into the session — the caller contains failures the
|
|
52
|
+
* way the signature listener does.
|
|
53
|
+
*/
|
|
54
|
+
export declare function captureSessionRules(deps: CaptureDeps): CaptureResult;
|
|
55
|
+
/**
|
|
56
|
+
* The current session's ancestor path, root-first — the async mirror of
|
|
57
|
+
* `registry.ancestorPath`, which takes a synchronous getter the realm does not
|
|
58
|
+
* have. §7.5 activation is "this rule's session is on MY path", so every render
|
|
59
|
+
* site needs this walk; a missing parent stops the walk instead of throwing,
|
|
60
|
+
* because a rules render must never break a compaction (the same containment
|
|
61
|
+
* the notice path uses).
|
|
62
|
+
*/
|
|
63
|
+
export declare function ancestorSessionIds(get: (id: string) => Promise<{
|
|
64
|
+
parentSession: string | null;
|
|
65
|
+
} | undefined>, sessionId: string, maxDepth?: number): Promise<string[]>;
|
|
66
|
+
/** One mined rule candidate, as the enrichment pass proposes it (§7.5). */
|
|
67
|
+
/**
|
|
68
|
+
* Rule ancestry crosses BOTH lineage edges (#52, user decision 2026-10-21).
|
|
69
|
+
*
|
|
70
|
+
* Our registry is the sole authority on CONTINUATION lineage and deliberately
|
|
71
|
+
* ignores the kernel header's `parentSession` — right for chapter numbering and
|
|
72
|
+
* the TOC, and it left a whole session shape uncovered: a delegated subagent is an
|
|
73
|
+
* unrelated root by that view, so it inherited no activated rules from ANY door.
|
|
74
|
+
* The rule tier now walks both edges, READ-ONLY, writing nothing:
|
|
75
|
+
*
|
|
76
|
+
* registry parentSession (ours) ∪ header parentSession (the host's)
|
|
77
|
+
*
|
|
78
|
+
* transitively, so a subagent of a continuation child of a delegating parent sees
|
|
79
|
+
* the whole chain — and still walks UP only, which keeps the direction one-way: a
|
|
80
|
+
* child's inferences never govern the session that delegated them. Two bounds,
|
|
81
|
+
* because this runs inside a head rebuild: an expansion cap, and a reader that
|
|
82
|
+
* throws contributes no edge — a rule walk must never break a turn.
|
|
83
|
+
*/
|
|
84
|
+
export declare const RULE_LINEAGE_MAX_EXPANSIONS = 64;
|
|
85
|
+
export interface RuleAncestryWalk {
|
|
86
|
+
ancestors: Set<string>;
|
|
87
|
+
truncated: boolean;
|
|
88
|
+
}
|
|
89
|
+
export declare function ruleAncestorSet(opts: {
|
|
90
|
+
/** Usually [sessionId]; the session always belongs to its own ancestry. */
|
|
91
|
+
seeds: readonly string[];
|
|
92
|
+
/** Our registry chain (continuation lineage). */
|
|
93
|
+
registryAncestors?: ((id: string) => Promise<readonly string[]>) | undefined;
|
|
94
|
+
/** The host header's delegation edge, for any session id. */
|
|
95
|
+
parentOf?: ((id: string) => Promise<string | null | undefined>) | undefined;
|
|
96
|
+
maxExpansions?: number;
|
|
97
|
+
}): Promise<RuleAncestryWalk>;
|
|
98
|
+
/**
|
|
99
|
+
* A `parentOf` from whatever session views this plane can reach: a live agent
|
|
100
|
+
* handle first, then `sessionQuery`'s durable headers. undefined when NEITHER
|
|
101
|
+
* exists — which degrades the walk to registry edges only.
|
|
102
|
+
*
|
|
103
|
+
* Cordis `ctx.get` THROWS for an unregistered service and a plugin plane may have
|
|
104
|
+
* neither, so every lookup is guarded (the #43 lesson: a strict host makes a
|
|
105
|
+
* plausible service name throw).
|
|
106
|
+
*/
|
|
107
|
+
export declare function makeHeaderParentReader(get?: (name: string) => unknown): ((id: string) => Promise<string | null | undefined>) | undefined;
|
|
108
|
+
export interface MinedRule {
|
|
109
|
+
text: string;
|
|
110
|
+
category: string;
|
|
111
|
+
/** the human's own words this rule came from — the quote IS the gate */
|
|
112
|
+
quote: string;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* THE verbatim-quote gate (§7.5). A mined rule activates only when its quote is
|
|
116
|
+
* the human's own words, present verbatim (whitespace-normalized) in the
|
|
117
|
+
* chapter's USER text. Assistant prose is deliberately NOT admissible evidence:
|
|
118
|
+
* the model restating an instruction it was given is the amplification path, and
|
|
119
|
+
* a quote it invented is a guess wearing a citation.
|
|
120
|
+
*/
|
|
121
|
+
export declare function quoteIsHuman(humanText: string, quote: string): boolean;
|
|
122
|
+
export interface MineDeps {
|
|
123
|
+
storeDir: string;
|
|
124
|
+
harnessId: string;
|
|
125
|
+
/** the session whose tree the mined rules bind — the chapter's OWN session */
|
|
126
|
+
sessionId: string;
|
|
127
|
+
records: Iterable<[string, RuleRecord]>;
|
|
128
|
+
/** the chapter's human-authored bytes (its `**User…:**` chunks joined) */
|
|
129
|
+
humanText: string;
|
|
130
|
+
rules: readonly MinedRule[];
|
|
131
|
+
/** per-chapter cap: one conversation must not mint a dozen standing orders */
|
|
132
|
+
maxPerChapter: number;
|
|
133
|
+
/** the chapter these came from (provenance rendered into the head) */
|
|
134
|
+
chapterNumber: number;
|
|
135
|
+
/**
|
|
136
|
+
* §7.5 policy (amended 2026-10-21): may a mined rule govern on arrival? TRUE by
|
|
137
|
+
* default — a rule the model inferred from context is the reason this tier
|
|
138
|
+
* exists; the human may not have known to say it. When false every mined rule is
|
|
139
|
+
* written `reviewOnly: true` and stays inert until this machine approves it, the
|
|
140
|
+
* conservative posture a shared box may want.
|
|
141
|
+
*/
|
|
142
|
+
autoActivate: boolean;
|
|
143
|
+
now?: () => string;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Write the mined candidates. Each records its warrant (`stated` / `inferred`) and
|
|
147
|
+
* the chapter it came from; whether it governs on arrival is the `autoActivate`
|
|
148
|
+
* POLICY, not an evidence test (§7.5, amended 2026-10-21 — the tier's premise is
|
|
149
|
+
* that the human may not have known to say the rule). Deduped by normalized body
|
|
150
|
+
* against what the machine already holds, so re-running the ladder (or a
|
|
151
|
+
* chapter annotated by a newer model) fills gaps and never duplicates.
|
|
152
|
+
*/
|
|
153
|
+
export declare function captureMinedRules(deps: MineDeps): {
|
|
154
|
+
ids: string[];
|
|
155
|
+
stated: number;
|
|
156
|
+
inferred: number;
|
|
157
|
+
pending: number;
|
|
158
|
+
};
|
|
159
|
+
/** Turn a task description into the relevance term set §7.4 selects on — the same
|
|
160
|
+
* normalization the rule-term index uses, so a query and a rule body meet in one
|
|
161
|
+
* vocabulary instead of two. */
|
|
162
|
+
export declare function queryTopics(query: string): string[];
|
|
163
|
+
/** The registry view the renderer needs — kept structural so tests can hand it a map. */
|
|
164
|
+
export interface RulesStoreLike {
|
|
165
|
+
get(id: string): Promise<{
|
|
166
|
+
parentSession: string | null;
|
|
167
|
+
collections: {
|
|
168
|
+
terms: string[];
|
|
169
|
+
}[];
|
|
170
|
+
} | undefined>;
|
|
171
|
+
}
|
|
172
|
+
export interface RenderRulesOpts {
|
|
173
|
+
mirrorDir: string;
|
|
174
|
+
storeDir: string;
|
|
175
|
+
projectKey: string;
|
|
176
|
+
harnessId: string;
|
|
177
|
+
budgetTokens: number;
|
|
178
|
+
projectCap?: number | undefined;
|
|
179
|
+
sessionCap?: number | undefined;
|
|
180
|
+
/** §7.4 relevance on; off ⇒ deterministic category→id order (the tape order). */
|
|
181
|
+
relevance: boolean;
|
|
182
|
+
/** §7.6: a BRANCH head carries the project tier only — zero session bytes. */
|
|
183
|
+
includeSession: boolean;
|
|
184
|
+
/** When supplied, selection uses THESE terms (the assembly door passes the
|
|
185
|
+
* task query) instead of the session's own recent signatures. */
|
|
186
|
+
topicsOverride?: readonly string[] | undefined;
|
|
187
|
+
store: RulesStoreLike;
|
|
188
|
+
sessionId: string;
|
|
189
|
+
/** #52: reads the host header's parentSession for any id in the chain. Absent
|
|
190
|
+
* ⇒ the walk follows registry edges only, so delegation contributes nothing. */
|
|
191
|
+
parentOf?: ((id: string) => Promise<string | null | undefined>) | undefined;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* THE one rules renderer for a rebuilt head. Both doors (continue / fork) and the
|
|
195
|
+
* compaction checkpoint call this and nothing else, so the same session can never
|
|
196
|
+
* see two different rule sets depending on which path rebuilt its head — the same
|
|
197
|
+
* one-builder discipline as `search.rankEntries` and `deterministicCommand`.
|
|
198
|
+
*
|
|
199
|
+
* Relevance topics come from the session’s OWN stored turn signatures:
|
|
200
|
+
* deterministic, already on disk, zero model tokens (§1 invariant 1).
|
|
201
|
+
*/
|
|
202
|
+
export declare function renderRulesBlock(opts: RenderRulesOpts): Promise<RulesSectionResult>;
|
|
203
|
+
/**
|
|
204
|
+
* The `turn/end` listener (the same boundary the topic signatures collect on —
|
|
205
|
+
* §7.5: this extends the existing door, it is not a sixth archive moment).
|
|
206
|
+
*
|
|
207
|
+
* Never-break containment: every failure lands in `warn`, because a rule that
|
|
208
|
+
* could not be written must not cost a user their turn.
|
|
209
|
+
*/
|
|
210
|
+
export declare function makeRuleCaptureListener(deps: {
|
|
211
|
+
enabled: () => boolean;
|
|
212
|
+
prefix: () => string;
|
|
213
|
+
harnessId: () => string;
|
|
214
|
+
storeRoot: (cwd: string) => string;
|
|
215
|
+
store: () => Promise<{
|
|
216
|
+
store: {
|
|
217
|
+
rules(): IterableIterator<[string, RuleRecord]>;
|
|
218
|
+
};
|
|
219
|
+
}>;
|
|
220
|
+
warn: (error: unknown) => void;
|
|
221
|
+
onCaptured?: (sessionId: string, ids: string[]) => void;
|
|
222
|
+
}): (session: unknown, event: unknown) => void;
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #50 §7.5 — the session tier's capture path.
|
|
3
|
+
*
|
|
4
|
+
* Two speeds, deliberately (this is §6.2's single-slot constraint applied to
|
|
5
|
+
* rules, not a new policy):
|
|
6
|
+
*
|
|
7
|
+
* - **marker**: `ADD RULE: <text>` on a line of a HUMAN message becomes an
|
|
8
|
+
* active session rule at the existing `turn/end` boundary — deterministic,
|
|
9
|
+
* zero model tokens (§1 invariant 1), the body is the human's own words.
|
|
10
|
+
* - **enrichment** (src/enrich-wire.ts): idle-batched, default off, and only
|
|
11
|
+
* ever activates behind a verbatim quote of human bytes.
|
|
12
|
+
*
|
|
13
|
+
* Session rules are **files, not registry rows**: their activation derives from
|
|
14
|
+
* the ancestor path (§7.5), so they need no record to resolve status, no linked
|
|
15
|
+
* project (an unlinked checkout still gets its rules — the whole point of the
|
|
16
|
+
* tier), and no fact in a pool-synced curation file. The registry keeps holding
|
|
17
|
+
* the shared tier's pre-sync records exactly as before.
|
|
18
|
+
*
|
|
19
|
+
* Idempotent by body: repeated `turn/end`s on a continuable child, and the
|
|
20
|
+
* after-loss re-derivation walk over the durable log, both FILL GAPS and never
|
|
21
|
+
* duplicate — the same posture `rehydrateStoreFromMirror` takes.
|
|
22
|
+
*
|
|
23
|
+
* Pure module: events and paths in, files out. The engine listener mounts it.
|
|
24
|
+
*/
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { isHumanAuthored, stripReminderSpans } from "./injections.js";
|
|
28
|
+
import { turnSpanOf } from "./signature.js";
|
|
29
|
+
import { buildRulesSection, collectStoreRules, parseRuleFile, parseRuleMarkers, renderRuleFile, ruleSlug, } from "./rules.js";
|
|
30
|
+
import { redactText } from "./redact.js";
|
|
31
|
+
const messageText = (data) => {
|
|
32
|
+
const message = (data.message ?? data);
|
|
33
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
34
|
+
return content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('\n');
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The markers a turn's HUMAN messages contain. Two screens, both load-bearing:
|
|
38
|
+
*
|
|
39
|
+
* - `isHumanAuthored` — the same shared predicate signatures use (r28: injected
|
|
40
|
+
* context moves chapter boundaries and would here too). A host-injected
|
|
41
|
+
* instruction file that happens to contain a line starting with the marker is
|
|
42
|
+
* NOT a rule request.
|
|
43
|
+
* - `stripReminderSpans` — `<system-reminder>` spans ride INSIDE human
|
|
44
|
+
* messages. The human's own lines survive; the injected span does not (§15
|
|
45
|
+
* amendment, "host-injected context is not conversation").
|
|
46
|
+
*/
|
|
47
|
+
export function humanRuleMarkers(events, prefix) {
|
|
48
|
+
const out = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const ev of events) {
|
|
51
|
+
if (ev.type !== 'user/message')
|
|
52
|
+
continue;
|
|
53
|
+
const data = (ev.data ?? {});
|
|
54
|
+
if (!isHumanAuthored(data))
|
|
55
|
+
continue;
|
|
56
|
+
const text = stripReminderSpans(messageText(data)).text;
|
|
57
|
+
for (const body of parseRuleMarkers(text, prefix)) {
|
|
58
|
+
if (seen.has(body))
|
|
59
|
+
continue;
|
|
60
|
+
seen.add(body);
|
|
61
|
+
out.push({ body, seq: ev.seq });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The next free number in the SHARED per-machine sequence. Both tiers author
|
|
68
|
+
* into `rules/<harness>/`, and ids are `<harness>/<NNN>`, so a session rule must
|
|
69
|
+
* not take a number a project rule already holds — neither the registry's (pre-
|
|
70
|
+
* sync) records' nor the disk's (a killed write leaves a file without a record;
|
|
71
|
+
* the r39 walk-past rule). Numbers are allocated, never scanned-for-content.
|
|
72
|
+
*/
|
|
73
|
+
export function nextRuleNumber(opts) {
|
|
74
|
+
const used = new Set();
|
|
75
|
+
const tail = new RegExp(`^${opts.harnessId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/(\\d{3})$`);
|
|
76
|
+
for (const [id] of opts.records) {
|
|
77
|
+
const m = tail.exec(id);
|
|
78
|
+
if (m !== null)
|
|
79
|
+
used.add(Number(m[1]));
|
|
80
|
+
}
|
|
81
|
+
if (fs.existsSync(opts.rulesDir)) {
|
|
82
|
+
for (const f of fs.readdirSync(opts.rulesDir)) {
|
|
83
|
+
const m = /^(\d{3})-/.exec(f);
|
|
84
|
+
if (m !== null)
|
|
85
|
+
used.add(Number(m[1]));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let n = 1;
|
|
89
|
+
while (used.has(n))
|
|
90
|
+
n += 1;
|
|
91
|
+
return n;
|
|
92
|
+
}
|
|
93
|
+
const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
94
|
+
/**
|
|
95
|
+
* Capture this turn's markers. Returns the rule ids written (empty when nothing
|
|
96
|
+
* matched), and never throws into the session — the caller contains failures the
|
|
97
|
+
* way the signature listener does.
|
|
98
|
+
*/
|
|
99
|
+
export function captureSessionRules(deps) {
|
|
100
|
+
const markers = humanRuleMarkers(deps.events, deps.prefix);
|
|
101
|
+
if (markers.length === 0)
|
|
102
|
+
return { written: [], skipped: 0 };
|
|
103
|
+
const rulesDir = path.join(deps.storeDir, 'rules', deps.harnessId);
|
|
104
|
+
// dedupe against what is ALREADY on disk for this machine (§7.5 idempotence):
|
|
105
|
+
// a body captured last turn is not captured twice, and a re-derivation pass
|
|
106
|
+
// over the log fills only the gaps.
|
|
107
|
+
const existing = new Set();
|
|
108
|
+
if (fs.existsSync(rulesDir)) {
|
|
109
|
+
for (const f of fs.readdirSync(rulesDir)) {
|
|
110
|
+
if (!f.endsWith('.md'))
|
|
111
|
+
continue;
|
|
112
|
+
// the SAME parser the collectors use — one definition of a rule body,
|
|
113
|
+
// never a second hand-rolled frontmatter split that can drift from it.
|
|
114
|
+
const parsed = parseRuleFile(fs.readFileSync(path.join(rulesDir, f), 'utf8'));
|
|
115
|
+
if (parsed !== null)
|
|
116
|
+
existing.add(norm(parsed.body));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let written = 0;
|
|
120
|
+
const ids = [];
|
|
121
|
+
for (const m of markers) {
|
|
122
|
+
if (existing.has(norm(m.body)))
|
|
123
|
+
continue;
|
|
124
|
+
// §9 applies to rule bodies like any other archived bytes: a captured rule
|
|
125
|
+
// can carry a credential, and this tier is local but still a file on disk.
|
|
126
|
+
const body = redactText(m.body).text;
|
|
127
|
+
const number = nextRuleNumber({ records: deps.records, harnessId: deps.harnessId, rulesDir });
|
|
128
|
+
const title = body.split(/[.!?\n]/)[0].trim().slice(0, 60);
|
|
129
|
+
const name = `${String(number).padStart(3, '0')}-${ruleSlug('general', title)}.md`;
|
|
130
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
131
|
+
const file = path.join(rulesDir, name);
|
|
132
|
+
// write-once: the file is created complete, never edited in place (§1.3)
|
|
133
|
+
fs.writeFileSync(file, renderRuleFile({
|
|
134
|
+
number,
|
|
135
|
+
category: 'general',
|
|
136
|
+
title,
|
|
137
|
+
sourceSession: deps.sessionId,
|
|
138
|
+
at: (deps.now !== undefined ? deps.now() : new Date().toISOString()),
|
|
139
|
+
body: `${body}\n`,
|
|
140
|
+
scope: 'session',
|
|
141
|
+
session: deps.sessionId,
|
|
142
|
+
derivedFrom: 'marker',
|
|
143
|
+
}), { flag: 'wx' });
|
|
144
|
+
ids.push(`${deps.harnessId}/${String(number).padStart(3, '0')}`);
|
|
145
|
+
existing.add(norm(body));
|
|
146
|
+
written += 1;
|
|
147
|
+
}
|
|
148
|
+
return { written: ids, skipped: markers.length - written };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The current session's ancestor path, root-first — the async mirror of
|
|
152
|
+
* `registry.ancestorPath`, which takes a synchronous getter the realm does not
|
|
153
|
+
* have. §7.5 activation is "this rule's session is on MY path", so every render
|
|
154
|
+
* site needs this walk; a missing parent stops the walk instead of throwing,
|
|
155
|
+
* because a rules render must never break a compaction (the same containment
|
|
156
|
+
* the notice path uses).
|
|
157
|
+
*/
|
|
158
|
+
export async function ancestorSessionIds(get, sessionId, maxDepth = 1000) {
|
|
159
|
+
const out = [];
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
let cursor = sessionId;
|
|
162
|
+
while (cursor !== undefined && !seen.has(cursor) && seen.size < maxDepth) {
|
|
163
|
+
seen.add(cursor);
|
|
164
|
+
out.push(cursor);
|
|
165
|
+
const state = await get(cursor);
|
|
166
|
+
if (state === undefined)
|
|
167
|
+
break;
|
|
168
|
+
cursor = state.parentSession ?? undefined;
|
|
169
|
+
}
|
|
170
|
+
return out.reverse();
|
|
171
|
+
}
|
|
172
|
+
/** One mined rule candidate, as the enrichment pass proposes it (§7.5). */
|
|
173
|
+
/**
|
|
174
|
+
* Rule ancestry crosses BOTH lineage edges (#52, user decision 2026-10-21).
|
|
175
|
+
*
|
|
176
|
+
* Our registry is the sole authority on CONTINUATION lineage and deliberately
|
|
177
|
+
* ignores the kernel header's `parentSession` — right for chapter numbering and
|
|
178
|
+
* the TOC, and it left a whole session shape uncovered: a delegated subagent is an
|
|
179
|
+
* unrelated root by that view, so it inherited no activated rules from ANY door.
|
|
180
|
+
* The rule tier now walks both edges, READ-ONLY, writing nothing:
|
|
181
|
+
*
|
|
182
|
+
* registry parentSession (ours) ∪ header parentSession (the host's)
|
|
183
|
+
*
|
|
184
|
+
* transitively, so a subagent of a continuation child of a delegating parent sees
|
|
185
|
+
* the whole chain — and still walks UP only, which keeps the direction one-way: a
|
|
186
|
+
* child's inferences never govern the session that delegated them. Two bounds,
|
|
187
|
+
* because this runs inside a head rebuild: an expansion cap, and a reader that
|
|
188
|
+
* throws contributes no edge — a rule walk must never break a turn.
|
|
189
|
+
*/
|
|
190
|
+
export const RULE_LINEAGE_MAX_EXPANSIONS = 64;
|
|
191
|
+
export async function ruleAncestorSet(opts) {
|
|
192
|
+
const cap = opts.maxExpansions ?? RULE_LINEAGE_MAX_EXPANSIONS;
|
|
193
|
+
const ancestors = new Set();
|
|
194
|
+
for (const s of opts.seeds)
|
|
195
|
+
if (s !== '')
|
|
196
|
+
ancestors.add(s);
|
|
197
|
+
const queue = [...ancestors];
|
|
198
|
+
let expansions = 0;
|
|
199
|
+
let truncated = false;
|
|
200
|
+
while (queue.length > 0) {
|
|
201
|
+
if (expansions >= cap) {
|
|
202
|
+
truncated = true;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
const id = queue.shift();
|
|
206
|
+
expansions += 1;
|
|
207
|
+
const edges = [];
|
|
208
|
+
if (opts.parentOf !== undefined) {
|
|
209
|
+
try {
|
|
210
|
+
const p = await opts.parentOf(id);
|
|
211
|
+
if (typeof p === 'string' && p !== '')
|
|
212
|
+
edges.push(p);
|
|
213
|
+
}
|
|
214
|
+
catch { /* unreadable header ⇒ not an ancestor */ }
|
|
215
|
+
}
|
|
216
|
+
if (opts.registryAncestors !== undefined) {
|
|
217
|
+
try {
|
|
218
|
+
edges.push(...(await opts.registryAncestors(id)));
|
|
219
|
+
}
|
|
220
|
+
catch { /* shrink, never fail */ }
|
|
221
|
+
}
|
|
222
|
+
for (const e of edges)
|
|
223
|
+
if (e !== '' && !ancestors.has(e)) {
|
|
224
|
+
ancestors.add(e);
|
|
225
|
+
queue.push(e);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { ancestors, truncated };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A `parentOf` from whatever session views this plane can reach: a live agent
|
|
232
|
+
* handle first, then `sessionQuery`'s durable headers. undefined when NEITHER
|
|
233
|
+
* exists — which degrades the walk to registry edges only.
|
|
234
|
+
*
|
|
235
|
+
* Cordis `ctx.get` THROWS for an unregistered service and a plugin plane may have
|
|
236
|
+
* neither, so every lookup is guarded (the #43 lesson: a strict host makes a
|
|
237
|
+
* plausible service name throw).
|
|
238
|
+
*/
|
|
239
|
+
export function makeHeaderParentReader(get) {
|
|
240
|
+
if (get === undefined)
|
|
241
|
+
return undefined;
|
|
242
|
+
// `agents` is a SERVICE: ctx.get('agents').get(id) hands back the live handle —
|
|
243
|
+
// the same two-step auto-save uses, and the only way to read a session other
|
|
244
|
+
// than the one currently rendering.
|
|
245
|
+
const liveParent = (id) => {
|
|
246
|
+
try {
|
|
247
|
+
const svc = get('agents');
|
|
248
|
+
const handle = svc?.get?.(id);
|
|
249
|
+
const session = handle?.agent?.session ?? handle?.session;
|
|
250
|
+
const p = session?.header?.parentSession;
|
|
251
|
+
return typeof p === 'string' && p !== '' ? p : null;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
let durable = null;
|
|
258
|
+
const durableParent = async (id) => {
|
|
259
|
+
if (durable === null) {
|
|
260
|
+
try {
|
|
261
|
+
const sq = get('sessionQuery');
|
|
262
|
+
if (sq?.listSessions === undefined)
|
|
263
|
+
return null;
|
|
264
|
+
const m = new Map();
|
|
265
|
+
for (const r of await sq.listSessions() ?? []) {
|
|
266
|
+
const sid = r?.header?.id;
|
|
267
|
+
if (typeof sid === 'string' && sid !== '') {
|
|
268
|
+
const p = r.header?.parentSession;
|
|
269
|
+
m.set(sid, typeof p === 'string' && p !== '' ? p : null);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
durable = m;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return durable.get(id) ?? null;
|
|
279
|
+
};
|
|
280
|
+
return async (id) => {
|
|
281
|
+
// live handle first: it is the current session's own edge, and the freshest
|
|
282
|
+
// answer for any session still open in this process
|
|
283
|
+
const live = liveParent(id);
|
|
284
|
+
if (live !== null)
|
|
285
|
+
return live;
|
|
286
|
+
return durableParent(id);
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const normQuote = (t) => t.toLowerCase().replace(/\s+/g, ' ').replace(/[\u2018\u2019]/g, "'").replace(/[\u201c\u201d]/g, '"').trim();
|
|
290
|
+
/**
|
|
291
|
+
* THE verbatim-quote gate (§7.5). A mined rule activates only when its quote is
|
|
292
|
+
* the human's own words, present verbatim (whitespace-normalized) in the
|
|
293
|
+
* chapter's USER text. Assistant prose is deliberately NOT admissible evidence:
|
|
294
|
+
* the model restating an instruction it was given is the amplification path, and
|
|
295
|
+
* a quote it invented is a guess wearing a citation.
|
|
296
|
+
*/
|
|
297
|
+
export function quoteIsHuman(humanText, quote) {
|
|
298
|
+
const q = normQuote(quote);
|
|
299
|
+
if (q.length < 3)
|
|
300
|
+
return false;
|
|
301
|
+
return normQuote(humanText).includes(q);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Write the mined candidates. Each records its warrant (`stated` / `inferred`) and
|
|
305
|
+
* the chapter it came from; whether it governs on arrival is the `autoActivate`
|
|
306
|
+
* POLICY, not an evidence test (§7.5, amended 2026-10-21 — the tier's premise is
|
|
307
|
+
* that the human may not have known to say the rule). Deduped by normalized body
|
|
308
|
+
* against what the machine already holds, so re-running the ladder (or a
|
|
309
|
+
* chapter annotated by a newer model) fills gaps and never duplicates.
|
|
310
|
+
*/
|
|
311
|
+
export function captureMinedRules(deps) {
|
|
312
|
+
const rulesDir = path.join(deps.storeDir, 'rules', deps.harnessId);
|
|
313
|
+
const existing = new Set();
|
|
314
|
+
for (const r of collectStoreRules(deps.storeDir, deps.harnessId))
|
|
315
|
+
existing.add(norm(r.body));
|
|
316
|
+
const written = [];
|
|
317
|
+
let stated = 0;
|
|
318
|
+
let inferred = 0;
|
|
319
|
+
let pending = 0;
|
|
320
|
+
const cap = Math.max(0, deps.maxPerChapter);
|
|
321
|
+
for (const m of deps.rules) {
|
|
322
|
+
// the cap counts rules WRITTEN, not candidates examined: a duplicate or an
|
|
323
|
+
// over-short candidate must not eat a chapter's budget
|
|
324
|
+
if (written.length >= cap)
|
|
325
|
+
break;
|
|
326
|
+
const body = redactText(m.text.trim().replace(/\s+/g, ' ')).text;
|
|
327
|
+
if (body.length < 10 || existing.has(norm(body)))
|
|
328
|
+
continue;
|
|
329
|
+
// The quote check CLASSIFIES, it does not authorize (§7.5, amended
|
|
330
|
+
// 2026-10-21): `stated` = a human sentence is behind this, `inferred` = the
|
|
331
|
+
// model judged it useful and nobody said it. Both govern unless the plugin is
|
|
332
|
+
// configured to hold mined rules for review, and the difference is SHOWN in
|
|
333
|
+
// the rendered rule line — which is where a judgement call belongs.
|
|
334
|
+
const basis = quoteIsHuman(deps.humanText, m.quote) ? 'stated' : 'inferred';
|
|
335
|
+
const reviewOnly = !deps.autoActivate;
|
|
336
|
+
const number = nextRuleNumber({ records: deps.records, harnessId: deps.harnessId, rulesDir });
|
|
337
|
+
const category = (m.category || 'general').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24) || 'general';
|
|
338
|
+
const title = body.split(/[.!?]/)[0].trim().slice(0, 60);
|
|
339
|
+
const name = `${String(number).padStart(3, '0')}-${ruleSlug(category, title)}.md`;
|
|
340
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
341
|
+
fs.writeFileSync(path.join(rulesDir, name), renderRuleFile({
|
|
342
|
+
number, category, title, sourceSession: deps.sessionId,
|
|
343
|
+
at: (deps.now !== undefined ? deps.now() : new Date().toISOString()),
|
|
344
|
+
body: `${body}\n`,
|
|
345
|
+
scope: 'session', session: deps.sessionId, derivedFrom: 'model',
|
|
346
|
+
reviewOnly, basis, chapter: deps.chapterNumber,
|
|
347
|
+
}), { flag: 'wx' });
|
|
348
|
+
written.push(`${deps.harnessId}/${String(number).padStart(3, '0')}`);
|
|
349
|
+
existing.add(norm(body));
|
|
350
|
+
if (basis === 'stated')
|
|
351
|
+
stated += 1;
|
|
352
|
+
else
|
|
353
|
+
inferred += 1;
|
|
354
|
+
if (reviewOnly)
|
|
355
|
+
pending += 1;
|
|
356
|
+
}
|
|
357
|
+
return { ids: written, stated, inferred, pending };
|
|
358
|
+
}
|
|
359
|
+
/** Turn a task description into the relevance term set §7.4 selects on — the same
|
|
360
|
+
* normalization the rule-term index uses, so a query and a rule body meet in one
|
|
361
|
+
* vocabulary instead of two. */
|
|
362
|
+
export function queryTopics(query) {
|
|
363
|
+
const out = [];
|
|
364
|
+
for (const tok of query.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
365
|
+
if (tok.length < 3 || tok.length > 28 || out.includes(tok))
|
|
366
|
+
continue;
|
|
367
|
+
out.push(tok);
|
|
368
|
+
}
|
|
369
|
+
return out.slice(0, 40);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* THE one rules renderer for a rebuilt head. Both doors (continue / fork) and the
|
|
373
|
+
* compaction checkpoint call this and nothing else, so the same session can never
|
|
374
|
+
* see two different rule sets depending on which path rebuilt its head — the same
|
|
375
|
+
* one-builder discipline as `search.rankEntries` and `deterministicCommand`.
|
|
376
|
+
*
|
|
377
|
+
* Relevance topics come from the session’s OWN stored turn signatures:
|
|
378
|
+
* deterministic, already on disk, zero model tokens (§1 invariant 1).
|
|
379
|
+
*/
|
|
380
|
+
export async function renderRulesBlock(opts) {
|
|
381
|
+
const walk = opts.includeSession
|
|
382
|
+
? await ruleAncestorSet({
|
|
383
|
+
seeds: [opts.sessionId],
|
|
384
|
+
registryAncestors: async (id) => await ancestorSessionIds(async (s) => await opts.store.get(s), id),
|
|
385
|
+
...(opts.parentOf !== undefined ? { parentOf: opts.parentOf } : {}),
|
|
386
|
+
})
|
|
387
|
+
: { ancestors: new Set(), truncated: false };
|
|
388
|
+
const ancestors = walk.ancestors;
|
|
389
|
+
const state = opts.relevance ? await opts.store.get(opts.sessionId) : undefined;
|
|
390
|
+
const topics = opts.topicsOverride !== undefined && opts.topicsOverride.length > 0
|
|
391
|
+
? opts.topicsOverride
|
|
392
|
+
: opts.relevance && state !== undefined
|
|
393
|
+
? state.collections.slice(-6).flatMap((c) => c.terms).slice(0, 40)
|
|
394
|
+
: undefined;
|
|
395
|
+
return buildRulesSection(opts.mirrorDir, {
|
|
396
|
+
projectKey: opts.projectKey,
|
|
397
|
+
harnessId: opts.harnessId,
|
|
398
|
+
budgetTokens: opts.budgetTokens,
|
|
399
|
+
projectCap: opts.projectCap,
|
|
400
|
+
sessionCap: opts.sessionCap,
|
|
401
|
+
...(opts.includeSession ? { storeDir: opts.storeDir, ancestorSessions: ancestors } : {}),
|
|
402
|
+
...(topics !== undefined ? { relevanceTopics: topics } : {}),
|
|
403
|
+
...(walk.truncated ? { lineageTruncated: true } : {}),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The `turn/end` listener (the same boundary the topic signatures collect on —
|
|
408
|
+
* §7.5: this extends the existing door, it is not a sixth archive moment).
|
|
409
|
+
*
|
|
410
|
+
* Never-break containment: every failure lands in `warn`, because a rule that
|
|
411
|
+
* could not be written must not cost a user their turn.
|
|
412
|
+
*/
|
|
413
|
+
export function makeRuleCaptureListener(deps) {
|
|
414
|
+
const chains = new Map();
|
|
415
|
+
return (session, event) => {
|
|
416
|
+
try {
|
|
417
|
+
if (!deps.enabled())
|
|
418
|
+
return;
|
|
419
|
+
if (event?.type !== 'turn/end')
|
|
420
|
+
return;
|
|
421
|
+
const id = session?.id;
|
|
422
|
+
const events = session?.snapshotEvents?.();
|
|
423
|
+
const cwd = session?.header?.cwd;
|
|
424
|
+
const endSeq = event.seq;
|
|
425
|
+
if (id === undefined || events === undefined || cwd === undefined || endSeq === undefined)
|
|
426
|
+
return;
|
|
427
|
+
const span = turnSpanOf(events, endSeq);
|
|
428
|
+
if (span.length === 0)
|
|
429
|
+
return;
|
|
430
|
+
// cheap pre-screen OUTSIDE the async chain: no marker in this turn is the
|
|
431
|
+
// overwhelmingly common case, and it must cost one regex over one message.
|
|
432
|
+
if (humanRuleMarkers(span, deps.prefix()).length === 0)
|
|
433
|
+
return;
|
|
434
|
+
const job = () => deps.store()
|
|
435
|
+
.then(({ store }) => captureSessionRules({
|
|
436
|
+
storeDir: deps.storeRoot(cwd),
|
|
437
|
+
harnessId: deps.harnessId(),
|
|
438
|
+
sessionId: id,
|
|
439
|
+
records: store.rules(),
|
|
440
|
+
prefix: deps.prefix(),
|
|
441
|
+
events: span,
|
|
442
|
+
}))
|
|
443
|
+
.then((res) => { if (res.written.length > 0)
|
|
444
|
+
deps.onCaptured?.(id, res.written); })
|
|
445
|
+
.catch(deps.warn);
|
|
446
|
+
const prev = chains.get(id);
|
|
447
|
+
void (prev === undefined ? job() : prev.then(job)).finally(() => chains.delete(id));
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
deps.warn(error);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
//# sourceMappingURL=session-rules.js.map
|