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 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` values, `move` scopes, and
79
- `keep`/`drop`/`move` item ids all autocomplete in the TUI as you type after
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 or malformed → defaults):
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.15.1",
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",
@@ -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
- // Completed turns observed since the last fire (or the seeded baseline).
44
- // -1 = unseen. Aborted/error turns never touch this count — the window is
45
- // completed turns of work, not raw turn indices.
46
- let completedTurns = -1;
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
- completedTurns = -1;
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
- if (every <= 0) return false;
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/malformed → defaults.
317
- * Cached for the process lifetime (config is read once per session). */
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
- const prev = snapshots.at(-1);
313
- if (prev) diffSnapshots(prev, next, citeBump, lifecycle);
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 { turnEndedBadly } from "./auto-refine.js";
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
- // Completed turns observed since the last import reminder (or the seeded
48
- // baseline). -1 = unseen. Aborted/error turns never touch this count — the
49
- // cadence counts completed turns of work (mirrors remind.ts / #18), never raw
50
- // turn indices: pi's `turnIndex` resets to 0 on every agent_start (each user
51
- // prompt), so index deltas would never accumulate across prompts (review #26).
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
- completedTurns = -1;
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 (completedTurns < 0) {
161
- completedTurns = 0; // seed: the seed turn itself doesn't count
162
- return false;
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 `completedTurns`): aborted/error outcomes
192
- // are inert and pi's per-agent-start turnIndex resets never matter.
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
- function preview(text: string, max = 60): string {
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 ("--…" prefix, or a fresh token for flag-taking subs).
179
- // A fresh --kind/--model value was already completed above (early return),
180
- // so a fresh token here means "offer the remaining flags".
181
- if (last.startsWith("--") || (fresh && sub in FLAGS)) {
182
- const used = new Set(before.filter((t) => t.startsWith("--")));
183
- const rows = (FLAGS[sub] ?? []).filter((f) => !used.has(f.name));
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 \"${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
- function durableLayer(i: HarnessItem): string {
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 { turnEndedBadly } from "./auto-refine.js";
31
+ import { makeTurnCadence } from "./cadence.js";
32
32
  import { getState } from "./store.js";
33
33
 
34
- // Completed turns observed since the last reminder (or the seeded baseline).
35
- // -1 = unseen. Aborted/error turns never touch this count.
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
- completedTurns = -1;
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 <= 0) return idle;
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
- if (t.event === "created") {
114
- whole.created += 1;
115
- o.created += 1;
116
- } else if (t.event === "kept") {
117
- whole.kept += 1;
118
- o.kept += 1;
119
- } else if (t.event === "dropped") {
120
- whole.dropped += 1;
121
- o.dropped += 1;
122
- } else if (t.event === "cited") {
123
- whole.cited += 1;
124
- o.cited += 1;
125
- } else if (t.event === "pruned") {
126
- whole.pruned += 1;
127
- o.pruned += 1;
128
- } else {
129
- whole.deleted += 1;
130
- o.deleted += 1;
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
- items: last.items.map((i): HarnessItem => {
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
- version += 1;
281
- persist(state, version);
282
- return { pruned: before - state.items.length, decayed };
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 = p.id ? existingById.get(p.id) : undefined;
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 lookup current so a later layer's copy of the same id
660
- // UPDATES the item we just created instead of duplicating it (the same
661
- // id in both layers is the normal collision case, not an anomaly).
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: [{ type: "text", text: summary }],
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>();