@xingular/harness 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +70 -37
  4. package/dist/cli.js +6572 -6700
  5. package/dist/terminal-pi.js +51 -38
  6. package/docs/terminal-tui.md +50 -12
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/bus.test.ts +15 -1
  12. package/src/core/bus.ts +39 -0
  13. package/src/core/caller.ts +34 -0
  14. package/src/core/fs.ts +17 -1
  15. package/src/harness.ts +55 -66
  16. package/src/{memory → history}/recall.test.ts +1 -1
  17. package/src/{memory → history}/recall.ts +1 -1
  18. package/src/{memory → history}/trajectory.test.ts +4 -4
  19. package/src/{memory → history}/trajectory.ts +1 -1
  20. package/src/hub/hub.test.ts +4 -5
  21. package/src/hub/hub.ts +2 -2
  22. package/src/index.ts +28 -14
  23. package/src/mcp/connections.ts +1 -1
  24. package/src/mcp/service.ts +1 -1
  25. package/src/pi/proxy.test.ts +68 -0
  26. package/src/pi/runtime.test.ts +61 -0
  27. package/src/pi/runtime.ts +50 -0
  28. package/src/plugins/api.ts +11 -9
  29. package/src/plugins/builtin.ts +22 -40
  30. package/src/plugins/catalogue.test.ts +13 -41
  31. package/src/plugins/catalogue.ts +3 -32
  32. package/src/plugins/discover.ts +2 -1
  33. package/src/plugins/firstParty.test.ts +1 -1
  34. package/src/plugins/hooks.test.ts +21 -71
  35. package/src/plugins/hooks.ts +6 -7
  36. package/src/plugins/identity.ts +16 -2
  37. package/src/plugins/install.test.ts +1 -1
  38. package/src/plugins/install.ts +2 -1
  39. package/src/plugins/inventory.ts +19 -18
  40. package/src/plugins/plugins.ts +38 -2
  41. package/src/prompt/compile.test.ts +82 -193
  42. package/src/prompt/compile.ts +98 -363
  43. package/src/refine/cli.ts +114 -0
  44. package/src/refine/demo.ts +124 -0
  45. package/src/refine/evidence.ts +349 -0
  46. package/src/refine/host.ts +102 -0
  47. package/src/refine/ledger.ts +104 -0
  48. package/src/refine/library.ts +192 -0
  49. package/src/refine/pi.test.ts +90 -0
  50. package/src/refine/pi.ts +218 -0
  51. package/src/refine/prompts.ts +430 -0
  52. package/src/refine/recursion.ts +353 -0
  53. package/src/refine/refine.test.ts +140 -0
  54. package/src/refine/runner.ts +205 -0
  55. package/src/refine/selection.ts +291 -0
  56. package/src/refine/service.ts +194 -0
  57. package/src/refine/trajectory.ts +71 -0
  58. package/src/refine/types.ts +91 -0
  59. package/src/server/methods.ts +137 -123
  60. package/src/server/server.test.ts +37 -30
  61. package/src/sessions/contracts.ts +7 -2
  62. package/src/sessions/conversation.test.ts +2 -27
  63. package/src/sessions/planMode.test.ts +8 -1
  64. package/src/sessions/planMode.ts +17 -9
  65. package/src/sessions/policy.ts +1 -1
  66. package/src/sessions/pricing.ts +3 -1
  67. package/src/sessions/questionBatches.test.ts +15 -3
  68. package/src/sessions/registry.ts +13 -1
  69. package/src/sessions/runStore.ts +3 -1
  70. package/src/sessions/runner.ts +10 -5
  71. package/src/sessions/sessions.test.ts +60 -13
  72. package/src/sessions/sessions.ts +43 -18
  73. package/src/sessions/subagentService.test.ts +22 -1
  74. package/src/sessions/subagentService.ts +14 -1
  75. package/src/sessions/subagents.ts +2 -0
  76. package/src/terminal/cli.ts +24 -48
  77. package/src/terminal/logs.test.ts +26 -0
  78. package/src/terminal/logs.ts +67 -0
  79. package/src/terminal/piExtension.lifecycle.test.ts +1 -1
  80. package/src/terminal/piExtension.ts +3 -3
  81. package/src/terminal/pluginControl.test.ts +7 -8
  82. package/src/terminal/socket.ts +3 -3
  83. package/src/terminal/terminal.test.ts +41 -39
  84. package/src/terminal/terminals.ts +31 -13
  85. package/src/terminal/tui/application.test.ts +58 -0
  86. package/src/terminal/tui/application.ts +15 -4
  87. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  88. package/src/terminal/tui/clipboardFiles.ts +86 -0
  89. package/src/terminal/tui/commands.test.ts +13 -1
  90. package/src/terminal/tui/commands.ts +16 -0
  91. package/src/terminal/tui/logPanel.test.ts +61 -0
  92. package/src/terminal/tui/logPanel.ts +175 -0
  93. package/src/terminal/tui/piAdapter.ts +7 -4
  94. package/src/terminal/tui/questionPicker.ts +168 -0
  95. package/src/terminal/tui/questions.test.ts +86 -19
  96. package/src/terminal/tui/questions.ts +27 -22
  97. package/src/terminal/tui/runtime.ts +2 -0
  98. package/src/tools/askUser.test.ts +23 -2
  99. package/src/tools/askUser.ts +15 -5
  100. package/src/tools/definitions.ts +49 -132
  101. package/src/tools/dispatch.ts +78 -0
  102. package/src/tools/harnessTools.ts +23 -0
  103. package/src/tools/spawnSubagent.ts +3 -3
  104. package/src/tools/types.ts +3 -1
  105. package/src/tree/components.ts +221 -0
  106. package/src/tree/materialise.ts +122 -0
  107. package/src/tree/seed.ts +123 -0
  108. package/src/tree/store.ts +193 -0
  109. package/src/tree/surface.ts +135 -0
  110. package/src/tree/tree.test.ts +126 -0
  111. package/src/tree/tree.ts +346 -0
  112. package/src/tree/trust.ts +86 -0
  113. package/src/web/tools.ts +27 -5
  114. package/src/web/web.test.ts +26 -1
  115. package/src/wiki/notes.ts +313 -0
  116. package/src/memory/api.test.ts +0 -82
  117. package/src/memory/api.ts +0 -303
  118. package/src/memory/blocks.test.ts +0 -78
  119. package/src/memory/blocks.ts +0 -408
  120. package/src/memory/caller.ts +0 -69
  121. package/src/memory/curation.test.ts +0 -206
  122. package/src/memory/curation.ts +0 -64
  123. package/src/memory/dispatch.ts +0 -64
  124. package/src/memory/facts.test.ts +0 -41
  125. package/src/memory/facts.ts +0 -248
  126. package/src/memory/graph.ts +0 -103
  127. package/src/memory/health.test.ts +0 -130
  128. package/src/memory/health.ts +0 -74
  129. package/src/memory/maintainer.ts +0 -272
  130. package/src/memory/maintenance.test.ts +0 -192
  131. package/src/memory/maintenance.ts +0 -679
  132. package/src/memory/migrate.ts +0 -79
  133. package/src/memory/operations.ts +0 -150
  134. package/src/memory/proposals.test.ts +0 -168
  135. package/src/memory/proposals.ts +0 -584
  136. package/src/memory/references.ts +0 -77
  137. package/src/memory/runContext.ts +0 -93
  138. package/src/memory/store.test.ts +0 -66
  139. package/src/memory/store.ts +0 -385
  140. package/src/memory/wiki.test.ts +0 -47
  141. package/src/memory/wiki.ts +0 -423
  142. package/src/memory/wikiIndex.test.ts +0 -88
  143. package/src/memory/wikiIndex.ts +0 -117
  144. package/src/plugins/proposal.ts +0 -121
  145. package/src/skills/registry.test.ts +0 -104
  146. package/src/skills/registry.ts +0 -413
  147. package/src/tools/maintenance.ts +0 -29
  148. package/src/tools/memoryTools.ts +0 -22
  149. package/src/tools/pluginPropose.ts +0 -41
  150. package/src/tools/registryWrite.test.ts +0 -101
  151. package/src/tools/registryWrite.ts +0 -182
  152. /package/src/{memory → core}/secrets.ts +0 -0
@@ -1,8 +1,9 @@
1
1
  // Which plugin a found file belongs to, and what it is called there. The list a person sees and
2
2
  // the choice of what loads both use this, so a switch on the page names exactly what stops loading.
3
+ import { readFileSync } from 'node:fs'
3
4
  import { basename, dirname } from 'node:path'
4
5
 
5
- import * as registry from '../skills/registry'
6
+ import * as frontmatter from '../core/frontmatter'
6
7
  import { BUILTIN_PREFIX, type Found } from './discover'
7
8
  import { packageManifest } from './manifest'
8
9
  import type { ContributionKind } from './types'
@@ -24,7 +25,7 @@ export function describeFound(item: Found): { name: string; description: string
24
25
  const file = basename(item.path)
25
26
  if (item.kind === 'extension') return { name: file.startsWith('index.') ? basename(dirname(item.path)) : file, description: null }
26
27
  if (item.kind === 'context' || item.kind === 'theme' || item.kind === 'hook') return { name: file, description: null }
27
- const described = registry.describeFile(item.path)
28
+ const described = describeFile(item.path)
28
29
  return { name: described?.name ?? file, description: described?.description ?? '' }
29
30
  }
30
31
 
@@ -35,3 +36,16 @@ export function foundIds(item: Found): [string, string] {
35
36
  const pluginId = pluginIdOf(item)
36
37
  return [contributionId(pluginId, FOUND_KINDS[item.kind], describeFound(item).name), pluginId]
37
38
  }
39
+
40
+ /** A Markdown file's `name` and `description` frontmatter; a `SKILL.md` is named after its folder. */
41
+ export function describeFile(path: string): { name: string; description: string } | null {
42
+ let text: string
43
+ try {
44
+ text = readFileSync(path, 'utf8')
45
+ } catch {
46
+ return null
47
+ }
48
+ const front = frontmatter.parse(text)?.front ?? {}
49
+ const fallback = basename(path) === 'SKILL.md' ? basename(dirname(path)) : basename(path).replace(/\.md$/, '')
50
+ return { name: typeof front.name === 'string' && front.name.trim() ? front.name.trim() : fallback, description: typeof front.description === 'string' ? front.description.trim() : '' }
51
+ }
@@ -83,7 +83,7 @@ describe('installing a package', () => {
83
83
  it('refuses a folder that is not there, and a plugin that was not installed', async () => {
84
84
  const { base, call } = setup()
85
85
  await expect(call('plugins.install', { source: join(base, 'missing') })).rejects.toThrow('does not exist')
86
- await expect(call('plugins.remove', { pluginId: 'memory' })).rejects.toThrow('was not installed as a package')
86
+ await expect(call('plugins.remove', { pluginId: 'wiki' })).rejects.toThrow('was not installed as a package')
87
87
  await expect(call('plugins.update', { pluginId: 'nope' })).rejects.toThrow()
88
88
  })
89
89
 
@@ -10,6 +10,7 @@ import { ensureSharedDir } from '../accounts/env'
10
10
  import { invalid, notFound } from '../core/errors'
11
11
  import { now } from '../core/fs'
12
12
  import type { Harness } from '../harness'
13
+ import { loadPi } from '../pi/runtime'
13
14
  import { ensureAdopted, list, type PluginScope } from './api'
14
15
  import * as attachments from './attachments'
15
16
  import { withoutInstallScripts } from './discover'
@@ -26,7 +27,7 @@ export function normalizeSource(input: string): string {
26
27
  }
27
28
 
28
29
  async function manager(harness: Harness) {
29
- const pi = await import('@earendil-works/pi-coding-agent')
30
+ const pi = await loadPi()
30
31
  // The shared folder is where `settings.json` really is; an account's own is a link to it.
31
32
  const agentDir = ensureSharedDir(harness.accounts.dir)
32
33
  const settings = pi.SettingsManager.create(harness.stateDir, agentDir)
@@ -7,13 +7,12 @@ import type { HarnessEnv } from '../core/env'
7
7
  import { charCount } from '../core/text'
8
8
  import { allowed } from '../mcp/connections'
9
9
  import type { Connection } from '../mcp/types'
10
- import type { CallerContext } from '../memory/caller'
10
+ import type { CallerContext } from '../core/caller'
11
11
  import { BUILT_IN_DYNAMIC, compile, toolGroups, type Launch } from '../prompt/compile'
12
- import * as registry from '../skills/registry'
12
+ import type { Materialised } from '../tree/materialise'
13
13
  import { ASK_USER } from '../tools/askUser'
14
- import { PLUGIN_PROPOSE } from '../tools/pluginPropose'
15
- import { WIKI_TOOL_NAMES } from '../tools/definitions'
16
- import { INCLUDED_PLUGIN, YOURS_PLUGIN } from './builtin'
14
+ import { DEFAULT_TOOL_NAMES } from '../tools/definitions'
15
+ import { HARNESS_TREE_PLUGIN } from './builtin'
17
16
  import type { Contribution, Inventory, InventoryItem, Plugin, PluginDescriptor } from './types'
18
17
 
19
18
  export interface InventoryInput {
@@ -28,6 +27,8 @@ export interface InventoryInput {
28
27
  plugins: readonly Plugin[]
29
28
  descriptors: readonly PluginDescriptor[]
30
29
  saved: readonly Connection[]
30
+ /** The harness tree the caller runs, when it has one. */
31
+ tree?: Materialised | null
31
32
  }
32
33
 
33
34
  const size = (path: string | null): number | null => {
@@ -47,10 +48,8 @@ export function inventory(input: InventoryInput): Inventory {
47
48
  items.push({ pluginId, contributionId: null, kind, name, delivery, chars, cut: false, ...rest })
48
49
  }
49
50
 
50
- const pack = compile(env, context, launch)
51
+ const pack = compile(env, context, launch, input.tree ?? null)
51
52
  const dynamic = new Set([...BUILT_IN_DYNAMIC.map((entry) => entry.id), ...(launch.sections ?? []).filter((section) => section.dynamic).map((section) => section.id)])
52
- const active = registry.enabledFor(env.root, context.projectId ?? null, context.agentId ?? null)
53
- const owner = (entry: registry.RegistryItem): string => (entry.builtin ? INCLUDED_PLUGIN.id : YOURS_PLUGIN.id)
54
53
  let systemChars = 0
55
54
  let contextChars = 0
56
55
 
@@ -68,26 +67,28 @@ export function inventory(input: InventoryInput): Inventory {
68
67
  }
69
68
  continue
70
69
  }
71
- if (section.id === 'rules') {
72
- for (const rule of active.filter((entry) => entry.kind === 'rules')) {
73
- item(owner(rule), 'instruction', rule.name, delivery, charCount(`### ${rule.name} (${rule.id})\n\n${registry.itemBody(rule).trim()}`), { contributionId: `${owner(rule)}/${rule.id}`, cut: section.cut })
74
- }
75
- continue
76
- }
77
70
  const plugin = descriptors.find((descriptor) => descriptor.sections?.some((declared) => declared.id === section.id))
78
71
  if (!plugin) unattributed.push({ kind: 'section', id: section.id })
79
72
  const name = plugin?.sections?.find((declared) => declared.id === section.id)?.name ?? section.title
80
73
  item(plugin?.id ?? '', 'instruction', name, delivery, section.size, { contributionId: plugin ? `${plugin.id}/instruction/${name}` : null, cut: section.cut })
81
74
  }
82
75
 
83
- // A skill's or a prompt's own text is loaded only when it is used.
84
- for (const entry of active.filter((candidate) => candidate.kind === 'skills' || candidate.kind === 'prompts')) {
85
- item(owner(entry), entry.kind === 'skills' ? 'skill' : 'command', entry.name, 'on-demand', size(entry.path), { contributionId: `${owner(entry)}/${entry.id}` })
76
+ // The harness tree's skills are loaded only when used; its plugins run in the tool loop.
77
+ const tree = input.tree ?? null
78
+ if (tree) {
79
+ for (const rel of Object.keys(tree.tree).filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)).sort()) {
80
+ const name = rel.split('/')[1]!
81
+ item(HARNESS_TREE_PLUGIN.id, 'skill', name, 'on-demand', charCount(tree.tree[rel]!), { contributionId: `${HARNESS_TREE_PLUGIN.id}/skill/${name}` })
82
+ }
83
+ for (const path of tree.extensionPaths) {
84
+ const name = path.split('/').pop()!
85
+ item(HARNESS_TREE_PLUGIN.id, 'code', name, 'code', size(path), { contributionId: `${HARNESS_TREE_PLUGIN.id}/code/${name}` })
86
+ }
86
87
  }
87
88
 
88
89
  const names = [...input.tools]
89
90
  const extensionToolNames = new Set(input.plugins.filter((plugin) => ['project', 'package', 'shared', 'home'].includes(plugin.source)).flatMap((plugin) => plugin.contributions.filter((entry) => entry.kind === 'tool' && entry.state === 'on').map((entry) => entry.name)))
90
- for (const name of [ASK_USER, PLUGIN_PROPOSE, ...WIKI_TOOL_NAMES, ...(input.connections ? ['mcp_list_tools', 'mcp_call_tool'] : [])]) if (!names.includes(name)) names.push(name)
91
+ for (const name of [ASK_USER, ...DEFAULT_TOOL_NAMES, ...(input.connections ? ['mcp_list_tools', 'mcp_call_tool'] : [])]) if (!names.includes(name)) names.push(name)
91
92
  for (const name of names) {
92
93
  if (extensionToolNames.has(name)) continue
93
94
  const plugin = descriptors.find((descriptor) => descriptor.tools?.some((tool) => tool.name === name))
@@ -1,11 +1,10 @@
1
1
  // The harness decides what a Pi session loads. Pi's own discovery is switched off for sessions
2
2
  // and terminals; they receive the paths chosen here, so nothing reaches a model unlisted.
3
- import { chmodSync, readdirSync } from 'node:fs'
3
+ import { chmodSync, lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
 
6
6
  import type { Bus } from '../core/bus'
7
7
  import { now, readJsonOr, writeJson } from '../core/fs'
8
- import { removeLinkedSkills } from '../skills/registry'
9
8
  import { attached, type Attachments, type AttachScope } from './attachments'
10
9
  import { piResolver, type Found, type ResolveInput, type Resolver } from './discover'
11
10
  import { foundIds, pluginIdOf } from './identity'
@@ -187,3 +186,40 @@ export class Plugins {
187
186
  this.options.bus.emit({ type: 'plugins.changed' })
188
187
  }
189
188
  }
189
+
190
+ // Prefix of the entries an earlier version of the harness linked into a Pi agent folder's `skills/`.
191
+ const LINKED_SKILL_PREFIX = 'agentx-'
192
+
193
+ function isLinkedSkill(path: string): boolean {
194
+ let meta
195
+ try {
196
+ meta = lstatSync(path)
197
+ } catch {
198
+ return false
199
+ }
200
+ if (meta.isSymbolicLink()) return true
201
+ if (!meta.isDirectory()) return false
202
+ // A single-file skill was linked as `agentx-<name>/SKILL.md` inside a folder the harness made.
203
+ try {
204
+ const entries = readdirSync(path)
205
+ return entries.length === 1 && entries[0] === 'SKILL.md' && lstatSync(join(path, 'SKILL.md')).isSymbolicLink()
206
+ } catch {
207
+ return false
208
+ }
209
+ }
210
+
211
+ /** Removes the skill links an earlier version of the harness kept in a Pi agent folder's `skills/`. */
212
+ export function removeLinkedSkills(skillsDir: string): void {
213
+ let names: string[]
214
+ try {
215
+ names = readdirSync(skillsDir)
216
+ } catch {
217
+ return
218
+ }
219
+ for (const name of names) {
220
+ const path = join(skillsDir, name)
221
+ if (!name.startsWith(LINKED_SKILL_PREFIX) || !isLinkedSkill(path)) continue
222
+ if (lstatSync(path).isSymbolicLink()) unlinkSync(path)
223
+ else rmSync(path, { recursive: true, force: true })
224
+ }
225
+ }
@@ -3,17 +3,16 @@ import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterEach, expect, test } from 'vitest'
5
5
 
6
- import { dataRoot, writeFile } from '../../tests/helpers'
6
+ import { dataRoot } from '../../tests/helpers'
7
+ import type { CallerContext } from '../core/caller'
7
8
  import { createEnv, type HarnessEnv } from '../core/env'
8
9
  import { writeJson } from '../core/fs'
9
10
  import { closeIndex } from '../core/index'
10
- import * as blocks from '../memory/blocks'
11
- import type { CallerContext } from '../memory/caller'
12
- import * as references from '../memory/references'
13
- import { readRunContext } from '../memory/runContext'
14
- import { ensureProjectNamespace, wikiDir } from '../memory/store'
15
- import { reindexWiki } from '../memory/wikiIndex'
16
- import { compile, fit, launchText, load, memoryDelta, profileFor, profiles, record, REFERENCES_BUDGET, render, saveForSession, sessionFile, sessionLaunch, setProfile, type ContextPack, type ContextUpdate, type HostSection, type Launch } from './compile'
11
+ import { materialise } from '../tree/materialise'
12
+ import * as store from '../tree/store'
13
+ import type { Tree } from '../tree/surface'
14
+ import * as notes from '../wiki/notes'
15
+ import { compile, fit, load, render, sessionFile, sessionLaunch, SECTION_ORDER, type ContextUpdate, type HostSection, type Launch } from './compile'
17
16
 
18
17
  const roots: string[] = []
19
18
  afterEach(() => roots.splice(0).forEach(closeIndex))
@@ -21,33 +20,33 @@ afterEach(() => roots.splice(0).forEach(closeIndex))
21
20
  function setup(): HarnessEnv {
22
21
  const root = dataRoot()
23
22
  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
- const wiki = wikiDir(root)
30
- writeFileSync(join(wiki, 'projects/xingular-agent/decisions.md'), '---\nid: decisions\ntitle: Decisions\ntype: decision\n---\n\nTauri 2 for the shell.\n')
31
- writeFileSync(join(wiki, 'facts/pnpm.md'), '---\nid: pnpm\ntitle: Use pnpm\nkind: preference\nnamespace: /\nconfidence: high\n---\n\nUse pnpm, not npm.\n')
32
- writeFileSync(join(wiki, 'facts/on-call.md'), '---\nid: on-call\ntitle: On call this week\nkind: fact\nnamespace: /\nconfidence: high\nvolatile: true\n---\n\nAstro team is on call.\n')
33
- writeFileSync(join(wiki, 'facts/site-only.md'), '---\nid: site-only\ntitle: Site uses Astro\nkind: fact\nnamespace: /projects/site\n---\n\nAstro renders the site.\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'] })
23
+ writeJson(join(root, 'projects.json'), [{ id: 'p1', name: 'Xingular Agent', rootPath: '/src/xingular-agent', autoMaintenance: true, createdAt: 't', slug: 'xingular-agent' }])
24
+ notes.ensureLayout(root, 'Test')
25
+ notes.write(root, 'projects/xingular-agent/decisions', '---\ntitle: Decisions\n---\n\nTauri 2 for the shell. See [[library/glossary]].\n')
26
+ notes.write(root, 'library/glossary', '# Glossary\n\nA harness is the system around a model.\n')
37
27
  mkdirSync(join(root, 'runs/run-1'), { recursive: true })
38
- reindexWiki(root)
39
28
  return createEnv(root)
40
29
  }
41
30
 
42
- const session = (project: string): CallerContext => ({ runId: 'run-1', sessionId: 's1', projectId: project, actor: 'Code' })
43
- const agent = (extra: Partial<CallerContext> = {}): CallerContext => ({ agentId: 'nora', agentName: 'Nora', actor: 'Chat', ...extra })
44
- const ids = (pack: ContextPack): string[] => pack.sections.map((entry) => entry.id)
31
+ const tree: Tree = {
32
+ 'systemprompt.md': 'You draft release notes.\n',
33
+ 'guardrails.md': 'Never publish without a review.\n- When writing a changelog, load the `changelog` skill.\n',
34
+ 'memory.md': 'Releases ship on Tuesdays.\n',
35
+ 'tool_guidance.md': 'Prefer grep over reading whole files.\n',
36
+ 'skills/changelog/SKILL.md': '---\nname: changelog\ndescription: writing a changelog\n---\nGroup by type.\n',
37
+ 'sub_agents/reader/AGENT.md': '---\ndescription: reads long logs and answers briefly\n---\nRead the log and answer in three lines.\n',
38
+ 'scripts/count.py': 'print(1)\n'
39
+ }
40
+
41
+ const agent = (extra: Partial<CallerContext> = {}): CallerContext => ({ agentId: 'nora', agentName: 'Nora', projectId: 'p1', runId: 'run-1', actor: 'Chat', ...extra })
42
+ const identity: HostSection = { id: 'identity', title: 'Identity', body: 'You are Nora.', budget: 1000, order: 10 }
43
+ const work = (body: string): HostSection => ({ id: 'work', title: 'Active work', body, budget: 1000, order: 70, dynamic: true })
44
+ const hosted = (extra: Partial<Launch> = {}): Launch => ({ sections: [identity, work('You have no unresolved work.')], tools: ['read'], ...extra })
45
45
 
46
- // What an application supplies: who the model is, its role, and what it is working on.
47
- const identity: HostSection = { id: 'identity', title: 'Identity', body: 'You are "Nora", an Xingular Agent agent.', budget: 6000, order: 10 }
48
- const role: HostSection = { id: 'role', title: 'Role', body: 'You coordinate.', budget: 1500, order: 20 }
49
- const work = (body: string | null, pinned?: string): HostSection => ({ id: 'work', title: 'Active work', body, budget: 1500, pointer: '(Call list_work for the full inventory.)', order: 70, dynamic: true, pinned: pinned ? { text: pinned, pointer: '(Call list_work for the full evidence.)' } : null })
50
- const hosted = (extra: Partial<Launch> = {}): Launch => ({ sections: [identity, role, work('You have no unresolved work.\n')], ...extra })
46
+ function scoped(env: HarnessEnv) {
47
+ store.seed(env.root, 'nora', tree)
48
+ return materialise(env.root, { scope: 'nora' }, { subagents: true })
49
+ }
51
50
 
52
51
  test('cuts at a paragraph and points to the rest', () => {
53
52
  expect(fit('short', 100, '(more)')).toEqual({ body: 'short', cut: false })
@@ -56,126 +55,46 @@ test('cuts at a paragraph and points to the rest', () => {
56
55
  expect(cut.body).toBe(`${'a'.repeat(40)}\n\n${'b'.repeat(40)}\n\n(more)`)
57
56
  })
58
57
 
59
- test('selected content is pinned and retained with automatic context off', () => {
60
- const env = setup()
61
- setProfile(env.root, 'nora', 'off')
62
- const revision = references.pin(env.root, 'skill', 'skills/review')
63
- writeFileSync(join(env.root, 'registry/skills/review/SKILL.md'), 'Changed instructions.')
64
- const newer = references.pin(env.root, 'skill', 'skills/review')
65
- const caller = agent({ runId: 'run-1' })
66
- const pack = compile(env, caller, hosted({ references: [{ kind: 'skill', id: 'skills/review', label: 'Review', revision }] }))
67
- expect(ids(pack)).toEqual(['identity', 'role', 'references'])
68
- const selected = pack.sections.find((entry) => entry.id === 'references')!
69
- expect(selected.body).toContain('Read the diff first.')
70
- expect(selected.body).not.toContain('Changed instructions.')
71
- expect(revision).not.toBe(newer)
72
- record(env.root, caller, pack)
73
- expect(references.readPinned(env.root, 'run-1', 'skills/review', revision, 0)).toContain('Read the diff first.')
74
- expect(() => references.readPinned(env.root, 'run-1', 'skills/review', newer, 0)).toThrow()
75
- expect(readRunContext(env.root, 'run-1')[0].revisionId).toBe(revision)
76
- })
77
-
78
- test('application lines sit with the references and large sources keep retrieval pointers', () => {
58
+ test('the harness tree supplies the instructions, guardrails, memory, subagents, scripts and tool guidance, in order', () => {
79
59
  const env = setup()
80
- writeFileSync(join(env.root, 'registry/skills/review/SKILL.md'), 'Review instructions.\n\n'.repeat(2000))
81
- const revision = references.pin(env.root, 'skill', 'skills/review')
82
- const pack = compile(env, agent(), { references: [{ kind: 'skill', id: 'skills/review', label: 'Review', revision }, { kind: 'line', id: 'exact-agent-id', label: 'Duplicate name', text: '- Agent "Duplicate name": exact ID "exact-agent-id". Agent messaging is unavailable in this reply.' }] })
83
- const selected = pack.sections.find((entry) => entry.id === 'references')!
84
- expect(selected.body).toContain('Agent messaging is unavailable')
85
- expect(selected.cut).toBe(true)
86
- expect(selected.size).toBeLessThanOrEqual(REFERENCES_BUDGET)
87
- expect(selected.body).toContain('wiki_read(id="skills/review", revision=')
88
- expect(selected.sources[0].revision).toBe(revision)
89
- })
90
-
91
- test('a session gets blocks, rules, skills, knowledge and tools', () => {
92
- const env = setup()
93
- const pack = compile(env, session('p1'), { connections: 'Use get_capabilities.' })
94
- expect(pack.profile).toBe('session')
95
- expect(ids(pack)).toEqual(['blocks', 'rules', 'skills', 'knowledge', 'tools'])
60
+ const pack = compile(env, agent(), hosted(), scoped(env))
61
+ const ids = pack.sections.map((entry) => entry.id)
62
+ expect(ids).toEqual(['identity', 'system', 'guardrails', 'memory', 'subagents', 'scripts', 'work', 'wiki', 'tools'])
96
63
  const text = render(env, pack)
97
- for (const expected of ['<block label="user"', '<block label="project/xingular-agent"', 'Always run the tests.', 'review (skills/review)', '/projects/xingular-agent/decisions — Decisions', 'Use pnpm', 'App tools and connected apps: Use get_capabilities.']) expect(text).toContain(expected)
98
- expect(text).not.toContain('Astro')
99
- const cli = render(env, compile(env, { ...session('p1'), surface: 'cli' }))
100
- expect(cli).toContain('xagent-wiki search')
101
- expect(cli).toContain('xagent-wiki block append')
64
+ expect(text).toContain('You draft release notes.')
65
+ expect(text).toContain('Never publish without a review.')
66
+ expect(text).toContain('Releases ship on Tuesdays.')
67
+ expect(text).toContain('- reader — reads long logs and answers briefly')
68
+ expect(text).toMatch(/scripts: count\.py/)
69
+ expect(text).toContain('### How to use your tools\n\nPrefer grep over reading whole files.')
70
+ expect(pack.harness).toEqual({ scope: 'nora', fingerprint: store.head(env.root, 'nora')!.fingerprint })
71
+ expect(pack.sections.find((entry) => entry.id === 'system')!.sources).toEqual([{ id: 'nora', kind: 'harness', revision: pack.harness!.fingerprint }])
102
72
  })
103
73
 
104
- test('long rules shorten only the rules section', () => {
74
+ test('without a tree, only the application, the Wiki and the tools speak', () => {
105
75
  const env = setup()
106
- writeFileSync(join(env.root, 'registry/rules/tests.md'), `---\nname: Run tests\n---\n\n${'Always run the tests.\n\n'.repeat(600)}\n`)
107
- const pack = compile(env, session('p1'))
108
- const rules = pack.sections.find((entry) => entry.id === 'rules')!
109
- expect(rules.cut && rules.size <= 3000 && rules.body.endsWith('wiki_list kind rule.)')).toBe(true)
110
- expect(ids(pack)).toEqual(['blocks', 'rules', 'skills', 'knowledge', 'tools'])
111
- expect(pack.sections.filter((entry) => entry.id !== 'rules').every((entry) => !entry.cut)).toBe(true)
76
+ const pack = compile(env, agent(), hosted())
77
+ expect(pack.sections.map((entry) => entry.id)).toEqual(['identity', 'work', 'wiki', 'tools'])
78
+ expect(pack.harness).toBeNull()
112
79
  })
113
80
 
114
- test('an agent gets the application sections, its blocks and the tool groups in order', () => {
81
+ test('the Wiki section lists the current project\'s notes and only describes wiki_write when it is on', () => {
115
82
  const env = setup()
116
- const pack = compile(env, agent({ runId: 'run-1' }), hosted({
117
- input: 'How does Astro render the site?',
118
- tools: ['dispatch_work', 'list_agents', 'read'],
119
- toolGroups: [
120
- { group: 'Work', tools: ['dispatch_work'], guidance: 'Work (dispatch_work): record and dispatch substantial tasks.', order: 30 },
121
- { group: 'Agents', tools: ['list_agents', 'get_agent'], guidance: 'Agents (list_agents, get_agent): see and message the other agents.', order: 40 },
122
- { group: 'Browser', tools: [], guidance: 'Browser: act in the managed browser.', order: 50 },
123
- { group: 'Files', tools: ['files_list', 'files_read'], guidance: 'Saved files (files_list, files_read): read the shared vault.', order: 80 }
124
- ]
125
- }))
126
- expect(pack.toolGroups.map((group) => group.group)).toEqual(['Memory', 'Knowledge', 'Work', 'Agents', 'Native', 'Files'])
127
- expect(pack.toolGroups[3].tools).toEqual(['list_agents', 'get_agent'])
128
- const tools = pack.sections.at(-1)!.body
129
- expect(tools).toContain('Work (dispatch_work)')
130
- expect(tools).toContain('Files (read)')
131
- expect(tools).not.toContain('Browser')
132
- expect(tools).not.toContain('connected apps')
133
- expect(pack.profile).toBe('agent')
134
- expect(ids(pack)).toEqual(['identity', 'role', 'blocks', 'rules', 'skills', 'work', 'knowledge', 'tools'])
135
- const text = render(env, pack)
136
- for (const expected of ['# Xingular Harness context\n\nYou are "Nora", an Xingular Agent agent.\n\n## Role', '<block label="persona"', '<block label="notes"', 'You have no unresolved work.', '- /projects/xingular-agent', 'Site uses Astro']) expect(text).toContain(expected)
137
- // A volatile fact is never carried, even when it matches.
138
- expect(text).not.toContain('On call this week')
139
- // A skill enabled for one project is not listed for an agent, only pointed to.
140
- expect(text).not.toContain('review (skills/review)')
83
+ const text = render(env, compile(env, agent(), hosted()))
84
+ expect(text).toContain('- projects/xingular-agent/decisions — Decisions')
85
+ expect(text).toContain('wiki_search, wiki_read and wiki_list read project notes.')
86
+ expect(text).not.toContain('wiki_write')
87
+ expect(render(env, compile(env, agent(), hosted({ tools: ['read', 'wiki_write'] })))).toContain('wiki_write saves one')
141
88
  })
142
89
 
143
- test('a running session is told once when a block changes', () => {
90
+ test('selected notes are references for this request only', () => {
144
91
  const env = setup()
145
- const directory = mkdtempSync(join(tmpdir(), 'harness-session-'))
146
- saveForSession(directory, compile(env, session('p1')))
147
- expect(memoryDelta(env, directory, session('p1'))).toBeNull()
148
- blocks.edit(env.root, { kind: 'user' }, 'project/xingular-agent', { kind: 'append', text: 'Uses pnpm.' })
149
- const note = memoryDelta(env, directory, session('p1'))!
150
- expect(note.startsWith('[Memory updated since')).toBe(true)
151
- expect(note).toContain('<block label="project/xingular-agent"')
152
- expect(note).toContain('Uses pnpm.')
153
- expect(note).not.toContain('<block label="user"')
154
- expect(memoryDelta(env, directory, session('p1'))).toBeNull()
155
- })
156
-
157
- test('a profile limits the sections and is recorded', () => {
158
- const env = setup()
159
- expect(profileFor(env.root, session('p1'))).toBe('full')
160
- setProfile(env.root, 's1', 'minimal')
161
- const pack = compile(env, session('p1'))
162
- expect([pack.contextProfile, ids(pack)]).toEqual(['minimal', ['rules', 'tools']])
163
- setProfile(env.root, 's1', 'off')
164
- const off = compile(env, session('p1'))
165
- expect([off.sections, off.toolGroups]).toEqual([[], []])
166
- setProfile(env.root, 's1', 'full')
167
- expect(profiles(env.root)).toEqual({})
168
- expect(() => setProfile(env.root, '../x', 'off')).toThrow()
169
- })
170
-
171
- test('the pack is recorded with the run', () => {
172
- const env = setup()
173
- const text = launchText(env, session('p1'))
174
- const saved = load(env.root, 'run-1')!
175
- expect(render(env, saved)).toBe(text)
176
- expect(saved.sections.some((entry) => entry.id === 'blocks' && entry.sources.some((source) => source.id === 'blocks/shared/user'))).toBe(true)
177
- expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'rules/tests' && entry.via === 'entrypoint')).toBe(true)
178
- expect(load(env.root, 'missing')).toBeNull()
92
+ const launch = hosted({ references: [{ kind: 'note', path: 'library/glossary' }, { kind: 'line', id: 'l1', label: 'Line', text: 'The user pinned a screenshot.' }] })
93
+ const section = compile(env, agent(), launch).sections.find((entry) => entry.id === 'references')!
94
+ expect(section.body).toContain('The user pinned a screenshot.')
95
+ expect(section.body).toContain('A harness is the system around a model.')
96
+ expect(section.sources[0]).toMatchObject({ id: 'library/glossary', kind: 'note' })
97
+ expect(SECTION_ORDER.references).toBeLessThan(SECTION_ORDER.wiki)
179
98
  })
180
99
 
181
100
  function delivered(path: string, update: ContextUpdate): void {
@@ -184,64 +103,28 @@ function delivered(path: string, update: ContextUpdate): void {
184
103
 
185
104
  test('state stays out of the system prompt and only delivered changes are resent', () => {
186
105
  const env = setup()
187
- const caller = agent({ runId: 'run-1' })
188
- const first = sessionLaunch(env, caller, hosted(), null)
189
- const initial = first.update!
190
- expect(first.system).not.toContain('<block')
106
+ const materialised = scoped(env)
107
+ const first = sessionLaunch(env, agent(), hosted(), null, materialised)
108
+ expect(first.system).toContain('You draft release notes.')
191
109
  expect(first.system).not.toContain('You have no unresolved work.')
192
- expect(first.system.endsWith('through tools when needed.\n')).toBe(true)
193
- expect(initial.content).toContain('<block')
194
- expect(initial.content).toContain('You have no unresolved work.')
195
- // Recording a prepared launch is not evidence that the session received its context.
196
- expect(sessionLaunch(env, caller, hosted(), null).update).not.toBeNull()
110
+ expect(first.update!.content).toContain('You have no unresolved work.')
111
+ expect(first.update!.content).toContain('## Wiki')
197
112
  const history = join(mkdtempSync(join(tmpdir(), 'harness-history-')), 'chat.jsonl')
198
- delivered(history, initial)
199
- const unchanged = sessionLaunch(env, caller, hosted(), history)
113
+ delivered(history, first.update!)
114
+ const unchanged = sessionLaunch(env, agent(), hosted(), history, materialised)
200
115
  expect(unchanged.system).toBe(first.system)
201
116
  expect(unchanged.update).toBeNull()
202
- blocks.edit(env.root, { kind: 'user' }, 'user', { kind: 'append', text: 'New shared preference.' })
203
- const changed = sessionLaunch(env, caller, hosted(), history)
117
+ notes.write(env.root, 'projects/xingular-agent/release', '# Release checklist\n')
118
+ const changed = sessionLaunch(env, agent(), hosted(), history, materialised)
204
119
  expect(changed.system).toBe(first.system)
205
- expect(changed.update!.content).toContain('New shared preference.')
120
+ expect(changed.update!.content).toContain('projects/xingular-agent/release')
206
121
  expect(changed.update!.content).not.toContain('## Active work')
207
- expect(changed.update!.content).not.toContain('## Knowledge')
208
- expect(load(env.root, 'run-1')!.delivery!.updatedSectionIds).toEqual(['blocks'])
209
- writeFileSync(join(env.root, 'registry/rules/tests.md'), '---\nname: Run tests\n---\n\nFollow the updated rule.\n')
210
- const updated = sessionLaunch(env, caller, hosted(), history).system
211
- expect(updated).not.toBe(first.system)
212
- expect(updated).toContain('Follow the updated rule.')
213
- })
214
-
215
- test('pinned application text reaches native history with context off without changing the system prefix', () => {
216
- const env = setup()
217
- setProfile(env.root, 'nora', 'off')
218
- const before = sessionLaunch(env, agent(), hosted(), null)
219
- const after = sessionLaunch(env, agent(), { sections: [identity, role, work('You have no unresolved work.\n', 'Recent task reports:\n\nTask t1: Reviewed evidence shown to the user\n')] }, null)
220
- expect(after.system).toBe(before.system)
221
- expect(after.update!.content).toContain('Reviewed evidence shown to the user')
222
- expect(after.update!.content).not.toContain('You have no unresolved work.')
223
- expect(before.update!.content).not.toContain('Reviewed evidence')
224
- })
225
-
226
- test('references apply to each request and profile changes withdraw old state', () => {
227
- const env = setup()
228
- const revision = references.pin(env.root, 'skill', 'skills/review')
229
- const launch = hosted({ references: [{ kind: 'skill', id: 'skills/review', label: 'Review', revision }] })
230
- const first = sessionLaunch(env, agent(), launch, null)
231
- expect(first.system).not.toContain('Read the diff first.')
232
- const history = join(mkdtempSync(join(tmpdir(), 'harness-history-')), 'chat.jsonl')
233
- delivered(history, first.update!)
234
- const repeated = sessionLaunch(env, agent(), launch, history).update!
235
- expect(repeated.content).toContain('Read the diff first.')
236
- expect(repeated.content).not.toContain('## Memory blocks')
237
- const removed = sessionLaunch(env, agent(), hosted(), history).update!
238
- expect(removed.content).toContain('## references')
239
- expect(removed.content).not.toContain('Read the diff first.')
240
- setProfile(env.root, 'nora', 'off')
241
- const withdrawn = sessionLaunch(env, agent(), hosted(), history).update!
242
- expect(withdrawn.snapshot).toEqual([])
243
- for (const id of ['blocks', 'references', 'work', 'knowledge']) expect(withdrawn.content).toContain(`## ${id}`)
244
- expect(sessionLaunch(env, agent(), launch, history).update!.content).toContain('Read the diff first.')
122
+ expect(load(env.root, 'run-1')!.delivery!.updatedSectionIds).toEqual(['wiki'])
123
+ // A new harness version changes the stable prefix, never the delivered state.
124
+ store.commit(env.root, 'nora', { ...tree, 'memory.md': 'Releases ship on Thursdays.\n' }, { kind: 'edit' })
125
+ const next = sessionLaunch(env, agent(), hosted(), history, materialise(env.root, { scope: 'nora' }, { subagents: true }))
126
+ expect(next.system).toContain('Releases ship on Thursdays.')
127
+ expect(next.system).not.toBe(first.system)
245
128
  })
246
129
 
247
130
  test('the snapshot follows the active branch and refreshes after compaction', () => {
@@ -251,11 +134,17 @@ test('the snapshot follows the active branch and refreshes after compaction', ()
251
134
  const history = join(directory, 'chat.jsonl')
252
135
  delivered(history, initial)
253
136
  const original = readFileSync(history, 'utf8')
254
- // An unrelated branch's snapshot must not decide what this branch already received.
255
137
  writeFileSync(history, `${original}${JSON.stringify({ type: 'custom_message', id: 'other', parentId: null, customType: 'xingular-context', details: { xingularContext: [] } })}\n${JSON.stringify({ type: 'message', id: 'leaf', parentId: 'context-1' })}\n`)
256
138
  expect(sessionLaunch(env, agent(), hosted(), history).update).toBeNull()
257
139
  writeFileSync(history, `${original}${JSON.stringify({ type: 'compaction', id: 'summary', parentId: 'context-1' })}\n`)
258
- expect(sessionLaunch(env, agent(), hosted(), history).update!.content).toContain('## Memory blocks')
140
+ expect(sessionLaunch(env, agent(), hosted(), history).update!.content).toContain('## Wiki')
259
141
  expect(sessionFile(directory, join(directory, 'older.jsonl'))).toBe(join(directory, 'older.jsonl'))
260
142
  expect(sessionFile(directory)).toBe(history)
261
143
  })
144
+
145
+ test('a subagent section is left out when the session cannot start subagents', () => {
146
+ const env = setup()
147
+ store.seed(env.root, 'nora', tree)
148
+ const pack = compile(env, agent(), hosted(), materialise(env.root, { scope: 'nora' }, { subagents: false }))
149
+ expect(pack.sections.some((entry) => entry.id === 'subagents')).toBe(false)
150
+ })