pi-continual-harness 0.13.0 → 0.15.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
@@ -70,11 +70,13 @@ Durable I/O (round-trip with pi-reflect):
70
70
  /harness drop <id> # nudge importance down (−0.1)
71
71
  /harness move <id> global|project # move an item between durable layers
72
72
  /harness split # steer the agent to classify every item's scope
73
+ /harness adopt --from <provider/id> [--all] # steer the agent to copy another model's items here
73
74
  /harness push-mem [--all|--kind <kind>|--model <provider/id|active>] # persist active items to pi-mem (save_memory)
75
+ /harness setup [--preset <quiet|recommended|power>] [--yes] # guided config wizard
74
76
  ```
75
77
 
76
- Subcommands, flags, `--kind`/`--model` values, `move` scopes, and `keep`/
77
- `drop`/`move` item ids all autocomplete in the TUI as you type after
78
+ Subcommands, flags, `--kind`/`--model`/`--from` values, `move` scopes, and
79
+ `keep`/`drop`/`move` item ids all autocomplete in the TUI as you type after
78
80
  `/harness `.
79
81
 
80
82
  **Durable layers.** Every item carries its own durable scope: `global`
@@ -104,7 +106,10 @@ The model-facing tools:
104
106
 
105
107
  - `harness_list({ kind?, model? })` — read current state. `model` defaults to
106
108
  the active model's items (what gets injected this turn); `"*"` returns every
107
- model.
109
+ model. Empty results are **diagnostic**: the message distinguishes a
110
+ genuinely empty store (with zero-state guidance) from items hidden by the
111
+ model or kind filter (with a tally and a `model: "*"` pointer), so an
112
+ empty list never reads as "broken".
108
113
  - `harness_mutate { deltas: [...] }` — apply a batch of `create` / `update` /
109
114
  `delete` deltas. Every `create` requires `evidence`. New items are stamped
110
115
  automatically with the active model (see [Model binding](#model-binding-per-model-isolation)).
@@ -136,15 +141,39 @@ unchanged by a push — pi-mem gets a separate copy.
136
141
 
137
142
  ## Configuration
138
143
 
144
+ **First-run setup:** instead of hand-editing the JSON below, run
145
+ `/harness setup`. It is a guided wizard:
146
+
147
+ 1. Shows the **current effective config** (your file merged over the shipped
148
+ defaults) plus the durable-layer paths.
149
+ 2. Offers three **presets**: `quiet` (pin every opt-in to the shipped
150
+ defaults), `recommended` (`autoImport` + import reminders on), `power`
151
+ (`recommended` + `autoRefine`). Each preset is shown as a declarative diff
152
+ before anything is applied.
153
+ 3. Confirms **each delta** with a one-line rationale. Autonomous switches
154
+ get their own explicit "this lets the agent mutate state on a cadence"
155
+ confirmation regardless of preset.
156
+ 4. Writes via one reviewed path: backs up the existing file
157
+ (`harness.json.bak-<timestamp>`), atomically writes the merged result,
158
+ then reloads and prints the effective config exactly as pi will read it.
159
+
160
+ For scripted setups: `/harness setup --preset recommended --yes` (the `--yes`
161
+ flag is the scripted form of the confirmations, so it enables autonomous
162
+ features without prompts). Nothing is written on unknown input, a cancelled
163
+ preset menu, or a declined final write; declining an individual feature only
164
+ skips that feature (the accepted deltas still apply). The wizard never
165
+ touches harness state, only the config file.
166
+
139
167
  Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults):
140
168
 
141
169
  ```json
142
170
  {
143
171
  "autoImport": false,
172
+ "importRemind": { "enabled": true, "everyTurns": 25 },
144
173
  "proposer": "steering",
145
174
  "dedupe": { "threshold": 0.6, "merge": true },
146
175
  "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
147
- "remindRefine": { "enabled": false, "everyTurns": 50 },
176
+ "remindRefine": { "enabled": true, "everyTurns": 25, "detailEvery": 4 },
148
177
  "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false, "gate": false },
149
178
  "outcomeImportance": { "enabled": false, "bump": 0.03 },
150
179
  "classifier": { "model": "typesafe/jev-latest" },
@@ -160,7 +189,21 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
160
189
  changed since the last export. This is what makes `/refine` output survive
161
190
  into **new** sessions without manual export/import ceremony. Both halves are
162
191
  visible (one notify line) and use the same persisted `harness-state` entries
163
- as every other mutation (`/tree` rollback covers them).
192
+ as every other mutation (`/tree` rollback covers them). Enabling it
193
+ mid-session (e.g. via `/harness setup`) is safe: the first `turn_end`
194
+ imports the layers *before* any export, so a late enable can never
195
+ overwrite the durable files with a never-imported store.
196
+ - **`importRemind`** — **awareness reminders** for that sync (effective only
197
+ when `autoImport` is on; default `{ "enabled": true, "everyTurns": 25 }`).
198
+ The session-start import doesn't just count — it **lists what arrived**
199
+ (item id, kind, first words of content), and every `everyTurns` turns a
200
+ one-line reminder re-surfaces the imported items so silently-injected
201
+ instructions never go unnoticed (full list via the agent's `harness_list`
202
+ tool — `/harness status` shows only counts). Bounded: at most 3 reminders
203
+ per imported batch, then quiet until the next session; aborted/error turns
204
+ are inert (completed-turn cadence). Kill-switch:
205
+ `{ "importRemind": { "enabled": false } }` (the start-of-session listing
206
+ still fires).
164
207
  - **`injection`** — the selection policy for WHAT gets surfaced in the system
165
208
  prompt each turn (see [Injection selection](#injection-selection-on-by-default)).
166
209
  ON by default: items are importance-ordered, capped at `maxPerKind` (default
@@ -181,9 +224,20 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
181
224
  companion packages inject a richer comparator whose return may **abstain**
182
225
  per pair (`{ score, abstain }` — see [`SimilarityResult`](#proposers)); an
183
226
  abstaining pair is always kept, never merged or deleted.
184
- - **`remindRefine`** — opt-in `turn_end` nudge. `{ "enabled": true,
185
- "everyTurns": 50 }` notifies you to run `/refine` on a cadence. It is
186
- informational only — it never mutates state.
227
+ - **`remindRefine`** — the `turn_end` **activation nudge**, ON by default
228
+ (informational only; it never mutates state). Every `everyTurns` completed
229
+ turns (default 25) a one-line reminder suggests `/refine`, garnished with
230
+ live data from the store (e.g. "12 active item(s), 4 never cited since
231
+ creation"; an empty store gets first-run guidance instead). Every
232
+ `detailEvery`-th reminder (default 4: at the default cadence, roughly
233
+ every 100 turns) escalates to a rotating educational guide: how the loop
234
+ works (with alternate descriptions so it never reads as boilerplate), the
235
+ `/refine` workflow, `keep`/`drop`/`prune --decay` hygiene, `push-mem`,
236
+ `split`, `export-corpus`, and a recommended starter config. Fresh installs
237
+ used to be silent; that was the bug, not the feature. Aborted/error turns
238
+ are skipped, so `everyTurns` counts completed turns of work. Silence it
239
+ with `{ "remindRefine": { "enabled": false } }`; `detailEvery: 1` makes
240
+ every reminder detailed.
187
241
  - **`autoRefine`** — opt-in autonomous self-improvement (**off by default**).
188
242
  When `enabled`, the agent runs `/refine` itself every `everyTurns` turns
189
243
  (default 100). It is one of the package's opt-in autonomous paths: it reuses
@@ -195,14 +249,21 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
195
249
  signal, not just time (errors fall back to the plain cadence). The steering
196
250
  prompt is delivered via the pi ≥ 0.87 actionable `turn_end` boundary (a
197
251
  structural `harness.auto-refine-request` entry + `continue: true`), not a
198
- synthetic user message.
252
+ synthetic user message. Aborted/error turns (pi `turn_end`
253
+ `BoundaryState.outcome`) are inert: they neither seed nor advance the
254
+ cadence window — a refine never fires over partial evidence.
199
255
  - **`classifier`** — opt-in classifier model (pi ≥ 0.99) for the cheap gated
200
256
  paths: `"provider/id"` or a bare id of a **classifier** catalog entry, e.g.
201
257
  `"typesafe/jev-latest"` or a local `llama-cpp` classifier (every chat model
202
- listed for llama.cpp also appears as a classifier). When set and resolvable
203
- it enables the classifier-confirmed dedupe (see `dedupe` above) and the
204
- auto-refine `gate`. When unset or unresolvable, every classifier feature
205
- degrades to its no-classifier behavior.
258
+ listed for llama.cpp also appears as one). With pi ≥ 1.1.0 the catalog also
259
+ offers OpenAI's GPT-6 Luna through the Decisions API (e.g.
260
+ `"openai/gpt-6-luna"`, needs `OPENAI_API_KEY`) and the native llama.cpp
261
+ decision models (Julia-1, Laya, Kev, lev, OpenJev through `/v1/systemone`,
262
+ llama.cpp ≥ 0.6.0 — listed *only* as classifiers, which is exactly our use:
263
+ a free local gate). When set and resolvable it enables the
264
+ classifier-confirmed dedupe (see `dedupe` above) and the auto-refine `gate`.
265
+ When unset or unresolvable, every classifier feature degrades to its
266
+ no-classifier behavior.
206
267
  - **`virtualBinding`** — how the per-model isolation key treats pi **virtual
207
268
  models** (router extensions, pi ≥ 0.99). Default `"virtual"`: the key is
208
269
  the selected model's own `provider/id` — a router starts a blank harness.
@@ -215,19 +276,23 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
215
276
  - **`outcomeImportance`** — opt-in autonomous **promotion** loop (**off by
216
277
  default**). When `enabled`, a `turn_end` hook bumps (+`bump`, default 0.03)
217
278
  the importance of any active item the agent cited by its `[h_xxxx]` tag in the
218
- turn's output. Referenced items gain importance and get their `updatedAt`
279
+ turn's output. Citations are read from completed turns only — an aborted
280
+ turn's partial text is scanned by the next completed turn (the initial seed
281
+ turn itself is never scanned, as before). Referenced items
282
+ gain importance and get their `updatedAt`
219
283
  touched (so they survive time-based decay); ignored items keep decaying. This
220
284
  is the package's second opt-in autonomous path — promotion only, never
221
285
  deletes, persisted/branchable like `harness_mutate`. Autonomous demotion from
222
286
  outcomes is intentionally NOT done (high false-positive); use `/harness drop`,
223
287
  `prune --decay`, or the `dedupe` proposer for that.
224
288
 
225
- > **Deprecated:** `durableScope` (0.9.0). Items now carry their own scope
226
- > (`/harness move`), and durable I/O is always layered — the key no longer
227
- > switches anything but is still parsed so existing configs keep loading.
228
- > Migration from a `durableScope: "project"` setup: run `/harness import` once
229
- > in the project (its file's items adopt project scope), then move any strays
230
- > with `/harness move` (or classify the whole store with `/harness split`).
289
+ > **Removed:** the store-wide `durableScope` config key (deprecated 0.9.0,
290
+ > removed now). Items carry their own scope (`/harness move`), and durable I/O
291
+ > is always layered. Config files that still set the key load fine — unknown
292
+ > keys are ignored. Migration from a `durableScope: "project"` setup: run
293
+ > `/harness import` once in the project (its file's items adopt project
294
+ > scope), then move any strays with `/harness move` (or classify the whole
295
+ > store with `/harness split`).
231
296
 
232
297
  ## How it works
233
298
 
@@ -276,6 +341,29 @@ How the binding is set and respected:
276
341
  - **Durable round-trip preserves owner.** `/harness export` tags each item with
277
342
  `model: provider/id`; `/harness import` restores it. An item whose tag
278
343
  pi-reflect stripped becomes an orphan and is adopted by the active model.
344
+ - **Carrying owned items across models: `/harness adopt`.** Orphan adoption
345
+ only covers *unowned* items. `/harness adopt --from <provider/id>` is the
346
+ reviewable path for owned ones: it steers the agent to classify the source
347
+ model's active items **generalizes** (engineering practices, user
348
+ preferences, project facts) vs **stays** (that model's quirks, formatting
349
+ preferences, failure modes), then apply the adoption as ONE `harness_mutate`
350
+ batch of create deltas. It is **copy, not move**: the source model's harness
351
+ stays untouched, content is copied verbatim (importance is listed at full
352
+ precision so the copy keeps its exact ranking), and evidence is annotated
353
+ `adopted from <provider/id>`. Because `ownerModel` is never model-settable,
354
+ the creates stamp to the **active** model via the normal create-stamping
355
+ path — audited and `/tree`-rollback-able like every mutation. `--all` skips
356
+ the filter; overlaps afterwards clean up with `/refine --proposer dedupe`.
357
+ Two store-level filters keep the copy honest (review #25): items scoped to
358
+ a **different project** are skipped (a copy would re-stamp them into this
359
+ session's project — adopt them from a session in that project instead),
360
+ and content the adopter already owns is skipped **before** the batch cap,
361
+ so re-running against a >20-item source reaches new items instead of
362
+ re-selecting the same top ones. The self-adopt guard compares against the
363
+ **resolved binding key** (`virtualBinding`-aware — under `physical` a
364
+ router selection adopts as the routed model), the same key
365
+ create-stamping uses. Explicit and manual only — there is no automatic
366
+ cross-model adoption.
279
367
 
280
368
  Manual commands (`export`, `import`, `move`, `split`, `keep`, `drop`, `prune`,
281
369
  `push-mem`, `status`) operate on the **whole store** by design — they are
@@ -410,10 +498,10 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
410
498
  auto-import + turn_end layered auto-export when the store changed —
411
499
  `/refine` output survives into new sessions with zero ceremony. Off by
412
500
  default; every action visible and `/tree`-rollback-able.
413
- - Optional config (`~/.pi/agent/harness.json`): the durable-sync opt-in, an
414
- opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
415
- `turn_end` outcome-importance loop — the package's opt-in autonomous
416
- paths (all off by default).
501
+ - Optional config (`~/.pi/agent/harness.json`): the durable-sync opt-in, a
502
+ default-on `turn_end` reminder (notify-only; the activation nudge), and the
503
+ opt-in `turn_end` auto-refine and outcome-importance loops. The package's
504
+ autonomous paths stay opt-in and off by default.
417
505
  - **Pluggable delta proposers** with a registry: `steering` (default) and
418
506
  `dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
419
507
  - **Per-model isolation**: every item is bound to a `provider/id` and injected
@@ -422,7 +510,9 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
422
510
  round-trip preserves the owner tag. With pi virtual models (routers), the
423
511
  `"virtualBinding": "physical"` policy keys items by the routed physical
424
512
  model instead of the router id, so a router selection does not silently
425
- reset the harness.
513
+ reset the harness. Owned items can be carried across models explicitly with
514
+ `/harness adopt --from <provider/id> [--all]` (agent-filtered copy; see
515
+ [Model binding](#model-binding-per-model-isolation)).
426
516
  - **Bounded injection (on by default)**: the supplemental block is
427
517
  importance-ordered and capped per kind + by a total token budget, so a growing
428
518
  harness never balloons the system prompt. Tunable / opt-out via the `injection`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",
@@ -25,6 +25,12 @@
25
25
  // sendUserMessage when the boundary cannot continue (event.context.
26
26
  // canContinue false, e.g. after an error turn).
27
27
  //
28
+ // Aborted/error turns are inert here: a turn_end whose BoundaryState.outcome
29
+ // is "aborted" or "error" neither seeds nor advances the cadence window, so
30
+ // "every N turns" counts completed turns of work and a refine never fires
31
+ // over partial evidence. (Durable auto-export deliberately still exports on
32
+ // such turns — committed entries must not be lost.)
33
+ //
28
34
  // It stays visible: it notifies before firing, and the boundary draft (and the
29
35
  // gate decision) appear in the transcript. The decision logic is factored into
30
36
  // evaluateAutoRefine() so it is unit-testable without a live pi runtime.
@@ -34,29 +40,47 @@ import { DEFAULT_AUTO_EVERY_TURNS, type HarnessConfig, loadConfig } from "./conf
34
40
  import { runRefine, gatherEvidence, DEFAULT_LOOKBACK_TURNS } from "./refine.js";
35
41
  import { buildClassify, buildGateRequest, gateDecision } from "./classify.js";
36
42
 
37
- // turnIndex of the last auto-refine (or the seeded baseline). -1 = unseen.
38
- let lastTurn = -1;
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;
39
47
 
40
48
  /** Test hook: reset internal counter. */
41
49
  export function resetAutoRefine(): void {
42
- lastTurn = -1;
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";
43
61
  }
44
62
 
45
63
  /**
46
64
  * Decide whether to auto-refine this turn. Side-effectful: the first observed
47
- * turn seeds the baseline (no refine then); each fired refine resets it, which
48
- * also prevents the refine turn itself from immediately re-triggering.
65
+ * completed turn seeds the baseline (no refine then); every completed turn
66
+ * after it advances the count, and each fired refine resets it (which also
67
+ * prevents the refine turn itself from immediately re-triggering).
68
+ * Aborted/error turns are inert: they neither seed nor advance the count —
69
+ * the cadence counts completed turns of work, never raw turn indices, so an
70
+ * aborted turn in between cannot shorten the window.
49
71
  */
50
- export function evaluateAutoRefine(config: HarnessConfig, turnIndex: number): boolean {
72
+ export function evaluateAutoRefine(config: HarnessConfig, outcome?: string): boolean {
51
73
  if (!config.autoRefine?.enabled) return false;
74
+ if (turnEndedBadly(outcome)) return false;
52
75
  const every = config.autoRefine.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
53
76
  if (every <= 0) return false;
54
- if (lastTurn < 0) {
55
- lastTurn = turnIndex;
77
+ if (completedTurns < 0) {
78
+ completedTurns = 0; // seed: the seed turn itself doesn't count
56
79
  return false;
57
80
  }
58
- if (turnIndex - lastTurn >= every) {
59
- lastTurn = turnIndex;
81
+ completedTurns += 1;
82
+ if (completedTurns >= every) {
83
+ completedTurns = 0;
60
84
  return true;
61
85
  }
62
86
  return false;
@@ -112,7 +136,7 @@ export function buildOmissionDrafts(projected: Iterable<ProjectedLike>): Omissio
112
136
  export function registerAutoRefine(pi: ExtensionAPI): void {
113
137
  pi.on("turn_end", async (event, ctx) => {
114
138
  const config = await loadConfig();
115
- if (!evaluateAutoRefine(config, event.turnIndex)) return;
139
+ if (!evaluateAutoRefine(config, event.outcome)) return;
116
140
  const every = config.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
117
141
 
118
142
  // Opt-in classifier gate: cheap yes/no before spending the refine.
package/src/config.ts CHANGED
@@ -6,26 +6,31 @@
6
6
  // current project's file) and turn_end layered auto-export whenever the
7
7
  // live store changed since the last export. Off by default — importing is
8
8
  // normally an explicit, reviewable /harness import.
9
- // - durableScope: DEPRECATED (0.9.0). Items now carry their own scope
10
- // (`/harness move <id> global|project`); durable I/O is layered on top of
11
- // per-item scope and this key no longer switches anything. Still parsed so
12
- // existing configs keep loading; will be removed in a future release.
13
- // - remindRefine / autoRefine / outcomeImportance / injection / proposer:
14
- // unchanged (see below).
9
+ // - remindRefine: the /refine activation nudge (issue #19), ON by default
10
+ // (notify-only, never mutates; `enabled: false` opts back
11
+ // out). autoRefine / outcomeImportance / injection / proposer: unchanged
12
+ // (see below).
15
13
  //
16
14
  // Robust by design: missing or malformed file → DEFAULT_CONFIG, never throws.
15
+ // Unknown keys are ignored (e.g. the removed pre-0.9.0 `durableScope`).
17
16
 
18
- import { readFile } from "node:fs/promises";
17
+ import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
18
+ import { randomUUID } from "node:crypto";
19
19
  import { homedir } from "node:os";
20
20
  import { basename, dirname, join } from "node:path";
21
21
  import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR, type LayerFile, type ScopeInfo } from "./store.js";
22
22
  import { DEFAULT_INJECTION, normalizeInjection, type NormalizedInjection } from "./select.js";
23
23
  import { DEDUPE_THRESHOLD } from "./proposer.js";
24
24
 
25
- export const DEFAULT_EVERY_TURNS = 50;
25
+ export const DEFAULT_EVERY_TURNS = 25;
26
26
  export const DEFAULT_AUTO_EVERY_TURNS = 100;
27
27
  /** Default per-reference importance bump for the opt-in outcome loop. */
28
28
  export const DEFAULT_REF_BUMP = 0.03;
29
+ /** Default cadence (turns) for the auto-import awareness reminder. */
30
+ export const DEFAULT_IMPORT_REMIND_TURNS = 25;
31
+ /** Default escalation cadence for the /refine reminder: every Nth fired
32
+ * reminder is the detailed guide instead of the one-line nudge. */
33
+ export const DEFAULT_DETAIL_EVERY = 4;
29
34
 
30
35
  /** How the binding key treats virtual (router) models. "virtual" (default):
31
36
  * key = the selected model's own provider/id. "physical": when the selection
@@ -43,15 +48,27 @@ export interface NormalizedDedupe {
43
48
  }
44
49
 
45
50
  export interface HarnessConfig {
46
- /** DEPRECATED (0.9.0): items carry their own scope now (see /harness move);
47
- * durable I/O is always layered. Parsed for compatibility, ignored. */
48
- durableScope?: "global" | "project";
49
51
  /** Opt-in durable sync (issue #7): session_start layered auto-import +
50
52
  * turn_end layered auto-export when the store changed. Default false. */
51
53
  autoImport?: boolean;
54
+ /** Awareness reminders for durable imports (only effective when `autoImport`
55
+ * is on): the session-start import notifies WHAT arrived, and a bounded
56
+ * cadence reminder re-surfaces those items so silently-imported instructions
57
+ * never go unnoticed. `enabled` defaults true; `everyTurns` defaults 25. */
58
+ importRemind?: {
59
+ enabled?: boolean;
60
+ everyTurns?: number;
61
+ };
62
+ /** /refine cadence nudge (ON by default, issue #19 — activation nudge, notify-only):
63
+ * every `everyTurns` completed turns a one-line reminder fires; every
64
+ * `detailEvery`-th reminder escalates to a rotating educational guide
65
+ * (see src/remind.ts). Never mutates state; `enabled: false` restores the
66
+ * old silence. */
52
67
  remindRefine?: {
53
68
  enabled?: boolean;
54
69
  everyTurns?: number;
70
+ /** Every Nth fired reminder is the detailed guide (1 = every reminder). */
71
+ detailEvery?: number;
55
72
  };
56
73
  autoRefine?: {
57
74
  enabled?: boolean;
@@ -94,9 +111,9 @@ export interface HarnessConfig {
94
111
  }
95
112
 
96
113
  export const DEFAULT_CONFIG: HarnessConfig = {
97
- durableScope: "global",
98
114
  autoImport: false,
99
- remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
115
+ importRemind: { enabled: true, everyTurns: DEFAULT_IMPORT_REMIND_TURNS },
116
+ remindRefine: { enabled: true, everyTurns: DEFAULT_EVERY_TURNS, detailEvery: DEFAULT_DETAIL_EVERY },
100
117
  autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false, gate: false, omitStaleDrafts: true },
101
118
  classifier: { model: undefined },
102
119
  virtualBinding: "virtual",
@@ -119,11 +136,17 @@ let cached: HarnessConfig | undefined;
119
136
  /** Merge a (possibly partial) parsed file over the defaults. */
120
137
  function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
121
138
  return {
122
- durableScope: over.durableScope === "project" ? "project" : "global",
123
139
  autoImport: over.autoImport === true,
140
+ importRemind: {
141
+ enabled: over.importRemind?.enabled !== false,
142
+ everyTurns: coerceMinTurns(over.importRemind?.everyTurns, DEFAULT_IMPORT_REMIND_TURNS),
143
+ },
124
144
  remindRefine: {
125
- enabled: over.remindRefine?.enabled ?? false,
126
- everyTurns: over.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS,
145
+ // Default-on (issue #19): a notify-only nudge mutates nothing, so the
146
+ // activation lever is pulled unless explicitly declined.
147
+ enabled: over.remindRefine?.enabled !== false,
148
+ everyTurns: coerceMinTurns(over.remindRefine?.everyTurns, DEFAULT_EVERY_TURNS),
149
+ detailEvery: coerceMinTurns(over.remindRefine?.detailEvery, DEFAULT_DETAIL_EVERY),
127
150
  },
128
151
  autoRefine: {
129
152
  enabled: over.autoRefine?.enabled ?? false,
@@ -152,6 +175,14 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
152
175
  };
153
176
  }
154
177
 
178
+ /** Coerce a user-provided turn count to a finite integer ≥ 1, else the
179
+ * default. Cadence arithmetic divides/compares against it; 0 or a fraction
180
+ * would fire every turn (spam) and NaN never fires. */
181
+ function coerceMinTurns(raw: unknown, def: number): number {
182
+ if (typeof raw === "number" && Number.isFinite(raw) && Number.isInteger(raw) && raw >= 1) return raw;
183
+ return def;
184
+ }
185
+
155
186
  /** Coerce a user-provided bump to a finite number, else the default. The bump
156
187
  * is an ARITHMETIC operand (importance + bump), so a non-numeric value must not
157
188
  * leak through: importance + "0.03" === "0.50.03" → NaN, which would then be
@@ -168,6 +199,120 @@ function coerceThreshold(raw: unknown): number {
168
199
  return typeof raw === "number" && Number.isFinite(raw) && raw > 0 && raw <= 1 ? raw : DEDUPE_THRESHOLD;
169
200
  }
170
201
 
202
+ /** Result of a reviewed config write (see saveConfig). */
203
+ export interface SaveConfigResult {
204
+ /** The file that was written (== `path`). */
205
+ written: string;
206
+ /** Backup of the previous file (`harness.json.bak-<timestamp>`), when one existed. */
207
+ backup: string | undefined;
208
+ /** The exact object serialized to disk (existing raw file ∪ `next`). */
209
+ merged: Record<string, unknown>;
210
+ /** Effective config re-read through loadConfig — what pi will actually load. */
211
+ effective: HarnessConfig;
212
+ }
213
+
214
+ /** True for a non-array plain object (a deep-merge candidate). */
215
+ export function isPlainObject(v: unknown): v is Record<string, unknown> {
216
+ return typeof v === "object" && v !== null && !Array.isArray(v);
217
+ }
218
+
219
+ /** Two-level deep merge (the config shape is two levels deep at most):
220
+ * plain-object keys merge key-by-key so sibling settings survive; everything
221
+ * else is replaced by `next`. Unknown existing keys ride along untouched. */
222
+ export function deepMergeConfig(
223
+ existing: Record<string, unknown>,
224
+ next: Record<string, unknown>,
225
+ ): Record<string, unknown> {
226
+ const out: Record<string, unknown> = { ...existing };
227
+ for (const [k, v] of Object.entries(next)) {
228
+ const cur = out[k];
229
+ out[k] = isPlainObject(cur) && isPlainObject(v) ? { ...cur, ...v } : v;
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /** Parse the raw file when it is a JSON object; undefined for missing,
235
+ * malformed, or non-object content. Unknown keys are preserved verbatim
236
+ * (the loader ignores them, a rewrite must not drop them). */
237
+ export async function readConfigRaw(path: string): Promise<Record<string, unknown> | undefined> {
238
+ try {
239
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
240
+ return isPlainObject(parsed) ? parsed : undefined;
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ }
245
+
246
+ /** Filesystem-safe, sortable backup timestamp (colons/dots → dashes). */
247
+ function backupStamp(d = new Date()): string {
248
+ return d.toISOString().replace(/[:.]/g, "-");
249
+ }
250
+
251
+ /** rename with a bounded retry. On Windows, replacing a file that was itself
252
+ * JUST replaced (a concurrent save racing to the same target) can transiently
253
+ * fail with EPERM/EACCES/ENOTEMPTY while the previous replacement settles
254
+ * into the delete-pending state. A short backoff clears it; every other
255
+ * error (and errors that persist) propagates. */
256
+ async function renameRetry(tmp: string, path: string, tries = 3): Promise<void> {
257
+ for (let attempt = 0; ; attempt += 1) {
258
+ try {
259
+ await rename(tmp, path);
260
+ return;
261
+ } catch (err) {
262
+ const code = (err as NodeJS.ErrnoException).code;
263
+ if (attempt < tries && (code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY")) {
264
+ await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
265
+ continue;
266
+ }
267
+ throw err;
268
+ }
269
+ }
270
+ }
271
+
272
+ /** The ONE reviewed write path for harness.json (used by /harness setup):
273
+ * merge `next` over the existing RAW file (two levels deep, unknown keys
274
+ * preserved), back the previous file up as `harness.json.bak-<timestamp>`,
275
+ * atomically write (same-dir tmp + rename), then drop the config cache and
276
+ * reload — the returned `effective` round-trips through the same
277
+ * loader/coercion path pi uses, so what the wizard displayed is what pi
278
+ * reads. Throws BEFORE any write when the backup cannot be made: the
279
+ * previous file is never destroyed without one. */
280
+ export async function saveConfig(
281
+ next: Partial<HarnessConfig> | Record<string, unknown>,
282
+ path: string = CONFIG_PATH,
283
+ ): Promise<SaveConfigResult> {
284
+ const existing = await readConfigRaw(path);
285
+ const merged = deepMergeConfig(existing ?? {}, next as Record<string, unknown>);
286
+ const json = `${JSON.stringify(merged, null, 2)}\n`;
287
+ // Back up by FILE EXISTENCE, not parse success: a malformed file must be
288
+ // preserved too (it may hold recoverable content; the rewrite replaces it).
289
+ let backup: string | undefined;
290
+ try {
291
+ await stat(path);
292
+ backup = `${path}.bak-${backupStamp()}`;
293
+ await copyFile(path, backup);
294
+ } catch (err) {
295
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; // backup failure → abort, no write
296
+ }
297
+ await mkdir(dirname(path), { recursive: true }); // first run: ~/.pi/agent may not exist yet
298
+ // Unique + exclusive temp name: two concurrent saveConfig calls (two pi
299
+ // sessions, or two wizards) must never share a tmp file — the loser would
300
+ // truncate the winner's content or rename it away mid-write. "wx" fails
301
+ // loudly on collision instead of corrupting (a UUID collision is already
302
+ // theoretical; this makes it impossible).
303
+ const tmp = `${path}.tmp-${randomUUID().slice(0, 8)}`;
304
+ try {
305
+ await writeFile(tmp, json, { encoding: "utf8", flag: "wx" });
306
+ await renameRetry(tmp, path); // atomic on POSIX; REPLACE_EXISTING on Windows
307
+ } catch (err) {
308
+ await rm(tmp, { force: true }).catch(() => {}); // best-effort tmp cleanup
309
+ throw err;
310
+ }
311
+ resetConfigCache();
312
+ const effective = await loadConfig(path);
313
+ return { written: path, backup, merged, effective };
314
+ }
315
+
171
316
  /** Load the config, merged over defaults. Tolerant: missing/malformed → defaults.
172
317
  * Cached for the process lifetime (config is read once per session). */
173
318
  export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
@@ -186,15 +331,6 @@ export function resetConfigCache(): void {
186
331
  cached = undefined;
187
332
  }
188
333
 
189
- /**
190
- * @deprecated Durable I/O is layered on per-item scope (0.9.0); this key no
191
- * longer switches the durable path. Kept so old configs keep loading.
192
- */
193
- export function resolveDurablePath(config: HarnessConfig, cwd?: string): string {
194
- if (config.durableScope !== "project") return DEFAULT_DURABLE_PATH;
195
- return projectDurablePath(cwd);
196
- }
197
-
198
334
  /** Path of a project's durable layer file: <projectDir>/<slug>.md. */
199
335
  export function projectDurablePath(cwd?: string): string {
200
336
  return join(PROJECT_DURABLE_DIR, `${projectSlug(cwd)}.md`);
package/src/corpus.ts CHANGED
@@ -135,38 +135,58 @@ function durableLayer(i: HarnessItem): string {
135
135
 
136
136
  // ---- lifecycle ---------------------------------------------------------------
137
137
 
138
- /** Classify one snapshot → next-snapshot transition into lifecycle records.
138
+ /** One classified lifecycle transition, with the item it happened to.
139
+ * Exported so the status report (src/status.ts) can aggregate events with
140
+ * owner-model attribution; `buildCorpus` maps these onto contract records. */
141
+ export interface LifecycleTransition {
142
+ event: LifecycleEvent;
143
+ /** Post-transition item (for removals: the removed item as last seen). */
144
+ item: HarnessItem;
145
+ }
146
+
147
+ /** Classify one snapshot → next-snapshot transition into lifecycle events.
139
148
  * `citeBump` is the CONFIGURED outcome-loop bump (see CorpusOptions). */
140
- function diffSnapshots(
149
+ export function classifyTransition(
141
150
  prev: HarnessState,
142
151
  next: HarnessState,
143
152
  citeBump: number,
144
- out: LifecycleRecord[],
145
- ): void {
153
+ ): LifecycleTransition[] {
154
+ const out: LifecycleTransition[] = [];
146
155
  const prevById = new Map(prev.items.map((i) => [i.id, i]));
147
156
  const nextById = new Map(next.items.map((i) => [i.id, i]));
148
157
 
149
158
  for (const item of next.items) {
150
159
  const before = prevById.get(item.id);
151
160
  if (!before) {
152
- out.push(lifecycle(item, "created", item.importance));
161
+ out.push({ event: "created", item });
153
162
  continue;
154
163
  }
155
164
  const d = item.importance - before.importance;
156
165
  if (Math.abs(d) < EPSILON) continue;
157
166
  // keep/drop FIRST: a configured citation bump of exactly ±0.1 collides and
158
167
  // keep/drop win (documented in CorpusOptions).
159
- if (Math.abs(d - KEEP_DROP_BUMP) <= BUMP_TOL) out.push(lifecycle(item, "kept", item.importance));
160
- else if (Math.abs(d + KEEP_DROP_BUMP) <= BUMP_TOL)
161
- out.push(lifecycle(item, "dropped", item.importance));
168
+ if (Math.abs(d - KEEP_DROP_BUMP) <= BUMP_TOL) out.push({ event: "kept", item });
169
+ else if (Math.abs(d + KEEP_DROP_BUMP) <= BUMP_TOL) out.push({ event: "dropped", item });
162
170
  else if (citeBump > 0 && Math.abs(d - citeBump) <= BUMP_TOL)
163
- out.push(lifecycle(item, "cited", item.importance));
171
+ out.push({ event: "cited", item });
164
172
  // else: explicit harness_mutate importance set — not a lifecycle signal.
165
173
  }
166
174
  for (const item of prev.items) {
167
175
  if (nextById.has(item.id)) continue;
168
176
  const event = item.importance < IMPORTANCE_FLOOR ? "pruned" : "deleted";
169
- out.push(lifecycle(item, event, item.importance));
177
+ out.push({ event, item });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ function diffSnapshots(
183
+ prev: HarnessState,
184
+ next: HarnessState,
185
+ citeBump: number,
186
+ out: LifecycleRecord[],
187
+ ): void {
188
+ for (const t of classifyTransition(prev, next, citeBump)) {
189
+ out.push(lifecycle(t.item, t.event, t.item.importance));
170
190
  }
171
191
  }
172
192