pi-continual-harness 0.8.1 → 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,26 +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
- Subcommands, flags, `--kind`/`--model` values, and `keep`/`drop` item ids all
71
- autocomplete in the TUI as you type after `/harness `.
72
-
73
- `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
74
91
  existing entry are updated (offline edits win on content/evidence/importance),
75
92
  new entries are created. By default nothing is deleted — `--prune` also drops
76
- active items whose id is no longer in the file (inactive items are always
77
- 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:
78
95
 
79
96
  ```
80
97
  /reflect ~/.pi/agent/harness-state.md
98
+ /reflect ~/.pi/agent/harness-state/<project-slug>.md
81
99
  ```
82
100
 
83
101
  The model-facing tools:
@@ -117,7 +135,7 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
117
135
 
118
136
  ```json
119
137
  {
120
- "durableScope": "global",
138
+ "autoImport": false,
121
139
  "proposer": "steering",
122
140
  "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
123
141
  "remindRefine": { "enabled": false, "everyTurns": 50 },
@@ -126,10 +144,15 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
126
144
  }
127
145
  ```
128
146
 
129
- - **`durableScope`** — `"global"` (default) writes the durable markdown to
130
- `~/.pi/agent/harness-state.md`; `"project"` writes to
131
- `~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
132
- 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).
133
156
  - **`injection`** — the selection policy for WHAT gets surfaced in the system
134
157
  prompt each turn (see [Injection selection](#injection-selection-on-by-default)).
135
158
  ON by default: items are importance-ordered, capped at `maxPerKind` (default
@@ -157,6 +180,13 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
157
180
  outcomes is intentionally NOT done (high false-positive); use `/harness drop`,
158
181
  `prune --decay`, or the `dedupe` proposer for that.
159
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
+
160
190
  ## How it works
161
191
 
162
192
  1. `/refine` gathers recent trajectory evidence from the current session branch.
@@ -313,19 +343,26 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
313
343
 
314
344
  ## Status
315
345
 
316
- 0.8.x. Implemented:
346
+ 0.9.x. Implemented:
317
347
 
318
348
  - Unified harness-state store with branch-local snapshots (`/tree` rollback).
319
349
  - Online `/refine` + `harness_mutate` / `harness_list` tools.
320
- - 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.
321
354
  - Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
322
355
  <id>`.
323
356
  - **pi-mem composition**: `/harness push-mem [--all|--kind|--model]` steers the agent
324
357
  to persist active items into pi-mem (soft-fail; no dependency).
325
- - 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
326
363
  opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
327
- `turn_end` outcome-importance loop — the package's two opt-in autonomous
328
- paths (both off by default).
364
+ `turn_end` outcome-importance loop — the package's opt-in autonomous
365
+ paths (all off by default).
329
366
  - **Pluggable delta proposers** with a registry: `steering` (default) and
330
367
  `dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
331
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.1",
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
+ }
package/src/harness.ts CHANGED
@@ -1,34 +1,51 @@
1
1
  // /harness — durable I/O. The two-way round-trip seam with pi-reflect.
2
2
  //
3
- // /harness status [path] counts + durable file presence/mtime
4
- // /harness export [path] write active items to a markdown file
5
- // /harness import [--prune] [path] parse it back and merge (durable wins)
3
+ // /harness status [path] counts + durable layer presence/mtime
4
+ // /harness export [path] layered export (or full snapshot to path)
5
+ // /harness import [--prune] [path] layered import (or single file from path)
6
+ // /harness move <id> <global|project> move an item between durable layers
7
+ // /harness split steer the agent to classify scopes
6
8
  //
7
9
  // The command registers getArgumentCompletions so the TUI offers a filtered
8
- // menu of subcommands (and, one level deeper, flags / item ids / kinds) as
10
+ // menu of subcommands (and, one level deeper, flags / item ids / values) as
9
11
  // you type — see completions() below. The handler itself stays parsing-only.
10
12
  //
11
- // export writes the active items to ~/.pi/agent/harness-state.md (best-effort);
12
- // import parses that file and merges into the live store. Because pi-reflect
13
- // edits markdown files and git-commits, pointing it at the same file closes the
14
- // loop: offline refinement flows back into the online store.
13
+ // Durable I/O is LAYERED on per-item scope (issue #7): every item belongs to
14
+ // the global layer (~/.pi/agent/harness-state.md) or a project layer
15
+ // (~/.pi/agent/harness-state/<slug>.md). Without an explicit path, export
16
+ // writes each layer from the items' own scope and import merges global first,
17
+ // then the current project's file (project wins id collisions; --prune drops
18
+ // only items absent from EVERY layer). An explicit path keeps the classic
19
+ // single-file semantics — untagged items default to the layer the path
20
+ // represents (see defaultScopeForPath).
15
21
  //
16
- // Manual only (no session_start auto-import): importing is an explicit,
17
- // reviewable action, matching the package's "no autonomous mutation" stance.
22
+ // Importing stays a manual, reviewable action by default; opt into the
23
+ // session_start auto-import + turn_end auto-export bundle with
24
+ // harness.json { "autoImport": true } (see durable.ts).
18
25
 
19
26
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
20
27
  import { stat } from "node:fs/promises";
21
28
  import {
29
+ applyDeltas,
22
30
  bumpImportance,
23
31
  decayAndPrune,
32
+ DEFAULT_DURABLE_PATH,
24
33
  exportDurable,
34
+ exportDurableLayers,
25
35
  getState,
36
+ importDurableLayers,
26
37
  modelKey,
38
+ PROJECT_DURABLE_DIR,
27
39
  reconstructFromDurable,
28
40
  } from "./store.js";
29
- import { loadConfig, resolveDurablePath } from "./config.js";
41
+ import {
42
+ defaultScopeForPath,
43
+ layerFilesFor,
44
+ projectDurablePath,
45
+ projectSlug,
46
+ } from "./config.js";
30
47
  import { KIND_LABEL } from "./types.js";
31
- import type { ComponentKind, HarnessItem } from "./types.js";
48
+ import type { ComponentKind, Delta, HarnessItem } from "./types.js";
32
49
 
33
50
  /** One row of the /harness subcommand menu (label = name, value = name). */
34
51
  interface CompletionEntry {
@@ -37,12 +54,14 @@ interface CompletionEntry {
37
54
  }
38
55
 
39
56
  const SUBCOMMANDS: CompletionEntry[] = [
40
- { name: "import", description: "Import durable state (--prune to prune stale items)" },
41
- { name: "export", description: "Export active items to durable file" },
42
- { name: "status", description: "Show harness status (active/total, per-kind counts, durable file)" },
57
+ { name: "import", description: "Import durable state, layered (--prune to prune stale items)" },
58
+ { name: "export", description: "Export active items to durable layers (or one file with a path)" },
59
+ { name: "status", description: "Show harness status (active/total, per-kind counts, durable layers)" },
43
60
  { name: "prune", description: "Decay & prune inactive items (--decay <days>)" },
44
61
  { name: "keep", description: "Bump item importance (+0.1)" },
45
62
  { name: "drop", description: "Lower item importance (−0.1)" },
63
+ { name: "move", description: "Move an item between durable scopes (global | project)" },
64
+ { name: "split", description: "Steer the agent to classify every item global vs project" },
46
65
  { name: "push-mem", description: "Persist active items to pi-mem (--all, --kind, --model)" },
47
66
  ];
48
67
 
@@ -128,10 +147,25 @@ function completions(argumentPrefix: string) {
128
147
  return items.length > 0 ? items : null;
129
148
  }
130
149
 
131
- // Positional id completion for keep/drop: the one argument they take.
132
- if (sub === "keep" || sub === "drop") {
133
- const idCommitted = before.slice(1).some((t) => !t.startsWith("--"));
134
- if (idCommitted) return null; // id already chosen; nothing left to complete
150
+ // Positional id completion for keep/drop/move: the first argument they take.
151
+ if (sub === "keep" || sub === "drop" || sub === "move") {
152
+ const positionals = before.slice(1).filter((t) => !t.startsWith("--"));
153
+ // move takes a second positional: the target scope.
154
+ if (sub === "move") {
155
+ if (positionals.length >= 2) return null; // id + scope chosen; done
156
+ if (positionals.length === 1) {
157
+ return filterValues(
158
+ [
159
+ { value: "global", description: "The shared durable file (~/.pi/agent/harness-state.md)" },
160
+ { value: "project", description: "This project's durable file (harness-state/<slug>.md)" },
161
+ ],
162
+ last,
163
+ before,
164
+ );
165
+ }
166
+ } else if (positionals.length > 0) {
167
+ return null; // id already chosen; nothing left to complete
168
+ }
135
169
  const items = getState()
136
170
  .items.filter((i) => i.active && i.id.startsWith(last))
137
171
  .map((i: HarnessItem) => ({
@@ -193,12 +227,18 @@ export function registerHarness(pi: ExtensionAPI): void {
193
227
  case "drop":
194
228
  await handleBump(pi, ctx, rest, -0.1, "drop");
195
229
  return;
230
+ case "move":
231
+ await handleMove(pi, ctx, rest);
232
+ return;
233
+ case "split":
234
+ await handleSplit(pi, ctx);
235
+ return;
196
236
  case "push-mem":
197
237
  await handlePushMem(pi, ctx, rest);
198
238
  return;
199
239
  case "status":
200
240
  default:
201
- await handleStatus(ctx, rest);
241
+ await handleStatus(ctx);
202
242
  return;
203
243
  }
204
244
  },
@@ -206,11 +246,9 @@ export function registerHarness(pi: ExtensionAPI): void {
206
246
  });
207
247
  }
208
248
 
209
- async function resolvePath(rest: string[], ctx: ExtensionCommandContext): Promise<string> {
210
- const explicit = rest.find((a) => !a.startsWith("-"));
211
- if (explicit) return explicit;
212
- const config = await loadConfig();
213
- return resolveDurablePath(config, ctx.cwd);
249
+ /** Explicit path argument, if any (flags like --prune are skipped). */
250
+ function explicitPath(rest: string[]): string | undefined {
251
+ return rest.find((a) => !a.startsWith("-"));
214
252
  }
215
253
 
216
254
  async function handleImport(
@@ -219,22 +257,38 @@ async function handleImport(
219
257
  rest: string[],
220
258
  ): Promise<void> {
221
259
  const prune = rest.includes("--prune");
222
- const path = await resolvePath(rest, ctx);
260
+ const explicit = explicitPath(rest);
223
261
  ctx.ui.setStatus("harness", `Importing durable state${prune ? " (prune)" : ""}…`);
224
262
  try {
225
- const res = await reconstructFromDurable(path, { prune }, (snapshot, ver) => {
226
- pi.appendEntry("harness-state", { state: snapshot, version: ver });
227
- });
228
- if (res.missingFile) {
229
- ctx.ui.notify(
230
- `No durable file at ${path}. Run /refine --commit or /harness export first.`,
231
- "warning",
232
- );
233
- return;
263
+ let res;
264
+ if (explicit) {
265
+ // Single-file semantics; untagged items adopt the layer the path
266
+ // represents (global file → global; project dir → that project).
267
+ const defaultScope = defaultScopeForPath(explicit);
268
+ res = await reconstructFromDurable(explicit, { prune }, (snapshot, ver) => {
269
+ pi.appendEntry("harness-state", { state: snapshot, version: ver });
270
+ }).then((r) => ({ ...r, path: explicit }));
271
+ if (res.missingFile) {
272
+ ctx.ui.notify(`No durable file at ${explicit}. Run /refine --commit or /harness export first.`, "warning");
273
+ return;
274
+ }
275
+ } else {
276
+ // Layered: global always + the current project's file, merged in one
277
+ // pass (project wins id collisions; --prune is union-scoped).
278
+ res = await importDurableLayers(layerFilesFor(ctx.cwd), { prune }, (snapshot, ver) => {
279
+ pi.appendEntry("harness-state", { state: snapshot, version: ver });
280
+ });
281
+ if (res.missingFile) {
282
+ ctx.ui.notify(
283
+ `No durable files at ${DEFAULT_DURABLE_PATH} (or ${projectDurablePath(ctx.cwd)}). Run /refine --commit or /harness export first.`,
284
+ "warning",
285
+ );
286
+ return;
287
+ }
234
288
  }
235
289
  const bits = [`${res.created} created`, `${res.updated} updated`];
236
290
  if (prune) bits.push(`${res.pruned} pruned`);
237
- ctx.ui.notify(`Imported ${res.imported} item(s) from ${path} (${bits.join(", ")}).`, "info");
291
+ ctx.ui.notify(`Imported ${res.imported} item(s) (${bits.join(", ")}).`, "info");
238
292
  } catch (err) {
239
293
  ctx.ui.notify(`Harness import failed: ${(err as Error).message}`, "error");
240
294
  }
@@ -242,12 +296,24 @@ async function handleImport(
242
296
  }
243
297
 
244
298
  async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
245
- const path = await resolvePath(rest, ctx);
246
299
  ctx.ui.setStatus("harness", "Exporting durable state…");
247
300
  try {
248
- const written = await exportDurable(path);
249
- const n = getState().items.filter((i) => i.active).length;
250
- ctx.ui.notify(`Exported ${n} active item(s) to ${written}`, "info");
301
+ const explicit = explicitPath(rest);
302
+ if (explicit) {
303
+ // Full single-file snapshot (both scopes; project items tagged with a
304
+ // `scope:` sub-line so the file round-trips from anywhere).
305
+ const written = await exportDurable(explicit);
306
+ const n = getState().items.filter((i) => i.active).length;
307
+ ctx.ui.notify(`Exported ${n} active item(s) to ${written}`, "info");
308
+ } else {
309
+ // Layered: partition by each item's own scope.
310
+ const written = await exportDurableLayers(
311
+ { globalPath: DEFAULT_DURABLE_PATH, projectDir: PROJECT_DURABLE_DIR },
312
+ projectSlug(ctx.cwd),
313
+ );
314
+ const n = getState().items.filter((i) => i.active).length;
315
+ ctx.ui.notify(`Exported ${n} active item(s) to ${written.length} layer file(s): ${written.join(", ")}`, "info");
316
+ }
251
317
  } catch (err) {
252
318
  ctx.ui.notify(`Harness export failed: ${(err as Error).message}`, "error");
253
319
  }
@@ -307,6 +373,84 @@ async function handleBump(
307
373
  ctx.ui.notify(`${label}: "${preview}" → importance ${item.importance.toFixed(2)}`, "info");
308
374
  }
309
375
 
376
+ /** /harness move <id> <global|project> — flip an item's durable layer.
377
+ * Implemented as an audited update delta (no actor model: like keep/drop,
378
+ * this is cross-model user maintenance). "project" is stamped with the
379
+ * CURRENT session's slug so the item lands in this project's file. */
380
+ async function handleMove(
381
+ pi: ExtensionAPI,
382
+ ctx: ExtensionCommandContext,
383
+ rest: string[],
384
+ ): Promise<void> {
385
+ const positionals = rest.filter((a) => a && !a.startsWith("-"));
386
+ const id = positionals[0];
387
+ const scope = positionals[1];
388
+ if (!id || (scope !== "global" && scope !== "project")) {
389
+ ctx.ui.notify(`/harness move requires: <id> <global|project> (ids via /harness status).`, "warning");
390
+ return;
391
+ }
392
+ const delta: Delta =
393
+ scope === "project"
394
+ ? { op: "update", id, scope, project: projectSlug(ctx.cwd) }
395
+ : { op: "update", id, scope };
396
+ try {
397
+ const [applied] = applyDeltas([delta], (snapshot, ver) => {
398
+ pi.appendEntry("harness-state", { state: snapshot, version: ver });
399
+ });
400
+ if (applied?.op === "update") {
401
+ const target =
402
+ scope === "project"
403
+ ? `project "${applied.after.project ?? ""}"`
404
+ : "global";
405
+ ctx.ui.notify(
406
+ `Moved ${id} → ${target}: "${preview(applied.after.content)}" (run /harness export to update the layer files).`,
407
+ "info",
408
+ );
409
+ }
410
+ } catch (err) {
411
+ ctx.ui.notify(`/harness move failed: ${(err as Error).message}`, "error");
412
+ }
413
+ }
414
+
415
+ /** Compose the /harness split steering message: the agent classifies every
416
+ * active item global-vs-project and applies the classification as ONE
417
+ * harness_mutate batch of scope-only update deltas — visible in the
418
+ * transcript, audited, and /tree-rollback-able, exactly like push-mem. */
419
+ function buildSplitPrompt(items: HarnessItem[], slug: string, cwd: string): string {
420
+ const lines = [
421
+ `/harness split — classify ${items.length} Continual Harness item(s) into durable scopes`,
422
+ "",
423
+ `Current project: ${cwd} (slug \"${slug}\").`,
424
+ "",
425
+ "For EACH item below, decide its durable scope:",
426
+ "- `project` — only useful when working in THIS project (deploy procedures, project architecture, local conventions, project-specific quirks).",
427
+ "- `global` — useful in every project (model quirks, owner preferences, general engineering practices).",
428
+ "",
429
+ "Then apply the whole classification in ONE harness_mutate call: one update delta per item, changing ONLY the scope field, e.g.",
430
+ '{ "op": "update", "id": "h_xxx", "scope": "project" }',
431
+ `(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.`,
432
+ "",
433
+ "Items:",
434
+ ];
435
+ items.forEach((i, n) => {
436
+ lines.push(
437
+ `${n + 1}. [${i.id}] (${i.kind}, importance ${i.importance.toFixed(2)}, ${i.scope === "project" ? `project \"${i.project ?? ""}\"` : "global"}) ${i.content}`,
438
+ ` evidence: ${i.evidence}`,
439
+ );
440
+ });
441
+ return lines.join("\n");
442
+ }
443
+
444
+ async function handleSplit(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
445
+ const items = getState().items.filter((i) => i.active);
446
+ if (items.length === 0) {
447
+ ctx.ui.notify("No active items to split (run /refine first).", "warning");
448
+ return;
449
+ }
450
+ pi.sendUserMessage(buildSplitPrompt(items, projectSlug(ctx.cwd), ctx.cwd));
451
+ ctx.ui.notify(`Steering agent to classify ${items.length} item(s) global vs project "${projectSlug(ctx.cwd)}".`, "info");
452
+ }
453
+
310
454
  /** Compose a steering message that asks the agent to persist the given active
311
455
  * items to long-term memory via a memory tool (pi-mem's save_memory).
312
456
  * Tool-agnostic: if no memory tool is present the agent says so; we never
@@ -374,8 +518,7 @@ async function handlePushMem(
374
518
  ctx.ui.notify(`Steering agent to persist ${items.length} ${scope}${modelNote} to pi-mem.`, "info");
375
519
  }
376
520
 
377
- async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
378
- const path = await resolvePath(rest, ctx);
521
+ async function handleStatus(ctx: ExtensionCommandContext): Promise<void> {
379
522
  const items = getState().items;
380
523
  const active = items.filter((i) => i.active);
381
524
  const key = modelKey(ctx.model);
@@ -385,19 +528,25 @@ async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promi
385
528
  // the current model's share so the per-model picture is still visible.
386
529
  const counts: Record<ComponentKind, number> = { prompt: 0, memory: 0, skill: 0, subagent: 0 };
387
530
  for (const i of active) counts[i.kind] += 1;
388
- let fileState: string;
389
- try {
390
- const st = await stat(path);
391
- fileState = `${path} (modified ${st.mtime.toISOString()})`;
392
- } catch {
393
- fileState = `none at ${path}`;
531
+ const nProject = active.filter((i) => i.scope === "project").length;
532
+ async function layerState(path: string): Promise<string> {
533
+ try {
534
+ const st = await stat(path);
535
+ return `${path} (modified ${st.mtime.toISOString()})`;
536
+ } catch {
537
+ return `none at ${path}`;
538
+ }
394
539
  }
540
+ const durable =
541
+ ` Durable: global ${await layerState(DEFAULT_DURABLE_PATH)};` +
542
+ ` project ${await layerState(projectDurablePath(ctx.cwd))}.`;
395
543
  ctx.ui.setStatus("harness", undefined);
396
544
  ctx.ui.notify(
397
545
  `Harness: ${active.length} active / ${items.length} total — ` +
398
- `prompt ${counts.prompt}, memory ${counts.memory}, skill ${counts.skill}, subagent ${counts.subagent}.` +
546
+ `prompt ${counts.prompt}, memory ${counts.memory}, skill ${counts.skill}, subagent ${counts.subagent}` +
547
+ ` — scope ${active.length - nProject} global / ${nProject} project.` +
399
548
  (key ? ` ${mine} active for [${key}] across ${models.length} model(s).` : "") +
400
- ` Durable: ${fileState}.`,
549
+ durable,
401
550
  "info",
402
551
  );
403
552
  }
package/src/index.ts CHANGED
@@ -14,7 +14,9 @@ import { registerTools } from "./tools.js";
14
14
  import { registerReminder, resetReminder } from "./remind.js";
15
15
  import { registerAutoRefine, resetAutoRefine } from "./auto-refine.js";
16
16
  import { registerOutcome, resetOutcome } from "./outcome.js";
17
- import { getState, reconstruct, setActiveModelKey, STATE_ENTRY } from "./store.js";
17
+ import { registerAutoExport, resetDurableSync, syncDurableOnStart } from "./durable.js";
18
+ import { getState, reconstruct, setActiveModelKey, setSessionProject, STATE_ENTRY } from "./store.js";
19
+ import { projectSlug } from "./config.js";
18
20
 
19
21
  export default function continualHarness(pi: ExtensionAPI): void {
20
22
  // Rebuild in-memory state from the current branch on every session start /
@@ -26,14 +28,22 @@ export default function continualHarness(pi: ExtensionAPI): void {
26
28
  resetAutoRefine();
27
29
  resetReminder();
28
30
  resetOutcome();
31
+ resetDurableSync();
29
32
  // Drop any stale active-model key from the previous session: the next
30
33
  // before_agent_start re-caches it before any tool can run.
31
34
  setActiveModelKey(undefined);
35
+ // Cache the session's project slug BEFORE any tool delta can arrive: it is
36
+ // the server-side stamp for scope:"project" (harness_mutate has no ctx).
37
+ setSessionProject(projectSlug(ctx.cwd));
32
38
  reconstruct(ctx.sessionManager.getBranch() as Iterable<unknown>);
33
39
  const n = getState().items.length;
34
40
  if (n > 0) {
35
41
  ctx.ui.notify(`Continual Harness: ${n} item(s) restored`, "info");
36
42
  }
43
+ // Opt-in durable sync (issue #7): layered auto-import on top of the
44
+ // branch-restored state, so refinements made in OTHER sessions reach this
45
+ // one. No-op (and quiet) when autoImport is off or nothing changed.
46
+ await syncDurableOnStart(pi, ctx);
37
47
  });
38
48
 
39
49
  registerInjection(pi);
@@ -41,9 +51,9 @@ export default function continualHarness(pi: ExtensionAPI): void {
41
51
  registerRefine(pi);
42
52
  registerHarness(pi);
43
53
  registerReminder(pi);
44
- // Register outcome BEFORE auto-refine: the test fake overwrites turn_end on
45
- // each registration (Map.set), and the auto-refine integration tests assume
46
- // auto-refine is the surviving handler. In production pi, all three run.
54
+ // Durable sync's turn_end export (opt-in via autoImport) registers before
55
+ // outcome/auto-refine; in production pi every turn_end handler runs.
56
+ registerAutoExport(pi);
47
57
  registerOutcome(pi);
48
58
  registerAutoRefine(pi);
49
59
  }
package/src/refine.ts CHANGED
@@ -19,9 +19,10 @@
19
19
 
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
- import { applyDeltas, exportDurable, modelKey, REFINE_ENTRY, snapshotState } from "./store.js";
22
+ import { applyDeltas, DEFAULT_DURABLE_PATH, exportDurableLayers, modelKey, PROJECT_DURABLE_DIR, REFINE_ENTRY, snapshotState } from "./store.js";
23
23
  import { getProposer } from "./proposer.js";
24
24
  import type { CompleteOptions, CompleteResult } from "./proposer.js";
25
+ import { projectSlug } from "./config.js";
25
26
 
26
27
  const DEFAULT_EVIDENCE_BYTES = 16000;
27
28
  export const DEFAULT_LOOKBACK_TURNS = 25;
@@ -151,11 +152,15 @@ export async function runRefine(
151
152
  });
152
153
 
153
154
  if (commit) {
154
- // Flush current state to the durable file first so the agent refines
155
- // against the same view pi-reflect / pi-mem would see.
155
+ // Flush current state to the durable layers first so the agent refines
156
+ // against the same view pi-reflect / pi-mem would see (layered: items land
157
+ // in the file their own scope selects).
156
158
  try {
157
- const path = await exportDurable();
158
- ctx.ui.notify(`Durable state exported to ${path}`, "info");
159
+ const written = await exportDurableLayers(
160
+ { globalPath: DEFAULT_DURABLE_PATH, projectDir: PROJECT_DURABLE_DIR },
161
+ projectSlug(ctx.cwd),
162
+ );
163
+ ctx.ui.notify(`Durable state exported to ${written.length} layer file(s)`, "info");
159
164
  } catch (err) {
160
165
  ctx.ui.notify(`Durable export failed: ${(err as Error).message}`, "warning");
161
166
  }
package/src/store.ts CHANGED
@@ -10,15 +10,23 @@
10
10
  // to a markdown file pi-reflect can read and refine offline, and pi-mem can
11
11
  // ingest. Best-effort; the package works without it.
12
12
 
13
- import { mkdir, readFile, writeFile } from "node:fs/promises";
13
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
  import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } from "./types.js";
17
17
 
18
+ /** Resolved durable-layer scope for a parsed item. */
19
+ export interface ScopeInfo {
20
+ scope: "global" | "project";
21
+ project?: string;
22
+ }
23
+
18
24
  const STATE_ENTRY = "harness-state";
19
25
  const REFINE_ENTRY = "harness-refinement";
20
26
 
21
27
  export const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
28
+ /** Directory holding per-project durable files: <slug>.md (see projectSlug). */
29
+ export const PROJECT_DURABLE_DIR = join(homedir(), ".pi", "agent", "harness-state");
22
30
 
23
31
  const IMPORTANCE_FLOOR = 0.3;
24
32
 
@@ -38,6 +46,12 @@ let version = 0;
38
46
  // "model unknown" — tools fall back gracefully (create orphans, list all).
39
47
  let activeModelKey: string | undefined;
40
48
 
49
+ // Same pattern for the session's project slug: the durable-layer stamp for
50
+ // scope:"project" deltas that arrive without an explicit slug (the
51
+ // model-facing tools have no ctx). Cached once per session_start from ctx.cwd
52
+ // so scope stamps are stable no matter where a delta originates.
53
+ let sessionProject: string | undefined;
54
+
41
55
  /** Canonical owner key for a model: "provider/id". Accepts the structural
42
56
  * shape of pi-ai's Model (provider + id) without importing the type. */
43
57
  export function modelKey(m?: { provider: string; id: string }): string | undefined {
@@ -54,6 +68,18 @@ export function getActiveModelKey(): string | undefined {
54
68
  return activeModelKey;
55
69
  }
56
70
 
71
+ /** Cache the session's project slug (called from session_start). */
72
+ export function setSessionProject(slug: string | undefined): void {
73
+ sessionProject = slug;
74
+ }
75
+
76
+ /** Current store version — bumped by every persisted mutation, reset by
77
+ * reconstruct. The auto-export loop compares it to detect "store changed
78
+ * since the last durable export". */
79
+ export function getVersion(): number {
80
+ return version;
81
+ }
82
+
57
83
  export function getState(): HarnessState {
58
84
  return state;
59
85
  }
@@ -93,9 +119,13 @@ function applyOne(delta: Delta, actorModel?: string): AppliedDelta {
93
119
  // Owner: explicit delta wins; else the actor model; else orphan (""),
94
120
  // adopted by the active model on first contact.
95
121
  ownerModel: delta.ownerModel ?? actorModel ?? "",
122
+ // Durable-layer scope: global unless the delta says project (which
123
+ // requires a slug — explicit or the cached session slug).
124
+ scope: "global",
96
125
  createdAt: now,
97
126
  updatedAt: now,
98
127
  };
128
+ applyDeltaScope(item, delta);
99
129
  state.items.push(item);
100
130
  return { op: "create", item };
101
131
  }
@@ -117,6 +147,7 @@ function applyOne(delta: Delta, actorModel?: string): AppliedDelta {
117
147
  ownerModel: delta.ownerModel ?? before.ownerModel,
118
148
  updatedAt: Date.now(),
119
149
  };
150
+ applyDeltaScope(after, delta);
120
151
  state.items[idx] = after;
121
152
  return { op: "update", before, after };
122
153
  }
@@ -129,6 +160,28 @@ function applyOne(delta: Delta, actorModel?: string): AppliedDelta {
129
160
  return { op: "delete", id: delta.id, reason: delta.reason };
130
161
  }
131
162
 
163
+ /** Stamp a delta's scope onto an item. No-op when the delta is silent about
164
+ * scope. "project" needs a slug: explicit delta.project (set by
165
+ * /harness move) or the cached session slug (model-driven scope updates);
166
+ * throws otherwise so the surrounding applyDeltas rolls the batch back. */
167
+ function applyDeltaScope(
168
+ item: HarnessItem,
169
+ delta: { scope?: "global" | "project"; project?: string },
170
+ ): void {
171
+ if (delta.scope === undefined) return;
172
+ if (delta.scope === "global") {
173
+ item.scope = "global";
174
+ delete item.project;
175
+ return;
176
+ }
177
+ const slug = delta.project ?? sessionProject;
178
+ if (!slug) {
179
+ throw new Error('scope: "project" requires a project slug (no session cwd cached)');
180
+ }
181
+ item.scope = "project";
182
+ item.project = slug;
183
+ }
184
+
132
185
  /** Enforce that `actorModel` owns `item`; no-op when the actor is unknown
133
186
  * (manual / cross-model paths). Throws an audited, rollback-triggering error
134
187
  * otherwise. */
@@ -178,10 +231,23 @@ export function reconstruct(entries: Iterable<unknown>): void {
178
231
  last = entry.data.state;
179
232
  }
180
233
  }
181
- // Normalize legacy snapshots that predate ownerModel: missing → orphan (""),
182
- // adopted by the active model on first contact (see adoptOrphans).
234
+ // Normalize legacy snapshots: missing ownerModel → orphan (""), adopted by
235
+ // the active model on first contact (see adoptOrphans); missing/corrupt
236
+ // scope → "global" (a project item without a slug cannot be placed in a
237
+ // layer, so it falls back to the global durable file).
183
238
  state = last
184
- ? { items: last.items.map((i) => ({ ...i, ownerModel: i.ownerModel ?? "" })) }
239
+ ? {
240
+ items: last.items.map((i): HarnessItem => {
241
+ const item: HarnessItem = { ...i, ownerModel: i.ownerModel ?? "", scope: "global" };
242
+ if (i.scope === "project" && i.project) {
243
+ item.scope = "project";
244
+ item.project = i.project;
245
+ } else {
246
+ delete item.project;
247
+ }
248
+ return item;
249
+ }),
250
+ }
185
251
  : { items: [] };
186
252
  version = 0;
187
253
  }
@@ -256,28 +322,95 @@ export function adoptOrphans(
256
322
  export { STATE_ENTRY, REFINE_ENTRY, IMPORTANCE_FLOOR };
257
323
 
258
324
  // ---- Durable export (composition seam with pi-reflect / pi-mem) -------------
259
-
260
- export async function exportDurable(path = DEFAULT_DURABLE_PATH): Promise<string> {
325
+ //
326
+ // Two write modes:
327
+ // - exportDurable(path): a FULL single-file snapshot (every active item,
328
+ // both scopes, project items tagged with a `scope:` sub-line so any copy of
329
+ // the file round-trips). Used for explicit-path /harness export and
330
+ // /refine --commit's legacy single-file output.
331
+ // - exportDurableLayers(paths, currentSlug): the LAYERED mode — partitions
332
+ // active items by their own scope: global-scoped → paths.globalPath,
333
+ // project-scoped → paths.projectDir/<slug>.md (one file per slug).
334
+
335
+ /** Render the durable markdown for a set of items (empty → placeholder). */
336
+ function renderDurable(items: HarnessItem[]): string {
261
337
  const lines: string[] = ["# Continual Harness State", ""];
262
338
  const kinds: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
263
339
  for (const kind of kinds) {
264
- const items = state.items.filter((i) => i.kind === kind && i.active);
265
- if (items.length === 0) continue;
340
+ const forKind = items.filter((i) => i.kind === kind);
341
+ if (forKind.length === 0) continue;
266
342
  lines.push(`## ${titleFor(kind)}`, "");
267
- for (const i of items) {
343
+ for (const i of forKind) {
268
344
  lines.push(`- **[${i.id}]** (importance ${i.importance.toFixed(2)}) ${i.content}`);
269
345
  lines.push(` - evidence: ${i.evidence}`);
270
346
  if (i.ownerModel) lines.push(` - model: ${i.ownerModel}`);
347
+ if (i.scope === "project") lines.push(` - scope: project (${i.project ?? ""})`);
271
348
  }
272
349
  lines.push("");
273
350
  }
274
351
  if (lines.length <= 2) lines.push("_(no active items)_", "");
275
- const body = lines.join("\n");
352
+ return lines.join("\n");
353
+ }
354
+
355
+ async function writeFileDur(path: string, body: string): Promise<void> {
276
356
  await mkdir(dirname(path), { recursive: true }).catch(() => {});
277
357
  await writeFile(path, body, "utf8");
358
+ }
359
+
360
+ export async function exportDurable(path = DEFAULT_DURABLE_PATH): Promise<string> {
361
+ await writeFileDur(path, renderDurable(state.items.filter((i) => i.active)));
278
362
  return path;
279
363
  }
280
364
 
365
+ /** Where the layered export writes each project's items. */
366
+ export interface LayerPaths {
367
+ globalPath: string;
368
+ projectDir: string;
369
+ }
370
+
371
+ async function fileExists(path: string): Promise<boolean> {
372
+ try {
373
+ await stat(path);
374
+ return true;
375
+ } catch {
376
+ return false;
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Layered export: global file always; one file per project slug that has
382
+ * active items; the CURRENT session's project file is also rewritten when it
383
+ * already exists on disk (so deleting the last project item empties that
384
+ * layer instead of leaving a stale file that auto-import would resurrect).
385
+ * Project files the store has no items for are never created.
386
+ */
387
+ export async function exportDurableLayers(paths: LayerPaths, currentSlug?: string): Promise<string[]> {
388
+ const active = state.items.filter((i) => i.active);
389
+ const globalItems = active.filter((i) => !(i.scope === "project" && i.project));
390
+ const bySlug = new Map<string, HarnessItem[]>();
391
+ for (const i of active) {
392
+ if (i.scope === "project" && i.project) {
393
+ const arr = bySlug.get(i.project) ?? [];
394
+ arr.push(i);
395
+ bySlug.set(i.project, arr);
396
+ }
397
+ }
398
+ const written: string[] = [];
399
+ await writeFileDur(paths.globalPath, renderDurable(globalItems));
400
+ written.push(paths.globalPath);
401
+ const slugs = new Set(bySlug.keys());
402
+ if (currentSlug) slugs.add(currentSlug);
403
+ for (const slug of slugs) {
404
+ const file = join(paths.projectDir, `${slug}.md`);
405
+ const items = bySlug.get(slug) ?? [];
406
+ // Don't create empty per-project files as a side effect of visiting.
407
+ if (items.length === 0 && !(await fileExists(file))) continue;
408
+ await writeFileDur(file, renderDurable(items));
409
+ written.push(file);
410
+ }
411
+ return written;
412
+ }
413
+
281
414
  function titleFor(kind: ComponentKind): string {
282
415
  switch (kind) {
283
416
  case "prompt":
@@ -299,7 +432,10 @@ function titleFor(kind: ComponentKind): string {
299
432
  //
300
433
  // Merge semantics (predictable, loss-free by default):
301
434
  // - parsed item whose id matches an existing item → UPDATE in place
302
- // (durable wins on content/evidence/importance; reactivated; createdAt kept).
435
+ // (durable wins on content/evidence/importance/owner/scope; reactivated;
436
+ // createdAt kept). When nothing actually differs the update is a NO-OP —
437
+ // no version bump, no persist — so repeated imports (and the opt-in
438
+ // session_start auto-import) stay idempotent and don't spam the tree.
303
439
  // - parsed item with a new/foreign id → CREATE.
304
440
  // - items in the store but absent from the file → KEPT by default.
305
441
  // Pass { prune: true } to also drop active items whose id is not in the
@@ -317,13 +453,16 @@ export interface DurableImportResult {
317
453
  missingFile: boolean;
318
454
  }
319
455
 
320
- interface ParsedItem {
456
+ export interface ParsedItem {
321
457
  id?: string;
322
458
  kind: ComponentKind;
323
459
  importance: number;
324
460
  content: string;
325
461
  evidence: string;
326
462
  ownerModel?: string;
463
+ /** Only set when the file carries an explicit `scope:` sub-line. */
464
+ scope?: "global" | "project";
465
+ project?: string;
327
466
  }
328
467
 
329
468
  // Section title → kind. Exact export titles first, then tolerant keyword
@@ -349,6 +488,7 @@ const RE_ID_BULLET = /^-\s+\*\*\[([^\]]+)\]\*\*\s*\(importance\s+([\d.]+)\)\s*(.
349
488
  const RE_PLAIN_BULLET = /^-\s+(.+)$/;
350
489
  const RE_EVIDENCE = /^\s+-\s+evidence:\s*(.*)$/i;
351
490
  const RE_MODEL = /^\s+-\s+model:\s*(.*)$/i;
491
+ const RE_SCOPE = /^\s+-\s+scope:\s*(global|project)\b\s*(?:\(([^)]*)\))?/i;
352
492
 
353
493
  /** Parse a durable markdown export into items. Tolerant of pi-reflect's edits. */
354
494
  export function parseDurable(text: string): ParsedItem[] {
@@ -386,6 +526,20 @@ export function parseDurable(text: string): ParsedItem[] {
386
526
  continue;
387
527
  }
388
528
 
529
+ const scp = line.match(RE_SCOPE);
530
+ if (scp) {
531
+ if (pending) {
532
+ if (scp[1]!.toLowerCase() === "project") {
533
+ pending.scope = "project";
534
+ const slug = (scp[2] ?? "").trim();
535
+ if (slug) pending.project = slug;
536
+ } else {
537
+ pending.scope = "global";
538
+ }
539
+ }
540
+ continue;
541
+ }
542
+
389
543
  const idm = line.match(RE_ID_BULLET);
390
544
  if (idm) {
391
545
  flush();
@@ -419,44 +573,51 @@ export function parseDurable(text: string): ParsedItem[] {
419
573
  return out.filter((p) => p.content && !/^\(?no active items\)?$/i.test(p.content));
420
574
  }
421
575
 
422
- /**
423
- * Parse the durable file and merge it into the live store, then persist a
424
- * snapshot. See the file-level comment above for merge semantics.
425
- */
426
- export async function reconstructFromDurable(
427
- path: string,
576
+ /** Merge parsed items (with fully-resolved scope) into the live store.
577
+ * Shared by the single-file and layered import paths. */
578
+ function mergeParsed(
579
+ parsed: Array<ParsedItem & ScopeInfo>,
428
580
  options: { prune?: boolean },
429
581
  persist: (snapshot: HarnessState, version: number) => void,
430
- ): Promise<DurableImportResult> {
431
- let text: string;
432
- try {
433
- text = await readFile(path, "utf8");
434
- } catch {
435
- return { imported: 0, created: 0, updated: 0, pruned: 0, missingFile: true };
436
- }
437
-
438
- const parsed = parseDurable(text);
582
+ ): DurableImportResult {
439
583
  const fileIds = new Set(parsed.map((p) => p.id).filter((id): id is string => Boolean(id)));
440
584
  const existingById = new Map(state.items.map((i) => [i.id, i] as const));
441
585
  const preActiveIds = new Set(state.items.filter((i) => i.active).map((i) => i.id));
442
586
 
443
587
  let created = 0;
444
588
  let updated = 0;
589
+ let dirty = false;
445
590
  const now = Date.now();
446
591
  for (const p of parsed) {
447
592
  const existing = p.id ? existingById.get(p.id) : undefined;
448
593
  if (existing) {
449
- // Durable wins; reactivate; keep createdAt.
594
+ const importance = clamp(p.importance);
595
+ const ownerModel = p.ownerModel ?? "";
596
+ // Durable wins; reactivate; keep createdAt. Idempotent imports skip the
597
+ // write (and the persist) when the live item already matches the file.
598
+ const unchanged =
599
+ existing.content === p.content &&
600
+ existing.evidence === p.evidence &&
601
+ existing.importance === importance &&
602
+ existing.active &&
603
+ existing.ownerModel === ownerModel &&
604
+ (existing.scope ?? "global") === p.scope &&
605
+ (existing.scope === "project" ? existing.project : undefined) === p.project;
606
+ if (unchanged) continue;
450
607
  existing.content = p.content;
451
608
  existing.evidence = p.evidence;
452
- existing.importance = clamp(p.importance);
609
+ existing.importance = importance;
453
610
  existing.active = true;
454
611
  // Durable wins on owner too: a present tag sets the owner; an absent tag
455
612
  // (e.g. pi-reflect stripped it) orphans the item so it's adopted by the
456
613
  // active model on first contact — matching the documented round-trip.
457
- existing.ownerModel = p.ownerModel ?? "";
614
+ existing.ownerModel = ownerModel;
615
+ existing.scope = p.scope;
616
+ if (p.scope === "project" && p.project) existing.project = p.project;
617
+ else delete existing.project;
458
618
  existing.updatedAt = now;
459
619
  updated += 1;
620
+ dirty = true;
460
621
  } else {
461
622
  const id = p.id && /^h_/.test(p.id) ? p.id : genId();
462
623
  state.items.push({
@@ -467,10 +628,16 @@ export async function reconstructFromDurable(
467
628
  importance: clamp(p.importance),
468
629
  active: true,
469
630
  ownerModel: p.ownerModel ?? "",
631
+ ...(p.scope === "project" && p.project ? { scope: "project" as const, project: p.project } : { scope: "global" as const }),
470
632
  createdAt: now,
471
633
  updatedAt: now,
472
634
  });
473
635
  created += 1;
636
+ dirty = true;
637
+ // Keep the lookup current so a later layer's copy of the same id
638
+ // UPDATES the item we just created instead of duplicating it (the same
639
+ // id in both layers is the normal collision case, not an anomaly).
640
+ if (p.id) existingById.set(p.id, state.items[state.items.length - 1]!);
474
641
  }
475
642
  }
476
643
 
@@ -484,9 +651,80 @@ export async function reconstructFromDurable(
484
651
  return false; // was active before, absent from the file → drop
485
652
  });
486
653
  pruned = before - state.items.length;
654
+ if (pruned > 0) dirty = true;
487
655
  }
488
656
 
657
+ if (!dirty) return { imported: parsed.length, created, updated, pruned, missingFile: false };
489
658
  version += 1;
490
659
  persist(state, version);
491
660
  return { imported: parsed.length, created, updated, pruned, missingFile: false };
492
661
  }
662
+
663
+ /** Resolve a parsed item's scope against the layer default (for items without
664
+ * an explicit `scope:` sub-line: the layer they were read from decides).
665
+ * A `scope: project` tag without any resolvable slug degrades to global —
666
+ * the invariant is scope==="project" ⟹ project (a slugless project item
667
+ * could not be placed in any layer file). */
668
+ function withDefaultScope(p: ParsedItem, def: ScopeInfo): ParsedItem & ScopeInfo {
669
+ if (p.scope === "project") {
670
+ const project = p.project ?? def.project;
671
+ return project ? { ...p, scope: "project", project } : { ...p, scope: "global" };
672
+ }
673
+ if (p.scope === "global") return { ...p, scope: "global" };
674
+ return { ...p, ...def };
675
+ }
676
+
677
+ /**
678
+ * Parse the durable file and merge it into the live store, then persist a
679
+ * snapshot. Untagged items default to global scope (single-file semantics).
680
+ * See the file-level comment above for merge semantics.
681
+ */
682
+ export async function reconstructFromDurable(
683
+ path: string,
684
+ options: { prune?: boolean },
685
+ persist: (snapshot: HarnessState, version: number) => void,
686
+ ): Promise<DurableImportResult> {
687
+ let text: string;
688
+ try {
689
+ text = await readFile(path, "utf8");
690
+ } catch {
691
+ return { imported: 0, created: 0, updated: 0, pruned: 0, missingFile: true };
692
+ }
693
+ return mergeParsed(parseDurable(text).map((p) => withDefaultScope(p, { scope: "global" })), options, persist);
694
+ }
695
+
696
+ /** One layer of a layered import: items without an explicit `scope:` sub-line
697
+ * adopt this layer's scope (the global file → global; a project file → that
698
+ * project's slug). */
699
+ export interface LayerFile {
700
+ path: string;
701
+ defaultScope: ScopeInfo;
702
+ }
703
+
704
+ /**
705
+ * Layered import (issue #7): read every layer file that exists, then merge
706
+ * ALL of them in one pass — so { prune: true } drops only items absent from
707
+ * EVERY layer (union semantics), not from each file in turn. Earlier layers
708
+ * lose id collisions to later ones; callers pass the global file first so the
709
+ * project layer wins on conflicts.
710
+ */
711
+ export async function importDurableLayers(
712
+ files: LayerFile[],
713
+ options: { prune?: boolean },
714
+ persist: (snapshot: HarnessState, version: number) => void,
715
+ ): Promise<DurableImportResult> {
716
+ const all: Array<ParsedItem & ScopeInfo> = [];
717
+ let foundAny = false;
718
+ for (const f of files) {
719
+ let text: string;
720
+ try {
721
+ text = await readFile(f.path, "utf8");
722
+ } catch {
723
+ continue;
724
+ }
725
+ foundAny = true;
726
+ for (const p of parseDurable(text)) all.push(withDefaultScope(p, f.defaultScope));
727
+ }
728
+ if (!foundAny) return { imported: 0, created: 0, updated: 0, pruned: 0, missingFile: true };
729
+ return mergeParsed(all, options, persist);
730
+ }
package/src/tools.ts CHANGED
@@ -24,6 +24,12 @@ const CreateShape = Type.Object({
24
24
  // ownerModel is intentionally NOT exposed to the model: it is stamped
25
25
  // server-side from the active model so items bind to the model driving the
26
26
  // turn without the agent having to (or being able to) name it.
27
+ scope: Type.Optional(
28
+ StringEnum(["global", "project"], {
29
+ description:
30
+ "Durable-layer scope (default global). \"project\" binds the item to this session's project file (slug stamped server-side). Normally managed via /harness move or /harness split — set directly only when a new item is clearly project-specific.",
31
+ }),
32
+ ),
27
33
  });
28
34
 
29
35
  const UpdateShape = Type.Object({
@@ -33,6 +39,12 @@ const UpdateShape = Type.Object({
33
39
  evidence: Type.Optional(Type.String()),
34
40
  importance: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
35
41
  active: Type.Optional(Type.Boolean()),
42
+ scope: Type.Optional(
43
+ StringEnum(["global", "project"], {
44
+ description:
45
+ "Move the item between durable layers (global file vs this session's project file). Use during a /harness split classification; leave unchanged otherwise.",
46
+ }),
47
+ ),
36
48
  });
37
49
 
38
50
  const DeleteShape = Type.Object({
package/src/types.ts CHANGED
@@ -33,6 +33,16 @@ export interface HarnessItem {
33
33
  * Orphans are adopted by the active model on first contact (see store.ts).
34
34
  */
35
35
  ownerModel: string;
36
+ /**
37
+ * Durable-layer scope: "global" (default) → the shared durable file
38
+ * (~/.pi/agent/harness-state.md); "project" → the per-project file
39
+ * (~/.pi/agent/harness-state/<slug>.md). Scope decides WHERE an item is
40
+ * exported / imported from — it does NOT affect per-turn injection.
41
+ * Undefined is treated as "global" (legacy snapshots / durable files).
42
+ */
43
+ scope?: "global" | "project";
44
+ /** Project slug (see projectSlug in config.ts); set iff scope === "project". */
45
+ project?: string;
36
46
  createdAt: number;
37
47
  updatedAt: number;
38
48
  }
@@ -52,6 +62,11 @@ export type Delta =
52
62
  /** Owner "provider/id". Stamped server-side from the active model when the
53
63
  * agent omits it; set explicitly by direct-apply proposers. Absent → orphan. */
54
64
  ownerModel?: string;
65
+ /** Durable-layer scope (default "global"). "project" requires a slug:
66
+ * taken from the session cwd (stamped at session_start) when omitted. */
67
+ scope?: "global" | "project";
68
+ /** Explicit project slug for scope:"project" (server-side use). */
69
+ project?: string;
55
70
  }
56
71
  | {
57
72
  op: "update";
@@ -62,6 +77,12 @@ export type Delta =
62
77
  active?: boolean;
63
78
  /** Reassign ownership (rare; used by migration/import). Absent → keep current. */
64
79
  ownerModel?: string;
80
+ /** Move the item between durable layers. Absent → keep current scope.
81
+ * "project" requires `project` (set by /harness move) or a cached
82
+ * session slug; "global" clears the project binding. */
83
+ scope?: "global" | "project";
84
+ /** Explicit project slug for scope:"project" (server-side use). */
85
+ project?: string;
65
86
  }
66
87
  | { op: "delete"; id: string; reason: string };
67
88