@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
@@ -1,42 +1,34 @@
1
1
  // Everything a host process can ask of the harness, by name. Each method takes one JSON object
2
2
  // and returns JSON; the in-process API these call is the same one a Node application uses
3
3
  // directly. Methods are grouped by the part of the harness they belong to.
4
- import { readFileSync, statSync } from 'node:fs'
5
- import { basename } from 'node:path'
6
-
7
4
  import { ensureSharedDir, nativeConfig } from '../accounts/env'
8
5
  import { audit, readAudit, summaryAuthentication } from '../accounts/store'
9
6
  import { HarnessError, invalid } from '../core/errors'
10
- import { projectsOrNone } from '../core/projects'
11
7
  import * as capabilities from '../capabilities/api'
12
8
  import { loadRun as loadCapabilitySnapshot } from '../capabilities/runSnapshot'
13
9
  import { capabilitySet } from '../capabilities/runSnapshot'
14
10
  import type { Harness } from '../harness'
15
- import * as blocks from '../memory/blocks'
16
11
  import { validateScopeIds } from '../mcp/connections'
17
- import { callerBlocks, type CallerContext } from '../memory/caller'
18
- import { dispatch } from '../memory/dispatch'
19
- import * as facts from '../memory/facts'
20
- import * as graph from '../memory/graph'
21
- import * as health from '../memory/health'
22
- import { listInbox, dismissInboxItem } from '../memory/operations'
23
- import { writeIndex } from '../memory/proposals'
24
- import * as recall from '../memory/recall'
25
- import * as references from '../memory/references'
26
- import { readRunContext } from '../memory/runContext'
27
- import * as store from '../memory/store'
28
- import * as wiki from '../memory/wiki'
29
- import { reindexQuietly, reindexWiki } from '../memory/wikiIndex'
12
+ import type { CallerContext } from '../core/caller'
13
+ import * as recall from '../history/recall'
30
14
  import * as plugins from '../plugins/api'
31
15
  import * as install from '../plugins/install'
32
- import { comparison, compile, CONTEXT_PROFILES, load, profiles, render, setProfile, type ContextProfile, type Launch } from '../prompt/compile'
16
+ import { compile, load, render, type Launch } from '../prompt/compile'
17
+ import { readSummary } from '../refine/runner'
33
18
  import { selectHistory } from '../sessions/history'
34
19
  import { runsSince } from '../sessions/runIndex'
35
20
  import { findRun, listRuns, loadRun, pendingDecisions, readEvents, runFamily, snapshot } from '../sessions/runStore'
36
21
  import type { SubagentInput, SubagentSelection } from '../sessions/subagents'
37
22
  import type { SessionRunInput } from '../sessions/sessions'
38
- import * as registry from '../skills/registry'
39
23
  import type { TerminalPrepareInput } from '../terminal/terminals'
24
+ import { dispatch } from '../tools/dispatch'
25
+ import * as components from '../tree/components'
26
+ import { materialise, type HarnessChoice } from '../tree/materialise'
27
+ import { seedTree } from '../tree/seed'
28
+ import * as trees from '../tree/store'
29
+ import { PI_SURFACE, type Tree } from '../tree/surface'
30
+ import { applyEdit, autotrigger, TreeError, validate } from '../tree/tree'
31
+ import * as notes from '../wiki/notes'
40
32
  import type { Handler } from './rpc'
41
33
 
42
34
  type Params = Record<string, any>
@@ -59,27 +51,35 @@ const questionAnswer = (value: unknown): { selected: string[]; text?: string; no
59
51
  }
60
52
  const pluginScope = (params: Params): plugins.PluginScope => ({ cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') })
61
53
 
62
- /** Sources larger than this are not imported. */
63
- const MAX_SOURCE_BYTES = 50 * 1024 * 1024
54
+ const treeOf = (value: unknown): Tree => {
55
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid('tree must be an object of path → text.')
56
+ const out: Tree = {}
57
+ for (const [path, body] of Object.entries(value as Record<string, unknown>)) {
58
+ if (typeof body !== 'string') throw invalid(`${path} must be text.`)
59
+ out[path] = body
60
+ }
61
+ return out
62
+ }
63
+
64
+ /** Validates a tree without throwing: its census, or every problem. */
65
+ function check(tree: Tree): { ok: true; census: ReturnType<typeof validate> } | { ok: false; problems: string[] } {
66
+ try {
67
+ return { ok: true, census: validate(PI_SURFACE, tree) }
68
+ } catch (error) {
69
+ return { ok: false, problems: error instanceof TreeError ? error.problems : [error instanceof Error ? error.message : String(error)] }
70
+ }
71
+ }
64
72
 
65
73
  export function methods(harness: Harness): Record<string, Handler> {
66
- const { accounts, approvals, apps, runs, sessions, maintenance, terminals, env } = harness
74
+ const { accounts, approvals, apps, runs, sessions, terminals, env } = harness
67
75
  const root = (): string => env.root
68
- const wikiDir = (): string => store.wikiDir(env.root)
69
- const changed = (): void => {
70
- reindexQuietly(env.root)
71
- harness.bus.emit({ type: 'wiki.changed' })
72
- }
76
+ const harnessesChanged = (scope: string): void => harness.bus.emit({ type: 'harnesses.changed', scope })
73
77
 
74
78
  return {
75
79
  // ---- The harness itself --------------------------------------------------------------------
76
80
  'harness.status': () => ({ dataRoot: harness.dataRoot, productName: env.productName, recovery: harness.recovery, activeRuns: runs.activeRunIds() }),
77
81
  'harness.open': (params: Params) => harness.open(text(params, 'dataRoot'), Array.isArray(params.agentIds) ? params.agentIds : []),
78
82
  'harness.rebuildIndex': () => harness.rebuildIndex(),
79
- 'maintenance.list': (params: Params) => maintenance.jobs().filter((job) => params.runId ? job.runId === params.runId || (Array.isArray(params.ids) && params.ids.includes(job.id)) : params.includeCompleted === true || !['completed', 'rejected'].includes(job.status)).map(({ prepared: _prepared, hashes: _hashes, ...job }) => job),
80
- 'maintenance.review': (params: Params) => { maintenance.enqueue({ kind: 'review', requestId: optional(params, 'requestId') ?? String(Date.now()), manual: params.manual === true }); return { queued: true } },
81
- 'maintenance.failRun': (params: Params) => { maintenance.finishRun(text(params, 'runId'), true); return null },
82
- 'maintenance.retry': (params: Params) => maintenance.retry(text(params, 'id')),
83
83
  'hub.list': (params: Params) => harness.hub.list(params.refresh === true),
84
84
  'hub.get': (params: Params) => harness.hub.get(text(params, 'id'), text(params, 'version'), params.preview === true),
85
85
  'hub.install': (params: Params) => harness.hub.install(text(params, 'id'), text(params, 'version')),
@@ -140,91 +140,124 @@ export function methods(harness: Harness): Record<string, Handler> {
140
140
  'connections.appClients': () => apps.appClients(),
141
141
  'connections.validateScope': (params: Params) => validateScopeIds(apps.connections.list(), { agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') }, Array.isArray(params.ids) ? params.ids : []),
142
142
 
143
- // ---- Wiki: what a person browses and edits ---------------------------------------------------
144
- 'wiki.pages': (params: Params) => wiki.listPages(root(), optional(params, 'namespace')),
145
- 'wiki.page': (params: Params) => wiki.loadPage(root(), text(params, 'pageId')),
146
- 'wiki.find': (params: Params) => wiki.searchEntries(root(), typeof params.query === 'string' ? params.query : '', params.options ?? {}),
147
- 'wiki.entry': (params: Params) => wiki.loadEntry(root(), text(params, 'entryId')),
148
- 'wiki.backlinks': (params: Params) => wiki.backlinks(root(), text(params, 'entryId')),
149
- 'wiki.namespaces': () => wiki.namespaces(root()),
150
- 'wiki.revisions': (params: Params) => wiki.listRevisions(root(), text(params, 'entryId')),
151
- 'wiki.revision': (params: Params) => wiki.loadRevision(root(), text(params, 'entryId'), text(params, 'revisionId')),
152
- 'wiki.restore': (params: Params) => {
153
- wiki.restore(root(), text(params, 'entryId'), text(params, 'revisionId'))
154
- changed()
143
+ // ---- Harness trees: the eight components that shape an agent ---------------------------------
144
+ 'harnesses.surface': () => PI_SURFACE,
145
+ 'harnesses.list': () => trees.scopes(root()).map((scope) => {
146
+ const head = trees.head(root(), scope)
147
+ return { scope, head, version: head ? trees.versionRecord(root(), scope, head.fingerprint) : null }
148
+ }),
149
+ 'harnesses.get': (params: Params) => {
150
+ const scope = text(params, 'scope')
151
+ const head = trees.head(root(), scope)
152
+ const fp = optional(params, 'version') ?? head?.fingerprint ?? null
153
+ if (!fp) return { scope, head: null, version: null, tree: null, plugins: [] }
154
+ const tree = trees.readVersion(root(), scope, fp)
155
+ return { scope, head, version: trees.versionRecord(root(), scope, fp), tree, plugins: harness.trust.review(tree), probeCode: harness.trust.probeCode(scope) }
155
156
  },
156
- // A project was added: it gets its namespace, its fixed pages, and its brief.
157
- 'wiki.ensureProject': (params: Params) => {
158
- store.ensureProjectNamespace(root(), text(params, 'slug'), text(params, 'name'))
159
- blocks.ensureProject(wikiDir(), text(params, 'slug'), text(params, 'name'))
160
- writeIndex(wikiDir())
161
- changed()
157
+ 'harnesses.lineage': (params: Params) => trees.lineage(root(), text(params, 'scope')),
158
+ 'harnesses.compare': (params: Params) => trees.compare(root(), text(params, 'scope'), text(params, 'from'), text(params, 'to')),
159
+ 'harnesses.validate': (params: Params) => {
160
+ const tree = treeOf(params.tree)
161
+ const repaired = params.repair === true ? autotrigger(PI_SURFACE, tree) : []
162
+ return { ...check(tree), repaired, tree }
162
163
  },
163
- // Files changed outside the harness (an editor, a sync client): bring the index up to date.
164
- 'wiki.reindex': () => {
165
- const pages = reindexWiki(root())
166
- harness.bus.emit({ type: 'wiki.changed' })
167
- return pages
164
+ // A whole tree, or an edit of the head that names only the files it changes (null removes one).
165
+ 'harnesses.save': (params: Params) => {
166
+ const scope = text(params, 'scope')
167
+ const base = trees.headTree(root(), scope)?.tree ?? {}
168
+ const tree = params.tree !== undefined ? treeOf(params.tree) : applyEdit(base, (params.edit ?? {}) as Record<string, string | null>)
169
+ if (params.repair === true) autotrigger(PI_SURFACE, tree)
170
+ const record = trees.commit(root(), scope, tree, { kind: params.source === 'import' ? 'import' : 'edit', note: optional(params, 'note') ?? undefined, components: Array.isArray(params.components) ? params.components : undefined })
171
+ harnessesChanged(scope)
172
+ return record
173
+ },
174
+ 'harnesses.restore': (params: Params) => {
175
+ const scope = text(params, 'scope')
176
+ const head = trees.restore(root(), scope, text(params, 'version'), optional(params, 'note') ?? undefined)
177
+ harnessesChanged(scope)
178
+ return head
168
179
  },
169
- 'wiki.sources': () => store.listSources(wikiDir()),
170
- 'wiki.ingest': (params: Params) => {
180
+ // A first version: the tree given, or one built from what shaped the caller before harness trees.
181
+ 'harnesses.seed': (params: Params) => {
182
+ const scope = text(params, 'scope')
183
+ const tree = params.tree !== undefined ? treeOf(params.tree) : seedTree(root(), { agentId: optional(params, 'agentId') ?? scope, projectId: optional(params, 'projectId'), instructions: typeof params.instructions === 'string' ? params.instructions : '' })
184
+ const head = trees.seed(root(), scope, tree, optional(params, 'note') ?? undefined)
185
+ harnessesChanged(scope)
186
+ return head
187
+ },
188
+ 'harnesses.legacy': (params: Params) => seedTree(root(), { agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId'), instructions: typeof params.instructions === 'string' ? params.instructions : '' }),
189
+ 'harnesses.trusted': () => harness.trust.list(),
190
+ 'harnesses.trust': (params: Params) => {
191
+ const scope = text(params, 'scope')
192
+ const tree = trees.headTree(root(), scope)?.tree ?? {}
171
193
  const path = text(params, 'path')
172
- if (statSync(path).size > MAX_SOURCE_BYTES) throw invalid('Sources larger than 50 MB are not imported.')
173
- const source = maintenance.ingest(optional(params, 'name') ?? basename(path), readFileSync(path), optional(params, 'namespace') ?? store.LIBRARY_NAMESPACE)
174
- harness.bus.emit({ type: 'wiki.changed' })
175
- return source
194
+ const body = typeof params.text === 'string' ? params.text : tree[path]
195
+ if (typeof body !== 'string') throw invalid('That plugin file is not in the harness.')
196
+ const entry = harness.trust.grant(body, path, scope)
197
+ harnessesChanged(scope)
198
+ return entry
176
199
  },
177
- 'wiki.log': () => wiki.recentLog(root()),
178
- 'wiki.graph': () => graph.build(env),
179
- 'wiki.health': () => health.check(root()),
180
- 'wiki.inbox': () => listInbox(wikiDir()),
181
- 'wiki.inboxDismiss': (params: Params) => {
182
- dismissInboxItem(wikiDir(), text(params, 'itemId'))
183
- harness.bus.emit({ type: 'wiki.changed' })
200
+ 'harnesses.untrust': (params: Params) => {
201
+ harness.trust.revoke(text(params, 'hash'))
202
+ harness.bus.emit({ type: 'harnesses.changed' })
184
203
  },
185
- 'wiki.inboxAccept': async (params: Params) => {
186
- // An accepted plugin must arrive untrusted: packages that loaded before are taken over first, never together with it.
187
- await plugins.ensureAdopted(harness)
188
- const message = maintenance.acceptInboxItem(text(params, 'itemId'))
189
- harness.bus.emit({ type: 'plugins.changed' })
190
- return message
204
+ 'harnesses.setProbeCode': (params: Params) => {
205
+ const scope = text(params, 'scope')
206
+ harness.trust.setProbeCode(scope, params.on === true)
207
+ harnessesChanged(scope)
208
+ },
209
+ // The component library (skills, rules and prompts kept as files), and adding one to a tree.
210
+ 'harnesses.components': () => components.list(root()),
211
+ 'harnesses.importComponents': (params: Params) => components.importItems(root(), (optional(params, 'kind') ?? 'skills') as components.ComponentKind, Array.isArray(params.items) ? params.items : []),
212
+ 'harnesses.addComponent': (params: Params) => {
213
+ const scope = text(params, 'scope')
214
+ const base = params.tree !== undefined ? treeOf(params.tree) : trees.headTree(root(), scope)?.tree ?? {}
215
+ const tree = components.addToTree(root(), base, text(params, 'id'))
216
+ return { tree, ...check(tree) }
217
+ },
218
+ 'harnesses.materialise': (params: Params) => {
219
+ const built = materialise(root(), params.harness as HarnessChoice, { trust: harness.trust })
220
+ return { ...built, tree: undefined }
191
221
  },
192
- 'wiki.undoOperation': (params: Params) => maintenance.undoOperation(text(params, 'operationId')),
193
- 'wiki.runContext': (params: Params) => readRunContext(root(), text(params, 'runId')),
194
222
 
195
- // ---- Facts and memory blocks -----------------------------------------------------------------
196
- 'facts.list': () => facts.loadMemories(wikiDir()).map((memory) => facts.response(wikiDir(), memory)),
197
- 'facts.save': (params: Params) => {
198
- const saved = facts.saveAsUser(wikiDir(), params.input)
199
- changed()
200
- return saved
223
+ // ---- Refinement: improving a harness from evidence -------------------------------------------
224
+ 'refine.start': (params: Params) => harness.refine.start({ task: params.task, k: typeof params.k === 'number' ? params.k : 1, mode: params.mode === 'sequential' ? 'sequential' : 'parallel', improver: params.improver ?? {}, baseVersion: optional(params, 'baseVersion'), id: optional(params, 'id') ?? undefined }),
225
+ 'refine.get': (params: Params) => harness.refine.get(text(params, 'id')),
226
+ 'refine.list': (params: Params) => harness.refine.list({ scope: optional(params, 'scope'), limit: typeof params.limit === 'number' ? params.limit : undefined }),
227
+ 'refine.running': () => harness.refine.running(),
228
+ 'refine.cancel': async (params: Params) => {
229
+ await harness.refine.cancel(text(params, 'id'))
230
+ return readSummary(root(), text(params, 'id'))
201
231
  },
202
- 'blocks.list': (params: Params) => {
203
- // Defaults exist for the user, each agent the application names, and each project.
204
- const directory = wikiDir()
205
- blocks.ensureUser(directory)
206
- for (const agentId of Array.isArray(params.agentIds) ? params.agentIds : []) blocks.ensureAgent(directory, String(agentId))
207
- for (const project of projectsOrNone(root())) blocks.ensureProject(directory, project.slug, project.name)
208
- return blocks.loadAll(directory).map((block) => blocks.response(directory, block))
232
+ 'refine.candidates': (params: Params) => harness.refine.candidates(text(params, 'id')),
233
+ 'refine.adopt': (params: Params) => harness.refine.adopt(text(params, 'id'), { label: optional(params, 'label') }),
234
+ 'library.list': (params: Params) => harness.refine.library(text(params, 'scope')),
235
+ 'library.read': (params: Params) => harness.refine.libraryEntry(text(params, 'scope'), text(params, 'area'), text(params, 'task')),
236
+ 'library.forget': (params: Params) => harness.refine.forget(text(params, 'scope'), text(params, 'area'), Array.isArray(params.tasks) ? params.tasks : []),
237
+
238
+ // ---- The Wiki: project notes ------------------------------------------------------------------
239
+ 'wiki.list': (params: Params) => notes.list(root(), params.project === undefined ? {} : { project: optional(params, 'project') }),
240
+ 'wiki.read': (params: Params) => notes.read(root(), text(params, 'path')),
241
+ 'wiki.write': (params: Params) => {
242
+ const note = notes.write(root(), text(params, 'path'), typeof params.text === 'string' ? params.text : '', { expectedUpdatedAt: optional(params, 'expectedUpdatedAt') })
243
+ harness.bus.emit({ type: 'wiki.changed' })
244
+ return note
209
245
  },
210
- 'blocks.save': (params: Params) => {
211
- const saved = blocks.response(wikiDir(), blocks.saveAsUser(wikiDir(), params.input))
246
+ 'wiki.remove': (params: Params) => {
247
+ notes.remove(root(), text(params, 'path'))
212
248
  harness.bus.emit({ type: 'wiki.changed' })
213
- return saved
214
249
  },
215
- 'blocks.undo': (params: Params) => {
216
- const block = blocks.response(wikiDir(), blocks.undoLast(wikiDir(), text(params, 'blockId')))
250
+ 'wiki.search': (params: Params) => notes.search(root(), typeof params.query === 'string' ? params.query : '', { project: optional(params, 'project'), limit: typeof params.limit === 'number' ? params.limit : undefined }),
251
+ 'wiki.graph': () => notes.graph(root()),
252
+ 'wiki.versions': (params: Params) => notes.versions(root(), text(params, 'path')),
253
+ 'wiki.version': (params: Params) => notes.version(root(), text(params, 'path'), text(params, 'id')),
254
+ 'wiki.ensureProject': () => {
255
+ notes.ensureLayout(root(), env.productName)
217
256
  harness.bus.emit({ type: 'wiki.changed' })
218
- return block
219
257
  },
220
258
 
221
259
  // ---- What a session calls, on behalf of a caller the host names -------------------------------
222
- 'memory.permissions': (params: Params) => {
223
- const context = (params.context ?? {}) as CallerContext
224
- const { caller, attached } = callerBlocks(env, context)
225
- return attached.map((block) => ({ id: block.id, writable: blocks.editRefusal(caller, block) === null, reason: blocks.editRefusal(caller, block) }))
226
- },
227
- 'memory.call': (params: Params) => dispatch(env, (params.context ?? {}) as CallerContext, text(params, 'call'), params.params ?? {}),
260
+ 'harness.call': (params: Params) => dispatch(env, (params.context ?? {}) as CallerContext, text(params, 'call'), params.params ?? {}, { treeDir: optional(params, 'treeDir') }),
228
261
 
229
262
  // ---- Recall ------------------------------------------------------------------------------------
230
263
  'recall.search': (params: Params) => recall.searchAll(env, typeof params.query === 'string' ? params.query : '', params.filter ?? {}),
@@ -232,30 +265,11 @@ export function methods(harness: Harness): Record<string, Handler> {
232
265
 
233
266
  // ---- The compiled prompt -----------------------------------------------------------------------
234
267
  'context.compile': (params: Params) => {
235
- const pack = compile(env, (params.context ?? {}) as CallerContext, (params.launch ?? {}) as Launch)
268
+ const tree = params.harness ? materialise(root(), params.harness as HarnessChoice, { trust: harness.trust }) : null
269
+ const pack = compile(env, (params.context ?? {}) as CallerContext, (params.launch ?? {}) as Launch, tree)
236
270
  return params.render ? { pack, text: render(env, pack) } : pack
237
271
  },
238
272
  'context.runPack': (params: Params) => load(root(), text(params, 'runId')),
239
- 'context.profiles': () => ({ profiles: profiles(root()), stats: comparison(root()) }),
240
- 'context.setProfile': (params: Params) => {
241
- const profile = params.profile as ContextProfile
242
- if (!CONTEXT_PROFILES.includes(profile)) throw invalid('Unknown context profile.')
243
- setProfile(root(), text(params, 'subject'), profile)
244
- },
245
- 'references.pin': (params: Params) => references.pinAll(root(), (Array.isArray(params.items) ? params.items : []).map((item: Params) => ({ kind: item.kind === 'skill' ? ('skill' as const) : ('page' as const), id: text(item, 'id') }))),
246
- 'references.body': (params: Params) => references.body(root(), text(params, 'id'), optional(params, 'revision')),
247
-
248
- // ---- Skills, prompts, rules --------------------------------------------------------------------
249
- 'skills.list': () => registry.state(root()),
250
- 'skills.setEnabled': (params: Params) => {
251
- const state = registry.setEnabled(root(), optional(params, 'projectId'), Array.isArray(params.ids) ? params.ids : [])
252
- // The same choices are what the plugin list shows.
253
- harness.bus.emit({ type: 'plugins.changed' })
254
- return state
255
- },
256
- 'skills.create': (params: Params) => registry.create(root(), text(params, 'kind'), text(params, 'name')),
257
- 'skills.import': (params: Params) => registry.importSkills(root(), Array.isArray(params.skills) ? params.skills : []),
258
- 'skills.enabledFor': (params: Params) => registry.enabledFor(root(), optional(params, 'projectId')),
259
273
 
260
274
  // ---- Plugins: everything that shapes a session, and the trust a working folder's code needs ---
261
275
  // A host that is not a Node program describes its own parts here instead of in the options.
@@ -273,7 +287,7 @@ export function methods(harness: Harness): Record<string, Handler> {
273
287
  'plugins.update': (params: Params) => install.update(harness, { ...pluginScope(params), pluginId: optional(params, 'pluginId') }),
274
288
  'plugins.configure': (params: Params) => plugins.configure(harness, { ...pluginScope(params), pluginId: text(params, 'pluginId'), settings: (params.settings && typeof params.settings === 'object' ? params.settings : {}) as Record<string, unknown> }),
275
289
  'plugins.forgetAgent': (params: Params) => plugins.forgetAgent(harness, text(params, 'agentId')),
276
- 'plugins.inventory': (params: Params) => plugins.inventoryFor(harness, { ...pluginScope(params), context: (params.context ?? {}) as CallerContext, launch: (params.launch ?? {}) as Launch, tools: params.tools, connections: params.connections }),
290
+ 'plugins.inventory': (params: Params) => plugins.inventoryFor(harness, { ...pluginScope(params), context: (params.context ?? {}) as CallerContext, launch: (params.launch ?? {}) as Launch, tools: params.tools, connections: params.connections, harness: (params.harness ?? null) as HarnessChoice | null }),
277
291
  'plugins.trust': (params: Params) => plugins.trust(harness, { cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), hash: text(params, 'hash') }),
278
292
  'plugins.untrust': (params: Params) => plugins.untrust(harness, { cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), hash: text(params, 'hash') }),
279
293
 
@@ -84,7 +84,7 @@ describe('the harness served to a host', () => {
84
84
 
85
85
  const status = await request('harness.status')
86
86
  expect(status).toMatchObject({ dataRoot: harness.env.root, productName: 'Xingular Harness', activeRuns: [] })
87
- expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'sessions.spawnSubagent', 'subagents.spawn', 'subagents.wait', 'subagents.results', 'wiki.pages', 'terminal.prepare', 'skills.list', 'skills.import', 'connections.list', 'harness.shutdown']))
87
+ expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'sessions.spawnSubagent', 'subagents.spawn', 'subagents.wait', 'subagents.results', 'wiki.list', 'wiki.write', 'terminal.prepare', 'harnesses.seed', 'harnesses.save', 'refine.start', 'refine.adopt', 'library.list', 'connections.list', 'harness.shutdown']))
88
88
 
89
89
  await expect(request('sessions.spawnSubagent', { parentRunId: 'missing', brief: 'Review' })).rejects.toMatchObject({ code: 'run_not_active' })
90
90
 
@@ -95,14 +95,11 @@ describe('the harness served to a host', () => {
95
95
  await expect(request('accounts.rename', {})).rejects.toMatchObject({ code: 'invalid_request', message: 'accountId is required.' })
96
96
  await expect(request('nope.nothing')).rejects.toMatchObject({ code: 'unknown_method' })
97
97
 
98
- expect((await request<any[]>('wiki.namespaces')).map((entry) => entry.namespace)).toEqual(expect.arrayContaining(['/projects/app']))
99
- const blocks = await request<any[]>('blocks.list', { agentIds: ['a1'] })
100
- expect(blocks.map((block) => block.label)).toEqual(expect.arrayContaining(['user', 'persona', 'notes']))
101
- expect(await request('memory.call', { context: { agentId: 'a1', agentName: 'Helper', actor: 'Chat' }, call: 'memory.append', params: { block: 'notes', text: 'Ship on Fridays.' } })).toContain('notes')
98
+ await request('wiki.write', { path: 'projects/app/decisions', text: '# Decisions\n\nShip on Fridays.\n' })
102
99
  expect(notices).toContainEqual({ type: 'wiki.changed' })
103
- const compiled = await request('context.compile', { context: { agentId: 'a1', agentName: 'Helper', actor: 'Chat' }, render: true })
104
- expect(compiled.text).toContain('Ship on Fridays.')
105
- expect(await request('context.profiles')).toEqual({ profiles: {}, stats: [] })
100
+ expect(await request('harness.call', { context: { agentId: 'a1', agentName: 'Helper', actor: 'Chat', projectId: 'p1' }, call: 'wiki.search', params: { query: 'Fridays' } })).toContain('projects/app/decisions')
101
+ const compiled = await request('context.compile', { context: { agentId: 'a1', agentName: 'Helper', actor: 'Chat', projectId: 'p1' }, render: true })
102
+ expect(compiled.text).toContain('projects/app/decisions — Decisions')
106
103
 
107
104
  const started = await request('sessions.run', { run: { id: 'r1', sessionId: 'a1' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'sessions', 'a1'), text: 'hello', caller: { agentId: 'a1', agentName: 'Helper', actor: 'Chat' }, tools: { allow: ['read'] } })
108
105
  expect(started).toEqual({ runId: 'r1', nativeSessionId: 'native-1' })
@@ -110,7 +107,7 @@ describe('the harness served to a host', () => {
110
107
  expect(events.map((event) => event.type)).toEqual(['started', 'status.changed', 'context.snapshot', 'completed'])
111
108
  expect((await request('runs.load', { runId: 'r1' })).run).toMatchObject({ id: 'r1', status: 'completed' })
112
109
  expect((await request<any[]>('runs.list', { filter: { sessionId: 'a1' } })).map((run) => run.id)).toEqual(['r1'])
113
- expect((await request('context.profiles')).stats).toMatchObject([{ subject: 'a1', mode: 'chat', contextProfile: 'full', runs: 1, completed: 1, runIds: ['r1'] }])
110
+ expect((await request('context.runPack', { runId: 'r1' })).sections.map((section: { id: string }) => section.id)).toContain('tools')
114
111
  })
115
112
 
116
113
  it('lets a host that is not a Node program supply secrets, policy, names, and tools over the protocol', async () => {
@@ -163,31 +160,41 @@ describe('the harness served to a host', () => {
163
160
  })
164
161
  })
165
162
 
166
- describe('built-in skills', () => {
167
- it('are on everywhere until switched off, and a person\'s own item of the same name replaces one', async () => {
163
+ describe('harness trees over the wire', () => {
164
+ it('seeds, edits, validates, restores, and adds a built-in component, then compiles the tree', async () => {
168
165
  const builtins = mkdtempSync(join(tmpdir(), 'builtin-'))
169
- writeFile(join(builtins, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Review a change before it ships\n---\nRead the diff first.\n')
170
- writeFile(join(builtins, 'rules', 'plain.md'), '---\nname: Plain words\ndescription: Write plainly\n---\nUse plain words.\n')
171
- const { transport, request } = host()
166
+ writeFile(join(builtins, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: reviewing a change before it ships\n---\nRead the diff first.\n')
167
+ writeFile(join(builtins, 'rules', 'plain.md'), '---\nname: Plain words\n---\nUse plain words.\n')
168
+ const { transport, request, notices } = host()
172
169
  const harness = createHarness({ dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), builtins: [builtins] })
173
170
  open.push(harness)
174
171
  serve(harness, transport)
175
172
 
176
- const state = await request('skills.list')
177
- expect(state.items).toMatchObject([{ id: 'rules/plain', builtin: true }, { id: 'skills/review', builtin: true }])
178
- expect(state.enabled.global).toEqual(['rules/plain', 'skills/review'])
179
- expect((await request<any[]>('skills.enabledFor', { projectId: 'p1' })).map((item) => item.id)).toEqual(['rules/plain', 'skills/review'])
180
- expect((await request('context.compile', { context: { projectId: 'p1', actor: 'Code' }, render: true })).text).toContain('Use plain words.')
181
-
182
- const off = await request('skills.setEnabled', { projectId: null, ids: ['skills/review'] })
183
- expect(off.enabled).toMatchObject({ global: ['skills/review'], builtinOff: ['rules/plain'] })
184
- expect((await request<any[]>('skills.enabledFor', {})).map((item) => item.id)).toEqual(['skills/review'])
185
-
186
- writeFile(join(harness.env.root, 'registry', 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: My own review steps\n---\nMine.\n')
187
- const mine = (await request('skills.list')).items.find((item: any) => item.id === 'skills/review')
188
- expect(mine).toMatchObject({ description: 'My own review steps' })
189
- expect(mine.builtin).toBeUndefined()
190
- // A person's own item is enabled the usual way, not by default.
191
- expect((await request<any[]>('skills.enabledFor', {})).map((item) => item.id)).toEqual([])
173
+ const seeded = await request('harnesses.seed', { scope: 'a1', instructions: 'You help with releases.' })
174
+ expect(seeded.fingerprint).toMatch(/^[0-9a-f]{16}$/)
175
+ expect(notices).toContainEqual({ type: 'harnesses.changed', scope: 'a1' })
176
+ const first = await request('harnesses.get', { scope: 'a1' })
177
+ expect(first.tree['systemprompt.md']).toBe('You help with releases.\n')
178
+
179
+ const library = await request<any[]>('harnesses.components')
180
+ expect(library.map((item) => item.id)).toEqual(['rules/plain', 'skills/review'])
181
+ const added = await request('harnesses.addComponent', { scope: 'a1', id: 'skills/review' })
182
+ expect(added.ok).toBe(true)
183
+ expect(added.tree['guardrails.md']).toContain('load the `review` skill')
184
+ const saved = await request('harnesses.save', { scope: 'a1', tree: added.tree, note: 'Added review' })
185
+ expect(saved.parent).toBe(seeded.fingerprint)
186
+
187
+ const bad = await request('harnesses.validate', { tree: { ...added.tree, 'plugins/x.ts': 'nothing' } })
188
+ expect(bad).toMatchObject({ ok: false })
189
+ expect(bad.problems.join(' ')).toContain('default export')
190
+ await expect(request('harnesses.save', { scope: 'a1', edit: { 'plugins/x.ts': 'nothing' } })).rejects.toMatchObject({ code: 'harness_invalid' })
191
+
192
+ const edited = await request('harnesses.save', { scope: 'a1', edit: { 'memory.md': 'Releases ship on Tuesdays.\n' } })
193
+ expect((await request<any[]>('harnesses.lineage', { scope: 'a1' })).map((version) => version.fingerprint)).toEqual([edited.fingerprint, saved.fingerprint, seeded.fingerprint])
194
+ const compiled = await request('context.compile', { context: { agentId: 'a1', actor: 'Chat' }, harness: { scope: 'a1' }, render: true })
195
+ expect(compiled.text).toContain('Releases ship on Tuesdays.')
196
+ await request('harnesses.restore', { scope: 'a1', version: seeded.fingerprint })
197
+ expect((await request('harnesses.get', { scope: 'a1' })).head.fingerprint).toBe(seeded.fingerprint)
198
+ expect(await request('refine.list', { scope: 'a1' })).toEqual([])
192
199
  })
193
200
  })
@@ -97,7 +97,7 @@ const approvalRequestedPayload = z.object({
97
97
  automatic: approvalAutomaticSchema.optional()
98
98
  })
99
99
  const approvalResolvedPayload = z.object({ approvalId: z.string(), decision: z.enum(['approved', 'denied', 'native']), automatic: approvalAutomaticSchema.optional() })
100
- export const questionOptionSchema = z.object({ label: z.string().min(1), description: z.string().optional() })
100
+ export const questionOptionSchema = z.object({ label: z.string().min(1), description: z.string().optional(), recommended: z.boolean().optional() })
101
101
  export type QuestionOption = z.infer<typeof questionOptionSchema>
102
102
  const questionBatchFields = {
103
103
  batchId: z.string().min(1).optional(),
@@ -299,6 +299,9 @@ export interface RunRecord {
299
299
  agentId: string | null
300
300
  /** What started the run. */
301
301
  trigger: string | null
302
+ /** The harness tree the run used: its scope and version fingerprint. */
303
+ harnessScope?: string | null
304
+ harnessVersion?: string | null
302
305
  }
303
306
 
304
307
  const text = z.string().nullish().transform((value) => value ?? null)
@@ -326,7 +329,9 @@ export const runRecordSchema = z.object({
326
329
  handoffId: text,
327
330
  accountId: text,
328
331
  agentId: text,
329
- trigger: text
332
+ trigger: text,
333
+ harnessScope: text.optional(),
334
+ harnessVersion: text.optional()
330
335
  }) satisfies z.ZodType<RunRecord, unknown>
331
336
 
332
337
  export const ACTIVE_STATUSES: readonly string[] = ['queued', 'running', 'waiting_for_approval', 'waiting_for_input']
@@ -1,15 +1,13 @@
1
1
  // Whole replies, through everything the harness does for one: the compiled prompt, the plugins
2
2
  // it chose to load, the gate, the run's events. Pi is real and so are the extensions; only the
3
3
  // model is scripted, so no request leaves the machine.
4
- import { existsSync, mkdtempSync } from 'node:fs'
4
+ import { mkdtempSync } from 'node:fs'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { join } from 'node:path'
7
7
  import { afterEach, describe, expect, it } from 'vitest'
8
8
 
9
9
  import { memorySecretStore } from '../accounts/secrets'
10
10
  import { createHarness, type Harness } from '../harness'
11
- import { listInbox } from '../memory/operations'
12
- import { wikiDir } from '../memory/store'
13
11
  import type { Plugin } from '../plugins/types'
14
12
  import { methods } from '../server/methods'
15
13
  import { fauxModel } from '../../tests/faux'
@@ -33,7 +31,7 @@ export default function (pi) {
33
31
  async function setup() {
34
32
  const root = rootWithProject()
35
33
  const base = mkdtempSync(join(tmpdir(), 'conversation-'))
36
- const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore(), maintainerFactory: async () => ({ prompt: async () => undefined, dispose: () => undefined, error: () => null }) })
34
+ const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore() })
37
35
  open.push(harness)
38
36
  harness.accounts.create('Main', 'apiKey')
39
37
  const model = await fauxModel(harness)
@@ -181,29 +179,6 @@ describe('a whole reply, with a scripted model', () => {
181
179
  expect(model.lastToolResults().at(-1)).toMatch(/denied/)
182
180
  })
183
181
 
184
- it('lets the agent propose a plugin, which reaches the Inbox and nothing else', async () => {
185
- const { root, model, start, until, settled, of } = await setup()
186
- model.script([
187
- model.calls(['plugin_propose', { name: 'Memory to Docs', description: 'Copies each saved memory to the team document.', extends: ['memory'] as never, files: [{ path: 'hooks.mjs', content: "export default function (harness) { harness.on('memory.saved', async () => {}) }\n" }] as never }]),
188
- model.says('I proposed it; it is in your Inbox.')
189
- ])
190
- const id = await start()
191
- await until(() => settled(id))
192
- expect(model.lastToolResults().at(-1)).toContain('The plugin "memory-to-docs" is waiting in the Inbox')
193
- expect(listInbox(wikiDir(root)).map((item) => item.title)).toEqual(['Proposed plugin "Memory to Docs"'])
194
- expect(existsSync(join(root, 'plugins'))).toBe(false)
195
- // Proposing is not something a person is asked to approve: it changes nothing until they accept.
196
- expect(of(id).some((event) => event.type === 'approval.requested')).toBe(false)
197
- })
198
-
199
- it('has no plugin proposal tool in unattended work', async () => {
200
- const { model, start, until, settled } = await setup()
201
- model.script([model.calls(['plugin_propose', { name: 'x', description: 'y', files: [] as never }]), model.says('Done.')])
202
- const id = await start({ automatic: true })
203
- await until(() => settled(id))
204
- expect(model.lastToolResults().at(-1)).not.toContain('waiting in the Inbox')
205
- })
206
-
207
182
  it('delivers a message sent during a tool call after that call, before the next request', async () => {
208
183
  const { harness, model, call, start, until, settled, of } = await setup()
209
184
  model.script([model.calls(['bash', { command: 'ls' }]), model.says('Noted.')])
@@ -26,10 +26,17 @@ describe('plan mode', () => {
26
26
  const readOnly = new Set(['files_read'])
27
27
  for (const tool of ['read', 'grep', 'wiki_read', 'files_read', 'ask_user']) expect(planRefusal(tool, readOnly), tool).toBeNull()
28
28
  for (const tool of ['bash', 'write', 'edit', 'memory_append', 'wiki_propose', 'spawn_subagent', 'mcp_call_tool', 'files_write']) {
29
- expect(planRefusal(tool, readOnly), tool).toContain('Plan mode is read-only')
29
+ expect(planRefusal(tool, readOnly), tool).toContain('Plan mode is research-only')
30
30
  }
31
31
  })
32
32
 
33
+ it('allows only provider calls whose arguments are explicitly classified as research-safe', () => {
34
+ const safeBrowserCall = (input: Record<string, unknown>) => input.op === 'navigate' || input.op === 'scroll'
35
+ expect(planRefusal('browser', new Set(), { op: 'navigate' }, safeBrowserCall)).toBeNull()
36
+ expect(planRefusal('browser', new Set(), { op: 'click' }, safeBrowserCall)).toContain('research-only')
37
+ expect(planRefusal('browser', new Set(), { op: 'submit' }, safeBrowserCall)).toContain('research-only')
38
+ })
39
+
33
40
  it('adds its instructions while on, withdraws them once after, and is absent otherwise', () => {
34
41
  expect(planSection(true, null)).toMatchObject({ id: PLAN_SECTION_ID, body: PLAN_INSTRUCTIONS, dynamic: true })
35
42
  expect(PLAN_INSTRUCTIONS).toContain('<proposed_plan>')