pi-longhaul 1.0.2 → 1.0.3

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/CHANGELOG.md CHANGED
@@ -8,6 +8,38 @@ entries and log event types. Rendered state text, prompt wording and section lay
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [v1.0.3] - 2026-10-22
12
+
13
+ ### Changed
14
+
15
+ - Tested with pi 1.3.0, now recorded in `pi-compat.json`. No code changes to the extension for it.
16
+
17
+ ### Fixed
18
+
19
+ - A span's record was lost whenever another extension wrote an entry between pi's compaction and Longhaul's span
20
+ record. pi-goal does this at every compaction (its `goal-state`, sometimes a `goal-contract` message), active
21
+ goal or not, so in a session with pi-goal installed most spans folded as narrative-less: the next state kept
22
+ only the span just closed, and every earlier span's work-log line, Now, decisions, dead ends, objective moves and
23
+ labelled rules were gone from it. The fold now passes over other extensions' entries until our record arrives; a
24
+ message, a compaction or one of Longhaul's own event entries arriving first still means the record never
25
+ landed. The records were always written, so an affected transcript gets its history back at its next
26
+ compaction. While its records were lost, each pass was shown earlier messages again and recorded their rules
27
+ again under new ids; with the records back, a rule recorded from the same message in near-duplicate words is
28
+ one rule, under its newest id, and a lift of any copy lifts it. A rule the user withdrew while its record was
29
+ lost could not be lifted then (the summarizer did not see it) and comes back now, so look over the
30
+ constraints the first state after the upgrade shows.
31
+ - Spans whose records were written while earlier records were being lost can carry the same event seqs as an
32
+ earlier span (each pass numbered from a fold that had lost events). The fold now treats a seq as unique within
33
+ its span, so those spans fold too; a seq repeated inside one span is still refused as a duplicate. A correction
34
+ `longhaul_recall` shows as lifted is now the one the lift landed on, never another span's event with the same
35
+ seq.
36
+ - A replayed event whose span or seq is not a whole number from 1 is refused as malformed. A seq of `1e999`
37
+ (Infinity) used to be accepted and broke the numbering of every event after it: a `longhaul_remember` write
38
+ was then stored without a seq and dropped, with no anomaly, when the transcript was read back.
39
+ - The load smoke (`npm run load-smoke`, a development check that is not part of the package) resolved a
40
+ wildcard `exports` target by replacing only its first `*`; it now replaces every one, as Node does (CodeQL
41
+ `js/incomplete-sanitization`). No pi package uses a target with two, so the smoke's results are unchanged.
42
+
11
43
  ## [v1.0.2] - 2026-10-13
12
44
 
13
45
  ### Changed
package/README.md CHANGED
@@ -361,9 +361,9 @@ listed in [pi-compat.json](pi-compat.json). On a listed line it runs silently. O
361
361
  per session and runs; a session that opted out of it (`PI_COMPACTOR=pi`) is not warned. If a compaction fails, pi's own compaction takes over. A nightly job tests against the
362
362
  newest pi and opens an issue when it breaks.
363
363
 
364
- Tested with pi 0.87.1, 0.99.0, 0.99.1, 0.99.2, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.1.0 and 1.2.0. Upgrading or
365
- downgrading pi mid-project is covered too: CI hands one transcript through every listed pi, oldest to newest and
366
- back, with a Longhaul compaction by each, and each must continue what the others wrote.
364
+ Tested with pi 0.87.1, 0.99.0, 0.99.1, 0.99.2, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.1.0, 1.2.0 and 1.3.0.
365
+ Upgrading or downgrading pi mid-project is covered too: CI hands one transcript through every listed pi, oldest to
366
+ newest and back, with a Longhaul compaction by each, and each must continue what the others wrote.
367
367
 
368
368
  ## Known issues
369
369
 
package/docs/security.md CHANGED
@@ -39,6 +39,11 @@ extension.
39
39
  reading it into memory.
40
40
  - A timestamp a `Date` cannot hold is dropped rather than thrown on.
41
41
  - An id held by two entries (an old transcript can carry a re-minted id) resolves to the newest holder.
42
+ - A rule recorded again from the message that gave it (near-duplicate words) folds into that rule; a copy of a
43
+ lifted rule stays lifted, so restored span records cannot bring back a rule the user withdrew from a copy. A lift
44
+ lands on the rule holding its id now, and on one that held it before only when none does: the store judges the
45
+ lift against that same rule, and `longhaul_recall` marks lifted what the state lifted.
46
+ - A span or seq that is not a whole number from 1 (`1e999` parses as Infinity) makes the event malformed.
42
47
  - An entry from a newer state format is refused by name, so a downgrade starts clean rather than misreading.
43
48
  - Learned values read back from a transcript are checked again (length, printable ASCII, count). A name is
44
49
  checked as when it was given and dropped if it fails, and a named value must still have a value's shape.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-longhaul",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Keep pi sessions working all night. A compactor that carries your session forward instead of rewriting it: your rules kept in your own words, the state written by code, old reasoning left behind, and about 5x faster than pi's own compaction on a local model.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "extensions": [
33
33
  "./src/index.ts"
34
34
  ],
35
- "image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.0.2/.github/assets/infographic.png"
35
+ "image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.0.3/.github/assets/infographic.png"
36
36
  },
37
37
  "files": [
38
38
  "src/**/*.ts",
@@ -75,10 +75,10 @@
75
75
  "typebox": "*"
76
76
  },
77
77
  "devDependencies": {
78
- "@earendil-works/pi-coding-agent": "1.2.0",
79
- "@types/node": "^24.19.1",
78
+ "@earendil-works/pi-coding-agent": "1.3.0",
79
+ "@types/node": "^24.19.2",
80
80
  "typebox": "1.3.7",
81
- "typescript": "5.9.3"
81
+ "typescript": "7.0.2"
82
82
  },
83
83
  "author": "Justin Barlow"
84
84
  }
package/pi-compat.json CHANGED
@@ -93,6 +93,15 @@
93
93
  "npm run smoke and npm run cross-version -- --extra 1.2.0: every listed pi continues 1.2.0's transcripts and it continues theirs",
94
94
  "one real compaction through pi --mode rpc on the setup-check fixture with a local model: a compaction record, no fallback"
95
95
  ]
96
+ },
97
+ {
98
+ "pi": "1.3.0",
99
+ "date": "2026-10-16",
100
+ "how": [
101
+ "npm test on the vendored pi (the version gate skipped): typecheck, unit suite, load smoke and the extension-API contract checks",
102
+ "npm run smoke and npm run cross-version -- --extra 1.3.0: every listed pi continues 1.3.0's transcripts and it continues theirs",
103
+ "one real compaction through pi --mode rpc on the setup-check fixture with a local model: a compaction record, no fallback"
104
+ ]
96
105
  }
97
106
  ]
98
107
  }
@@ -229,10 +229,13 @@ function isEventBody(event: Rec): boolean {
229
229
  }
230
230
  }
231
231
 
232
+ /** A span or seq: a whole number from 1, so a replayed `1e999` (Infinity) cannot stall the numbering after it. */
233
+ const isCount = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 1;
234
+
232
235
  /** Shape check for replayed entries: a malformed one is skipped and counted by the fold, never thrown. */
233
236
  export function isStateEvent(value: unknown): value is StateEvent {
234
237
  if (!isRec(value)) return false;
235
- const provenanceOk = (value.source === "remember" || value.source === "diff" || value.source === "pass") && typeof value.span === "number" && typeof value.at === "string" && typeof value.seq === "number";
238
+ const provenanceOk = (value.source === "remember" || value.source === "diff" || value.source === "pass") && isCount(value.span) && typeof value.at === "string" && isCount(value.seq);
236
239
  return provenanceOk && isEventBody(value);
237
240
  }
238
241
 
package/src/store/log.ts CHANGED
@@ -39,6 +39,7 @@ interface Fold {
39
39
  events: StateEvent[];
40
40
  spans: SpanSummary[];
41
41
  anomalies: FoldAnomaly[];
42
+ /** `span:seq` of every admitted event: a seq is unique within its span (see seqKey). */
42
43
  seqs: Set<string>;
43
44
  /** Loose events collected since the last compaction entry, keyed by nothing: in branch order. */
44
45
  loose: StateEvent[];
@@ -54,6 +55,15 @@ function quoteOf(event: StateEvent): Quote[] {
54
55
  }
55
56
  }
56
57
 
58
+ /**
59
+ * The duplicate key of an event: its seq within its span. A pass numbers from the fold it reads, and a fold that
60
+ * lost span records (another extension's entry ahead of one) numbered later spans from too low a seq, so two
61
+ * spans' records can share seqs; each span's events are still its own.
62
+ */
63
+ function seqKey(event: { span: number; seq: number }): string {
64
+ return `${event.span}:${event.seq}`;
65
+ }
66
+
57
67
  /** Admit one event into the fold, or record why not. */
58
68
  function admit(fold: Fold, raw: unknown, span: number, entryId: string, checker: QuoteChecker, into: StateEvent[]): void {
59
69
  const newer = newerStateFormat(raw);
@@ -73,7 +83,7 @@ function admit(fold: Fold, raw: unknown, span: number, entryId: string, checker:
73
83
  fold.anomalies.push({ span, entryId, reason: `event names span ${raw.span} while folding span ${span}: ${entryId}` });
74
84
  return;
75
85
  }
76
- const key = String(raw.seq);
86
+ const key = seqKey(raw);
77
87
  if (fold.seqs.has(key)) {
78
88
  fold.anomalies.push({ span, entryId, reason: `duplicate seq: ${raw.seq} at ${entryId}` });
79
89
  return;
@@ -131,7 +141,7 @@ function takeRecord(fold: Fold, entry: Extract<SessionEntry, { type: "custom" }>
131
141
  }
132
142
  // The record SUPERSEDES the span's loose events, so release their seqs before admitting its own
133
143
  // (otherwise the record's authoritative copy of a loose event is dropped as a duplicate seq).
134
- for (const loose of fold.loose) fold.seqs.delete(String(loose.seq));
144
+ for (const loose of fold.loose) fold.seqs.delete(seqKey(loose));
135
145
  const events: StateEvent[] = [];
136
146
  for (const raw of record.events) admit(fold, raw, pending.span, entry.id, checker, events);
137
147
  for (const event of events) fold.events.push(event);
@@ -140,13 +150,23 @@ function takeRecord(fold: Fold, entry: Extract<SessionEntry, { type: "custom" }>
140
150
  fold.pending = undefined;
141
151
  }
142
152
 
153
+ /**
154
+ * Whether `entry` proves a pending compaction's span record never landed: a message, another compaction, or one
155
+ * of our own event entries. Anything else another extension wrote at session_compact (pi-goal's goal-state and
156
+ * goal-contract land there, ahead of the record) is passed over, so the record that follows still closes the span.
157
+ */
158
+ function endsPendingSpan(entry: SessionEntry): boolean {
159
+ if (entry.type === "message" || entry.type === "compaction") return true;
160
+ return entry.type === "custom" && entry.customType === EVENT_ENTRY_TYPE;
161
+ }
162
+
143
163
  export function readLog(branchEntries: SessionEntry[], checker: QuoteChecker): StateLog {
144
164
  const fold: Fold = { events: [], spans: [], anomalies: [], seqs: new Set(), loose: [] };
145
165
  let compactions = 0;
146
166
  for (const entry of branchEntries) {
147
- // A pending state compaction is closed by its span record, which is the NEXT entry of interest;
148
- // anything else arriving first means the record never landed.
149
- if (fold.pending && !(entry.type === "custom" && entry.customType === SPAN_ENTRY_TYPE)) synthesizeClose(fold, undefined, entry.timestamp);
167
+ // A pending state compaction is closed by its span record, the next entry of OURS after it; a message,
168
+ // compaction or event arriving first means the record never landed.
169
+ if (fold.pending && endsPendingSpan(entry)) synthesizeClose(fold, undefined, entry.timestamp);
150
170
  if (entry.type === "compaction") {
151
171
  compactions++;
152
172
  const compactor = compactorOfEntry(entry);
@@ -6,6 +6,7 @@
6
6
  * Retirement is a projection rule: the log never deletes, and recall reads the log.
7
7
  */
8
8
  import type { Caps, ForeignCompactor, Now, Quote, Source, StateEvent, StateLog, World } from "./events.ts";
9
+ import { DEFAULT_NEAR_DUPLICATE_THRESHOLD, overlap, tokenize } from "./near-duplicate.ts";
9
10
  import { oneLine } from "../one-line.ts";
10
11
 
11
12
  export interface CorrectionItem {
@@ -17,6 +18,10 @@ export interface CorrectionItem {
17
18
  seq: number;
18
19
  liftedBy?: { messageId: string; ordinal: string; span: number };
19
20
  retired: boolean;
21
+ /** Ids this rule held before being recorded again from its message (addCorrection); see liftTarget. */
22
+ formerIds?: Set<string>;
23
+ /** `span:seq` of the earlier correction.add events folded into this rule, for recall (addCorrection). */
24
+ copyKeys?: string[];
20
25
  }
21
26
 
22
27
  export interface TextItem {
@@ -108,6 +113,70 @@ function applyClose(state: SessionState, event: Extract<StateEvent, { kind: "spa
108
113
  consolidateWorkLog(state, caps);
109
114
  }
110
115
 
116
+ /**
117
+ * A rule recorded again from the message that gave it is the same rule: passes that lost their fold's span records
118
+ * were shown the message again and recorded it again under a new id, sometimes in other words, so a restored log
119
+ * holds one copy per such span. The rule takes the newest copy's id, span and place, and keeps its older ids for a lift
120
+ * naming one; a lifted rule stays lifted, as verify.ts keeps it for a new pass.
121
+ */
122
+ /**
123
+ * The newest standing or lifted rule `quote` repeats: one from the same message whose words are a near-duplicate
124
+ * (near-duplicate.ts, at verify's default threshold; two rules one message gave passed that check against each
125
+ * other when recorded). A rule retired by the cap is not matched, so a later copy brings it back.
126
+ */
127
+ function sameRuleIndex(corrections: CorrectionItem[], quote: Quote): number {
128
+ for (let index = corrections.length - 1; index >= 0; index--) {
129
+ const item = corrections[index];
130
+ if (item.retired && !item.liftedBy) continue;
131
+ if (isSameRule(item.quote, quote)) return index;
132
+ }
133
+ return -1;
134
+ }
135
+
136
+ /** Whether two recorded rules are one: the same message, in the same or near-duplicate words. */
137
+ export function isSameRule(a: Quote, b: Quote): boolean {
138
+ if (a.messageId !== b.messageId) return false;
139
+ return a.text === b.text || overlap(tokenize(a.text), tokenize(b.text)) >= DEFAULT_NEAR_DUPLICATE_THRESHOLD;
140
+ }
141
+
142
+ /** An event's key in the log: its seq is unique within its span (log.ts seqKey). */
143
+ export const eventKey = (event: { span: number; seq: number }): string => `${event.span}:${event.seq}`;
144
+
145
+ /**
146
+ * The rule a lift naming `target` lands on, for verify.ts, which judges the lift against it, and for the
147
+ * projection, which applies it, so the two never differ: the newest holder of the id (an old transcript can carry
148
+ * a reminted one, security.md T1), and only when no rule holds it now, the newest that held it before being
149
+ * recorded again. A former id another rule holds now names that rule: the lifting pass saw only that one.
150
+ */
151
+ export function liftTarget(corrections: CorrectionItem[], target: string): CorrectionItem | undefined {
152
+ return newestWhere(corrections, (item) => item.id === target) ?? newestWhere(corrections, (item) => item.formerIds?.has(target) ?? false);
153
+ }
154
+
155
+ function newestWhere(corrections: CorrectionItem[], match: (item: CorrectionItem) => boolean): CorrectionItem | undefined {
156
+ for (let index = corrections.length - 1; index >= 0; index--) if (match(corrections[index])) return corrections[index];
157
+ return undefined;
158
+ }
159
+
160
+ function addCorrection(state: SessionState, event: Extract<StateEvent, { kind: "correction.add" }>, caps: Caps): void {
161
+ state.cursor.R = Math.max(state.cursor.R, idNumber(event.id));
162
+ const index = sameRuleIndex(state.corrections, event.quote);
163
+ const earlier = index < 0 ? undefined : state.corrections[index];
164
+ if (earlier?.liftedBy) {
165
+ (earlier.copyKeys ??= []).push(eventKey(event));
166
+ return;
167
+ }
168
+ // The new item takes over the earlier one's lists, grown in place: copying them per copy would make N copies of
169
+ // one rule cost N² (security.md T4).
170
+ if (earlier) state.corrections.splice(index, 1);
171
+ const formerIds = earlier?.formerIds ?? new Set<string>();
172
+ if (earlier) formerIds.add(earlier.id);
173
+ formerIds.delete(event.id);
174
+ const copyKeys = earlier?.copyKeys ?? [];
175
+ if (earlier) copyKeys.push(eventKey(earlier));
176
+ state.corrections.push({ id: event.id, quote: event.quote, span: event.span, source: event.source, evidence: event.evidence, seq: event.seq, retired: false, ...(formerIds.size ? { formerIds } : {}), ...(copyKeys.length ? { copyKeys } : {}) });
177
+ state.retired.corrections += retireOverCap(state.corrections, caps.corrections);
178
+ }
179
+
111
180
  export function applyEvent(previous: SessionState, event: StateEvent, caps: Caps): SessionState {
112
181
  const state: SessionState = structuredClone(previous);
113
182
  applyInPlace(state, event, caps);
@@ -116,7 +185,7 @@ export function applyEvent(previous: SessionState, event: StateEvent, caps: Caps
116
185
 
117
186
  /** Fold one event into `state`, mutating it. The new state can share objects with the event. */
118
187
  function applyInPlace(state: SessionState, event: StateEvent, caps: Caps): void {
119
- state.seq = event.seq;
188
+ state.seq = Math.max(state.seq, event.seq);
120
189
  switch (event.kind) {
121
190
  case "goal.set":
122
191
  state.goal = { quote: event.quote, setSpan: event.span };
@@ -131,15 +200,12 @@ function applyInPlace(state: SessionState, event: StateEvent, caps: Caps): void
131
200
  if (state.objective) state.objective.doneWhen = event.quote;
132
201
  break;
133
202
  case "correction.add":
134
- state.corrections.push({ id: event.id, quote: event.quote, span: event.span, source: event.source, evidence: event.evidence, seq: event.seq, retired: false });
135
- state.cursor.R = Math.max(state.cursor.R, idNumber(event.id));
136
- state.retired.corrections += retireOverCap(state.corrections, caps.corrections);
203
+ addCorrection(state, event, caps);
137
204
  break;
138
205
  case "correction.lift": {
139
- // Newest first: older transcripts can hold two corrections with one id (a foreign close dropped
140
- // the first, the cursor fell back and reminted it; the fold now keeps both). A lift recorded then could only
141
- // see the newer one, so the newest holder is the target (security.md T1).
142
- const target = [...state.corrections].reverse().find((item) => item.id === event.target);
206
+ // Older transcripts can hold two corrections with one id (a foreign close dropped the first, the cursor fell
207
+ // back and reminted it; the fold keeps both): liftTarget picks the one the lifting pass saw.
208
+ const target = liftTarget(state.corrections, event.target);
143
209
  if (target) target.liftedBy = { ...event.by, span: event.span };
144
210
  break;
145
211
  }
@@ -7,6 +7,7 @@ import type { CommandRecord, ErrorRecord } from "../ledger/handoff.ts";
7
7
  import type { Caps, StateEvent, StateLog } from "./events.ts";
8
8
  import { orderedFiles } from "../files-render.ts";
9
9
  import { oneLine as sanitize } from "../one-line.ts";
10
+ import { eventKey, project } from "./project.ts";
10
11
 
11
12
  export type RecallWhat = "span" | "commands" | "errors" | "decisions" | "dead-ends" | "corrections" | "message" | "files" | "all";
12
13
 
@@ -39,28 +40,31 @@ export interface RecallHit {
39
40
  export const RECALL_DEFAULT_LIMIT = 20;
40
41
 
41
42
 
43
+ /** One projection per recall call, however many kinds it lists (`all`). */
44
+ const liftedKeysCache = new WeakMap<RecallDeps, Set<string>>();
45
+
42
46
  /**
43
- * The seqs of the corrections a lift landed on, over the WHOLE log (a lift can sit in a later span than its rule).
44
- * Same rule as the projection: the newest earlier holder of the id, because an older transcript can hold two
45
- * corrections with one id (security.md T1).
47
+ * `span:seq` of every correction.add the projection lifted, the copies folded into a lifted rule included, so recall
48
+ * shows a correction as lifted exactly when the state does (project.ts liftTarget, addCorrection).
46
49
  */
47
- function liftedSeqs(events: StateEvent[]): Set<number> {
48
- const seen: Array<{ id: string; seq: number }> = [];
49
- const lifted = new Set<number>();
50
- for (const event of events) {
51
- if (event.kind === "correction.add") seen.push({ id: event.id, seq: event.seq });
52
- if (event.kind !== "correction.lift") continue;
53
- const target = [...seen].reverse().find((item) => item.id === event.target);
54
- if (target) lifted.add(target.seq);
50
+ function liftedKeys(deps: RecallDeps): Set<string> {
51
+ const cached = liftedKeysCache.get(deps);
52
+ if (cached) return cached;
53
+ const keys = new Set<string>();
54
+ liftedKeysCache.set(deps, keys);
55
+ for (const item of project(deps.log, deps.caps).corrections) {
56
+ if (!item.liftedBy) continue;
57
+ keys.add(eventKey(item));
58
+ for (const key of item.copyKeys ?? []) keys.add(key);
55
59
  }
56
- return lifted;
60
+ return keys;
57
61
  }
58
62
 
59
- function itemHits(events: StateEvent[], kind: "decisions" | "dead-ends" | "corrections", lifted: Set<number>): RecallHit[] {
63
+ function itemHits(events: StateEvent[], kind: "decisions" | "dead-ends" | "corrections", lifted: Set<string>): RecallHit[] {
60
64
  return events.flatMap((event): RecallHit[] => {
61
65
  if (kind === "decisions" && event.kind === "decision.add") return [{ kind, ref: event.id, span: event.span, text: `${event.id} ${sanitize(event.text)}${event.evidence ? ` (${event.evidence})` : ""}` }];
62
66
  if (kind === "dead-ends" && event.kind === "deadEnd.add") return [{ kind, ref: event.id, span: event.span, text: `${event.id} ${sanitize(event.text)}${event.evidence ? ` (${event.evidence})` : ""}` }];
63
- if (kind === "corrections" && event.kind === "correction.add") return [{ kind, ref: event.id, span: event.span, text: `${event.id} [${event.quote.ordinal}] ${sanitize(event.quote.text)}`, lifted: lifted.has(event.seq) }];
67
+ if (kind === "corrections" && event.kind === "correction.add") return [{ kind, ref: event.id, span: event.span, text: `${event.id} [${event.quote.ordinal}] ${sanitize(event.quote.text)}`, lifted: lifted.has(eventKey(event)) }];
64
68
  return [];
65
69
  });
66
70
  }
@@ -102,7 +106,7 @@ function spanHits(deps: RecallDeps, span: number): RecallHit[] {
102
106
  const record = deps.log.spans.find((summary) => summary.span === span)?.record;
103
107
  const commands = (record ? record.ledger.commands.map((c) => ({ ...c, span })) : allCommands(deps).filter((c) => c.span === span)).map(commandHit);
104
108
  const errors = (record ? record.ledger.errors.map((e) => ({ ...e, span })) : allErrors(deps).filter((e) => e.span === span)).map(errorHit);
105
- const lifted = liftedSeqs(deps.log.events);
109
+ const lifted = liftedKeys(deps);
106
110
  return [head, ...commands, ...errors, ...itemHits(events, "corrections", lifted), ...itemHits(events, "decisions", lifted), ...itemHits(events, "dead-ends", lifted)];
107
111
  }
108
112
 
@@ -117,7 +121,7 @@ function hitsFor(what: RecallWhat, deps: RecallDeps, query: string | undefined):
117
121
  case "span": return query && /^\d+$/.test(query) ? spanHits(deps, Number(query)) : spanHeads(deps);
118
122
  case "commands": return allCommands(deps).map(commandHit);
119
123
  case "errors": return allErrors(deps).map(errorHit);
120
- case "decisions": case "dead-ends": case "corrections": return itemHits(deps.log.events, what, liftedSeqs(deps.log.events));
124
+ case "decisions": case "dead-ends": case "corrections": return itemHits(deps.log.events, what, liftedKeys(deps));
121
125
  case "message": {
122
126
  const text = query ? deps.messageText(query) : undefined;
123
127
  return text === undefined ? [] : [{ kind: "message", ref: query!, text: sanitize(text) }];
@@ -10,7 +10,7 @@ import { ownRuleOf, withoutReplacedWords } from "./lift-replacement.ts";
10
10
  import { liftNamesRule } from "./lift-words.ts";
11
11
  import type { Caps, Now, Provenance, Quote, Rejection, StateEvent } from "./events.ts";
12
12
  import { findNearDuplicate } from "./near-duplicate.ts";
13
- import { activeCorrections, activeDeadEnds, activeDecisions, applyEvent, type SessionState } from "./project.ts";
13
+ import { activeCorrections, activeDeadEnds, activeDecisions, applyEvent, liftTarget, type SessionState } from "./project.ts";
14
14
 
15
15
  export type Proposal =
16
16
  | { kind: "objective"; ref?: string; text: string; doneWhen?: { ref?: string; text: string } }
@@ -110,10 +110,10 @@ function verifyQuoteKind(proposal: Extract<Proposal, { kind: "objective" | "done
110
110
  return stamp(ctx, undefined, { kind: "correction.add", id: `R${ctx.state.cursor.R + 1}`, quote: resolved.quote });
111
111
  }
112
112
  case "lift": {
113
- // Newest holder of the id, as in project.ts: an old transcript can carry a reminted id (security.md T1). A target
113
+ // The rule the projection will lift (project.ts liftTarget): an old transcript can carry a reminted id. A target
114
114
  // written u<n> names the rule by the message that gave it: a rule labeled in this same pass has no id the model saw.
115
115
  const byOrdinal = /^u\d+$/.test(proposal.target);
116
- const named = [...ctx.state.corrections].reverse().find((item) => (byOrdinal ? item.quote.ordinal === proposal.target && !item.retired : item.id === proposal.target));
116
+ const named = byOrdinal ? [...ctx.state.corrections].reverse().find((item) => item.quote.ordinal === proposal.target && !item.retired) : liftTarget(ctx.state.corrections, proposal.target);
117
117
  if (!named) return reject({ code: "unknown-target", target: proposal.target, known: activeCorrections(ctx.state).map((item) => item.id) });
118
118
  const target = named;
119
119
  if (target.liftedBy) {