pi-continual-harness 0.8.0 → 0.9.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
@@ -13,10 +13,12 @@ with:
13
13
  → behavioral-file refinement (the "deep" path).
14
14
  - **pi-mem / pi-memory** — durable memory storage.
15
15
 
16
- The durable markdown file (`~/.pi/agent/harness-state.md`) is the composition seam,
17
- and it is **two-way**: `/refine --commit` and `/harness export` write it;
18
- `/harness import` parses it back and merges into the live store (offline edits
19
- win on conflict), so refinements pi-reflect makes flow back online. `/harness push-mem`
16
+ The durable markdown layers are the composition seam — the shared
17
+ `~/.pi/agent/harness-state.md` plus per-project files under
18
+ `~/.pi/agent/harness-state/<slug>.md` — and they are **two-way**:
19
+ `/refine --commit` and `/harness export` write them; `/harness import` parses
20
+ them back and merges into the live store (offline edits win on conflict), so
21
+ refinements pi-reflect makes flow back online. `/harness push-mem`
20
22
  pushes active items into pi-mem's semantic store (see
21
23
  [Composing with pi-mem](#composing-with-pi-mem)).
22
24
 
@@ -58,23 +60,42 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
58
60
  Durable I/O (round-trip with pi-reflect):
59
61
 
60
62
  ```
61
- /harness status # counts + durable file presence/mtime
62
- /harness export [path] # write active items to a markdown file
63
- /harness import [--prune] [path] # parse it back and merge (offline edits win)
63
+ /harness status # counts + durable layer presence/mtime
64
+ /harness export [path] # layered export (or full snapshot to a path)
65
+ /harness import [--prune] [path] # layered import (or single file from a path)
64
66
  /harness prune [--decay <days>] # drop items below the importance floor
65
67
  /harness keep <id> # nudge importance up (+0.1)
66
68
  /harness drop <id> # nudge importance down (−0.1)
69
+ /harness move <id> global|project # move an item between durable layers
70
+ /harness split # steer the agent to classify every item's scope
67
71
  /harness push-mem [--all|--kind <kind>|--model <provider/id|active>] # persist active items to pi-mem (save_memory)
68
72
  ```
69
73
 
70
- `import` reconciles the file into the live store: items whose id matches an
74
+ Subcommands, flags, `--kind`/`--model` values, `move` scopes, and `keep`/
75
+ `drop`/`move` item ids all autocomplete in the TUI as you type after
76
+ `/harness `.
77
+
78
+ **Durable layers.** Every item carries its own durable scope: `global`
79
+ (the shared `~/.pi/agent/harness-state.md`) or `project`
80
+ (`~/.pi/agent/harness-state/<slug>.md`, slug derived from the session `cwd`).
81
+ `/harness export` without a path partitions items by their scope into the
82
+ layer files; `/harness import` without a path merges the global file always,
83
+ plus the current project's file (the project layer wins id collisions;
84
+ `--prune` drops only items absent from **every** layer). An explicit path keeps
85
+ the classic single-file semantics. Move items between layers with
86
+ `/harness move`, or classify the whole store at once with `/harness split`
87
+ (the agent proposes scope-only `harness_mutate` updates — visible in the
88
+ transcript, `/tree`-rollback-able).
89
+
90
+ `import` reconciles the file(s) into the live store: items whose id matches an
71
91
  existing entry are updated (offline edits win on content/evidence/importance),
72
92
  new entries are created. By default nothing is deleted — `--prune` also drops
73
- active items whose id is no longer in the file (inactive items are always
74
- preserved). Point pi-reflect at the same file to refine it offline:
93
+ active items whose id is no longer in the file(s) (inactive items are always
94
+ preserved). Point pi-reflect at either layer to refine it offline:
75
95
 
76
96
  ```
77
97
  /reflect ~/.pi/agent/harness-state.md
98
+ /reflect ~/.pi/agent/harness-state/<project-slug>.md
78
99
  ```
79
100
 
80
101
  The model-facing tools:
@@ -114,7 +135,7 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
114
135
 
115
136
  ```json
116
137
  {
117
- "durableScope": "global",
138
+ "autoImport": false,
118
139
  "proposer": "steering",
119
140
  "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
120
141
  "remindRefine": { "enabled": false, "everyTurns": 50 },
@@ -123,10 +144,15 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
123
144
  }
124
145
  ```
125
146
 
126
- - **`durableScope`** — `"global"` (default) writes the durable markdown to
127
- `~/.pi/agent/harness-state.md`; `"project"` writes to
128
- `~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
129
- project keeps separate state for pi-reflect.
147
+ - **`autoImport`** — opt-in **durable sync** (off by default). `true` bundles
148
+ both directions: on `session_start` the durable layers are imported
149
+ automatically (global file always + the current project's file, with the same
150
+ loss-free merge as `/harness import` — an import that changes nothing stays
151
+ silent), and on `turn_end` the layers are re-exported whenever the live store
152
+ changed since the last export. This is what makes `/refine` output survive
153
+ into **new** sessions without manual export/import ceremony. Both halves are
154
+ visible (one notify line) and use the same persisted `harness-state` entries
155
+ as every other mutation (`/tree` rollback covers them).
130
156
  - **`injection`** — the selection policy for WHAT gets surfaced in the system
131
157
  prompt each turn (see [Injection selection](#injection-selection-on-by-default)).
132
158
  ON by default: items are importance-ordered, capped at `maxPerKind` (default
@@ -154,6 +180,13 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
154
180
  outcomes is intentionally NOT done (high false-positive); use `/harness drop`,
155
181
  `prune --decay`, or the `dedupe` proposer for that.
156
182
 
183
+ > **Deprecated:** `durableScope` (0.9.0). Items now carry their own scope
184
+ > (`/harness move`), and durable I/O is always layered — the key no longer
185
+ > switches anything but is still parsed so existing configs keep loading.
186
+ > Migration from a `durableScope: "project"` setup: run `/harness import` once
187
+ > in the project (its file's items adopt project scope), then move any strays
188
+ > with `/harness move` (or classify the whole store with `/harness split`).
189
+
157
190
  ## How it works
158
191
 
159
192
  1. `/refine` gathers recent trajectory evidence from the current session branch.
@@ -310,19 +343,26 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
310
343
 
311
344
  ## Status
312
345
 
313
- 0.8.x. Implemented:
346
+ 0.9.x. Implemented:
314
347
 
315
348
  - Unified harness-state store with branch-local snapshots (`/tree` rollback).
316
349
  - Online `/refine` + `harness_mutate` / `harness_list` tools.
317
- - Two-way durable round-trip with pi-reflect (`/harness import|export|status`).
350
+ - Two-way durable round-trip with pi-reflect (`/harness import|export|status`),
351
+ **layered on per-item scope**: every item is `global` or `project`
352
+ (`/harness move`, `/harness split`), and `/harness export|import` operate on
353
+ both layers.
318
354
  - Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
319
355
  <id>`.
320
356
  - **pi-mem composition**: `/harness push-mem [--all|--kind|--model]` steers the agent
321
357
  to persist active items into pi-mem (soft-fail; no dependency).
322
- - Optional config (`~/.pi/agent/harness.json`): project-local durable scope, an
358
+ - **Opt-in durable sync** (`"autoImport": true`): session_start layered
359
+ auto-import + turn_end layered auto-export when the store changed —
360
+ `/refine` output survives into new sessions with zero ceremony. Off by
361
+ default; every action visible and `/tree`-rollback-able.
362
+ - Optional config (`~/.pi/agent/harness.json`): the durable-sync opt-in, an
323
363
  opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
324
- `turn_end` outcome-importance loop — the package's two opt-in autonomous
325
- paths (both off by default).
364
+ `turn_end` outcome-importance loop — the package's opt-in autonomous
365
+ paths (all off by default).
326
366
  - **Pluggable delta proposers** with a registry: `steering` (default) and
327
367
  `dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
328
368
  - **Per-model isolation**: every item is bound to a `provider/id` and injected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
package/src/config.ts CHANGED
@@ -1,19 +1,24 @@
1
1
  // User configuration for continual-harness: ~/.pi/agent/harness.json
2
2
  //
3
- // Phase 2 introduces this file (optional; missing → defaults) to configure:
4
- // - durableScope: where the durable markdown lives. "global" (default,
5
- // ~/.pi/agent/harness-state.md — backward compatible) or "project"
6
- // (~/.pi/agent/harness-state/<slug>.md, slug derived from cwd) so different
7
- // projects keep separate harness state for pi-reflect.
8
- // - remindRefine: opt-in turn_end nudge to run /refine (Phase 2 = reminder
9
- // only; Phase 3 adds opt-in auto-refine).
3
+ // This file (optional; missing → defaults) configures:
4
+ // - autoImport: opt-in durable sync (issue #7). `true` bundles BOTH
5
+ // directions: session_start layered auto-import (global file always + the
6
+ // current project's file) and turn_end layered auto-export whenever the
7
+ // live store changed since the last export. Off by default — importing is
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).
10
15
  //
11
16
  // Robust by design: missing or malformed file → DEFAULT_CONFIG, never throws.
12
17
 
13
18
  import { readFile } from "node:fs/promises";
14
19
  import { homedir } from "node:os";
15
- import { join } from "node:path";
16
- import { DEFAULT_DURABLE_PATH } from "./store.js";
20
+ import { basename, dirname, join } from "node:path";
21
+ import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR, type LayerFile, type ScopeInfo } from "./store.js";
17
22
  import { DEFAULT_INJECTION, normalizeInjection, type NormalizedInjection } from "./select.js";
18
23
 
19
24
  export const DEFAULT_EVERY_TURNS = 50;
@@ -22,7 +27,12 @@ export const DEFAULT_AUTO_EVERY_TURNS = 100;
22
27
  export const DEFAULT_REF_BUMP = 0.03;
23
28
 
24
29
  export interface HarnessConfig {
30
+ /** DEPRECATED (0.9.0): items carry their own scope now (see /harness move);
31
+ * durable I/O is always layered. Parsed for compatibility, ignored. */
25
32
  durableScope?: "global" | "project";
33
+ /** Opt-in durable sync (issue #7): session_start layered auto-import +
34
+ * turn_end layered auto-export when the store changed. Default false. */
35
+ autoImport?: boolean;
26
36
  remindRefine?: {
27
37
  enabled?: boolean;
28
38
  everyTurns?: number;
@@ -47,6 +57,7 @@ export interface HarnessConfig {
47
57
 
48
58
  export const DEFAULT_CONFIG: HarnessConfig = {
49
59
  durableScope: "global",
60
+ autoImport: false,
50
61
  remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
51
62
  autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
52
63
  proposer: "steering",
@@ -65,6 +76,7 @@ let cached: HarnessConfig | undefined;
65
76
  function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
66
77
  return {
67
78
  durableScope: over.durableScope === "project" ? "project" : "global",
79
+ autoImport: over.autoImport === true,
68
80
  remindRefine: {
69
81
  enabled: over.remindRefine?.enabled ?? false,
70
82
  everyTurns: over.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS,
@@ -113,13 +125,41 @@ export function resetConfigCache(): void {
113
125
  }
114
126
 
115
127
  /**
116
- * Resolve the durable markdown path for the configured scope.
117
- * - global → ~/.pi/agent/harness-state.md (the default, unchanged)
118
- * - project → ~/.pi/agent/harness-state/<slug>.md (slug derived from cwd)
128
+ * @deprecated Durable I/O is layered on per-item scope (0.9.0); this key no
129
+ * longer switches the durable path. Kept so old configs keep loading.
119
130
  */
120
131
  export function resolveDurablePath(config: HarnessConfig, cwd?: string): string {
121
132
  if (config.durableScope !== "project") return DEFAULT_DURABLE_PATH;
122
- return join(homedir(), ".pi", "agent", "harness-state", `${projectSlug(cwd)}.md`);
133
+ return projectDurablePath(cwd);
134
+ }
135
+
136
+ /** Path of a project's durable layer file: <projectDir>/<slug>.md. */
137
+ export function projectDurablePath(cwd?: string): string {
138
+ return join(PROJECT_DURABLE_DIR, `${projectSlug(cwd)}.md`);
139
+ }
140
+
141
+ /** The two layers a layered import reads for a session in `cwd`: global first
142
+ * (so the project layer wins id collisions), the project layer only when its
143
+ * slug matches the session cwd. Scope defaults for untagged items ride along
144
+ * (a layer file's items belong to that layer). */
145
+ export function layerFilesFor(cwd?: string): LayerFile[] {
146
+ return [
147
+ { path: DEFAULT_DURABLE_PATH, defaultScope: { scope: "global" } },
148
+ { path: projectDurablePath(cwd), defaultScope: { scope: "project", project: projectSlug(cwd) } },
149
+ ];
150
+ }
151
+
152
+ /** Derive the layer scope a file at `path` represents — used when a user
153
+ * imports a file by explicit path: the global file → global; anything under
154
+ * the project dir → project + slug from the filename stem; other paths →
155
+ * global. Pure path-layout knowledge. */
156
+ export function defaultScopeForPath(path: string): ScopeInfo {
157
+ if (path === DEFAULT_DURABLE_PATH) return { scope: "global" };
158
+ if (dirname(path) === PROJECT_DURABLE_DIR) {
159
+ const stem = basename(path, ".md");
160
+ if (stem) return { scope: "project", project: stem };
161
+ }
162
+ return { scope: "global" };
123
163
  }
124
164
 
125
165
  /** Stable, filesystem-safe slug from a directory path. Falls back to "default". */
package/src/durable.ts ADDED
@@ -0,0 +1,88 @@
1
+ // Layered durable sync (issue #7), opt-in via harness.json { "autoImport": true }.
2
+ //
3
+ // One flag bundles BOTH directions so the durable seam never lags the live
4
+ // store and /refine output survives into NEW sessions without manual
5
+ // export/import ceremony:
6
+ //
7
+ // session_start → syncDurableOnStart(): layered auto-import with the same
8
+ // loss-free merge semantics as /harness import — global file always, the
9
+ // current project's file when its slug matches the session cwd (global
10
+ // first, so the project layer wins id collisions). Idempotent: an import
11
+ // that changes nothing persists nothing and stays quiet.
12
+ //
13
+ // turn_end → registerAutoExport(): layered export when the store version
14
+ // changed since the last export (bounded: the layer files are small).
15
+ // Materializes the session-restored store on the first turn of a session,
16
+ // so a missing/stale durable file self-heals.
17
+ //
18
+ // Both paths are visible (one notify line) and use the same persisted
19
+ // harness-state entries as every other mutation (/tree rollback covers them).
20
+
21
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR } from "./store.js";
23
+ import { exportDurableLayers, getVersion, importDurableLayers } from "./store.js";
24
+ import type { LayerFile, LayerPaths } from "./store.js";
25
+ import { layerFilesFor, loadConfig, projectSlug } from "./config.js";
26
+
27
+ /** Real (homedir-based) layer paths. Tests inject their own. */
28
+ function layerPaths(): LayerPaths {
29
+ return { globalPath: DEFAULT_DURABLE_PATH, projectDir: PROJECT_DURABLE_DIR };
30
+ }
31
+
32
+ // Version at the last auto-export. -1 = never exported this session.
33
+ let lastExportedVersion = -1;
34
+
35
+ /** Test hook / session reset: next turn_end re-exports once (materialize). */
36
+ export function resetDurableSync(): void {
37
+ lastExportedVersion = -1;
38
+ }
39
+
40
+ /**
41
+ * session_start half of the durable sync: layered import (opt-in). `files`
42
+ * defaults to the real layers for ctx.cwd; tests inject temp paths.
43
+ */
44
+ export async function syncDurableOnStart(
45
+ pi: ExtensionAPI,
46
+ ctx: ExtensionContext,
47
+ files?: LayerFile[],
48
+ ): Promise<void> {
49
+ const config = await loadConfig();
50
+ if (!config.autoImport) return;
51
+ const layers = files ?? layerFilesFor(ctx.cwd);
52
+ const res = await importDurableLayers(layers, {}, (snapshot, ver) => {
53
+ pi.appendEntry("harness-state", { state: snapshot, version: ver });
54
+ });
55
+ if (res.missingFile) return; // nothing durable yet — stay quiet
56
+ const changed = res.created + res.updated + res.pruned;
57
+ if (changed === 0) return; // in sync — no noise
58
+ const bits = [`${res.created} created`, `${res.updated} updated`];
59
+ if (res.pruned > 0) bits.push(`${res.pruned} pruned`);
60
+ ctx.ui.notify(`Continual Harness: durable sync imported ${res.imported} item(s) (${bits.join(", ")}).`, "info");
61
+ }
62
+
63
+ /**
64
+ * turn_end half of the durable sync: layered export when the live store
65
+ * changed since the last export (opt-in, bundled with autoImport). `paths`
66
+ * defaults to the real layer dirs; tests inject temp dirs.
67
+ */
68
+ export function registerAutoExport(pi: ExtensionAPI, paths?: LayerPaths): void {
69
+ pi.on("turn_end", async (_event, ctx) => {
70
+ const config = await loadConfig();
71
+ if (!config.autoImport) return;
72
+ if (getVersion() === lastExportedVersion) return;
73
+ try {
74
+ const written = await exportDurableLayers(
75
+ paths ?? layerPaths(),
76
+ projectSlug(ctx.cwd),
77
+ );
78
+ lastExportedVersion = getVersion();
79
+ ctx.ui.notify(
80
+ `Continual Harness: durable state exported (${written.length} file(s)).`,
81
+ "info",
82
+ );
83
+ } catch (err) {
84
+ // Soft-fail but leave lastExportedVersion stale → retried next turn.
85
+ ctx.ui.notify(`Durable auto-export failed: ${(err as Error).message}`, "warning");
86
+ }
87
+ });
88
+ }