pi-continual-harness 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -165,7 +165,7 @@ preset menu, or a declined final write; declining an individual feature only
165
165
  skips that feature (the accepted deltas still apply). The wizard never
166
166
  touches harness state, only the config file.
167
167
 
168
- Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults):
168
+ Optional config at `~/.pi/agent/harness.json` (missing → defaults; malformed → defaults plus a once-per-session warning, so a typo can't silently reset your settings):
169
169
 
170
170
  ```json
171
171
  {
@@ -473,6 +473,17 @@ registerProposer({
473
473
  Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
474
474
  `"proposer": "my-proposer"` in the config.
475
475
 
476
+ **Cross-instance registry (0.17+).** pi's extension loader isolates module
477
+ instances between extensions, so before 0.17.0 a companion's `registerProposer`
478
+ call wrote to a private module copy that `/refine` never read — the
479
+ registration was silently invisible. The registry is now backed by a
480
+ process-wide global (`Symbol.for("pi-continual-harness.proposers")`), so any
481
+ copy's registrations are visible to every copy. Companions that want a
482
+ zero-import startup entry can skip importing this package at runtime entirely
483
+ and push a `DeltaProposer` (typically a lazy delegate that dynamic-imports its
484
+ implementation on first `propose()`) directly into
485
+ `globalThis[Symbol.for("pi-continual-harness.proposers")]`.
486
+
476
487
  ## Scope and non-goals
477
488
 
478
489
  - **In scope:** unified state store, online `/refine`, structured deltas,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Online self-improvement layer for pi. A unified, in-trajectory harness-state store (prompt notes, memory, skill descriptions, sub-agent specs) with a manual /refine that proposes evidence-backed structured CRUD deltas. Composes with pi-reflect (offline) and pi-mem (storage); owns only the online optimizer layer. Inspired by Continual Harness (arXiv 2605.09998) and ACE (arXiv 2510.04618).",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -39,25 +39,18 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
39
  import { DEFAULT_AUTO_EVERY_TURNS, type HarnessConfig, loadConfig } from "./config.js";
40
40
  import { runRefine, gatherEvidence, DEFAULT_LOOKBACK_TURNS } from "./refine.js";
41
41
  import { buildClassify, buildGateRequest, gateDecision } from "./classify.js";
42
+ import { makeTurnCadence } from "./cadence.js";
42
43
 
43
- // Completed turns observed since the last fire (or the seeded baseline).
44
- // -1 = unseen. Aborted/error turns never touch this count — the window is
45
- // completed turns of work, not raw turn indices.
46
- let completedTurns = -1;
44
+ // turnEndedBadly's canonical home is cadence.ts (shared completed-turn
45
+ // semantics); re-exported here so existing imports keep working.
46
+ export { turnEndedBadly } from "./cadence.js";
47
+
48
+ // The auto-refine cadence counter (shared implementation — see cadence.ts).
49
+ const cadence = makeTurnCadence();
47
50
 
48
51
  /** Test hook: reset internal counter. */
49
52
  export function resetAutoRefine(): void {
50
- completedTurns = -1;
51
- }
52
-
53
- /**
54
- * True when a turn_end's outcome marks a turn that ended badly (pi's
55
- * BoundaryState.outcome: "aborted" | "error"). Undefined is treated as
56
- * completed — current pi always reports the field, but synthetic events and
57
- * the earliest 0.99.x may omit it.
58
- */
59
- export function turnEndedBadly(outcome?: string): boolean {
60
- return outcome === "aborted" || outcome === "error";
53
+ cadence.reset();
61
54
  }
62
55
 
63
56
  /**
@@ -71,19 +64,8 @@ export function turnEndedBadly(outcome?: string): boolean {
71
64
  */
72
65
  export function evaluateAutoRefine(config: HarnessConfig, outcome?: string): boolean {
73
66
  if (!config.autoRefine?.enabled) return false;
74
- if (turnEndedBadly(outcome)) return false;
75
67
  const every = config.autoRefine.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
76
- if (every <= 0) return false;
77
- if (completedTurns < 0) {
78
- completedTurns = 0; // seed: the seed turn itself doesn't count
79
- return false;
80
- }
81
- completedTurns += 1;
82
- if (completedTurns >= every) {
83
- completedTurns = 0;
84
- return true;
85
- }
86
- return false;
68
+ return cadence.tick(every, outcome);
87
69
  }
88
70
 
89
71
  /** The boundary draft custom type for auto-refine steering messages. */
package/src/cadence.ts ADDED
@@ -0,0 +1,56 @@
1
+ // Shared completed-turn cadence counter (review simplification #1).
2
+ //
3
+ // Three features — auto-refine, the /refine reminder, and the import-awareness
4
+ // reminder — used to each re-implement the same side-effectful counter: seed
5
+ // on the first observed COMPLETED turn, count completed turns, fire and reset
6
+ // every N. They drifted once already (review #26's turnIndex → turn_end fix
7
+ // had to touch several of them), so the counter now lives here exactly once.
8
+ //
9
+ // Semantics (identical for every consumer):
10
+ // - Aborted/error turns are inert: they neither seed nor advance the count,
11
+ // so an aborted turn in between can never shorten the window.
12
+ // - The seed turn itself does not count.
13
+ // - every <= 0 disables the cadence (tick never fires).
14
+ // - Firing resets the count, so the firing turn itself never re-triggers.
15
+
16
+ /** True when a turn_end's outcome marks a turn that ended badly (pi's
17
+ * BoundaryState.outcome: "aborted" | "error"). Undefined is treated as
18
+ * completed — current pi always reports the field, but synthetic events and
19
+ * the earliest 0.99.x may omit it. */
20
+ export function turnEndedBadly(outcome?: string): boolean {
21
+ return outcome === "aborted" || outcome === "error";
22
+ }
23
+
24
+ export interface TurnCadence {
25
+ /** Observe a turn boundary; true when the cadence fires this turn.
26
+ * Aborted/error outcomes and every <= 0 are inert (state untouched). */
27
+ tick(every: number, outcome?: string): boolean;
28
+ /** Back to the unseen baseline (session_start: a fork/resume opens a fresh window). */
29
+ reset(): void;
30
+ }
31
+
32
+ /** One independent cadence counter. Feature gates stay with the caller. */
33
+ export function makeTurnCadence(): TurnCadence {
34
+ // Completed turns observed since the last fire (or the seeded baseline).
35
+ // -1 = unseen.
36
+ let completedTurns = -1;
37
+ return {
38
+ tick(every, outcome) {
39
+ if (turnEndedBadly(outcome)) return false;
40
+ if (every <= 0) return false;
41
+ if (completedTurns < 0) {
42
+ completedTurns = 0; // seed: the seed turn itself doesn't count
43
+ return false;
44
+ }
45
+ completedTurns += 1;
46
+ if (completedTurns >= every) {
47
+ completedTurns = 0;
48
+ return true;
49
+ }
50
+ return false;
51
+ },
52
+ reset() {
53
+ completedTurns = -1;
54
+ },
55
+ };
56
+ }
package/src/config.ts CHANGED
@@ -131,7 +131,12 @@ export const DEFAULT_CONFIG: HarnessConfig = {
131
131
 
132
132
  export const CONFIG_PATH = join(homedir(), ".pi", "agent", "harness.json");
133
133
 
134
+ // Single process-lifetime cache (config is read once per session; tests warm
135
+ // it with an injected path and every later caller shares it — by contract).
134
136
  let cached: HarnessConfig | undefined;
137
+ // Path whose JSON existed but failed to parse on the last load — the
138
+ // effective config silently fell back to defaults there (review finding #6).
139
+ let malformedPath: string | undefined;
135
140
 
136
141
  /** Merge a (possibly partial) parsed file over the defaults. */
137
142
  function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
@@ -313,22 +318,44 @@ export async function saveConfig(
313
318
  return { written: path, backup, merged, effective };
314
319
  }
315
320
 
316
- /** Load the config, merged over defaults. Tolerant: missing/malformed → defaults.
317
- * Cached for the process lifetime (config is read once per session). */
321
+ /** Load the config, merged over defaults. Tolerant: a missing file → defaults
322
+ * silently (expected for a fresh setup); a MALFORMED file → defaults too, but
323
+ * flagged via configMalformed() so the session can warn instead of every
324
+ * setting silently stopping (review finding #6). Cached for the process
325
+ * lifetime (config is read once per session). */
318
326
  export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
319
327
  if (cached) return cached;
328
+ let raw: string | undefined;
329
+ try {
330
+ raw = await readFile(path, "utf8");
331
+ } catch {
332
+ // Missing/unreadable file → defaults, by design.
333
+ }
334
+ if (raw === undefined) {
335
+ cached = { ...DEFAULT_CONFIG };
336
+ return cached;
337
+ }
320
338
  try {
321
- const raw = await readFile(path, "utf8");
322
339
  cached = mergeConfig(JSON.parse(raw) as Partial<HarnessConfig>);
340
+ // Parse succeeded — clear the flag (a previous load may have failed).
341
+ malformedPath = undefined;
323
342
  } catch {
343
+ malformedPath = path;
324
344
  cached = { ...DEFAULT_CONFIG };
325
345
  }
326
346
  return cached;
327
347
  }
328
348
 
349
+ /** True when the file at `path` existed but failed to parse on the last
350
+ * loadConfig — the effective config silently fell back to defaults. */
351
+ export function configMalformed(path: string = CONFIG_PATH): boolean {
352
+ return malformedPath === path;
353
+ }
354
+
329
355
  /** Test hook: drop the in-process cache. */
330
356
  export function resetConfigCache(): void {
331
357
  cached = undefined;
358
+ malformedPath = undefined;
332
359
  }
333
360
 
334
361
  /** Path of a project's durable layer file: <projectDir>/<slug>.md. */
package/src/corpus.ts CHANGED
@@ -31,7 +31,7 @@
31
31
 
32
32
  import { createHash } from "node:crypto";
33
33
  import { IMPORTANCE_FLOOR, REFINE_ENTRY, STATE_ENTRY } from "./store.js";
34
- import { DEDUPE_THRESHOLD, tokenOverlap } from "./proposer.js";
34
+ import { DEDUPE_THRESHOLD, durableLayer, tokenOverlap } from "./proposer.js";
35
35
  import { DEFAULT_REF_BUMP } from "./config.js";
36
36
  import type { ComponentKind, HarnessItem, HarnessState } from "./types.js";
37
37
 
@@ -127,12 +127,6 @@ function hash(content: string): string {
127
127
  return `sha256:${createHash("sha256").update(content).digest("hex")}`;
128
128
  }
129
129
 
130
- /** Durable placement, mirroring planDedupe's private key: only items in the
131
- * same layer were ever compared by the planner. */
132
- function durableLayer(i: HarnessItem): string {
133
- return i.scope === "project" ? `project:${i.project ?? ""}` : "global";
134
- }
135
-
136
130
  // ---- lifecycle ---------------------------------------------------------------
137
131
 
138
132
  /** One classified lifecycle transition, with the item it happened to.
@@ -309,8 +303,14 @@ export function buildCorpus(entries: Iterable<unknown>, opts: CorpusOptions = {}
309
303
  const entry = raw as AnyEntry;
310
304
  if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data?.state) {
311
305
  const next = entry.data.state;
312
- const prev = snapshots.at(-1);
313
- if (prev) diffSnapshots(prev, next, citeBump, lifecycle);
306
+ // Implicit EMPTY baseline before the branch's first snapshot (review
307
+ // #27 fixed this in status.ts; the corpus walk was missed — review
308
+ // finding #1): a fresh session persists no empty harness-state entry,
309
+ // so the first create batch / auto-import IS the first snapshot —
310
+ // without this its `created` events were silently dropped from
311
+ // lifecycle.jsonl.
312
+ const prev = snapshots.at(-1) ?? { items: [] };
313
+ diffSnapshots(prev, next, citeBump, lifecycle);
314
314
  snapshots.push(next);
315
315
  continue;
316
316
  }
package/src/durable.ts CHANGED
@@ -28,7 +28,7 @@ import { exportDurableLayers, getVersion, importDurableLayers } from "./store.js
28
28
  import type { LayerFile, LayerPaths } from "./store.js";
29
29
  import type { HarnessItem } from "./types.js";
30
30
  import { DEFAULT_IMPORT_REMIND_TURNS, layerFilesFor, loadConfig, projectSlug, type HarnessConfig } from "./config.js";
31
- import { turnEndedBadly } from "./auto-refine.js";
31
+ import { makeTurnCadence } from "./cadence.js";
32
32
 
33
33
  /** Real (homedir-based) layer paths. Tests inject their own. */
34
34
  function layerPaths(): LayerPaths {
@@ -44,12 +44,11 @@ let lastExportedVersion = -1;
44
44
  // BEFORE any export — never clobbering the durable layers (review #26).
45
45
  let startSyncRan = false;
46
46
 
47
- // Completed turns observed since the last import reminder (or the seeded
48
- // baseline). -1 = unseen. Aborted/error turns never touch this count — the
49
- // cadence counts completed turns of work (mirrors remind.ts / #18), never raw
50
- // turn indices: pi's `turnIndex` resets to 0 on every agent_start (each user
51
- // prompt), so index deltas would never accumulate across prompts (review #26).
52
- let completedTurns = -1;
47
+ // The import-reminder cadence counter (shared implementation — see
48
+ // cadence.ts): completed turns of work, never raw turn indices — pi's
49
+ // `turnIndex` resets to 0 on every agent_start (each user prompt), so index
50
+ // deltas would never accumulate across prompts (review #26).
51
+ const importCadence = makeTurnCadence();
53
52
 
54
53
  /** A durable-imported item the user should stay aware of (id + what it says). */
55
54
  export interface ImportedRef {
@@ -75,7 +74,7 @@ export function resetDurableSync(): void {
75
74
  lastExportedVersion = -1;
76
75
  startSyncRan = false;
77
76
  importedThisSession = [];
78
- completedTurns = -1;
77
+ importCadence.reset();
79
78
  remindersFired = 0;
80
79
  }
81
80
 
@@ -154,20 +153,11 @@ export function evaluateImportReminder(
154
153
  if (!config.autoImport) return false;
155
154
  if (config.importRemind?.enabled === false) return false;
156
155
  if (importedCount <= 0) return false;
157
- if (turnEndedBadly(outcome)) return false;
158
156
  if (remindersFired >= MAX_IMPORT_REMINDERS) return false;
159
157
  const every = config.importRemind?.everyTurns ?? DEFAULT_IMPORT_REMIND_TURNS;
160
- if (completedTurns < 0) {
161
- completedTurns = 0; // seed: the seed turn itself doesn't count
162
- return false;
163
- }
164
- completedTurns += 1;
165
- if (completedTurns >= every) {
166
- completedTurns = 0;
167
- remindersFired += 1;
168
- return true;
169
- }
170
- return false;
158
+ if (!importCadence.tick(every, outcome)) return false;
159
+ remindersFired += 1;
160
+ return true;
171
161
  }
172
162
 
173
163
  /** Keep importedThisSession truthful against the live store (review #27): an
@@ -188,8 +178,9 @@ function reconcileImported(): void {
188
178
  export function registerImportReminder(pi: ExtensionAPI): void {
189
179
  pi.on("turn_end", async (event, ctx) => {
190
180
  const config = await loadConfig();
191
- // Completed-turn counting (see `completedTurns`): aborted/error outcomes
192
- // are inert and pi's per-agent-start turnIndex resets never matter.
181
+ // Completed-turn counting (the shared cadence — see cadence.ts):
182
+ // aborted/error outcomes are inert and pi's per-agent-start turnIndex
183
+ // resets never matter.
193
184
  reconcileImported(); // deleted/deactivated imports stop being advertised (review #27)
194
185
  const outcome = (event as { outcome?: string }).outcome;
195
186
  if (!evaluateImportReminder(config, outcome, importedThisSession.length)) {
package/src/harness.ts CHANGED
@@ -189,13 +189,18 @@ function completions(argumentPrefix: string) {
189
189
  );
190
190
  }
191
191
 
192
- // Flag completion ("-…" prefix, or a fresh token for flag-taking subs —
192
+ // Flag completion ("-" prefix, or a fresh token for flag-taking subs —
193
193
  // the "-" prefix also matches short flags like list's -f). A fresh
194
194
  // --kind/--scope/--model value was already completed above (early
195
195
  // return), so a fresh token here means "offer the remaining flags".
196
- if (last.startsWith("-") || (fresh && sub in FLAGS)) {
196
+ // Object.hasOwn guards BOTH the condition and the lookup (review finding
197
+ // #2): a bogus subcommand naming an inherited Object member ("constructor",
198
+ // "toString", …) otherwise resolves FLAGS[sub] to Function — the `?? []`
199
+ // does not catch it (Function is not nullish) and .filter throws. Same
200
+ // class as the expandPreset fix in review #26.
201
+ if (last.startsWith("-") || (fresh && Object.hasOwn(FLAGS, sub))) {
197
202
  const used = new Set(before.filter((t) => t.startsWith("-")));
198
- const rows = (FLAGS[sub] ?? []).filter((f) => !used.has(f.name));
203
+ const rows = (Object.hasOwn(FLAGS, sub) ? FLAGS[sub]! : []).filter((f) => !used.has(f.name));
199
204
  const items = rows
200
205
  .filter((f) => f.name.startsWith(last))
201
206
  .map((f) => ({ value: [...before, f.name].join(" "), label: f.name, description: f.description }));
@@ -564,6 +569,17 @@ async function handleMove(
564
569
  }
565
570
  }
566
571
 
572
+ /** Numbered item listing shared by the split/adopt steering prompts. The
573
+ * format had already drifted once (adopt carries full-precision importance
574
+ * because the agent copies it verbatim into create deltas — review #25;
575
+ * split rounds to 2). One renderer keeps the layout in lockstep. */
576
+ function formatItemLines(items: HarnessItem[], importance: "raw" | "fixed"): string[] {
577
+ return items.flatMap((i, n) => [
578
+ `${n + 1}. [${i.id}] (${i.kind}, importance ${importance === "fixed" ? i.importance.toFixed(2) : i.importance}, ${i.scope === "project" ? `project "${i.project ?? ""}"` : "global"}) ${i.content}`,
579
+ ` evidence: ${i.evidence}`,
580
+ ]);
581
+ }
582
+
567
583
  /** Compose the /harness split steering message: the agent classifies every
568
584
  * active item global-vs-project and applies the classification as ONE
569
585
  * harness_mutate batch of scope-only update deltas — visible in the
@@ -572,7 +588,7 @@ function buildSplitPrompt(items: HarnessItem[], slug: string, cwd: string): stri
572
588
  const lines = [
573
589
  `/harness split — classify ${items.length} Continual Harness item(s) into durable scopes`,
574
590
  "",
575
- `Current project: ${cwd} (slug \"${slug}\").`,
591
+ `Current project: ${cwd} (slug "${slug}").`,
576
592
  "",
577
593
  "For EACH item below, decide its durable scope:",
578
594
  "- `project` — only useful when working in THIS project (deploy procedures, project architecture, local conventions, project-specific quirks).",
@@ -583,13 +599,8 @@ function buildSplitPrompt(items: HarnessItem[], slug: string, cwd: string): stri
583
599
  `(the "project" slug is stamped server-side to "${slug}"; content/evidence/importance stay untouched). Give every item an explicit decision — skip nothing. If an item is project-specific only in part, choose "project" and leave generalizing it for a later /refine.`,
584
600
  "",
585
601
  "Items:",
602
+ ...formatItemLines(items, "fixed"),
586
603
  ];
587
- items.forEach((i, n) => {
588
- lines.push(
589
- `${n + 1}. [${i.id}] (${i.kind}, importance ${i.importance.toFixed(2)}, ${i.scope === "project" ? `project \"${i.project ?? ""}\"` : "global"}) ${i.content}`,
590
- ` evidence: ${i.evidence}`,
591
- );
592
- });
593
604
  return lines.join("\n");
594
605
  }
595
606
 
@@ -677,16 +688,8 @@ export function buildAdoptPrompt(
677
688
  : "- Summarize at the end: adopted N, skipped M (stays) — with one short reason per skip.",
678
689
  "",
679
690
  "Items:",
691
+ ...formatItemLines(items, "raw"),
680
692
  );
681
- items.forEach((i, n) => {
682
- // Full precision (no toFixed): the agent copies the listed importance
683
- // verbatim into the create delta, and a rounded value would change the
684
- // copy's ranking against existing items in injection selection (review #25).
685
- lines.push(
686
- `${n + 1}. [${i.id}] (${i.kind}, importance ${i.importance}, ${i.scope === "project" ? `project \"${i.project ?? ""}\"` : "global"}) ${i.content}`,
687
- ` evidence: ${i.evidence}`,
688
- );
689
- });
690
693
  return lines.join("\n");
691
694
  }
692
695
 
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ import { registerOutcome, resetOutcome } from "./outcome.js";
17
17
  import { registerAutoExport, registerImportReminder, resetDurableSync, syncDurableOnStart } from "./durable.js";
18
18
  import { getState, reconstruct, setActiveModelKey, setSessionProject, STATE_ENTRY } from "./store.js";
19
19
  import { resetHints } from "./hints.js";
20
- import { projectSlug } from "./config.js";
20
+ import { CONFIG_PATH, configMalformed, loadConfig, projectSlug } from "./config.js";
21
21
 
22
22
  export default function continualHarness(pi: ExtensionAPI): void {
23
23
  // Rebuild in-memory state from the current branch on every session start /
@@ -40,6 +40,16 @@ export default function continualHarness(pi: ExtensionAPI): void {
40
40
  // Cache the session's project slug BEFORE any tool delta can arrive: it is
41
41
  // the server-side stamp for scope:"project" (harness_mutate has no ctx).
42
42
  setSessionProject(projectSlug(ctx.cwd));
43
+ // Surface a malformed harness.json ONCE per session start: loadConfig
44
+ // falls back to defaults for it, so without this warning every configured
45
+ // setting would silently stop applying (review finding #6).
46
+ await loadConfig();
47
+ if (configMalformed()) {
48
+ ctx.ui.notify(
49
+ `Continual Harness: ${CONFIG_PATH} is not valid JSON — every setting is on its DEFAULT until the file parses.`,
50
+ "warning",
51
+ );
52
+ }
43
53
  reconstruct(ctx.sessionManager.getBranch() as Iterable<unknown>);
44
54
  const n = getState().items.length;
45
55
  if (n > 0) {
package/src/proposer.ts CHANGED
@@ -204,8 +204,10 @@ function truncate(s: string, n = 48): string {
204
204
 
205
205
  /** Durable placement of an item: "global" or "project:<slug>". Only items in
206
206
  * the SAME layer merge — a merge must never silently move an item between
207
- * durable files or projects. */
208
- function durableLayer(i: HarnessItem): string {
207
+ * durable files or projects. Exported: the corpus builder filters dedupe
208
+ * pair candidates by the same layer key (review simplification #2 — the two
209
+ * copies had to stay in sync by hand). */
210
+ export function durableLayer(i: HarnessItem): string {
209
211
  return i.scope === "project" ? `project:${i.project ?? ""}` : "global";
210
212
  }
211
213
 
@@ -432,21 +434,55 @@ export const dedupeProposer: DeltaProposer = {
432
434
 
433
435
  // ---- registry -------------------------------------------------------------
434
436
 
435
- const registry = new Map<string, DeltaProposer>([
437
+ // Cross-instance registry key (0.17.0). pi's extension loader isolates module
438
+ // instances between extensions (a fresh jiti per extension load, module cache
439
+ // off), so a companion package's `import { registerProposer } from
440
+ // "pi-continual-harness"` resolves to a DIFFERENT copy of this module than the
441
+ // one whose /refine reads the registry — a module-level Map made companion
442
+ // registrations silently invisible. Backing the registry with a process-wide
443
+ // global makes registration work no matter which copy each side holds.
444
+ //
445
+ // The string is the public contract: companions that avoid importing this
446
+ // package at all (thin startup entries) can push a DeltaProposer straight into
447
+ // `globalThis[Symbol.for("pi-continual-harness.proposers")]`.
448
+ const REGISTRY_KEY = Symbol.for("pi-continual-harness.proposers");
449
+ type ProposerRegistry = Map<string, DeltaProposer>;
450
+
451
+ function globalRegistry(): ProposerRegistry {
452
+ const g = globalThis as Record<symbol, unknown>;
453
+ let r = g[REGISTRY_KEY] as ProposerRegistry | undefined;
454
+ if (!(r instanceof Map)) {
455
+ r = new Map<string, DeltaProposer>();
456
+ g[REGISTRY_KEY] = r;
457
+ }
458
+ return r;
459
+ }
460
+
461
+ // Built-ins stay module-local: they are pure functions of their input, and
462
+ // keeping them off the shared map means two isolated harness copies never hand
463
+ // each other's closures to /refine. External registrations live ONLY in the
464
+ // shared map, so every copy can see them.
465
+ const builtins = new Map<string, DeltaProposer>([
436
466
  ["steering", steeringProposer],
437
467
  ["dedupe", dedupeProposer],
438
468
  ]);
439
469
 
440
- /** Register (or replace) a named proposer. For external extensions. */
470
+ /** Register (or replace) a named proposer. For external extensions. Writes to
471
+ * the process-wide registry (Symbol.for("pi-continual-harness.proposers")),
472
+ * so it takes effect across pi's per-extension module isolation. */
441
473
  export function registerProposer(p: DeltaProposer): void {
442
- registry.set(p.name, p);
474
+ globalRegistry().set(p.name, p);
443
475
  }
444
476
 
445
477
  /** Look up a proposer by name; falls back to the steering default. */
446
478
  export function getProposer(name: string | undefined): DeltaProposer {
447
- return registry.get(name ?? "steering") ?? steeringProposer;
479
+ const key = name ?? "steering";
480
+ const custom = globalRegistry().get(key);
481
+ // Defensive: the shared map can be written by anyone in the process.
482
+ if (custom && typeof custom.propose === "function") return custom;
483
+ return builtins.get(key) ?? steeringProposer;
448
484
  }
449
485
 
450
486
  export function listProposers(): string[] {
451
- return [...registry.keys()];
487
+ return [...new Set([...builtins.keys(), ...globalRegistry().keys()])];
452
488
  }
package/src/refine.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import type { Context, Model, TextContent } from "@earendil-works/pi-ai";
22
22
  import { applyDeltas, DEFAULT_DURABLE_PATH, exportDurableLayers, PROJECT_DURABLE_DIR, REFINE_ENTRY, snapshotState } from "./store.js";
23
- import { getProposer, DEFAULT_DEDUPE } from "./proposer.js";
23
+ import { getProposer, listProposers, DEFAULT_DEDUPE } from "./proposer.js";
24
24
  import type { CompleteOptions, CompleteResult } from "./proposer.js";
25
25
  import { buildClassify } from "./classify.js";
26
26
  import { resolveBindingKey, branchEntries } from "./vmodel.js";
@@ -58,10 +58,13 @@ export function registerRefine(pi: ExtensionAPI): void {
58
58
  description:
59
59
  "Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit] [--proposer steering|dedupe] [--threshold 0.75]",
60
60
  handler: async (args, ctx) => {
61
- const { lookback, commit, proposer, threshold, thresholdInvalid } = parseArgs(args);
61
+ const { lookback, commit, proposer, threshold, thresholdInvalid, unknownFlags } = parseArgs(args);
62
62
  if (thresholdInvalid) {
63
63
  ctx.ui.notify("Invalid --threshold ignored (expected 0 < t ≤ 1).", "warning");
64
64
  }
65
+ if (unknownFlags.length > 0) {
66
+ ctx.ui.notify(`Unknown /refine flag(s) ignored: ${unknownFlags.join(", ")}.`, "warning");
67
+ }
65
68
  await runRefine(pi, ctx, {
66
69
  lookback,
67
70
  commit,
@@ -120,6 +123,14 @@ export async function runRefine(
120
123
  const lookback = options.lookback ?? DEFAULT_LOOKBACK_TURNS;
121
124
  const commit = options.commit ?? false;
122
125
  const proposer = getProposer(options.proposer);
126
+ // An explicit but unknown --proposer must not silently run steering
127
+ // (review finding #7): name the fallback so a typo is visible.
128
+ if (options.proposer && proposer.name !== options.proposer) {
129
+ ctx.ui.notify(
130
+ `Unknown proposer "${options.proposer}" — falling back to steering. Registered: ${listProposers().join(", ")}.`,
131
+ "warning",
132
+ );
133
+ }
123
134
  ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)… [${proposer.name}]`);
124
135
 
125
136
  const evidence = gatherEvidence(ctx, lookback);
@@ -248,6 +259,7 @@ function parseArgs(args: string): {
248
259
  proposer: string | undefined;
249
260
  threshold: number | undefined;
250
261
  thresholdInvalid: boolean;
262
+ unknownFlags: string[];
251
263
  } {
252
264
  const parts = args.trim().split(/\s+/).filter(Boolean);
253
265
  let lookback = DEFAULT_LOOKBACK_TURNS;
@@ -255,6 +267,7 @@ function parseArgs(args: string): {
255
267
  let proposer: string | undefined;
256
268
  let threshold: number | undefined;
257
269
  let thresholdInvalid = false;
270
+ const unknownFlags: string[] = [];
258
271
  const readThreshold = (raw: string): void => {
259
272
  const v = Number(raw);
260
273
  if (Number.isFinite(v) && v > 0 && v <= 1) threshold = v;
@@ -268,8 +281,11 @@ function parseArgs(args: string): {
268
281
  else if (p === "--threshold" && i + 1 < parts.length) readThreshold(parts[++i]!);
269
282
  else if (p.startsWith("--threshold=")) readThreshold(p.slice("--threshold=".length));
270
283
  else if (/^\d+$/.test(p)) lookback = Math.max(1, Math.min(200, Number(p)));
284
+ // Unknown --flags are collected, not silently dropped: a typo like
285
+ // "--comit" used to vanish and run a plain refine (review finding #7).
286
+ else if (p.startsWith("--")) unknownFlags.push(p);
271
287
  }
272
- return { lookback, commit, proposer, threshold, thresholdInvalid };
288
+ return { lookback, commit, proposer, threshold, thresholdInvalid, unknownFlags };
273
289
  }
274
290
 
275
291
  export function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
package/src/remind.ts CHANGED
@@ -28,18 +28,17 @@ import {
28
28
  type HarnessConfig,
29
29
  loadConfig,
30
30
  } from "./config.js";
31
- import { turnEndedBadly } from "./auto-refine.js";
31
+ import { makeTurnCadence } from "./cadence.js";
32
32
  import { getState } from "./store.js";
33
33
 
34
- // Completed turns observed since the last reminder (or the seeded baseline).
35
- // -1 = unseen. Aborted/error turns never touch this count.
36
- let completedTurns = -1;
34
+ // The reminder cadence counter (shared implementation — see cadence.ts).
35
+ const cadence = makeTurnCadence();
37
36
  // Fired reminders this session — the rotation seed for the detailed tier.
38
37
  let fires = 0;
39
38
 
40
39
  /** Test hook: reset cadence baseline and rotation state. */
41
40
  export function resetReminder(): void {
42
- completedTurns = -1;
41
+ cadence.reset();
43
42
  fires = 0;
44
43
  }
45
44
 
@@ -89,16 +88,8 @@ export interface ReminderDecision {
89
88
  export function evaluateReminder(config: HarnessConfig, outcome?: string): ReminderDecision {
90
89
  const idle: ReminderDecision = { fire: false, detail: false, fires, detailOrdinal: 0 };
91
90
  if (!config.remindRefine?.enabled) return idle;
92
- if (turnEndedBadly(outcome)) return idle;
93
91
  const every = config.remindRefine.everyTurns ?? DEFAULT_EVERY_TURNS;
94
- if (every <= 0) return idle;
95
- if (completedTurns < 0) {
96
- completedTurns = 0; // seed: the seed turn itself doesn't count
97
- return idle;
98
- }
99
- completedTurns += 1;
100
- if (completedTurns < every) return idle;
101
- completedTurns = 0;
92
+ if (!cadence.tick(every, outcome)) return idle;
102
93
  fires += 1;
103
94
  const detailEvery = config.remindRefine.detailEvery ?? DEFAULT_DETAIL_EVERY;
104
95
  const detail = detailEvery > 0 && fires % detailEvery === 0;
package/src/select.ts CHANGED
@@ -163,6 +163,11 @@ export function selectForInjection(
163
163
  }
164
164
  }
165
165
 
166
+ // FOOTER_TOKENS is reserved unconditionally: the footer renders whenever
167
+ // ANY pass omits an item (the token pass below can be the first to omit),
168
+ // and a test pins "rendered block never exceeds the budget". Paying 18
169
+ // tokens always keeps that guarantee exact (review simplification —
170
+ // reverted: the conditional reserve broke the budget invariant).
166
171
  const overhead = INTRO_TOKENS + presentKinds.length * SECTION_HEADER_TOKENS + FOOTER_TOKENS;
167
172
  let remaining = Math.max(0, n.maxTokens - overhead);
168
173
  const taken: Ranked[] = [];
package/src/status.ts CHANGED
@@ -110,24 +110,36 @@ function aggregateImpact(
110
110
  for (const t of classifyTransition(prev, next, citeBump)) {
111
111
  const key = t.item.ownerModel || "(orphan)";
112
112
  const o = owner(key);
113
- if (t.event === "created") {
114
- whole.created += 1;
115
- o.created += 1;
116
- } else if (t.event === "kept") {
117
- whole.kept += 1;
118
- o.kept += 1;
119
- } else if (t.event === "dropped") {
120
- whole.dropped += 1;
121
- o.dropped += 1;
122
- } else if (t.event === "cited") {
123
- whole.cited += 1;
124
- o.cited += 1;
125
- } else if (t.event === "pruned") {
126
- whole.pruned += 1;
127
- o.pruned += 1;
128
- } else {
129
- whole.deleted += 1;
130
- o.deleted += 1;
113
+ // A switch (not if/else): a future LifecycleEvent falls to default
114
+ // and is NOT counted as a delete — the old trailing `else` would have
115
+ // silently mislabeled it (review simplification #5).
116
+ switch (t.event) {
117
+ case "created":
118
+ whole.created += 1;
119
+ o.created += 1;
120
+ break;
121
+ case "kept":
122
+ whole.kept += 1;
123
+ o.kept += 1;
124
+ break;
125
+ case "dropped":
126
+ whole.dropped += 1;
127
+ o.dropped += 1;
128
+ break;
129
+ case "cited":
130
+ whole.cited += 1;
131
+ o.cited += 1;
132
+ break;
133
+ case "pruned":
134
+ whole.pruned += 1;
135
+ o.pruned += 1;
136
+ break;
137
+ case "deleted":
138
+ whole.deleted += 1;
139
+ o.deleted += 1;
140
+ break;
141
+ default:
142
+ break; // unknown future event: ignore, never mislabel
131
143
  }
132
144
  }
133
145
  snapshots.push(next);
package/src/store.ts CHANGED
@@ -235,9 +235,14 @@ export function reconstruct(entries: Iterable<unknown>): void {
235
235
  // the active model on first contact (see adoptOrphans); missing/corrupt
236
236
  // scope → "global" (a project item without a slug cannot be placed in a
237
237
  // layer, so it falls back to the global durable file).
238
- state = last
238
+ state = last && Array.isArray(last.items)
239
239
  ? {
240
- items: last.items.map((i): HarnessItem => {
240
+ // Guard both levels (review finding #5): one corrupt harness-state
241
+ // entry (state without an items array, or non-object items) must not
242
+ // crash the session_start handler — degrade to the salvageable part.
243
+ items: last.items
244
+ .filter((i): i is HarnessItem => Boolean(i) && typeof i === "object")
245
+ .map((i): HarnessItem => {
241
246
  const item: HarnessItem = { ...i, ownerModel: i.ownerModel ?? "", scope: "global" };
242
247
  if (i.scope === "project" && i.project) {
243
248
  item.scope = "project";
@@ -246,7 +251,7 @@ export function reconstruct(entries: Iterable<unknown>): void {
246
251
  delete item.project;
247
252
  }
248
253
  return item;
249
- }),
254
+ }),
250
255
  }
251
256
  : { items: [] };
252
257
  version = 0;
@@ -277,9 +282,15 @@ export function decayAndPrune(
277
282
  }
278
283
  const before = state.items.length;
279
284
  state.items = state.items.filter((i) => i.importance >= IMPORTANCE_FLOOR);
280
- version += 1;
281
- persist(state, version);
282
- return { pruned: before - state.items.length, decayed };
285
+ const pruned = before - state.items.length;
286
+ // No-op idempotency (review finding #3): like mergeParsed and adoptOrphans,
287
+ // a prune that changed nothing persists nothing — otherwise every no-op
288
+ // /harness prune appends a harness-state entry of pure /tree noise.
289
+ if (decayed > 0 || pruned > 0) {
290
+ version += 1;
291
+ persist(state, version);
292
+ }
293
+ return { pruned, decayed };
283
294
  }
284
295
 
285
296
  /** Nudge an item's importance by delta (clamped to [0,1]) and touch updatedAt. */
@@ -587,6 +598,16 @@ function mergeParsed(
587
598
  ): DurableImportResult {
588
599
  const fileIds = new Set(parsed.map((p) => p.id).filter((id): id is string => Boolean(id)));
589
600
  const existingById = new Map(state.items.map((i) => [i.id, i] as const));
601
+ // Content fallback (review finding #4): id-less bullets and foreign
602
+ // (non-h_) ids never hit the id map, so import→import of the same file
603
+ // without an export in between used to duplicate the item every time.
604
+ // Matching kind+content folds the re-import into the item the first import
605
+ // created. Keyed with \x00 so content cannot collide across kinds.
606
+ const existingByContent = new Map(state.items.map((i) => [`${i.kind}${i.content}`, i] as const));
607
+ // Store ids any parsed line resolved to (id match, content match, or
608
+ // create): with --prune these count as "present in the file" even when the
609
+ // file never named the id (an id-less bullet carries no fileIds entry).
610
+ const touchedIds = new Set<string>();
590
611
  const preActiveIds = new Set(state.items.filter((i) => i.active).map((i) => i.id));
591
612
  // Distinct parsed items: an explicit id folding across layers (the normal
592
613
  // global-then-project collision) is ONE item, not one per copy; bullets
@@ -609,8 +630,10 @@ function mergeParsed(
609
630
  const updatedIds: string[] = [];
610
631
  const now = Date.now();
611
632
  for (const p of parsed) {
612
- const existing = p.id ? existingById.get(p.id) : undefined;
633
+ const existing =
634
+ (p.id ? existingById.get(p.id) : undefined) ?? existingByContent.get(`${p.kind}${p.content}`);
613
635
  if (existing) {
636
+ touchedIds.add(existing.id);
614
637
  const importance = clamp(p.importance);
615
638
  const ownerModel = p.ownerModel ?? "";
616
639
  // Durable wins; reactivate; keep createdAt. Idempotent imports skip the
@@ -639,6 +662,10 @@ function mergeParsed(
639
662
  updated += 1;
640
663
  updatedIds.push(existing.id);
641
664
  dirty = true;
665
+ // A content-matched line that named a foreign id: register it so a
666
+ // second copy of that id later in the same import id-matches instead
667
+ // of content-matching again (same dedupe as the create branch below).
668
+ if (p.id) existingById.set(p.id, existing);
642
669
  } else {
643
670
  const id = p.id && /^h_/.test(p.id) ? p.id : genId();
644
671
  state.items.push({
@@ -656,10 +683,11 @@ function mergeParsed(
656
683
  created += 1;
657
684
  createdIds.push(id);
658
685
  dirty = true;
659
- // Keep the lookup current so a later layer's copy of the same id
660
- // UPDATES the item we just created instead of duplicating it (the same
661
- // id in both layers is the normal collision case, not an anomaly).
686
+ // Keep the lookups current so a later copy of the same item (another
687
+ // layer's id collision, or a repeated id-less bullet) UPDATES the item
688
+ // we just created instead of duplicating it.
662
689
  if (p.id) existingById.set(p.id, state.items[state.items.length - 1]!);
690
+ existingByContent.set(`${p.kind}${p.content}`, state.items[state.items.length - 1]!);
663
691
  }
664
692
  }
665
693
 
@@ -670,6 +698,7 @@ function mergeParsed(
670
698
  if (!i.active) return true; // inactive items are never touched by durable I/O
671
699
  if (!preActiveIds.has(i.id)) return true; // created during this import
672
700
  if (fileIds.has(i.id)) return true; // present in the file
701
+ if (touchedIds.has(i.id)) return true; // matched by content (id-less / foreign id)
673
702
  return false; // was active before, absent from the file → drop
674
703
  });
675
704
  pruned = before - state.items.length;
package/src/tools.ts CHANGED
@@ -178,12 +178,16 @@ export function registerTools(pi: ExtensionAPI): void {
178
178
  },
179
179
  getActiveModelKey(),
180
180
  );
181
- const summary = summarize(applied);
182
181
  const created = applied.filter((a) => a.op === "create").length;
183
182
  const updated = applied.filter((a) => a.op === "update").length;
184
183
  const deleted = applied.filter((a) => a.op === "delete").length;
185
184
  return {
186
- content: [{ type: "text", text: summary }],
185
+ content: [
186
+ {
187
+ type: "text",
188
+ text: `Applied ${applied.length} delta(s): ${created} created, ${updated} updated, ${deleted} deleted.`,
189
+ },
190
+ ],
187
191
  details: { applied, version: getState().items.length },
188
192
  structuredContent: { applied: applied.length, created, updated, deleted },
189
193
  };
@@ -191,13 +195,6 @@ export function registerTools(pi: ExtensionAPI): void {
191
195
  });
192
196
  }
193
197
 
194
- function summarize(applied: AppliedDelta[]): string {
195
- const created = applied.filter((a) => a.op === "create").length;
196
- const updated = applied.filter((a) => a.op === "update").length;
197
- const deleted = applied.filter((a) => a.op === "delete").length;
198
- return `Applied ${applied.length} delta(s): ${created} created, ${updated} updated, ${deleted} deleted.`;
199
- }
200
-
201
198
  /** Tally `items` by a string key, rendered as "key (n), key (n)". */
202
199
  function tally<T>(items: T[], key: (i: T) => string): string {
203
200
  const counts = new Map<string, number>();