pi-continual-harness 0.1.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alessandro Pungitore
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -13,8 +13,11 @@ with:
13
13
  → behavioral-file refinement (the "deep" path).
14
14
  - **pi-mem / pi-memory** — durable memory storage.
15
15
 
16
- The durable markdown export (`.pi/harness-state.md`) is the composition seam:
17
- pi-reflect can read and refine it, pi-mem can ingest it.
16
+ The durable markdown file (`.pi/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. pi-mem
20
+ ingestion is a future/manual step.
18
21
 
19
22
  ## Why
20
23
 
@@ -50,6 +53,24 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
50
53
  /refine 25 --commit # also export durable state to ~/.pi/agent/harness-state.md
51
54
  ```
52
55
 
56
+ Durable I/O (round-trip with pi-reflect):
57
+
58
+ ```
59
+ /harness status # counts + durable file presence/mtime
60
+ /harness export [path] # write active items to a markdown file
61
+ /harness import [--prune] [path] # parse it back and merge (offline edits win)
62
+ ```
63
+
64
+ `import` reconciles the file into the live store: items whose id matches an
65
+ existing entry are updated (offline edits win on content/evidence/importance),
66
+ new entries are created. By default nothing is deleted — `--prune` also drops
67
+ active items whose id is no longer in the file (inactive items are always
68
+ preserved). Point pi-reflect at the same file to refine it offline:
69
+
70
+ ```
71
+ /reflect ~/.pi/agent/harness-state.md
72
+ ```
73
+
53
74
  The model-facing tools:
54
75
 
55
76
  - `harness_list [kind]` — read current state (optionally filtered by kind).
@@ -82,13 +103,15 @@ model-agnostic, and keeps every delta visible and reviewable in the transcript.
82
103
 
83
104
  ## Status
84
105
 
85
- Scaffold (0.1.0). Extension points to grow into:
106
+ 0.1.x. The durable round-trip with pi-reflect is implemented
107
+ (`/harness import|export|status`). Open extension points:
86
108
 
87
109
  - A pluggable delta proposer (currently the agent itself, via steering).
88
110
  - Project-local vs global durable paths.
89
111
  - Optional `turn_end` auto-trigger behind a setting (intentionally off by
90
112
  default — autonomous self-mutation is a sharp edge).
91
- - Importance decay/prune policy.
113
+ - Importance decay/prune policy — `decayAndPrune()` exists but is not yet wired.
114
+ - pi-mem ingestion of the durable export (manual/future).
92
115
 
93
116
  ## License
94
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -15,6 +15,7 @@
15
15
  "agent"
16
16
  ],
17
17
  "license": "MIT",
18
+ "author": "alessandro@pungitore.ch",
18
19
  "type": "module",
19
20
  "main": "src/index.ts",
20
21
  "files": [
@@ -44,6 +45,10 @@
44
45
  "typescript": "^5.6.0",
45
46
  "vitest": "^3.0.0"
46
47
  },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "provenance": true
51
+ },
47
52
  "pi": {
48
53
  "extensions": [
49
54
  "./src/index.ts"
@@ -52,6 +57,7 @@
52
57
  "scripts": {
53
58
  "test": "vitest run",
54
59
  "typecheck": "tsc --noEmit",
55
- "check": "npm run typecheck && npm run test"
60
+ "check": "npm run typecheck && npm run test",
61
+ "prepublishOnly": "node -e \"if(!process.env.CI)throw new Error('publish via release.yml CI: push a v* tag. Manual npm publish is blocked to guarantee provenance + version sync.')\""
56
62
  }
57
63
  }
package/src/harness.ts ADDED
@@ -0,0 +1,110 @@
1
+ // /harness — durable I/O. The two-way round-trip seam with pi-reflect.
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)
6
+ //
7
+ // export writes the active items to ~/.pi/agent/harness-state.md (best-effort);
8
+ // import parses that file and merges into the live store. Because pi-reflect
9
+ // edits markdown files and git-commits, pointing it at the same file closes the
10
+ // loop: offline refinement flows back into the online store.
11
+ //
12
+ // Manual only (no session_start auto-import): importing is an explicit,
13
+ // reviewable action, matching the package's "no autonomous mutation" stance.
14
+
15
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
+ import { stat } from "node:fs/promises";
17
+ import { DEFAULT_DURABLE_PATH, exportDurable, getState, reconstructFromDurable } from "./store.js";
18
+ import type { ComponentKind } from "./types.js";
19
+
20
+ export function registerHarness(pi: ExtensionAPI): void {
21
+ pi.registerCommand("harness", {
22
+ description:
23
+ "Durable harness-state I/O (round-trip seam with pi-reflect). " +
24
+ "Subcommands: import [--prune] [path] · export [path] · status [path].",
25
+ handler: async (args, ctx) => {
26
+ const parts = args.trim().split(/\s+/).filter(Boolean);
27
+ const sub = (parts[0] ?? "status").toLowerCase();
28
+ const rest = parts.slice(1);
29
+ switch (sub) {
30
+ case "import":
31
+ await handleImport(pi, ctx, rest);
32
+ return;
33
+ case "export":
34
+ await handleExport(ctx, rest);
35
+ return;
36
+ case "status":
37
+ default:
38
+ await handleStatus(ctx, rest);
39
+ return;
40
+ }
41
+ },
42
+ });
43
+ }
44
+
45
+ function resolvePath(rest: string[]): string {
46
+ return rest.find((a) => !a.startsWith("-")) ?? DEFAULT_DURABLE_PATH;
47
+ }
48
+
49
+ async function handleImport(
50
+ pi: ExtensionAPI,
51
+ ctx: ExtensionCommandContext,
52
+ rest: string[],
53
+ ): Promise<void> {
54
+ const prune = rest.includes("--prune");
55
+ const path = resolvePath(rest);
56
+ ctx.ui.setStatus("harness", `Importing durable state${prune ? " (prune)" : ""}…`);
57
+ try {
58
+ const res = await reconstructFromDurable(path, { prune }, (snapshot, ver) => {
59
+ pi.appendEntry("harness-state", { state: snapshot, version: ver });
60
+ });
61
+ if (res.missingFile) {
62
+ ctx.ui.notify(
63
+ `No durable file at ${path}. Run /refine --commit or /harness export first.`,
64
+ "warning",
65
+ );
66
+ return;
67
+ }
68
+ const bits = [`${res.created} created`, `${res.updated} updated`];
69
+ if (prune) bits.push(`${res.pruned} pruned`);
70
+ ctx.ui.notify(`Imported ${res.imported} item(s) from ${path} (${bits.join(", ")}).`, "info");
71
+ } catch (err) {
72
+ ctx.ui.notify(`Harness import failed: ${(err as Error).message}`, "error");
73
+ }
74
+ ctx.ui.setStatus("harness", undefined);
75
+ }
76
+
77
+ async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
78
+ const path = resolvePath(rest);
79
+ ctx.ui.setStatus("harness", "Exporting durable state…");
80
+ try {
81
+ const written = await exportDurable(path);
82
+ const n = getState().items.filter((i) => i.active).length;
83
+ ctx.ui.notify(`Exported ${n} active item(s) to ${written}`, "info");
84
+ } catch (err) {
85
+ ctx.ui.notify(`Harness export failed: ${(err as Error).message}`, "error");
86
+ }
87
+ ctx.ui.setStatus("harness", undefined);
88
+ }
89
+
90
+ async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
91
+ const path = resolvePath(rest);
92
+ const items = getState().items;
93
+ const active = items.filter((i) => i.active);
94
+ const counts: Record<ComponentKind, number> = { prompt: 0, memory: 0, skill: 0, subagent: 0 };
95
+ for (const i of active) counts[i.kind] += 1;
96
+ let fileState: string;
97
+ try {
98
+ const st = await stat(path);
99
+ fileState = `${path} (modified ${st.mtime.toISOString()})`;
100
+ } catch {
101
+ fileState = `none at ${path}`;
102
+ }
103
+ ctx.ui.setStatus("harness", undefined);
104
+ ctx.ui.notify(
105
+ `Harness: ${active.length} active / ${items.length} total — ` +
106
+ `prompt ${counts.prompt}, memory ${counts.memory}, skill ${counts.skill}, subagent ${counts.subagent}. ` +
107
+ `Durable: ${fileState}.`,
108
+ "info",
109
+ );
110
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // See README for design rationale and the research it is grounded in.
8
8
 
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { registerHarness } from "./harness.js";
10
11
  import { registerInjection } from "./inject.js";
11
12
  import { registerRefine } from "./refine.js";
12
13
  import { registerTools } from "./tools.js";
@@ -27,6 +28,7 @@ export default function continualHarness(pi: ExtensionAPI): void {
27
28
  registerInjection(pi);
28
29
  registerTools(pi);
29
30
  registerRefine(pi);
31
+ registerHarness(pi);
30
32
  }
31
33
 
32
34
  export { STATE_ENTRY };
package/src/store.ts CHANGED
@@ -10,7 +10,7 @@
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, writeFile } from "node:fs/promises";
13
+ import { mkdir, readFile, 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";
@@ -18,7 +18,7 @@ import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } fr
18
18
  const STATE_ENTRY = "harness-state";
19
19
  const REFINE_ENTRY = "harness-refinement";
20
20
 
21
- const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
21
+ export const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
22
22
 
23
23
  const IMPORTANCE_FLOOR = 0.3;
24
24
 
@@ -167,3 +167,190 @@ function titleFor(kind: ComponentKind): string {
167
167
  return "Sub-agent specs";
168
168
  }
169
169
  }
170
+
171
+ // ---- Durable import (round-trip seam with pi-reflect) ---------------------
172
+ //
173
+ // exportDurable() is write-only by design. reconstructFromDurable() closes the
174
+ // loop: it parses the markdown back into items and merges them into the live
175
+ // store, so offline edits pi-reflect makes to harness-state.md flow back in.
176
+ //
177
+ // Merge semantics (predictable, loss-free by default):
178
+ // - parsed item whose id matches an existing item → UPDATE in place
179
+ // (durable wins on content/evidence/importance; reactivated; createdAt kept).
180
+ // - parsed item with a new/foreign id → CREATE.
181
+ // - items in the store but absent from the file → KEPT by default.
182
+ // Pass { prune: true } to also drop active items whose id is not in the
183
+ // file (inactive items are always preserved — the durable export never
184
+ // contains them, so they cannot have been "deleted" by pi-reflect).
185
+
186
+ export interface DurableImportResult {
187
+ /** Items successfully parsed from the file. */
188
+ imported: number;
189
+ created: number;
190
+ updated: number;
191
+ /** Only non-zero when { prune: true }. */
192
+ pruned: number;
193
+ /** True if the file did not exist (no-op). */
194
+ missingFile: boolean;
195
+ }
196
+
197
+ interface ParsedItem {
198
+ id?: string;
199
+ kind: ComponentKind;
200
+ importance: number;
201
+ content: string;
202
+ evidence: string;
203
+ }
204
+
205
+ // Section title → kind. Exact export titles first, then tolerant keyword
206
+ // fallbacks so pi-reflect's edits to headings still parse.
207
+ const TITLE_TO_KIND: Array<[RegExp, ComponentKind]> = [
208
+ [/supplemental prompt notes/i, "prompt"],
209
+ [/memory facts/i, "memory"],
210
+ [/skill descriptions/i, "skill"],
211
+ [/sub-?agent specs/i, "subagent"],
212
+ [/\bprompt\b/i, "prompt"],
213
+ [/\bmemory\b/i, "memory"],
214
+ [/\bskills?\b/i, "skill"],
215
+ [/\bsub-?agents?\b/i, "subagent"],
216
+ ];
217
+
218
+ function titleToKind(title: string): ComponentKind | undefined {
219
+ for (const [re, kind] of TITLE_TO_KIND) if (re.test(title)) return kind;
220
+ return undefined;
221
+ }
222
+
223
+ const RE_H2 = /^##\s+(.*)$/;
224
+ const RE_ID_BULLET = /^-\s+\*\*\[([^\]]+)\]\*\*\s*\(importance\s+([\d.]+)\)\s*(.*)$/;
225
+ const RE_PLAIN_BULLET = /^-\s+(.+)$/;
226
+ const RE_EVIDENCE = /^\s+-\s+evidence:\s*(.*)$/i;
227
+
228
+ /** Parse a durable markdown export into items. Tolerant of pi-reflect's edits. */
229
+ export function parseDurable(text: string): ParsedItem[] {
230
+ const out: ParsedItem[] = [];
231
+ let kind: ComponentKind | undefined;
232
+ let pending: ParsedItem | null = null;
233
+ const flush = (): void => {
234
+ if (pending && pending.content) out.push(pending);
235
+ pending = null;
236
+ };
237
+
238
+ for (const raw of text.split(/\r?\n/)) {
239
+ const line = raw.replace(/\s+$/, "");
240
+ const h2 = line.match(RE_H2);
241
+ if (h2) {
242
+ flush();
243
+ kind = titleToKind(h2[1]!.trim());
244
+ continue;
245
+ }
246
+ if (/^#/.test(line)) {
247
+ flush(); // any other header (h1, h3, …)
248
+ continue;
249
+ }
250
+ if (!kind) continue; // ignore bullets outside a known section
251
+
252
+ const ev = line.match(RE_EVIDENCE);
253
+ if (ev) {
254
+ if (pending) pending.evidence = ev[1]!.trim();
255
+ continue;
256
+ }
257
+
258
+ const idm = line.match(RE_ID_BULLET);
259
+ if (idm) {
260
+ flush();
261
+ const imp = Number(idm[2]!);
262
+ pending = {
263
+ id: idm[1]!.trim(),
264
+ kind,
265
+ importance: Number.isNaN(imp) ? 0.5 : imp,
266
+ content: idm[3]!.trim(),
267
+ evidence: "",
268
+ };
269
+ continue;
270
+ }
271
+
272
+ const pm = line.match(RE_PLAIN_BULLET);
273
+ if (pm) {
274
+ flush();
275
+ const body = pm[1]!.trim();
276
+ const idInBody = body.match(/^\*\*\[([^\]]+)\]\*\*/);
277
+ pending = {
278
+ kind,
279
+ importance: 0.5,
280
+ content: body.replace(/^\*\*\[([^\]]+)\]\*\*\s*/, "").trim(),
281
+ evidence: "",
282
+ };
283
+ if (idInBody) pending.id = idInBody[1]!.trim();
284
+ continue;
285
+ }
286
+ }
287
+ flush();
288
+ return out.filter((p) => p.content && !/^\(?no active items\)?$/i.test(p.content));
289
+ }
290
+
291
+ /**
292
+ * Parse the durable file and merge it into the live store, then persist a
293
+ * snapshot. See the file-level comment above for merge semantics.
294
+ */
295
+ export async function reconstructFromDurable(
296
+ path: string,
297
+ options: { prune?: boolean },
298
+ persist: (snapshot: HarnessState, version: number) => void,
299
+ ): Promise<DurableImportResult> {
300
+ let text: string;
301
+ try {
302
+ text = await readFile(path, "utf8");
303
+ } catch {
304
+ return { imported: 0, created: 0, updated: 0, pruned: 0, missingFile: true };
305
+ }
306
+
307
+ const parsed = parseDurable(text);
308
+ const fileIds = new Set(parsed.map((p) => p.id).filter((id): id is string => Boolean(id)));
309
+ const existingById = new Map(state.items.map((i) => [i.id, i] as const));
310
+ const preActiveIds = new Set(state.items.filter((i) => i.active).map((i) => i.id));
311
+
312
+ let created = 0;
313
+ let updated = 0;
314
+ const now = Date.now();
315
+ for (const p of parsed) {
316
+ const existing = p.id ? existingById.get(p.id) : undefined;
317
+ if (existing) {
318
+ // Durable wins; reactivate; keep createdAt.
319
+ existing.content = p.content;
320
+ existing.evidence = p.evidence;
321
+ existing.importance = clamp(p.importance);
322
+ existing.active = true;
323
+ existing.updatedAt = now;
324
+ updated += 1;
325
+ } else {
326
+ const id = p.id && /^h_/.test(p.id) ? p.id : genId();
327
+ state.items.push({
328
+ id,
329
+ kind: p.kind,
330
+ content: p.content,
331
+ evidence: p.evidence,
332
+ importance: clamp(p.importance),
333
+ active: true,
334
+ createdAt: now,
335
+ updatedAt: now,
336
+ });
337
+ created += 1;
338
+ }
339
+ }
340
+
341
+ let pruned = 0;
342
+ if (options.prune) {
343
+ const before = state.items.length;
344
+ state.items = state.items.filter((i) => {
345
+ if (!i.active) return true; // inactive items are never touched by durable I/O
346
+ if (!preActiveIds.has(i.id)) return true; // created during this import
347
+ if (fileIds.has(i.id)) return true; // present in the file
348
+ return false; // was active before, absent from the file → drop
349
+ });
350
+ pruned = before - state.items.length;
351
+ }
352
+
353
+ version += 1;
354
+ persist(state, version);
355
+ return { imported: parsed.length, created, updated, pruned, missingFile: false };
356
+ }