@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.
- package/CHANGELOG.md +12 -0
- package/NOTICE.md +22 -0
- package/README.md +67 -36
- package/dist/cli.js +6023 -6621
- package/dist/terminal-pi.js +19 -24
- package/docs/terminal-tui.md +27 -7
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +6 -1
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/registry.ts +5 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +45 -6
- package/src/sessions/sessions.ts +40 -17
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +21 -39
- package/src/terminal/terminals.ts +20 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +10 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +1 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import * as store from './store'
|
|
7
|
+
import { PI_SURFACE, componentOf, type Tree } from './surface'
|
|
8
|
+
import { applyEdit, autotrigger, diffSummary, diversity, isAllowed, normalizeRel, readTree, touchedComponents, TreeError, validate, writeTree } from './tree'
|
|
9
|
+
|
|
10
|
+
const skill = (name: string, description = 'reviewing a contract') => `---\nname: ${name}\ndescription: ${description}\n---\nSteps.\n`
|
|
11
|
+
|
|
12
|
+
function base(): Tree {
|
|
13
|
+
return { 'systemprompt.md': 'You draft documents.\n', 'guardrails.md': '', 'memory.md': '', 'tool_guidance.md': '' }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('tree surface', () => {
|
|
17
|
+
it('knows which paths are editable and which component they are', () => {
|
|
18
|
+
expect(isAllowed(PI_SURFACE, 'systemprompt.md')).toBe(true)
|
|
19
|
+
expect(isAllowed(PI_SURFACE, 'skills/doc_review/SKILL.md')).toBe(true)
|
|
20
|
+
expect(isAllowed(PI_SURFACE, 'skills/doc_review/scripts/x.py')).toBe(true)
|
|
21
|
+
expect(isAllowed(PI_SURFACE, 'scripts/check_totals.py')).toBe(true)
|
|
22
|
+
expect(isAllowed(PI_SURFACE, 'scripts/check.mjs')).toBe(true)
|
|
23
|
+
expect(isAllowed(PI_SURFACE, 'plugins/guard.ts')).toBe(true)
|
|
24
|
+
expect(isAllowed(PI_SURFACE, 'sub_agents/reader/AGENT.md')).toBe(true)
|
|
25
|
+
expect(isAllowed(PI_SURFACE, 'notes.txt')).toBe(false)
|
|
26
|
+
expect(isAllowed(PI_SURFACE, '../systemprompt.md')).toBe(false)
|
|
27
|
+
expect(isAllowed(PI_SURFACE, 'skills/Bad Name/SKILL.md')).toBe(false)
|
|
28
|
+
expect(componentOf(PI_SURFACE, 'memory.md')).toBe('memory')
|
|
29
|
+
expect(componentOf(PI_SURFACE, 'plugins/guard.ts')).toBe('plugins')
|
|
30
|
+
expect(() => normalizeRel('/etc/passwd')).toThrow()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('round-trips a tree through disk, keeping off-surface files and skipping stubs', () => {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), 'tree-'))
|
|
35
|
+
writeFileSync(join(dir, 'unrelated.txt'), 'keep me')
|
|
36
|
+
const tree = { ...base(), 'skills/doc_review/SKILL.md': skill('doc_review') }
|
|
37
|
+
writeTree(PI_SURFACE, tree, dir)
|
|
38
|
+
expect(existsSync(join(dir, 'skills/README.md'))).toBe(true)
|
|
39
|
+
expect(readFileSync(join(dir, 'unrelated.txt'), 'utf8')).toBe('keep me')
|
|
40
|
+
const rejected: string[] = []
|
|
41
|
+
expect(readTree(PI_SURFACE, dir, rejected)).toEqual(tree)
|
|
42
|
+
expect(rejected).toEqual(['unrelated.txt'])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('validate', () => {
|
|
47
|
+
it('lists every problem in one error', () => {
|
|
48
|
+
const tree: Tree = {
|
|
49
|
+
...base(),
|
|
50
|
+
'skills/doc_review/SKILL.md': '---\nname: other\n---\n',
|
|
51
|
+
'sub_agents/reader/AGENT.md': 'no frontmatter',
|
|
52
|
+
'plugins/guard.ts': 'const x = 1',
|
|
53
|
+
'systemprompt.md': 'Run `python3 scripts/missing.py`.'
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
validate(PI_SURFACE, tree, { compileScripts: false })
|
|
57
|
+
throw new Error('expected a failure')
|
|
58
|
+
} catch (error) {
|
|
59
|
+
expect(error).toBeInstanceOf(TreeError)
|
|
60
|
+
const problems = (error as TreeError).problems.join('\n')
|
|
61
|
+
expect(problems).toContain('needs a `description:`')
|
|
62
|
+
expect(problems).toContain('`name: doc_review`')
|
|
63
|
+
expect(problems).toContain('never named in the always-on text')
|
|
64
|
+
expect(problems).toContain('sub_agents/reader/AGENT.md needs a `description:`')
|
|
65
|
+
expect(problems).toContain('default export')
|
|
66
|
+
expect(problems).toContain('invokes `scripts/missing.py`')
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('enforces the always-on cap', () => {
|
|
71
|
+
expect(() => validate(PI_SURFACE, { ...base(), 'memory.md': 'x'.repeat(30_000) })).toThrow(/over the 24000 cap/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('catches scripts that do not compile', () => {
|
|
75
|
+
expect(() => validate(PI_SURFACE, { ...base(), 'scripts/bad.mjs': 'export const = ;' })).toThrow(/does not compile/)
|
|
76
|
+
expect(validate(PI_SURFACE, { ...base(), 'scripts/good.mjs': 'export const ok = 1\n' }).scripts).toBe(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('repairs an untriggered skill with a trigger line', () => {
|
|
80
|
+
const tree: Tree = { ...base(), 'skills/doc_review/SKILL.md': skill('doc_review', 'reviewing a contract.') }
|
|
81
|
+
expect(autotrigger(PI_SURFACE, tree)).toEqual(['doc_review'])
|
|
82
|
+
expect(tree['guardrails.md']).toContain('- When reviewing a contract, load the `doc_review` skill.')
|
|
83
|
+
expect(validate(PI_SURFACE, tree).skills).toBe(1)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('diff and diversity', () => {
|
|
88
|
+
it('reports edits by component', () => {
|
|
89
|
+
const before = base()
|
|
90
|
+
const after = applyEdit(before, { 'guardrails.md': 'Cite sources.', 'scripts/x.py': 'print(1)\n', 'memory.md': null })
|
|
91
|
+
const diff = diffSummary(PI_SURFACE, before, after)
|
|
92
|
+
expect(diff).toMatchObject({ added: ['scripts/x.py'], removed: ['memory.md'], modified: ['guardrails.md'] })
|
|
93
|
+
expect(touchedComponents(PI_SURFACE, diff).sort()).toEqual(['guardrails', 'memory', 'scripts'])
|
|
94
|
+
expect(diversity({ a: before, b: before }).distinct).toBe(1)
|
|
95
|
+
expect(diversity({ a: before, b: after }).meanLineJaccard).toBeLessThan(1)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('store', () => {
|
|
100
|
+
it('versions trees, records lineage, and restores', () => {
|
|
101
|
+
const root = mkdtempSync(join(tmpdir(), 'tree-store-'))
|
|
102
|
+
const first = store.seed(root, 'agent-1', base(), 'from instructions')
|
|
103
|
+
expect(store.seed(root, 'agent-1', { ...base(), 'memory.md': 'ignored' })).toEqual(first)
|
|
104
|
+
const second = store.commit(root, 'agent-1', applyEdit(base(), { 'guardrails.md': 'Cite sources.' }), { kind: 'edit', components: ['guardrails'] })
|
|
105
|
+
expect(second.parent).toBe(first.fingerprint)
|
|
106
|
+
expect(store.head(root, 'agent-1')?.fingerprint).toBe(second.fingerprint)
|
|
107
|
+
expect(store.lineage(root, 'agent-1').map((v) => v.source.kind)).toEqual(['edit', 'seed'])
|
|
108
|
+
expect(store.compare(root, 'agent-1', first.fingerprint, second.fingerprint).files).toEqual([{ path: 'guardrails.md', before: '', after: 'Cite sources.' }])
|
|
109
|
+
store.restore(root, 'agent-1', first.fingerprint)
|
|
110
|
+
expect(store.headTree(root, 'agent-1')?.tree).toEqual(base())
|
|
111
|
+
expect(store.scopes(root)).toEqual(['agent-1'])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('refuses an invalid tree and leaves the head alone', () => {
|
|
115
|
+
const root = mkdtempSync(join(tmpdir(), 'tree-store-'))
|
|
116
|
+
store.seed(root, 'a', base())
|
|
117
|
+
const before = store.head(root, 'a')
|
|
118
|
+
expect(() => store.commit(root, 'a', { ...base(), 'plugins/x.ts': 'nothing' }, { kind: 'edit' })).toThrow(/default export/)
|
|
119
|
+
expect(store.head(root, 'a')).toEqual(before)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('fingerprints by content, not by key order', () => {
|
|
123
|
+
expect(store.fingerprint({ a: '1', b: '2' })).toBe(store.fingerprint({ b: '2', a: '1' }))
|
|
124
|
+
expect(store.fingerprint({ a: '1' })).not.toBe(store.fingerprint({ a: '2' }))
|
|
125
|
+
})
|
|
126
|
+
})
|
package/src/tree/tree.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Reading, writing, validating, repairing and comparing a harness tree. The edit contract is
|
|
2
|
+
// omitted-is-inherited: an edit names only the files it changes.
|
|
3
|
+
//
|
|
4
|
+
// Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/tree.py`).
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { componentOf, patterns, type ComponentId, type SurfaceSpec, type Tree } from './surface'
|
|
11
|
+
|
|
12
|
+
/** Scripts the harness itself ships; a reference to one needs no file in the tree. */
|
|
13
|
+
export const RESERVED_SCRIPTS: ReadonlySet<string> = new Set()
|
|
14
|
+
|
|
15
|
+
/** Normalises a relative path and refuses one that escapes the tree. */
|
|
16
|
+
export function normalizeRel(rel: string): string {
|
|
17
|
+
let r = String(rel).trim().replace(/\\/g, '/')
|
|
18
|
+
if (r.startsWith('/') || r.split('/').includes('..')) throw new Error(`path escapes the harness: ${JSON.stringify(rel)}`)
|
|
19
|
+
while (r.startsWith('./')) r = r.slice(2)
|
|
20
|
+
return r.replace(/^\/+|\/+$/g, '')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether a path is on the editable surface. Never throws. */
|
|
24
|
+
export function isAllowed(surface: SurfaceSpec, rel: string): boolean {
|
|
25
|
+
let clean: string
|
|
26
|
+
try {
|
|
27
|
+
clean = normalizeRel(rel)
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
return surface.alwaysOn.includes(clean) || componentOf(surface, clean) !== null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function skillNames(surface: SurfaceSpec, tree: Tree): string[] {
|
|
35
|
+
return names(tree, patterns(surface).skill)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function subagentNames(surface: SurfaceSpec, tree: Tree): string[] {
|
|
39
|
+
return names(tree, patterns(surface).subagent)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pluginNames(surface: SurfaceSpec, tree: Tree): string[] {
|
|
43
|
+
return names(tree, patterns(surface).plugin)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function names(tree: Tree, rx: RegExp): string[] {
|
|
47
|
+
const found = new Set<string>()
|
|
48
|
+
for (const rel of Object.keys(tree)) {
|
|
49
|
+
const m = rx.exec(rel)
|
|
50
|
+
if (m?.[1]) found.add(m[1])
|
|
51
|
+
}
|
|
52
|
+
return [...found].sort()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stubPaths(surface: SurfaceSpec): Set<string> {
|
|
56
|
+
return new Set(surface.stubs.map(([rel]) => rel))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function walk(dir: string): string[] {
|
|
60
|
+
const out: string[] = []
|
|
61
|
+
let entries: string[]
|
|
62
|
+
try {
|
|
63
|
+
entries = readdirSync(dir)
|
|
64
|
+
} catch {
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
for (const name of entries.sort()) {
|
|
68
|
+
if (name === '__pycache__' || name === '.DS_Store' || name === 'node_modules') continue
|
|
69
|
+
const path = join(dir, name)
|
|
70
|
+
let isDir = false
|
|
71
|
+
try {
|
|
72
|
+
isDir = statSync(path).isDirectory()
|
|
73
|
+
} catch {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (isDir) out.push(...walk(path))
|
|
77
|
+
else out.push(path)
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Every file on the editable surface under `dir`, README stubs excluded. Pass `rejected` to
|
|
84
|
+
* collect paths that exist but are off the surface.
|
|
85
|
+
*/
|
|
86
|
+
export function readTree(surface: SurfaceSpec, dir: string, rejected?: string[]): Tree {
|
|
87
|
+
const out: Tree = {}
|
|
88
|
+
const stubs = stubPaths(surface)
|
|
89
|
+
for (const path of walk(dir)) {
|
|
90
|
+
const rel = relative(dir, path).split(sep).join('/')
|
|
91
|
+
if (stubs.has(rel)) continue
|
|
92
|
+
if (!isAllowed(surface, rel)) {
|
|
93
|
+
rejected?.push(rel)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
out[rel] = readFileSync(path, 'utf8')
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether `dir` holds a tree: at least one always-on file. */
|
|
102
|
+
export function isTree(surface: SurfaceSpec, dir: string): boolean {
|
|
103
|
+
return surface.alwaysOn.some((rel) => {
|
|
104
|
+
try {
|
|
105
|
+
return statSync(join(dir, rel)).isFile()
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Writes exactly `tree` to `dest`. Always-on files are present even when empty; files off the
|
|
114
|
+
* surface are kept, editable files already there are replaced.
|
|
115
|
+
*/
|
|
116
|
+
export function writeTree(surface: SurfaceSpec, tree: Tree, dest: string, options: { stubs?: boolean } = {}): string {
|
|
117
|
+
mkdirSync(dest, { recursive: true })
|
|
118
|
+
const stubs = stubPaths(surface)
|
|
119
|
+
for (const path of walk(dest).reverse()) {
|
|
120
|
+
const rel = relative(dest, path).split(sep).join('/')
|
|
121
|
+
if (isAllowed(surface, rel) || stubs.has(rel)) unlinkSync(path)
|
|
122
|
+
}
|
|
123
|
+
for (const rel of surface.alwaysOn) {
|
|
124
|
+
if (!(rel in tree)) {
|
|
125
|
+
const path = join(dest, rel)
|
|
126
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
127
|
+
writeFileSync(path, '')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const [rel, text] of Object.entries(tree)) {
|
|
131
|
+
const path = join(dest, normalizeRel(rel))
|
|
132
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
133
|
+
writeFileSync(path, text)
|
|
134
|
+
}
|
|
135
|
+
if (options.stubs !== false) {
|
|
136
|
+
for (const [rel, body] of surface.stubs) {
|
|
137
|
+
const path = join(dest, rel)
|
|
138
|
+
try {
|
|
139
|
+
statSync(path)
|
|
140
|
+
} catch {
|
|
141
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
142
|
+
writeFileSync(path, body)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return dest
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The always-on text, as the model sees it, joined in surface order. */
|
|
150
|
+
export function alwaysOnText(surface: SurfaceSpec, tree: Tree): string {
|
|
151
|
+
return surface.alwaysOn.map((rel) => tree[rel] ?? '').join('\n\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function frontmatterField(body: string, field: string): string | null {
|
|
155
|
+
const m = new RegExp(`^${field}:\\s*(.+)$`, 'm').exec(body.slice(0, 600))
|
|
156
|
+
return m?.[1]?.trim() ?? null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Gives every skill a trigger line in the always-on text, mutating `tree`. Returns the names
|
|
161
|
+
* repaired. A line that would push the text over the cap is skipped.
|
|
162
|
+
*/
|
|
163
|
+
export function autotrigger(surface: SurfaceSpec, tree: Tree, alwaysOnMax = surface.alwaysOnMax): string[] {
|
|
164
|
+
let body = alwaysOnText(surface, tree)
|
|
165
|
+
const fixed: string[] = []
|
|
166
|
+
for (const name of skillNames(surface, tree)) {
|
|
167
|
+
if (body.includes(name)) continue
|
|
168
|
+
const desc = frontmatterField(tree[`${surface.skillDir}/${name}/SKILL.md`] ?? '', 'description')?.replace(/\.$/, '') ?? `the ${name} workflow applies`
|
|
169
|
+
const line = surface.triggerLine.replace('{name}', name).replace('{desc}', desc)
|
|
170
|
+
if (body.length + line.length + 2 > alwaysOnMax) continue
|
|
171
|
+
const current = tree[surface.autotriggerFile] ?? ''
|
|
172
|
+
tree[surface.autotriggerFile] = current.trim() ? `${current.replace(/\s+$/, '')}\n${line}\n` : `${line}\n`
|
|
173
|
+
body = alwaysOnText(surface, tree)
|
|
174
|
+
fixed.push(name)
|
|
175
|
+
}
|
|
176
|
+
return fixed
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface TreeCensus {
|
|
180
|
+
alwaysOnChars: number
|
|
181
|
+
files: number
|
|
182
|
+
skills: number
|
|
183
|
+
subagents: number
|
|
184
|
+
scripts: number
|
|
185
|
+
plugins: number
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ValidateOptions {
|
|
189
|
+
allowSubagents?: boolean
|
|
190
|
+
alwaysOnMax?: number
|
|
191
|
+
reservedScripts?: ReadonlySet<string>
|
|
192
|
+
/** Check that scripts compile with the interpreters on this machine. On by default. */
|
|
193
|
+
compileScripts?: boolean
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A tree that does not validate. `problems` is written to be fed back to an editor as-is. */
|
|
197
|
+
export class TreeError extends Error {
|
|
198
|
+
constructor(readonly problems: string[]) {
|
|
199
|
+
super(problems.join(' | '))
|
|
200
|
+
this.name = 'TreeError'
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function checkCompiles(rel: string, src: string, errs: string[]): void {
|
|
205
|
+
const dir = mkdtempSync(join(tmpdir(), 'harness-script-'))
|
|
206
|
+
try {
|
|
207
|
+
const file = join(dir, rel.split('/').pop() ?? 'script')
|
|
208
|
+
writeFileSync(file, src)
|
|
209
|
+
const python = rel.endsWith('.py')
|
|
210
|
+
const result = python
|
|
211
|
+
? spawnSync('python3', ['-c', 'import ast,sys; ast.parse(open(sys.argv[1]).read(), sys.argv[1])', file], { encoding: 'utf8', timeout: 15_000 })
|
|
212
|
+
: spawnSync(process.execPath, ['--check', file], { encoding: 'utf8', timeout: 15_000 })
|
|
213
|
+
// A missing interpreter is not the tree's fault.
|
|
214
|
+
if (result.error) return
|
|
215
|
+
if (result.status !== 0) {
|
|
216
|
+
const detail = (result.stderr || result.stdout || '').trim().split('\n').filter(Boolean).slice(-2).join(' ')
|
|
217
|
+
errs.push(`${rel} does not compile: ${detail.slice(0, 200)}`)
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(dir, { recursive: true, force: true })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Throws a `TreeError` listing every problem; returns a census of a valid tree. */
|
|
225
|
+
export function validate(surface: SurfaceSpec, tree: Tree, options: ValidateOptions = {}): TreeCensus {
|
|
226
|
+
const errs: string[] = []
|
|
227
|
+
const r = patterns(surface)
|
|
228
|
+
const cap = options.alwaysOnMax ?? surface.alwaysOnMax
|
|
229
|
+
const body = alwaysOnText(surface, tree)
|
|
230
|
+
if (body.length > cap) errs.push(`always-on content is ${body.length} chars, over the ${cap} cap; ${surface.alwaysOnCapReason}`)
|
|
231
|
+
|
|
232
|
+
for (const rel of Object.keys(tree)) {
|
|
233
|
+
if (!isAllowed(surface, rel)) errs.push(`${rel} is not on the editable surface`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const skills = skillNames(surface, tree)
|
|
237
|
+
for (const name of skills) {
|
|
238
|
+
const fm = (tree[`${surface.skillDir}/${name}/SKILL.md`] ?? '').slice(0, 600)
|
|
239
|
+
if (!/^description:\s*\S/m.test(fm)) errs.push(`${surface.skillDir}/${name}/SKILL.md needs a \`description:\` in its frontmatter, or the skill is dropped SILENTLY at load`)
|
|
240
|
+
const declared = /^name:\s*(\S+)\s*$/m.exec(fm)?.[1]
|
|
241
|
+
if (surface.skillNameRule === 'must_match') {
|
|
242
|
+
if (declared !== name) errs.push(`${surface.skillDir}/${name}/SKILL.md needs frontmatter \`name: ${name}\` matching its folder (a mismatch is dropped SILENTLY at load)`)
|
|
243
|
+
} else if (declared && declared !== name) {
|
|
244
|
+
errs.push(`${surface.skillDir}/${name}/SKILL.md frontmatter \`name: ${declared}\` overrides the folder name at load, so trigger lines naming \`${name}\` stop matching`)
|
|
245
|
+
}
|
|
246
|
+
if (!body.includes(name)) errs.push(`skill '${name}' is never named in the always-on text, so the agent will not know to invoke it — an untriggered skill is dead weight. ${surface.triggerHint.replaceAll('{name}', name)}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const subagents = subagentNames(surface, tree)
|
|
250
|
+
if (subagents.length && options.allowSubagents === false) errs.push(`subagents are not available here: ${subagents.join(', ')}`)
|
|
251
|
+
for (const name of subagents) {
|
|
252
|
+
const fm = (tree[`${surface.subagentDir}/${name}/AGENT.md`] ?? '').slice(0, 600)
|
|
253
|
+
if (!/^description:\s*\S/m.test(fm)) errs.push(`${surface.subagentDir}/${name}/AGENT.md needs a \`description:\``)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const [rel, src] of Object.entries(tree)) {
|
|
257
|
+
if (options.compileScripts !== false && (r.script.test(rel) || (r.skillScript.test(rel) && /\.(py|mjs)$/.test(rel)))) checkCompiles(rel, src, errs)
|
|
258
|
+
if (r.plugin.test(rel) && !/export\s+default\b/.test(src)) errs.push(`${rel} needs a default export: a Pi extension is a function \`export default function (pi) { ... }\``)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const reserved = options.reservedScripts ?? RESERVED_SCRIPTS
|
|
262
|
+
const have = new Set(Object.keys(tree).filter((rel) => r.script.test(rel) || r.skillScript.test(rel)))
|
|
263
|
+
for (const [rel, src] of Object.entries(tree)) {
|
|
264
|
+
if (!(r.skill.test(rel) || surface.alwaysOn.includes(rel))) continue
|
|
265
|
+
for (const m of src.matchAll(r.scriptRef)) {
|
|
266
|
+
const ref = m[1]!
|
|
267
|
+
if (reserved.has(ref.split('/').pop()!)) continue
|
|
268
|
+
if (!have.has(ref)) errs.push(`${rel} invokes \`${ref}\` but that file is not in the harness`)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (errs.length) throw new TreeError(errs)
|
|
273
|
+
return {
|
|
274
|
+
alwaysOnChars: body.length,
|
|
275
|
+
files: Object.keys(tree).length,
|
|
276
|
+
skills: skills.length,
|
|
277
|
+
subagents: subagents.length,
|
|
278
|
+
scripts: Object.keys(tree).filter((rel) => r.script.test(rel)).length,
|
|
279
|
+
plugins: pluginNames(surface, tree).length
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface TreeDiff {
|
|
284
|
+
added: string[]
|
|
285
|
+
removed: string[]
|
|
286
|
+
modified: string[]
|
|
287
|
+
alwaysOnCharDelta: number
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function diffSummary(surface: SurfaceSpec, before: Tree, after: Tree): TreeDiff {
|
|
291
|
+
const a = new Set(Object.keys(before))
|
|
292
|
+
const b = new Set(Object.keys(after))
|
|
293
|
+
return {
|
|
294
|
+
added: [...b].filter((rel) => !a.has(rel)).sort(),
|
|
295
|
+
removed: [...a].filter((rel) => !b.has(rel)).sort(),
|
|
296
|
+
modified: [...a].filter((rel) => b.has(rel) && before[rel] !== after[rel]).sort(),
|
|
297
|
+
alwaysOnCharDelta: alwaysOnText(surface, after).length - alwaysOnText(surface, before).length
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function isUnchanged(surface: SurfaceSpec, before: Tree, after: Tree): boolean {
|
|
302
|
+
const d = diffSummary(surface, before, after)
|
|
303
|
+
return !d.added.length && !d.removed.length && !d.modified.length
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Which components a diff touches. */
|
|
307
|
+
export function touchedComponents(surface: SurfaceSpec, diff: TreeDiff): ComponentId[] {
|
|
308
|
+
const out = new Set<ComponentId>()
|
|
309
|
+
for (const rel of [...diff.added, ...diff.removed, ...diff.modified]) {
|
|
310
|
+
const id = componentOf(surface, rel)
|
|
311
|
+
if (id) out.add(id)
|
|
312
|
+
}
|
|
313
|
+
return [...out]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Pairwise line-Jaccard over trees, so a collapse to one body is visible. */
|
|
317
|
+
export function diversity(trees: Record<string, Tree>): { n: number; distinct: number; meanLineJaccard: number } {
|
|
318
|
+
const labels = Object.keys(trees).sort()
|
|
319
|
+
if (labels.length < 2) return { n: labels.length, distinct: labels.length, meanLineJaccard: 1 }
|
|
320
|
+
const lines = (tree: Tree) => new Set(Object.entries(tree).flatMap(([rel, text]) => text.split('\n').filter((line) => line.trim()).map((line) => `${rel}:${line}`)))
|
|
321
|
+
const sets = Object.fromEntries(labels.map((label) => [label, lines(trees[label]!)]))
|
|
322
|
+
const sims: number[] = []
|
|
323
|
+
for (let i = 0; i < labels.length; i++) {
|
|
324
|
+
for (const b of labels.slice(i + 1)) {
|
|
325
|
+
const x = sets[labels[i]!]!
|
|
326
|
+
const y = sets[b]!
|
|
327
|
+
const union = new Set([...x, ...y])
|
|
328
|
+
let shared = 0
|
|
329
|
+
for (const line of x) if (y.has(line)) shared++
|
|
330
|
+
sims.push(union.size ? shared / union.size : 1)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const distinct = new Set(labels.map((label) => JSON.stringify(Object.entries(trees[label]!).sort()))).size
|
|
334
|
+
return { n: labels.length, distinct, meanLineJaccard: Math.round((sims.reduce((sum, value) => sum + value, 0) / sims.length) * 10_000) / 10_000 }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Applies an omitted-is-inherited edit: `null` removes a file. */
|
|
338
|
+
export function applyEdit(base: Tree, edit: Record<string, string | null>): Tree {
|
|
339
|
+
const next: Tree = { ...base }
|
|
340
|
+
for (const [rel, text] of Object.entries(edit)) {
|
|
341
|
+
const clean = normalizeRel(rel)
|
|
342
|
+
if (text === null) delete next[clean]
|
|
343
|
+
else next[clean] = text
|
|
344
|
+
}
|
|
345
|
+
return next
|
|
346
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Plugin code in a harness tree (c8) runs inside every session's tool loop, so it loads only
|
|
2
|
+
// after a person trusted that exact code: trust is a hash of the file, and a changed file asks
|
|
3
|
+
// again. A person may also let refinement probes run the plugin code an improver wrote for one
|
|
4
|
+
// harness scope; adopting such a harness still needs the code trusted.
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { chmodSync, existsSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { now, readJsonOr, safeId, writeJson } from '../core/fs'
|
|
10
|
+
import { patterns, PI_SURFACE, type Tree } from './surface'
|
|
11
|
+
|
|
12
|
+
export interface TrustedPlugin {
|
|
13
|
+
hash: string
|
|
14
|
+
/** The file it was trusted as, for display. */
|
|
15
|
+
name: string
|
|
16
|
+
scope: string | null
|
|
17
|
+
trustedAt: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Stored {
|
|
21
|
+
trusted?: TrustedPlugin[]
|
|
22
|
+
/** Scopes whose refinement probes may run untrusted improver-written plugins. */
|
|
23
|
+
probeCode?: Record<string, boolean>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pluginHash(text: string): string {
|
|
27
|
+
return createHash('sha256').update(text).digest('hex')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class TreeTrust {
|
|
31
|
+
private readonly path: string
|
|
32
|
+
|
|
33
|
+
constructor(stateDir: string) {
|
|
34
|
+
this.path = join(stateDir, 'tree-plugin-trust.json')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private read(): Stored {
|
|
38
|
+
return readJsonOr<Stored>(this.path, {})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private write(stored: Stored): void {
|
|
42
|
+
writeJson(this.path, stored)
|
|
43
|
+
if (existsSync(this.path)) chmodSync(this.path, 0o600)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
list(): TrustedPlugin[] {
|
|
47
|
+
return (this.read().trusted ?? []).filter((entry) => typeof entry?.hash === 'string')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
has(hash: string): boolean {
|
|
51
|
+
return this.list().some((entry) => entry.hash === hash)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
grant(text: string, name: string, scope: string | null): TrustedPlugin {
|
|
55
|
+
const entry = { hash: pluginHash(text), name, scope, trustedAt: now() }
|
|
56
|
+
const stored = this.read()
|
|
57
|
+
this.write({ ...stored, trusted: [...this.list().filter((known) => known.hash !== entry.hash), entry] })
|
|
58
|
+
return entry
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
revoke(hash: string): void {
|
|
62
|
+
const stored = this.read()
|
|
63
|
+
this.write({ ...stored, trusted: this.list().filter((entry) => entry.hash !== hash) })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
probeCode(scope: string): boolean {
|
|
67
|
+
return this.read().probeCode?.[safeId(scope)] === true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setProbeCode(scope: string, on: boolean): void {
|
|
71
|
+
const stored = this.read()
|
|
72
|
+
const probeCode = { ...(stored.probeCode ?? {}) }
|
|
73
|
+
if (on) probeCode[safeId(scope)] = true
|
|
74
|
+
else delete probeCode[safeId(scope)]
|
|
75
|
+
this.write({ ...stored, probeCode })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The plugin files of a tree and whether each is trusted. */
|
|
79
|
+
review(tree: Tree): { path: string; hash: string; trusted: boolean }[] {
|
|
80
|
+
const rx = patterns(PI_SURFACE).plugin
|
|
81
|
+
return Object.keys(tree).filter((rel) => rx.test(rel)).sort().map((path) => {
|
|
82
|
+
const hash = pluginHash(tree[path]!)
|
|
83
|
+
return { path, hash, trusted: this.has(hash) }
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|