@xingular/harness 0.1.7 → 2.1.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 (135) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +36 -13
  3. package/dist/cli.js +10728 -8033
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +109 -13
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/hub/hub.test.ts +49 -1
  18. package/src/hub/hub.ts +122 -6
  19. package/src/hub/schema.ts +13 -1
  20. package/src/index.ts +7 -2
  21. package/src/mcp/connections.ts +39 -12
  22. package/src/mcp/service.test.ts +49 -3
  23. package/src/mcp/service.ts +14 -3
  24. package/src/mcp/types.ts +10 -0
  25. package/src/memory/curation.test.ts +44 -1
  26. package/src/memory/dispatch.ts +5 -0
  27. package/src/memory/maintenance.test.ts +17 -1
  28. package/src/memory/maintenance.ts +169 -8
  29. package/src/memory/recall.ts +2 -1
  30. package/src/memory/trajectory.test.ts +178 -0
  31. package/src/memory/trajectory.ts +220 -0
  32. package/src/memory/wiki.ts +2 -1
  33. package/src/memory/wikiIndex.ts +3 -2
  34. package/src/plugins/builtin.ts +4 -2
  35. package/src/plugins/catalogue.test.ts +1 -1
  36. package/src/server/methods.ts +8 -0
  37. package/src/server/server.test.ts +20 -1
  38. package/src/sessions/approvals.test.ts +49 -0
  39. package/src/sessions/approvals.ts +26 -4
  40. package/src/sessions/conversation.test.ts +56 -0
  41. package/src/sessions/lineageJoin.ts +1 -1
  42. package/src/sessions/planMode.test.ts +40 -0
  43. package/src/sessions/planMode.ts +57 -0
  44. package/src/sessions/registry.ts +38 -7
  45. package/src/sessions/runStore.ts +14 -0
  46. package/src/sessions/runner.ts +9 -3
  47. package/src/sessions/sessions.test.ts +69 -7
  48. package/src/sessions/sessions.ts +47 -39
  49. package/src/sessions/subagentService.test.ts +159 -0
  50. package/src/sessions/subagentService.ts +236 -0
  51. package/src/sessions/subagents.ts +97 -0
  52. package/src/terminal/controller.ts +73 -10
  53. package/src/terminal/piExtension.ts +42 -6
  54. package/src/terminal/socket.test.ts +23 -0
  55. package/src/terminal/socket.ts +42 -18
  56. package/src/terminal/standalone.ts +12 -0
  57. package/src/terminal/terminal.test.ts +150 -15
  58. package/src/terminal/terminals.ts +113 -5
  59. package/src/terminal/trajectory.test.ts +66 -0
  60. package/src/terminal/trajectory.ts +44 -0
  61. package/src/terminal/tui/application.test.ts +206 -9
  62. package/src/terminal/tui/application.ts +251 -81
  63. package/src/terminal/tui/commands.test.ts +70 -9
  64. package/src/terminal/tui/commands.ts +99 -7
  65. package/src/terminal/tui/editor.test.ts +56 -27
  66. package/src/terminal/tui/editor.ts +64 -19
  67. package/src/terminal/tui/keybindings.test.ts +7 -0
  68. package/src/terminal/tui/keybindings.ts +9 -1
  69. package/src/terminal/tui/planMode.test.ts +10 -0
  70. package/src/terminal/tui/planMode.ts +2 -0
  71. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  72. package/src/terminal/tui/runtime.test.ts +10 -0
  73. package/src/terminal/tui/runtime.ts +24 -9
  74. package/src/terminal/tui/screen.test.ts +143 -0
  75. package/src/terminal/tui/screen.ts +121 -0
  76. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  77. package/src/terminal/tui/skillInvocation.ts +51 -0
  78. package/src/terminal/tui/status.test.ts +24 -46
  79. package/src/terminal/tui/status.ts +42 -69
  80. package/src/terminal/tui/theme.ts +18 -7
  81. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  82. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  83. package/src/terminal/tui/transcript.test.ts +14 -0
  84. package/src/terminal/tui/transcript.ts +11 -0
  85. package/src/terminal/tui/ui.ts +2 -2
  86. package/src/terminal/tui/welcome.ts +4 -4
  87. package/src/terminal/tui/workingStatus.test.ts +44 -0
  88. package/src/terminal/tui/workingStatus.ts +238 -0
  89. package/src/tools/definitions.ts +42 -1
  90. package/src/tools/maintenance.ts +6 -1
  91. package/src/tools/spawnSubagent.ts +40 -85
  92. package/src/tools/types.ts +4 -0
  93. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  95. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  100. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  108. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  116. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  118. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  119. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  120. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  121. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  122. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  124. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  125. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  127. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  128. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  129. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  130. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  131. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  132. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  133. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  134. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  135. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -11,9 +11,10 @@ import type { ToolSession } from '../tools/types'
11
11
  import * as blocks from './blocks'
12
12
  import { memoryEdit, propose } from './api'
13
13
  import { enqueueCuration } from './curation'
14
- import { findMemory } from './facts'
14
+ import { findMemory, saveAsUser } from './facts'
15
15
  import * as registry from '../skills/registry'
16
16
  import * as attachments from '../plugins/attachments'
17
+ import { listInbox } from './operations'
17
18
  import { wikiDir } from './store'
18
19
 
19
20
  const opened: Harness[] = []
@@ -57,6 +58,48 @@ test('a shared fact is proposed without writing, then validated against its orig
57
58
  expect(h.maintenance.applyJob(curator, job.id, undefined, 'Retry receipt').receipt).toEqual(receipt.receipt)
58
59
  })
59
60
 
61
+ test('an assigned proposal batch is read together and committed as one reconciled Wiki operation', async () => {
62
+ const h = setup(); await origin(h)
63
+ propose(h.env, self, { kind: 'fact', title: 'Build package', body: 'Prefer pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
64
+ propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
65
+ const ids = h.maintenance.jobs().filter(job => job.input.kind === 'proposal').map(job => job.id)
66
+ const batchCurator = { ...curator, maintenanceJobIds: ids }
67
+ expect(h.maintenance.readBatch(batchCurator).jobs).toHaveLength(2)
68
+
69
+ const applied = h.maintenance.applyBatch(batchCurator, ids.map(id => ({ id, decision: 'apply' as const, reason: 'Merge duplicate statements into one evidence-backed fact.' })), {
70
+ pages: [],
71
+ memories: [{ title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
72
+ blocks: [],
73
+ flags: []
74
+ })
75
+
76
+ expect(applied.operationId).toMatch(/^batch-/)
77
+ expect(applied.jobs.map(job => job.status)).toEqual(['completed', 'completed'])
78
+ expect(applied.jobs.filter(job => job.receipt?.operationId === applied.operationId)).toHaveLength(1)
79
+ expect(findMemory(wikiDir(h.env.root), 'build-tool').body.trim()).toBe('Prefer pnpm for builds.')
80
+ expect(listInbox(wikiDir(h.env.root)).filter(item => item.operationId === applied.operationId)).toHaveLength(1)
81
+ })
82
+
83
+ test('a batch parks updates whose captured Wiki target changed before review', async () => {
84
+ const h = setup(); await origin(h)
85
+ const wiki = wikiDir(h.env.root)
86
+ saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'Use pnpm for builds.' })
87
+ propose(h.env, self, { kind: 'fact', id: 'build-tool', title: 'Build tool', body: 'Prefer pnpm for all builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
88
+ const id = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!.id
89
+ saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'The user changed this preference.' })
90
+
91
+ const result = h.maintenance.applyBatch({ ...curator, maintenanceJobIds: [id] }, [{ id, decision: 'apply', reason: 'Review the proposed update against its evidence.' }], {
92
+ pages: [],
93
+ memories: [{ id: 'build-tool', title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for all builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
94
+ blocks: [],
95
+ flags: []
96
+ })
97
+
98
+ expect(result.jobs[0].status).toBe('needs_user')
99
+ expect(result.operationId).toBeNull()
100
+ expect(findMemory(wiki, 'build-tool').body.trim()).toBe('The user changed this preference.')
101
+ })
102
+
60
103
  test('invented evidence is parked and a read-only memory block is not rewritten', async () => {
61
104
  const h = setup(); await origin(h)
62
105
  propose(h.env, self, { kind: 'fact', title: 'Invented', body: 'Use another tool.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'This was never said anywhere.' }] })
@@ -7,6 +7,7 @@ import { launchText, load, render } from '../prompt/compile'
7
7
  import * as api from './api'
8
8
  import type { CallerContext } from './caller'
9
9
  import * as recall from './recall'
10
+ import * as trajectory from './trajectory'
10
11
  import { reindexQuietly } from './wikiIndex'
11
12
 
12
13
  export type DispatchEnv = HarnessEnv & { bus?: Bus }
@@ -22,6 +23,10 @@ export function dispatch(env: DispatchEnv, context: CallerContext, call: string,
22
23
  return recall.searchTool(env, context, params)
23
24
  case 'recall.read':
24
25
  return recall.readTool(env, context, params)
26
+ case 'trajectory.search':
27
+ return trajectory.searchTool(env, context, params)
28
+ case 'trajectory.read':
29
+ return trajectory.readTool(env, context, params)
25
30
  case 'memory.append':
26
31
  case 'memory.replace':
27
32
  case 'memory.rethink': {
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
5
5
 
6
6
  import { memorySecretStore } from '../accounts/secrets'
7
7
  import { writeJson } from '../core/fs'
8
- import type { Notice } from '../core/bus'
8
+ import { setLogSink, type Notice } from '../core/bus'
9
9
  import { createHarness, type Harness } from '../harness'
10
10
  import type { PiSessionLike } from '../sessions/runner'
11
11
  import * as blocks from './blocks'
@@ -132,6 +132,22 @@ describe('the memory lifecycle', () => {
132
132
  expect([chatUpdatesPaused({}), chatUpdatesPaused({ autoMaintenanceChat: true }), chatUpdatesPaused({ autoMaintenanceChat: false })]).toEqual([false, false, true])
133
133
  })
134
134
 
135
+ it('a code run outside any project is not maintained, and it leaves no log line', async () => {
136
+ const lines: string[] = []
137
+ setLogSink((text) => void lines.push(text))
138
+ try {
139
+ let jobs = 0
140
+ const { harness } = setup(async () => ({ prompt: async () => void (jobs += 1), dispose: () => undefined, error: () => null }))
141
+ await harness.sessions.run({ run: { id: 'code-1', sessionId: 'nora', mode: 'code' },cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Which shell do we use?', caller: { agentId: 'nora', agentName: 'Nora', actor: 'Code' }, tools: { allow: [] } })
142
+ await new Promise((resolve) => setTimeout(resolve, 30))
143
+ await harness.maintenance.settled()
144
+ expect(jobs).toBe(0)
145
+ expect(lines.filter((line) => line.includes('maintenance'))).toEqual([])
146
+ } finally {
147
+ setLogSink(null)
148
+ }
149
+ })
150
+
135
151
  it('an imported source is read once and its proposals are checked against its text', async () => {
136
152
  const { harness, wiki } = setup(async ({ kind, collect }) => ({
137
153
  prompt: async () => {
@@ -5,6 +5,7 @@
5
5
  // their limit are condensed.
6
6
  import { readFileSync } from 'node:fs'
7
7
  import { join } from 'node:path'
8
+ import { createHash } from 'node:crypto'
8
9
 
9
10
  import type { Accounts } from '../accounts/accounts'
10
11
  import { log, type Bus } from '../core/bus'
@@ -20,7 +21,7 @@ import { callerBlocks, type CallerContext } from './caller'
20
21
  import * as facts from './facts'
21
22
  import { piMaintainerFactory, runMaintenance, type ExistingBlock, type ExistingMemory, type ExistingPage, type MaintainerFactory, type MaintenanceRequest } from './maintainer'
22
23
  import { inbox, undo } from './operations'
23
- import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type Trigger } from './proposals'
24
+ import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type ProposalSet, type SourceRef, type Trigger } from './proposals'
24
25
  import { looksSecret } from './secrets'
25
26
  import { enqueueCuration, listCuration, curationJob, saveCuration, requireCurator, type CurationJob } from './curation'
26
27
  import * as registry from '../skills/registry'
@@ -46,27 +47,30 @@ export function chatUpdatesPaused(settings: unknown): boolean {
46
47
  return (settings as { autoMaintenanceChat?: unknown } | null)?.autoMaintenanceChat === false
47
48
  }
48
49
 
50
+ /** A run that automatic maintenance leaves alone for a settled reason: a setting, a pause, or no project. Not an error. */
51
+ class NotEligible extends Error {}
52
+
49
53
  /** Where a settled run's knowledge is filed and what the job reads of it; throws the reason it is not eligible. */
50
54
  function runEvidence(root: string, runId: string): { namespace: string; transcript: string } {
51
55
  const run = findRun(root, runId)
52
56
  if (!run) throw new Error('Run was not found.')
53
57
  const settings = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)
54
- if (Array.isArray(settings?.excludedSourceIds) && settings.excludedSourceIds.includes(runId)) throw new Error('run excluded in settings')
58
+ if (Array.isArray(settings?.excludedSourceIds) && settings.excludedSourceIds.includes(runId)) throw new NotEligible('run excluded in settings')
55
59
  const projectId = run.mode === 'chat' || run.mode === 'code' ? run.projectId : null
56
60
  let namespace: string
57
61
  if (projectId) {
58
62
  const project = findProject(root, projectId)
59
- if (!project.autoMaintenance) throw new Error('automatic updates are paused for this project')
63
+ if (!project.autoMaintenance) throw new NotEligible('automatic updates are paused for this project')
60
64
  namespace = project.wikiNamespace
61
65
  } else if (run.mode === 'chat') {
62
- if (chatUpdatesPaused(settings)) throw new Error('automatic updates are paused for Chat')
66
+ if (chatUpdatesPaused(settings)) throw new NotEligible('automatic updates are paused for Chat')
63
67
  namespace = store.LIBRARY_NAMESPACE
64
68
  } else {
65
- throw new Error('run has no project')
69
+ throw new NotEligible('run has no project')
66
70
  }
67
71
  const text = transcript(run.goal ?? '', readEvents(root, runId))
68
- if (!text.includes('Assistant:')) throw new Error('no recorded assistant output to use as evidence')
69
- if (looksSecret(text)) throw new Error('the run contains text that looks like a credential')
72
+ if (!text.includes('Assistant:')) throw new NotEligible('no recorded assistant output to use as evidence')
73
+ if (looksSecret(text)) throw new NotEligible('the run contains text that looks like a credential')
70
74
  return { namespace, transcript: text }
71
75
  }
72
76
 
@@ -144,6 +148,12 @@ export function fullBlocks(wiki: string, tried: Set<string>): string[] {
144
148
 
145
149
  const blockValues = (found: readonly blocks.Block[]): ExistingBlock[] => found.map((block) => ({ id: block.id, label: block.front.label, description: block.front.description, limit: block.front.limit, body: block.body }))
146
150
 
151
+ export interface MaintenanceBatchDecision {
152
+ id: string
153
+ decision: 'apply' | 'reject' | 'needs_user'
154
+ reason: string
155
+ }
156
+
147
157
  export interface MaintenanceOptions {
148
158
  env: HarnessEnv
149
159
  bus: Bus
@@ -208,7 +218,12 @@ export class Maintenance {
208
218
  if (this.idle) clearTimeout(this.idle)
209
219
  if (this.options.external) {
210
220
  if (job.kind === 'run') {
211
- try { runEvidence(this.options.env.root, job.runId) } catch (error) { log('maintenance source skipped', error instanceof Error ? error.message : String(error)); return }
221
+ try {
222
+ runEvidence(this.options.env.root, job.runId)
223
+ } catch (error) {
224
+ if (!(error instanceof NotEligible)) log('maintenance source skipped', error instanceof Error ? error.message : String(error))
225
+ return
226
+ }
212
227
  const run = findRun(this.options.env.root, job.runId)
213
228
  const namespace = run?.projectId ? findProject(this.options.env.root, run.projectId).wikiNamespace : store.LIBRARY_NAMESPACE
214
229
  enqueueCuration(this.options.env.root, { ...job, namespace })
@@ -233,6 +248,150 @@ export class Maintenance {
233
248
 
234
249
  jobs(): CurationJob[] { return listCuration(this.options.env.root) }
235
250
 
251
+ /** Reads every request frozen into the current host-assigned review batch. */
252
+ readBatch(context: CallerContext): { jobs: unknown[] } {
253
+ requireCurator(context)
254
+ const ids = context.maintenanceJobIds ?? (context.maintenanceJobId ? [context.maintenanceJobId] : [])
255
+ return {
256
+ jobs: ids.map((id) => {
257
+ try {
258
+ return this.readJob(context, id)
259
+ } catch (error) {
260
+ return { id, status: 'failed', error: error instanceof Error ? error.message : String(error) }
261
+ }
262
+ })
263
+ }
264
+ }
265
+
266
+ /** Applies one evidence-checked Wiki change set for all accepted Wiki requests in a batch. */
267
+ applyBatch(context: CallerContext, decisions: readonly MaintenanceBatchDecision[], replacement: unknown): { operationId: string | null; jobs: CurationJob[] } {
268
+ requireCurator(context)
269
+ if (!decisions.length) throw invalid('Decide every assigned request in the batch.')
270
+ const ids = decisions.map((decision) => decision.id)
271
+ if (new Set(ids).size !== ids.length) throw invalid('Each request can appear only once in a batch decision.')
272
+ const assigned = context.maintenanceJobIds ?? (context.maintenanceJobId ? [context.maintenanceJobId] : [])
273
+ const pending = assigned.map((id) => curationJob(this.options.env.root, id)).filter((job) => !['completed', 'rejected', 'needs_user'].includes(job.status))
274
+ if (pending.some((job) => !ids.includes(job.id))) throw invalid('Decide every assigned request in one batch.')
275
+
276
+ const entries = decisions.map((decision) => {
277
+ if (!decision.reason.trim()) throw invalid('Explain every batch decision.')
278
+ if (!['apply', 'reject', 'needs_user'].includes(decision.decision)) throw invalid('Choose apply, reject, or needs_user for every request.')
279
+ const job = curationJob(this.options.env.root, decision.id)
280
+ if (context.maintenanceJobIds !== undefined && !context.maintenanceJobIds.includes(decision.id) && !('caller' in job.input && job.input.caller.runId === context.runId)) throw invalid('This request is not assigned to this run.')
281
+ if (job.runId && job.runId !== context.runId && job.status === 'running') throw invalid('Another run owns this request.')
282
+ return { decision, job }
283
+ })
284
+ const wikiEntries = entries.filter(({ decision, job }) => decision.decision === 'apply' && !['registry', 'plugin'].includes(job.input.kind) && !['completed', 'rejected', 'needs_user'].includes(job.status))
285
+ if (wikiEntries.length && replacement === undefined) throw invalid('Provide one combined, evidence-backed Wiki change set for the approved requests.')
286
+ const proposals: ProposalSet | null = wikiEntries.length ? parseProposalSet(replacement) : null
287
+ const operationId = wikiEntries.length ? `batch-${createHash('sha256').update(wikiEntries.map(({ job }) => job.id).sort().join('\0')).digest('hex').slice(0, 32)}` : null
288
+ const results = new Map<string, CurationJob>()
289
+
290
+ if (wikiEntries.length) {
291
+ const prepared = wikiEntries.map(({ job }) => this.claim(context, job.id))
292
+ const root = this.options.env.root
293
+ const wiki = store.wikiDir(root)
294
+ const evidence: Evidence = { runs: {}, sources: {}, blocks: [] }
295
+ const blocksSeen = new Set<string>()
296
+ const captured = new Map<string, string>()
297
+ const conflicts = new Set<string>()
298
+ for (const job of prepared) {
299
+ const input = job.input
300
+ for (const id of job.prepared?.evidence.blocks ?? (input.kind === 'proposal' ? input.proposals.blocks.map((proposal) => proposal.block) : [])) blocksSeen.add(id)
301
+ for (const [id, hash] of job.hashes) {
302
+ const previous = captured.get(id)
303
+ if (previous !== undefined && previous !== hash) conflicts.add(id)
304
+ else captured.set(id, hash)
305
+ }
306
+ }
307
+ for (const id of conflicts) captured.set(id, `conflicting-batch-snapshot:${id}`)
308
+ evidence.blocks = [...blocksSeen]
309
+
310
+ const refs: SourceRef[] = [...proposals!.pages, ...proposals!.memories, ...proposals!.blocks].flatMap((proposal) => proposal.sourceRefs)
311
+ const excluded = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)?.excludedSourceIds
312
+ const excludedIds = Array.isArray(excluded) ? excluded.filter((id): id is string => typeof id === 'string') : []
313
+ for (const ref of refs) {
314
+ if (ref.kind === 'run' && findRun(root, ref.ref)) {
315
+ const caller = readJsonOr<CallerContext | null>(join(root, 'runs', ref.ref, 'caller.json'), null)
316
+ if (!excludedIds.includes(ref.ref) && caller?.vaultAccess !== 'curator') {
317
+ const text = evidenceText(root, ref.ref)
318
+ if (!looksSecret(text)) evidence.runs![ref.ref] = takeChars(text, 60_000)
319
+ }
320
+ }
321
+ if (ref.kind === 'source') {
322
+ const source = store.listSources(wiki).find((item) => item.id === ref.ref)
323
+ if (source) {
324
+ try {
325
+ const text = readFileSync(join(wiki, 'raw', source.id, source.name), 'utf8')
326
+ if (!looksSecret(text)) evidence.sources![ref.ref] = takeChars(text, 60_000)
327
+ } catch {
328
+ // Missing or unreadable evidence is rejected by the proposal validator.
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ const namespaceFor = (sourceRefs: readonly SourceRef[]): string => {
335
+ for (const ref of sourceRefs) {
336
+ const sourceJob = prepared.find((job) => {
337
+ if (job.input.kind === 'proposal') return job.input.caller.runId === ref.ref
338
+ if (job.input.kind === 'run') return job.input.runId === ref.ref
339
+ if (job.input.kind === 'ingest') return job.input.sourceId === ref.ref
340
+ return false
341
+ })
342
+ const namespace = sourceJob?.prepared?.namespace ?? (sourceJob?.input.kind === 'proposal' ? sourceJob.input.namespace : undefined)
343
+ if (namespace) return namespace
344
+ }
345
+ return prepared[0]?.prepared?.namespace ?? (prepared[0]?.input.kind === 'proposal' ? prepared[0].input.namespace : undefined) ?? store.LIBRARY_NAMESPACE
346
+ }
347
+ const batchProposals: ProposalSet = {
348
+ ...proposals!,
349
+ pages: proposals!.pages.map((proposal) => ({ ...proposal, namespace: proposal.namespace ?? namespaceFor(proposal.sourceRefs) })),
350
+ memories: proposals!.memories.map((proposal) => ({ ...proposal, namespace: proposal.namespace ?? namespaceFor(proposal.sourceRefs) }))
351
+ }
352
+ const trigger: Trigger = prepared.some((job) => job.input.kind === 'consolidate')
353
+ ? { kind: 'consolidate' }
354
+ : prepared.some((job) => job.input.kind === 'run')
355
+ ? { kind: 'run', runId: (prepared.find((job) => job.input.kind === 'run')!.input as Extract<Job, { kind: 'run' }>).runId }
356
+ : prepared.some((job) => job.input.kind === 'ingest')
357
+ ? (prepared.find((job) => job.input.kind === 'ingest')!.prepared?.trigger ?? { kind: 'agent', runId: context.runId ?? null, actor: context.agentName ?? 'Curator' })
358
+ : { kind: 'agent', runId: context.runId ?? null, actor: context.agentName ?? 'Curator' }
359
+ const outcome = apply(wiki, namespaceFor(refs), trigger, batchProposals, captured, evidence, operationId!, context.agentName ?? 'Curator')
360
+ if (outcome.operation) writeJson(join(wiki, 'operations', `${outcome.operation.id}.json`), { ...outcome.operation, protected: true })
361
+ reindexQuietly(root)
362
+ this.options.bus.emit({ type: 'wiki.changed' })
363
+ let operationAssigned = false
364
+ for (const { decision, job: original } of wikiEntries) {
365
+ const job = curationJob(root, original.id)
366
+ const jobReason = decision.reason.trim()
367
+ job.status = outcome.parked ? 'needs_user' : 'completed'
368
+ job.reason = jobReason
369
+ job.receipt = {
370
+ operationId: outcome.operation && !operationAssigned ? outcome.operation.id : null,
371
+ notes: [...outcome.notes, ...(outcome.operation ? [`Combined batch operation ${outcome.operation.id}.`] : ['The combined review produced no Wiki changes.'])],
372
+ parked: outcome.parked,
373
+ flagged: outcome.flagged
374
+ }
375
+ if (outcome.operation && !operationAssigned) operationAssigned = true
376
+ results.set(job.id, saveCuration(root, job))
377
+ }
378
+ }
379
+
380
+ for (const { decision, job } of entries) {
381
+ if (results.has(job.id) || ['completed', 'rejected', 'needs_user'].includes(job.status)) {
382
+ results.set(job.id, curationJob(this.options.env.root, job.id))
383
+ continue
384
+ }
385
+ if (decision.decision === 'apply') {
386
+ results.set(job.id, this.applyJob(context, job.id, undefined, decision.reason))
387
+ } else {
388
+ results.set(job.id, this.decide(context, job.id, decision.decision, decision.reason))
389
+ }
390
+ }
391
+ const receiptOperationId = [...results.values()].find((job) => job.receipt?.operationId)?.receipt?.operationId ?? null
392
+ return { operationId: receiptOperationId, jobs: decisions.map(({ id }) => results.get(id) ?? curationJob(this.options.env.root, id)) }
393
+ }
394
+
236
395
  claim(context: CallerContext, id: string, prepare = true): CurationJob {
237
396
  requireCurator(context)
238
397
  const root = this.options.env.root
@@ -448,6 +607,8 @@ export class Maintenance {
448
607
  const wiki = store.wikiDir(root)
449
608
  let prepared: ReturnType<Maintenance['prepare']>
450
609
  try { prepared = this.prepare(job) } catch (error) {
610
+ // A run that is not eligible is an expected outcome, not a problem to report.
611
+ if (error instanceof NotEligible) return
451
612
  const message = error instanceof Error ? error.message : String(error)
452
613
  if (job.kind === 'run') log(`maintenance skipped run ${job.runId}: ${message}`)
453
614
  else if (job.kind === 'ingest') { inbox(wiki, 'unresolved', 'Source was imported but not summarized', message, []); bus.emit({ type: 'wiki.changed' }) }
@@ -3,6 +3,7 @@
3
3
  // and events stay canonical; the recall index is rebuilt from `runs/`.
4
4
  import type { DatabaseSync } from 'node:sqlite'
5
5
 
6
+ import { logLine } from '../core/bus'
6
7
  import type { HarnessEnv } from '../core/env'
7
8
  import { invalid, notFound } from '../core/errors'
8
9
  import { all, run as execute, transaction, withIndex } from '../core/index'
@@ -67,7 +68,7 @@ export function indexRun(root: string, runId: string): void {
67
68
  try {
68
69
  withIndex(root, (database) => writeRun(database, run, found))
69
70
  } catch (error) {
70
- process.stderr.write(`[recall] run ${runId} was not indexed: ${error instanceof Error ? error.message : String(error)}\n`)
71
+ logLine(`[recall] run ${runId} was not indexed: ${error instanceof Error ? error.message : String(error)}`)
71
72
  }
72
73
  }
73
74
 
@@ -0,0 +1,178 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, expect, test } from 'vitest'
5
+
6
+ import { createEnv } from '../core/env'
7
+ import { closeIndex } from '../core/index'
8
+ import { writeJson } from '../core/fs'
9
+ import { runRecordSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
10
+ import { appendEvent, saveRun } from '../sessions/runStore'
11
+ import { WIKI_AGENT_TOOLS } from '../tools/definitions'
12
+ import type { CallerContext } from './caller'
13
+ import { dispatch } from './dispatch'
14
+ import { readTool, searchTool } from './trajectory'
15
+
16
+ const roots: string[] = []
17
+ afterEach(() => roots.splice(0).forEach(closeIndex))
18
+
19
+ function setup() {
20
+ const root = mkdtempSync(join(tmpdir(), 'harness-trajectory-'))
21
+ roots.push(root)
22
+ writeJson(join(root, 'projects.json'), [
23
+ { id: 'p1', name: 'App', slug: 'app', rootPath: '/tmp/app', autoMaintenance: true, createdAt: 't' },
24
+ { id: 'p2', name: 'Site', slug: 'site', rootPath: '/tmp/site', autoMaintenance: true, createdAt: 't' }
25
+ ])
26
+ const env = createEnv(root, {
27
+ directory: {
28
+ agentName: (id) => ({ 'agent-a': 'Nora', 'agent-b': 'Kai', 'agent-hidden': 'Hidden' })[id] ?? null,
29
+ sessionAgent: (id) => id === 'hidden-session' ? 'agent-hidden' : null
30
+ }
31
+ })
32
+ return { root, env }
33
+ }
34
+
35
+ function run(id: string, overrides: Partial<RunRecord> = {}): RunRecord {
36
+ return runRecordSchema.parse({
37
+ id,
38
+ projectId: null,
39
+ mode: 'chat',
40
+ harness: 'pi',
41
+ sessionType: 'structured',
42
+ sessionId: `session-${id}`,
43
+ goal: `Goal for ${id}`,
44
+ startedAt: '2026-10-18T10:00:00.000Z',
45
+ endedAt: null,
46
+ status: 'running',
47
+ agentId: 'agent-a',
48
+ ...overrides
49
+ })
50
+ }
51
+
52
+ function event(runId: string, sequence: number, type: RunEvent['type'], payload: unknown, native?: unknown): RunEvent {
53
+ return {
54
+ id: `${runId}-event-${sequence}`,
55
+ runId,
56
+ sessionId: `session-${runId}`,
57
+ sequence,
58
+ timestamp: `2026-10-18T10:00:${String(sequence).padStart(2, '0')}.000Z`,
59
+ type,
60
+ payload,
61
+ ...(native === undefined ? {} : { native })
62
+ } as RunEvent
63
+ }
64
+
65
+ function save(root: string, record: RunRecord, events: RunEvent[] = []): void {
66
+ saveRun(root, record)
67
+ for (const entry of events) appendEvent(root, entry)
68
+ }
69
+
70
+ const agent: CallerContext = { agentId: 'agent-a', actor: 'Chat' }
71
+
72
+ test('trajectory search filters runs and paginates deterministically, including active runs', () => {
73
+ const { root, env } = setup()
74
+ save(root, run('z-run', { projectId: 'p2', agentId: 'agent-b' }))
75
+ save(root, run('b-run', { projectId: 'p2', agentId: 'agent-b' }))
76
+ save(root, run('a-run', { projectId: 'p1', agentId: 'agent-a', startedAt: '2026-10-17T10:00:00.000Z', status: 'completed', endedAt: '2026-10-17T10:01:00.000Z' }))
77
+
78
+ const first = JSON.parse(searchTool(env, agent, { project: 'site', agent: 'Kai', limit: 1 }))
79
+ expect(first.runs.map((item: { id: string }) => item.id)).toEqual(['z-run'])
80
+ expect(first.runs[0]).toMatchObject({ agentId: 'agent-b', agentName: 'Kai', project: { id: 'p2', name: 'Site', slug: 'site' }, status: 'running' })
81
+ expect(first.hasMore).toBe(true)
82
+
83
+ const second = JSON.parse(searchTool(env, agent, { project: 'p2', agent: 'agent-b', limit: 1, cursor: first.nextCursor }))
84
+ expect(second.runs.map((item: { id: string }) => item.id)).toEqual(['b-run'])
85
+ expect(second.hasMore).toBe(false)
86
+
87
+ const bounded = JSON.parse(searchTool(env, agent, { since: '2026-10-18', until: '2026-10-18', status: 'running' }))
88
+ expect(bounded.runs.map((item: { id: string }) => item.id)).toEqual(['z-run', 'b-run'])
89
+ })
90
+
91
+ test('trajectory search and read keep host-excluded agents hidden', () => {
92
+ const { root, env } = setup()
93
+ save(root, run('hidden-agent-run', { agentId: 'agent-hidden' }))
94
+ save(root, run('hidden-session-run', { agentId: null, sessionId: 'hidden-session' }))
95
+ save(root, run('visible-run'))
96
+ const caller = { ...agent, recallExcludeAgents: ['agent-hidden'] }
97
+
98
+ const results = JSON.parse(searchTool(env, caller, {}))
99
+ expect(results.runs.map((item: { id: string }) => item.id)).toEqual(['visible-run'])
100
+ expect(() => readTool(env, caller, { run: 'hidden-agent-run' })).toThrow()
101
+ expect(() => readTool(env, caller, { run: 'hidden-session-run' })).toThrow()
102
+ })
103
+
104
+ test('trajectory_read resolves current and returns bounded event pages without native data', () => {
105
+ const { root, env } = setup()
106
+ const longInput = 'x'.repeat(20_000)
107
+ save(root, run('active-run'), [
108
+ event('active-run', 0, 'context.snapshot', { systemPrompt: 'Be useful', tools: [] }, { privateNative: true }),
109
+ event('active-run', 1, 'tool.started', { toolCallId: 't1', name: 'read', input: longInput }),
110
+ event('active-run', 2, 'tool.completed', { toolCallId: 't1', name: 'read', output: 'file contents' })
111
+ ])
112
+ const caller = { ...agent, runId: 'active-run' }
113
+
114
+ const initial = JSON.parse(readTool(env, caller, { run: 'current', limit: 1 }))
115
+ expect(initial.events[0]).toMatchObject({ sequence: 0, type: 'context.snapshot', payload: { systemPrompt: 'Be useful', tools: [] } })
116
+ expect(JSON.stringify(initial)).not.toContain('privateNative')
117
+
118
+ const first = JSON.parse(readTool(env, caller, { run: 'current', afterSequence: 0, limit: 1 }))
119
+ expect(first).toMatchObject({ active: true, run: { id: 'active-run', status: 'running' }, hasMore: true, nextAfterSequence: 1 })
120
+ expect(first.events[0]).toMatchObject({ sequence: 1, type: 'tool.started', truncatedFields: ['payload.input'] })
121
+ expect(JSON.stringify(first)).not.toContain('privateNative')
122
+ expect(first.events[0].payload.input.length).toBeLessThan(longInput.length)
123
+
124
+ const second = JSON.parse(readTool(env, caller, { run: 'active-run', afterSequence: first.nextAfterSequence, limit: 1 }))
125
+ expect(second.events[0]).toMatchObject({ sequence: 2, type: 'tool.completed', payload: { output: 'file contents' } })
126
+ expect(second.hasMore).toBe(false)
127
+ })
128
+
129
+ test('trajectory_read follows execution events through the first outcome only', () => {
130
+ const { root, env } = setup()
131
+ save(root, run('settled-run', { status: 'completed', endedAt: '2026-10-18T10:01:00.000Z' }), [
132
+ event('settled-run', 0, 'started', {}),
133
+ event('settled-run', 1, 'completed', {}),
134
+ event('settled-run', 2, 'tool.started', { toolCallId: 'late', name: 'ignored' }, { hostIgnored: 'run_already_ended' })
135
+ ])
136
+
137
+ const result = JSON.parse(readTool(env, agent, { run: 'settled-run' }))
138
+ expect(result.active).toBe(false)
139
+ expect(result.events.map((item: { sequence: number }) => item.sequence)).toEqual([0, 1])
140
+ expect(JSON.stringify(result)).not.toContain('ignored')
141
+ })
142
+
143
+ test('trajectory_read keeps account identifiers and credential fields out of event payloads', () => {
144
+ const { root, env } = setup()
145
+ save(root, run('safe-run'), [
146
+ event('safe-run', 0, 'account.status', { accountId: 'private-account-id', category: 'quota', message: 'Limit reached for sk-abcdefghijklmnopqrstuvwxyz012345' }),
147
+ event('safe-run', 1, 'tool.started', { toolCallId: 't1', name: 'login', input: '{"username":"ada","password":"private-password"}' }),
148
+ event('safe-run', 2, 'usage.updated', { inputTokens: 10, request: { index: 1, reasoningSummary: 'private reasoning summary' } })
149
+ ])
150
+
151
+ const result = JSON.parse(readTool(env, agent, { run: 'safe-run' }))
152
+ expect(result.events[0].omittedFields).toContain('payload.accountId')
153
+ expect(JSON.stringify(result)).not.toContain('private-account-id')
154
+ expect(JSON.stringify(result)).not.toContain('sk-abcdefghijklmnopqrstuvwxyz012345')
155
+ expect(JSON.stringify(result)).not.toContain('private-password')
156
+ expect(result.events[1].payload.input).not.toContain('password')
157
+ expect(result.events[2].omittedFields).toContain('payload.request.reasoningSummary')
158
+ expect(JSON.stringify(result)).not.toContain('private reasoning summary')
159
+ })
160
+
161
+ test('trajectory calls reject malformed filters and missing current runs', () => {
162
+ const { env } = setup()
163
+ expect(() => searchTool(env, agent, { status: 'unknown' })).toThrow()
164
+ expect(() => searchTool(env, agent, { since: 'not-a-date' })).toThrow()
165
+ expect(() => searchTool(env, agent, { cursor: 'not-a-cursor' })).toThrow()
166
+ expect(() => readTool(env, agent, { run: 'current' })).toThrow()
167
+ expect(() => readTool(env, agent, { run: 'missing' })).toThrow()
168
+ })
169
+
170
+ test('the model tool registry exposes both trajectory calls through Wiki dispatch', () => {
171
+ const { root, env } = setup()
172
+ save(root, run('registered-run'))
173
+ const calls = WIKI_AGENT_TOOLS.map((tool) => tool.call)
174
+ expect(calls).toContain('trajectory.search')
175
+ expect(calls).toContain('trajectory.read')
176
+ expect(JSON.parse(dispatch(env, agent, 'trajectory.search', {})).runs[0].id).toBe('registered-run')
177
+ expect(JSON.parse(dispatch(env, agent, 'trajectory.read', { run: 'registered-run' })).run.id).toBe('registered-run')
178
+ })