@xingular/harness 0.0.0-stage → 0.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 (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. package/src/web/web.test.ts +141 -0
@@ -0,0 +1,576 @@
1
+ // Validated Wiki updates.
2
+ //
3
+ // Agents and the sleep-time maintainer only *propose* pages, facts, and block rewrites. This
4
+ // module validates every proposal (type, links, evidence, confidence, secrets, concurrent
5
+ // edits), applies the valid ones as one atomic operation with revisions, and sends the rest to
6
+ // the Inbox, where the user can accept them. Proposals to change skills, prompts, or rules
7
+ // always wait in the Inbox.
8
+ import { createHash } from 'node:crypto'
9
+ import { mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
10
+ import { dirname, join, relative } from 'node:path'
11
+ import { z } from 'zod'
12
+
13
+ import { noteChange } from '../core/changes'
14
+ import { HarnessError, invalid, notFound } from '../core/errors'
15
+ import { atomicWrite, newId, now, readJson, safeId, writeJson } from '../core/fs'
16
+ import { charCount, compareText, takeChars } from '../core/text'
17
+ import { acceptPluginProposal, pluginProposalSchema } from '../plugins/proposal'
18
+ import * as registry from '../skills/registry'
19
+ import * as blocks from './blocks'
20
+ import * as facts from './facts'
21
+ import { inbox, operationsDir, type InboxItem, type Operation, type OperationPage } from './operations'
22
+ import { looksSecret } from './secrets'
23
+ import * as store from './store'
24
+ import { LINK_TYPES, PAGE_TYPES, type WikiLink } from './store'
25
+
26
+ // ---- Proposals ------------------------------------------------------------------------------
27
+
28
+ const optionalText = z.string().nullish().transform((value) => value ?? null)
29
+
30
+ const sourceRefSchema = z.object({
31
+ kind: z.string(),
32
+ ref: z.string(),
33
+ /** A passage quoted from the evidence. A run or source reference counts only when this passage is found in it. */
34
+ excerpt: z.string().nullish().transform((value) => value ?? undefined)
35
+ })
36
+ export type SourceRef = z.infer<typeof sourceRefSchema>
37
+
38
+ const linkProposalSchema = z.object({ toId: z.string(), type: z.string() })
39
+ export type LinkProposal = z.infer<typeof linkProposalSchema>
40
+
41
+ export const pageProposalSchema = z.object({
42
+ id: optionalText,
43
+ title: z.string(),
44
+ type: z.string(),
45
+ body: z.string(),
46
+ /** Where a new page is filed (`/library`, `/projects/<slug>`); the job's namespace by default. */
47
+ namespace: optionalText,
48
+ confidence: optionalText,
49
+ sourceRefs: z.array(sourceRefSchema).default([]),
50
+ links: z.array(linkProposalSchema).default([])
51
+ })
52
+ export type PageProposal = z.infer<typeof pageProposalSchema>
53
+
54
+ export const memoryProposalSchema = z.object({
55
+ id: optionalText,
56
+ title: z.string(),
57
+ kind: z.string(),
58
+ body: z.string(),
59
+ /** `/` for everything, or a project namespace. The job's project namespace by default. */
60
+ namespace: optionalText,
61
+ confidence: optionalText,
62
+ supersedes: optionalText,
63
+ volatile: z.boolean().nullish().transform((value) => value ?? null),
64
+ sourceRefs: z.array(sourceRefSchema).default([]),
65
+ links: z.array(linkProposalSchema).default([])
66
+ })
67
+ export type MemoryProposal = z.infer<typeof memoryProposalSchema>
68
+
69
+ /** A rewritten memory block from sleep-time consolidation. */
70
+ const blockProposalSchema = z.object({ block: z.string(), body: z.string(), sourceRefs: z.array(sourceRefSchema).default([]) })
71
+ export type BlockProposal = z.infer<typeof blockProposalSchema>
72
+
73
+ const claimFlagSchema = z.object({ claim: z.string(), kind: z.string(), reason: z.string() })
74
+ export type ClaimFlag = z.infer<typeof claimFlagSchema>
75
+
76
+ export const proposalSetSchema = z.object({
77
+ pages: z.array(pageProposalSchema).default([]),
78
+ memories: z.array(memoryProposalSchema).default([]),
79
+ blocks: z.array(blockProposalSchema).default([]),
80
+ flags: z.array(claimFlagSchema).default([])
81
+ })
82
+ export type ProposalSet = z.infer<typeof proposalSetSchema>
83
+
84
+ export function parseProposalSet(value: unknown): ProposalSet {
85
+ const parsed = proposalSetSchema.safeParse(value ?? {})
86
+ if (!parsed.success) throw new HarnessError('invalid_data', `Stored data could not be read: ${parsed.error.issues[0]?.message ?? 'invalid proposals'}`)
87
+ return parsed.data
88
+ }
89
+
90
+ /** The text proposals are checked against, and the blocks a job may rewrite. */
91
+ export interface Evidence {
92
+ /** The triggering run's request, assistant messages, and tool summaries. */
93
+ runText?: string | null
94
+ /** The imported source's text. */
95
+ sourceText?: string | null
96
+ /** Ids of the blocks attached to the run. */
97
+ blocks?: string[]
98
+ }
99
+
100
+ const MIN_EXCERPT = 12
101
+
102
+ function squash(text: string): string {
103
+ return text.split(/\s+/).filter(Boolean).join(' ').toLowerCase()
104
+ }
105
+
106
+ /**
107
+ * Whether `excerpt` occurs in `text`, ignoring case and runs of whitespace. Short excerpts
108
+ * prove nothing, so they never count.
109
+ */
110
+ export function quotedIn(text: string | null | undefined, excerpt: string | null | undefined): boolean {
111
+ if (typeof text !== 'string' || typeof excerpt !== 'string') return false
112
+ const wanted = squash(excerpt)
113
+ return charCount(wanted) >= MIN_EXCERPT && squash(text).includes(wanted)
114
+ }
115
+
116
+ /** What caused the change; decides which evidence references are valid and who the actor is. */
117
+ export type Trigger =
118
+ | { kind: 'ingest'; sourceId: string; sourceName: string }
119
+ | { kind: 'run'; runId: string }
120
+ /** An agent's `wiki_propose` during a run. */
121
+ | { kind: 'agent'; runId: string | null; actor: string }
122
+ /** The user accepted an Inbox item. */
123
+ | { kind: 'user'; itemId: string }
124
+ /** Idle consolidation of memory blocks that are close to their limit. */
125
+ | { kind: 'consolidate' }
126
+
127
+ export function describeTrigger(trigger: Trigger): string {
128
+ switch (trigger.kind) {
129
+ case 'ingest':
130
+ return `ingest of ${trigger.sourceName} (${trigger.sourceId})`
131
+ case 'run':
132
+ return `run ${trigger.runId}`
133
+ case 'agent':
134
+ return trigger.runId ? `${trigger.actor} agent in run ${trigger.runId}` : `${trigger.actor} agent`
135
+ case 'user':
136
+ return `Inbox item ${trigger.itemId.slice(0, 8)}`
137
+ case 'consolidate':
138
+ return 'consolidation of full memory blocks'
139
+ }
140
+ }
141
+
142
+ function triggerActor(trigger: Trigger): string {
143
+ return trigger.kind === 'agent' ? trigger.actor : trigger.kind === 'user' ? 'user' : 'Sleep-time'
144
+ }
145
+
146
+ function triggerRunId(trigger: Trigger): string | null {
147
+ return trigger.kind === 'run' ? trigger.runId : trigger.kind === 'agent' ? trigger.runId : null
148
+ }
149
+
150
+ // ---- Applying ---------------------------------------------------------------------------------
151
+
152
+ function fileHash(path: string): string | null {
153
+ try {
154
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ function readOrNull(path: string): string | null {
161
+ try {
162
+ return readFileSync(path, 'utf8')
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+
168
+ /** Captures page, fact, and block file hashes when a job starts, to detect edits made while it ran. */
169
+ export function captureHashes(wiki: string): Map<string, string> {
170
+ const hashes = new Map<string, string>()
171
+ const add = (id: string, path: string) => {
172
+ const hash = fileHash(path)
173
+ if (hash) hashes.set(id, hash)
174
+ }
175
+ for (const page of store.loadPages(wiki)) add(page.frontmatter.id, page.path)
176
+ for (const memory of facts.loadMemories(wiki)) add(memory.front.id, memory.path)
177
+ for (const block of blocks.loadAll(wiki)) add(block.id, block.path)
178
+ return hashes
179
+ }
180
+
181
+ /**
182
+ * Rewrites the generated indexes: the root `index.md` lists the namespaces, and each namespace
183
+ * has its own `index.md` listing its pages with one line each. Any page is two steps away.
184
+ */
185
+ export function writeIndex(wiki: string): void {
186
+ const pages = store.loadPages(wiki)
187
+ const memories = facts.loadMemories(wiki)
188
+ const namespaces = [...new Set(pages.map((page) => page.location.namespace).filter((namespace) => namespace !== store.ROOT_NAMESPACE))].sort(compareText)
189
+ let root = '# Wiki index\n\nEach namespace has its own index of pages.\n\n'
190
+ if (!namespaces.length) root += 'No pages yet.\n'
191
+ for (const namespace of namespaces) {
192
+ const own = pages.filter((page) => page.location.namespace === namespace).sort((left, right) => compareText(left.location.path, right.location.path))
193
+ const folder = namespace.replace(/^\/+/, '')
194
+ root += `- [${namespace}](${folder}/index.md) — ${own.length} page${own.length === 1 ? '' : 's'}\n`
195
+ let text = `# Index of ${namespace}\n\n`
196
+ for (const page of own) {
197
+ const inside = relative(join(wiki, folder), page.path)
198
+ text += `- [${page.frontmatter.title}](${inside.startsWith('..') ? page.path : inside}) (${page.frontmatter.type}, \`${page.frontmatter.id}\`) — ${store.firstParagraph(page.body)}\n`
199
+ }
200
+ atomicWrite(join(wiki, folder, 'index.md'), text)
201
+ }
202
+ const active = memories.filter((memory) => memory.front.status === 'active')
203
+ if (active.length) {
204
+ root += '\n## Facts\n\n'
205
+ for (const memory of active) root += `- [${memory.front.title}](facts/${memory.front.id}.md) (${memory.front.kind}, ${memory.front.namespace}${memory.front.volatile ? ', volatile' : ''})\n`
206
+ }
207
+ atomicWrite(join(wiki, 'index.md'), root)
208
+ }
209
+
210
+ export interface ApplyOutcome {
211
+ operation: Operation | null
212
+ parked: number
213
+ flagged: number
214
+ /** One line per proposal: what happened to it. */
215
+ notes: string[]
216
+ }
217
+
218
+ interface Write {
219
+ entryId: string
220
+ path: string
221
+ oldText: string | null
222
+ newText: string
223
+ }
224
+
225
+ /** Validates proposals and applies the valid ones as one operation. Pure file logic. */
226
+ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence): ApplyOutcome {
227
+ const existing = store.loadPages(wiki)
228
+ const existingMemories = facts.loadMemories(wiki)
229
+ const root = dirname(wiki)
230
+ const sourceIds = new Set(store.listSources(wiki).map((source) => source.id))
231
+ const knownIds = new Set<string>([
232
+ ...existing.map((page) => page.frontmatter.id),
233
+ ...existingMemories.map((memory) => memory.front.id),
234
+ ...registry.list(root).map((item) => item.id),
235
+ ...sourceIds
236
+ ])
237
+ const proposedIds = new Set<string>([
238
+ ...proposals.pages.map((page) => store.slug(page.id ?? page.title)),
239
+ ...proposals.memories.map((memory) => store.slug(memory.id ?? memory.title))
240
+ ])
241
+ const outcome: ApplyOutcome = { operation: null, parked: 0, flagged: 0, notes: [] }
242
+ const writes: Write[] = []
243
+ const actor = triggerActor(trigger)
244
+ const described = describeTrigger(trigger)
245
+ const fromUser = trigger.kind === 'user'
246
+
247
+ // Sends one proposal to the Inbox with its payload, so the user can accept it later.
248
+ const park = (kind: string, title: string, label: string, reason: string, body: string, entryId: string, payload: unknown): void => {
249
+ outcome.parked += 1
250
+ outcome.notes.push(`${label} went to the Inbox: ${reason}`)
251
+ inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, payload)
252
+ }
253
+
254
+ // Evidence: a source in the Wiki, the triggering run, a URL, or the user's acceptance. A run
255
+ // or an imported source counts only with an excerpt found in its text; a bare reference to
256
+ // the caller's own run proves nothing.
257
+ const evidenceFor = (refs: readonly SourceRef[]): string[] => {
258
+ const found: string[] = []
259
+ for (const reference of refs) {
260
+ let id: string | null = null
261
+ if (reference.kind === 'source') {
262
+ if (sourceIds.has(reference.ref) && (evidence.sourceText == null || quotedIn(evidence.sourceText, reference.excerpt))) id = reference.ref
263
+ } else if (reference.kind === 'run') {
264
+ if (triggerRunId(trigger) === reference.ref && quotedIn(evidence.runText, reference.excerpt)) id = `run:${reference.ref}`
265
+ } else if (reference.kind === 'url') {
266
+ if (reference.ref.startsWith('https://') || reference.ref.startsWith('http://')) id = `url:${reference.ref}`
267
+ } else if (reference.kind === 'user') {
268
+ if (fromUser) id = 'user:accepted'
269
+ }
270
+ if (id && !found.includes(id)) found.push(id)
271
+ }
272
+ return found
273
+ }
274
+ const filterLinks = (links: readonly LinkProposal[], ownId: string): WikiLink[] =>
275
+ links
276
+ .filter((link) => (LINK_TYPES as readonly string[]).includes(link.type))
277
+ .filter((link) => knownIds.has(link.toId) || proposedIds.has(link.toId))
278
+ .filter((link) => link.toId !== ownId)
279
+ .map((link) => ({ fromId: ownId, toId: link.toId, type: link.type }))
280
+ const changedSince = (id: string, path: string): boolean => captured.has(id) && captured.get(id) !== fileHash(path)
281
+
282
+ for (const proposal of proposals.pages) {
283
+ const pageId = store.slug(proposal.id ?? proposal.title)
284
+ const current = existing.find((page) => page.frontmatter.id === pageId)
285
+ const namespace = current?.location.namespace ?? proposal.namespace ?? defaultNamespace
286
+ const payload = { kind: 'page', namespace, proposal, trigger: described }
287
+ const label = `Page "${proposal.title}"`
288
+ const blocked = `Proposed page "${proposal.title}" was not applied`
289
+
290
+ if (!(PAGE_TYPES as readonly string[]).includes(proposal.type) || !proposal.title.trim() || !proposal.body.trim()) {
291
+ park('blocked', blocked, label, 'The proposal was incomplete (type, title, or body).', proposal.body, pageId, payload)
292
+ continue
293
+ }
294
+ if (looksSecret(proposal.body) || looksSecret(proposal.title)) {
295
+ // Never stored, not even in the Inbox payload.
296
+ outcome.parked += 1
297
+ outcome.notes.push(`Page "${proposal.title}" was rejected: it contains text that looks like a credential.`)
298
+ inbox(wiki, 'blocked', blocked, 'The proposal contained text that looks like a credential, so it was not written.', [pageId])
299
+ continue
300
+ }
301
+ let targetDir: string | null = null
302
+ if (!current) {
303
+ try {
304
+ targetDir = store.namespaceDir(wiki, namespace)
305
+ } catch (error) {
306
+ park('blocked', blocked, label, error instanceof Error ? error.message : String(error), proposal.body, pageId, payload)
307
+ continue
308
+ }
309
+ }
310
+ // Consolidating a run never creates a page: one run is an episode, not an entity.
311
+ if (!current && (trigger.kind === 'run' || trigger.kind === 'consolidate')) {
312
+ park('unresolved', `"${proposal.title}" would be a new page`, label, 'Sleep-time consolidation updates existing pages only. Accept this to create the page.', proposal.body, pageId, payload)
313
+ continue
314
+ }
315
+ const found = evidenceFor(proposal.sourceRefs)
316
+ if (!found.length) {
317
+ park('unresolved', `"${proposal.title}" has no verifiable evidence`, label, 'The proposal quotes nothing the host could find in its source or run, so it was not written.', proposal.body, pageId, payload)
318
+ continue
319
+ }
320
+ if (proposal.confidence === 'low' && !fromUser) {
321
+ park('unresolved', `"${proposal.title}" is low confidence`, label, 'Low-confidence proposals wait for review.', proposal.body, pageId, payload)
322
+ continue
323
+ }
324
+ // An entry edited while the job ran is never overwritten.
325
+ if (current && changedSince(pageId, current.path)) {
326
+ park('blocked', blocked, label, 'The page changed while the update was running, so the proposal was parked instead of overwriting the edit.', proposal.body, pageId, payload)
327
+ continue
328
+ }
329
+
330
+ const links = filterLinks(proposal.links, pageId)
331
+ const contradicted = links.filter((link) => link.type === 'contradicts')
332
+ if (contradicted.length) {
333
+ inbox(wiki, 'unresolved', `"${proposal.title}" contradicts another entry`, 'The page was written with an explicit contradicts link. Review both claims and their sources.', [pageId, ...contradicted.map((link) => link.toId)])
334
+ outcome.flagged += 1
335
+ }
336
+ const sourceList = current ? [...current.frontmatter.sourceIds] : []
337
+ for (const id of found) if (!sourceList.includes(id)) sourceList.push(id)
338
+ const front = { id: pageId, title: proposal.title.trim(), type: proposal.type, updatedAt: now(), sourceIds: sourceList, links }
339
+ const path = current?.path ?? join(targetDir!, `${pageId}.md`)
340
+ outcome.notes.push(`Page "${proposal.title}" (${pageId}) ${current ? 'updated' : 'created'} in ${namespace}.`)
341
+ writes.push({ entryId: pageId, oldText: current ? readOrNull(current.path) : null, newText: store.serializePage(front, proposal.body), path })
342
+ }
343
+
344
+ const projectDefault = store.projectNamespaceOf(defaultNamespace) ?? store.ROOT_NAMESPACE
345
+ for (const proposal of proposals.memories) {
346
+ const memoryId = store.slug(proposal.id ?? proposal.title)
347
+ const current = existingMemories.find((memory) => memory.front.id === memoryId)
348
+ const payload = { kind: 'memory', proposal, trigger: described }
349
+ const label = `Fact "${proposal.title}"`
350
+ const title = `Fact "${proposal.title}" needs review`
351
+ if (looksSecret(proposal.body) || looksSecret(proposal.title)) {
352
+ outcome.parked += 1
353
+ outcome.notes.push(`Fact "${proposal.title}" was rejected: it contains text that looks like a credential.`)
354
+ inbox(wiki, 'blocked', `Fact "${proposal.title}" was not saved`, 'The proposal contained text that looks like a credential, so it was not written.', [])
355
+ continue
356
+ }
357
+ const front: facts.MemoryFront = {
358
+ id: memoryId,
359
+ title: proposal.title.trim(),
360
+ kind: proposal.kind,
361
+ namespace: current?.front.namespace ?? proposal.namespace ?? projectDefault,
362
+ sourceIds: current ? [...current.front.sourceIds] : [],
363
+ confidence: proposal.confidence ?? 'medium',
364
+ status: 'active',
365
+ supersedes: proposal.supersedes && existingMemories.some((memory) => memory.front.id === proposal.supersedes) ? proposal.supersedes : null,
366
+ links: filterLinks(proposal.links, memoryId),
367
+ createdAt: current?.front.createdAt || now(),
368
+ updatedAt: now(),
369
+ observedAt: now(),
370
+ volatile: proposal.volatile ?? current?.front.volatile ?? false
371
+ }
372
+ const problem = facts.validate(front, proposal.body)
373
+ if (problem) {
374
+ park('blocked', title, label, problem, proposal.body, memoryId, payload)
375
+ continue
376
+ }
377
+ const found = evidenceFor(proposal.sourceRefs)
378
+ if (!found.length) {
379
+ park('unresolved', title, label, 'The fact quotes nothing the host could find in its source or run, so it was not saved.', proposal.body, memoryId, payload)
380
+ continue
381
+ }
382
+ if (front.confidence === 'low' && !fromUser) {
383
+ park('unresolved', title, label, 'Low-confidence facts wait for review.', proposal.body, memoryId, payload)
384
+ continue
385
+ }
386
+ if (current && changedSince(memoryId, current.path)) {
387
+ park('blocked', title, label, 'The fact changed while the update was running, so the proposal was parked.', proposal.body, memoryId, payload)
388
+ continue
389
+ }
390
+ for (const id of found) if (!front.sourceIds.includes(id)) front.sourceIds.push(id)
391
+ // A superseding fact retires the one it replaces in the same operation.
392
+ const old = front.supersedes ? existingMemories.find((memory) => memory.front.id === front.supersedes) : undefined
393
+ if (old) {
394
+ writes.push({ entryId: old.front.id, path: old.path, oldText: readOrNull(old.path), newText: facts.serialize({ ...old.front, status: 'superseded', updatedAt: now() }, old.body) })
395
+ }
396
+ const path = current?.path ?? facts.pathFor(wiki, memoryId)
397
+ outcome.notes.push(`Fact "${proposal.title}" (${memoryId}) ${current ? 'updated' : 'saved'}.`)
398
+ writes.push({ entryId: memoryId, oldText: current ? readOrNull(current.path) : null, newText: facts.serialize(front, proposal.body), path })
399
+ }
400
+
401
+ const attachedBlocks = evidence.blocks ?? []
402
+ for (const proposal of proposals.blocks) {
403
+ const refuse = (reason: string): void => {
404
+ outcome.parked += 1
405
+ outcome.notes.push(`Block ${proposal.block} was not rewritten: ${reason}`)
406
+ inbox(wiki, 'unresolved', `A rewrite of ${proposal.block} was not applied`, `${reason}\n\n${takeChars(proposal.body, 600)}`, [proposal.block])
407
+ }
408
+ let block: blocks.Block
409
+ try {
410
+ block = blocks.find(wiki, proposal.block)
411
+ } catch {
412
+ refuse('there is no such memory block.')
413
+ continue
414
+ }
415
+ const body = proposal.body.trim()
416
+ if (!attachedBlocks.includes(block.id) || block.front.readOnly) {
417
+ refuse('the block is not attached to this run, or it is read-only.')
418
+ continue
419
+ }
420
+ const problem = blocks.check(block.front, body)
421
+ if (problem) {
422
+ refuse(problem)
423
+ continue
424
+ }
425
+ // A full block is only condensed: the rewrite must be shorter and add no evidence.
426
+ const grounded = trigger.kind === 'consolidate' ? body !== '' && charCount(body) < charCount(block.body) : evidenceFor(proposal.sourceRefs).length > 0
427
+ if (!grounded) {
428
+ refuse(trigger.kind === 'consolidate' ? 'a consolidated block must be shorter than it was.' : 'it quotes nothing the host could find in the run.')
429
+ continue
430
+ }
431
+ if (changedSince(block.id, block.path)) {
432
+ refuse('the block changed while the update was running.')
433
+ continue
434
+ }
435
+ if (body === block.body.trim()) continue
436
+ outcome.notes.push(`Block ${block.front.label} rewritten.`)
437
+ writes.push({ entryId: block.id, oldText: readOrNull(block.path), newText: blocks.serializeBlock({ ...block.front, updatedAt: now() }, body), path: block.path })
438
+ }
439
+
440
+ for (const flag of proposals.flags) {
441
+ inbox(wiki, flag.kind === 'contradiction' ? 'blocked' : 'unresolved', `Flagged claim: ${takeChars(flag.claim, 120)}`, `${flag.reason} (${flag.kind})`, [])
442
+ outcome.flagged += 1
443
+ }
444
+
445
+ if (!writes.length) {
446
+ store.log(wiki, `- ${now()} · ${actor} · ${described} · nothing changed · ${outcome.parked} parked · ${outcome.flagged} flagged`)
447
+ return outcome
448
+ }
449
+
450
+ // Apply everything or nothing: on any failure, restore what was already written.
451
+ writes.forEach((write, index) => {
452
+ try {
453
+ atomicWrite(write.path, write.newText)
454
+ } catch (error) {
455
+ for (const done of writes.slice(0, index)) {
456
+ try {
457
+ if (done.oldText === null) rmSync(done.path, { force: true })
458
+ else atomicWrite(done.path, done.oldText)
459
+ } catch {
460
+ // Best effort: the original failure is what the caller needs to see.
461
+ }
462
+ }
463
+ throw error
464
+ }
465
+ })
466
+
467
+ const operationId = newId()
468
+ const short = operationId.slice(0, 8)
469
+ const pages: OperationPage[] = writes.map((write) => ({
470
+ pageId: write.entryId,
471
+ path: write.path,
472
+ beforeRevisionId: write.oldText === null ? null : store.snapshot(wiki, write.entryId, write.oldText, actor, `Before update ${short}`).id,
473
+ afterRevisionId: store.snapshot(wiki, write.entryId, write.newText, actor, `Update ${short} (${described})`).id
474
+ }))
475
+ const operation: Operation = { id: operationId, createdAt: now(), trigger: described, pages, undone: false }
476
+ writeJson(join(operationsDir(wiki), `${operationId}.json`), operation)
477
+ for (const write of writes) noteChange({ wiki, id: write.entryId, path: write.path, text: write.newText, actor, reason: described })
478
+ writeIndex(wiki)
479
+ store.log(
480
+ wiki,
481
+ `- ${now()} · ${actor} · ${described} · entries ${pages.map((page) => `\`${page.pageId}\``).join(', ')} · revisions ${pages.map((page) => `\`${page.afterRevisionId.slice(0, 8)}\``).join(', ')} · op \`${operationId}\` · ${outcome.parked} parked · ${outcome.flagged} flagged`
482
+ )
483
+ inbox(
484
+ wiki,
485
+ 'applied',
486
+ `Updated ${pages.length} entr${pages.length === 1 ? 'y' : 'ies'}`,
487
+ `From ${described}. Undo restores the previous content as new revisions.`,
488
+ pages.map((page) => page.pageId),
489
+ operationId
490
+ )
491
+ outcome.operation = operation
492
+ return outcome
493
+ }
494
+
495
+ // ---- Registry proposals (skills, prompts, rules) -------------------------------------------
496
+
497
+ export const registryProposalSchema = z.object({
498
+ /** `skill`, `prompt`, or `rule`. */
499
+ kind: z.string(),
500
+ name: z.string(),
501
+ description: z.string().default(''),
502
+ body: z.string(),
503
+ reason: z.string().default('')
504
+ })
505
+ export type RegistryProposal = z.infer<typeof registryProposalSchema>
506
+
507
+ /**
508
+ * Skills, prompts, and rules change how every agent behaves, so proposals never apply on
509
+ * their own: they wait in the Inbox until the user accepts them.
510
+ */
511
+ export function parkRegistryProposal(wiki: string, trigger: Trigger, proposal: RegistryProposal): string {
512
+ if (!['skill', 'prompt', 'rule'].includes(proposal.kind) || !proposal.name.trim() || !proposal.body.trim()) {
513
+ throw invalid('A skill, prompt, or rule proposal needs a kind, name, and body.')
514
+ }
515
+ if (looksSecret(proposal.body)) throw invalid('The proposal contains text that looks like a credential.')
516
+ const id = `${proposal.kind}s/${store.slug(proposal.name)}`
517
+ inbox(
518
+ wiki,
519
+ 'proposed',
520
+ `Proposed ${proposal.kind} "${proposal.name.trim()}"`,
521
+ `${proposal.reason.trim() || 'Review the change before it applies to every agent.'}\n\nFrom ${describeTrigger(trigger)}.`,
522
+ [id],
523
+ null,
524
+ { kind: proposal.kind, proposal, trigger: describeTrigger(trigger) }
525
+ )
526
+ store.log(wiki, `- ${now()} · ${triggerActor(trigger)} · proposed \`${id}\` · waiting in Inbox`)
527
+ return id
528
+ }
529
+
530
+ function parsed<T>(schema: z.ZodType<T>, value: unknown): T {
531
+ const result = schema.safeParse(value)
532
+ if (!result.success) throw new HarnessError('invalid_data', `Stored data could not be read: ${result.error.issues[0]?.message ?? 'invalid proposal'}`)
533
+ return result.data
534
+ }
535
+
536
+ /**
537
+ * Accepts a parked proposal: pages and facts go through `apply` with the user as the
538
+ * authority; registry items are written with a revision before and after.
539
+ */
540
+ export function accept(root: string, itemId: string): string {
541
+ const wiki = store.wikiDir(root)
542
+ const path = join(wiki, 'inbox', `${safeId(itemId)}.json`)
543
+ const item = readJson<InboxItem>(path)
544
+ if (!item) throw notFound('Inbox item')
545
+ const payload = item.payload as { kind?: unknown; proposal?: unknown; namespace?: unknown } | undefined | null
546
+ if (!payload) throw invalid('This Inbox item has nothing to accept.')
547
+ const kind = typeof payload.kind === 'string' ? payload.kind : ''
548
+ const trigger: Trigger = { kind: 'user', itemId: item.id }
549
+ const userRef: SourceRef = { kind: 'user', ref: 'accepted', excerpt: undefined }
550
+ let message: string
551
+ if (kind === 'page' || kind === 'memory') {
552
+ const set: ProposalSet = { pages: [], memories: [], blocks: [], flags: [] }
553
+ if (kind === 'page') {
554
+ const proposal = parsed(pageProposalSchema, payload.proposal)
555
+ set.pages.push({ ...proposal, sourceRefs: [...proposal.sourceRefs, userRef] })
556
+ } else {
557
+ const proposal = parsed(memoryProposalSchema, payload.proposal)
558
+ set.memories.push({ ...proposal, sourceRefs: [...proposal.sourceRefs, userRef] })
559
+ }
560
+ const namespace = typeof payload.namespace === 'string' ? payload.namespace : store.LIBRARY_NAMESPACE
561
+ const outcome = apply(wiki, namespace, trigger, set, new Map(), {})
562
+ if (!outcome.operation) throw invalid(outcome.notes.join(' '))
563
+ message = outcome.notes.join(' ')
564
+ } else if (kind === 'skill' || kind === 'prompt' || kind === 'rule') {
565
+ const proposal = parsed(registryProposalSchema, payload.proposal)
566
+ message = `Saved ${registry.writeItem(root, proposal.kind, proposal.name, proposal.description, proposal.body, `Accepted ${describeTrigger(trigger)}`)}.`
567
+ } else if (kind === 'plugin') {
568
+ message = acceptPluginProposal(root, parsed(pluginProposalSchema, payload.proposal))
569
+ } else {
570
+ throw invalid('Unknown proposal kind.')
571
+ }
572
+ const target = join(wiki, 'inbox', 'dismissed')
573
+ mkdirSync(target, { recursive: true })
574
+ renameSync(path, join(target, `${item.id}.json`))
575
+ return message
576
+ }