@agentskit/doc-bridge 0.1.0-alpha.2 → 1.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 (57) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +139 -57
  3. package/action.yml +78 -0
  4. package/dist/cli/program.js +1122 -114
  5. package/dist/cli/program.js.map +1 -1
  6. package/dist/index.d.ts +258 -28
  7. package/dist/index.js +824 -54
  8. package/dist/index.js.map +1 -1
  9. package/docs/DOGFOOD-ROUND2.md +142 -0
  10. package/docs/DOGFOOD-ROUND3.md +74 -0
  11. package/docs/POSITIONING.md +2 -0
  12. package/docs/RELEASE.md +5 -3
  13. package/docs/chat-and-rag.md +2 -0
  14. package/docs/getting-started.md +14 -1
  15. package/docs/landing/index.html +299 -0
  16. package/docs/mcp.md +10 -1
  17. package/docs/ollama-demo.md +64 -0
  18. package/docs/playbook/doc-bridge-pattern.md +114 -0
  19. package/docs/recipes/index-pipeline.md +89 -0
  20. package/docs/skills/doc-bridge.md +64 -0
  21. package/docs/spec/cli.md +6 -0
  22. package/docs/spec/playbook-feedback.md +12 -4
  23. package/examples/demo-example/doc-bridge.config.json +23 -0
  24. package/examples/demo-example/docs/for-agents/INDEX.md +3 -0
  25. package/examples/demo-example/docs/for-agents/packages/example.md +14 -0
  26. package/examples/demo-example/package.json +7 -0
  27. package/examples/demo-example/src/.gitkeep +0 -0
  28. package/examples/demo-monorepo/doc-bridge.config.json +37 -0
  29. package/examples/demo-monorepo/docs/for-agents/INDEX.md +4 -0
  30. package/examples/demo-monorepo/docs/for-agents/packages/auth.md +22 -0
  31. package/examples/demo-monorepo/docs/for-agents/packages/billing.md +19 -0
  32. package/examples/demo-monorepo/docs/human/guides/auth.md +7 -0
  33. package/examples/demo-monorepo/package.json +7 -0
  34. package/examples/demo-monorepo/packages/auth/package.json +8 -0
  35. package/examples/demo-monorepo/packages/billing/package.json +7 -0
  36. package/examples/demo-monorepo/pnpm-workspace.yaml +2 -0
  37. package/examples/ollama-chat.config.ts +42 -0
  38. package/package.json +12 -9
  39. package/src/cli/demo.ts +143 -0
  40. package/src/cli/program.ts +220 -26
  41. package/src/doctor/badge.ts +53 -0
  42. package/src/doctor/run-doctor.ts +286 -0
  43. package/src/federation/llms.ts +20 -11
  44. package/src/index-builder/build-handoffs.ts +35 -2
  45. package/src/index-builder/scan-corpus.ts +5 -1
  46. package/src/index-builder/watch-index.ts +94 -0
  47. package/src/index.ts +24 -0
  48. package/src/lib/markdown.ts +31 -4
  49. package/src/mcp/install.ts +84 -0
  50. package/src/memory/github-pr.ts +190 -0
  51. package/src/playbook/doc-bridge-pattern.ts +121 -0
  52. package/src/query/query.ts +19 -1
  53. package/src/query/search.ts +85 -17
  54. package/src/schemas/agent-handoff.ts +11 -0
  55. package/src/schemas/doc-bridge-index.ts +3 -1
  56. package/src/schemas/json-schemas.ts +2 -1
  57. package/src/version.ts +1 -1
@@ -0,0 +1,84 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join, resolve } from 'node:path'
4
+
5
+ export type McpInstallTarget = 'cursor' | 'claude'
6
+
7
+ export type McpInstallResult = {
8
+ readonly ok: boolean
9
+ readonly target: McpInstallTarget
10
+ readonly configPath: string
11
+ readonly created: boolean
12
+ readonly serverName: string
13
+ readonly nextSteps: readonly string[]
14
+ }
15
+
16
+ const SERVER_NAME = 'ak-docs'
17
+
18
+ const mcpServerEntry = (root: string) => ({
19
+ command: 'npx',
20
+ args: ['ak-docs', 'mcp'],
21
+ cwd: root,
22
+ })
23
+
24
+ const readJson = (path: string): Record<string, unknown> => {
25
+ if (!existsSync(path)) return {}
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
28
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
29
+ ? (parsed as Record<string, unknown>)
30
+ : {}
31
+ } catch {
32
+ return {}
33
+ }
34
+ }
35
+
36
+ const writeJson = (path: string, value: Record<string, unknown>): void => {
37
+ mkdirSync(dirname(path), { recursive: true })
38
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
39
+ }
40
+
41
+ const resolveTargetPath = (target: McpInstallTarget, root: string): string => {
42
+ if (target === 'cursor') return resolve(root, '.cursor', 'mcp.json')
43
+ return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
44
+ }
45
+
46
+ export const installMcpConfig = (
47
+ root: string,
48
+ target: McpInstallTarget,
49
+ ): McpInstallResult => {
50
+ const configPath = resolveTargetPath(target, root)
51
+ const created = !existsSync(configPath)
52
+ const existing = readJson(configPath)
53
+ const servers =
54
+ existing.mcpServers && typeof existing.mcpServers === 'object' && !Array.isArray(existing.mcpServers)
55
+ ? { ...(existing.mcpServers as Record<string, unknown>) }
56
+ : {}
57
+
58
+ servers[SERVER_NAME] = mcpServerEntry(root)
59
+ writeJson(configPath, { ...existing, mcpServers: servers })
60
+
61
+ const nextSteps =
62
+ target === 'cursor'
63
+ ? [
64
+ 'Restart Cursor or reload MCP servers',
65
+ 'Paste the doc-bridge skill into Cursor rules (see docs/skills/doc-bridge.md)',
66
+ 'Before editing packages/* call handoff.resolve',
67
+ ]
68
+ : [
69
+ 'Restart Claude Desktop',
70
+ 'Run ak-docs index after doc changes',
71
+ ]
72
+
73
+ return {
74
+ ok: true,
75
+ target,
76
+ configPath,
77
+ created,
78
+ serverName: SERVER_NAME,
79
+ nextSteps,
80
+ }
81
+ }
82
+
83
+ export const mcpSnippet = (root: string): string =>
84
+ JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpServerEntry(root) } }, null, 2)
@@ -0,0 +1,190 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import type { MemoryPromotionDraft } from './pipeline.js'
6
+
7
+ export type GithubPrOptions = {
8
+ readonly dryRun?: boolean
9
+ readonly force?: boolean
10
+ readonly branch?: string
11
+ readonly base?: string
12
+ }
13
+
14
+ export type GithubPrResult = {
15
+ readonly ok: boolean
16
+ readonly dryRun: boolean
17
+ readonly draftPath: string
18
+ readonly branch: string
19
+ readonly prUrl?: string
20
+ readonly previewUrl?: string
21
+ readonly commands: readonly string[]
22
+ readonly message: string
23
+ }
24
+
25
+ const run = (cmd: string, args: readonly string[], cwd: string): { ok: boolean; out: string } => {
26
+ const result = spawnSync(cmd, [...args], { cwd, encoding: 'utf8' })
27
+ const out = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
28
+ return { ok: result.status === 0, out }
29
+ }
30
+
31
+ const hasGh = (): boolean => run('gh', ['--version'], process.cwd()).ok
32
+
33
+ const slug = (): string => new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
34
+
35
+ export const defaultPromotionDraftPath = (root: string): string =>
36
+ join(root, '.doc-bridge', 'drafts', `memory-promotion-${slug()}.md`)
37
+
38
+ export const writePromotionDraft = (root: string, draft: MemoryPromotionDraft, path?: string): string => {
39
+ const draftPath = path ?? defaultPromotionDraftPath(root)
40
+ mkdirSync(join(root, '.doc-bridge', 'drafts'), { recursive: true })
41
+ writeFileSync(draftPath, `${draft.body}\n`, 'utf8')
42
+ return draftPath
43
+ }
44
+
45
+ export const promoteMemoryToGithubPr = (
46
+ root: string,
47
+ draft: MemoryPromotionDraft,
48
+ options: GithubPrOptions = {},
49
+ ): GithubPrResult => {
50
+ if (!draft.ok && !options.force) {
51
+ return {
52
+ ok: false,
53
+ dryRun: Boolean(options.dryRun),
54
+ draftPath: '',
55
+ branch: '',
56
+ commands: [],
57
+ message: 'Safety scan blocked promotion. Fix findings or pass --force to draft anyway.',
58
+ }
59
+ }
60
+
61
+ const branch = options.branch ?? `doc-bridge/memory-promotion-${slug()}`
62
+ const draftPath = writePromotionDraft(root, draft)
63
+ const relDraft = draftPath.startsWith(`${root}/`) ? draftPath.slice(root.length + 1) : draftPath
64
+
65
+ const commands = [
66
+ `git checkout -b ${branch}`,
67
+ `git add ${relDraft}`,
68
+ `git commit -m "draft: doc-bridge memory promotion"`,
69
+ `git push -u origin ${branch}`,
70
+ `gh pr create --draft --title "${draft.title}" --body-file ${relDraft}${options.base ? ` --base ${options.base}` : ''}`,
71
+ ]
72
+
73
+ if (options.dryRun) {
74
+ return {
75
+ ok: true,
76
+ dryRun: true,
77
+ draftPath,
78
+ branch,
79
+ commands,
80
+ message: `Wrote draft to ${relDraft}. Run the printed git/gh commands to open a draft PR.`,
81
+ }
82
+ }
83
+
84
+ if (!existsSync(join(root, '.git'))) {
85
+ return {
86
+ ok: false,
87
+ dryRun: false,
88
+ draftPath,
89
+ branch,
90
+ commands,
91
+ message: 'Not a git repository. Commit the draft manually or run with --dry-run.',
92
+ }
93
+ }
94
+
95
+ if (!hasGh()) {
96
+ return {
97
+ ok: false,
98
+ dryRun: false,
99
+ draftPath,
100
+ branch,
101
+ commands,
102
+ message: 'GitHub CLI (gh) not found. Install gh or use --dry-run for local draft + commands.',
103
+ }
104
+ }
105
+
106
+ const auth = run('gh', ['auth', 'status'], root)
107
+ if (!auth.ok) {
108
+ return {
109
+ ok: false,
110
+ dryRun: false,
111
+ draftPath,
112
+ branch,
113
+ commands,
114
+ message: `gh is not authenticated. Run: gh auth login\n${auth.out}`,
115
+ }
116
+ }
117
+
118
+ const checkout = run('git', ['checkout', '-b', branch], root)
119
+ if (!checkout.ok) {
120
+ return {
121
+ ok: false,
122
+ dryRun: false,
123
+ draftPath,
124
+ branch,
125
+ commands,
126
+ message: `git checkout failed: ${checkout.out}`,
127
+ }
128
+ }
129
+
130
+ run('git', ['add', relDraft], root)
131
+ const commit = run('git', ['commit', '-m', 'draft: doc-bridge memory promotion'], root)
132
+ if (!commit.ok) {
133
+ return {
134
+ ok: false,
135
+ dryRun: false,
136
+ draftPath,
137
+ branch,
138
+ commands,
139
+ message: `git commit failed: ${commit.out}`,
140
+ }
141
+ }
142
+
143
+ const push = run('git', ['push', '-u', 'origin', branch], root)
144
+ if (!push.ok) {
145
+ return {
146
+ ok: false,
147
+ dryRun: false,
148
+ draftPath,
149
+ branch,
150
+ commands,
151
+ message: `git push failed: ${push.out}`,
152
+ }
153
+ }
154
+
155
+ const prArgs = [
156
+ 'pr',
157
+ 'create',
158
+ '--draft',
159
+ '--title',
160
+ draft.title,
161
+ '--body-file',
162
+ relDraft,
163
+ ...(options.base ? ['--base', options.base] : []),
164
+ ]
165
+ let prUrl = ''
166
+ try {
167
+ prUrl = execFileSync('gh', prArgs, { cwd: root, encoding: 'utf8' }).trim()
168
+ } catch (error) {
169
+ const message = error instanceof Error ? error.message : String(error)
170
+ return {
171
+ ok: false,
172
+ dryRun: false,
173
+ draftPath,
174
+ branch,
175
+ commands,
176
+ message: `gh pr create failed: ${message}`,
177
+ }
178
+ }
179
+
180
+ return {
181
+ ok: true,
182
+ dryRun: false,
183
+ draftPath,
184
+ branch,
185
+ prUrl,
186
+ ...(prUrl ? { previewUrl: prUrl } : {}),
187
+ commands,
188
+ message: prUrl ? `Draft PR opened: ${prUrl}` : 'Draft PR created.',
189
+ }
190
+ }
@@ -0,0 +1,121 @@
1
+ export const DOC_BRIDGE_PATTERN_ID = 'doc-bridge-pattern' as const
2
+
3
+ export const DOC_BRIDGE_PATTERN_META = {
4
+ id: DOC_BRIDGE_PATTERN_ID,
5
+ title: 'Doc Bridge Pattern',
6
+ slug: 'pillars/ai-collaboration/doc-bridge-pattern',
7
+ license: 'CC-BY-4.0',
8
+ visibility: 'public',
9
+ playbookUrl: 'https://playbook.agentskit.io/patterns/doc-bridge-pattern',
10
+ npmPackage: '@agentskit/doc-bridge',
11
+ cli: 'ak-docs',
12
+ } as const
13
+
14
+ export const docBridgePatternMarkdown = (): string => `---
15
+ type: pattern
16
+ id: ${DOC_BRIDGE_PATTERN_ID}
17
+ purpose: Route coding agents to the correct package, checks, and human docs in any monorepo.
18
+ owner: AgentsKit
19
+ license: CC-BY-4.0
20
+ visibility: public
21
+ tags: [agents, documentation, monorepo, handoff, mcp]
22
+ ---
23
+
24
+ # Doc Bridge Pattern
25
+
26
+ **AgentHandoff for your monorepo** — deterministic routing so agents edit the right roots, run the right checks, and stay linked to human documentation.
27
+
28
+ ## Problem
29
+
30
+ Coding agents guess package ownership. They edit sibling modules, run repo-wide tests, and ship changes without a bridge to human-facing guides. Wikis and RAG explain concepts but weakly answer *where to act*.
31
+
32
+ ## Solution (three artifacts)
33
+
34
+ | Artifact | Role |
35
+ |----------|------|
36
+ | **AgentHandoff** | JSON handoff: \`startHere\`, \`editRoots\`, \`checks\`, \`humanDoc\`, \`bridge\` |
37
+ | **DocBridgeIndex** | Deterministic index + \`contentHash\` for CI freshness gates |
38
+ | **Self-describe** | \`llms.txt\`, \`capabilities.json\` for discovery |
39
+
40
+ ## Four loops
41
+
42
+ | Loop | Command | Outcome |
43
+ |------|---------|---------|
44
+ | **Act** | \`ak-docs query package <id> --agent\` | Agent edits only \`editRoots\` |
45
+ | **Bridge** | \`ak-docs bootstrap agent-docs\` | Link agent corpus ↔ human site |
46
+ | **Learn** | \`ak-docs memory promote --pr\` | HITL draft PR from agent memory |
47
+ | **Explain** | \`ak-docs ask "<question>"\` | Local consult + handoff preview |
48
+
49
+ ## 60-second proof
50
+
51
+ \`\`\`bash
52
+ npm i -D @agentskit/doc-bridge
53
+ npx ak-docs demo --text
54
+ ak-docs mcp install --cursor
55
+ \`\`\`
56
+
57
+ ## AgentHandoff example
58
+
59
+ \`\`\`json
60
+ {
61
+ "type": "agent-handoff",
62
+ "startHere": "docs/for-agents/packages/auth.md",
63
+ "editRoots": ["packages/auth"],
64
+ "checks": ["pnpm --filter @demo/auth test"],
65
+ "humanDoc": "/docs/guides/auth",
66
+ "bridge": { "humanDoc": "linked" }
67
+ }
68
+ \`\`\`
69
+
70
+ When \`humanDoc\` is missing, handoffs surface \`bridge.action: "ak-docs bootstrap agent-docs"\` — a feature, not a silent gap.
71
+
72
+ ## MCP contract
73
+
74
+ Agents call \`handoff.resolve\` before editing \`packages/*\`:
75
+
76
+ 1. Read \`startHere\`
77
+ 2. Stay inside \`editRoots\`
78
+ 3. Run every \`checks\` command
79
+ 4. Link \`humanDoc\` or escalate missing bridge
80
+
81
+ ## CI gate
82
+
83
+ \`\`\`yaml
84
+ - uses: AgentsKit-io/doc-bridge@v1.0.0
85
+ \`\`\`
86
+
87
+ Or: \`ak-docs index && ak-docs gate run\` — stale index fails the PR.
88
+
89
+ ## Coverage metric
90
+
91
+ \`\`\`bash
92
+ ak-docs doctor --text
93
+ ak-docs doctor --badge
94
+ \`\`\`
95
+
96
+ Teams track handoff % and human-bridge % daily.
97
+
98
+ ## When to use
99
+
100
+ - pnpm/npm monorepos with real package ownership
101
+ - Fumadocus / Docusaurus human sites + dense agent corpus
102
+ - Cursor / Claude / Codex agents that should not guess edit roots
103
+
104
+ ## When not to use
105
+
106
+ - Single-file repos with no ownership boundaries (AGENTS.md may suffice)
107
+ - Hosted doc chat as primary product (doc-bridge is routing + bridge, not SaaS chat)
108
+
109
+ ## References
110
+
111
+ - npm: https://www.npmjs.com/package/@agentskit/doc-bridge
112
+ - repo: https://github.com/AgentsKit-io/doc-bridge
113
+ - skill: https://github.com/AgentsKit-io/doc-bridge/blob/master/docs/skills/doc-bridge.md
114
+ - landing: https://agentskit-io.github.io/doc-bridge/
115
+ `
116
+
117
+ export const docBridgePatternPayload = () => ({
118
+ ...DOC_BRIDGE_PATTERN_META,
119
+ format: 'okf-pattern-v1',
120
+ body: docBridgePatternMarkdown(),
121
+ })
@@ -32,6 +32,18 @@ const handoffForPackage = (
32
32
  const owner = index.lookup?.ownership?.[id]
33
33
  if (!owner) throw new Error(`Unknown package/ownership id "${id}". Try: ak-docs list packages`)
34
34
 
35
+ const bridge = owner.humanDoc
36
+ ? /^https?:\/\//.test(owner.humanDoc)
37
+ ? { humanDoc: 'external' as const }
38
+ : { humanDoc: 'linked' as const }
39
+ : config.corpus.human
40
+ ? {
41
+ humanDoc: 'missing' as const,
42
+ action: 'ak-docs bootstrap agent-docs',
43
+ bootstrap: `docs/for-agents/human/${id}.md`,
44
+ }
45
+ : undefined
46
+
35
47
  return normalizeAgentHandoff({
36
48
  type: 'agent-handoff',
37
49
  source: config.index?.outFile ?? '.doc-bridge/index.json',
@@ -47,7 +59,13 @@ const handoffForPackage = (
47
59
  editRoots: [owner.path],
48
60
  checks: [...owner.checks],
49
61
  ...(owner.humanDoc ? { humanDoc: owner.humanDoc } : {}),
50
- notes: owner.purpose ? [owner.purpose] : [],
62
+ ...(bridge ? { bridge } : {}),
63
+ notes: [
64
+ ...(owner.purpose ? [owner.purpose] : []),
65
+ ...(!owner.humanDoc && config.corpus.human
66
+ ? [`Human guide missing for ${id}. Run: ak-docs bootstrap agent-docs`]
67
+ : []),
68
+ ],
51
69
  })
52
70
  }
53
71
 
@@ -11,23 +11,80 @@ export type SearchMatch = {
11
11
  const tokenize = (value: string): string[] =>
12
12
  value
13
13
  .toLowerCase()
14
- .split(/[^a-z0-9]+/)
14
+ .split(/[^a-z0-9@/_-]+/)
15
15
  .filter((t) => t.length >= 2)
16
16
 
17
+ const PACKAGE_INTENT =
18
+ /\b(package|module|pkg|edit|change|where|owns?|ownership|handoff|start)\b/i
19
+
20
+ const scoreHay = (tokens: readonly string[], hay: string, weight = 1): number => {
21
+ let score = 0
22
+ for (const token of tokens) {
23
+ if (!hay.includes(token)) continue
24
+ score += token.length * weight
25
+ // whole-word-ish bonus
26
+ if (new RegExp(`(?:^|[^a-z0-9])${token}(?:[^a-z0-9]|$)`).test(hay)) {
27
+ score += token.length
28
+ }
29
+ }
30
+ return score
31
+ }
32
+
33
+ /** Exact / near-exact identity boost so "core" ranks the core package over mentions of @agentskit/core. */
34
+ const identityBoost = (id: string, path: string, tokens: readonly string[], term: string): number => {
35
+ const idLower = id.toLowerCase()
36
+ const termLower = term.toLowerCase().trim()
37
+ const base = path.split('/').pop()?.replace(/\.mdx?$/i, '').toLowerCase() ?? ''
38
+ let boost = 0
39
+
40
+ if (idLower === termLower || base === termLower) boost += 200
41
+ if (tokens.length === 1 && (idLower === tokens[0] || base === tokens[0])) boost += 200
42
+
43
+ for (const token of tokens) {
44
+ if (idLower === token) boost += 120
45
+ else if (idLower.startsWith(`${token}-`) || idLower.endsWith(`-${token}`)) boost += 40
46
+ else if (idLower.includes(token) && idLower.length <= token.length + 4) boost += 30
47
+ if (base === token) boost += 100
48
+ }
49
+
50
+ // Prefer short ids when term is the id (package name)
51
+ if (tokens.includes(idLower)) boost += Math.max(0, 40 - idLower.length)
52
+
53
+ return boost
54
+ }
55
+
56
+ const preferOwnership = (term: string): boolean =>
57
+ PACKAGE_INTENT.test(term) || /^(where|how).*(edit|change|package|module)/i.test(term)
58
+
17
59
  export const searchIndex = (index: DocBridgeIndexV1, term: string, limit = 20): SearchMatch[] => {
18
60
  const tokens = tokenize(term)
19
61
  if (!tokens.length) return []
20
62
 
21
- const matches: SearchMatch[] = []
63
+ const wantOwnership = preferOwnership(term)
64
+ const byPath = new Map<string, SearchMatch>()
22
65
 
23
- for (const entry of index.knowledge) {
24
- const hay = `${entry.id} ${entry.title} ${entry.description ?? ''} ${entry.path}`.toLowerCase()
25
- let score = 0
26
- for (const token of tokens) {
27
- if (hay.includes(token)) score += token.length
66
+ const consider = (match: SearchMatch) => {
67
+ const key = match.path
68
+ const existing = byPath.get(key)
69
+ if (!existing) {
70
+ byPath.set(key, match)
71
+ return
28
72
  }
73
+ // Prefer ownership over knowledge for same path; else higher score
74
+ const prefer =
75
+ match.score > existing.score ||
76
+ (match.score === existing.score && match.type === 'ownership' && existing.type !== 'ownership') ||
77
+ (wantOwnership && match.type === 'ownership' && existing.type !== 'ownership' && match.score >= existing.score - 20)
78
+ if (prefer) byPath.set(key, match)
79
+ }
80
+
81
+ for (const entry of index.knowledge) {
82
+ const body = (entry as { body?: string }).body ?? ''
83
+ const hay = `${entry.id} ${entry.title} ${entry.description ?? ''} ${entry.path} ${body}`.toLowerCase()
84
+ let score = scoreHay(tokens, hay, 1)
85
+ score += identityBoost(entry.id, entry.path, tokens, term)
29
86
  if (score > 0) {
30
- matches.push({
87
+ consider({
31
88
  type: 'knowledge',
32
89
  id: entry.id,
33
90
  path: entry.path,
@@ -38,21 +95,32 @@ export const searchIndex = (index: DocBridgeIndexV1, term: string, limit = 20):
38
95
  }
39
96
 
40
97
  for (const [id, owner] of Object.entries(index.lookup?.ownership ?? {})) {
41
- const hay = `${id} ${owner.path} ${owner.purpose ?? ''} ${owner.group ?? ''}`.toLowerCase()
42
- let score = 0
43
- for (const token of tokens) {
44
- if (hay.includes(token)) score += token.length * 2
45
- }
98
+ const path = owner.agentDoc ?? owner.path
99
+ const hay = `${id} ${owner.path} ${owner.purpose ?? ''} ${owner.group ?? ''} ${owner.agentDoc ?? ''} ${owner.humanDoc ?? ''}`.toLowerCase()
100
+ let score = scoreHay(tokens, hay, 2)
101
+ score += identityBoost(id, path, tokens, term)
102
+ // Ownership is primary for routing questions
103
+ if (wantOwnership) score += 25
104
+ score += 15 // slight base preference for actionable ownership targets
46
105
  if (score > 0) {
47
- matches.push({
106
+ consider({
48
107
  type: 'ownership',
49
108
  id,
50
- path: owner.agentDoc ?? owner.path,
109
+ path,
51
110
  ...(owner.purpose ? { summary: owner.purpose } : {}),
52
111
  score,
53
112
  })
54
113
  }
55
114
  }
56
115
 
57
- return matches.sort((a, b) => b.score - a.score).slice(0, limit)
58
- }
116
+ return [...byPath.values()].sort((a, b) => {
117
+ if (b.score !== a.score) return b.score - a.score
118
+ // Tie-break: exact id match, then ownership, then shorter id
119
+ const aExact = tokens.includes(a.id.toLowerCase()) ? 1 : 0
120
+ const bExact = tokens.includes(b.id.toLowerCase()) ? 1 : 0
121
+ if (bExact !== aExact) return bExact - aExact
122
+ if (a.type === 'ownership' && b.type !== 'ownership') return -1
123
+ if (b.type === 'ownership' && a.type !== 'ownership') return 1
124
+ return a.id.localeCompare(b.id)
125
+ }).slice(0, limit)
126
+ }
@@ -28,6 +28,16 @@ export const HandoffTargetSchema = z
28
28
 
29
29
  export type HandoffTarget = z.infer<typeof HandoffTargetSchema>
30
30
 
31
+ export const HandoffBridgeSchema = z
32
+ .object({
33
+ humanDoc: z.enum(['linked', 'missing', 'external']),
34
+ action: z.string().min(1).max(256).optional(),
35
+ bootstrap: z.string().min(1).max(256).optional(),
36
+ })
37
+ .strict()
38
+
39
+ export type HandoffBridge = z.infer<typeof HandoffBridgeSchema>
40
+
31
41
  /** v1 — canonical AgentHandoff. Legacy payloads may omit schemaVersion. */
32
42
  export const AgentHandoffV1Schema = z
33
43
  .object({
@@ -40,6 +50,7 @@ export const AgentHandoffV1Schema = z
40
50
  editRoots: z.array(z.string().min(1).max(512)).max(32),
41
51
  checks: z.array(z.string().min(1).max(256)).max(32),
42
52
  humanDoc: z.string().min(1).max(512).nullable().optional(),
53
+ bridge: HandoffBridgeSchema.optional(),
43
54
  playbookPatterns: z.array(z.string().url()).max(16).optional(),
44
55
  notes: z.array(z.string().min(1).max(1024)).max(16),
45
56
  })
@@ -21,7 +21,9 @@ export const KnowledgeEntrySchema = z
21
21
  type: z.string().min(1).max(128),
22
22
  title: z.string().min(1).max(256),
23
23
  path: z.string().min(1).max(512),
24
- description: z.string().max(1024).optional(),
24
+ description: z.string().max(2_048).optional(),
25
+ /** Flattened body excerpt for full-text search (not for display). */
26
+ body: z.string().max(8_000).optional(),
25
27
  links: z.array(z.string().min(1).max(512)).max(64).optional(),
26
28
  tags: z.array(z.string().min(1).max(64)).max(32).optional(),
27
29
  })
@@ -121,7 +121,8 @@ export const DocBridgeIndexV1JsonSchema = {
121
121
  type: { type: 'string', minLength: 1, maxLength: 128 },
122
122
  title: { type: 'string', minLength: 1, maxLength: 256 },
123
123
  path: { type: 'string', minLength: 1, maxLength: 512 },
124
- description: { type: 'string', maxLength: 1024 },
124
+ description: { type: 'string', maxLength: 2048 },
125
+ body: { type: 'string', maxLength: 8000 },
125
126
  links: stringArray(64),
126
127
  tags: stringArray(32),
127
128
  },
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '0.1.0-alpha.2'
1
+ export const PACKAGE_VERSION = '1.0.0'