@xingular/harness 0.1.6 → 0.1.7

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 (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. package/src/tools/spawnSubagent.ts +92 -0
@@ -10,11 +10,23 @@ import * as blocks from './blocks'
10
10
  * never limits what can be read.
11
11
  */
12
12
  export interface CallerContext {
13
+ /** Opaque, host-owned policy data frozen when this session starts. The harness only forwards it to the embedding app's tool policy. */
14
+ policyContext?: unknown
15
+ /**
16
+ * Trusted, frozen vault mutation capability supplied by an embedding host. `self` writes its own
17
+ * persona and notes directly and proposes the rest; `propose` writes nothing directly; `curator`
18
+ * applies proposals.
19
+ */
20
+ vaultAccess?: 'self' | 'propose' | 'curator'
21
+ maintenanceJobId?: string | null
22
+ maintenanceJobIds?: string[]
13
23
  runId?: string | null
14
24
  sessionId?: string | null
15
25
  projectId?: string | null
16
26
  /** The agent calling; it decides which memory blocks are attached and editable. */
17
27
  agentId?: string | null
28
+ /** Agents whose conversations this caller may not recall (opaque ids from the embedding host). */
29
+ recallExcludeAgents?: string[] | null
18
30
  /** The agent's name, recorded on the revisions it makes. */
19
31
  agentName?: string | null
20
32
  /** Projects whose briefs the agent carries, most recently active first. */
@@ -42,7 +54,7 @@ export function projectOf(root: string, context: CallerContext): Project | null
42
54
  export function blockCaller(env: HarnessEnv, context: CallerContext): blocks.Caller {
43
55
  if (isSession(context)) return { kind: 'session', projectSlug: projectOf(env.root, context)?.slug ?? null }
44
56
  if (!context.agentId) throw invalid('Memory blocks need an agent or a Harness session.')
45
- return { kind: 'agent', id: context.agentId, name: context.agentName ?? env.directory.agentName(context.agentId) ?? context.agentId }
57
+ return { kind: 'agent', id: context.agentId, name: context.agentName ?? env.directory.agentName(context.agentId) ?? context.agentId, vaultAccess: context.vaultAccess === 'propose' ? 'propose' : context.vaultAccess ? 'self' : undefined }
46
58
  }
47
59
 
48
60
  /** The blocks in the caller's context: an agent also carries the briefs of its active projects. */
@@ -0,0 +1,163 @@
1
+ import { existsSync, mkdtempSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+ import { afterEach, expect, test } from 'vitest'
5
+
6
+ import { createHarness, type Harness } from '../harness'
7
+ import { memorySecretStore } from '../accounts/secrets'
8
+ import type { PiSessionLike } from '../sessions/runner'
9
+ import { registryTools } from '../tools/registryWrite'
10
+ import type { ToolSession } from '../tools/types'
11
+ import * as blocks from './blocks'
12
+ import { memoryEdit, propose } from './api'
13
+ import { enqueueCuration } from './curation'
14
+ import { findMemory } from './facts'
15
+ import * as registry from '../skills/registry'
16
+ import * as attachments from '../plugins/attachments'
17
+ import { wikiDir } from './store'
18
+
19
+ const opened: Harness[] = []
20
+ afterEach(async () => { for (const harness of opened.splice(0)) await harness.close() })
21
+ const self = { agentId: 'nora', agentName: 'Nora', actor: 'Chat', vaultAccess: 'self' as const, runId: 'origin' }
22
+ const curator = { agentId: 'curator', agentName: 'Curator', actor: 'Chat', vaultAccess: 'curator' as const, runId: 'review' }
23
+
24
+ function setup(): Harness {
25
+ let emit: (event: unknown) => void = () => undefined
26
+ const session: PiSessionLike = { sessionId: 'test-session', subscribe: listener => { emit = listener as never; return () => undefined }, prompt: async () => { emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Prefer pnpm for all builds.' }] } }) }, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined }
27
+ const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'curation-data-')), stateDir: mkdtempSync(join(tmpdir(), 'curation-state-')), externalMaintenance: true, secrets: memorySecretStore(), sessionFactory: async () => session, maintainerFactory: async () => { throw new Error('Hidden maintenance must not run') } })
28
+ opened.push(harness)
29
+ harness.accounts.create('Main', 'apiKey')
30
+ blocks.ensureAgent(wikiDir(harness.env.root), 'nora')
31
+ return harness
32
+ }
33
+ async function origin(harness: Harness): Promise<void> {
34
+ await harness.sessions.run({ run: { id: 'origin', sessionId: 'nora' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Prefer pnpm for all builds.', caller: self, tools: { allow: [] } })
35
+ await new Promise(resolve => setTimeout(resolve, 30))
36
+ }
37
+
38
+ test('normal callers directly write only their own persona and notes; standalone callers retain their prior scope', () => {
39
+ const h = setup()
40
+ expect(memoryEdit(h.env, self, 'memory.append', { block: 'notes', text: 'Working on release notes.' })).toContain('Updated')
41
+ expect(() => memoryEdit(h.env, self, 'memory.append', { block: 'user', text: 'A shared preference.' })).toThrow('outside your writable memory')
42
+ blocks.ensureAgent(wikiDir(h.env.root), 'other')
43
+ expect(() => memoryEdit(h.env, self, 'memory.append', { block: 'blocks/agents/other/notes', text: 'Not mine.' })).toThrow()
44
+ expect(memoryEdit(h.env, { ...self, vaultAccess: undefined }, 'memory.append', { block: 'user', text: 'Standalone scope.' })).toContain('Updated')
45
+ })
46
+
47
+ test('a shared fact is proposed without writing, then validated against its original run and applied once', async () => {
48
+ const h = setup(); await origin(h)
49
+ const submitted = 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.' }] })
50
+ const job = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
51
+ expect(submitted).toContain(job.id)
52
+ expect(() => findMemory(wikiDir(h.env.root), 'build-tool')).toThrow()
53
+ expect(() => h.maintenance.readJob(self, job.id)).toThrow('curator capability')
54
+ const receipt = h.maintenance.applyJob(curator, job.id, undefined, 'Preserve the stated build preference.')
55
+ expect(receipt.status).toBe('completed')
56
+ expect(findMemory(wikiDir(h.env.root), 'build-tool').front.sourceIds).toContain('run:origin')
57
+ expect(h.maintenance.applyJob(curator, job.id, undefined, 'Retry receipt').receipt).toEqual(receipt.receipt)
58
+ })
59
+
60
+ test('invented evidence is parked and a read-only memory block is not rewritten', async () => {
61
+ const h = setup(); await origin(h)
62
+ 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.' }] })
63
+ const job = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
64
+ expect(h.maintenance.applyJob(curator, job.id, undefined, 'Review the claimed evidence').status).toBe('needs_user')
65
+ expect(() => findMemory(wikiDir(h.env.root), 'invented')).toThrow()
66
+ const block = blocks.find(wikiDir(h.env.root), 'blocks/agents/nora/notes')
67
+ blocks.saveAsUser(wikiDir(h.env.root), { id: block.id, body: '', description: block.front.description, limit: 2000, readOnly: true })
68
+ expect(() => propose(h.env, self, { kind: 'block', id: block.id, title: 'Notes', body: 'Changed.' })).toThrow('user-only')
69
+ })
70
+
71
+ test('text resources remain inactive until curated; undo restores attachments and preserves subsequent user edits', async () => {
72
+ const h = setup()
73
+ const tool = (await registryTools({ harness: h })({ caller: self } as ToolSession)).find(tool => tool.name === 'registry_write')!
74
+ await tool.execute({ kind: 'skill', name: 'release-notes', description: 'Write release notes', body: 'Summarize verified changes.', reason: 'Repeated release work.' })
75
+ const job = h.maintenance.jobs().find(job => job.input.kind === 'registry')!
76
+ expect(registry.list(h.env.root).some(item => item.id === 'skills/release-notes')).toBe(false)
77
+ const receipt = h.maintenance.applyJob(curator, job.id, undefined, 'Reuse the repeated release procedure.')
78
+ expect(attachments.attached(attachments.load(h.env.root), ['skills/release-notes'], { agentId: 'nora' }, false)).toBe(true)
79
+ h.maintenance.undoOperation(receipt.receipt!.operationId!)
80
+ expect(registry.list(h.env.root).some(item => item.id === 'skills/release-notes')).toBe(false)
81
+ const next = enqueueCuration(h.env.root, { kind: 'registry', caller: self, proposal: { kind: 'skill', name: 'other-notes', description: 'Notes', body: 'Write verified notes.', reason: 'Workflow improvement.' } })
82
+ const applied = h.maintenance.applyJob(curator, next.id, undefined, 'Apply text-only improvement.')
83
+ registry.writeItem(h.env.root, 'skill', 'other-notes', 'Notes', 'The user changed this.', 'User edit')
84
+ expect(() => h.maintenance.undoOperation(applied.receipt!.operationId!)).toThrow('changed after')
85
+ })
86
+
87
+ test('changed target versions are escalated instead of overwritten', () => {
88
+ const h = setup()
89
+ registry.writeItem(h.env.root, 'skill', 'notes', 'Notes', 'Original notes.', 'Initial')
90
+ const job = enqueueCuration(h.env.root, { kind: 'registry', caller: self, proposal: { kind: 'skill', name: 'notes', description: 'Notes', body: 'Proposed notes.', reason: 'Improve' } })
91
+ registry.writeItem(h.env.root, 'skill', 'notes', 'Notes', 'User edits win.', 'User edit')
92
+ expect(h.maintenance.applyJob(curator, job.id, undefined, 'Review update').status).toBe('needs_user')
93
+ expect(registry.list(h.env.root).find(item => item.id === 'skills/notes')).toBeDefined()
94
+ })
95
+
96
+ test('executable plugins are submitted without saving, activation or trust', async () => {
97
+ const h = setup()
98
+ const tool = (await registryTools({ harness: h })({ caller: self } as ToolSession)).find(tool => tool.name === 'plugin_write')!
99
+ await tool.execute({ name: 'code-change', description: 'A code plugin', files: [{ path: 'extensions/index.ts', content: 'export default () => {}' }] })
100
+ const job = h.maintenance.jobs().find(job => job.input.kind === 'plugin')!
101
+ expect(h.maintenance.applyJob(curator, job.id, undefined, 'Code needs trust').status).toBe('needs_user')
102
+ expect(existsSync(join(h.env.root, 'plugins/code-change'))).toBe(false)
103
+ })
104
+
105
+ test('external jobs persist and repeated schedule occurrences are distinct; no hidden provider runs', async () => {
106
+ const h = setup()
107
+ const source = h.maintenance.ingest('source.txt', Buffer.from('Prefer pnpm for all builds.'))
108
+ expect(h.maintenance.jobs().some(job => job.input.kind === 'ingest' && job.input.sourceId === source.id)).toBe(true)
109
+ h.maintenance.enqueue({ kind: 'review', requestId: 'day-one' })
110
+ h.maintenance.enqueue({ kind: 'review', requestId: 'day-one' })
111
+ h.maintenance.enqueue({ kind: 'review', requestId: 'day-two' })
112
+ expect(h.maintenance.jobs().filter(job => job.input.kind === 'review')).toHaveLength(2)
113
+ await h.maintenance.settled()
114
+ })
115
+
116
+ test('a curator run never enqueues another completed-run maintenance job', async () => {
117
+ const h = setup()
118
+ await h.sessions.run({ run: { id: 'curator-run', sessionId: 'curator' }, cwd: h.env.root, sessionDir: join(h.env.root, 'curator-history'), text: 'Review maintenance.', caller: curator, tools: { allow: [] } })
119
+ await new Promise(resolve => setTimeout(resolve, 30))
120
+ expect(h.maintenance.jobs().some(job => job.input.kind === 'run' && job.input.runId === 'curator-run')).toBe(false)
121
+ })
122
+
123
+ test('curator prose cannot become its own evidence; original source runs can support a review', async () => {
124
+ const h = setup(); await origin(h)
125
+ await h.sessions.run({ run: { id: 'review', sessionId: 'curator' }, cwd: h.env.root, sessionDir: join(h.env.root, 'curator-history'), text: 'Prefer pnpm for all builds.', caller: curator, tools: { allow: [] } })
126
+ await new Promise(resolve => setTimeout(resolve, 30))
127
+ propose(h.env, curator, { kind: 'fact', title: 'Curator claim', body: 'A claim from the review.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
128
+ const own = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
129
+ expect(h.maintenance.applyJob(curator, own.id, undefined, 'Check the original evidence.').status).toBe('needs_user')
130
+ expect(() => findMemory(wikiDir(h.env.root), 'curator-claim')).toThrow()
131
+ const review = enqueueCuration(h.env.root, { kind: 'review', requestId: 'grounded-review' })
132
+ const applied = h.maintenance.applyJob(curator, review.id, { memories: [{ title: 'Original preference', kind: 'preference', body: 'Prefer pnpm.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }] }] }, 'Retain the original recorded preference.')
133
+ expect(applied.status).toBe('completed')
134
+ expect(findMemory(wikiDir(h.env.root), 'original-preference').front.sourceIds).toContain('run:origin')
135
+ })
136
+
137
+
138
+ test('a failed evidence preparation remains attributed and can be rejected without rereading invalid input', () => {
139
+ const h = setup()
140
+ const job = enqueueCuration(h.env.root, { kind: 'consolidate', blockIds: ['missing-block'] })
141
+ expect(() => h.maintenance.readJob(curator, job.id)).toThrow('No writable blocks')
142
+ expect(h.maintenance.jobs().find(item => item.id === job.id)).toMatchObject({ status: 'failed', runId: 'review' })
143
+ expect(h.maintenance.decide(curator, job.id, 'reject', 'The target no longer exists.').status).toBe('rejected')
144
+ })
145
+
146
+ test('a propose-only caller writes no memory directly, not even its own persona and notes', () => {
147
+ const h = setup()
148
+ const proposer = { ...self, vaultAccess: 'propose' as const }
149
+ expect(() => memoryEdit(h.env, proposer, 'memory.append', { block: 'notes', text: 'Working on release notes.' })).toThrow('Memory changes are proposals')
150
+ expect(() => memoryEdit(h.env, proposer, 'memory.append', { block: 'persona', text: 'I am terse.' })).toThrow('Memory changes are proposals')
151
+ expect(propose(h.env, proposer, { kind: 'block', id: 'blocks/agents/nora/notes', title: 'Notes', body: 'Working on release notes.' })).toContain('Submitted proposal')
152
+ })
153
+
154
+ test('with the run trigger off, a settled run queues no consolidation of its own', async () => {
155
+ let emit: (event: unknown) => void = () => undefined
156
+ const session: PiSessionLike = { sessionId: 'quiet', subscribe: listener => { emit = listener as never; return () => undefined }, prompt: async () => { emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Prefer pnpm for all builds.' }] } }) }, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined }
157
+ const h = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'curation-data-')), stateDir: mkdtempSync(join(tmpdir(), 'curation-state-')), externalMaintenance: true, maintenanceRunTrigger: false, secrets: memorySecretStore(), sessionFactory: async () => session })
158
+ opened.push(h)
159
+ h.accounts.create('Main', 'apiKey')
160
+ await h.sessions.run({ run: { id: 'quiet-run', sessionId: 'nora' }, cwd: h.env.root, sessionDir: join(h.env.root, 'history'), text: 'Prefer pnpm.', caller: self, tools: { allow: [] } })
161
+ await new Promise(resolve => setTimeout(resolve, 30))
162
+ expect(h.maintenance.jobs().filter(job => job.input.kind === 'run')).toEqual([])
163
+ })
@@ -0,0 +1,64 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ import { invalid, notFound } from '../core/errors'
6
+ import { newId, now, readJson, safeId, writeJson } from '../core/fs'
7
+ import type { CallerContext } from './caller'
8
+ import type { Job } from './maintenance'
9
+ import type { MaintenanceRequest } from './maintainer'
10
+ import { captureHashes, parseProposalSet, type ProposalSet, type RegistryProposal, type Evidence, type Trigger } from './proposals'
11
+ import { looksSecret } from './secrets'
12
+ import * as store from './store'
13
+
14
+ export type CurationInput = Job
15
+ | { kind: 'proposal'; namespace: string; proposals: ProposalSet; caller: CallerContext }
16
+ | { kind: 'registry'; proposal: RegistryProposal; caller: CallerContext }
17
+ | { kind: 'plugin'; proposal: unknown; caller: CallerContext }
18
+ export interface CurationJob {
19
+ id: string
20
+ key: string
21
+ input: CurationInput
22
+ status: 'queued' | 'running' | 'completed' | 'failed' | 'rejected' | 'needs_user'
23
+ createdAt: string
24
+ updatedAt: string
25
+ runId: string | null
26
+ reason: string
27
+ hashes: [string, string][]
28
+ prepared?: { request: MaintenanceRequest; evidence: Evidence; namespace: string; trigger: Trigger }
29
+ receipt: { operationId: string | null; notes: string[]; parked: number; flagged: number } | null
30
+ }
31
+
32
+ const directory = (root: string): string => join(store.wikiDir(root), 'maintenance-jobs')
33
+ export function listCuration(root: string): CurationJob[] {
34
+ let names: string[] = []
35
+ try { names = readdirSync(directory(root)) } catch { return [] }
36
+ return names.filter((name) => name.endsWith('.json')).flatMap((name) => {
37
+ const job = readJson<CurationJob>(join(directory(root), name))
38
+ return job && typeof job.id === 'string' && job.input && typeof job.input.kind === 'string' ? [job] : []
39
+ }).sort((a, b) => a.createdAt.localeCompare(b.createdAt))
40
+ }
41
+ export function curationJob(root: string, id: string): CurationJob {
42
+ const job = readJson<CurationJob>(join(directory(root), `${safeId(id)}.json`))
43
+ if (!job) throw notFound('Maintenance job')
44
+ return job
45
+ }
46
+ export function saveCuration(root: string, job: CurationJob): CurationJob {
47
+ writeJson(join(directory(root), `${safeId(job.id)}.json`), { ...job, updatedAt: now() })
48
+ return job
49
+ }
50
+ export function enqueueCuration(root: string, input: CurationInput): CurationJob {
51
+ if (input.kind === 'proposal') input = { ...input, proposals: parseProposalSet(input.proposals) }
52
+ if (Buffer.byteLength(JSON.stringify(input)) > 256 * 1024) throw invalid('The proposal is larger than 256 KiB.')
53
+ if (looksSecret(JSON.stringify(input))) throw invalid('The proposal contains text that looks like a credential.')
54
+ const captured = captureHashes(store.wikiDir(root))
55
+ const revisions = input.kind === 'consolidate' ? input.blockIds.map(id => [id, captured.get(id)]) : undefined
56
+ const key = createHash('sha256').update(JSON.stringify({ input, revisions })).digest('hex')
57
+ const existing = listCuration(root).find((job) => job.key === key)
58
+ if (existing) return existing
59
+ const timestamp = now()
60
+ return saveCuration(root, { id: newId(), key, input, status: 'queued', createdAt: timestamp, updatedAt: timestamp, runId: null, reason: '', hashes: [...captured], receipt: null })
61
+ }
62
+ export function requireCurator(context: CallerContext): void {
63
+ if (context.vaultAccess !== 'curator' || !context.runId) throw invalid('This operation needs a curator capability issued by the host for this run.')
64
+ }
@@ -22,11 +22,18 @@ import { piMaintainerFactory, runMaintenance, type ExistingBlock, type ExistingM
22
22
  import { inbox, undo } from './operations'
23
23
  import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type Trigger } from './proposals'
24
24
  import { looksSecret } from './secrets'
25
+ import { enqueueCuration, listCuration, curationJob, saveCuration, requireCurator, type CurationJob } from './curation'
26
+ import * as registry from '../skills/registry'
27
+ import * as attachments from '../plugins/attachments'
28
+ import { parkPluginProposal, pluginProposalSchema } from '../plugins/proposal'
29
+ import { registryProposalSchema } from './proposals'
30
+ import { writeJson } from '../core/fs'
31
+ import { invalid } from '../core/errors'
25
32
  import * as store from './store'
26
33
  import { namespaces, searchEntries } from './wiki'
27
34
  import { reindexQuietly } from './wikiIndex'
28
35
 
29
- export type Job = { kind: 'ingest'; namespace: string; sourceId: string } | { kind: 'run'; runId: string } | { kind: 'consolidate'; blockIds: string[] }
36
+ export type Job = { kind: 'ingest'; namespace: string; sourceId: string } | { kind: 'run'; runId: string; namespace?: string } | { kind: 'consolidate'; blockIds: string[] } | { kind: 'review'; requestId?: string; manual?: boolean }
30
37
 
31
38
  const FULL_PAGES = 5
32
39
  const FULL_PAGE_LIMIT = 6000
@@ -144,6 +151,10 @@ export interface MaintenanceOptions {
144
151
  factory?: MaintainerFactory
145
152
  /** How long the queue stays empty before full blocks are condensed. */
146
153
  idleMs?: number
154
+ external?: boolean
155
+ excludedTriggers?: string[]
156
+ /** False: a settled run queues no consolidation of its own. */
157
+ runTrigger?: boolean
147
158
  }
148
159
 
149
160
  export class Maintenance {
@@ -157,6 +168,13 @@ export class Maintenance {
157
168
  /** Starts the idle check. Jobs can be queued whether or not it runs. */
158
169
  start(): void {
159
170
  this.stopped = false
171
+ if (this.options.external) {
172
+ for (const job of this.jobs().filter((job) => job.status === 'running')) {
173
+ job.status = 'failed'
174
+ job.reason = 'The previous process stopped before this request settled. Retry it explicitly.'
175
+ saveCuration(this.options.env.root, job)
176
+ }
177
+ }
160
178
  this.rest()
161
179
  }
162
180
 
@@ -188,6 +206,17 @@ export class Maintenance {
188
206
 
189
207
  enqueue(job: Job): void {
190
208
  if (this.idle) clearTimeout(this.idle)
209
+ if (this.options.external) {
210
+ 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 }
212
+ const run = findRun(this.options.env.root, job.runId)
213
+ const namespace = run?.projectId ? findProject(this.options.env.root, run.projectId).wikiNamespace : store.LIBRARY_NAMESPACE
214
+ enqueueCuration(this.options.env.root, { ...job, namespace })
215
+ } else enqueueCuration(this.options.env.root, job)
216
+ this.options.bus.emit({ type: 'maintenance.queued' })
217
+ this.rest()
218
+ return
219
+ }
191
220
  this.chain = this.chain
192
221
  .then(() => this.process(job))
193
222
  .catch((error) => log('maintenance', error instanceof Error ? error.message : String(error)))
@@ -196,11 +225,158 @@ export class Maintenance {
196
225
 
197
226
  /** A settled run is evidence; whether it is eligible is decided when its job runs. */
198
227
  onRunEvent(event: RunEvent): void {
228
+ if (this.options.runTrigger === false) return
229
+ const caller = readJsonOr<CallerContext | null>(join(this.options.env.root, 'runs', event.runId, 'caller.json'), null)
230
+ if (caller?.vaultAccess === 'curator' || this.options.excludedTriggers?.includes(findRun(this.options.env.root, event.runId)?.trigger ?? '')) return
199
231
  if (event.type === 'completed') this.enqueue({ kind: 'run', runId: event.runId })
200
232
  }
201
233
 
202
- private async process(job: Job): Promise<void> {
203
- const { env, bus } = this.options
234
+ jobs(): CurationJob[] { return listCuration(this.options.env.root) }
235
+
236
+ claim(context: CallerContext, id: string, prepare = true): CurationJob {
237
+ requireCurator(context)
238
+ const root = this.options.env.root
239
+ const job = curationJob(root, id)
240
+ if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
241
+ if (context.maintenanceJobIds !== undefined && !context.maintenanceJobIds.includes(id) && !('caller' in job.input && job.input.caller.runId === context.runId)) throw invalid('This request is not assigned to this run.')
242
+ if (job.runId && job.runId !== context.runId && job.status === 'running') throw invalid('Another run owns this request.')
243
+ job.runId = context.runId!
244
+ if (prepare && job.input.kind !== 'proposal' && job.input.kind !== 'registry' && job.input.kind !== 'plugin' && !job.prepared) {
245
+ let prepared: ReturnType<Maintenance['prepare']>
246
+ try { prepared = this.prepare(job.input) } catch (error) { job.status = 'failed'; job.reason = error instanceof Error ? error.message : String(error); saveCuration(root, job); throw error }
247
+ job.prepared = { request: prepared.request, evidence: prepared.evidence, namespace: prepared.namespace, trigger: prepared.trigger }
248
+ job.hashes = [...prepared.captured]
249
+ if (job.prepared.evidence.sourceText) job.prepared.evidence.sourceText = takeChars(job.prepared.evidence.sourceText, 60_000)
250
+ }
251
+ if (prepare && job.input.kind === 'proposal' && !job.prepared) {
252
+ const input = job.input
253
+ const origin = input.caller.runId
254
+ const attached = input.proposals.blocks.map((proposal) => blocks.find(store.wikiDir(root), proposal.block))
255
+ const evidence = origin && input.caller.vaultAccess !== 'curator' ? evidenceText(root, origin) : ''
256
+ const context = maintainerContext(root, input.namespace, evidence)
257
+ job.prepared = { request: { jobId: job.id, namespace: input.namespace, trigger: { kind: 'run', runId: origin ?? '', transcript: takeChars(evidence, 24_000) }, pages: context.pages, memories: context.memories, blocks: blockValues(attached), index: '' }, evidence: { runText: evidence, blocks: attached.map((block) => block.id) }, namespace: input.namespace, trigger: { kind: 'agent', runId: origin ?? null, actor: 'Curator' } }
258
+ }
259
+ job.status = 'running'
260
+ job.runId = context.runId!
261
+ return saveCuration(root, job)
262
+ }
263
+
264
+ readJob(context: CallerContext, id: string): unknown {
265
+ const job = this.claim(context, id)
266
+ return { id: job.id, status: job.status, input: job.input, context: job.prepared?.request ?? null, receipt: job.receipt }
267
+ }
268
+
269
+ decide(context: CallerContext, id: string, decision: 'reject' | 'needs_user', reason: string): CurationJob {
270
+ if (!reason.trim()) throw invalid('Explain the decision.')
271
+ const job = this.claim(context, id, false)
272
+ if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
273
+ job.status = decision === 'reject' ? 'rejected' : 'needs_user'
274
+ job.reason = reason.trim()
275
+ job.receipt = { operationId: null, notes: [job.reason], parked: decision === 'needs_user' ? 1 : 0, flagged: 0 }
276
+ if (decision === 'needs_user') {
277
+ const input = job.input
278
+ if (input.kind === 'registry') {
279
+ const proposal = input.proposal
280
+ inbox(store.wikiDir(this.options.env.root), 'proposed', `Review ${proposal.kind}: ${proposal.name}`, reason, [], null, { kind: proposal.kind, proposal, curationJobId: id })
281
+ } else if (input.kind === 'plugin') parkPluginProposal(store.wikiDir(this.options.env.root), `Curated request ${id}`, pluginProposalSchema.parse(input.proposal))
282
+ else inbox(store.wikiDir(this.options.env.root), 'unresolved', 'Maintenance needs your decision', reason, [])
283
+ }
284
+ this.options.bus.emit({ type: 'wiki.changed' })
285
+ return saveCuration(this.options.env.root, job)
286
+ }
287
+
288
+ applyJob(context: CallerContext, id: string, replacement?: unknown, reason = '', scope?: attachments.AttachScope): CurationJob {
289
+ if (!reason.trim()) throw invalid('Explain why this change should be applied.')
290
+ const root = this.options.env.root
291
+ const wiki = store.wikiDir(root)
292
+ const job = this.claim(context, id)
293
+ if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
294
+ const committed = readJsonOr<{ id: string } | null>(join(wiki, 'operations', `${job.id}.json`), null)
295
+ if (committed) {
296
+ job.status = 'completed'
297
+ job.receipt = { operationId: committed.id, notes: ['This request was already committed.'], parked: 0, flagged: 0 }
298
+ return saveCuration(root, job)
299
+ }
300
+ const input = job.input
301
+ if (input.kind === 'plugin') return this.decide(context, id, 'needs_user', reason || 'Plugin files require user review before activation; executable code also needs explicit trust.')
302
+ if (input.kind === 'registry') {
303
+ const proposal = registryProposalSchema.parse(input.proposal)
304
+ const itemId = `${proposal.kind}s/${store.slug(proposal.name)}`
305
+ if (!['skill', 'prompt', 'rule'].includes(proposal.kind) || !proposal.name.trim() || !proposal.body.trim() || looksSecret(proposal.body)) throw invalid('Invalid text resource proposal.')
306
+ const existing = registry.list(root).find((item) => item.id === itemId)
307
+ const current = captureHashes(wiki).get(itemId)
308
+ if (existing?.builtin || current !== new Map(job.hashes).get(itemId)) return this.decide(context, id, 'needs_user', 'The resource is built-in or changed since it was proposed. Review the current version.')
309
+ const target = scope ?? { agentId: input.caller.agentId ?? context.agentId }
310
+ if (target.agentId && target.projectId) throw invalid('Choose one attachment scope.')
311
+ if (target.agentId && target.agentId !== context.agentId && target.agentId !== input.caller.agentId && !this.options.env.directory.agentName(target.agentId)) throw invalid('Unknown attachment target.')
312
+ if (target.projectId) findProject(root, target.projectId)
313
+ const before = existing ? store.snapshot(wiki, itemId, readFileSync(existing.path, 'utf8'), 'Curator', `Before ${id}`).id : null
314
+ const beforeChoices = attachments.load(root)
315
+ registry.writeItem(root, proposal.kind, proposal.name, proposal.description, proposal.body, `Curated request ${id}: ${reason || proposal.reason}`, context.agentName ?? 'Curator')
316
+ const saved = registry.list(root).find((item) => item.id === itemId)!
317
+ const after = store.currentRevision(wiki, itemId)!
318
+ const choices = attachments.load(root)
319
+ attachments.set(choices, [itemId], target, true, false)
320
+ attachments.save(root, choices)
321
+ writeJson(join(wiki, 'operations', `${id}.json`), { id, createdAt: now(), trigger: `Curated request ${id}`, pages: [{ pageId: itemId, path: saved.path, beforeRevisionId: before, afterRevisionId: after }], undone: false, protected: true, attachmentsBefore: beforeChoices, attachmentsAfter: choices })
322
+ job.receipt = { operationId: id, notes: [`Saved ${itemId}; enabled for ${target.agentId ?? target.projectId ?? 'everyone'}. ${reason || proposal.reason}`], parked: 0, flagged: 0 }
323
+ this.options.bus.emit({ type: 'plugins.changed' })
324
+ } else {
325
+ const prepared = job.prepared
326
+ const proposalInput = input.kind === 'proposal' ? input : null
327
+ const proposals = parseProposalSet(replacement ?? proposalInput?.proposals)
328
+ const origin = proposalInput?.caller.runId ?? null
329
+ const trigger: Trigger = prepared?.trigger ?? { kind: 'agent', runId: origin, actor: context.agentName ?? 'Curator' }
330
+ const evidence: Evidence = input.kind === 'proposal' ? (prepared?.evidence ?? {}) : this.prepare(input).evidence
331
+ const originEvidence: Evidence = prepared?.evidence ?? { runText: origin ? evidenceText(root, origin) : null, blocks: proposals.blocks.map((block) => block.block) }
332
+ {
333
+ if (input.kind === 'proposal') Object.assign(evidence, originEvidence)
334
+ evidence.runs = {}
335
+ evidence.sources = {}
336
+ const refs = [...proposals.pages, ...proposals.memories, ...proposals.blocks].flatMap(proposal => proposal.sourceRefs).slice(0, 20)
337
+ for (const ref of refs) {
338
+ if (ref.kind === 'run' && findRun(root, ref.ref) && readJsonOr<CallerContext | null>(join(root, 'runs', ref.ref, 'caller.json'), null)?.vaultAccess !== 'curator') {
339
+ const excluded = readJsonOr<{ excludedSourceIds?: string[] }>(join(root, 'config/settings.json'), {}).excludedSourceIds ?? []
340
+ const text = evidenceText(root, ref.ref)
341
+ if (!excluded.includes(ref.ref) && !looksSecret(text)) evidence.runs[ref.ref] = takeChars(text, 60_000)
342
+ }
343
+ if (ref.kind === 'source') {
344
+ const source = store.listSources(wiki).find(source => source.id === ref.ref)
345
+ if (source) {
346
+ const text = readFileSync(join(wiki, 'raw', source.id, source.name), 'utf8')
347
+ if (!looksSecret(text)) evidence.sources[ref.ref] = takeChars(text, 60_000)
348
+ }
349
+ }
350
+ }
351
+ }
352
+ const outcome = apply(wiki, prepared?.namespace ?? proposalInput?.namespace ?? store.LIBRARY_NAMESPACE, trigger, proposals, new Map(job.hashes), evidence, id, context.agentName ?? 'Curator')
353
+ if (outcome.operation) writeJson(join(wiki, 'operations', `${outcome.operation.id}.json`), { ...outcome.operation, protected: true })
354
+ job.receipt = { operationId: outcome.operation?.id ?? null, notes: outcome.notes, parked: outcome.parked, flagged: outcome.flagged }
355
+ }
356
+ job.status = job.receipt.parked ? 'needs_user' : 'completed'
357
+ job.reason = reason
358
+ reindexQuietly(root)
359
+ this.options.bus.emit({ type: 'wiki.changed' })
360
+ return saveCuration(root, job)
361
+ }
362
+
363
+ finishRun(runId: string, failed: boolean): void {
364
+ for (const job of this.jobs().filter((job) => job.runId === runId && job.status === 'running')) {
365
+ job.status = failed ? 'failed' : 'completed'
366
+ job.reason = failed ? 'The run stopped before completing this request. Retry it explicitly.' : 'The review completed without applying a change.'
367
+ job.receipt ??= { operationId: null, notes: [job.reason], parked: 0, flagged: 0 }
368
+ saveCuration(this.options.env.root, job)
369
+ }
370
+ }
371
+
372
+ retry(id: string): CurationJob {
373
+ const job = curationJob(this.options.env.root, id)
374
+ if (job.status !== 'failed') throw invalid('Only a failed request can be retried.')
375
+ return saveCuration(this.options.env.root, { ...job, status: 'queued', runId: null, reason: '', receipt: null })
376
+ }
377
+
378
+ prepare(job: Job): { request: MaintenanceRequest; evidence: Evidence; captured: Map<string, string>; namespace: string; trigger: Trigger } {
379
+ const { env } = this.options
204
380
  const root = env.root
205
381
  const wiki = store.wikiDir(root)
206
382
  const evidence: Evidence = {}
@@ -220,9 +396,7 @@ export class Maintenance {
220
396
  text = null
221
397
  }
222
398
  if (text === null) {
223
- inbox(wiki, 'unresolved', `${sourceName} was imported but not summarized`, 'Only text sources can be synthesized automatically right now.', [])
224
- bus.emit({ type: 'wiki.changed' })
225
- return
399
+ throw invalid('Only text sources can be synthesized automatically right now.')
226
400
  }
227
401
  shown = takeChars(text, 60_000)
228
402
  evidence.sourceText = text
@@ -234,8 +408,7 @@ export class Maintenance {
234
408
  try {
235
409
  found = runEvidence(root, job.runId)
236
410
  } catch (error) {
237
- log(`maintenance skipped run ${job.runId}: ${error instanceof Error ? error.message : String(error)}`)
238
- return
411
+ throw error
239
412
  }
240
413
  evidence.runText = evidenceText(root, job.runId)
241
414
  attached = runBlocks(env, job.runId)
@@ -244,7 +417,7 @@ export class Maintenance {
244
417
  trigger = { kind: 'run', runId: job.runId }
245
418
  request = { kind: 'run', runId: job.runId, transcript: shown }
246
419
  } else {
247
- attached = job.blockIds.flatMap((id) => {
420
+ attached = (job.kind === 'review' ? blocks.loadAll(wiki).filter((block) => !block.front.readOnly).sort((a, b) => charCount(b.body) / b.front.limit - charCount(a.body) / a.front.limit).slice(0, 30).map((block) => block.id) : job.blockIds).flatMap((id) => {
248
421
  try {
249
422
  const block = blocks.find(wiki, id)
250
423
  return block.front.readOnly ? [] : [block]
@@ -252,7 +425,7 @@ export class Maintenance {
252
425
  return []
253
426
  }
254
427
  })
255
- if (!attached.length) return
428
+ if (!attached.length && job.kind !== 'review') throw invalid('No writable blocks remain in this job.')
256
429
  namespace = store.LIBRARY_NAMESPACE
257
430
  trigger = { kind: 'consolidate' }
258
431
  request = { kind: 'consolidate' }
@@ -266,12 +439,27 @@ export class Maintenance {
266
439
  } catch {
267
440
  index = ''
268
441
  }
442
+ return { request: { jobId: '', namespace, trigger: request, pages: context.pages, memories: context.memories, blocks: blockValues(attached), namespaces: namespaces(root), index }, evidence, captured, namespace, trigger }
443
+ }
444
+
445
+ private async process(job: Job): Promise<void> {
446
+ const { env, bus } = this.options
447
+ const root = env.root
448
+ const wiki = store.wikiDir(root)
449
+ let prepared: ReturnType<Maintenance['prepare']>
450
+ try { prepared = this.prepare(job) } catch (error) {
451
+ const message = error instanceof Error ? error.message : String(error)
452
+ if (job.kind === 'run') log(`maintenance skipped run ${job.runId}: ${message}`)
453
+ else if (job.kind === 'ingest') { inbox(wiki, 'unresolved', 'Source was imported but not summarized', message, []); bus.emit({ type: 'wiki.changed' }) }
454
+ return
455
+ }
456
+ const { namespace, trigger, evidence, captured } = prepared
269
457
  const described = describeTrigger(trigger)
270
458
  try {
271
459
  // The job runs on the default account.
272
460
  const account = this.options.accounts.context(this.options.accounts.resolve())
273
461
  const proposed = await runMaintenance(
274
- { jobId: newId(), namespace, trigger: request, pages: context.pages, memories: context.memories, blocks: blockValues(attached), namespaces: namespaces(root), index },
462
+ { ...prepared.request, jobId: newId() },
275
463
  { cwd: wiki, product: env.productName, account, runtime: () => this.options.accounts.runtime(account) },
276
464
  this.options.factory ?? piMaintainerFactory
277
465
  )
@@ -8,6 +8,8 @@ import { invalid, notFound } from '../core/errors'
8
8
  import { atomicWrite, newId, now, readJson, safeId, writeJson } from '../core/fs'
9
9
  import { compareText } from '../core/text'
10
10
  import * as store from './store'
11
+ import * as attachments from '../plugins/attachments'
12
+ import { dirname } from 'node:path'
11
13
 
12
14
  export interface OperationPage {
13
15
  pageId: string
@@ -23,6 +25,9 @@ export interface Operation {
23
25
  trigger: string
24
26
  pages: OperationPage[]
25
27
  undone: boolean
28
+ protected?: boolean
29
+ attachmentsBefore?: attachments.Attachments
30
+ attachmentsAfter?: attachments.Attachments
26
31
  }
27
32
 
28
33
  export function operationsDir(wiki: string): string {
@@ -107,7 +112,14 @@ export function undo(wiki: string, operationId: string): Operation {
107
112
  const operation = readJson<Operation>(path)
108
113
  if (!operation) throw notFound('Maintenance operation')
109
114
  if (operation.undone) throw invalid('This operation was already undone.')
115
+ if (operation.protected) {
116
+ for (const page of operation.pages) {
117
+ if (!existsSync(page.path) || readFileSync(page.path, 'utf8') !== store.revisionText(wiki, page.pageId, page.afterRevisionId)) throw invalid('An entry changed after this operation; review it before undoing.')
118
+ }
119
+ if (operation.attachmentsAfter && JSON.stringify(attachments.load(dirname(wiki))) !== JSON.stringify(operation.attachmentsAfter)) throw invalid('Attachment settings changed after this operation; review them before undoing.')
120
+ }
110
121
  const short = operation.id.slice(0, 8)
122
+ if (operation.attachmentsBefore) attachments.save(dirname(wiki), operation.attachmentsBefore)
111
123
  for (const page of operation.pages) {
112
124
  let current = ''
113
125
  try {