pi-continual-harness 0.16.0 → 0.16.1
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 +1 -1
- package/package.json +1 -1
- package/src/auto-refine.ts +9 -27
- package/src/cadence.ts +56 -0
- package/src/config.ts +30 -3
- package/src/corpus.ts +9 -9
- package/src/durable.ts +13 -22
- package/src/harness.ts +22 -19
- package/src/index.ts +11 -1
- package/src/proposer.ts +4 -2
- package/src/refine.ts +19 -3
- package/src/remind.ts +5 -14
- package/src/select.ts +5 -0
- package/src/status.ts +30 -18
- package/src/store.ts +39 -10
- package/src/tools.ts +6 -9
package/README.md
CHANGED
|
@@ -165,7 +165,7 @@ preset menu, or a declined final write; declining an individual feature only
|
|
|
165
165
|
skips that feature (the accepted deltas still apply). The wizard never
|
|
166
166
|
touches harness state, only the config file.
|
|
167
167
|
|
|
168
|
-
Optional config at `~/.pi/agent/harness.json` (missing
|
|
168
|
+
Optional config at `~/.pi/agent/harness.json` (missing → defaults; malformed → defaults plus a once-per-session warning, so a typo can't silently reset your settings):
|
|
169
169
|
|
|
170
170
|
```json
|
|
171
171
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
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/auto-refine.ts
CHANGED
|
@@ -39,25 +39,18 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
39
39
|
import { DEFAULT_AUTO_EVERY_TURNS, type HarnessConfig, loadConfig } from "./config.js";
|
|
40
40
|
import { runRefine, gatherEvidence, DEFAULT_LOOKBACK_TURNS } from "./refine.js";
|
|
41
41
|
import { buildClassify, buildGateRequest, gateDecision } from "./classify.js";
|
|
42
|
+
import { makeTurnCadence } from "./cadence.js";
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
-
// -
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// turnEndedBadly's canonical home is cadence.ts (shared completed-turn
|
|
45
|
+
// semantics); re-exported here so existing imports keep working.
|
|
46
|
+
export { turnEndedBadly } from "./cadence.js";
|
|
47
|
+
|
|
48
|
+
// The auto-refine cadence counter (shared implementation — see cadence.ts).
|
|
49
|
+
const cadence = makeTurnCadence();
|
|
47
50
|
|
|
48
51
|
/** Test hook: reset internal counter. */
|
|
49
52
|
export function resetAutoRefine(): void {
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* True when a turn_end's outcome marks a turn that ended badly (pi's
|
|
55
|
-
* BoundaryState.outcome: "aborted" | "error"). Undefined is treated as
|
|
56
|
-
* completed — current pi always reports the field, but synthetic events and
|
|
57
|
-
* the earliest 0.99.x may omit it.
|
|
58
|
-
*/
|
|
59
|
-
export function turnEndedBadly(outcome?: string): boolean {
|
|
60
|
-
return outcome === "aborted" || outcome === "error";
|
|
53
|
+
cadence.reset();
|
|
61
54
|
}
|
|
62
55
|
|
|
63
56
|
/**
|
|
@@ -71,19 +64,8 @@ export function turnEndedBadly(outcome?: string): boolean {
|
|
|
71
64
|
*/
|
|
72
65
|
export function evaluateAutoRefine(config: HarnessConfig, outcome?: string): boolean {
|
|
73
66
|
if (!config.autoRefine?.enabled) return false;
|
|
74
|
-
if (turnEndedBadly(outcome)) return false;
|
|
75
67
|
const every = config.autoRefine.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
76
|
-
|
|
77
|
-
if (completedTurns < 0) {
|
|
78
|
-
completedTurns = 0; // seed: the seed turn itself doesn't count
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
completedTurns += 1;
|
|
82
|
-
if (completedTurns >= every) {
|
|
83
|
-
completedTurns = 0;
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
return false;
|
|
68
|
+
return cadence.tick(every, outcome);
|
|
87
69
|
}
|
|
88
70
|
|
|
89
71
|
/** The boundary draft custom type for auto-refine steering messages. */
|
package/src/cadence.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Shared completed-turn cadence counter (review simplification #1).
|
|
2
|
+
//
|
|
3
|
+
// Three features — auto-refine, the /refine reminder, and the import-awareness
|
|
4
|
+
// reminder — used to each re-implement the same side-effectful counter: seed
|
|
5
|
+
// on the first observed COMPLETED turn, count completed turns, fire and reset
|
|
6
|
+
// every N. They drifted once already (review #26's turnIndex → turn_end fix
|
|
7
|
+
// had to touch several of them), so the counter now lives here exactly once.
|
|
8
|
+
//
|
|
9
|
+
// Semantics (identical for every consumer):
|
|
10
|
+
// - Aborted/error turns are inert: they neither seed nor advance the count,
|
|
11
|
+
// so an aborted turn in between can never shorten the window.
|
|
12
|
+
// - The seed turn itself does not count.
|
|
13
|
+
// - every <= 0 disables the cadence (tick never fires).
|
|
14
|
+
// - Firing resets the count, so the firing turn itself never re-triggers.
|
|
15
|
+
|
|
16
|
+
/** True when a turn_end's outcome marks a turn that ended badly (pi's
|
|
17
|
+
* BoundaryState.outcome: "aborted" | "error"). Undefined is treated as
|
|
18
|
+
* completed — current pi always reports the field, but synthetic events and
|
|
19
|
+
* the earliest 0.99.x may omit it. */
|
|
20
|
+
export function turnEndedBadly(outcome?: string): boolean {
|
|
21
|
+
return outcome === "aborted" || outcome === "error";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TurnCadence {
|
|
25
|
+
/** Observe a turn boundary; true when the cadence fires this turn.
|
|
26
|
+
* Aborted/error outcomes and every <= 0 are inert (state untouched). */
|
|
27
|
+
tick(every: number, outcome?: string): boolean;
|
|
28
|
+
/** Back to the unseen baseline (session_start: a fork/resume opens a fresh window). */
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One independent cadence counter. Feature gates stay with the caller. */
|
|
33
|
+
export function makeTurnCadence(): TurnCadence {
|
|
34
|
+
// Completed turns observed since the last fire (or the seeded baseline).
|
|
35
|
+
// -1 = unseen.
|
|
36
|
+
let completedTurns = -1;
|
|
37
|
+
return {
|
|
38
|
+
tick(every, outcome) {
|
|
39
|
+
if (turnEndedBadly(outcome)) return false;
|
|
40
|
+
if (every <= 0) return false;
|
|
41
|
+
if (completedTurns < 0) {
|
|
42
|
+
completedTurns = 0; // seed: the seed turn itself doesn't count
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
completedTurns += 1;
|
|
46
|
+
if (completedTurns >= every) {
|
|
47
|
+
completedTurns = 0;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
},
|
|
52
|
+
reset() {
|
|
53
|
+
completedTurns = -1;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -131,7 +131,12 @@ export const DEFAULT_CONFIG: HarnessConfig = {
|
|
|
131
131
|
|
|
132
132
|
export const CONFIG_PATH = join(homedir(), ".pi", "agent", "harness.json");
|
|
133
133
|
|
|
134
|
+
// Single process-lifetime cache (config is read once per session; tests warm
|
|
135
|
+
// it with an injected path and every later caller shares it — by contract).
|
|
134
136
|
let cached: HarnessConfig | undefined;
|
|
137
|
+
// Path whose JSON existed but failed to parse on the last load — the
|
|
138
|
+
// effective config silently fell back to defaults there (review finding #6).
|
|
139
|
+
let malformedPath: string | undefined;
|
|
135
140
|
|
|
136
141
|
/** Merge a (possibly partial) parsed file over the defaults. */
|
|
137
142
|
function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
@@ -313,22 +318,44 @@ export async function saveConfig(
|
|
|
313
318
|
return { written: path, backup, merged, effective };
|
|
314
319
|
}
|
|
315
320
|
|
|
316
|
-
/** Load the config, merged over defaults. Tolerant: missing
|
|
317
|
-
*
|
|
321
|
+
/** Load the config, merged over defaults. Tolerant: a missing file → defaults
|
|
322
|
+
* silently (expected for a fresh setup); a MALFORMED file → defaults too, but
|
|
323
|
+
* flagged via configMalformed() so the session can warn instead of every
|
|
324
|
+
* setting silently stopping (review finding #6). Cached for the process
|
|
325
|
+
* lifetime (config is read once per session). */
|
|
318
326
|
export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
|
|
319
327
|
if (cached) return cached;
|
|
328
|
+
let raw: string | undefined;
|
|
329
|
+
try {
|
|
330
|
+
raw = await readFile(path, "utf8");
|
|
331
|
+
} catch {
|
|
332
|
+
// Missing/unreadable file → defaults, by design.
|
|
333
|
+
}
|
|
334
|
+
if (raw === undefined) {
|
|
335
|
+
cached = { ...DEFAULT_CONFIG };
|
|
336
|
+
return cached;
|
|
337
|
+
}
|
|
320
338
|
try {
|
|
321
|
-
const raw = await readFile(path, "utf8");
|
|
322
339
|
cached = mergeConfig(JSON.parse(raw) as Partial<HarnessConfig>);
|
|
340
|
+
// Parse succeeded — clear the flag (a previous load may have failed).
|
|
341
|
+
malformedPath = undefined;
|
|
323
342
|
} catch {
|
|
343
|
+
malformedPath = path;
|
|
324
344
|
cached = { ...DEFAULT_CONFIG };
|
|
325
345
|
}
|
|
326
346
|
return cached;
|
|
327
347
|
}
|
|
328
348
|
|
|
349
|
+
/** True when the file at `path` existed but failed to parse on the last
|
|
350
|
+
* loadConfig — the effective config silently fell back to defaults. */
|
|
351
|
+
export function configMalformed(path: string = CONFIG_PATH): boolean {
|
|
352
|
+
return malformedPath === path;
|
|
353
|
+
}
|
|
354
|
+
|
|
329
355
|
/** Test hook: drop the in-process cache. */
|
|
330
356
|
export function resetConfigCache(): void {
|
|
331
357
|
cached = undefined;
|
|
358
|
+
malformedPath = undefined;
|
|
332
359
|
}
|
|
333
360
|
|
|
334
361
|
/** Path of a project's durable layer file: <projectDir>/<slug>.md. */
|
package/src/corpus.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import { createHash } from "node:crypto";
|
|
33
33
|
import { IMPORTANCE_FLOOR, REFINE_ENTRY, STATE_ENTRY } from "./store.js";
|
|
34
|
-
import { DEDUPE_THRESHOLD, tokenOverlap } from "./proposer.js";
|
|
34
|
+
import { DEDUPE_THRESHOLD, durableLayer, tokenOverlap } from "./proposer.js";
|
|
35
35
|
import { DEFAULT_REF_BUMP } from "./config.js";
|
|
36
36
|
import type { ComponentKind, HarnessItem, HarnessState } from "./types.js";
|
|
37
37
|
|
|
@@ -127,12 +127,6 @@ function hash(content: string): string {
|
|
|
127
127
|
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
/** Durable placement, mirroring planDedupe's private key: only items in the
|
|
131
|
-
* same layer were ever compared by the planner. */
|
|
132
|
-
function durableLayer(i: HarnessItem): string {
|
|
133
|
-
return i.scope === "project" ? `project:${i.project ?? ""}` : "global";
|
|
134
|
-
}
|
|
135
|
-
|
|
136
130
|
// ---- lifecycle ---------------------------------------------------------------
|
|
137
131
|
|
|
138
132
|
/** One classified lifecycle transition, with the item it happened to.
|
|
@@ -309,8 +303,14 @@ export function buildCorpus(entries: Iterable<unknown>, opts: CorpusOptions = {}
|
|
|
309
303
|
const entry = raw as AnyEntry;
|
|
310
304
|
if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data?.state) {
|
|
311
305
|
const next = entry.data.state;
|
|
312
|
-
|
|
313
|
-
|
|
306
|
+
// Implicit EMPTY baseline before the branch's first snapshot (review
|
|
307
|
+
// #27 fixed this in status.ts; the corpus walk was missed — review
|
|
308
|
+
// finding #1): a fresh session persists no empty harness-state entry,
|
|
309
|
+
// so the first create batch / auto-import IS the first snapshot —
|
|
310
|
+
// without this its `created` events were silently dropped from
|
|
311
|
+
// lifecycle.jsonl.
|
|
312
|
+
const prev = snapshots.at(-1) ?? { items: [] };
|
|
313
|
+
diffSnapshots(prev, next, citeBump, lifecycle);
|
|
314
314
|
snapshots.push(next);
|
|
315
315
|
continue;
|
|
316
316
|
}
|
package/src/durable.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { exportDurableLayers, getVersion, importDurableLayers } from "./store.js
|
|
|
28
28
|
import type { LayerFile, LayerPaths } from "./store.js";
|
|
29
29
|
import type { HarnessItem } from "./types.js";
|
|
30
30
|
import { DEFAULT_IMPORT_REMIND_TURNS, layerFilesFor, loadConfig, projectSlug, type HarnessConfig } from "./config.js";
|
|
31
|
-
import {
|
|
31
|
+
import { makeTurnCadence } from "./cadence.js";
|
|
32
32
|
|
|
33
33
|
/** Real (homedir-based) layer paths. Tests inject their own. */
|
|
34
34
|
function layerPaths(): LayerPaths {
|
|
@@ -44,12 +44,11 @@ let lastExportedVersion = -1;
|
|
|
44
44
|
// BEFORE any export — never clobbering the durable layers (review #26).
|
|
45
45
|
let startSyncRan = false;
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
let completedTurns = -1;
|
|
47
|
+
// The import-reminder cadence counter (shared implementation — see
|
|
48
|
+
// cadence.ts): completed turns of work, never raw turn indices — pi's
|
|
49
|
+
// `turnIndex` resets to 0 on every agent_start (each user prompt), so index
|
|
50
|
+
// deltas would never accumulate across prompts (review #26).
|
|
51
|
+
const importCadence = makeTurnCadence();
|
|
53
52
|
|
|
54
53
|
/** A durable-imported item the user should stay aware of (id + what it says). */
|
|
55
54
|
export interface ImportedRef {
|
|
@@ -75,7 +74,7 @@ export function resetDurableSync(): void {
|
|
|
75
74
|
lastExportedVersion = -1;
|
|
76
75
|
startSyncRan = false;
|
|
77
76
|
importedThisSession = [];
|
|
78
|
-
|
|
77
|
+
importCadence.reset();
|
|
79
78
|
remindersFired = 0;
|
|
80
79
|
}
|
|
81
80
|
|
|
@@ -154,20 +153,11 @@ export function evaluateImportReminder(
|
|
|
154
153
|
if (!config.autoImport) return false;
|
|
155
154
|
if (config.importRemind?.enabled === false) return false;
|
|
156
155
|
if (importedCount <= 0) return false;
|
|
157
|
-
if (turnEndedBadly(outcome)) return false;
|
|
158
156
|
if (remindersFired >= MAX_IMPORT_REMINDERS) return false;
|
|
159
157
|
const every = config.importRemind?.everyTurns ?? DEFAULT_IMPORT_REMIND_TURNS;
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
completedTurns += 1;
|
|
165
|
-
if (completedTurns >= every) {
|
|
166
|
-
completedTurns = 0;
|
|
167
|
-
remindersFired += 1;
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
return false;
|
|
158
|
+
if (!importCadence.tick(every, outcome)) return false;
|
|
159
|
+
remindersFired += 1;
|
|
160
|
+
return true;
|
|
171
161
|
}
|
|
172
162
|
|
|
173
163
|
/** Keep importedThisSession truthful against the live store (review #27): an
|
|
@@ -188,8 +178,9 @@ function reconcileImported(): void {
|
|
|
188
178
|
export function registerImportReminder(pi: ExtensionAPI): void {
|
|
189
179
|
pi.on("turn_end", async (event, ctx) => {
|
|
190
180
|
const config = await loadConfig();
|
|
191
|
-
// Completed-turn counting (see
|
|
192
|
-
// are inert and pi's per-agent-start turnIndex
|
|
181
|
+
// Completed-turn counting (the shared cadence — see cadence.ts):
|
|
182
|
+
// aborted/error outcomes are inert and pi's per-agent-start turnIndex
|
|
183
|
+
// resets never matter.
|
|
193
184
|
reconcileImported(); // deleted/deactivated imports stop being advertised (review #27)
|
|
194
185
|
const outcome = (event as { outcome?: string }).outcome;
|
|
195
186
|
if (!evaluateImportReminder(config, outcome, importedThisSession.length)) {
|
package/src/harness.ts
CHANGED
|
@@ -189,13 +189,18 @@ function completions(argumentPrefix: string) {
|
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// Flag completion ("
|
|
192
|
+
// Flag completion ("-" prefix, or a fresh token for flag-taking subs —
|
|
193
193
|
// the "-" prefix also matches short flags like list's -f). A fresh
|
|
194
194
|
// --kind/--scope/--model value was already completed above (early
|
|
195
195
|
// return), so a fresh token here means "offer the remaining flags".
|
|
196
|
-
|
|
196
|
+
// Object.hasOwn guards BOTH the condition and the lookup (review finding
|
|
197
|
+
// #2): a bogus subcommand naming an inherited Object member ("constructor",
|
|
198
|
+
// "toString", …) otherwise resolves FLAGS[sub] to Function — the `?? []`
|
|
199
|
+
// does not catch it (Function is not nullish) and .filter throws. Same
|
|
200
|
+
// class as the expandPreset fix in review #26.
|
|
201
|
+
if (last.startsWith("-") || (fresh && Object.hasOwn(FLAGS, sub))) {
|
|
197
202
|
const used = new Set(before.filter((t) => t.startsWith("-")));
|
|
198
|
-
const rows = (FLAGS[sub]
|
|
203
|
+
const rows = (Object.hasOwn(FLAGS, sub) ? FLAGS[sub]! : []).filter((f) => !used.has(f.name));
|
|
199
204
|
const items = rows
|
|
200
205
|
.filter((f) => f.name.startsWith(last))
|
|
201
206
|
.map((f) => ({ value: [...before, f.name].join(" "), label: f.name, description: f.description }));
|
|
@@ -564,6 +569,17 @@ async function handleMove(
|
|
|
564
569
|
}
|
|
565
570
|
}
|
|
566
571
|
|
|
572
|
+
/** Numbered item listing shared by the split/adopt steering prompts. The
|
|
573
|
+
* format had already drifted once (adopt carries full-precision importance
|
|
574
|
+
* because the agent copies it verbatim into create deltas — review #25;
|
|
575
|
+
* split rounds to 2). One renderer keeps the layout in lockstep. */
|
|
576
|
+
function formatItemLines(items: HarnessItem[], importance: "raw" | "fixed"): string[] {
|
|
577
|
+
return items.flatMap((i, n) => [
|
|
578
|
+
`${n + 1}. [${i.id}] (${i.kind}, importance ${importance === "fixed" ? i.importance.toFixed(2) : i.importance}, ${i.scope === "project" ? `project "${i.project ?? ""}"` : "global"}) ${i.content}`,
|
|
579
|
+
` evidence: ${i.evidence}`,
|
|
580
|
+
]);
|
|
581
|
+
}
|
|
582
|
+
|
|
567
583
|
/** Compose the /harness split steering message: the agent classifies every
|
|
568
584
|
* active item global-vs-project and applies the classification as ONE
|
|
569
585
|
* harness_mutate batch of scope-only update deltas — visible in the
|
|
@@ -572,7 +588,7 @@ function buildSplitPrompt(items: HarnessItem[], slug: string, cwd: string): stri
|
|
|
572
588
|
const lines = [
|
|
573
589
|
`/harness split — classify ${items.length} Continual Harness item(s) into durable scopes`,
|
|
574
590
|
"",
|
|
575
|
-
`Current project: ${cwd} (slug
|
|
591
|
+
`Current project: ${cwd} (slug "${slug}").`,
|
|
576
592
|
"",
|
|
577
593
|
"For EACH item below, decide its durable scope:",
|
|
578
594
|
"- `project` — only useful when working in THIS project (deploy procedures, project architecture, local conventions, project-specific quirks).",
|
|
@@ -583,13 +599,8 @@ function buildSplitPrompt(items: HarnessItem[], slug: string, cwd: string): stri
|
|
|
583
599
|
`(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.`,
|
|
584
600
|
"",
|
|
585
601
|
"Items:",
|
|
602
|
+
...formatItemLines(items, "fixed"),
|
|
586
603
|
];
|
|
587
|
-
items.forEach((i, n) => {
|
|
588
|
-
lines.push(
|
|
589
|
-
`${n + 1}. [${i.id}] (${i.kind}, importance ${i.importance.toFixed(2)}, ${i.scope === "project" ? `project \"${i.project ?? ""}\"` : "global"}) ${i.content}`,
|
|
590
|
-
` evidence: ${i.evidence}`,
|
|
591
|
-
);
|
|
592
|
-
});
|
|
593
604
|
return lines.join("\n");
|
|
594
605
|
}
|
|
595
606
|
|
|
@@ -677,16 +688,8 @@ export function buildAdoptPrompt(
|
|
|
677
688
|
: "- Summarize at the end: adopted N, skipped M (stays) — with one short reason per skip.",
|
|
678
689
|
"",
|
|
679
690
|
"Items:",
|
|
691
|
+
...formatItemLines(items, "raw"),
|
|
680
692
|
);
|
|
681
|
-
items.forEach((i, n) => {
|
|
682
|
-
// Full precision (no toFixed): the agent copies the listed importance
|
|
683
|
-
// verbatim into the create delta, and a rounded value would change the
|
|
684
|
-
// copy's ranking against existing items in injection selection (review #25).
|
|
685
|
-
lines.push(
|
|
686
|
-
`${n + 1}. [${i.id}] (${i.kind}, importance ${i.importance}, ${i.scope === "project" ? `project \"${i.project ?? ""}\"` : "global"}) ${i.content}`,
|
|
687
|
-
` evidence: ${i.evidence}`,
|
|
688
|
-
);
|
|
689
|
-
});
|
|
690
693
|
return lines.join("\n");
|
|
691
694
|
}
|
|
692
695
|
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { registerOutcome, resetOutcome } from "./outcome.js";
|
|
|
17
17
|
import { registerAutoExport, registerImportReminder, resetDurableSync, syncDurableOnStart } from "./durable.js";
|
|
18
18
|
import { getState, reconstruct, setActiveModelKey, setSessionProject, STATE_ENTRY } from "./store.js";
|
|
19
19
|
import { resetHints } from "./hints.js";
|
|
20
|
-
import { projectSlug } from "./config.js";
|
|
20
|
+
import { CONFIG_PATH, configMalformed, loadConfig, projectSlug } from "./config.js";
|
|
21
21
|
|
|
22
22
|
export default function continualHarness(pi: ExtensionAPI): void {
|
|
23
23
|
// Rebuild in-memory state from the current branch on every session start /
|
|
@@ -40,6 +40,16 @@ export default function continualHarness(pi: ExtensionAPI): void {
|
|
|
40
40
|
// Cache the session's project slug BEFORE any tool delta can arrive: it is
|
|
41
41
|
// the server-side stamp for scope:"project" (harness_mutate has no ctx).
|
|
42
42
|
setSessionProject(projectSlug(ctx.cwd));
|
|
43
|
+
// Surface a malformed harness.json ONCE per session start: loadConfig
|
|
44
|
+
// falls back to defaults for it, so without this warning every configured
|
|
45
|
+
// setting would silently stop applying (review finding #6).
|
|
46
|
+
await loadConfig();
|
|
47
|
+
if (configMalformed()) {
|
|
48
|
+
ctx.ui.notify(
|
|
49
|
+
`Continual Harness: ${CONFIG_PATH} is not valid JSON — every setting is on its DEFAULT until the file parses.`,
|
|
50
|
+
"warning",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
43
53
|
reconstruct(ctx.sessionManager.getBranch() as Iterable<unknown>);
|
|
44
54
|
const n = getState().items.length;
|
|
45
55
|
if (n > 0) {
|
package/src/proposer.ts
CHANGED
|
@@ -204,8 +204,10 @@ function truncate(s: string, n = 48): string {
|
|
|
204
204
|
|
|
205
205
|
/** Durable placement of an item: "global" or "project:<slug>". Only items in
|
|
206
206
|
* the SAME layer merge — a merge must never silently move an item between
|
|
207
|
-
* durable files or projects.
|
|
208
|
-
|
|
207
|
+
* durable files or projects. Exported: the corpus builder filters dedupe
|
|
208
|
+
* pair candidates by the same layer key (review simplification #2 — the two
|
|
209
|
+
* copies had to stay in sync by hand). */
|
|
210
|
+
export function durableLayer(i: HarnessItem): string {
|
|
209
211
|
return i.scope === "project" ? `project:${i.project ?? ""}` : "global";
|
|
210
212
|
}
|
|
211
213
|
|
package/src/refine.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
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
22
|
import { applyDeltas, DEFAULT_DURABLE_PATH, exportDurableLayers, PROJECT_DURABLE_DIR, REFINE_ENTRY, snapshotState } from "./store.js";
|
|
23
|
-
import { getProposer, DEFAULT_DEDUPE } from "./proposer.js";
|
|
23
|
+
import { getProposer, listProposers, DEFAULT_DEDUPE } from "./proposer.js";
|
|
24
24
|
import type { CompleteOptions, CompleteResult } from "./proposer.js";
|
|
25
25
|
import { buildClassify } from "./classify.js";
|
|
26
26
|
import { resolveBindingKey, branchEntries } from "./vmodel.js";
|
|
@@ -58,10 +58,13 @@ export function registerRefine(pi: ExtensionAPI): void {
|
|
|
58
58
|
description:
|
|
59
59
|
"Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit] [--proposer steering|dedupe] [--threshold 0.75]",
|
|
60
60
|
handler: async (args, ctx) => {
|
|
61
|
-
const { lookback, commit, proposer, threshold, thresholdInvalid } = parseArgs(args);
|
|
61
|
+
const { lookback, commit, proposer, threshold, thresholdInvalid, unknownFlags } = parseArgs(args);
|
|
62
62
|
if (thresholdInvalid) {
|
|
63
63
|
ctx.ui.notify("Invalid --threshold ignored (expected 0 < t ≤ 1).", "warning");
|
|
64
64
|
}
|
|
65
|
+
if (unknownFlags.length > 0) {
|
|
66
|
+
ctx.ui.notify(`Unknown /refine flag(s) ignored: ${unknownFlags.join(", ")}.`, "warning");
|
|
67
|
+
}
|
|
65
68
|
await runRefine(pi, ctx, {
|
|
66
69
|
lookback,
|
|
67
70
|
commit,
|
|
@@ -120,6 +123,14 @@ export async function runRefine(
|
|
|
120
123
|
const lookback = options.lookback ?? DEFAULT_LOOKBACK_TURNS;
|
|
121
124
|
const commit = options.commit ?? false;
|
|
122
125
|
const proposer = getProposer(options.proposer);
|
|
126
|
+
// An explicit but unknown --proposer must not silently run steering
|
|
127
|
+
// (review finding #7): name the fallback so a typo is visible.
|
|
128
|
+
if (options.proposer && proposer.name !== options.proposer) {
|
|
129
|
+
ctx.ui.notify(
|
|
130
|
+
`Unknown proposer "${options.proposer}" — falling back to steering. Registered: ${listProposers().join(", ")}.`,
|
|
131
|
+
"warning",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
123
134
|
ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)… [${proposer.name}]`);
|
|
124
135
|
|
|
125
136
|
const evidence = gatherEvidence(ctx, lookback);
|
|
@@ -248,6 +259,7 @@ function parseArgs(args: string): {
|
|
|
248
259
|
proposer: string | undefined;
|
|
249
260
|
threshold: number | undefined;
|
|
250
261
|
thresholdInvalid: boolean;
|
|
262
|
+
unknownFlags: string[];
|
|
251
263
|
} {
|
|
252
264
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
253
265
|
let lookback = DEFAULT_LOOKBACK_TURNS;
|
|
@@ -255,6 +267,7 @@ function parseArgs(args: string): {
|
|
|
255
267
|
let proposer: string | undefined;
|
|
256
268
|
let threshold: number | undefined;
|
|
257
269
|
let thresholdInvalid = false;
|
|
270
|
+
const unknownFlags: string[] = [];
|
|
258
271
|
const readThreshold = (raw: string): void => {
|
|
259
272
|
const v = Number(raw);
|
|
260
273
|
if (Number.isFinite(v) && v > 0 && v <= 1) threshold = v;
|
|
@@ -268,8 +281,11 @@ function parseArgs(args: string): {
|
|
|
268
281
|
else if (p === "--threshold" && i + 1 < parts.length) readThreshold(parts[++i]!);
|
|
269
282
|
else if (p.startsWith("--threshold=")) readThreshold(p.slice("--threshold=".length));
|
|
270
283
|
else if (/^\d+$/.test(p)) lookback = Math.max(1, Math.min(200, Number(p)));
|
|
284
|
+
// Unknown --flags are collected, not silently dropped: a typo like
|
|
285
|
+
// "--comit" used to vanish and run a plain refine (review finding #7).
|
|
286
|
+
else if (p.startsWith("--")) unknownFlags.push(p);
|
|
271
287
|
}
|
|
272
|
-
return { lookback, commit, proposer, threshold, thresholdInvalid };
|
|
288
|
+
return { lookback, commit, proposer, threshold, thresholdInvalid, unknownFlags };
|
|
273
289
|
}
|
|
274
290
|
|
|
275
291
|
export function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
|
package/src/remind.ts
CHANGED
|
@@ -28,18 +28,17 @@ import {
|
|
|
28
28
|
type HarnessConfig,
|
|
29
29
|
loadConfig,
|
|
30
30
|
} from "./config.js";
|
|
31
|
-
import {
|
|
31
|
+
import { makeTurnCadence } from "./cadence.js";
|
|
32
32
|
import { getState } from "./store.js";
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
let completedTurns = -1;
|
|
34
|
+
// The reminder cadence counter (shared implementation — see cadence.ts).
|
|
35
|
+
const cadence = makeTurnCadence();
|
|
37
36
|
// Fired reminders this session — the rotation seed for the detailed tier.
|
|
38
37
|
let fires = 0;
|
|
39
38
|
|
|
40
39
|
/** Test hook: reset cadence baseline and rotation state. */
|
|
41
40
|
export function resetReminder(): void {
|
|
42
|
-
|
|
41
|
+
cadence.reset();
|
|
43
42
|
fires = 0;
|
|
44
43
|
}
|
|
45
44
|
|
|
@@ -89,16 +88,8 @@ export interface ReminderDecision {
|
|
|
89
88
|
export function evaluateReminder(config: HarnessConfig, outcome?: string): ReminderDecision {
|
|
90
89
|
const idle: ReminderDecision = { fire: false, detail: false, fires, detailOrdinal: 0 };
|
|
91
90
|
if (!config.remindRefine?.enabled) return idle;
|
|
92
|
-
if (turnEndedBadly(outcome)) return idle;
|
|
93
91
|
const every = config.remindRefine.everyTurns ?? DEFAULT_EVERY_TURNS;
|
|
94
|
-
if (every
|
|
95
|
-
if (completedTurns < 0) {
|
|
96
|
-
completedTurns = 0; // seed: the seed turn itself doesn't count
|
|
97
|
-
return idle;
|
|
98
|
-
}
|
|
99
|
-
completedTurns += 1;
|
|
100
|
-
if (completedTurns < every) return idle;
|
|
101
|
-
completedTurns = 0;
|
|
92
|
+
if (!cadence.tick(every, outcome)) return idle;
|
|
102
93
|
fires += 1;
|
|
103
94
|
const detailEvery = config.remindRefine.detailEvery ?? DEFAULT_DETAIL_EVERY;
|
|
104
95
|
const detail = detailEvery > 0 && fires % detailEvery === 0;
|
package/src/select.ts
CHANGED
|
@@ -163,6 +163,11 @@ export function selectForInjection(
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// FOOTER_TOKENS is reserved unconditionally: the footer renders whenever
|
|
167
|
+
// ANY pass omits an item (the token pass below can be the first to omit),
|
|
168
|
+
// and a test pins "rendered block never exceeds the budget". Paying 18
|
|
169
|
+
// tokens always keeps that guarantee exact (review simplification —
|
|
170
|
+
// reverted: the conditional reserve broke the budget invariant).
|
|
166
171
|
const overhead = INTRO_TOKENS + presentKinds.length * SECTION_HEADER_TOKENS + FOOTER_TOKENS;
|
|
167
172
|
let remaining = Math.max(0, n.maxTokens - overhead);
|
|
168
173
|
const taken: Ranked[] = [];
|
package/src/status.ts
CHANGED
|
@@ -110,24 +110,36 @@ function aggregateImpact(
|
|
|
110
110
|
for (const t of classifyTransition(prev, next, citeBump)) {
|
|
111
111
|
const key = t.item.ownerModel || "(orphan)";
|
|
112
112
|
const o = owner(key);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
113
|
+
// A switch (not if/else): a future LifecycleEvent falls to default
|
|
114
|
+
// and is NOT counted as a delete — the old trailing `else` would have
|
|
115
|
+
// silently mislabeled it (review simplification #5).
|
|
116
|
+
switch (t.event) {
|
|
117
|
+
case "created":
|
|
118
|
+
whole.created += 1;
|
|
119
|
+
o.created += 1;
|
|
120
|
+
break;
|
|
121
|
+
case "kept":
|
|
122
|
+
whole.kept += 1;
|
|
123
|
+
o.kept += 1;
|
|
124
|
+
break;
|
|
125
|
+
case "dropped":
|
|
126
|
+
whole.dropped += 1;
|
|
127
|
+
o.dropped += 1;
|
|
128
|
+
break;
|
|
129
|
+
case "cited":
|
|
130
|
+
whole.cited += 1;
|
|
131
|
+
o.cited += 1;
|
|
132
|
+
break;
|
|
133
|
+
case "pruned":
|
|
134
|
+
whole.pruned += 1;
|
|
135
|
+
o.pruned += 1;
|
|
136
|
+
break;
|
|
137
|
+
case "deleted":
|
|
138
|
+
whole.deleted += 1;
|
|
139
|
+
o.deleted += 1;
|
|
140
|
+
break;
|
|
141
|
+
default:
|
|
142
|
+
break; // unknown future event: ignore, never mislabel
|
|
131
143
|
}
|
|
132
144
|
}
|
|
133
145
|
snapshots.push(next);
|
package/src/store.ts
CHANGED
|
@@ -235,9 +235,14 @@ export function reconstruct(entries: Iterable<unknown>): void {
|
|
|
235
235
|
// the active model on first contact (see adoptOrphans); missing/corrupt
|
|
236
236
|
// scope → "global" (a project item without a slug cannot be placed in a
|
|
237
237
|
// layer, so it falls back to the global durable file).
|
|
238
|
-
state = last
|
|
238
|
+
state = last && Array.isArray(last.items)
|
|
239
239
|
? {
|
|
240
|
-
|
|
240
|
+
// Guard both levels (review finding #5): one corrupt harness-state
|
|
241
|
+
// entry (state without an items array, or non-object items) must not
|
|
242
|
+
// crash the session_start handler — degrade to the salvageable part.
|
|
243
|
+
items: last.items
|
|
244
|
+
.filter((i): i is HarnessItem => Boolean(i) && typeof i === "object")
|
|
245
|
+
.map((i): HarnessItem => {
|
|
241
246
|
const item: HarnessItem = { ...i, ownerModel: i.ownerModel ?? "", scope: "global" };
|
|
242
247
|
if (i.scope === "project" && i.project) {
|
|
243
248
|
item.scope = "project";
|
|
@@ -246,7 +251,7 @@ export function reconstruct(entries: Iterable<unknown>): void {
|
|
|
246
251
|
delete item.project;
|
|
247
252
|
}
|
|
248
253
|
return item;
|
|
249
|
-
|
|
254
|
+
}),
|
|
250
255
|
}
|
|
251
256
|
: { items: [] };
|
|
252
257
|
version = 0;
|
|
@@ -277,9 +282,15 @@ export function decayAndPrune(
|
|
|
277
282
|
}
|
|
278
283
|
const before = state.items.length;
|
|
279
284
|
state.items = state.items.filter((i) => i.importance >= IMPORTANCE_FLOOR);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
285
|
+
const pruned = before - state.items.length;
|
|
286
|
+
// No-op idempotency (review finding #3): like mergeParsed and adoptOrphans,
|
|
287
|
+
// a prune that changed nothing persists nothing — otherwise every no-op
|
|
288
|
+
// /harness prune appends a harness-state entry of pure /tree noise.
|
|
289
|
+
if (decayed > 0 || pruned > 0) {
|
|
290
|
+
version += 1;
|
|
291
|
+
persist(state, version);
|
|
292
|
+
}
|
|
293
|
+
return { pruned, decayed };
|
|
283
294
|
}
|
|
284
295
|
|
|
285
296
|
/** Nudge an item's importance by delta (clamped to [0,1]) and touch updatedAt. */
|
|
@@ -587,6 +598,16 @@ function mergeParsed(
|
|
|
587
598
|
): DurableImportResult {
|
|
588
599
|
const fileIds = new Set(parsed.map((p) => p.id).filter((id): id is string => Boolean(id)));
|
|
589
600
|
const existingById = new Map(state.items.map((i) => [i.id, i] as const));
|
|
601
|
+
// Content fallback (review finding #4): id-less bullets and foreign
|
|
602
|
+
// (non-h_) ids never hit the id map, so import→import of the same file
|
|
603
|
+
// without an export in between used to duplicate the item every time.
|
|
604
|
+
// Matching kind+content folds the re-import into the item the first import
|
|
605
|
+
// created. Keyed with \x00 so content cannot collide across kinds.
|
|
606
|
+
const existingByContent = new Map(state.items.map((i) => [`${i.kind}${i.content}`, i] as const));
|
|
607
|
+
// Store ids any parsed line resolved to (id match, content match, or
|
|
608
|
+
// create): with --prune these count as "present in the file" even when the
|
|
609
|
+
// file never named the id (an id-less bullet carries no fileIds entry).
|
|
610
|
+
const touchedIds = new Set<string>();
|
|
590
611
|
const preActiveIds = new Set(state.items.filter((i) => i.active).map((i) => i.id));
|
|
591
612
|
// Distinct parsed items: an explicit id folding across layers (the normal
|
|
592
613
|
// global-then-project collision) is ONE item, not one per copy; bullets
|
|
@@ -609,8 +630,10 @@ function mergeParsed(
|
|
|
609
630
|
const updatedIds: string[] = [];
|
|
610
631
|
const now = Date.now();
|
|
611
632
|
for (const p of parsed) {
|
|
612
|
-
const existing =
|
|
633
|
+
const existing =
|
|
634
|
+
(p.id ? existingById.get(p.id) : undefined) ?? existingByContent.get(`${p.kind}${p.content}`);
|
|
613
635
|
if (existing) {
|
|
636
|
+
touchedIds.add(existing.id);
|
|
614
637
|
const importance = clamp(p.importance);
|
|
615
638
|
const ownerModel = p.ownerModel ?? "";
|
|
616
639
|
// Durable wins; reactivate; keep createdAt. Idempotent imports skip the
|
|
@@ -639,6 +662,10 @@ function mergeParsed(
|
|
|
639
662
|
updated += 1;
|
|
640
663
|
updatedIds.push(existing.id);
|
|
641
664
|
dirty = true;
|
|
665
|
+
// A content-matched line that named a foreign id: register it so a
|
|
666
|
+
// second copy of that id later in the same import id-matches instead
|
|
667
|
+
// of content-matching again (same dedupe as the create branch below).
|
|
668
|
+
if (p.id) existingById.set(p.id, existing);
|
|
642
669
|
} else {
|
|
643
670
|
const id = p.id && /^h_/.test(p.id) ? p.id : genId();
|
|
644
671
|
state.items.push({
|
|
@@ -656,10 +683,11 @@ function mergeParsed(
|
|
|
656
683
|
created += 1;
|
|
657
684
|
createdIds.push(id);
|
|
658
685
|
dirty = true;
|
|
659
|
-
// Keep the
|
|
660
|
-
//
|
|
661
|
-
//
|
|
686
|
+
// Keep the lookups current so a later copy of the same item (another
|
|
687
|
+
// layer's id collision, or a repeated id-less bullet) UPDATES the item
|
|
688
|
+
// we just created instead of duplicating it.
|
|
662
689
|
if (p.id) existingById.set(p.id, state.items[state.items.length - 1]!);
|
|
690
|
+
existingByContent.set(`${p.kind}${p.content}`, state.items[state.items.length - 1]!);
|
|
663
691
|
}
|
|
664
692
|
}
|
|
665
693
|
|
|
@@ -670,6 +698,7 @@ function mergeParsed(
|
|
|
670
698
|
if (!i.active) return true; // inactive items are never touched by durable I/O
|
|
671
699
|
if (!preActiveIds.has(i.id)) return true; // created during this import
|
|
672
700
|
if (fileIds.has(i.id)) return true; // present in the file
|
|
701
|
+
if (touchedIds.has(i.id)) return true; // matched by content (id-less / foreign id)
|
|
673
702
|
return false; // was active before, absent from the file → drop
|
|
674
703
|
});
|
|
675
704
|
pruned = before - state.items.length;
|
package/src/tools.ts
CHANGED
|
@@ -178,12 +178,16 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
178
178
|
},
|
|
179
179
|
getActiveModelKey(),
|
|
180
180
|
);
|
|
181
|
-
const summary = summarize(applied);
|
|
182
181
|
const created = applied.filter((a) => a.op === "create").length;
|
|
183
182
|
const updated = applied.filter((a) => a.op === "update").length;
|
|
184
183
|
const deleted = applied.filter((a) => a.op === "delete").length;
|
|
185
184
|
return {
|
|
186
|
-
content: [
|
|
185
|
+
content: [
|
|
186
|
+
{
|
|
187
|
+
type: "text",
|
|
188
|
+
text: `Applied ${applied.length} delta(s): ${created} created, ${updated} updated, ${deleted} deleted.`,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
187
191
|
details: { applied, version: getState().items.length },
|
|
188
192
|
structuredContent: { applied: applied.length, created, updated, deleted },
|
|
189
193
|
};
|
|
@@ -191,13 +195,6 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
191
195
|
});
|
|
192
196
|
}
|
|
193
197
|
|
|
194
|
-
function summarize(applied: AppliedDelta[]): string {
|
|
195
|
-
const created = applied.filter((a) => a.op === "create").length;
|
|
196
|
-
const updated = applied.filter((a) => a.op === "update").length;
|
|
197
|
-
const deleted = applied.filter((a) => a.op === "delete").length;
|
|
198
|
-
return `Applied ${applied.length} delta(s): ${created} created, ${updated} updated, ${deleted} deleted.`;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
198
|
/** Tally `items` by a string key, rendered as "key (n), key (n)". */
|
|
202
199
|
function tally<T>(items: T[], key: (i: T) => string): string {
|
|
203
200
|
const counts = new Map<string, number>();
|