@xingular/harness 2.1.2 → 3.0.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.
Files changed (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +67 -36
  4. package/dist/cli.js +6023 -6621
  5. package/dist/terminal-pi.js +19 -24
  6. package/docs/terminal-tui.md +27 -7
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/caller.ts +34 -0
  12. package/src/core/fs.ts +17 -1
  13. package/src/harness.ts +55 -66
  14. package/src/{memory → history}/recall.test.ts +1 -1
  15. package/src/{memory → history}/recall.ts +1 -1
  16. package/src/{memory → history}/trajectory.test.ts +4 -4
  17. package/src/{memory → history}/trajectory.ts +1 -1
  18. package/src/hub/hub.test.ts +4 -5
  19. package/src/hub/hub.ts +2 -2
  20. package/src/index.ts +28 -14
  21. package/src/mcp/connections.ts +1 -1
  22. package/src/mcp/service.ts +1 -1
  23. package/src/pi/proxy.test.ts +68 -0
  24. package/src/pi/runtime.test.ts +61 -0
  25. package/src/pi/runtime.ts +50 -0
  26. package/src/plugins/api.ts +11 -9
  27. package/src/plugins/builtin.ts +22 -40
  28. package/src/plugins/catalogue.test.ts +13 -41
  29. package/src/plugins/catalogue.ts +3 -32
  30. package/src/plugins/discover.ts +2 -1
  31. package/src/plugins/firstParty.test.ts +1 -1
  32. package/src/plugins/hooks.test.ts +21 -71
  33. package/src/plugins/hooks.ts +6 -7
  34. package/src/plugins/identity.ts +16 -2
  35. package/src/plugins/install.test.ts +1 -1
  36. package/src/plugins/install.ts +2 -1
  37. package/src/plugins/inventory.ts +19 -18
  38. package/src/plugins/plugins.ts +38 -2
  39. package/src/prompt/compile.test.ts +82 -193
  40. package/src/prompt/compile.ts +98 -363
  41. package/src/refine/cli.ts +114 -0
  42. package/src/refine/demo.ts +124 -0
  43. package/src/refine/evidence.ts +349 -0
  44. package/src/refine/host.ts +102 -0
  45. package/src/refine/ledger.ts +104 -0
  46. package/src/refine/library.ts +192 -0
  47. package/src/refine/pi.test.ts +90 -0
  48. package/src/refine/pi.ts +218 -0
  49. package/src/refine/prompts.ts +430 -0
  50. package/src/refine/recursion.ts +353 -0
  51. package/src/refine/refine.test.ts +140 -0
  52. package/src/refine/runner.ts +205 -0
  53. package/src/refine/selection.ts +291 -0
  54. package/src/refine/service.ts +194 -0
  55. package/src/refine/trajectory.ts +71 -0
  56. package/src/refine/types.ts +91 -0
  57. package/src/server/methods.ts +137 -123
  58. package/src/server/server.test.ts +37 -30
  59. package/src/sessions/contracts.ts +6 -1
  60. package/src/sessions/conversation.test.ts +2 -27
  61. package/src/sessions/policy.ts +1 -1
  62. package/src/sessions/pricing.ts +3 -1
  63. package/src/sessions/registry.ts +5 -1
  64. package/src/sessions/runStore.ts +3 -1
  65. package/src/sessions/runner.ts +10 -5
  66. package/src/sessions/sessions.test.ts +45 -6
  67. package/src/sessions/sessions.ts +40 -17
  68. package/src/sessions/subagentService.test.ts +22 -1
  69. package/src/sessions/subagentService.ts +14 -1
  70. package/src/sessions/subagents.ts +2 -0
  71. package/src/terminal/cli.ts +24 -48
  72. package/src/terminal/pluginControl.test.ts +7 -8
  73. package/src/terminal/socket.ts +3 -3
  74. package/src/terminal/terminal.test.ts +21 -39
  75. package/src/terminal/terminals.ts +20 -13
  76. package/src/terminal/tui/application.test.ts +58 -0
  77. package/src/terminal/tui/application.ts +10 -4
  78. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  79. package/src/terminal/tui/clipboardFiles.ts +86 -0
  80. package/src/terminal/tui/piAdapter.ts +7 -4
  81. package/src/terminal/tui/runtime.ts +2 -0
  82. package/src/tools/definitions.ts +49 -132
  83. package/src/tools/dispatch.ts +78 -0
  84. package/src/tools/harnessTools.ts +23 -0
  85. package/src/tools/spawnSubagent.ts +3 -3
  86. package/src/tools/types.ts +1 -1
  87. package/src/tree/components.ts +221 -0
  88. package/src/tree/materialise.ts +122 -0
  89. package/src/tree/seed.ts +123 -0
  90. package/src/tree/store.ts +193 -0
  91. package/src/tree/surface.ts +135 -0
  92. package/src/tree/tree.test.ts +126 -0
  93. package/src/tree/tree.ts +346 -0
  94. package/src/tree/trust.ts +86 -0
  95. package/src/wiki/notes.ts +313 -0
  96. package/src/memory/api.test.ts +0 -82
  97. package/src/memory/api.ts +0 -303
  98. package/src/memory/blocks.test.ts +0 -78
  99. package/src/memory/blocks.ts +0 -408
  100. package/src/memory/caller.ts +0 -69
  101. package/src/memory/curation.test.ts +0 -206
  102. package/src/memory/curation.ts +0 -64
  103. package/src/memory/dispatch.ts +0 -64
  104. package/src/memory/facts.test.ts +0 -41
  105. package/src/memory/facts.ts +0 -248
  106. package/src/memory/graph.ts +0 -103
  107. package/src/memory/health.test.ts +0 -130
  108. package/src/memory/health.ts +0 -74
  109. package/src/memory/maintainer.ts +0 -272
  110. package/src/memory/maintenance.test.ts +0 -192
  111. package/src/memory/maintenance.ts +0 -679
  112. package/src/memory/migrate.ts +0 -79
  113. package/src/memory/operations.ts +0 -150
  114. package/src/memory/proposals.test.ts +0 -168
  115. package/src/memory/proposals.ts +0 -584
  116. package/src/memory/references.ts +0 -77
  117. package/src/memory/runContext.ts +0 -93
  118. package/src/memory/store.test.ts +0 -66
  119. package/src/memory/store.ts +0 -385
  120. package/src/memory/wiki.test.ts +0 -47
  121. package/src/memory/wiki.ts +0 -423
  122. package/src/memory/wikiIndex.test.ts +0 -88
  123. package/src/memory/wikiIndex.ts +0 -117
  124. package/src/plugins/proposal.ts +0 -121
  125. package/src/skills/registry.test.ts +0 -104
  126. package/src/skills/registry.ts +0 -413
  127. package/src/tools/maintenance.ts +0 -29
  128. package/src/tools/memoryTools.ts +0 -22
  129. package/src/tools/pluginPropose.ts +0 -41
  130. package/src/tools/registryWrite.test.ts +0 -101
  131. package/src/tools/registryWrite.ts +0 -182
  132. /package/src/{memory → core}/secrets.ts +0 -0
@@ -0,0 +1,205 @@
1
+ // One refinement: k shared base executions on the current harness, one recursion mode, blind
2
+ // selection of a final, reflection, and a library entry. Everything is written under
3
+ // `<data>/refine/<id>/` as it happens; `summary.json` is the record a person or an application
4
+ // reads.
5
+ //
6
+ // Shaped after Harness² (Apache-2.0, Google LLC; `harness2/core/schedule.py::run_task`).
7
+ import { join } from 'node:path'
8
+
9
+ import { now, readJson, safeId, writeJson } from '../core/fs'
10
+ import * as store from '../tree/store'
11
+ import { PI_SURFACE, type Tree } from '../tree/surface'
12
+ import { isUnchanged, writeTree } from '../tree/tree'
13
+ import { Library, hasExperience, type Experience } from './library'
14
+ import type { LedgerRecord } from './ledger'
15
+ import { mapLimit, modeRecord, runMode, type ModeRecord, type RecursionEvent } from './recursion'
16
+ import { reflectAdoption, runSelection, selectionPool, trivialSelection, type Selection } from './selection'
17
+ import type { Executor, Improver, RecursionMode, RefineTask, Rollout } from './types'
18
+
19
+ export type RefineStatus = 'running' | 'ok' | 'partial' | 'failed' | 'cancelled'
20
+
21
+ export interface RefineSummary {
22
+ id: string
23
+ taskId: string
24
+ scope: string
25
+ area: string
26
+ k: number
27
+ mode: RecursionMode
28
+ status: RefineStatus
29
+ startedAt: string
30
+ endedAt: string | null
31
+ /** The head the refinement started from. */
32
+ baseFingerprint: string
33
+ base: { label: string; runId: string; ok: boolean; delivered: boolean; error: string }[]
34
+ recursion: ModeRecord | null
35
+ selection: Selection | null
36
+ /** The chosen harness, saved as a version of the scope without moving its head; null when it is the base. */
37
+ chosenFingerprint: string | null
38
+ chosenIsBase: boolean
39
+ libraryEntry: string | null
40
+ experience: Partial<Experience> | null
41
+ usd: number
42
+ errors: string[]
43
+ }
44
+
45
+ export type RefineEvent =
46
+ | RecursionEvent
47
+ | { type: 'phase'; phase: 'base' | 'recursion' | 'selection' | 'reflection' | 'done' }
48
+ | { type: 'summary'; summary: RefineSummary }
49
+
50
+ export interface RefineRunInput {
51
+ root: string
52
+ id: string
53
+ task: RefineTask
54
+ k: number
55
+ mode: RecursionMode
56
+ executor: Executor
57
+ improver: Improver
58
+ /** The selector; defaults to the improver. */
59
+ selector?: Improver
60
+ /** The harness to start from. Defaults to the scope's head. */
61
+ baseTree?: Tree
62
+ proposeWidth?: number
63
+ rolloutWidth?: number
64
+ onEvent?: (event: RefineEvent) => void
65
+ signal?: AbortSignal
66
+ }
67
+
68
+ export function refineDir(root: string, id: string): string {
69
+ return join(root, 'refine', safeId(id))
70
+ }
71
+
72
+ export function readSummary(root: string, id: string): RefineSummary | null {
73
+ return readJson<RefineSummary>(join(refineDir(root, id), 'summary.json'))
74
+ }
75
+
76
+ const modeToken = (mode: RecursionMode): 'agg' | 'seq' => (mode === 'parallel' ? 'agg' : 'seq')
77
+
78
+ /** Runs one refinement to the end. Failures are recorded on the summary; only bad input throws. */
79
+ export async function runRefinement(input: RefineRunInput): Promise<RefineSummary> {
80
+ const { root, id, task, k, mode, executor, improver } = input
81
+ if (!Number.isInteger(k) || k < 1 || k > 8) throw new Error('k must be a whole number from 1 to 8.')
82
+ safeId(task.scope)
83
+ const workDir = refineDir(root, id)
84
+ const area = task.domain ?? task.scope
85
+ const baseTree = input.baseTree ?? store.headTree(root, task.scope)?.tree
86
+ if (!baseTree) throw new Error(`The harness '${task.scope}' has no version to start from.`)
87
+ const baseFingerprint = store.fingerprint(baseTree)
88
+ const library = new Library(root, task.scope, area)
89
+ const runIdOf = (label: string) => `${id}-${label}`
90
+ const emit = (event: RefineEvent) => input.onEvent?.(event)
91
+
92
+ const summary: RefineSummary = {
93
+ id,
94
+ taskId: task.id,
95
+ scope: task.scope,
96
+ area,
97
+ k,
98
+ mode,
99
+ status: 'running',
100
+ startedAt: now(),
101
+ endedAt: null,
102
+ baseFingerprint,
103
+ base: [],
104
+ recursion: null,
105
+ selection: null,
106
+ chosenFingerprint: null,
107
+ chosenIsBase: false,
108
+ libraryEntry: null,
109
+ experience: null,
110
+ usd: 0,
111
+ errors: []
112
+ }
113
+ const save = () => {
114
+ writeJson(join(workDir, 'summary.json'), summary)
115
+ emit({ type: 'summary', summary: structuredClone(summary) })
116
+ }
117
+ const finish = (status: RefineStatus) => {
118
+ summary.status = input.signal?.aborted ? 'cancelled' : status
119
+ summary.endedAt = now()
120
+ save()
121
+ emit({ type: 'phase', phase: 'done' })
122
+ return summary
123
+ }
124
+ writeJson(join(workDir, 'task.json'), { task, k, mode })
125
+ save()
126
+
127
+ // The library a refinement reads is the one before it, so the record of what it read is stable.
128
+ const libraryDir = library.contextDir()
129
+
130
+ emit({ type: 'phase', phase: 'base' })
131
+ const baseDir = writeTree(PI_SURFACE, baseTree, join(workDir, 'harnesses', 'base'))
132
+ const labels = Array.from({ length: k }, (_, i) => `base${i + 1}`)
133
+ const base: Rollout[] = await mapLimit(labels, input.rolloutWidth ?? 4, async (label) => {
134
+ emit({ type: 'rollout.started', label })
135
+ let rollout: Rollout
136
+ try {
137
+ if (input.signal?.aborted) throw new Error('Refinement was cancelled.')
138
+ rollout = await executor.execute(task, baseDir, label, runIdOf(label))
139
+ } catch (error) {
140
+ rollout = { label, runId: runIdOf(label), dir: join(workDir, 'missing', label), ok: false, delivered: false, costUsd: 0, wallMs: 0, error: error instanceof Error ? error.message : String(error) }
141
+ }
142
+ emit({ type: 'rollout.finished', label, ok: rollout.ok, delivered: rollout.delivered, error: rollout.error })
143
+ return rollout
144
+ })
145
+ summary.base = base.map((r) => ({ label: r.label, runId: r.runId, ok: r.ok, delivered: r.delivered, error: r.error }))
146
+ if (!base.some((rollout) => rollout.ok)) {
147
+ summary.errors.push('no base rollout produced anything')
148
+ summary.usd = base.reduce((sum, r) => sum + r.costUsd, 0)
149
+ return finish('failed')
150
+ }
151
+ save()
152
+
153
+ emit({ type: 'phase', phase: 'recursion' })
154
+ let result
155
+ try {
156
+ result = await runMode(mode, { task, executor, improver, workDir, runIdOf, baseTree, libraryDir, proposeWidth: input.proposeWidth, rolloutWidth: input.rolloutWidth, onEvent: emit, signal: input.signal }, k, base)
157
+ } catch (error) {
158
+ summary.errors.push(`${mode}: ${error instanceof Error ? error.message : String(error)}`)
159
+ return finish(input.signal?.aborted ? 'cancelled' : 'failed')
160
+ }
161
+ summary.recursion = modeRecord(result)
162
+ summary.usd = result.ledger.usd()
163
+ save()
164
+
165
+ emit({ type: 'phase', phase: 'selection' })
166
+ const token = modeToken(mode)
167
+ const candidates = selectionPool(token, k, result.rollouts)
168
+ const sel = k === 1
169
+ ? trivialSelection({ task, k, mode: token, candidates, executor, workDir })
170
+ : await runSelection({ task, k, mode: token, candidates, executor, improver: input.selector ?? improver, workDir, signal: input.signal })
171
+ summary.selection = sel
172
+ if (!sel.ok) summary.errors.push(`${mode}: selection failed: ${sel.error}`)
173
+ const chosen = candidates[sel.chosenLabel]
174
+ if (chosen && !chosen.delivered) summary.errors.push(`${mode}: selected run ${chosen.runId} produced no deliverable`)
175
+
176
+ const chosenTree = result.trees[sel.chosenLabel] ?? baseTree
177
+ summary.chosenIsBase = isUnchanged(PI_SURFACE, baseTree, chosenTree)
178
+ if (sel.chosenLabel && !summary.chosenIsBase) {
179
+ try {
180
+ summary.chosenFingerprint = store.commit(root, task.scope, chosenTree, { kind: 'refinement', refineId: id, label: sel.chosenLabel, note: sel.why.slice(0, 500) }, { moveHead: false }).fingerprint
181
+ } catch (error) {
182
+ summary.errors.push(`could not save the chosen harness: ${error instanceof Error ? error.message : String(error)}`)
183
+ }
184
+ }
185
+ save()
186
+
187
+ if (sel.ok) {
188
+ emit({ type: 'phase', phase: 'reflection' })
189
+ const decisions = Object.fromEntries(result.stages.map((stage) => [stage.label, stage.notes || stage.error]))
190
+ const experience = await reflectAdoption({ task, selection: sel, rollouts: result.rollouts, executor, improver: input.selector ?? improver, workDir, baseTree, decisions, signal: input.signal })
191
+ summary.experience = Object.keys(experience).length ? experience : null
192
+ if (hasExperience(experience)) {
193
+ try {
194
+ summary.libraryEntry = library.add(task.id, chosenTree, { why: sel.why, label: sel.chosenLabel, runId: sel.chosenRunId, experience })
195
+ } catch (error) {
196
+ summary.errors.push(`could not add to the library: ${error instanceof Error ? error.message : String(error)}`)
197
+ }
198
+ }
199
+ }
200
+ summary.usd = Math.round((result.ledger.usd() + sel.usd) * 1e6) / 1e6
201
+ const ok = summary.recursion.ok && sel.ok && Boolean(sel.chosenLabel) && !summary.errors.length
202
+ return finish(ok ? 'ok' : sel.chosenLabel ? 'partial' : 'failed')
203
+ }
204
+
205
+ export type { LedgerRecord }
@@ -0,0 +1,291 @@
1
+ // Choosing one anonymised final output, then reflecting on the refinement that produced it.
2
+ // The selector sees only the request, the source and the outputs, shuffled under arbitrary ids;
3
+ // it never sees a harness, a trajectory, or any verdict.
4
+ //
5
+ // Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/selection.py`).
6
+ import { createHash } from 'node:crypto'
7
+ import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+
10
+ import { writeJson } from '../core/fs'
11
+ import { PI_SURFACE, type Tree } from '../tree/surface'
12
+ import { writeTree } from '../tree/tree'
13
+ import * as evidence from './evidence'
14
+ import type { Experience } from './library'
15
+ import * as P from './prompts'
16
+ import { defaultPrompts } from './recursion'
17
+ import { ImproverError, type Executor, type Improver, type RefineTask, type Rollout } from './types'
18
+
19
+ export interface Selection {
20
+ taskId: string
21
+ mode: 'agg' | 'seq'
22
+ k: number
23
+ chosenId: string
24
+ chosenLabel: string
25
+ chosenRunId: string
26
+ chosenDir: string
27
+ why: string
28
+ idMap: Record<string, string>
29
+ dropped: string[]
30
+ candidates: number
31
+ ok: boolean
32
+ fallback: boolean
33
+ trivial: boolean
34
+ error: string
35
+ usd: number
36
+ experience: Partial<Experience>
37
+ }
38
+
39
+ /** A seeded mulberry32, so a shuffle is stable across reruns of the same task. */
40
+ function seeded(seed: number): () => number {
41
+ let a = seed >>> 0
42
+ return () => {
43
+ a = (a + 0x6d2b79f5) >>> 0
44
+ let t = a
45
+ t = Math.imul(t ^ (t >>> 15), t | 1)
46
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
47
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
48
+ }
49
+ }
50
+
51
+ /** A..Z, AA..AZ, BA...: always accepted by the choice parser. */
52
+ export function anonId(index: number): string {
53
+ let out = ''
54
+ let n = index + 1
55
+ while (n) {
56
+ const rem = (n - 1) % 26
57
+ out = String.fromCharCode(65 + rem) + out
58
+ n = Math.floor((n - 1) / 26)
59
+ }
60
+ return out
61
+ }
62
+
63
+ /** anon id → label, by canonical sort then a seeded shuffle. */
64
+ export function assignIds(labels: readonly string[], taskId: string, k: number, mode: string): Record<string, string> {
65
+ const seed = parseInt(createHash('sha1').update(`${taskId}|${k}|${mode}`).digest('hex').slice(0, 8), 16)
66
+ const ordered = [...labels].sort()
67
+ const random = seeded(seed)
68
+ for (let i = ordered.length - 1; i > 0; i--) {
69
+ const j = Math.floor(random() * (i + 1))
70
+ ;[ordered[i], ordered[j]] = [ordered[j]!, ordered[i]!]
71
+ }
72
+ return Object.fromEntries(ordered.map((label, i) => [anonId(i), label]))
73
+ }
74
+
75
+ /** Exactly the k finals of a mode: agg1..aggk or c1..ck. The base executions are never in it. */
76
+ export function selectionPool(mode: 'agg' | 'seq', k: number, rollouts: Record<string, Rollout>): Record<string, Rollout> {
77
+ const labels = Array.from({ length: k }, (_, i) => (mode === 'agg' ? `agg${i + 1}` : `c${i + 1}`))
78
+ return Object.fromEntries(labels.filter((label) => rollouts[label]).map((label) => [label, rollouts[label]!]))
79
+ }
80
+
81
+ function newSelection(taskId: string, mode: 'agg' | 'seq', k: number): Selection {
82
+ return { taskId, mode, k, chosenId: '', chosenLabel: '', chosenRunId: '', chosenDir: '', why: '', idMap: {}, dropped: [], candidates: 0, ok: false, fallback: false, trivial: false, error: '', usd: 0, experience: {} }
83
+ }
84
+
85
+ /** Drops non-answers before anonymising, recording each drop and its cause. */
86
+ function renderPool(candidates: Record<string, Rollout>, executor: Executor, sel: Selection): { pool: Record<string, Rollout>; artifacts: Record<string, string> } {
87
+ const pool: Record<string, Rollout> = {}
88
+ const artifacts: Record<string, string> = {}
89
+ for (const [label, rollout] of Object.entries(candidates)) {
90
+ if (!(rollout.ok && rollout.delivered)) {
91
+ sel.dropped.push(label)
92
+ continue
93
+ }
94
+ let text = ''
95
+ try {
96
+ text = evidence.renderDeliverable(executor, rollout)
97
+ } catch (error) {
98
+ sel.dropped.push(`${label} (render failed: ${error instanceof Error ? error.message : String(error)})`.slice(0, 200))
99
+ continue
100
+ }
101
+ if (!text.trim()) {
102
+ sel.dropped.push(label)
103
+ continue
104
+ }
105
+ pool[label] = rollout
106
+ artifacts[label] = text
107
+ }
108
+ return { pool, artifacts }
109
+ }
110
+
111
+ function persist(sel: Selection, root: string): void {
112
+ try {
113
+ writeJson(join(root, 'selection.json'), sel)
114
+ } catch (error) {
115
+ sel.ok = false
116
+ sel.error = `${sel.error} | could not persist selection: ${error instanceof Error ? error.message : String(error)}`.slice(0, 400)
117
+ }
118
+ }
119
+
120
+ function choose(sel: Selection, label: string, pool: Record<string, Rollout>): void {
121
+ sel.chosenLabel = label
122
+ sel.chosenId = Object.entries(sel.idMap).find(([, l]) => l === label)?.[0] ?? ''
123
+ sel.chosenRunId = pool[label]?.runId ?? ''
124
+ sel.chosenDir = pool[label]?.dir ?? ''
125
+ }
126
+
127
+ export interface SelectInput {
128
+ task: RefineTask
129
+ k: number
130
+ mode: 'agg' | 'seq'
131
+ candidates: Record<string, Rollout>
132
+ executor: Executor
133
+ improver: Improver
134
+ workDir: string
135
+ signal?: AbortSignal
136
+ }
137
+
138
+ /** Picks one output. A selector failure falls back to the first final and never throws. */
139
+ export async function runSelection(input: SelectInput): Promise<Selection> {
140
+ const { task, k, mode, executor } = input
141
+ const sel = newSelection(task.id, mode, k)
142
+ const { pool, artifacts } = renderPool(input.candidates, executor, sel)
143
+ sel.candidates = Object.keys(pool).length
144
+ if (!sel.candidates) {
145
+ sel.error = 'no candidate produced an artifact'
146
+ return sel
147
+ }
148
+ sel.idMap = assignIds(Object.keys(pool), task.id, k, mode)
149
+ const root = join(input.workDir, 'selection', `${mode}${k}`)
150
+ rmSync(root, { recursive: true, force: true })
151
+ mkdirSync(join(root, 'candidates'), { recursive: true })
152
+ writeFileSync(join(root, 'task.md'), task.prompt)
153
+ evidence.copySource(task, root)
154
+ for (const [anon, label] of Object.entries(sel.idMap)) {
155
+ const text = artifacts[label]!
156
+ writeFileSync(join(root, 'candidates', `${anon}.md`), `<!-- candidate ${anon}: ${text.length.toLocaleString('en-US')} chars, ${(text.split('\n').length).toLocaleString('en-US')} lines. COMPLETE -- nothing trimmed. Navigate with grep/offsets; evaluating a candidate means opening the part that determines whether it works. -->\n${text}`)
157
+ }
158
+ const ids = Object.keys(sel.idMap).sort().join(', ')
159
+ const mission = [
160
+ P.select(task.prompts ?? defaultPrompts(), sel.candidates, { poolKind: 'finals' }),
161
+ `# YOUR EVIDENCE\n\nIt is on disk. Read it -- do not answer from this message alone.\n\ncontext/task.md the instructions\ncontext/source/ task source materials, when supplied\ncontext/candidates/<id>.md one file per candidate: ${ids}\n\nOpen every candidate before choosing. Choose exactly one of: ${ids}.`
162
+ ].join('\n\n')
163
+ evidence.assertNoGraderVerdicts(root)
164
+ try {
165
+ const reply = await input.improver.call({ mission, contextDir: root, label: `select.${mode}${k}`, signal: input.signal })
166
+ sel.usd += reply.costUsd
167
+ const choice = /```choice\s*\n?\s*([A-Za-z0-9_-]+)\s*\n?\s*```/.exec(reply.text)?.[1]?.trim()
168
+ if (!choice) throw new Error('no ```choice block in the reply')
169
+ if (!sel.idMap[choice]) throw new Error(`chose '${choice}', which is not one of ${ids}`)
170
+ sel.why = (/<why>([\s\S]*?)<\/why>/.exec(reply.text)?.[1]?.trim() ?? '').slice(0, 2000)
171
+ sel.ok = true
172
+ choose(sel, sel.idMap[choice]!, pool)
173
+ } catch (error) {
174
+ if (error instanceof ImproverError) sel.usd += error.costUsd
175
+ // The neutral stand-in: the first final in canonical order. It is delivered, but the
176
+ // selection did not succeed, so nothing is reflected on or added to the library.
177
+ choose(sel, Object.keys(pool).sort()[0]!, pool)
178
+ sel.fallback = true
179
+ sel.ok = false
180
+ sel.error = (error instanceof Error ? error.message : String(error)).slice(0, 400)
181
+ }
182
+ persist(sel, root)
183
+ return sel
184
+ }
185
+
186
+ /** Delivers the single k=1 final without a selector session. */
187
+ export function trivialSelection(input: Omit<SelectInput, 'improver' | 'signal'>): Selection {
188
+ const { task, k, mode } = input
189
+ if (Object.keys(input.candidates).length > 1) throw new Error(`trivialSelection expects at most one candidate, got ${Object.keys(input.candidates).sort().join(', ')}`)
190
+ const sel = newSelection(task.id, mode, k)
191
+ sel.trivial = true
192
+ const { pool } = renderPool(input.candidates, input.executor, sel)
193
+ sel.candidates = Object.keys(pool).length
194
+ if (!sel.candidates) {
195
+ sel.error = 'no candidate produced an artifact (the finals-only pool has no base fallback)'
196
+ return sel
197
+ }
198
+ const label = Object.keys(pool)[0]!
199
+ sel.idMap = { A: label }
200
+ choose(sel, label, pool)
201
+ sel.ok = true
202
+ sel.why = 'trivial pool: single final at k=1; delivered without a selector session'
203
+ const root = join(input.workDir, 'selection', `${mode}${k}`)
204
+ rmSync(root, { recursive: true, force: true })
205
+ mkdirSync(root, { recursive: true })
206
+ persist(sel, root)
207
+ return sel
208
+ }
209
+
210
+ export interface ReflectInput {
211
+ task: RefineTask
212
+ selection: Selection
213
+ rollouts: Record<string, Rollout>
214
+ executor: Executor
215
+ improver: Improver
216
+ workDir: string
217
+ baseTree: Tree
218
+ decisions?: Record<string, string>
219
+ signal?: AbortSignal
220
+ }
221
+
222
+ /** Learns from the edits, decisions, trajectories and outputs after adoption. Never throws. */
223
+ export async function reflectAdoption(input: ReflectInput): Promise<Partial<Experience>> {
224
+ const { task, selection: sel } = input
225
+ const chosen = input.rollouts[sel.chosenLabel]
226
+ if (!sel.ok || !chosen) return {}
227
+ const root = join(input.workDir, 'reflection', `${sel.mode}${sel.k}`)
228
+ const harnesses = join(input.workDir, 'harnesses')
229
+ let layout = ''
230
+ try {
231
+ const built = evidence.build(root, {
232
+ task,
233
+ executor: input.executor,
234
+ rollouts: Object.values(input.rollouts),
235
+ harnessDir: join(harnesses, sel.chosenLabel),
236
+ pool: Object.entries(input.rollouts).filter(([label]) => !label.startsWith('base')).map(([label, rollout]) => ({ label, harnessDir: join(harnesses, label), rollout })),
237
+ decisions: input.decisions
238
+ })
239
+ layout = evidence.layout(built)
240
+ writeTree(PI_SURFACE, input.baseTree, join(root, 'base_harness'), { stubs: false })
241
+ writeFileSync(join(root, 'adoption.json'), `${JSON.stringify({ chosen_label: sel.chosenLabel, candidate_ids: sel.idMap, reason: sel.why }, null, 2)}\n`)
242
+ evidence.assertNoLeakPaths(root, task.leakGlobs)
243
+ evidence.assertNoGraderVerdicts(root)
244
+ } catch {
245
+ return {}
246
+ }
247
+ const mission = [
248
+ P.experience(task.prompts ?? defaultPrompts()),
249
+ `# WHAT HAPPENED\n\nHarness ${sel.chosenLabel} was adopted. The selection reasoning was:\n\n${sel.why}\n\ncontext/adoption.json maps any anonymous IDs in that reasoning to rollout labels. Compare context/harness/ with context/base_harness/ and the candidates in context/pool/. Read the decisions and execution contrasts before writing.\n\n${layout}`
250
+ ].join('\n\n')
251
+ const expRoot = `${root}.experience`
252
+ rmSync(expRoot, { recursive: true, force: true })
253
+ cpSync(root, expRoot, { recursive: true })
254
+ let text: string
255
+ try {
256
+ const reply = await input.improver.call({ mission, contextDir: expRoot, label: `experience.${sel.mode}${sel.k}`, signal: input.signal })
257
+ sel.usd += reply.costUsd
258
+ text = reply.text
259
+ } catch (error) {
260
+ if (error instanceof ImproverError) sel.usd += error.costUsd
261
+ return {}
262
+ }
263
+ const out = parseExperience(text)
264
+ if (out.edits) writeFileSync(join(root, 'edits.md'), `${out.edits}\n`)
265
+ try {
266
+ evidence.assertTreeHasNoGraderVerdicts({ 'EXPERIENCE.md': JSON.stringify(out) })
267
+ } catch {
268
+ return {}
269
+ }
270
+ sel.experience = out
271
+ persist(sel, join(input.workDir, 'selection', `${sel.mode}${sel.k}`))
272
+ return out
273
+ }
274
+
275
+ /** Reads `<edits>` and `<experience>` blocks. A reply without a supported lesson gives only the edits. */
276
+ export function parseExperience(text: string): Partial<Experience> {
277
+ const edits = /<edits>([\s\S]*?)<\/edits>/.exec(text)?.[1]?.trim()
278
+ const block = /<experience>([\s\S]*?)<\/experience>/.exec(text)?.[1]
279
+ const out: Partial<Experience> = {}
280
+ if (block) {
281
+ for (const line of block.trim().split('\n')) {
282
+ const at = line.indexOf(':')
283
+ if (at < 0) continue
284
+ const key = line.slice(0, at).trim().toLowerCase()
285
+ if (key === 'cue' || key === 'lesson' || key === 'evidence' || key === 'confidence') out[key] = line.slice(at + 1).trim()
286
+ }
287
+ }
288
+ if (!out.cue || !out.lesson || !out.evidence) return edits ? { edits } : {}
289
+ if (edits) out.edits = edits
290
+ return out
291
+ }