@xingular/harness 2.1.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +67 -36
  4. package/dist/cli.js +6023 -6621
  5. package/dist/terminal-pi.js +19 -24
  6. package/docs/terminal-tui.md +27 -7
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/caller.ts +34 -0
  12. package/src/core/fs.ts +17 -1
  13. package/src/harness.ts +55 -66
  14. package/src/{memory → history}/recall.test.ts +1 -1
  15. package/src/{memory → history}/recall.ts +1 -1
  16. package/src/{memory → history}/trajectory.test.ts +4 -4
  17. package/src/{memory → history}/trajectory.ts +1 -1
  18. package/src/hub/hub.test.ts +4 -5
  19. package/src/hub/hub.ts +2 -2
  20. package/src/index.ts +28 -14
  21. package/src/mcp/connections.ts +1 -1
  22. package/src/mcp/service.ts +1 -1
  23. package/src/pi/proxy.test.ts +68 -0
  24. package/src/pi/runtime.test.ts +61 -0
  25. package/src/pi/runtime.ts +50 -0
  26. package/src/plugins/api.ts +11 -9
  27. package/src/plugins/builtin.ts +22 -40
  28. package/src/plugins/catalogue.test.ts +13 -41
  29. package/src/plugins/catalogue.ts +3 -32
  30. package/src/plugins/discover.ts +2 -1
  31. package/src/plugins/firstParty.test.ts +1 -1
  32. package/src/plugins/hooks.test.ts +21 -71
  33. package/src/plugins/hooks.ts +6 -7
  34. package/src/plugins/identity.ts +16 -2
  35. package/src/plugins/install.test.ts +1 -1
  36. package/src/plugins/install.ts +2 -1
  37. package/src/plugins/inventory.ts +19 -18
  38. package/src/plugins/plugins.ts +38 -2
  39. package/src/prompt/compile.test.ts +82 -193
  40. package/src/prompt/compile.ts +98 -363
  41. package/src/refine/cli.ts +114 -0
  42. package/src/refine/demo.ts +124 -0
  43. package/src/refine/evidence.ts +349 -0
  44. package/src/refine/host.ts +102 -0
  45. package/src/refine/ledger.ts +104 -0
  46. package/src/refine/library.ts +192 -0
  47. package/src/refine/pi.test.ts +90 -0
  48. package/src/refine/pi.ts +218 -0
  49. package/src/refine/prompts.ts +430 -0
  50. package/src/refine/recursion.ts +353 -0
  51. package/src/refine/refine.test.ts +140 -0
  52. package/src/refine/runner.ts +205 -0
  53. package/src/refine/selection.ts +291 -0
  54. package/src/refine/service.ts +194 -0
  55. package/src/refine/trajectory.ts +71 -0
  56. package/src/refine/types.ts +91 -0
  57. package/src/server/methods.ts +137 -123
  58. package/src/server/server.test.ts +37 -30
  59. package/src/sessions/contracts.ts +6 -1
  60. package/src/sessions/conversation.test.ts +2 -27
  61. package/src/sessions/policy.ts +1 -1
  62. package/src/sessions/pricing.ts +3 -1
  63. package/src/sessions/registry.ts +5 -1
  64. package/src/sessions/runStore.ts +3 -1
  65. package/src/sessions/runner.ts +10 -5
  66. package/src/sessions/sessions.test.ts +45 -6
  67. package/src/sessions/sessions.ts +40 -17
  68. package/src/sessions/subagentService.test.ts +22 -1
  69. package/src/sessions/subagentService.ts +14 -1
  70. package/src/sessions/subagents.ts +2 -0
  71. package/src/terminal/cli.ts +24 -48
  72. package/src/terminal/pluginControl.test.ts +7 -8
  73. package/src/terminal/socket.ts +3 -3
  74. package/src/terminal/terminal.test.ts +21 -39
  75. package/src/terminal/terminals.ts +20 -13
  76. package/src/terminal/tui/application.test.ts +58 -0
  77. package/src/terminal/tui/application.ts +10 -4
  78. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  79. package/src/terminal/tui/clipboardFiles.ts +86 -0
  80. package/src/terminal/tui/piAdapter.ts +7 -4
  81. package/src/terminal/tui/runtime.ts +2 -0
  82. package/src/tools/definitions.ts +49 -132
  83. package/src/tools/dispatch.ts +78 -0
  84. package/src/tools/harnessTools.ts +23 -0
  85. package/src/tools/spawnSubagent.ts +3 -3
  86. package/src/tools/types.ts +1 -1
  87. package/src/tree/components.ts +221 -0
  88. package/src/tree/materialise.ts +122 -0
  89. package/src/tree/seed.ts +123 -0
  90. package/src/tree/store.ts +193 -0
  91. package/src/tree/surface.ts +135 -0
  92. package/src/tree/tree.test.ts +126 -0
  93. package/src/tree/tree.ts +346 -0
  94. package/src/tree/trust.ts +86 -0
  95. package/src/wiki/notes.ts +313 -0
  96. package/src/memory/api.test.ts +0 -82
  97. package/src/memory/api.ts +0 -303
  98. package/src/memory/blocks.test.ts +0 -78
  99. package/src/memory/blocks.ts +0 -408
  100. package/src/memory/caller.ts +0 -69
  101. package/src/memory/curation.test.ts +0 -206
  102. package/src/memory/curation.ts +0 -64
  103. package/src/memory/dispatch.ts +0 -64
  104. package/src/memory/facts.test.ts +0 -41
  105. package/src/memory/facts.ts +0 -248
  106. package/src/memory/graph.ts +0 -103
  107. package/src/memory/health.test.ts +0 -130
  108. package/src/memory/health.ts +0 -74
  109. package/src/memory/maintainer.ts +0 -272
  110. package/src/memory/maintenance.test.ts +0 -192
  111. package/src/memory/maintenance.ts +0 -679
  112. package/src/memory/migrate.ts +0 -79
  113. package/src/memory/operations.ts +0 -150
  114. package/src/memory/proposals.test.ts +0 -168
  115. package/src/memory/proposals.ts +0 -584
  116. package/src/memory/references.ts +0 -77
  117. package/src/memory/runContext.ts +0 -93
  118. package/src/memory/store.test.ts +0 -66
  119. package/src/memory/store.ts +0 -385
  120. package/src/memory/wiki.test.ts +0 -47
  121. package/src/memory/wiki.ts +0 -423
  122. package/src/memory/wikiIndex.test.ts +0 -88
  123. package/src/memory/wikiIndex.ts +0 -117
  124. package/src/plugins/proposal.ts +0 -121
  125. package/src/skills/registry.test.ts +0 -104
  126. package/src/skills/registry.ts +0 -413
  127. package/src/tools/maintenance.ts +0 -29
  128. package/src/tools/memoryTools.ts +0 -22
  129. package/src/tools/pluginPropose.ts +0 -41
  130. package/src/tools/registryWrite.test.ts +0 -101
  131. package/src/tools/registryWrite.ts +0 -182
  132. /package/src/{memory → core}/secrets.ts +0 -0
@@ -0,0 +1,313 @@
1
+ // The Wiki: plain Markdown notes about projects, linked to each other. It is reference material
2
+ // a person and agents read and write; it is not what shapes an agent (that is the harness tree).
3
+ //
4
+ // <data>/wiki/home.md
5
+ // <data>/wiki/library/**.md notes that belong to no project
6
+ // <data>/wiki/projects/<slug>/**.md a project's notes
7
+ // <data>/wiki-history/<path>/<time>.md earlier versions, one file per save
8
+ //
9
+ // A note's path is its file path without `.md`. Links are `[[path]]`, `[[path|label]]`, or a
10
+ // Markdown link to a relative `.md` file.
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
12
+ import { dirname, join, relative, sep } from 'node:path'
13
+
14
+ import { noteChange } from '../core/changes'
15
+ import { HarnessError, invalid, notFound } from '../core/errors'
16
+ import * as frontmatter from '../core/frontmatter'
17
+ import { atomicWrite, now } from '../core/fs'
18
+ import { projectsOrNone } from '../core/projects'
19
+ import { looksSecret } from '../core/secrets'
20
+
21
+ /** Folders an earlier Wiki kept beside its pages. They are moved aside, never read as notes. */
22
+ export const LEGACY_FOLDERS = ['blocks', 'facts', 'raw', 'revisions', 'inbox', 'operations', 'trash'] as const
23
+ const MAX_NOTE = 400_000
24
+
25
+ export function wikiDir(root: string): string {
26
+ return join(root, 'wiki')
27
+ }
28
+
29
+ function historyDir(root: string): string {
30
+ return join(root, 'wiki-history')
31
+ }
32
+
33
+ export interface NoteSummary {
34
+ path: string
35
+ title: string
36
+ /** The project slug, or null for the Library and Home. */
37
+ project: string | null
38
+ updatedAt: string
39
+ size: number
40
+ }
41
+
42
+ export interface Note extends NoteSummary {
43
+ body: string
44
+ links: string[]
45
+ backlinks: string[]
46
+ }
47
+
48
+ /** Accepts a note path such as `projects/app/decisions` or `library/glossary`. */
49
+ export function cleanPath(path: string): string {
50
+ const clean = String(path ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').replace(/\.md$/i, '')
51
+ const parts = clean.split('/')
52
+ if (!clean || parts.some((part) => !part || part === '.' || part === '..' || part.startsWith('.'))) throw invalid('A note path is a relative path such as projects/<slug>/<name>.')
53
+ if (parts.length === 1 && clean !== 'home') throw invalid('A note lives under library/ or projects/<slug>/.')
54
+ if (parts.length > 1 && parts[0] !== 'library' && parts[0] !== 'projects') throw invalid('A note lives under library/ or projects/<slug>/.')
55
+ if (parts[0] === 'projects' && parts.length < 3) throw invalid('A project note is projects/<slug>/<name>.')
56
+ if ((LEGACY_FOLDERS as readonly string[]).includes(parts[0]!)) throw invalid('That folder is not part of the Wiki.')
57
+ return clean
58
+ }
59
+
60
+ function fileOf(root: string, path: string): string {
61
+ return join(wikiDir(root), `${cleanPath(path)}.md`)
62
+ }
63
+
64
+ function projectOfPath(path: string): string | null {
65
+ const parts = path.split('/')
66
+ return parts[0] === 'projects' ? parts[1] ?? null : null
67
+ }
68
+
69
+ function titleOf(path: string, text: string): string {
70
+ const parsed = safeParse(text)
71
+ const declared = parsed.front?.title
72
+ if (typeof declared === 'string' && declared.trim()) return declared.trim()
73
+ const heading = /^#\s+(.+)$/m.exec(parsed.body)?.[1]?.trim()
74
+ return heading || path.split('/').pop()!.replace(/[-_]/g, ' ')
75
+ }
76
+
77
+ function safeParse(text: string): { front: Record<string, unknown> | null; body: string } {
78
+ try {
79
+ const parsed = frontmatter.parse(text)
80
+ return parsed ? { front: parsed.front, body: parsed.body } : { front: null, body: text }
81
+ } catch {
82
+ return { front: null, body: text }
83
+ }
84
+ }
85
+
86
+ function walk(dir: string, into: string[]): void {
87
+ let names: string[]
88
+ try {
89
+ names = readdirSync(dir)
90
+ } catch {
91
+ return
92
+ }
93
+ for (const name of names.sort()) {
94
+ if (name.startsWith('.')) continue
95
+ const path = join(dir, name)
96
+ let isDir = false
97
+ try {
98
+ isDir = statSync(path).isDirectory()
99
+ } catch {
100
+ continue
101
+ }
102
+ if (isDir) walk(path, into)
103
+ else if (name.endsWith('.md')) into.push(path)
104
+ }
105
+ }
106
+
107
+ /** Every note path, Home first, then the Library, then projects. */
108
+ export function paths(root: string): string[] {
109
+ const wiki = wikiDir(root)
110
+ const files: string[] = []
111
+ if (existsSync(join(wiki, 'home.md'))) files.push(join(wiki, 'home.md'))
112
+ walk(join(wiki, 'library'), files)
113
+ walk(join(wiki, 'projects'), files)
114
+ return files.map((file) => relative(wiki, file).split(sep).join('/').replace(/\.md$/, ''))
115
+ }
116
+
117
+ function summary(root: string, path: string): NoteSummary {
118
+ const file = fileOf(root, path)
119
+ const text = readFileSync(file, 'utf8')
120
+ return { path, title: titleOf(path, text), project: projectOfPath(path), updatedAt: statSync(file).mtime.toISOString(), size: text.length }
121
+ }
122
+
123
+ export function list(root: string, options: { project?: string | null } = {}): NoteSummary[] {
124
+ return paths(root)
125
+ .filter((path) => options.project === undefined || projectOfPath(path) === options.project)
126
+ .map((path) => summary(root, path))
127
+ }
128
+
129
+ const WIKI_LINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g
130
+ const MD_LINK = /\[[^\]]*\]\(([^)\s]+\.md)(?:#[^)]*)?\)/g
131
+
132
+ /** Resolves a link written in `from` to a note path, or null when it points nowhere known. */
133
+ export function resolveLink(target: string, from: string, known: ReadonlySet<string>, titles: ReadonlyMap<string, string>): string | null {
134
+ const raw = target.trim().replace(/\.md$/i, '')
135
+ if (!raw || /^[a-z]+:/i.test(raw)) return null
136
+ const candidates: string[] = [raw.replace(/^\/+/, '')]
137
+ if (!raw.startsWith('/')) {
138
+ const base = from.split('/').slice(0, -1)
139
+ const parts = [...base]
140
+ for (const part of raw.split('/')) {
141
+ if (part === '..') parts.pop()
142
+ else if (part && part !== '.') parts.push(part)
143
+ }
144
+ candidates.unshift(parts.join('/'))
145
+ const project = projectOfPath(from)
146
+ if (project) candidates.push(`projects/${project}/${raw}`)
147
+ candidates.push(`library/${raw}`)
148
+ }
149
+ for (const candidate of candidates) if (known.has(candidate)) return candidate
150
+ return titles.get(raw.toLowerCase()) ?? null
151
+ }
152
+
153
+ function linksIn(body: string, from: string, known: ReadonlySet<string>, titles: ReadonlyMap<string, string>): string[] {
154
+ const out = new Set<string>()
155
+ for (const match of body.matchAll(WIKI_LINK)) {
156
+ const found = resolveLink(match[1]!, from, known, titles)
157
+ if (found && found !== from) out.add(found)
158
+ }
159
+ for (const match of body.matchAll(MD_LINK)) {
160
+ const found = resolveLink(match[1]!, from, known, titles)
161
+ if (found && found !== from) out.add(found)
162
+ }
163
+ return [...out].sort()
164
+ }
165
+
166
+ export interface Graph {
167
+ nodes: NoteSummary[]
168
+ edges: { from: string; to: string }[]
169
+ }
170
+
171
+ /** Every note and every resolved link between notes. */
172
+ export function graph(root: string): Graph {
173
+ const nodes = list(root)
174
+ const known = new Set(nodes.map((node) => node.path))
175
+ const titles = new Map(nodes.map((node) => [node.title.toLowerCase(), node.path]))
176
+ const edges: Graph['edges'] = []
177
+ for (const node of nodes) {
178
+ const body = readFileSync(fileOf(root, node.path), 'utf8')
179
+ for (const to of linksIn(body, node.path, known, titles)) edges.push({ from: node.path, to })
180
+ }
181
+ return { nodes, edges }
182
+ }
183
+
184
+ export function read(root: string, path: string): Note {
185
+ const clean = cleanPath(path)
186
+ const file = fileOf(root, clean)
187
+ if (!existsSync(file)) throw notFound(`The note ${clean}`)
188
+ const text = readFileSync(file, 'utf8')
189
+ const { nodes, edges } = graph(root)
190
+ const known = new Set(nodes.map((node) => node.path))
191
+ const titles = new Map(nodes.map((node) => [node.title.toLowerCase(), node.path]))
192
+ return {
193
+ ...summary(root, clean),
194
+ body: text,
195
+ links: linksIn(text, clean, known, titles),
196
+ backlinks: edges.filter((edge) => edge.to === clean).map((edge) => edge.from).sort()
197
+ }
198
+ }
199
+
200
+ /** Writes a note, keeping the earlier text as a version. Returns the note as saved. */
201
+ export function write(root: string, path: string, body: string, options: { expectedUpdatedAt?: string | null; actor?: string; reason?: string } = {}): Note {
202
+ const clean = cleanPath(path)
203
+ if (typeof body !== 'string') throw invalid('A note needs text.')
204
+ if (body.length > MAX_NOTE) throw invalid(`A note may hold at most ${MAX_NOTE.toLocaleString('en-US')} characters.`)
205
+ if (looksSecret(body)) throw new HarnessError('secret_refused', 'That text looks like it contains a credential, so it was not saved.')
206
+ const project = projectOfPath(clean)
207
+ if (project && !projectsOrNone(root).some((known) => known.slug === project)) throw invalid(`There is no project with the slug ${project}.`)
208
+ const file = fileOf(root, clean)
209
+ if (existsSync(file)) {
210
+ const current = statSync(file).mtime.toISOString()
211
+ if (options.expectedUpdatedAt && options.expectedUpdatedAt !== current) throw new HarnessError('conflict', 'The note changed since it was opened. Reload it and try again.')
212
+ const previous = readFileSync(file, 'utf8')
213
+ if (previous === body) return read(root, clean)
214
+ atomicWrite(join(historyDir(root), clean, `${now().replace(/[:.]/g, '-')}.md`), previous)
215
+ }
216
+ mkdirSync(dirname(file), { recursive: true })
217
+ atomicWrite(file, body)
218
+ noteChange({ wiki: wikiDir(root), id: clean, path: file, text: body, actor: options.actor ?? 'user', reason: options.reason ?? 'Saved' })
219
+ return read(root, clean)
220
+ }
221
+
222
+ /** Removes a note; its last text is kept as a version. */
223
+ export function remove(root: string, path: string): void {
224
+ const clean = cleanPath(path)
225
+ const file = fileOf(root, clean)
226
+ if (!existsSync(file)) throw notFound(`The note ${clean}`)
227
+ atomicWrite(join(historyDir(root), clean, `${now().replace(/[:.]/g, '-')}.md`), readFileSync(file, 'utf8'))
228
+ rmSync(file)
229
+ }
230
+
231
+ export interface Version {
232
+ id: string
233
+ savedAt: string
234
+ }
235
+
236
+ export function versions(root: string, path: string): Version[] {
237
+ const dir = join(historyDir(root), cleanPath(path))
238
+ try {
239
+ return readdirSync(dir).filter((name) => name.endsWith('.md')).sort().reverse().map((name) => ({ id: name.replace(/\.md$/, ''), savedAt: statSync(join(dir, name)).mtime.toISOString() }))
240
+ } catch {
241
+ return []
242
+ }
243
+ }
244
+
245
+ export function version(root: string, path: string, id: string): string {
246
+ if (!/^[0-9A-Za-z-]+$/.test(id)) throw invalid('Invalid version.')
247
+ const file = join(historyDir(root), cleanPath(path), `${id}.md`)
248
+ if (!existsSync(file)) throw notFound('That version')
249
+ return readFileSync(file, 'utf8')
250
+ }
251
+
252
+ export interface Hit {
253
+ path: string
254
+ title: string
255
+ project: string | null
256
+ excerpt: string
257
+ score: number
258
+ }
259
+
260
+ /** Notes that contain the query's words, best first. The current project's notes rank higher. */
261
+ export function search(root: string, query: string, options: { project?: string | null; limit?: number } = {}): Hit[] {
262
+ const words = [...new Set(String(query ?? '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word.length > 1))].slice(0, 16)
263
+ if (!words.length) return []
264
+ const hits: Hit[] = []
265
+ for (const path of paths(root)) {
266
+ const text = readFileSync(fileOf(root, path), 'utf8')
267
+ const lower = text.toLowerCase()
268
+ const title = titleOf(path, text)
269
+ let score = 0
270
+ for (const word of words) {
271
+ if (title.toLowerCase().includes(word)) score += 5
272
+ if (path.toLowerCase().includes(word)) score += 2
273
+ let at = lower.indexOf(word)
274
+ let count = 0
275
+ while (at >= 0 && count < 20) {
276
+ count++
277
+ at = lower.indexOf(word, at + word.length)
278
+ }
279
+ score += count
280
+ }
281
+ if (!score) continue
282
+ const project = projectOfPath(path)
283
+ if (options.project && project === options.project) score *= 1.5
284
+ const first = Math.max(0, Math.min(...words.map((word) => lower.indexOf(word)).filter((at) => at >= 0)))
285
+ const excerpt = text.slice(Math.max(0, first - 80), first + 160).replace(/\s+/g, ' ').trim()
286
+ hits.push({ path, title, project, excerpt, score })
287
+ }
288
+ return hits.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, Math.min(Math.max(options.limit ?? 10, 1), 50))
289
+ }
290
+
291
+ /** Creates the Wiki's folders and Home note. Existing files are never replaced. */
292
+ export function ensureLayout(root: string, productName: string): void {
293
+ const wiki = wikiDir(root)
294
+ mkdirSync(join(wiki, 'library'), { recursive: true })
295
+ mkdirSync(join(wiki, 'projects'), { recursive: true })
296
+ if (!existsSync(join(wiki, 'home.md'))) atomicWrite(join(wiki, 'home.md'), `# Home\n\nNotes about your projects for ${productName}. Link notes with [[library/name]] or [[projects/<slug>/name]].\n`)
297
+ for (const project of projectsOrNone(root)) mkdirSync(join(wiki, 'projects', project.slug), { recursive: true })
298
+ }
299
+
300
+ /**
301
+ * Moves what an earlier Wiki kept beside its notes (memory blocks, facts, sources, revisions,
302
+ * the Inbox) to `<data>/legacy/wiki-<time>/`, once. Nothing is deleted. Returns where it went.
303
+ */
304
+ export function moveLegacy(root: string): string | null {
305
+ const wiki = wikiDir(root)
306
+ const found = LEGACY_FOLDERS.filter((name) => existsSync(join(wiki, name)))
307
+ const loose = ['index.md', 'log.md', 'schema.md'].filter((name) => existsSync(join(wiki, name)))
308
+ if (!found.length && !loose.length) return null
309
+ const dest = join(root, 'legacy', `wiki-${now().replace(/[:.]/g, '-')}`)
310
+ mkdirSync(dest, { recursive: true })
311
+ for (const name of [...found, ...loose]) renameSync(join(wiki, name), join(dest, name))
312
+ return dest
313
+ }
@@ -1,82 +0,0 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { afterEach, expect, test } from 'vitest'
4
-
5
- import { dataRoot, writeFile } from '../../tests/helpers'
6
- import { createEnv, type HarnessEnv } from '../core/env'
7
- import { writeJson } from '../core/fs'
8
- import { closeIndex } from '../core/index'
9
- import { launchText } from '../prompt/compile'
10
- import { list, memories, memoryEdit, propose, read, search, skill } from './api'
11
- import * as blocks from './blocks'
12
- import type { CallerContext } from './caller'
13
- import { findMemory } from './facts'
14
- import { readRunContext } from './runContext'
15
- import { ensureProjectNamespace, wikiDir } from './store'
16
- import { reindexWiki } from './wikiIndex'
17
-
18
- const roots: string[] = []
19
- afterEach(() => roots.splice(0).forEach(closeIndex))
20
-
21
- function setup(): HarnessEnv {
22
- const root = dataRoot()
23
- roots.push(root)
24
- writeJson(join(root, 'projects.json'), [
25
- { id: 'p1', name: 'Xingular Agent', rootPath: '/src/xingular-agent', autoMaintenance: true, createdAt: 't', slug: 'xingular-agent' },
26
- { id: 'p2', name: 'Site', rootPath: '/src/site', autoMaintenance: true, createdAt: 't', slug: 'site' }
27
- ])
28
- ensureProjectNamespace(root, 'xingular-agent', 'Xingular Agent')
29
- ensureProjectNamespace(root, 'site', 'Site')
30
- const wiki = wikiDir(root)
31
- writeFileSync(join(wiki, 'projects/site/deploy.md'), '---\nid: deploy\ntitle: Deploying the site\ntype: decision\nsourceIds: [run:r0]\n---\n\nThe site deploys with wrangler.\n')
32
- writeFileSync(join(wiki, 'facts/pnpm.md'), '---\nid: pnpm\ntitle: Use pnpm\nkind: preference\nnamespace: /\nconfidence: high\n---\n\nUse pnpm, not npm.\n')
33
- writeFileSync(join(wiki, 'facts/site-only.md'), '---\nid: site-only\ntitle: Site uses Astro\nkind: fact\nnamespace: /projects/site\n---\n\nAstro.\n')
34
- writeFile(join(root, 'registry/rules/tests.md'), '---\nname: Run tests\n---\n\nAlways run the tests.\n')
35
- writeFile(join(root, 'registry/skills/review/SKILL.md'), '---\nname: review\ndescription: Review a change\n---\n\nRead the diff first.\n')
36
- writeJson(join(root, 'config/registry-enabled.json'), { global: ['rules/tests'], p1: ['skills/review'] })
37
- mkdirSync(join(root, 'runs/run-1'), { recursive: true })
38
- writeJson(join(root, 'runs/run-1/run.json'), { id: 'run-1', projectId: 'p1', mode: 'code', harness: 'pi', sessionType: 'structured', sessionId: 's1', nativeSessionId: null, workingDirectory: null, worktreeId: null, goal: 'Please keep commits small from now on.', startedAt: 't', endedAt: null, status: 'running' })
39
- reindexWiki(root)
40
- return createEnv(root)
41
- }
42
-
43
- const context = (project: string): CallerContext => ({ runId: 'run-1', projectId: project, actor: 'Code' })
44
-
45
- test('sessions edit their project brief through memory calls', () => {
46
- const env = setup()
47
- expect(memoryEdit(env, context('p1'), 'memory.append', { block: 'project', text: 'Tauri 2 desktop app.' })).toContain('project/xingular-agent')
48
- expect(launchText(env, context('p1'))).toContain('Tauri 2 desktop app.')
49
- expect(() => memoryEdit(env, context('p1'), 'memory.append', { block: 'user', text: 'x' })).toThrow()
50
- // A session reads its project's brief by id, but never an agent's blocks.
51
- expect(read(env, context('p1'), { id: 'blocks/projects/xingular-agent/brief' })).toContain('Tauri 2 desktop app.')
52
- blocks.ensureAgent(wikiDir(env.root), 'a1')
53
- expect(() => read(env, context('p1'), { id: 'blocks/agents/a1/persona' })).toThrow()
54
- expect(() => memoryEdit(env, { actor: 'Chat' }, 'memory.append', { block: 'user', text: 'x' })).toThrow()
55
- expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'blocks/projects/xingular-agent/brief' && entry.via === 'edit')).toBe(true)
56
- })
57
-
58
- test('agents read across project namespaces', () => {
59
- const env = setup()
60
- const hits = search(env, context('p1'), { query: 'wrangler' })
61
- expect(hits).toContain('Deploying the site')
62
- expect(hits).toContain('/projects/site/deploy')
63
- const page = read(env, context('p1'), { id: '/projects/site/deploy' })
64
- expect(page).toContain('The site deploys with wrangler.')
65
- expect(page).toContain('Sources: run:r0')
66
- expect(skill(env, context('p2'), { name: 'review' })).toContain('Read the diff first.')
67
- expect(memories(env, context('p1'), { namespace: '/projects/site' })).toContain('Astro')
68
- expect(list(env, {})).toContain('/projects/site')
69
- expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'deploy' && entry.via === 'read')).toBe(true)
70
- })
71
-
72
- test('proposals apply in the project namespace or wait in the inbox', () => {
73
- const env = setup()
74
- const applied = propose(env, context('p1'), { kind: 'memory', title: 'Small commits', type: 'preference', body: 'Keep commits small.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'please keep commits small' }] })
75
- expect(applied).toContain('saved')
76
- expect(findMemory(wikiDir(env.root), 'small-commits').front.namespace).toBe('/projects/xingular-agent')
77
- expect(propose(env, context('p1'), { kind: 'page', title: 'Rumor', body: 'Maybe.' })).toContain('Inbox')
78
- // The caller's own run without a quote from it is not evidence.
79
- expect(propose(env, context('p1'), { kind: 'memory', title: 'Bare', body: 'Unquoted.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current' }] })).toContain('Inbox')
80
- expect(propose(env, context('p1'), { kind: 'skill', title: 'Deploy', body: 'Steps.' })).toContain('waiting in the Wiki Inbox')
81
- expect(existsSync(join(env.root, 'registry/skills/deploy'))).toBe(false)
82
- })