@xingular/harness 2.1.1 → 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 (152) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +70 -37
  4. package/dist/cli.js +6572 -6700
  5. package/dist/terminal-pi.js +51 -38
  6. package/docs/terminal-tui.md +50 -12
  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/bus.test.ts +15 -1
  12. package/src/core/bus.ts +39 -0
  13. package/src/core/caller.ts +34 -0
  14. package/src/core/fs.ts +17 -1
  15. package/src/harness.ts +55 -66
  16. package/src/{memory → history}/recall.test.ts +1 -1
  17. package/src/{memory → history}/recall.ts +1 -1
  18. package/src/{memory → history}/trajectory.test.ts +4 -4
  19. package/src/{memory → history}/trajectory.ts +1 -1
  20. package/src/hub/hub.test.ts +4 -5
  21. package/src/hub/hub.ts +2 -2
  22. package/src/index.ts +28 -14
  23. package/src/mcp/connections.ts +1 -1
  24. package/src/mcp/service.ts +1 -1
  25. package/src/pi/proxy.test.ts +68 -0
  26. package/src/pi/runtime.test.ts +61 -0
  27. package/src/pi/runtime.ts +50 -0
  28. package/src/plugins/api.ts +11 -9
  29. package/src/plugins/builtin.ts +22 -40
  30. package/src/plugins/catalogue.test.ts +13 -41
  31. package/src/plugins/catalogue.ts +3 -32
  32. package/src/plugins/discover.ts +2 -1
  33. package/src/plugins/firstParty.test.ts +1 -1
  34. package/src/plugins/hooks.test.ts +21 -71
  35. package/src/plugins/hooks.ts +6 -7
  36. package/src/plugins/identity.ts +16 -2
  37. package/src/plugins/install.test.ts +1 -1
  38. package/src/plugins/install.ts +2 -1
  39. package/src/plugins/inventory.ts +19 -18
  40. package/src/plugins/plugins.ts +38 -2
  41. package/src/prompt/compile.test.ts +82 -193
  42. package/src/prompt/compile.ts +98 -363
  43. package/src/refine/cli.ts +114 -0
  44. package/src/refine/demo.ts +124 -0
  45. package/src/refine/evidence.ts +349 -0
  46. package/src/refine/host.ts +102 -0
  47. package/src/refine/ledger.ts +104 -0
  48. package/src/refine/library.ts +192 -0
  49. package/src/refine/pi.test.ts +90 -0
  50. package/src/refine/pi.ts +218 -0
  51. package/src/refine/prompts.ts +430 -0
  52. package/src/refine/recursion.ts +353 -0
  53. package/src/refine/refine.test.ts +140 -0
  54. package/src/refine/runner.ts +205 -0
  55. package/src/refine/selection.ts +291 -0
  56. package/src/refine/service.ts +194 -0
  57. package/src/refine/trajectory.ts +71 -0
  58. package/src/refine/types.ts +91 -0
  59. package/src/server/methods.ts +137 -123
  60. package/src/server/server.test.ts +37 -30
  61. package/src/sessions/contracts.ts +7 -2
  62. package/src/sessions/conversation.test.ts +2 -27
  63. package/src/sessions/planMode.test.ts +8 -1
  64. package/src/sessions/planMode.ts +17 -9
  65. package/src/sessions/policy.ts +1 -1
  66. package/src/sessions/pricing.ts +3 -1
  67. package/src/sessions/questionBatches.test.ts +15 -3
  68. package/src/sessions/registry.ts +13 -1
  69. package/src/sessions/runStore.ts +3 -1
  70. package/src/sessions/runner.ts +10 -5
  71. package/src/sessions/sessions.test.ts +60 -13
  72. package/src/sessions/sessions.ts +43 -18
  73. package/src/sessions/subagentService.test.ts +22 -1
  74. package/src/sessions/subagentService.ts +14 -1
  75. package/src/sessions/subagents.ts +2 -0
  76. package/src/terminal/cli.ts +24 -48
  77. package/src/terminal/logs.test.ts +26 -0
  78. package/src/terminal/logs.ts +67 -0
  79. package/src/terminal/piExtension.lifecycle.test.ts +1 -1
  80. package/src/terminal/piExtension.ts +3 -3
  81. package/src/terminal/pluginControl.test.ts +7 -8
  82. package/src/terminal/socket.ts +3 -3
  83. package/src/terminal/terminal.test.ts +41 -39
  84. package/src/terminal/terminals.ts +31 -13
  85. package/src/terminal/tui/application.test.ts +58 -0
  86. package/src/terminal/tui/application.ts +15 -4
  87. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  88. package/src/terminal/tui/clipboardFiles.ts +86 -0
  89. package/src/terminal/tui/commands.test.ts +13 -1
  90. package/src/terminal/tui/commands.ts +16 -0
  91. package/src/terminal/tui/logPanel.test.ts +61 -0
  92. package/src/terminal/tui/logPanel.ts +175 -0
  93. package/src/terminal/tui/piAdapter.ts +7 -4
  94. package/src/terminal/tui/questionPicker.ts +168 -0
  95. package/src/terminal/tui/questions.test.ts +86 -19
  96. package/src/terminal/tui/questions.ts +27 -22
  97. package/src/terminal/tui/runtime.ts +2 -0
  98. package/src/tools/askUser.test.ts +23 -2
  99. package/src/tools/askUser.ts +15 -5
  100. package/src/tools/definitions.ts +49 -132
  101. package/src/tools/dispatch.ts +78 -0
  102. package/src/tools/harnessTools.ts +23 -0
  103. package/src/tools/spawnSubagent.ts +3 -3
  104. package/src/tools/types.ts +3 -1
  105. package/src/tree/components.ts +221 -0
  106. package/src/tree/materialise.ts +122 -0
  107. package/src/tree/seed.ts +123 -0
  108. package/src/tree/store.ts +193 -0
  109. package/src/tree/surface.ts +135 -0
  110. package/src/tree/tree.test.ts +126 -0
  111. package/src/tree/tree.ts +346 -0
  112. package/src/tree/trust.ts +86 -0
  113. package/src/web/tools.ts +27 -5
  114. package/src/web/web.test.ts +26 -1
  115. package/src/wiki/notes.ts +313 -0
  116. package/src/memory/api.test.ts +0 -82
  117. package/src/memory/api.ts +0 -303
  118. package/src/memory/blocks.test.ts +0 -78
  119. package/src/memory/blocks.ts +0 -408
  120. package/src/memory/caller.ts +0 -69
  121. package/src/memory/curation.test.ts +0 -206
  122. package/src/memory/curation.ts +0 -64
  123. package/src/memory/dispatch.ts +0 -64
  124. package/src/memory/facts.test.ts +0 -41
  125. package/src/memory/facts.ts +0 -248
  126. package/src/memory/graph.ts +0 -103
  127. package/src/memory/health.test.ts +0 -130
  128. package/src/memory/health.ts +0 -74
  129. package/src/memory/maintainer.ts +0 -272
  130. package/src/memory/maintenance.test.ts +0 -192
  131. package/src/memory/maintenance.ts +0 -679
  132. package/src/memory/migrate.ts +0 -79
  133. package/src/memory/operations.ts +0 -150
  134. package/src/memory/proposals.test.ts +0 -168
  135. package/src/memory/proposals.ts +0 -584
  136. package/src/memory/references.ts +0 -77
  137. package/src/memory/runContext.ts +0 -93
  138. package/src/memory/store.test.ts +0 -66
  139. package/src/memory/store.ts +0 -385
  140. package/src/memory/wiki.test.ts +0 -47
  141. package/src/memory/wiki.ts +0 -423
  142. package/src/memory/wikiIndex.test.ts +0 -88
  143. package/src/memory/wikiIndex.ts +0 -117
  144. package/src/plugins/proposal.ts +0 -121
  145. package/src/skills/registry.test.ts +0 -104
  146. package/src/skills/registry.ts +0 -413
  147. package/src/tools/maintenance.ts +0 -29
  148. package/src/tools/memoryTools.ts +0 -22
  149. package/src/tools/pluginPropose.ts +0 -41
  150. package/src/tools/registryWrite.test.ts +0 -101
  151. package/src/tools/registryWrite.ts +0 -182
  152. /package/src/{memory → core}/secrets.ts +0 -0
@@ -0,0 +1,353 @@
1
+ // Task-level recursion, parallel and sequential. Each mode schedules 4k rollouts and 3k
2
+ // improvement stages, including the shared base executions. An invalid proposal gets one retry
3
+ // with the validator's message, then inherits the current harness.
4
+ //
5
+ // Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/recursion.py`).
6
+ import { existsSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ import { PI_SURFACE, type Tree } from '../tree/surface'
10
+ import { autotrigger, diversity, isUnchanged, readTree, TreeError, validate, writeTree } from '../tree/tree'
11
+ import * as evidence from './evidence'
12
+ import { Ledger, type LedgerRecord } from './ledger'
13
+ import * as P from './prompts'
14
+ import { ImproverError, type Executor, type Improver, type RefineTask, type Rollout } from './types'
15
+
16
+ export const STAGE_ATTEMPTS = 2
17
+
18
+ export type StageDim = 'INV' | 'DOM' | 'AGG' | 'COMPOSE'
19
+
20
+ export interface Stage {
21
+ label: string
22
+ dim: StageDim
23
+ tree: Tree
24
+ keptBase: boolean
25
+ fellBack: boolean
26
+ error: string
27
+ usd: number
28
+ attempts: number
29
+ cached: boolean
30
+ notes: string
31
+ }
32
+
33
+ export interface StageRecord extends Omit<Stage, 'tree'> {}
34
+
35
+ export interface ModeResult {
36
+ mode: 'agg' | 'seq'
37
+ taskId: string
38
+ k: number
39
+ stages: Stage[]
40
+ trees: Record<string, Tree>
41
+ rollouts: Record<string, Rollout>
42
+ finals: string[]
43
+ ledger: Ledger
44
+ similarity: ReturnType<typeof diversity> | null
45
+ errors: string[]
46
+ }
47
+
48
+ export interface ModeRecord {
49
+ mode: 'agg' | 'seq'
50
+ taskId: string
51
+ k: number
52
+ finals: string[]
53
+ ok: boolean
54
+ fellBack: string[]
55
+ keptBase: string[]
56
+ stages: StageRecord[]
57
+ similarity: ReturnType<typeof diversity> | null
58
+ ledger: LedgerRecord
59
+ errors: string[]
60
+ }
61
+
62
+ export function modeRecord(result: ModeResult): ModeRecord {
63
+ return {
64
+ mode: result.mode,
65
+ taskId: result.taskId,
66
+ k: result.k,
67
+ finals: result.finals,
68
+ ok: result.finals.length > 0 && result.errors.length === 0,
69
+ fellBack: result.stages.filter((stage) => stage.fellBack).map((stage) => stage.label),
70
+ keptBase: result.stages.filter((stage) => stage.keptBase).map((stage) => stage.label),
71
+ stages: result.stages.map(({ tree: _tree, ...rest }) => ({ ...rest, error: rest.error.slice(0, 400) })),
72
+ similarity: result.similarity,
73
+ ledger: result.ledger.toRecord(),
74
+ errors: result.errors
75
+ }
76
+ }
77
+
78
+ /** Progress a caller may show while a mode runs. */
79
+ export type RecursionEvent =
80
+ | { type: 'stage.started'; label: string; dim: StageDim }
81
+ | { type: 'stage.finished'; label: string; dim: StageDim; fellBack: boolean; keptBase: boolean; error: string }
82
+ | { type: 'rollout.started'; label: string }
83
+ | { type: 'rollout.finished'; label: string; ok: boolean; delivered: boolean; error: string }
84
+
85
+ export interface RecursionContext {
86
+ task: RefineTask
87
+ executor: Executor
88
+ improver: Improver
89
+ workDir: string
90
+ runIdOf: (label: string) => string
91
+ baseTree: Tree
92
+ libraryDir?: string | null
93
+ /** How many improver sessions run at once. */
94
+ proposeWidth?: number
95
+ /** How many rollouts run at once. */
96
+ rolloutWidth?: number
97
+ onEvent?: (event: RecursionEvent) => void
98
+ signal?: AbortSignal
99
+ }
100
+
101
+ /** Runs `fn` over `items` with at most `width` at a time, keeping input order in the result. */
102
+ export async function mapLimit<T, R>(items: readonly T[], width: number, fn: (item: T) => Promise<R>): Promise<R[]> {
103
+ const out = new Array<R>(items.length)
104
+ let next = 0
105
+ const workers = Array.from({ length: Math.max(1, Math.min(width, items.length)) }, async () => {
106
+ while (next < items.length) {
107
+ const i = next++
108
+ out[i] = await fn(items[i]!)
109
+ }
110
+ })
111
+ await Promise.all(workers)
112
+ return out
113
+ }
114
+
115
+ function checkAborted(signal?: AbortSignal): void {
116
+ if (signal?.aborted) throw new Error('Refinement was cancelled.')
117
+ }
118
+
119
+ /** Repairs, then validates, a proposed tree. Returns whether it equals the fallback and any error. */
120
+ export function acceptTree(task: RefineTask, executor: Executor, next: Tree, fallback: Tree): { keptBase: boolean; error: string } {
121
+ autotrigger(PI_SURFACE, next)
122
+ const keptBase = isUnchanged(PI_SURFACE, fallback, next)
123
+ try {
124
+ validate(PI_SURFACE, next, { allowSubagents: task.allowSubagents !== false })
125
+ executor.validateTree?.(task, next)
126
+ evidence.assertTreeHasNoGraderVerdicts(next)
127
+ } catch (error) {
128
+ return { keptBase, error: error instanceof TreeError ? error.problems.join(' | ') : error instanceof Error ? error.message : String(error) }
129
+ }
130
+ return { keptBase, error: '' }
131
+ }
132
+
133
+ interface StageInput {
134
+ label: string
135
+ dim: StageDim
136
+ job: string
137
+ fallback: Tree
138
+ rollouts: readonly Rollout[]
139
+ harnessDir: string
140
+ pool?: readonly evidence.PoolEntry[]
141
+ decisions?: Record<string, string>
142
+ }
143
+
144
+ /** Builds evidence, asks for an edit, and retries an invalid one once. Never throws. */
145
+ export async function runStage(ctx: RecursionContext, input: StageInput): Promise<Stage> {
146
+ const { label, dim, fallback } = input
147
+ ctx.onEvent?.({ type: 'stage.started', label, dim })
148
+ const finish = (stage: Stage): Stage => {
149
+ ctx.onEvent?.({ type: 'stage.finished', label, dim, fellBack: stage.fellBack, keptBase: stage.keptBase, error: stage.error })
150
+ return stage
151
+ }
152
+ let tree: evidence.EvidenceTree
153
+ let mission: string
154
+ try {
155
+ tree = evidence.build(join(ctx.workDir, 'context', label), {
156
+ task: ctx.task,
157
+ executor: ctx.executor,
158
+ rollouts: input.rollouts,
159
+ harnessDir: input.harnessDir,
160
+ libraryDir: ctx.libraryDir,
161
+ pool: input.pool,
162
+ decisions: input.decisions
163
+ })
164
+ mission = evidence.mission(tree, input.job)
165
+ } catch (error) {
166
+ return finish({ label, dim, tree: { ...fallback }, keptBase: false, fellBack: true, error: `could not build evidence: ${error instanceof Error ? error.message : String(error)}`.slice(0, 400), usd: 0, attempts: 0, cached: false, notes: '' })
167
+ }
168
+
169
+ let err = ''
170
+ let usd = 0
171
+ let attempts = 0
172
+ let allCached = true
173
+ for (let attempt = 1; attempt <= STAGE_ATTEMPTS; attempt++) {
174
+ if (ctx.signal?.aborted) {
175
+ err = 'cancelled'
176
+ break
177
+ }
178
+ let reply
179
+ try {
180
+ reply = await ctx.improver.call({
181
+ mission: mission + (err ? `\n\n# YOUR PREVIOUS ATTEMPT WAS REJECTED\n\n${err}\n\nFix it in \`context/harness/\` and reply again.` : ''),
182
+ contextDir: tree.root,
183
+ label: `${label}.a${attempt}`,
184
+ harvest: 'context/harness',
185
+ signal: ctx.signal
186
+ })
187
+ } catch (error) {
188
+ err = error instanceof Error ? error.message : String(error)
189
+ attempts += error instanceof ImproverError ? error.attempts || 1 : 1
190
+ usd += error instanceof ImproverError ? error.costUsd : 0
191
+ allCached = false
192
+ break
193
+ }
194
+ usd += reply.costUsd
195
+ attempts += reply.cached ? 0 : Math.max(1, reply.attempts)
196
+ allCached = allCached && reply.cached
197
+ if (!reply.workspace || !existsSync(reply.workspace)) {
198
+ err = 'the harness directory did not come back from the session; edit the files in `context/harness/` in place'
199
+ continue
200
+ }
201
+ const rejected: string[] = []
202
+ const next = readTree(PI_SURFACE, reply.workspace, rejected)
203
+ if (!Object.keys(next).length) {
204
+ err = '`context/harness/` came back empty -- edit the files, do not delete them all'
205
+ continue
206
+ }
207
+ if (rejected.length) {
208
+ err = `these files are off the editable surface and were not applied: ${rejected.sort().slice(0, 6).join(', ')}. Component names must be lowercase letters, digits, \`_\` or \`-\` (e.g. \`skills/doc_review/SKILL.md\`, \`scripts/check_totals.py\`).`
209
+ continue
210
+ }
211
+ const accepted = acceptTree(ctx.task, ctx.executor, next, fallback)
212
+ if (accepted.error) {
213
+ err = accepted.error
214
+ // The next attempt edits what came back, so it fixes rather than restarts.
215
+ writeTree(PI_SURFACE, next, join(tree.root, 'harness'))
216
+ continue
217
+ }
218
+ const notes = /<notes>([\s\S]*?)<\/notes>/.exec(reply.text)?.[1]?.trim() ?? reply.text.trim()
219
+ return finish({ label, dim, tree: next, keptBase: accepted.keptBase, fellBack: false, error: '', usd, attempts, cached: allCached, notes })
220
+ }
221
+ return finish({ label, dim, tree: { ...fallback }, keptBase: false, fellBack: true, error: err.slice(0, 400), usd, attempts, cached: allCached, notes: '' })
222
+ }
223
+
224
+ function recordSessions(ledger: Ledger, stages: readonly Stage[]): void {
225
+ for (const stage of stages) ledger.session(stage.label, stage.usd, { cached: stage.cached, attempts: stage.attempts })
226
+ }
227
+
228
+ /** Writes a harness and rolls it once. A dead rollout is recorded, never thrown. */
229
+ async function roll(ctx: RecursionContext, label: string, tree: Tree): Promise<Rollout> {
230
+ const harnessDir = writeTree(PI_SURFACE, tree, join(ctx.workDir, 'harnesses', label))
231
+ const runId = ctx.runIdOf(label)
232
+ ctx.onEvent?.({ type: 'rollout.started', label })
233
+ let rollout: Rollout
234
+ try {
235
+ checkAborted(ctx.signal)
236
+ rollout = await ctx.executor.execute(ctx.task, harnessDir, label, runId)
237
+ } catch (error) {
238
+ rollout = { label, runId, dir: join(ctx.workDir, 'missing', label), ok: false, delivered: false, costUsd: 0, wallMs: 0, error: error instanceof Error ? error.message : String(error) }
239
+ }
240
+ ctx.onEvent?.({ type: 'rollout.finished', label, ok: rollout.ok, delivered: rollout.delivered, error: rollout.error })
241
+ return rollout
242
+ }
243
+
244
+ /** Rolls independent harnesses concurrently, then records them in the caller's order. */
245
+ async function rollMany(ctx: RecursionContext, ledger: Ledger, items: readonly (readonly [string, Tree])[]): Promise<Record<string, Rollout>> {
246
+ const got = await mapLimit(items, ctx.rolloutWidth ?? 4, ([label, tree]) => roll(ctx, label, tree))
247
+ const out: Record<string, Rollout> = {}
248
+ items.forEach(([label], i) => {
249
+ ledger.rollout(label, got[i]!.costUsd)
250
+ out[label] = got[i]!
251
+ })
252
+ return out
253
+ }
254
+
255
+ function newResult(mode: 'agg' | 'seq', ctx: RecursionContext, k: number, base: readonly Rollout[]): ModeResult {
256
+ const ledger = new Ledger(mode, ctx.task.id, k)
257
+ for (const rollout of base) ledger.rollout(rollout.label, rollout.costUsd)
258
+ return { mode, taskId: ctx.task.id, k, stages: [], trees: {}, rollouts: Object.fromEntries(base.map((rollout) => [rollout.label, rollout])), finals: [], ledger, similarity: null, errors: [] }
259
+ }
260
+
261
+ const harnessPath = (ctx: RecursionContext, label: string): string => join(ctx.workDir, 'harnesses', label)
262
+
263
+ /** WIDTH: 2k independent candidates from shared evidence, then k independent compositions. */
264
+ export async function runParallel(ctx: RecursionContext, k: number, base: readonly Rollout[]): Promise<ModeResult> {
265
+ const result = newResult('agg', ctx, k, base)
266
+ const prompts = ctx.task.prompts ?? defaultPrompts()
267
+ const baseDir = writeTree(PI_SURFACE, ctx.baseTree, harnessPath(ctx, 'base'))
268
+
269
+ const specs: [string, 'INV' | 'DOM', number][] = []
270
+ for (let i = 1; i <= k; i++) for (const dim of ['INV', 'DOM'] as const) specs.push([`${dim.toLowerCase()}${i}`, dim, i])
271
+ const stages = await mapLimit(specs, ctx.proposeWidth ?? 4, ([label, dim, index]) =>
272
+ runStage(ctx, { label, dim, job: P.propose(prompts, dim, { k, index }), fallback: ctx.baseTree, rollouts: base, harnessDir: baseDir })
273
+ )
274
+ recordSessions(result.ledger, stages)
275
+ for (const stage of stages) {
276
+ result.stages.push(stage)
277
+ result.trees[stage.label] = stage.tree
278
+ }
279
+ Object.assign(result.rollouts, await rollMany(ctx, result.ledger, stages.map((stage) => [stage.label, stage.tree] as const)))
280
+
281
+ const pool = stages.map((stage) => ({ label: stage.label, harnessDir: harnessPath(ctx, stage.label), rollout: result.rollouts[stage.label] ?? null }))
282
+ const decisions = Object.fromEntries(stages.map((stage) => [stage.label, stage.notes || stage.error]))
283
+ const aggs = await mapLimit(Array.from({ length: k }, (_, i) => i + 1), ctx.proposeWidth ?? 4, (index) =>
284
+ runStage(ctx, { label: `agg${index}`, dim: 'AGG', job: P.aggregate(prompts, { k, index }), fallback: ctx.baseTree, rollouts: base, harnessDir: baseDir, pool, decisions })
285
+ )
286
+ recordSessions(result.ledger, aggs)
287
+ for (const stage of aggs) {
288
+ result.stages.push(stage)
289
+ result.trees[stage.label] = stage.tree
290
+ result.finals.push(stage.label)
291
+ }
292
+ Object.assign(result.rollouts, await rollMany(ctx, result.ledger, aggs.map((stage) => [stage.label, stage.tree] as const)))
293
+ result.similarity = diversity(Object.fromEntries(aggs.map((stage) => [stage.label, stage.tree])))
294
+ result.ledger.assertIdentity()
295
+ return result
296
+ }
297
+
298
+ /** DEPTH: k nested passes over the same shared base evidence. */
299
+ export async function runSequential(ctx: RecursionContext, k: number, base: readonly Rollout[]): Promise<ModeResult> {
300
+ const result = newResult('seq', ctx, k, base)
301
+ const prompts = ctx.task.prompts ?? defaultPrompts()
302
+ let current = { ...ctx.baseTree }
303
+ let history: evidence.PoolEntry[] = []
304
+ for (let t = 1; t <= k; t++) {
305
+ checkAborted(ctx.signal)
306
+ const currentLabel = t > 1 ? `c${t - 1}` : 'base'
307
+ const currentDir = writeTree(PI_SURFACE, current, harnessPath(ctx, currentLabel))
308
+ const decisions = () => Object.fromEntries(result.stages.map((stage) => [stage.label, stage.notes || stage.error]))
309
+ const specs: [string, 'INV' | 'DOM'][] = [[`inv${t}`, 'INV'], [`dom${t}`, 'DOM']]
310
+ const before = decisions()
311
+ const fallback = current
312
+ const stages = await mapLimit(specs, Math.min(2, ctx.proposeWidth ?? 2), ([label, dim]) =>
313
+ runStage(ctx, { label, dim, job: P.propose(prompts, dim, { k, index: t, roundT: k > 1 ? t : null }), fallback, rollouts: base, harnessDir: currentDir, pool: [...history], decisions: before })
314
+ )
315
+ recordSessions(result.ledger, stages)
316
+ for (const stage of stages) {
317
+ result.stages.push(stage)
318
+ result.trees[stage.label] = stage.tree
319
+ }
320
+ Object.assign(result.rollouts, await rollMany(ctx, result.ledger, stages.map((stage) => [stage.label, stage.tree] as const)))
321
+ const roundPool = [...history, ...stages.map((stage) => ({ label: stage.label, harnessDir: harnessPath(ctx, stage.label), rollout: result.rollouts[stage.label] ?? null }))]
322
+ const ck = `c${t}`
323
+ const job = k === 1 ? P.aggregate(prompts, { k: 1, index: 1 }) : P.composeRound(prompts, { t, k })
324
+ const comp = await runStage(ctx, { label: ck, dim: 'COMPOSE', job, fallback: current, rollouts: base, harnessDir: currentDir, pool: roundPool, decisions: decisions() })
325
+ recordSessions(result.ledger, [comp])
326
+ result.stages.push(comp)
327
+ result.trees[ck] = comp.tree
328
+ const rolled = await roll(ctx, ck, comp.tree)
329
+ result.ledger.rollout(ck, rolled.costUsd)
330
+ result.rollouts[ck] = rolled
331
+ history = [...roundPool, { label: ck, harnessDir: harnessPath(ctx, ck), rollout: rolled }]
332
+ current = comp.tree
333
+ }
334
+ result.finals = Array.from({ length: k }, (_, i) => `c${i + 1}`)
335
+ result.ledger.assertIdentity()
336
+ return result
337
+ }
338
+
339
+ export function defaultPrompts(): P.WorkPrompts {
340
+ return {
341
+ work: 'The work is the request in `context/task.md`, done for the person who asked for it, with the tools this agent has.',
342
+ domainWord: 'area'
343
+ }
344
+ }
345
+
346
+ export function runMode(mode: 'parallel' | 'sequential', ctx: RecursionContext, k: number, base: readonly Rollout[]): Promise<ModeResult> {
347
+ return mode === 'parallel' ? runParallel(ctx, k, base) : runSequential(ctx, k, base)
348
+ }
349
+
350
+ /** Reads a harness a stage wrote, for callers that hold only labels. */
351
+ export function stageTree(workDir: string, label: string): Tree {
352
+ return readTree(PI_SURFACE, harnessPath({ workDir } as RecursionContext, label))
353
+ }
@@ -0,0 +1,140 @@
1
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import * as store from '../tree/store'
7
+ import { DemoExecutor, DemoImprover, demoBaseTree, demoTasks } from './demo'
8
+ import * as evidence from './evidence'
9
+ import { Library } from './library'
10
+ import { Ledger, BudgetError } from './ledger'
11
+ import * as P from './prompts'
12
+ import { runRefinement } from './runner'
13
+ import { anonId, assignIds, parseExperience } from './selection'
14
+ import type { Improver, ImproverCall, RefineTask } from './types'
15
+
16
+ function setup() {
17
+ const root = mkdtempSync(join(tmpdir(), 'refine-'))
18
+ store.seed(root, 'demo', demoBaseTree())
19
+ return { root, tasks: demoTasks(root), executor: new DemoExecutor(root), improver: new DemoImprover() }
20
+ }
21
+
22
+ describe('refinement end to end', () => {
23
+ for (const mode of ['parallel', 'sequential'] as const) {
24
+ for (const k of [1, 2]) {
25
+ it(`${mode} k=${k} keeps the budget identity and delivers the signed total`, async () => {
26
+ const { root, tasks, executor, improver } = setup()
27
+ const expected: Record<string, string> = { 'ledger-1': 'Total: 85.15\n', 'ledger-2': 'Total: 20.25\n' }
28
+ for (const task of tasks) {
29
+ const summary = await runRefinement({ root, id: `${task.id}-${mode}-${k}`, task, k, mode, executor, improver })
30
+ expect(summary.status).toBe('ok')
31
+ expect(summary.recursion?.ledger).toMatchObject({ R: 4 * k, P: 3 * k })
32
+ const chosen = summary.selection!
33
+ expect(readFileSync(join(chosen.chosenDir, 'answer.txt'), 'utf8')).toBe(expected[task.id])
34
+ expect(summary.chosenIsBase).toBe(false)
35
+ expect(store.exists(root, 'demo', summary.chosenFingerprint!)).toBe(true)
36
+ // A refinement never moves the head; adopting is a separate step.
37
+ expect(store.head(root, 'demo')?.fingerprint).toBe(summary.baseFingerprint)
38
+ expect(summary.libraryEntry).toBeTruthy()
39
+ }
40
+ const library = new Library(root, 'demo', 'accounting')
41
+ expect(library.entries()).toEqual(['ledger-1', 'ledger-2'])
42
+ expect(readFileSync(library.index, 'utf8')).toContain('When a ledger includes credits and debits')
43
+ // No selector session at k=1.
44
+ expect(improver.calls.some((label) => label.startsWith('select.'))).toBe(k > 1)
45
+ })
46
+ }
47
+ }
48
+
49
+ it('gives the second task the first task\'s library, and never the answer key', async () => {
50
+ const { root, tasks, executor } = setup()
51
+ const seen: string[] = []
52
+ const improver = new DemoImprover()
53
+ const spy: Improver = {
54
+ call: (input: ImproverCall) => {
55
+ if (input.label.startsWith('inv1')) {
56
+ seen.push(existsSync(join(input.contextDir, 'library', 'INDEX.md')) ? 'library' : 'empty')
57
+ expect(existsSync(join(input.contextDir, 'source', 'reference_answer.txt'))).toBe(false)
58
+ }
59
+ return improver.call(input)
60
+ }
61
+ }
62
+ for (const task of tasks) await runRefinement({ root, id: task.id, task, k: 1, mode: 'parallel', executor, improver: spy })
63
+ expect(seen).toEqual(['empty', 'library'])
64
+ })
65
+
66
+ it('retries an invalid proposal once with the validator\'s message, then falls back', async () => {
67
+ const { root, tasks, executor } = setup()
68
+ const missions: string[] = []
69
+ const bad: Improver = {
70
+ async call(input) {
71
+ missions.push(input.mission)
72
+ if (input.label.startsWith('experience.')) return { label: input.label, text: 'NO_ENTRY', costUsd: 0, attempts: 1, cached: false, workspace: null }
73
+ const dir = mkdtempSync(join(tmpdir(), 'bad-'))
74
+ writeFileSync(join(dir, 'systemprompt.md'), 'x')
75
+ writeFileSync(join(dir, 'guardrails.md'), '')
76
+ // Drops the script the executor requires.
77
+ return { label: input.label, text: '<notes>x</notes>', costUsd: 0.01, attempts: 1, cached: false, workspace: dir }
78
+ }
79
+ }
80
+ const summary = await runRefinement({ root, id: 'bad', task: tasks[0]!, k: 1, mode: 'parallel', executor, improver: bad })
81
+ expect(summary.recursion?.fellBack.sort()).toEqual(['agg1', 'dom1', 'inv1'])
82
+ expect(missions.some((mission) => mission.includes('YOUR PREVIOUS ATTEMPT WAS REJECTED') && mission.includes('requires scripts/summarize.mjs'))).toBe(true)
83
+ expect(summary.recursion?.ledger).toMatchObject({ R: 4, P: 3 })
84
+ expect(summary.chosenIsBase).toBe(true)
85
+ expect(summary.libraryEntry).toBeNull()
86
+ })
87
+
88
+ it('records a failed refinement when no base rollout works', async () => {
89
+ const { root, tasks, improver } = setup()
90
+ const broken = new DemoExecutor(root)
91
+ broken.execute = async (_task, _dir, label, runId) => ({ label, runId, dir: root, ok: false, delivered: false, costUsd: 0, wallMs: 0, error: 'down' })
92
+ const summary = await runRefinement({ root, id: 'down', task: tasks[0]!, k: 1, mode: 'parallel', executor: broken, improver })
93
+ expect(summary.status).toBe('failed')
94
+ expect(summary.errors).toContain('no base rollout produced anything')
95
+ })
96
+ })
97
+
98
+ describe('guards and parsing', () => {
99
+ it('refuses grader output and answer keys in evidence', () => {
100
+ const dir = mkdtempSync(join(tmpdir(), 'guard-'))
101
+ writeFileSync(join(dir, 'notes.json'), '{"pass_rate": 1}')
102
+ expect(() => evidence.assertNoGraderVerdicts(dir)).toThrow(/grader verdict/)
103
+ expect(() => evidence.assertTreeHasNoGraderVerdicts({ 'memory.md': 'review_verdict: approved' })).toThrow()
104
+ expect(() => evidence.assertNoLeakPaths(dir, ['notes.json'])).toThrow(/answer-key/)
105
+ })
106
+
107
+ it('refuses work prompts that describe measurement', () => {
108
+ expect(() => P.checkWorkPrompts({ work: 'maximise the rubric score' })).toThrow(/must describe the WORK/)
109
+ })
110
+
111
+ it('shuffles candidate ids deterministically', () => {
112
+ expect(anonId(0)).toBe('A')
113
+ expect(anonId(26)).toBe('AA')
114
+ const a = assignIds(['agg1', 'agg2', 'agg3'], 't', 3, 'agg')
115
+ expect(assignIds(['agg3', 'agg1', 'agg2'], 't', 3, 'agg')).toEqual(a)
116
+ expect(Object.values(a).sort()).toEqual(['agg1', 'agg2', 'agg3'])
117
+ })
118
+
119
+ it('reads an experience block, and keeps only edits when there is no lesson', () => {
120
+ expect(parseExperience('<edits>e</edits>\n<experience>\ncue: c\nlesson: l\nevidence: v\nconfidence: low\n</experience>')).toEqual({ cue: 'c', lesson: 'l', evidence: 'v', confidence: 'low', edits: 'e' })
121
+ expect(parseExperience('<edits>e</edits>\nNO_ENTRY')).toEqual({ edits: 'e' })
122
+ })
123
+
124
+ it('catches a broken budget identity', () => {
125
+ const ledger = new Ledger('agg', 't', 1)
126
+ ledger.rollout('base1')
127
+ ledger.rollout('base1')
128
+ expect(() => ledger.assertIdentity()).toThrow(BudgetError)
129
+ })
130
+
131
+ it('builds missions that point at the evidence on disk', () => {
132
+ const root = mkdtempSync(join(tmpdir(), 'mission-'))
133
+ const task: RefineTask = { id: 't', scope: 's', prompt: 'Do it.' }
134
+ const tree = evidence.build(join(root, 'ctx'), { task, executor: new DemoExecutor(root) })
135
+ const text = evidence.mission(tree, P.propose({ work: 'drafting' }, 'INV'))
136
+ expect(text).toContain('# YOUR EVIDENCE')
137
+ expect(text).toContain('context/task.md')
138
+ expect(text).toContain('`plugins/<name>.ts`')
139
+ })
140
+ })