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 +60 -23
- package/package.json +1 -1
- package/src/config.ts +53 -13
- package/src/durable.ts +88 -0
- package/src/harness.ts +199 -50
- package/src/index.ts +14 -4
- package/src/refine.ts +10 -5
- package/src/store.ts +268 -30
- package/src/tools.ts +12 -0
- package/src/types.ts +21 -0
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
62
|
-
/harness export [path] #
|
|
63
|
-
/harness import [--prune] [path] #
|
|
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,
|
|
71
|
-
autocomplete in the TUI as you type after
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
- **`
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
328
|
-
paths (
|
|
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.
|
|
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
|
-
//
|
|
4
|
-
// -
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
*
|
|
117
|
-
*
|
|
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
|
|
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
|
|
4
|
-
// /harness export [path]
|
|
5
|
-
// /harness import [--prune] [path]
|
|
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 /
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
132
|
-
if (sub === "keep" || sub === "drop") {
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
260
|
+
const explicit = explicitPath(rest);
|
|
223
261
|
ctx.ui.setStatus("harness", `Importing durable state${prune ? " (prune)" : ""}…`);
|
|
224
262
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
"
|
|
232
|
-
);
|
|
233
|
-
|
|
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)
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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,
|
|
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
|
|
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
|
|
158
|
-
|
|
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
|
|
182
|
-
//
|
|
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
|
-
? {
|
|
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
|
-
|
|
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
|
|
265
|
-
if (
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
*
|
|
424
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|