pi-continual-harness 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -129,6 +129,11 @@ The model-facing tools:
129
129
  project slug is stamped server-side from the session cwd — used by
130
130
  `/harness split`; normally managed via `/harness move`).
131
131
 
132
+ In **codemode** (pi ≥ 1.2), these tools' input constraints render as `@`
133
+ tags on property comments — `importance` shows `@minimum 0 @maximum 1`, the
134
+ batch cap shows `@maxItems 20` — because the tool schemas already declare
135
+ them. Scripts see the limits without the descriptions repeating them.
136
+
132
137
  Active items are injected into the system prompt each turn as a structured
133
138
  block, appended to (never replacing) the base prompt.
134
139
 
@@ -180,19 +185,27 @@ Optional config at `~/.pi/agent/harness.json` (missing → defaults; malformed
180
185
 
181
186
  ```json
182
187
  {
188
+ "$schema": "https://raw.githubusercontent.com/pungggi/pi-continual-harness/main/harness.schema.json",
183
189
  "autoImport": false,
184
190
  "importRemind": { "enabled": true, "everyTurns": 25 },
185
191
  "proposer": "steering",
186
192
  "dedupe": { "threshold": 0.6, "merge": true },
187
193
  "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
188
194
  "remindRefine": { "enabled": true, "everyTurns": 25, "detailEvery": 4 },
189
- "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false, "gate": false },
195
+ "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false, "gate": false, "refineNoOpSkip": 3 },
190
196
  "outcomeImportance": { "enabled": false, "bump": 0.03 },
191
197
  "classifier": { "model": "typesafe/jev-latest" },
192
198
  "virtualBinding": "virtual"
193
199
  }
194
200
  ```
195
201
 
202
+ **Editor validation**: add `"$schema":
203
+ "https://raw.githubusercontent.com/pungggi/pi-continual-harness/main/harness.schema.json"`
204
+ to the file for completion and validation in editors that support JSON
205
+ schemas (the same pattern pi ≥ 1.2 ships for its own `settings.json` /
206
+ `models.json`). The loader ignores unknown keys — `$schema` included — and a
207
+ `/harness setup` rewrite preserves it.
208
+
196
209
  - **`autoImport`** — opt-in **durable sync** (off by default). `true` bundles
197
210
  both directions: on `session_start` the durable layers are imported
198
211
  automatically (global file always + the current project's file, with the same
@@ -258,7 +271,14 @@ Optional config at `~/.pi/agent/harness.json` (missing → defaults; malformed
258
271
  also flushes durable state on each run. `gate: true` (needs `classifier.model`)
259
272
  asks the classifier first whether the recent trajectory contains a durable,
260
273
  reusable correction and skips the refine when it does not — cadence becomes
261
- signal, not just time (errors fall back to the plain cadence). The steering
274
+ signal, not just time (errors fall back to the plain cadence).
275
+ `refineNoOpSkip` (default 3, `0` disables) is a free **history gate** that
276
+ runs first: when the last N refine runs (any source) all produced nothing
277
+ durable, the cadenced refine is skipped as a likely repeat no-op. It is
278
+ **bounded**: at most one consecutive cadence is skipped on history grounds,
279
+ so the gate can never latch shut — later trajectory evidence always gets a
280
+ refine within one cadence (and manual `/refine` never consults the gate).
281
+ The steering
262
282
  prompt is delivered via the pi ≥ 0.87 actionable `turn_end` boundary (a
263
283
  structural `harness.auto-refine-request` entry + `continue: true`), not a
264
284
  synthetic user message. Aborted/error turns (pi `turn_end`
@@ -306,6 +326,36 @@ Optional config at `~/.pi/agent/harness.json` (missing → defaults; malformed
306
326
  > scope), then move any strays with `/harness move` (or classify the whole
307
327
  > store with `/harness split`).
308
328
 
329
+ ## Lessons and support signals (harnessing-level recursion)
330
+
331
+ Harness²'s second loop: the improvement process should learn from what past
332
+ improvement actually yielded, and keep a lesson only when execution evidence
333
+ supports it. Four pieces implement this without any extra model calls:
334
+
335
+ - **Support counters.** Every item accumulates `support` signals: `citations`
336
+ (the outcome loop saw the agent cite the item's `[h_xxxx]` tag), `keeps` and
337
+ `drops` (`/harness keep|drop`). Dedupe merges carry the counters onto the
338
+ keeper, and durable export/import round-trips them (`support:` sub-line), so
339
+ feedback survives restarts. See them with `/harness list -f`.
340
+ - **Lessons digest.** `/refine` steers with a compact, evidence-gated digest:
341
+ items proven useful (cited or kept), rejected signals (drops that strictly
342
+ outweigh any later use — a drop followed by re-citations is contested, not
343
+ rejected), and how productive recent refine runs were. Never-cited items are
344
+ NOT named: no signal is not a negative signal. The digest is **scoped to the
345
+ active model's items** — per-model isolation applies to what the improver is
346
+ told, not just what it is shown. `/harness lessons` prints the exact digest
347
+ the next `/refine` will use. Why: the improver starts from
348
+ what worked instead of re-deriving it, and stops re-creating rejected notes.
349
+ - **Scoped proposals.** The `/refine` steering prompt classifies every create
350
+ as **invariant** (cross-domain agentic behavior → `scope: "global"`) or
351
+ **domain-specific** (this project's knowledge → `scope: "project"`).
352
+ Harness²'s ablation shows this scoping is load-bearing; here it also keeps
353
+ the global layer free of project noise.
354
+ - **History gate.** Auto-refine skips a cadenced run when the last
355
+ `refineNoOpSkip` refine runs all produced nothing durable, **bounded to one
356
+ consecutive skip** so later evidence always gets a chance (see
357
+ `autoRefine` in [Configuration](#configuration)).
358
+
309
359
  ## How it works
310
360
 
311
361
  1. `/refine` gathers recent trajectory evidence from the current session branch.
@@ -505,7 +555,7 @@ implementation on first `propose()`) directly into
505
555
 
506
556
  ## Status
507
557
 
508
- 0.12.x. Requires **pi ≥ 0.99.0**. Implemented:
558
+ 0.18.x. Requires **pi ≥ 1.2.0**. Implemented:
509
559
 
510
560
  - Unified harness-state store with branch-local snapshots (`/tree` rollback).
511
561
  - Online `/refine` + `harness_mutate` / `harness_list` tools.
@@ -535,7 +585,10 @@ implementation on first `propose()`) directly into
535
585
  model instead of the router id, so a router selection does not silently
536
586
  reset the harness. Owned items can be carried across models explicitly with
537
587
  `/harness adopt --from <provider/id> [--all]` (agent-filtered copy; see
538
- [Model binding](#model-binding-per-model-isolation)).
588
+ [Model binding](#model-binding-per-model-isolation)). Since pi 1.2,
589
+ `before_agent_start` also fires for runs started by `pi.sendMessage(...,
590
+ { triggerTurn: true })`, so scheduled and extension-triggered runs get the
591
+ same binding, orphan adoption, and injection as interactive prompts.
539
592
  - **Bounded injection (on by default)**: the supplemental block is
540
593
  importance-ordered and capped per kind + by a total token budget, so a growing
541
594
  harness never balloons the system prompt. Tunable / opt-out via the `injection`
@@ -1,7 +1,15 @@
1
1
  import type { ExtensionAPI, ExtensionContext, TurnEndEvent, TurnEndEventResult } from "@earendil-works/pi-coding-agent";
2
2
  import { type HarnessConfig } from "./config.js";
3
3
  export { turnEndedBadly } from "./cadence.js";
4
- /** Test hook: reset internal counter. */
4
+ /** History gate bound (review r4341960942): the gate's predicate only reads
5
+ * REFINE entries on the branch, and a SKIPPED cadence adds none — without a
6
+ * bound, three consecutive no-op runs would latch the gate shut until a
7
+ * manual /refine, no matter what later turns contain. So at most ONE
8
+ * consecutive cadence is skipped on history grounds: the next cadence runs
9
+ * the refine regardless (its result re-evaluates the window afterwards),
10
+ * giving later trajectory evidence a bounded path in. */
11
+ export declare const HISTORY_GATE_MAX_CONSECUTIVE_SKIPS = 1;
12
+ /** Test hook: reset internal counters. */
5
13
  export declare function resetAutoRefine(): void;
6
14
  /**
7
15
  * Decide whether to auto-refine this turn. Side-effectful: the first observed
@@ -34,18 +34,31 @@
34
34
  // It stays visible: it notifies before firing, and the boundary draft (and the
35
35
  // gate decision) appear in the transcript. The decision logic is factored into
36
36
  // evaluateAutoRefine() so it is unit-testable without a live pi runtime.
37
- import { DEFAULT_AUTO_EVERY_TURNS, loadConfig } from "./config.js";
37
+ import { DEFAULT_AUTO_EVERY_TURNS, DEFAULT_REFINE_NOOP_SKIP, loadConfig } from "./config.js";
38
38
  import { runRefine, gatherEvidence, DEFAULT_LOOKBACK_TURNS } from "./refine.js";
39
39
  import { buildClassify, buildGateRequest, gateDecision } from "./classify.js";
40
+ import { recentIneffectiveRuns } from "./lessons.js";
40
41
  import { makeTurnCadence } from "./cadence.js";
41
42
  // turnEndedBadly's canonical home is cadence.ts (shared completed-turn
42
43
  // semantics); re-exported here so existing imports keep working.
43
44
  export { turnEndedBadly } from "./cadence.js";
44
45
  // The auto-refine cadence counter (shared implementation — see cadence.ts).
45
46
  const cadence = makeTurnCadence();
46
- /** Test hook: reset internal counter. */
47
+ /** History gate bound (review r4341960942): the gate's predicate only reads
48
+ * REFINE entries on the branch, and a SKIPPED cadence adds none — without a
49
+ * bound, three consecutive no-op runs would latch the gate shut until a
50
+ * manual /refine, no matter what later turns contain. So at most ONE
51
+ * consecutive cadence is skipped on history grounds: the next cadence runs
52
+ * the refine regardless (its result re-evaluates the window afterwards),
53
+ * giving later trajectory evidence a bounded path in. */
54
+ export const HISTORY_GATE_MAX_CONSECUTIVE_SKIPS = 1;
55
+ /** Consecutive cadences the history gate has skipped this session (reset when
56
+ * a refine actually runs, by resetAutoRefine, and at session_start). */
57
+ let historyGateSkips = 0;
58
+ /** Test hook: reset internal counters. */
47
59
  export function resetAutoRefine() {
48
60
  cadence.reset();
61
+ historyGateSkips = 0;
49
62
  }
50
63
  /**
51
64
  * Decide whether to auto-refine this turn. Side-effectful: the first observed
@@ -97,6 +110,19 @@ export async function handleAutoRefineTurnEnd(pi, event, ctx) {
97
110
  if (!evaluateAutoRefine(config, event.outcome))
98
111
  return;
99
112
  const every = config.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
113
+ // History gate (harnessing-level recursion): free, runs before the classifier
114
+ // gate — when the last N refine runs were ALL ineffective, a cadenced refine
115
+ // over the same quiet trajectory would most likely be another no-op. Manual
116
+ // /refine never consults this gate. BOUNDED: at most
117
+ // HISTORY_GATE_MAX_CONSECUTIVE_SKIPS consecutive cadence is skipped, so the
118
+ // gate can never latch shut against later, richer trajectory evidence
119
+ // (review r4341960942) — the run after a skip always happens.
120
+ const noOpSkip = config.autoRefine?.refineNoOpSkip ?? DEFAULT_REFINE_NOOP_SKIP;
121
+ if (historyGateSkips < HISTORY_GATE_MAX_CONSECUTIVE_SKIPS && recentIneffectiveRuns(ctx.sessionManager.getBranch(), noOpSkip)) {
122
+ historyGateSkips += 1;
123
+ ctx.ui.notify(`Auto-refine: skipped — the last ${noOpSkip} refine run(s) produced nothing durable (history gate, ${historyGateSkips}/${HISTORY_GATE_MAX_CONSECUTIVE_SKIPS} consecutive skip; the next cadence runs regardless; refineNoOpSkip: 0 disables).`, "info");
124
+ return;
125
+ }
100
126
  // Opt-in classifier gate: cheap yes/no before spending the refine.
101
127
  if (config.autoRefine?.gate) {
102
128
  const classify = buildClassify(ctx, config.classifier?.model);
@@ -113,6 +139,9 @@ export async function handleAutoRefineTurnEnd(pi, event, ctx) {
113
139
  // No resolvable classifier → plain cadence behavior (documented soft-fail).
114
140
  }
115
141
  ctx.ui.notify(`Auto-refine: running /refine (every ${every} turns, opt-in).`, "info");
142
+ // A refine runs this cadence → the history gate's consecutive-skip budget
143
+ // is spent again (window re-evaluated from scratch next cadence).
144
+ historyGateSkips = 0;
116
145
  try {
117
146
  const result = await runRefine(pi, ctx, {
118
147
  commit: config.autoRefine?.commit ?? false,
@@ -124,7 +153,11 @@ export async function handleAutoRefineTurnEnd(pi, event, ctx) {
124
153
  // continue — otherwise fall back to the legacy steering send.
125
154
  if (result.steeringMessage) {
126
155
  if (event.context?.canContinue === false) {
127
- pi.sendUserMessage(result.steeringMessage);
156
+ // deliverAs "followUp": queue after the in-flight run instead of
157
+ // throwing — pi 1.2 counts the session as busy from run start, so a
158
+ // bare sendUserMessage can fail here when another extension is
159
+ // starting a run at this turn_end.
160
+ pi.sendUserMessage(result.steeringMessage, { deliverAs: "followUp" });
128
161
  return;
129
162
  }
130
163
  // Hygiene (pi ≥ 0.87 context edits): omit every LIVE prior auto-refine
package/dist/config.d.ts CHANGED
@@ -4,6 +4,10 @@ export declare const DEFAULT_EVERY_TURNS = 25;
4
4
  export declare const DEFAULT_AUTO_EVERY_TURNS = 100;
5
5
  /** Default per-reference importance bump for the opt-in outcome loop. */
6
6
  export declare const DEFAULT_REF_BUMP = 0.03;
7
+ /** History-gate default: a cadenced auto-refine is skipped when the last N
8
+ * refine runs (any source) were ALL ineffective — no deltas applied and no
9
+ * state mutation followed. 0 disables the gate (autoRefine.refineNoOpSkip). */
10
+ export declare const DEFAULT_REFINE_NOOP_SKIP = 3;
7
11
  /** Default cadence (turns) for the auto-import awareness reminder. */
8
12
  export declare const DEFAULT_IMPORT_REMIND_TURNS = 25;
9
13
  /** Default escalation cadence for the /refine reminder: every Nth fired
@@ -59,6 +63,13 @@ export interface HarnessConfig {
59
63
  * one (the newest) is ever model-visible. Raw history and /tree rollback
60
64
  * are untouched; set false to keep every draft in context. */
61
65
  omitStaleDrafts?: boolean;
66
+ /** History gate (harnessing-level recursion): skip a cadenced refine when
67
+ * the last N refine runs (any source) were ALL ineffective — no deltas
68
+ * applied and no state mutation followed. Refines over an unchanged quiet
69
+ * trajectory would most likely be no-ops again; the classifier gate (if
70
+ * enabled) then never even runs. Manual /refine bypasses this. Default
71
+ * DEFAULT_REFINE_NOOP_SKIP (3); 0 disables the history gate. */
72
+ refineNoOpSkip?: number;
62
73
  };
63
74
  /** Classifier model for the cheap gated paths (pi ≥ 0.99): "provider/id" or
64
75
  * a bare id of a classifier catalog entry (e.g. "typesafe/jev-latest" or a
package/dist/config.js CHANGED
@@ -24,6 +24,10 @@ export const DEFAULT_EVERY_TURNS = 25;
24
24
  export const DEFAULT_AUTO_EVERY_TURNS = 100;
25
25
  /** Default per-reference importance bump for the opt-in outcome loop. */
26
26
  export const DEFAULT_REF_BUMP = 0.03;
27
+ /** History-gate default: a cadenced auto-refine is skipped when the last N
28
+ * refine runs (any source) were ALL ineffective — no deltas applied and no
29
+ * state mutation followed. 0 disables the gate (autoRefine.refineNoOpSkip). */
30
+ export const DEFAULT_REFINE_NOOP_SKIP = 3;
27
31
  /** Default cadence (turns) for the auto-import awareness reminder. */
28
32
  export const DEFAULT_IMPORT_REMIND_TURNS = 25;
29
33
  /** Default escalation cadence for the /refine reminder: every Nth fired
@@ -33,7 +37,7 @@ export const DEFAULT_CONFIG = {
33
37
  autoImport: false,
34
38
  importRemind: { enabled: true, everyTurns: DEFAULT_IMPORT_REMIND_TURNS },
35
39
  remindRefine: { enabled: true, everyTurns: DEFAULT_EVERY_TURNS, detailEvery: DEFAULT_DETAIL_EVERY },
36
- autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false, gate: false, omitStaleDrafts: true },
40
+ autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false, gate: false, omitStaleDrafts: true, refineNoOpSkip: DEFAULT_REFINE_NOOP_SKIP },
37
41
  classifier: { model: undefined },
38
42
  virtualBinding: "virtual",
39
43
  proposer: "steering",
@@ -75,6 +79,9 @@ function mergeConfig(over) {
75
79
  commit: over.autoRefine?.commit ?? false,
76
80
  gate: over.autoRefine?.gate === true,
77
81
  omitStaleDrafts: over.autoRefine?.omitStaleDrafts !== false,
82
+ refineNoOpSkip: typeof over.autoRefine?.refineNoOpSkip === "number" && Number.isFinite(over.autoRefine.refineNoOpSkip)
83
+ ? Math.max(0, Math.floor(over.autoRefine.refineNoOpSkip))
84
+ : DEFAULT_REFINE_NOOP_SKIP,
78
85
  },
79
86
  classifier: {
80
87
  model: typeof over.classifier?.model === "string" && over.classifier.model ? over.classifier.model : undefined,
package/dist/harness.js CHANGED
@@ -40,6 +40,7 @@ import { buildStatusReport, renderStatusReport } from "./status.js";
40
40
  import { getInjectionStats } from "./inject.js";
41
41
  import { nextHint } from "./hints.js";
42
42
  import { preview, renderListReport } from "./list.js";
43
+ import { buildLessonsDigest } from "./lessons.js";
43
44
  import { handleSetup, PRESET_COMPLETIONS } from "./setup.js";
44
45
  const SUBCOMMANDS = [
45
46
  { name: "import", description: "Import durable state, layered (--prune to prune stale items)" },
@@ -55,6 +56,7 @@ const SUBCOMMANDS = [
55
56
  { name: "adopt", description: "Steer the agent to adopt another model's items (--from <provider/id> [--all])" },
56
57
  { name: "push-mem", description: "Persist active items to pi-mem (--all, --kind, --model)" },
57
58
  { name: "setup", description: "Guided config wizard — presets: quiet | recommended | power (--yes for scripted)" },
59
+ { name: "lessons", description: "Evidence-gated lessons from past refines (the digest /refine steers with)" },
58
60
  ];
59
61
  const KINDS = ["prompt", "memory", "skill", "subagent"];
60
62
  /** `--scope` values /harness list offers (semantics in src/list.ts). */
@@ -243,6 +245,9 @@ export async function runHarnessCommand(pi, args, ctx) {
243
245
  case "setup":
244
246
  await handleSetup(ctx, rest);
245
247
  return;
248
+ case "lessons":
249
+ handleLessons(ctx);
250
+ return;
246
251
  case "list":
247
252
  await handleList(ctx, rest);
248
253
  return;
@@ -442,7 +447,9 @@ async function handleBump(pi, ctx, rest, delta, label) {
442
447
  }
443
448
  const item = bumpImportance(id, delta, (snapshot, ver) => {
444
449
  pi.appendEntry("harness-state", { state: snapshot, version: ver });
445
- });
450
+ },
451
+ // Record the human signal in the support counters (keep/drop).
452
+ { support: label === "keep" ? "keep" : "drop" });
446
453
  if (!item) {
447
454
  ctx.ui.notify(`No harness item with id ${id}.`, "warning");
448
455
  return;
@@ -519,7 +526,9 @@ async function handleSplit(pi, ctx) {
519
526
  ctx.ui.notify("No active items to split (run /refine first).", "warning");
520
527
  return;
521
528
  }
522
- pi.sendUserMessage(buildSplitPrompt(items, projectSlug(ctx.cwd), ctx.cwd));
529
+ // deliverAs "followUp": queue behind any in-flight run (pi ≥ 1.2 busy window
530
+ // starts at run start; a bare send would throw) rather than steering mid-task.
531
+ pi.sendUserMessage(buildSplitPrompt(items, projectSlug(ctx.cwd), ctx.cwd), { deliverAs: "followUp" });
523
532
  ctx.ui.notify(`Steering agent to classify ${items.length} item(s) global vs project "${projectSlug(ctx.cwd)}".`, "info");
524
533
  }
525
534
  /** Active items grouped by owning model key ("provider/id" → count). The
@@ -640,7 +649,7 @@ async function handleAdopt(pi, ctx, rest) {
640
649
  if (foreign > 0)
641
650
  bits.push(`${foreign} foreign-project skipped`);
642
651
  const suffix = bits.length > 0 ? ` (${bits.join("; ")})` : "";
643
- pi.sendUserMessage(buildAdoptPrompt(items, from, { all: rest.includes("--all") }));
652
+ pi.sendUserMessage(buildAdoptPrompt(items, from, { all: rest.includes("--all") }), { deliverAs: "followUp" });
644
653
  ctx.ui.notify(`Steering agent to ${rest.includes("--all") ? "adopt" : "review for adoption"} ${items.length} item(s) from ${from}${suffix}.`, "info");
645
654
  }
646
655
  /** Compose a steering message that asks the agent to persist the given active
@@ -691,9 +700,20 @@ async function handlePushMem(pi, ctx, rest) {
691
700
  const scope = all ? "item(s)" : `${kind ?? "memory"} item(s)`;
692
701
  const modelNote = modelFilter !== undefined ? ` [model ${modelFilter || "(orphan)"}]` : "";
693
702
  const msg = buildPushMemPrompt(items, scope);
694
- pi.sendUserMessage(msg);
703
+ pi.sendUserMessage(msg, { deliverAs: "followUp" });
695
704
  ctx.ui.notify(`Steering agent to persist ${items.length} ${scope}${modelNote} to pi-mem.`, "info");
696
705
  }
706
+ /** `/harness lessons` — print the exact evidence-gated digest the next /refine
707
+ * steers with (harnessing-level recursion, Harness² arXiv 2610.14224).
708
+ * Read-only; distinct empty state when nothing supportable exists yet. */
709
+ function handleLessons(ctx) {
710
+ const digest = buildLessonsDigest(getState(), ctx.sessionManager.getBranch());
711
+ if (!digest) {
712
+ ctx.ui.notify("No lessons yet — items need citations (outcome loop) or /harness keep|drop feedback, or past refine runs, before anything is evidence-gated.", "info");
713
+ return;
714
+ }
715
+ ctx.ui.notify(digest, "info");
716
+ }
697
717
  async function handleStatus(ctx) {
698
718
  // Thin glue (issue #20): config + durable-layer stat() I/O here; every
699
719
  // derived band lives in the pure buildStatusReport (src/status.ts).
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export { default } from "./entry.js";
2
2
  export { STATE_ENTRY } from "./store.js";
3
- export { type AppliedDelta, type ComponentKind, type Delta, type HarnessItem, type HarnessState, } from "./types.js";
4
- export { DEFAULT_DEDUPE, DEDUPE_THRESHOLD, EVIDENCE_MERGE_CAP, listProposers, planDedupe, registerProposer, unionEvidence, type CompleteOptions, type CompleteResult, type DedupeOptions, type DeltaProposer, type ModelCallTelemetry, type ProposeInput, type ProposedDelta, type ProposeResult, type SimilarityResult, } from "./proposer.js";
3
+ export { type AppliedDelta, type ComponentKind, type Delta, type HarnessItem, type HarnessState, type SupportInfo, } from "./types.js";
4
+ export { DEFAULT_DEDUPE, DEDUPE_THRESHOLD, EVIDENCE_MERGE_CAP, buildSteeringPrompt, listProposers, planDedupe, registerProposer, unionEvidence, type CompleteOptions, type CompleteResult, type DedupeOptions, type DeltaProposer, type ModelCallTelemetry, type ProposeInput, type ProposedDelta, type ProposeResult, type SimilarityResult, } from "./proposer.js";
5
+ export { buildLessonsDigest, classifyRuns, isEffectiveRun, recentIneffectiveRuns, type DigestOptions, type RefineRun, } from "./lessons.js";
6
+ export { mergeSupport, renderSupport, parseSupport } from "./store.js";
7
+ export { DEFAULT_REFINE_NOOP_SKIP } from "./config.js";
5
8
  export { buildCorpus, classifyTransition, NOT_DUP_FLOOR, parseDedupeDelete, type CorpusResult, type DedupePairRecord, type LifecycleEvent, type LifecycleRecord, type LifecycleTransition, } from "./corpus.js";
6
9
  export { buildStatusReport, renderStatusReport, type ImpactCounts, type StatusOptions, type StatusReport, } from "./status.js";
7
10
  export { nextHint, pickHint, resetHints, type StatusHint, } from "./hints.js";
package/dist/index.js CHANGED
@@ -20,7 +20,13 @@ export {} from "./types.js";
20
20
  // Public extension API: other extensions can register their own delta proposer
21
21
  // (see src/proposer.ts) and it becomes selectable via /refine --proposer <name>
22
22
  // or the `proposer` config key.
23
- export { DEFAULT_DEDUPE, DEDUPE_THRESHOLD, EVIDENCE_MERGE_CAP, listProposers, planDedupe, registerProposer, unionEvidence, } from "./proposer.js";
23
+ export { DEFAULT_DEDUPE, DEDUPE_THRESHOLD, EVIDENCE_MERGE_CAP, buildSteeringPrompt, listProposers, planDedupe, registerProposer, unionEvidence, } from "./proposer.js";
24
+ // Harnessing-level recursion (Harness², arXiv 2610.14224): evidence-gated
25
+ // lessons from past refines + the auto-refine history gate. Re-exported for
26
+ // tests and companion packages.
27
+ export { buildLessonsDigest, classifyRuns, isEffectiveRun, recentIneffectiveRuns, } from "./lessons.js";
28
+ export { mergeSupport, renderSupport, parseSupport } from "./store.js";
29
+ export { DEFAULT_REFINE_NOOP_SKIP } from "./config.js";
24
30
  // Calibration corpus exporter (pi-reflex contract §4, formerly pi-jev). Re-exported so companion
25
31
  // packages can build corpora from exported session branches without reaching
26
32
  // into internal paths.
@@ -0,0 +1,65 @@
1
+ import type { HarnessState } from "./types.js";
2
+ /** What a harness-refinement entry records (subset we consume). */
3
+ export interface RefineRun {
4
+ /** Manual /refine vs. cadenced auto-refine. */
5
+ source?: string | undefined;
6
+ /** Which proposer ran. */
7
+ proposer?: string | undefined;
8
+ /** Deltas applied DIRECTLY by the proposer (0 for the steering path at
9
+ * entry-creation time — effectiveness is then inferred from a following
10
+ * harness-state entry, see classifyRuns). */
11
+ applied?: number | undefined;
12
+ /** ms epoch (entry data startedAt). */
13
+ at?: number | undefined;
14
+ /** True when a harness-state mutation landed AFTER this refine entry and
15
+ * before the next one — the steering agent actually mutated. */
16
+ followedByStateChange?: boolean | undefined;
17
+ }
18
+ /** Annotate runs with followedByStateChange by co-walking state entries.
19
+ *
20
+ * Attribution subtlety (review r4341960980): runRefine writes a direct-apply
21
+ * run's harness-state snapshot BEFORE that run's own audit entry, so a state
22
+ * entry sitting between run k and run k+1 may belong to EITHER. Disambiguate
23
+ * by ownership: if run k+1 direct-applied (applied > 0), exactly one of those
24
+ * state entries is its own snapshot and must NOT be credited to run k.
25
+ * Leftover entries credit run k (its steering mutation — or a human keep/drop
26
+ * in between, an acceptable false credit: it only ever LIFTS the gate, never
27
+ * makes it skip). */
28
+ export declare function classifyRuns(entries: Iterable<unknown>): RefineRun[];
29
+ /** A run is EFFECTIVE when the proposer applied deltas directly, or the
30
+ * steering agent mutated state right after. No-ops are everything else. */
31
+ export declare function isEffectiveRun(r: RefineRun): boolean;
32
+ /**
33
+ * History gate (auto-refine): true when the LAST `n` refine runs (fewer if
34
+ * the branch holds fewer) were ALL ineffective — a cadenced refine over the
35
+ * same quiet trajectory would most likely be another no-op. n <= 0 always
36
+ * returns false (gate off).
37
+ */
38
+ export declare function recentIneffectiveRuns(entries: Iterable<unknown>, n: number): boolean;
39
+ export interface DigestOptions {
40
+ /** Max supported items listed (default 5). */
41
+ maxSupported?: number;
42
+ /** Max struggling items listed (default 3). */
43
+ maxStruggling?: number;
44
+ /** Refine runs summarized (default 6). */
45
+ runsWindow?: number;
46
+ /** Hard character cap on the whole digest (default 1200). */
47
+ maxChars?: number;
48
+ /** Model-binding key (see vmodel.resolveBindingKey): only items OWNED by
49
+ * this model are named (review r4341960955) — the digest must respect the
50
+ * same per-model isolation as injection, otherwise a fresh model is fed
51
+ * foreign notes and told to delete ids it cannot mutate
52
+ * (assertOwnsItem rejects them). Undefined = no owner filter (callers that
53
+ * know the actor — runRefine — always pass it, `""` for an unknown model). */
54
+ ownerKey?: string | undefined;
55
+ }
56
+ /**
57
+ * Build the lessons digest injected into the refine steering prompt. Every
58
+ * line is evidence-gated (Harness²'s retention rule): supported items must
59
+ * have organic citations or keeps, struggling items must show explicit
60
+ * negative signal (drops) — never-cited-but-never-dropped items are NOT
61
+ * named (no signal is not a negative signal). Returns "" when there is
62
+ * nothing supportable to say, so the prompt stays unchanged for fresh stores.
63
+ * Pure.
64
+ */
65
+ export declare function buildLessonsDigest(state: HarnessState, entries: Iterable<unknown>, opts?: DigestOptions): string;
@@ -0,0 +1,182 @@
1
+ // lessons.ts — harnessing-level recursion (Harness², arXiv 2610.14224 §2.3).
2
+ //
3
+ // Harness² separates two loops: task-level recursion improves the harness for
4
+ // the CURRENT task, while harnessing-level recursion lets the IMPROVEMENT
5
+ // PROCESS ITSELF learn from what past refinements revealed. Its library rule:
6
+ // "lessons are retained only when supported by execution contrasts" — never
7
+ // from the mere fact that an edit was proposed.
8
+ //
9
+ // This module is our online-sized take on that rule, built entirely from
10
+ // signals we already persist:
11
+ // - item support counters (citations from the outcome loop, keeps/drops from
12
+ // /harness keep|drop) — the "execution contrast" proxy: adoption followed
13
+ // by observed use (or by the human explicitly rejecting it);
14
+ // - harness-refinement audit entries on the branch — which refine runs
15
+ // produced durable deltas vs. no-ops.
16
+ //
17
+ // Two consumers:
18
+ // - buildLessonsDigest() → injected into the /refine steering prompt (and any
19
+ // other proposer via ProposeInput.lessons), so the improver starts from
20
+ // what past improvement actually yielded.
21
+ // - recentIneffectiveRuns() → the history gate for auto-refine (skip a
22
+ // cadenced refine when the last N runs were all no-ops).
23
+ //
24
+ // Everything here is PURE over (state, branch entries): no I/O, no config,
25
+ // fully unit-testable, and silent when there is nothing supportable to say.
26
+ import { preview } from "./list.js";
27
+ import { REFINE_ENTRY, STATE_ENTRY } from "./store.js";
28
+ /** One-line, whitespace-flattened preview (shared style with list rows). */
29
+ function flat(text, max) {
30
+ return preview(text, max);
31
+ }
32
+ /** Annotate runs with followedByStateChange by co-walking state entries.
33
+ *
34
+ * Attribution subtlety (review r4341960980): runRefine writes a direct-apply
35
+ * run's harness-state snapshot BEFORE that run's own audit entry, so a state
36
+ * entry sitting between run k and run k+1 may belong to EITHER. Disambiguate
37
+ * by ownership: if run k+1 direct-applied (applied > 0), exactly one of those
38
+ * state entries is its own snapshot and must NOT be credited to run k.
39
+ * Leftover entries credit run k (its steering mutation — or a human keep/drop
40
+ * in between, an acceptable false credit: it only ever LIFTS the gate, never
41
+ * makes it skip). */
42
+ export function classifyRuns(entries) {
43
+ const runs = [];
44
+ let pending;
45
+ // harness-state entries seen since `pending`'s audit entry.
46
+ let stateAfter = 0;
47
+ const flush = (nextApplied) => {
48
+ if (pending) {
49
+ const ownedByNext = (nextApplied ?? 0) > 0 ? 1 : 0;
50
+ pending.followedByStateChange = stateAfter - ownedByNext > 0;
51
+ runs.push(pending);
52
+ }
53
+ pending = undefined;
54
+ stateAfter = 0;
55
+ };
56
+ for (const raw of entries) {
57
+ if (!raw || typeof raw !== "object")
58
+ continue;
59
+ const e = raw;
60
+ if (e.type === "custom" && e.customType === STATE_ENTRY) {
61
+ if (pending)
62
+ stateAfter += 1;
63
+ continue;
64
+ }
65
+ if (e.type === "custom" && e.customType === REFINE_ENTRY) {
66
+ const next = {
67
+ source: e.data?.source,
68
+ proposer: e.data?.proposer,
69
+ applied: e.data?.applied,
70
+ at: e.data?.startedAt,
71
+ };
72
+ // Flush the previous run knowing whether the new one direct-applied.
73
+ flush(next.applied);
74
+ pending = next;
75
+ }
76
+ }
77
+ flush(); // last run: any state entry after its audit is attributable to it
78
+ return runs;
79
+ }
80
+ /** A run is EFFECTIVE when the proposer applied deltas directly, or the
81
+ * steering agent mutated state right after. No-ops are everything else. */
82
+ export function isEffectiveRun(r) {
83
+ return (r.applied ?? 0) > 0 || r.followedByStateChange === true;
84
+ }
85
+ /**
86
+ * History gate (auto-refine): true when the LAST `n` refine runs (fewer if
87
+ * the branch holds fewer) were ALL ineffective — a cadenced refine over the
88
+ * same quiet trajectory would most likely be another no-op. n <= 0 always
89
+ * returns false (gate off).
90
+ */
91
+ export function recentIneffectiveRuns(entries, n) {
92
+ if (n <= 0)
93
+ return false;
94
+ const runs = classifyRuns(entries);
95
+ if (runs.length === 0)
96
+ return false;
97
+ const last = runs.slice(-n);
98
+ return last.length === n && last.every((r) => !isEffectiveRun(r));
99
+ }
100
+ // ---- the digest -----------------------------------------------------------
101
+ /** Positive support score: citations weigh double keeps (organic use is a
102
+ * stronger contrast than a human nudge), drops are negative. */
103
+ function supportScore(i) {
104
+ const s = i.support;
105
+ if (!s)
106
+ return 0;
107
+ return 2 * (s.citations ?? 0) + (s.keeps ?? 0) - 2 * (s.drops ?? 0);
108
+ }
109
+ /** Cap an assembled digest: drop trailing sections until it fits. */
110
+ function cap(sections, maxChars) {
111
+ const out = [];
112
+ let total = 0;
113
+ for (const s of sections) {
114
+ if (total + s.length > maxChars && out.length > 0)
115
+ break;
116
+ out.push(s);
117
+ total += s.length;
118
+ }
119
+ return out.join("\n");
120
+ }
121
+ /**
122
+ * Build the lessons digest injected into the refine steering prompt. Every
123
+ * line is evidence-gated (Harness²'s retention rule): supported items must
124
+ * have organic citations or keeps, struggling items must show explicit
125
+ * negative signal (drops) — never-cited-but-never-dropped items are NOT
126
+ * named (no signal is not a negative signal). Returns "" when there is
127
+ * nothing supportable to say, so the prompt stays unchanged for fresh stores.
128
+ * Pure.
129
+ */
130
+ export function buildLessonsDigest(state, entries, opts = {}) {
131
+ const sections = [];
132
+ const active = state.items.filter((i) => i.active && (opts.ownerKey === undefined || i.ownerModel === opts.ownerKey));
133
+ // 1. Supported — proven useful since adoption.
134
+ const supported = active
135
+ .filter((i) => supportScore(i) > 0)
136
+ .sort((a, b) => supportScore(b) - supportScore(a))
137
+ .slice(0, opts.maxSupported ?? 5);
138
+ if (supported.length > 0) {
139
+ sections.push([
140
+ `Proven useful (do not duplicate; strengthen only with new evidence):`,
141
+ ...supported.map((i) => {
142
+ const s = i.support;
143
+ const bits = [
144
+ s.citations ? `cited ${s.citations}×` : "",
145
+ s.keeps ? `kept ${s.keeps}×` : "",
146
+ ].filter(Boolean);
147
+ return `- [${i.id}] (${bits.join(", ")}): ${flat(i.content, 90)}`;
148
+ }),
149
+ ].join("\n"));
150
+ }
151
+ // 2. Struggling — explicit NEGATIVE net signal only (drops strictly
152
+ // outweigh any later use). An item dropped once and then cited again
153
+ // (score 0) or cited more than dropped (score > 0) is contested, not
154
+ // rejected — later execution evidence outranks the drop (review
155
+ // r4341960962). Net-neutral items are never named (no signal ≠ negative).
156
+ const struggling = active
157
+ .filter((i) => (i.support?.drops ?? 0) > 0 && supportScore(i) < 0)
158
+ .sort((a, b) => (b.support.drops ?? 0) - (a.support.drops ?? 0))
159
+ .slice(0, opts.maxStruggling ?? 3);
160
+ if (struggling.length > 0) {
161
+ sections.push([
162
+ `Rejected signals (candidates to delete or rewrite — past drops, no use since):`,
163
+ ...struggling.map((i) => `- [${i.id}] (dropped ${i.support.drops}×): ${flat(i.content, 90)}`),
164
+ ].join("\n"));
165
+ }
166
+ // 3. Refine-run history — how productive past improvement was.
167
+ const runs = classifyRuns(entries);
168
+ const window = opts.runsWindow ?? 6;
169
+ const recent = runs.slice(-window);
170
+ if (recent.length > 0) {
171
+ const effective = recent.filter(isEffectiveRun).length;
172
+ const noOps = recent.length - effective;
173
+ const line = noOps === 0
174
+ ? `Refine history: all ${recent.length} recent run(s) produced durable deltas — keep proposing surgical, evidence-backed edits.`
175
+ : `Refine history: ${noOps} of the last ${recent.length} run(s) proposed nothing durable — only record a correction when the trajectory clearly supports it; say so and do nothing otherwise.`;
176
+ sections.push(line);
177
+ }
178
+ if (sections.length === 0)
179
+ return "";
180
+ const header = "## Lessons from past refines (evidence-gated — apply when relevant)";
181
+ return cap([header, ...sections], opts.maxChars ?? 1200);
182
+ }
package/dist/list.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ComponentKind, HarnessItem } from "./types.js";
1
+ import type { ComponentKind, HarnessItem, SupportInfo } from "./types.js";
2
2
  /** `--scope` values for /harness list. `undefined` (no flag) = the session's
3
3
  * layer set: global + the active project. */
4
4
  export type ListScope = "global" | "project" | "all";
@@ -21,6 +21,9 @@ export declare function preview(text: string, max?: number): string;
21
21
  * desc with ties keeping store order — the same stable ranking policy as
22
22
  * injection selection and /harness adopt. Pure. */
23
23
  export declare function selectListItems(items: HarnessItem[], opts: ListOptions): HarnessItem[];
24
+ /** Compact support summary for one line: "cited 3, kept 1, dropped 0" —
25
+ * only non-zero signals, so clean items add nothing. */
26
+ export declare function supportSummary(s?: SupportInfo): string | undefined;
24
27
  /** Render the whole `/harness list` notify string: header (count + active
25
28
  * filters), rows (compact by default, full text + evidence with `full`),
26
29
  * and distinct empty-state guidance for an empty store vs an empty filter