pi-continual-harness 0.15.1 → 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 +5 -4
- 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 +102 -28
- package/src/index.ts +22 -1
- package/src/list.ts +157 -0
- 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 +7 -10
package/README.md
CHANGED
|
@@ -62,6 +62,7 @@ Durable I/O (round-trip with pi-reflect):
|
|
|
62
62
|
|
|
63
63
|
```
|
|
64
64
|
/harness status # counts + durable layer presence/mtime
|
|
65
|
+
/harness list [-f] [--kind <kind>] [--scope <global|project|all>] # read items — compact rows, -f = full text + evidence
|
|
65
66
|
/harness export [path] # layered export (or full snapshot to a path)
|
|
66
67
|
/harness export-corpus [path] # calibration corpus JSONL for pi-reflex (local-only; dedupe pairs + lifecycle)
|
|
67
68
|
/harness import [--prune] [path] # layered import (or single file from a path)
|
|
@@ -75,9 +76,9 @@ Durable I/O (round-trip with pi-reflect):
|
|
|
75
76
|
/harness setup [--preset <quiet|recommended|power>] [--yes] # guided config wizard
|
|
76
77
|
```
|
|
77
78
|
|
|
78
|
-
Subcommands, flags, `--kind`/`--model`/`--from`
|
|
79
|
-
`keep`/`drop`/`move` item ids all autocomplete in
|
|
80
|
-
`/harness `.
|
|
79
|
+
Subcommands, flags (`-f` included), `--kind`/`--model`/`--from`/`--scope`
|
|
80
|
+
values, `move` scopes, and `keep`/`drop`/`move` item ids all autocomplete in
|
|
81
|
+
the TUI as you type after `/harness `.
|
|
81
82
|
|
|
82
83
|
**Durable layers.** Every item carries its own durable scope: `global`
|
|
83
84
|
(the shared `~/.pi/agent/harness-state.md`) or `project`
|
|
@@ -164,7 +165,7 @@ preset menu, or a declined final write; declining an individual feature only
|
|
|
164
165
|
skips that feature (the accepted deltas still apply). The wizard never
|
|
165
166
|
touches harness state, only the config file.
|
|
166
167
|
|
|
167
|
-
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):
|
|
168
169
|
|
|
169
170
|
```json
|
|
170
171
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// /harness — durable I/O. The two-way round-trip seam with pi-reflect.
|
|
2
2
|
//
|
|
3
3
|
// /harness status [path] counts + durable layer presence/mtime
|
|
4
|
+
// /harness list [-f] [--kind] [--scope] read items: compact rows, -f = full text
|
|
4
5
|
// /harness export [path] layered export (or full snapshot to path)
|
|
5
6
|
// /harness import [--prune] [path] layered import (or single file from path)
|
|
6
7
|
// /harness move <id> <global|project> move an item between durable layers
|
|
@@ -59,6 +60,7 @@ import { buildCorpus } from "./corpus.js";
|
|
|
59
60
|
import { buildStatusReport, renderStatusReport } from "./status.js";
|
|
60
61
|
import { getInjectionStats } from "./inject.js";
|
|
61
62
|
import { nextHint } from "./hints.js";
|
|
63
|
+
import { preview, renderListReport, type ListScope } from "./list.js";
|
|
62
64
|
import { handleSetup, PRESET_COMPLETIONS } from "./setup.js";
|
|
63
65
|
|
|
64
66
|
/** One row of the /harness subcommand menu (label = name, value = name). */
|
|
@@ -72,6 +74,7 @@ const SUBCOMMANDS: CompletionEntry[] = [
|
|
|
72
74
|
{ name: "export", description: "Export active items to durable layers (or one file with a path)" },
|
|
73
75
|
{ name: "export-corpus", description: "Export calibration corpus JSONL for pi-reflex (dedupe pairs + lifecycle)" },
|
|
74
76
|
{ name: "status", description: "Show harness status (counts, audit-trail impact, injection stats, durable layers)" },
|
|
77
|
+
{ name: "list", description: "Read items — compact rows, -f for full text (--kind/--scope filter)" },
|
|
75
78
|
{ name: "prune", description: "Decay & prune inactive items (--decay <days>)" },
|
|
76
79
|
{ name: "keep", description: "Bump item importance (+0.1)" },
|
|
77
80
|
{ name: "drop", description: "Lower item importance (−0.1)" },
|
|
@@ -84,6 +87,13 @@ const SUBCOMMANDS: CompletionEntry[] = [
|
|
|
84
87
|
|
|
85
88
|
const KINDS: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
|
|
86
89
|
|
|
90
|
+
/** `--scope` values /harness list offers (semantics in src/list.ts). */
|
|
91
|
+
const LIST_SCOPES: Array<{ value: ListScope; description: string }> = [
|
|
92
|
+
{ value: "global", description: "Global-scope items only (~/.pi/agent/harness-state.md)" },
|
|
93
|
+
{ value: "project", description: "Project-scoped items (any project; rows carry the slug)" },
|
|
94
|
+
{ value: "all", description: "Every item, foreign-project included" },
|
|
95
|
+
];
|
|
96
|
+
|
|
87
97
|
/** Flags each subcommand accepts, in menu order. */
|
|
88
98
|
const FLAGS: Record<string, CompletionEntry[]> = {
|
|
89
99
|
import: [{ name: "--prune", description: "Drop live items missing from the durable file" }],
|
|
@@ -97,17 +107,18 @@ const FLAGS: Record<string, CompletionEntry[]> = {
|
|
|
97
107
|
{ name: "--from", description: "Source model (provider/id) whose active items are up for adoption" },
|
|
98
108
|
{ name: "--all", description: "Adopt everything; skip the generalizes-vs-stays filter" },
|
|
99
109
|
],
|
|
110
|
+
list: [
|
|
111
|
+
{ name: "-f", description: "Full item text + evidence instead of the compact preview rows" },
|
|
112
|
+
{ name: "--kind", description: `Limit to one kind (${KINDS.join("|")})` },
|
|
113
|
+
{ name: "--scope", description: "global | project | all (default: global + this project)" },
|
|
114
|
+
],
|
|
100
115
|
setup: [
|
|
101
116
|
{ name: "--preset", description: "Apply one preset: quiet | recommended | power" },
|
|
102
117
|
{ name: "--yes", description: "Scripted consent — skip every confirm prompt" },
|
|
103
118
|
],
|
|
104
119
|
};
|
|
105
120
|
|
|
106
|
-
|
|
107
|
-
// Single line + truncated so the menu row stays readable.
|
|
108
|
-
const flat = text.replace(/\s+/g, " ").trim();
|
|
109
|
-
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
110
|
-
}
|
|
121
|
+
// preview() lives in src/list.ts (shared with /harness list rows).
|
|
111
122
|
|
|
112
123
|
/**
|
|
113
124
|
* Argument autocomplete for /harness (pi calls this with the FULL text after
|
|
@@ -141,7 +152,7 @@ function completions(argumentPrefix: string) {
|
|
|
141
152
|
const sub = before[0]!.toLowerCase();
|
|
142
153
|
// Flag-value completion: the token right before the one being typed.
|
|
143
154
|
const prev = before.at(-1)!;
|
|
144
|
-
if (prev === "--kind" && sub === "push-mem") {
|
|
155
|
+
if (prev === "--kind" && (sub === "push-mem" || sub === "list")) {
|
|
145
156
|
return filterValues(
|
|
146
157
|
KINDS.map((k) => ({ value: k, description: KIND_LABEL[k] })),
|
|
147
158
|
last,
|
|
@@ -162,6 +173,9 @@ function completions(argumentPrefix: string) {
|
|
|
162
173
|
if (prev === "--preset" && sub === "setup") {
|
|
163
174
|
return filterValues(PRESET_COMPLETIONS, last, before);
|
|
164
175
|
}
|
|
176
|
+
if (prev === "--scope" && sub === "list") {
|
|
177
|
+
return filterValues(LIST_SCOPES, last, before);
|
|
178
|
+
}
|
|
165
179
|
// --from completes from the model keys actually present in the store (the
|
|
166
180
|
// adoption source pool), with each owner's active-item count as the help
|
|
167
181
|
// line — the same store-backed completion style as --model above.
|
|
@@ -175,12 +189,18 @@ function completions(argumentPrefix: string) {
|
|
|
175
189
|
);
|
|
176
190
|
}
|
|
177
191
|
|
|
178
|
-
// Flag completion ("
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
// Flag completion ("-" prefix, or a fresh token for flag-taking subs —
|
|
193
|
+
// the "-" prefix also matches short flags like list's -f). A fresh
|
|
194
|
+
// --kind/--scope/--model value was already completed above (early
|
|
195
|
+
// return), so a fresh token here means "offer the remaining flags".
|
|
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))) {
|
|
202
|
+
const used = new Set(before.filter((t) => t.startsWith("-")));
|
|
203
|
+
const rows = (Object.hasOwn(FLAGS, sub) ? FLAGS[sub]! : []).filter((f) => !used.has(f.name));
|
|
184
204
|
const items = rows
|
|
185
205
|
.filter((f) => f.name.startsWith(last))
|
|
186
206
|
.map((f) => ({ value: [...before, f.name].join(" "), label: f.name, description: f.description }));
|
|
@@ -285,6 +305,9 @@ export function registerHarness(pi: ExtensionAPI): void {
|
|
|
285
305
|
case "setup":
|
|
286
306
|
await handleSetup(ctx, rest);
|
|
287
307
|
return;
|
|
308
|
+
case "list":
|
|
309
|
+
await handleList(ctx, rest);
|
|
310
|
+
return;
|
|
288
311
|
case "status":
|
|
289
312
|
default:
|
|
290
313
|
await handleStatus(ctx);
|
|
@@ -300,6 +323,59 @@ function explicitPath(rest: string[]): string | undefined {
|
|
|
300
323
|
return rest.find((a) => !a.startsWith("-"));
|
|
301
324
|
}
|
|
302
325
|
|
|
326
|
+
/** `/harness list [-f] [--kind <kind>] [--scope <global|project|all>]`
|
|
327
|
+
* (issue #31) — the read view: thin glue (flag parsing + one notify); every
|
|
328
|
+
* selection/rendering rule lives in the pure src/list.ts. Read-only: no
|
|
329
|
+
* store mutation, no durable writes. Invalid flag values AND unexpected
|
|
330
|
+
* arguments warn instead of silently falling back — a filter that quietly
|
|
331
|
+
* ignores its own argument would make the listing lie. */
|
|
332
|
+
async function handleList(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
333
|
+
const full = rest.includes("-f");
|
|
334
|
+
let kind: ComponentKind | undefined;
|
|
335
|
+
const kindIdx = rest.indexOf("--kind");
|
|
336
|
+
if (kindIdx >= 0) {
|
|
337
|
+
const k = kindIdx + 1 < rest.length ? rest[kindIdx + 1]! : undefined;
|
|
338
|
+
if (!k || k.startsWith("-") || !KINDS.includes(k as ComponentKind)) {
|
|
339
|
+
ctx.ui.notify(`/harness list: --kind expects one of ${KINDS.join("|")}.`, "warning");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
kind = k as ComponentKind;
|
|
343
|
+
}
|
|
344
|
+
let scope: ListScope | undefined;
|
|
345
|
+
const scopeIdx = rest.indexOf("--scope");
|
|
346
|
+
if (scopeIdx >= 0) {
|
|
347
|
+
const s = scopeIdx + 1 < rest.length ? rest[scopeIdx + 1]! : undefined;
|
|
348
|
+
if (s !== "global" && s !== "project" && s !== "all") {
|
|
349
|
+
ctx.ui.notify("/harness list: --scope expects global|project|all.", "warning");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
scope = s;
|
|
353
|
+
}
|
|
354
|
+
// Unexpected arguments (unknown flags like --full, stray positionals,
|
|
355
|
+
// repeated flags) warn too — the same no-silent-fallback rule as invalid
|
|
356
|
+
// values. Anything but -f, --kind <value>, --scope <value> is refused.
|
|
357
|
+
const consumed = new Set<number>();
|
|
358
|
+
if (kindIdx >= 0) { consumed.add(kindIdx); consumed.add(kindIdx + 1); }
|
|
359
|
+
if (scopeIdx >= 0) { consumed.add(scopeIdx); consumed.add(scopeIdx + 1); }
|
|
360
|
+
const unexpected = rest.filter((t, idx) => t !== "-f" && !consumed.has(idx));
|
|
361
|
+
if (unexpected.length > 0) {
|
|
362
|
+
ctx.ui.notify(
|
|
363
|
+
`/harness list: unexpected argument(s): ${unexpected.join(" ")} — expected -f, --kind <kind>, --scope <global|project|all>.`,
|
|
364
|
+
"warning",
|
|
365
|
+
);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
ctx.ui.notify(
|
|
369
|
+
renderListReport(getState().items, {
|
|
370
|
+
...(kind ? { kind } : {}),
|
|
371
|
+
...(scope ? { scope } : {}),
|
|
372
|
+
...(full ? { full } : {}),
|
|
373
|
+
slug: projectSlug(ctx.cwd),
|
|
374
|
+
}),
|
|
375
|
+
"info",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
303
379
|
async function handleImport(
|
|
304
380
|
pi: ExtensionAPI,
|
|
305
381
|
ctx: ExtensionCommandContext,
|
|
@@ -493,6 +569,17 @@ async function handleMove(
|
|
|
493
569
|
}
|
|
494
570
|
}
|
|
495
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
|
+
|
|
496
583
|
/** Compose the /harness split steering message: the agent classifies every
|
|
497
584
|
* active item global-vs-project and applies the classification as ONE
|
|
498
585
|
* harness_mutate batch of scope-only update deltas — visible in the
|
|
@@ -501,7 +588,7 @@ function buildSplitPrompt(items: HarnessItem[], slug: string, cwd: string): stri
|
|
|
501
588
|
const lines = [
|
|
502
589
|
`/harness split — classify ${items.length} Continual Harness item(s) into durable scopes`,
|
|
503
590
|
"",
|
|
504
|
-
`Current project: ${cwd} (slug
|
|
591
|
+
`Current project: ${cwd} (slug "${slug}").`,
|
|
505
592
|
"",
|
|
506
593
|
"For EACH item below, decide its durable scope:",
|
|
507
594
|
"- `project` — only useful when working in THIS project (deploy procedures, project architecture, local conventions, project-specific quirks).",
|
|
@@ -512,13 +599,8 @@ function buildSplitPrompt(items: HarnessItem[], slug: string, cwd: string): stri
|
|
|
512
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.`,
|
|
513
600
|
"",
|
|
514
601
|
"Items:",
|
|
602
|
+
...formatItemLines(items, "fixed"),
|
|
515
603
|
];
|
|
516
|
-
items.forEach((i, n) => {
|
|
517
|
-
lines.push(
|
|
518
|
-
`${n + 1}. [${i.id}] (${i.kind}, importance ${i.importance.toFixed(2)}, ${i.scope === "project" ? `project \"${i.project ?? ""}\"` : "global"}) ${i.content}`,
|
|
519
|
-
` evidence: ${i.evidence}`,
|
|
520
|
-
);
|
|
521
|
-
});
|
|
522
604
|
return lines.join("\n");
|
|
523
605
|
}
|
|
524
606
|
|
|
@@ -606,16 +688,8 @@ export function buildAdoptPrompt(
|
|
|
606
688
|
: "- Summarize at the end: adopted N, skipped M (stays) — with one short reason per skip.",
|
|
607
689
|
"",
|
|
608
690
|
"Items:",
|
|
691
|
+
...formatItemLines(items, "raw"),
|
|
609
692
|
);
|
|
610
|
-
items.forEach((i, n) => {
|
|
611
|
-
// Full precision (no toFixed): the agent copies the listed importance
|
|
612
|
-
// verbatim into the create delta, and a rounded value would change the
|
|
613
|
-
// copy's ranking against existing items in injection selection (review #25).
|
|
614
|
-
lines.push(
|
|
615
|
-
`${n + 1}. [${i.id}] (${i.kind}, importance ${i.importance}, ${i.scope === "project" ? `project \"${i.project ?? ""}\"` : "global"}) ${i.content}`,
|
|
616
|
-
` evidence: ${i.evidence}`,
|
|
617
|
-
);
|
|
618
|
-
});
|
|
619
693
|
return lines.join("\n");
|
|
620
694
|
}
|
|
621
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) {
|
|
@@ -132,6 +142,17 @@ export {
|
|
|
132
142
|
} from "./hints.js";
|
|
133
143
|
export { getInjectionStats, resetInjectionStats, type InjectionStats } from "./inject.js";
|
|
134
144
|
|
|
145
|
+
// /harness list (issue #31): pure selection + rendering for the read view of
|
|
146
|
+
// the store (compact rows / full text + evidence). Re-exported for tests and
|
|
147
|
+
// companions without reaching into internal paths.
|
|
148
|
+
export {
|
|
149
|
+
preview,
|
|
150
|
+
renderListReport,
|
|
151
|
+
selectListItems,
|
|
152
|
+
type ListOptions,
|
|
153
|
+
type ListScope,
|
|
154
|
+
} from "./list.js";
|
|
155
|
+
|
|
135
156
|
// Injection selection policy (on by default). Re-exported so companion packages
|
|
136
157
|
// and tests can reuse the pure selection/normalization without reaching into
|
|
137
158
|
// internal paths.
|
package/src/list.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// `/harness list` (issue #31) — the read view of the store. One compact row
|
|
2
|
+
// per item (id, kind, importance, scope, preview) or, with `-f`, a
|
|
3
|
+
// harness_list-style full text + evidence rendering (extended with the
|
|
4
|
+
// scope). Read-only: selection
|
|
5
|
+
// and rendering are pure; harness.ts's handleList stays thin glue (flag
|
|
6
|
+
// parsing + one ctx.ui.notify).
|
|
7
|
+
//
|
|
8
|
+
// Scope semantics mirror the durable layers (issue #7): the default view is
|
|
9
|
+
// the session's own layer set — global + the ACTIVE project — the same set
|
|
10
|
+
// layered /harness import READS for this cwd (layered export writes more:
|
|
11
|
+
// every project layer with active items, foreign included — store.ts).
|
|
12
|
+
// Items scoped to a DIFFERENT project are hidden (and counted in a note,
|
|
13
|
+
// listed or not) unless you ask for them:
|
|
14
|
+
// `--scope project` lists every project-scoped item (rows carry the slug) and
|
|
15
|
+
// `--scope all` lists everything. An item's undefined scope counts as global
|
|
16
|
+
// (legacy snapshots / durable files — see types.ts).
|
|
17
|
+
|
|
18
|
+
import type { ComponentKind, HarnessItem } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** `--scope` values for /harness list. `undefined` (no flag) = the session's
|
|
21
|
+
* layer set: global + the active project. */
|
|
22
|
+
export type ListScope = "global" | "project" | "all";
|
|
23
|
+
|
|
24
|
+
/** Options for selectListItems / renderListReport. */
|
|
25
|
+
export interface ListOptions {
|
|
26
|
+
/** Filter by component kind (mirrors harness_list's kind parameter). */
|
|
27
|
+
kind?: ComponentKind;
|
|
28
|
+
/** Durable-scope filter; undefined = global + the active project. */
|
|
29
|
+
scope?: ListScope;
|
|
30
|
+
/** `-f`: full text + evidence per item (default: one compact row). */
|
|
31
|
+
full?: boolean;
|
|
32
|
+
/** The active project's slug (from the session cwd); selects the default
|
|
33
|
+
* view and labels the header. */
|
|
34
|
+
slug: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Single-line, whitespace-flattened preview truncated to `max` chars — the
|
|
38
|
+
* shared truncation style for one-line rows (menu entries, notifications). */
|
|
39
|
+
export function preview(text: string, max = 60): string {
|
|
40
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
41
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when an item belongs to the durable layer(s) the options select. */
|
|
45
|
+
function inScope(i: HarnessItem, opts: ListOptions): boolean {
|
|
46
|
+
switch (opts.scope) {
|
|
47
|
+
case "global":
|
|
48
|
+
return i.scope !== "project";
|
|
49
|
+
case "project":
|
|
50
|
+
return i.scope === "project";
|
|
51
|
+
case "all":
|
|
52
|
+
return true;
|
|
53
|
+
default:
|
|
54
|
+
// Session layer set: global + the active project (undefined scope is
|
|
55
|
+
// global; a project item only shows when its slug is the active one).
|
|
56
|
+
return i.scope !== "project" || i.project === opts.slug;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Select the listable items: kind filter, scope filter, then importance
|
|
61
|
+
* desc with ties keeping store order — the same stable ranking policy as
|
|
62
|
+
* injection selection and /harness adopt. Pure. */
|
|
63
|
+
export function selectListItems(items: HarnessItem[], opts: ListOptions): HarnessItem[] {
|
|
64
|
+
return items
|
|
65
|
+
.filter((i) => (opts.kind ? i.kind === opts.kind : true) && inScope(i, opts))
|
|
66
|
+
.map((i, idx) => [i, idx] as const)
|
|
67
|
+
.sort((a, b) => b[0].importance - a[0].importance || a[1] - b[1])
|
|
68
|
+
.map(([i]) => i);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Compact row: `[id] kind importance scope preview` (issue #31's table);
|
|
72
|
+
* inactive items carry an `(inactive)` marker so the row stays one line. */
|
|
73
|
+
function row(i: HarnessItem): string {
|
|
74
|
+
const scope = i.scope === "project" ? `project:${i.project ?? "?"}` : "global";
|
|
75
|
+
return `[${i.id}] ${i.kind.padEnd(8)} ${i.importance.toFixed(2)} ${scope} ${
|
|
76
|
+
i.active ? "" : "(inactive) "
|
|
77
|
+
}${preview(i.content)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Full row (`-f`): the harness_list rendering, scope-aware — the same
|
|
81
|
+
* parenthetical the split/adopt prompts use, plus the evidence line. */
|
|
82
|
+
function fullRow(i: HarnessItem): string {
|
|
83
|
+
const bits = [
|
|
84
|
+
i.kind,
|
|
85
|
+
`importance ${i.importance.toFixed(2)}`,
|
|
86
|
+
i.active ? "active" : "inactive",
|
|
87
|
+
`model ${i.ownerModel || "(orphan)"}`,
|
|
88
|
+
i.scope === "project" ? `project "${i.project ?? ""}"` : "global",
|
|
89
|
+
];
|
|
90
|
+
return `[${i.id}] (${bits.join(", ")}) ${i.content}\n evidence: ${i.evidence}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Tally `items` by a string key, rendered as "key (n), key (n)". */
|
|
94
|
+
function tally<T>(items: T[], key: (i: T) => string): string {
|
|
95
|
+
const counts = new Map<string, number>();
|
|
96
|
+
for (const i of items) counts.set(key(i), (counts.get(key(i)) ?? 0) + 1);
|
|
97
|
+
return [...counts.entries()].map(([k, n]) => `${k} (${n})`).join(", ");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scopeDescription(opts: ListOptions): string {
|
|
101
|
+
switch (opts.scope) {
|
|
102
|
+
case "global":
|
|
103
|
+
return "scope global";
|
|
104
|
+
case "project":
|
|
105
|
+
return 'scope project (any project\'s items; rows carry the slug)';
|
|
106
|
+
case "all":
|
|
107
|
+
return "scope all";
|
|
108
|
+
default:
|
|
109
|
+
return `scope global + project "${opts.slug}"`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Render the whole `/harness list` notify string: header (count + active
|
|
114
|
+
* filters), rows (compact by default, full text + evidence with `full`),
|
|
115
|
+
* and distinct empty-state guidance for an empty store vs an empty filter
|
|
116
|
+
* result (the emptyListMessage lesson: never collapse the two). Pure. */
|
|
117
|
+
export function renderListReport(items: HarnessItem[], opts: ListOptions): string {
|
|
118
|
+
const shown = selectListItems(items, opts);
|
|
119
|
+
const header =
|
|
120
|
+
`Harness list: ${shown.length} item(s) — ${scopeDescription(opts)}` +
|
|
121
|
+
(opts.kind ? `, kind ${opts.kind}` : "") +
|
|
122
|
+
` (${opts.full ? "full text" : "compact; -f for full text"}).`;
|
|
123
|
+
// The default view hides foreign-project items on purpose (they are not
|
|
124
|
+
// this session's layers) — say so instead of letting them look missing.
|
|
125
|
+
// Computed ONCE so the note rides along on every non-empty-store result,
|
|
126
|
+
// empty filter results included (a foreign-only store still gets it).
|
|
127
|
+
let foreignNote: string | undefined;
|
|
128
|
+
if (opts.scope === undefined) {
|
|
129
|
+
const foreign = items.filter(
|
|
130
|
+
(i) => i.scope === "project" && i.project !== opts.slug && (opts.kind ? i.kind === opts.kind : true),
|
|
131
|
+
).length;
|
|
132
|
+
if (foreign > 0) {
|
|
133
|
+
foreignNote = `+ ${foreign} foreign-project item(s) hidden — re-run with --scope all to see them.`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (shown.length === 0) {
|
|
137
|
+
if (items.length === 0) {
|
|
138
|
+
return [
|
|
139
|
+
header,
|
|
140
|
+
"No harness items yet. Run `/refine` after a working session (or `/harness import`) — items appear here as they are created.",
|
|
141
|
+
].join("\n");
|
|
142
|
+
}
|
|
143
|
+
// A store whose matching items are ALL foreign must not collapse into
|
|
144
|
+
// generic filter guidance: the hidden note explains where they went
|
|
145
|
+
// (review #32) — the default view hides them, no filter removed them.
|
|
146
|
+
// (No flag echo here: the header already names scope+kind, and a
|
|
147
|
+
// pseudo-flag like `--scope global+project` would not even parse.)
|
|
148
|
+
return [
|
|
149
|
+
header,
|
|
150
|
+
`Nothing matches the current filters. The store holds ${items.length} item(s): ${tally(items, (i) => i.kind)}. Widen --scope or drop --kind.`,
|
|
151
|
+
...(foreignNote ? [foreignNote] : []),
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
const lines = [header, ...shown.map(opts.full ? fullRow : row)];
|
|
155
|
+
if (foreignNote) lines.push(foreignNote);
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
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
|
@@ -88,7 +88,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
88
88
|
name: "harness_list",
|
|
89
89
|
label: "List harness state",
|
|
90
90
|
description:
|
|
91
|
-
"List items in the Continual Harness state (self-improved prompt notes, memory, skill descriptions, sub-agent specs). Optionally filter by kind.",
|
|
91
|
+
"List items in the Continual Harness state (self-improved prompt notes, memory, skill descriptions, sub-agent specs). Optionally filter by kind. (/harness list is the human-facing TUI view: compact rows + durable-scope filters.)",
|
|
92
92
|
promptSnippet: "Read Continual Harness self-improvement state",
|
|
93
93
|
promptGuidelines: [
|
|
94
94
|
"Call harness_list before proposing changes so you operate on current state.",
|
|
@@ -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>();
|