@agentskit/doc-bridge 0.1.0-alpha.3 → 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.
- package/CHANGELOG.md +46 -0
- package/README.md +139 -57
- package/action.yml +78 -0
- package/dist/cli/program.js +987 -69
- package/dist/cli/program.js.map +1 -1
- package/dist/index.d.ts +238 -26
- package/dist/index.js +704 -22
- package/dist/index.js.map +1 -1
- package/docs/DOGFOOD-ROUND3.md +74 -0
- package/docs/POSITIONING.md +2 -0
- package/docs/RELEASE.md +5 -3
- package/docs/chat-and-rag.md +2 -0
- package/docs/getting-started.md +14 -1
- package/docs/landing/index.html +299 -0
- package/docs/mcp.md +10 -1
- package/docs/ollama-demo.md +64 -0
- package/docs/playbook/doc-bridge-pattern.md +114 -0
- package/docs/recipes/index-pipeline.md +89 -0
- package/docs/skills/doc-bridge.md +64 -0
- package/docs/spec/cli.md +6 -0
- package/docs/spec/playbook-feedback.md +12 -4
- package/examples/demo-example/doc-bridge.config.json +23 -0
- package/examples/demo-example/docs/for-agents/INDEX.md +3 -0
- package/examples/demo-example/docs/for-agents/packages/example.md +14 -0
- package/examples/demo-example/package.json +7 -0
- package/examples/demo-example/src/.gitkeep +0 -0
- package/examples/demo-monorepo/doc-bridge.config.json +37 -0
- package/examples/demo-monorepo/docs/for-agents/INDEX.md +4 -0
- package/examples/demo-monorepo/docs/for-agents/packages/auth.md +22 -0
- package/examples/demo-monorepo/docs/for-agents/packages/billing.md +19 -0
- package/examples/demo-monorepo/docs/human/guides/auth.md +7 -0
- package/examples/demo-monorepo/package.json +7 -0
- package/examples/demo-monorepo/packages/auth/package.json +8 -0
- package/examples/demo-monorepo/packages/billing/package.json +7 -0
- package/examples/demo-monorepo/pnpm-workspace.yaml +2 -0
- package/examples/ollama-chat.config.ts +42 -0
- package/package.json +5 -2
- package/src/cli/demo.ts +143 -0
- package/src/cli/program.ts +194 -14
- package/src/doctor/badge.ts +53 -0
- package/src/doctor/run-doctor.ts +286 -0
- package/src/index-builder/build-handoffs.ts +19 -1
- package/src/index-builder/watch-index.ts +94 -0
- package/src/index.ts +24 -0
- package/src/mcp/install.ts +84 -0
- package/src/memory/github-pr.ts +190 -0
- package/src/playbook/doc-bridge-pattern.ts +121 -0
- package/src/query/query.ts +19 -1
- package/src/schemas/agent-handoff.ts +11 -0
- package/src/version.ts +1 -1
package/src/cli/program.ts
CHANGED
|
@@ -15,6 +15,16 @@ import { createDocBridgeRag } from '../intelligence/rag.js'
|
|
|
15
15
|
import { firstHeading, firstParagraph } from '../lib/markdown.js'
|
|
16
16
|
import { ingestMemoryCandidates } from '../memory/ingest.js'
|
|
17
17
|
import { classifyMemoryCandidates, draftMemoryPromotion } from '../memory/pipeline.js'
|
|
18
|
+
import { promoteMemoryToGithubPr } from '../memory/github-pr.js'
|
|
19
|
+
import { watchDocBridgeIndex } from '../index-builder/watch-index.js'
|
|
20
|
+
import {
|
|
21
|
+
formatDoctorBadgeJson,
|
|
22
|
+
formatDoctorBadgeMarkdown,
|
|
23
|
+
} from '../doctor/badge.js'
|
|
24
|
+
import { docBridgePatternMarkdown, docBridgePatternPayload } from '../playbook/doc-bridge-pattern.js'
|
|
25
|
+
import { formatDemoText, runDemo, withDemoWorkspace, type DemoFixture } from './demo.js'
|
|
26
|
+
import { formatDoctorText, runDoctor } from '../doctor/run-doctor.js'
|
|
27
|
+
import { installMcpConfig, mcpSnippet } from '../mcp/install.js'
|
|
18
28
|
import { startMcpStdioServer } from '../mcp/server.js'
|
|
19
29
|
import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
|
|
20
30
|
import { runQuery, type QueryKind } from '../query/query.js'
|
|
@@ -36,6 +46,8 @@ type Command =
|
|
|
36
46
|
| 'index'
|
|
37
47
|
| 'gate'
|
|
38
48
|
| 'mcp'
|
|
49
|
+
| 'doctor'
|
|
50
|
+
| 'demo'
|
|
39
51
|
| 'query'
|
|
40
52
|
| 'search'
|
|
41
53
|
| 'retrieve'
|
|
@@ -48,14 +60,17 @@ const usage = `ak-docs — human↔agent documentation bridge (@agentskit/doc-br
|
|
|
48
60
|
|
|
49
61
|
Core (no API key):
|
|
50
62
|
ak-docs init [--demo] [--scaffold-workspaces]
|
|
51
|
-
ak-docs
|
|
63
|
+
ak-docs demo [--fixture example|monorepo] [--text] [--in-project]
|
|
64
|
+
ak-docs doctor [--text] [--badge] [--write-badge]
|
|
65
|
+
ak-docs index [--watch]
|
|
52
66
|
ak-docs query <package|ownership|intent|change> <id> [--agent] [--text]
|
|
53
67
|
ak-docs search <term> [--agent] [--text]
|
|
54
68
|
ak-docs list <packages|intents|changes|knowledge> [--text]
|
|
55
69
|
ak-docs ask [question] local consult (no LLM)
|
|
56
70
|
ak-docs gate run [gate-id]
|
|
57
71
|
ak-docs mcp
|
|
58
|
-
ak-docs
|
|
72
|
+
ak-docs mcp install --cursor | --claude
|
|
73
|
+
ak-docs memory ingest|classify|promote [--pr] [--dry-run]
|
|
59
74
|
ak-docs bootstrap agent-docs
|
|
60
75
|
ak-docs validate-config | validate-handoff <file>
|
|
61
76
|
|
|
@@ -67,7 +82,7 @@ Intelligence (optional AgentsKit peers):
|
|
|
67
82
|
Advanced / ecosystem:
|
|
68
83
|
ak-docs retrieve <query>
|
|
69
84
|
ak-docs registry topology
|
|
70
|
-
ak-docs playbook draft
|
|
85
|
+
ak-docs playbook draft | pattern [--text]
|
|
71
86
|
|
|
72
87
|
Global flags:
|
|
73
88
|
-h, --help --version
|
|
@@ -112,6 +127,8 @@ const parseArgs = (argv: readonly string[]) => {
|
|
|
112
127
|
else if (positional[0] === 'index') command = 'index'
|
|
113
128
|
else if (positional[0] === 'gate') command = 'gate'
|
|
114
129
|
else if (positional[0] === 'mcp') command = 'mcp'
|
|
130
|
+
else if (positional[0] === 'doctor') command = 'doctor'
|
|
131
|
+
else if (positional[0] === 'demo') command = 'demo'
|
|
115
132
|
else if (positional[0] === 'query') command = 'query'
|
|
116
133
|
else if (positional[0] === 'search') command = 'search'
|
|
117
134
|
else if (positional[0] === 'retrieve') command = 'retrieve'
|
|
@@ -201,23 +218,60 @@ const writeTextSearch = (
|
|
|
201
218
|
])
|
|
202
219
|
}
|
|
203
220
|
|
|
221
|
+
const handoffSummaryLines = (
|
|
222
|
+
index: DocBridgeIndexV1,
|
|
223
|
+
config: DocBridgeConfigV1,
|
|
224
|
+
ownerId: string,
|
|
225
|
+
): string[] => {
|
|
226
|
+
try {
|
|
227
|
+
const handoff = runQuery(index, config, { kind: 'ownership', id: ownerId, agent: true })
|
|
228
|
+
if (!handoff || typeof handoff !== 'object' || !('editRoots' in handoff)) return []
|
|
229
|
+
const payload = handoff as {
|
|
230
|
+
startHere?: string
|
|
231
|
+
editRoots?: string[]
|
|
232
|
+
checks?: string[]
|
|
233
|
+
humanDoc?: string | null
|
|
234
|
+
bridge?: { humanDoc?: string; action?: string }
|
|
235
|
+
}
|
|
236
|
+
const bridgeLine =
|
|
237
|
+
payload.bridge?.humanDoc === 'missing'
|
|
238
|
+
? `Bridge: human guide missing → ${payload.bridge.action ?? 'ak-docs bootstrap agent-docs'}`
|
|
239
|
+
: payload.humanDoc
|
|
240
|
+
? `Bridge: ${payload.humanDoc}`
|
|
241
|
+
: 'Bridge: (no human plugin configured)'
|
|
242
|
+
return [
|
|
243
|
+
'',
|
|
244
|
+
'Handoff preview',
|
|
245
|
+
` start: ${payload.startHere ?? '(unknown)'}`,
|
|
246
|
+
` edit: ${(payload.editRoots ?? []).join(', ') || '(none)'}`,
|
|
247
|
+
` checks: ${(payload.checks ?? []).join(' · ') || '(none)'}`,
|
|
248
|
+
` ${bridgeLine}`,
|
|
249
|
+
]
|
|
250
|
+
} catch {
|
|
251
|
+
return []
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
204
255
|
const writeAsk = (
|
|
205
256
|
question: string,
|
|
206
257
|
matches: ReturnType<typeof searchIndex>,
|
|
207
258
|
index: DocBridgeIndexV1,
|
|
259
|
+
config: DocBridgeConfigV1,
|
|
208
260
|
): void => {
|
|
209
261
|
// Prefer ownership match for routing questions
|
|
210
262
|
const owner =
|
|
211
263
|
matches.find((match) => match.type === 'ownership') ??
|
|
212
264
|
matches.find((match) => Boolean(index.lookup?.ownership?.[match.id]))
|
|
213
265
|
const best = owner ?? matches[0]
|
|
214
|
-
const
|
|
215
|
-
best && (best.type === 'ownership' || index.lookup?.ownership?.[best.id])
|
|
216
|
-
|
|
217
|
-
|
|
266
|
+
const ownerId =
|
|
267
|
+
best && (best.type === 'ownership' || index.lookup?.ownership?.[best.id]) ? best.id : undefined
|
|
268
|
+
const bestQuery = ownerId
|
|
269
|
+
? `ak-docs query ownership ${ownerId} --agent`
|
|
270
|
+
: 'ak-docs list knowledge --text'
|
|
218
271
|
writeLines([
|
|
219
272
|
`Question: ${question}`,
|
|
220
273
|
best ? `Best match: ${best.type} ${best.id} (${best.path})` : 'Best match: none',
|
|
274
|
+
...(ownerId ? handoffSummaryLines(index, config, ownerId) : []),
|
|
221
275
|
'',
|
|
222
276
|
'Matches:',
|
|
223
277
|
...(
|
|
@@ -231,8 +285,9 @@ const writeAsk = (
|
|
|
231
285
|
? [
|
|
232
286
|
`ak-docs search "${question}" --agent`,
|
|
233
287
|
bestQuery,
|
|
288
|
+
...(ownerId ? [`ak-docs doctor --text`] : []),
|
|
234
289
|
]
|
|
235
|
-
: ['ak-docs list knowledge --text']),
|
|
290
|
+
: ['ak-docs list knowledge --text', 'ak-docs doctor --text']),
|
|
236
291
|
])
|
|
237
292
|
}
|
|
238
293
|
|
|
@@ -271,7 +326,7 @@ const runAskRepl = async (root: string, config: DocBridgeConfigV1): Promise<numb
|
|
|
271
326
|
const gateId = value || undefined
|
|
272
327
|
writeJson(runGates(root, config, gateId ? [gateId as GateId] : undefined))
|
|
273
328
|
} else {
|
|
274
|
-
writeAsk(line, searchIndex(index, line, 8), index)
|
|
329
|
+
writeAsk(line, searchIndex(index, line, 8), index, config)
|
|
275
330
|
}
|
|
276
331
|
} catch (error) {
|
|
277
332
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
@@ -605,7 +660,9 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
|
|
|
605
660
|
|
|
606
661
|
if (command === 'memory') {
|
|
607
662
|
if (!['ingest', 'classify', 'promote'].includes(positional[1] ?? '')) {
|
|
608
|
-
process.stderr.write(
|
|
663
|
+
process.stderr.write(
|
|
664
|
+
'Usage: ak-docs memory <ingest|classify|promote> [--pr] [--dry-run] [--force] [--config <path>]\n',
|
|
665
|
+
)
|
|
609
666
|
return 1
|
|
610
667
|
}
|
|
611
668
|
try {
|
|
@@ -622,6 +679,23 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
|
|
|
622
679
|
return 0
|
|
623
680
|
}
|
|
624
681
|
const draft = draftMemoryPromotion(classifications)
|
|
682
|
+
if (flags.has('--pr') || flags.has('--github')) {
|
|
683
|
+
const pr = promoteMemoryToGithubPr(root, draft, {
|
|
684
|
+
dryRun: flags.has('--dry-run'),
|
|
685
|
+
force: flags.has('--force'),
|
|
686
|
+
})
|
|
687
|
+
if (flags.has('--text')) {
|
|
688
|
+
writeLines([
|
|
689
|
+
pr.message,
|
|
690
|
+
...(pr.draftPath ? [`Draft: ${pr.draftPath}`] : []),
|
|
691
|
+
...(pr.prUrl ? [`PR: ${pr.prUrl}`] : []),
|
|
692
|
+
...(pr.commands.length ? ['', 'Commands:', ...pr.commands.map((cmd) => ` ${cmd}`)] : []),
|
|
693
|
+
])
|
|
694
|
+
} else {
|
|
695
|
+
writeJson({ ...draft, pr })
|
|
696
|
+
}
|
|
697
|
+
return pr.ok ? 0 : 1
|
|
698
|
+
}
|
|
625
699
|
writeJson(draft)
|
|
626
700
|
return draft.ok ? 0 : 1
|
|
627
701
|
} catch (error) {
|
|
@@ -640,8 +714,18 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
|
|
|
640
714
|
}
|
|
641
715
|
|
|
642
716
|
if (command === 'playbook') {
|
|
643
|
-
|
|
644
|
-
|
|
717
|
+
const action = positional[1]
|
|
718
|
+
if (action === 'pattern') {
|
|
719
|
+
const payload = docBridgePatternPayload()
|
|
720
|
+
if (flags.has('--text') || wantsTextOutput(flags, { schemaVersion: 1, corpus: { agent: { root: 'docs' } } } as DocBridgeConfigV1)) {
|
|
721
|
+
process.stdout.write(`${docBridgePatternMarkdown()}\n`)
|
|
722
|
+
} else {
|
|
723
|
+
writeJson(payload)
|
|
724
|
+
}
|
|
725
|
+
return 0
|
|
726
|
+
}
|
|
727
|
+
if (action !== 'draft') {
|
|
728
|
+
process.stderr.write('Usage: ak-docs playbook draft | pattern [--text] [--config <path>]\n')
|
|
645
729
|
return 1
|
|
646
730
|
}
|
|
647
731
|
try {
|
|
@@ -652,6 +736,8 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
|
|
|
652
736
|
...draft,
|
|
653
737
|
title: 'Draft Playbook feedback promotion',
|
|
654
738
|
pattern: 'Doc Bridge Pattern',
|
|
739
|
+
patternDoc: 'docs/playbook/doc-bridge-pattern.md',
|
|
740
|
+
exportCommand: 'ak-docs playbook pattern --text',
|
|
655
741
|
})
|
|
656
742
|
return 0
|
|
657
743
|
} catch (error) {
|
|
@@ -662,7 +748,14 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
|
|
|
662
748
|
|
|
663
749
|
if (command === 'index') {
|
|
664
750
|
try {
|
|
665
|
-
const { config, root } = loadProject(configPath)
|
|
751
|
+
const { config, root, configPath: loadedConfigPath } = loadProject(configPath)
|
|
752
|
+
if (flags.has('--watch')) {
|
|
753
|
+
return watchDocBridgeIndex({
|
|
754
|
+
root,
|
|
755
|
+
config,
|
|
756
|
+
configPath: loadedConfigPath,
|
|
757
|
+
})
|
|
758
|
+
}
|
|
666
759
|
const result = buildDocBridgeIndex({ root, config })
|
|
667
760
|
const diagnostics = indexDiagnostics(config, result)
|
|
668
761
|
const handoffCount = Object.keys(result.index.handoffs ?? {}).length
|
|
@@ -710,6 +803,34 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
|
|
|
710
803
|
}
|
|
711
804
|
|
|
712
805
|
if (command === 'mcp') {
|
|
806
|
+
if (positional[1] === 'install') {
|
|
807
|
+
const target = flags.has('--claude') ? 'claude' : flags.has('--cursor') ? 'cursor' : undefined
|
|
808
|
+
if (!target) {
|
|
809
|
+
process.stderr.write('Usage: ak-docs mcp install --cursor | --claude\n')
|
|
810
|
+
return 1
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
const { config, root } = loadProject(configPath)
|
|
814
|
+
const result = installMcpConfig(root, target)
|
|
815
|
+
if (wantsTextOutput(flags, config)) {
|
|
816
|
+
writeLines([
|
|
817
|
+
`Installed MCP server "${result.serverName}" for ${result.target}`,
|
|
818
|
+
`Config: ${result.configPath}`,
|
|
819
|
+
...(result.created ? ['Created new config file'] : ['Merged into existing config']),
|
|
820
|
+
'',
|
|
821
|
+
'Next steps:',
|
|
822
|
+
...result.nextSteps.map((step) => ` → ${step}`),
|
|
823
|
+
])
|
|
824
|
+
} else {
|
|
825
|
+
writeJson({ ...result, snippet: mcpSnippet(root) })
|
|
826
|
+
}
|
|
827
|
+
return 0
|
|
828
|
+
} catch (error) {
|
|
829
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
830
|
+
return 1
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
713
834
|
try {
|
|
714
835
|
const { config, root } = loadProject(configPath)
|
|
715
836
|
startMcpStdioServer({ root, config })
|
|
@@ -720,6 +841,65 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
|
|
|
720
841
|
}
|
|
721
842
|
}
|
|
722
843
|
|
|
844
|
+
if (command === 'doctor') {
|
|
845
|
+
try {
|
|
846
|
+
const { config, root } = loadProject(configPath)
|
|
847
|
+
const report = runDoctor(root, config)
|
|
848
|
+
if (flags.has('--write-badge')) {
|
|
849
|
+
const badgePath = resolve(root, '.doc-bridge', 'coverage-badge.json')
|
|
850
|
+
mkdirSync(dirname(badgePath), { recursive: true })
|
|
851
|
+
writeFileSync(badgePath, `${formatDoctorBadgeJson(report.badge)}\n`, 'utf8')
|
|
852
|
+
}
|
|
853
|
+
if (flags.has('--badge')) {
|
|
854
|
+
writeLines([formatDoctorBadgeMarkdown(report.badge)])
|
|
855
|
+
return 0
|
|
856
|
+
}
|
|
857
|
+
if (wantsTextOutput(flags, config)) writeLines(formatDoctorText(report))
|
|
858
|
+
else writeJson(report)
|
|
859
|
+
return report.ok ? 0 : 1
|
|
860
|
+
} catch (error) {
|
|
861
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
862
|
+
return 1
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (command === 'demo') {
|
|
867
|
+
const resolveDemoFixture = (): DemoFixture => {
|
|
868
|
+
const fixtureFlagIndex = positional.indexOf('--fixture')
|
|
869
|
+
if (fixtureFlagIndex >= 0) {
|
|
870
|
+
const value = positional[fixtureFlagIndex + 1]
|
|
871
|
+
if (value === 'monorepo' || value === 'example') return value
|
|
872
|
+
}
|
|
873
|
+
if (flags.has('--monorepo') || positional.includes('monorepo')) return 'monorepo'
|
|
874
|
+
if (positional[1] === 'monorepo') return 'monorepo'
|
|
875
|
+
return 'example'
|
|
876
|
+
}
|
|
877
|
+
const resolvedFixture = resolveDemoFixture()
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
const useProject = flags.has('--in-project') || flags.has('--copy-fixture')
|
|
881
|
+
if (!useProject) {
|
|
882
|
+
const result = withDemoWorkspace(resolvedFixture, (root, config) =>
|
|
883
|
+
runDemo(root, config, resolvedFixture),
|
|
884
|
+
)
|
|
885
|
+
const textMode =
|
|
886
|
+
flags.has('--text') || (!flags.has('--json') && !flags.has('--agent'))
|
|
887
|
+
if (textMode) writeLines(formatDemoText(result))
|
|
888
|
+
else writeJson(result)
|
|
889
|
+
return result.ok ? 0 : 1
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const { config, root } = loadProject(configPath)
|
|
893
|
+
const result = runDemo(root, config, resolvedFixture, { copyFixture: flags.has('--copy-fixture') })
|
|
894
|
+
if (wantsTextOutput(flags, config)) writeLines(formatDemoText(result))
|
|
895
|
+
else writeJson(result)
|
|
896
|
+
return result.ok ? 0 : 1
|
|
897
|
+
} catch (error) {
|
|
898
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
899
|
+
return 1
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
723
903
|
if (command === 'query') {
|
|
724
904
|
const kind = positional[1] as QueryKind | undefined
|
|
725
905
|
const id = positional[2]
|
|
@@ -889,7 +1069,7 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
|
|
|
889
1069
|
return 1
|
|
890
1070
|
}
|
|
891
1071
|
const index = loadDocBridgeIndex(root, config)
|
|
892
|
-
writeAsk(question, searchIndex(index, question, 8), index)
|
|
1072
|
+
writeAsk(question, searchIndex(index, question, 8), index, config)
|
|
893
1073
|
return 0
|
|
894
1074
|
} catch (error) {
|
|
895
1075
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DoctorReport } from './run-doctor.js'
|
|
2
|
+
|
|
3
|
+
export type DoctorBadgeMetrics = {
|
|
4
|
+
readonly handoffPct: number
|
|
5
|
+
readonly bridgePct: number
|
|
6
|
+
readonly score: number
|
|
7
|
+
readonly grade: DoctorReport['grade']
|
|
8
|
+
readonly packages: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const doctorBadgeMetrics = (report: DoctorReport): DoctorBadgeMetrics => {
|
|
12
|
+
const { packages } = report.coverage
|
|
13
|
+
const handoffPct =
|
|
14
|
+
packages.total > 0 ? Math.round((packages.withAgentDoc / packages.total) * 100) : 0
|
|
15
|
+
const bridgePct =
|
|
16
|
+
packages.total > 0 ? Math.round((packages.withHumanDoc / packages.total) * 100) : 0
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
handoffPct,
|
|
20
|
+
bridgePct,
|
|
21
|
+
score: report.score,
|
|
22
|
+
grade: report.grade,
|
|
23
|
+
packages: packages.total,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const badgeColor = (pct: number): string => {
|
|
28
|
+
if (pct >= 80) return '2ea44f'
|
|
29
|
+
if (pct >= 50) return 'dbab09'
|
|
30
|
+
return 'cb2431'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const formatDoctorBadgeMarkdown = (metrics: DoctorBadgeMetrics): string => {
|
|
34
|
+
const handoff = `handoff_coverage-${metrics.handoffPct}%25-${badgeColor(metrics.handoffPct)}`
|
|
35
|
+
const bridge = `human_bridge-${metrics.bridgePct}%25-${badgeColor(metrics.bridgePct)}`
|
|
36
|
+
return [
|
|
37
|
+
``,
|
|
38
|
+
``,
|
|
39
|
+
`}?style=flat-square)`,
|
|
40
|
+
].join(' ')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const formatDoctorBadgeJson = (metrics: DoctorBadgeMetrics): string =>
|
|
44
|
+
JSON.stringify(
|
|
45
|
+
{
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
...metrics,
|
|
48
|
+
markdown: formatDoctorBadgeMarkdown(metrics),
|
|
49
|
+
updatedAt: new Date().toISOString(),
|
|
50
|
+
},
|
|
51
|
+
null,
|
|
52
|
+
2,
|
|
53
|
+
)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
2
|
+
import { buildDocBridgeIndex } from '../index-builder/build-index.js'
|
|
3
|
+
import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
|
|
4
|
+
import { runGates, type GateRunResult } from '../gates/run-gates.js'
|
|
5
|
+
import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
|
|
6
|
+
import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
|
|
7
|
+
import { doctorBadgeMetrics, type DoctorBadgeMetrics } from './badge.js'
|
|
8
|
+
|
|
9
|
+
export type DoctorIssue = {
|
|
10
|
+
readonly severity: 'error' | 'warn' | 'info'
|
|
11
|
+
readonly code: string
|
|
12
|
+
readonly message: string
|
|
13
|
+
readonly action?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type DoctorCoverage = {
|
|
17
|
+
readonly packages: {
|
|
18
|
+
readonly total: number
|
|
19
|
+
readonly withAgentDoc: number
|
|
20
|
+
readonly withHumanDoc: number
|
|
21
|
+
readonly missingAgentDoc: readonly string[]
|
|
22
|
+
readonly missingHumanDoc: readonly string[]
|
|
23
|
+
}
|
|
24
|
+
readonly agentDocs: {
|
|
25
|
+
readonly total: number
|
|
26
|
+
readonly indexed: number
|
|
27
|
+
readonly unindexed: readonly string[]
|
|
28
|
+
}
|
|
29
|
+
readonly freshness: {
|
|
30
|
+
readonly ok: boolean
|
|
31
|
+
readonly message: string
|
|
32
|
+
readonly hasIndex: boolean
|
|
33
|
+
}
|
|
34
|
+
readonly gates: GateRunResult
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DoctorReport = {
|
|
38
|
+
readonly ok: boolean
|
|
39
|
+
readonly score: number
|
|
40
|
+
readonly grade: 'A' | 'B' | 'C' | 'D' | 'F'
|
|
41
|
+
readonly coverage: DoctorCoverage
|
|
42
|
+
readonly badge: DoctorBadgeMetrics
|
|
43
|
+
readonly issues: readonly DoctorIssue[]
|
|
44
|
+
readonly nextActions: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const gradeForScore = (score: number): DoctorReport['grade'] => {
|
|
48
|
+
if (score >= 90) return 'A'
|
|
49
|
+
if (score >= 75) return 'B'
|
|
50
|
+
if (score >= 60) return 'C'
|
|
51
|
+
if (score >= 40) return 'D'
|
|
52
|
+
return 'F'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const agentDocPaths = (index: DocBridgeIndexV1): Set<string> => {
|
|
56
|
+
const paths = new Set<string>()
|
|
57
|
+
for (const owner of Object.values(index.lookup?.ownership ?? {})) {
|
|
58
|
+
if (owner.agentDoc) paths.add(owner.agentDoc)
|
|
59
|
+
}
|
|
60
|
+
for (const handoff of Object.values(index.handoffs ?? {})) {
|
|
61
|
+
if (handoff.startHere) paths.add(handoff.startHere)
|
|
62
|
+
}
|
|
63
|
+
return paths
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const computeScore = (coverage: DoctorCoverage): number => {
|
|
67
|
+
let score = 0
|
|
68
|
+
|
|
69
|
+
if (coverage.freshness.hasIndex) score += 15
|
|
70
|
+
if (coverage.freshness.ok) score += 15
|
|
71
|
+
|
|
72
|
+
const { total, withAgentDoc, withHumanDoc } = coverage.packages
|
|
73
|
+
if (total > 0) {
|
|
74
|
+
score += Math.round((withAgentDoc / total) * 35)
|
|
75
|
+
score += Math.round((withHumanDoc / total) * 20)
|
|
76
|
+
} else if (coverage.agentDocs.indexed > 0) {
|
|
77
|
+
score += 35
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (coverage.gates.ok) score += 15
|
|
81
|
+
else {
|
|
82
|
+
const passed = coverage.gates.results.filter((gate) => gate.ok).length
|
|
83
|
+
const totalGates = coverage.gates.results.length || 1
|
|
84
|
+
score += Math.round((passed / totalGates) * 10)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return Math.min(100, Math.max(0, score))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
|
|
91
|
+
const issues: DoctorIssue[] = []
|
|
92
|
+
|
|
93
|
+
if (!coverage.freshness.hasIndex) {
|
|
94
|
+
issues.push({
|
|
95
|
+
severity: 'error',
|
|
96
|
+
code: 'index-missing',
|
|
97
|
+
message: 'No doc-bridge index found.',
|
|
98
|
+
action: 'ak-docs index',
|
|
99
|
+
})
|
|
100
|
+
} else if (!coverage.freshness.ok) {
|
|
101
|
+
issues.push({
|
|
102
|
+
severity: 'error',
|
|
103
|
+
code: 'index-stale',
|
|
104
|
+
message: coverage.freshness.message,
|
|
105
|
+
action: 'ak-docs index',
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const id of coverage.packages.missingAgentDoc) {
|
|
110
|
+
issues.push({
|
|
111
|
+
severity: 'warn',
|
|
112
|
+
code: 'missing-agent-doc',
|
|
113
|
+
message: `Package "${id}" has no dedicated agent doc.`,
|
|
114
|
+
action: `ak-docs init --scaffold-workspaces # or edit docs/for-agents/packages/${id}.md`,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const id of coverage.packages.missingHumanDoc) {
|
|
119
|
+
issues.push({
|
|
120
|
+
severity: 'info',
|
|
121
|
+
code: 'missing-human-doc',
|
|
122
|
+
message: `Package "${id}" has no linked human guide.`,
|
|
123
|
+
action: 'ak-docs bootstrap agent-docs',
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const gate of coverage.gates.results.filter((result) => !result.ok)) {
|
|
128
|
+
issues.push({
|
|
129
|
+
severity: gate.id === 'index-freshness' ? 'error' : 'warn',
|
|
130
|
+
code: `gate-${gate.id}`,
|
|
131
|
+
message: gate.message,
|
|
132
|
+
action: gate.id === 'index-freshness' ? 'ak-docs index' : 'ak-docs gate run',
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return issues
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const buildNextActions = (issues: readonly DoctorIssue[], coverage: DoctorCoverage): string[] => {
|
|
140
|
+
const actions = new Set<string>()
|
|
141
|
+
for (const issue of issues) {
|
|
142
|
+
if (issue.action) actions.add(issue.action)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!coverage.freshness.hasIndex || !coverage.freshness.ok) {
|
|
146
|
+
actions.add('ak-docs index')
|
|
147
|
+
}
|
|
148
|
+
if (coverage.packages.missingHumanDoc.length) {
|
|
149
|
+
actions.add('ak-docs bootstrap agent-docs')
|
|
150
|
+
}
|
|
151
|
+
if (coverage.packages.total > 0) {
|
|
152
|
+
const sample =
|
|
153
|
+
coverage.packages.missingAgentDoc[0] ??
|
|
154
|
+
coverage.packages.missingHumanDoc[0]
|
|
155
|
+
if (sample) actions.add(`ak-docs query package ${sample} --agent`)
|
|
156
|
+
}
|
|
157
|
+
if (!actions.size) {
|
|
158
|
+
actions.add('ak-docs mcp install --cursor')
|
|
159
|
+
actions.add('ak-docs gate run')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...actions].slice(0, 6)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport => {
|
|
166
|
+
let index: DocBridgeIndexV1 | undefined
|
|
167
|
+
let hasIndex = true
|
|
168
|
+
let freshnessOk = false
|
|
169
|
+
let freshnessMessage = 'Index is fresh'
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
index = loadDocBridgeIndex(root, config)
|
|
173
|
+
const next = buildDocBridgeIndex({ root, config, write: false }).index.contentHash
|
|
174
|
+
freshnessOk = index.contentHash === next
|
|
175
|
+
freshnessMessage = freshnessOk ? 'Index is fresh' : 'Index is stale. Run: ak-docs index'
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof IndexNotFoundError) {
|
|
178
|
+
hasIndex = false
|
|
179
|
+
freshnessOk = false
|
|
180
|
+
freshnessMessage = error.message
|
|
181
|
+
index = buildDocBridgeIndex({ root, config, write: false }).index
|
|
182
|
+
} else {
|
|
183
|
+
throw error
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const ownership = Object.entries(index.lookup?.ownership ?? {})
|
|
188
|
+
const missingAgentDoc = ownership
|
|
189
|
+
.filter(([, owner]) => !owner.agentDoc || owner.agentDoc === config.corpus.agent.index)
|
|
190
|
+
.map(([id]) => id)
|
|
191
|
+
const missingHumanDoc = ownership.filter(([, owner]) => !owner.humanDoc).map(([id]) => id)
|
|
192
|
+
|
|
193
|
+
const indexedPaths = new Set(index.knowledge.map((entry) => entry.path))
|
|
194
|
+
const expectedAgentDocs = agentDocPaths(index)
|
|
195
|
+
const unindexed = [...expectedAgentDocs].filter((path) => !indexedPaths.has(path))
|
|
196
|
+
|
|
197
|
+
const corpusDocs = scanAgentCorpus(root, config).filter(
|
|
198
|
+
(doc) => doc.path !== config.corpus.agent.index,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const gates = runGates(root, config)
|
|
202
|
+
|
|
203
|
+
const coverage: DoctorCoverage = {
|
|
204
|
+
packages: {
|
|
205
|
+
total: ownership.length,
|
|
206
|
+
withAgentDoc: ownership.length - missingAgentDoc.length,
|
|
207
|
+
withHumanDoc: ownership.length - missingHumanDoc.length,
|
|
208
|
+
missingAgentDoc,
|
|
209
|
+
missingHumanDoc,
|
|
210
|
+
},
|
|
211
|
+
agentDocs: {
|
|
212
|
+
total: corpusDocs.length,
|
|
213
|
+
indexed: corpusDocs.filter((doc) => indexedPaths.has(doc.path)).length,
|
|
214
|
+
unindexed: corpusDocs.filter((doc) => !indexedPaths.has(doc.path)).map((doc) => doc.path),
|
|
215
|
+
},
|
|
216
|
+
freshness: {
|
|
217
|
+
ok: freshnessOk,
|
|
218
|
+
message: freshnessMessage,
|
|
219
|
+
hasIndex,
|
|
220
|
+
},
|
|
221
|
+
gates,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const issues = buildIssues(coverage)
|
|
225
|
+
const score = computeScore(coverage)
|
|
226
|
+
const nextActions = buildNextActions(issues, coverage)
|
|
227
|
+
|
|
228
|
+
const report: DoctorReport = {
|
|
229
|
+
ok: issues.every((issue) => issue.severity !== 'error') && gates.ok,
|
|
230
|
+
score,
|
|
231
|
+
grade: gradeForScore(score),
|
|
232
|
+
coverage,
|
|
233
|
+
badge: { handoffPct: 0, bridgePct: 0, score, grade: gradeForScore(score), packages: 0 },
|
|
234
|
+
issues,
|
|
235
|
+
nextActions,
|
|
236
|
+
}
|
|
237
|
+
return { ...report, badge: doctorBadgeMetrics(report) }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const formatDoctorText = (report: DoctorReport): string[] => {
|
|
241
|
+
const { coverage } = report
|
|
242
|
+
const handoffPct =
|
|
243
|
+
coverage.packages.total > 0
|
|
244
|
+
? Math.round((coverage.packages.withAgentDoc / coverage.packages.total) * 100)
|
|
245
|
+
: 0
|
|
246
|
+
const humanPct =
|
|
247
|
+
coverage.packages.total > 0
|
|
248
|
+
? Math.round((coverage.packages.withHumanDoc / coverage.packages.total) * 100)
|
|
249
|
+
: 0
|
|
250
|
+
|
|
251
|
+
const lines = [
|
|
252
|
+
'doc-bridge doctor',
|
|
253
|
+
'─'.repeat(40),
|
|
254
|
+
`Score: ${report.score}/100 (${report.grade})`,
|
|
255
|
+
'',
|
|
256
|
+
'Coverage',
|
|
257
|
+
` Packages: ${coverage.packages.total}`,
|
|
258
|
+
` Agent docs: ${coverage.packages.withAgentDoc}/${coverage.packages.total} (${handoffPct}% handoff-ready)`,
|
|
259
|
+
` Human guides: ${coverage.packages.withHumanDoc}/${coverage.packages.total} (${humanPct}% bridged)`,
|
|
260
|
+
` Corpus indexed: ${coverage.agentDocs.indexed}/${coverage.agentDocs.total} agent docs`,
|
|
261
|
+
` Index freshness: ${coverage.freshness.ok ? 'fresh' : 'stale or missing'}`,
|
|
262
|
+
` Gates: ${coverage.gates.results.filter((g) => g.ok).length}/${coverage.gates.results.length} passing`,
|
|
263
|
+
` Badge: handoff ${report.badge.handoffPct}% · bridge ${report.badge.bridgePct}%`,
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
if (coverage.packages.missingHumanDoc.length) {
|
|
267
|
+
lines.push('', 'Missing humanDoc (bridge gap)', ...coverage.packages.missingHumanDoc.map((id) => ` • ${id}`))
|
|
268
|
+
}
|
|
269
|
+
if (coverage.packages.missingAgentDoc.length) {
|
|
270
|
+
lines.push('', 'Missing agent doc', ...coverage.packages.missingAgentDoc.map((id) => ` • ${id}`))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (report.issues.length) {
|
|
274
|
+
lines.push('', 'Issues')
|
|
275
|
+
for (const issue of report.issues.slice(0, 8)) {
|
|
276
|
+
const icon = issue.severity === 'error' ? '✗' : issue.severity === 'warn' ? '!' : '·'
|
|
277
|
+
lines.push(` ${icon} [${issue.code}] ${issue.message}`)
|
|
278
|
+
}
|
|
279
|
+
if (report.issues.length > 8) {
|
|
280
|
+
lines.push(` … +${report.issues.length - 8} more`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
lines.push('', 'Next actions', ...report.nextActions.map((action) => ` → ${action}`))
|
|
285
|
+
return lines
|
|
286
|
+
}
|