sdtk-wiki-kit 0.2.1 → 0.3.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/bin/sdtk-wiki.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdtk-wiki-kit",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Project-local wiki and knowledge graph toolkit for SDTK workspaces.",
5
5
  "bin": {
6
6
  "sdtk-wiki": "bin/sdtk-wiki.js"
@@ -57,6 +57,8 @@ Inputs:
57
57
  Behavior:
58
58
  Executes a local premium wiki.ask runtime pack when entitlement and graph preconditions pass.
59
59
  Fails closed when the graph, entitlement, or runtime pack is missing.
60
+ --source grounds on the chosen doc plus its directly-related siblings (docs sharing
61
+ the same BK issue, knowledge id, skill, or family), bounded and labelled as related.
60
62
  Query history is off by default.
61
63
  --save-query writes one redacted JSON record under .sdtk/wiki/queries after a successful answer.
62
64
  Full question and full answer are not stored by default.`);
@@ -53,6 +53,7 @@ R1 command model:
53
53
  wiki discover Write a local-only discovery plan from WIKI gap evidence.
54
54
  wiki compile Preview or explicitly apply local wiki compile plans.
55
55
  ask Ask grounded questions over the built SDTK-WIKI graph.
56
+ kaban Open the Agent Kaban board (Dashboard panel) for the current project.
56
57
  search Search generated local wiki pages without premium Ask.
57
58
  lint Write a report-first, non-destructive wiki lint report.
58
59
  update Package-only updater; no wiki/.sdtk/wiki/.sdtk/atlas files are mutated in R1.
@@ -32,7 +32,7 @@ BK-102 behavior:
32
32
  Options:
33
33
  --project-path <path> Project root. Defaults to current working directory.
34
34
  --output-dir <path> Graph output dir under .sdtk/wiki. Defaults to .sdtk/wiki/graph.
35
- --scan-root <path> Repeatable markdown scan root.
35
+ --scan-root <path> Repeatable markdown scan root. Defaults to ./docs if it exists, otherwise the project root.
36
36
  --force Overwrite existing config.
37
37
  --no-build Write config only.
38
38
  --no-open Build graph but do not launch viewer.
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+
3
+ // BK-272: sdtk-wiki kaban — open the viewer on the Dashboard (Kaban board) panel.
4
+ // Follows the same flow as cmdAtlasOpen; Dashboard is the default active panel.
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { resolveWikiConfig } = require("../lib/wiki-config");
9
+ const { openViewer, runBuild } = require("../lib/wiki-runner");
10
+ const { parseFlags } = require("../lib/args");
11
+ const { OPEN_FLAG_DEFS } = require("../lib/wiki-flags");
12
+
13
+ const KABAN_FLAG_DEFS = {
14
+ ...OPEN_FLAG_DEFS,
15
+ "project-path": { type: "string", alias: "project" },
16
+ };
17
+
18
+ function hasHelp(args) {
19
+ return args.includes("-h") || args.includes("--help");
20
+ }
21
+
22
+ async function cmdKaban(args) {
23
+ if (hasHelp(args)) {
24
+ console.log(`Usage:
25
+ sdtk-wiki kaban [--project <dir>] [--port <n>] [--no-open]
26
+
27
+ Purpose:
28
+ Open the SDTK-WIKI viewer showing the Agent Kaban board (Dashboard panel).
29
+ The board reads SHARED_PLANNING.md and QUALITY_CHECKLIST.md from the project
30
+ directory and polls /api/kaban every 3 s while the Dashboard panel is active.
31
+
32
+ Options:
33
+ --project, --project-path <dir> Project root containing SHARED_PLANNING.md
34
+ and QUALITY_CHECKLIST.md (default: cwd)
35
+ --port <n> Local server port (default: from config or 7654)
36
+ --no-open Print the viewer URL without opening a browser
37
+ -h, --help Show this help and exit
38
+
39
+ Example:
40
+ sdtk-wiki kaban --project /path/to/project
41
+ sdtk-wiki kaban --no-open`);
42
+ return 0;
43
+ }
44
+
45
+ const { flags } = parseFlags(args, KABAN_FLAG_DEFS);
46
+ const config = resolveWikiConfig(flags);
47
+
48
+ const viewerPath = path.join(config.outputDir, "viewer.html");
49
+ const legacyViewerPath = path.join(config.legacyAtlasDir || "", "viewer.html");
50
+
51
+ const hasViewer =
52
+ fs.existsSync(viewerPath) || fs.existsSync(legacyViewerPath);
53
+
54
+ if (!hasViewer) {
55
+ console.log("[kaban] No SDTK-WIKI viewer found. Running initial build...");
56
+ try {
57
+ await runBuild(config);
58
+ } catch (err) {
59
+ console.error("[kaban] Build failed: " + err.message);
60
+ console.error("[kaban] Run: sdtk-wiki atlas build");
61
+ return 1;
62
+ }
63
+ }
64
+
65
+ // Use legacy atlas dir if the wiki output dir has no viewer yet
66
+ const activeConfig = fs.existsSync(viewerPath)
67
+ ? config
68
+ : { ...config, outputDir: config.legacyAtlasDir };
69
+
70
+ const noOpen = !!flags["no-open"];
71
+ const { server } = await openViewer(activeConfig, noOpen);
72
+
73
+ if (noOpen) {
74
+ if (server) server.close();
75
+ return 0;
76
+ }
77
+
78
+ console.log("[kaban] Dashboard (Kaban board) is the active panel.");
79
+ console.log("[kaban] Edit SHARED_PLANNING.md or QUALITY_CHECKLIST.md to see live updates.");
80
+ console.log("[kaban] Press Ctrl+C to stop the viewer server.");
81
+ await new Promise(() => {});
82
+ return 0;
83
+ }
84
+
85
+ module.exports = { cmdKaban };
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { cmdAtlas } = require("./commands/atlas");
4
4
  const { cmdAsk } = require("./commands/ask");
5
+ const { cmdKaban } = require("./commands/kaban");
5
6
  const { cmdHelp } = require("./commands/help");
6
7
  const { cmdInit } = require("./commands/init");
7
8
  const { cmdLint } = require("./commands/lint");
@@ -47,6 +48,7 @@ const COMMANDS = new Set([
47
48
  "atlas",
48
49
  "wiki",
49
50
  "ask",
51
+ "kaban",
50
52
  "lint",
51
53
  "search",
52
54
  "ingest",
@@ -82,6 +84,8 @@ async function run(argv) {
82
84
  return cmdWiki(args);
83
85
  case "ask":
84
86
  return cmdAsk(args);
87
+ case "kaban":
88
+ return cmdKaban(args);
85
89
  case "lint":
86
90
  return cmdLint(args);
87
91
  case "search":
@@ -9,6 +9,11 @@ const { loadWikiAskHandler } = require("./wiki-premium-loader");
9
9
  const INDEX_FILE = "SDTK_DOC_INDEX.json";
10
10
  const GRAPH_FILE = "SDTK_DOC_GRAPH.json";
11
11
  const DEFAULT_MAX_SOURCES = 6;
12
+ // BK-269 hyperedge-aware context expansion (--source filter only).
13
+ // Mirrors run_doc_atlas_server.py so both grounding surfaces behave identically.
14
+ const DEFAULT_MAX_HYPEREDGE_EXPANSION = 6;
15
+ const DEFAULT_MIN_HYPEREDGE_CO_MEMBERSHIP = 1;
16
+ const DEFAULT_MAX_FAMILY_HYPEREDGE_CONTRIB = 3;
12
17
 
13
18
  function readJsonFile(filePath, label) {
14
19
  try {
@@ -69,28 +74,251 @@ function extractDocumentText(document) {
69
74
  return "";
70
75
  }
71
76
 
72
- function buildSources(index, sourceFilters, maxSources) {
77
+ function docId(document) {
78
+ return document.id || extractDocumentPath(document);
79
+ }
80
+
81
+ function docFacetKeys(document) {
82
+ const keys = [];
83
+ const groups = [
84
+ ["issue", document.issues],
85
+ ["knowledge", document.knowledge_ids],
86
+ ["skill", document.skill_refs],
87
+ ["lane", document.lane_refs],
88
+ ];
89
+ for (const [namespace, values] of groups) {
90
+ if (!Array.isArray(values)) {
91
+ continue;
92
+ }
93
+ for (const value of values) {
94
+ const text = String(value == null ? "" : value).trim();
95
+ if (text) {
96
+ keys.push(`${namespace}:${text}`);
97
+ }
98
+ }
99
+ }
100
+ const family = String(document.family == null ? "" : document.family).trim();
101
+ if (family) {
102
+ keys.push(`family:${family}`);
103
+ }
104
+ return keys;
105
+ }
106
+
107
+ // Invert shared reference facets into hyperedges (groups with >=2 docs). Pure and
108
+ // deterministic; mirrors _derive_hyperedges in run_doc_atlas_server.py.
109
+ function deriveHyperedges(documents) {
110
+ const facetToDocs = new Map();
111
+ for (const document of documents) {
112
+ if (!document || typeof document !== "object") {
113
+ continue;
114
+ }
115
+ const id = docId(document);
116
+ if (!id) {
117
+ continue;
118
+ }
119
+ for (const facetKey of docFacetKeys(document)) {
120
+ let members = facetToDocs.get(facetKey);
121
+ if (!members) {
122
+ members = [];
123
+ facetToDocs.set(facetKey, members);
124
+ }
125
+ if (!members.includes(id)) {
126
+ members.push(id);
127
+ }
128
+ }
129
+ }
130
+ const hyperedges = new Map();
131
+ for (const [facet, ids] of facetToDocs) {
132
+ if (ids.length >= 2) {
133
+ hyperedges.set(facet, ids.slice().sort());
134
+ }
135
+ }
136
+ const docHyperedges = new Map();
137
+ for (const [facet, ids] of hyperedges) {
138
+ for (const id of ids) {
139
+ let facets = docHyperedges.get(id);
140
+ if (!facets) {
141
+ facets = new Set();
142
+ docHyperedges.set(id, facets);
143
+ }
144
+ facets.add(facet);
145
+ }
146
+ }
147
+ return { hyperedges, docHyperedges };
148
+ }
149
+
150
+ function computeNodeDegree(graph, docIdSet) {
151
+ const degree = new Map();
152
+ const edges = graph && Array.isArray(graph.edges) ? graph.edges : [];
153
+ for (const edge of edges) {
154
+ const source = edge && edge.source;
155
+ const target = edge && edge.target;
156
+ if (docIdSet.has(source) && docIdSet.has(target)) {
157
+ degree.set(source, (degree.get(source) || 0) + 1);
158
+ degree.set(target, (degree.get(target) || 0) + 1);
159
+ }
160
+ }
161
+ return degree;
162
+ }
163
+
164
+ // Bounded hyperedge co-member expansion. Returns ranked expansion entries
165
+ // ({ id, facets, relation }). Mirrors _expand_via_hyperedges in the Python server:
166
+ // shared-facet-count desc -> node_degree desc -> title/id lexical, with a family cap.
167
+ function expandSourcesViaHyperedges(seedIds, derived, nodeDegree, docsById, options) {
168
+ const opts = options || {};
169
+ const maxExpansion = Number.isInteger(opts.maxExpansion)
170
+ ? opts.maxExpansion
171
+ : DEFAULT_MAX_HYPEREDGE_EXPANSION;
172
+ const minCoMembership = Number.isInteger(opts.minCoMembership)
173
+ ? opts.minCoMembership
174
+ : DEFAULT_MIN_HYPEREDGE_CO_MEMBERSHIP;
175
+ const familyCap = Number.isInteger(opts.familyCap)
176
+ ? opts.familyCap
177
+ : DEFAULT_MAX_FAMILY_HYPEREDGE_CONTRIB;
178
+ if (maxExpansion <= 0) {
179
+ return [];
180
+ }
181
+ const seedSet = new Set(seedIds);
182
+ const seedFacets = new Set();
183
+ for (const seedId of seedIds) {
184
+ const facets = derived.docHyperedges.get(seedId);
185
+ if (facets) {
186
+ for (const facet of facets) {
187
+ seedFacets.add(facet);
188
+ }
189
+ }
190
+ }
191
+ if (seedFacets.size === 0) {
192
+ return [];
193
+ }
194
+ const candidateFacets = new Map();
195
+ for (const facet of seedFacets) {
196
+ const members = derived.hyperedges.get(facet) || [];
197
+ for (const id of members) {
198
+ if (seedSet.has(id)) {
199
+ continue;
200
+ }
201
+ let facets = candidateFacets.get(id);
202
+ if (!facets) {
203
+ facets = new Set();
204
+ candidateFacets.set(id, facets);
205
+ }
206
+ facets.add(facet);
207
+ }
208
+ }
209
+ const candidates = [];
210
+ for (const [id, facets] of candidateFacets) {
211
+ if (facets.size >= minCoMembership) {
212
+ candidates.push({ id, facets });
213
+ }
214
+ }
215
+ candidates.sort((a, b) => {
216
+ if (b.facets.size !== a.facets.size) {
217
+ return b.facets.size - a.facets.size;
218
+ }
219
+ const degreeDelta = (nodeDegree.get(b.id) || 0) - (nodeDegree.get(a.id) || 0);
220
+ if (degreeDelta !== 0) {
221
+ return degreeDelta;
222
+ }
223
+ const titleA = String((docsById.get(a.id) || {}).title || a.id).toLowerCase();
224
+ const titleB = String((docsById.get(b.id) || {}).title || b.id).toLowerCase();
225
+ if (titleA !== titleB) {
226
+ return titleA < titleB ? -1 : 1;
227
+ }
228
+ const idA = a.id.toLowerCase();
229
+ const idB = b.id.toLowerCase();
230
+ if (idA !== idB) {
231
+ return idA < idB ? -1 : 1;
232
+ }
233
+ return 0;
234
+ });
235
+ const selected = [];
236
+ let familyOnlyAdmitted = 0;
237
+ for (const candidate of candidates) {
238
+ if (selected.length >= maxExpansion) {
239
+ break;
240
+ }
241
+ let hasNonFamily = false;
242
+ for (const facet of candidate.facets) {
243
+ if (!facet.startsWith("family:")) {
244
+ hasNonFamily = true;
245
+ break;
246
+ }
247
+ }
248
+ if (!hasNonFamily) {
249
+ // OQ-C: cap how many docs can enter via a family facet alone.
250
+ if (familyOnlyAdmitted >= familyCap) {
251
+ continue;
252
+ }
253
+ familyOnlyAdmitted += 1;
254
+ }
255
+ const sortedFacets = Array.from(candidate.facets).sort();
256
+ selected.push({
257
+ id: candidate.id,
258
+ facets: sortedFacets,
259
+ relation: `shares ${sortedFacets.join(", ")}`,
260
+ });
261
+ }
262
+ return selected;
263
+ }
264
+
265
+ function buildSources(index, sourceFilters, maxSources, graph) {
73
266
  const documents = Array.isArray(index.documents) ? index.documents : [];
74
267
  const filters = (sourceFilters || []).map((item) => item.trim()).filter(Boolean);
75
268
  const limit = Number.isInteger(maxSources) && maxSources > 0 ? maxSources : DEFAULT_MAX_SOURCES;
76
269
 
77
- return documents
78
- .map((document) => {
79
- const sourcePath = extractDocumentPath(document);
80
- return {
81
- id: document.id || sourcePath,
82
- path: sourcePath,
83
- title: document.title || sourcePath,
84
- text: extractDocumentText(document),
85
- };
86
- })
87
- .filter((source) => {
88
- if (filters.length === 0) {
89
- return true;
90
- }
91
- return filters.some((filter) => source.id === filter || source.path === filter);
92
- })
93
- .slice(0, limit);
270
+ const mapped = documents.map((document) => {
271
+ const sourcePath = extractDocumentPath(document);
272
+ return {
273
+ id: document.id || sourcePath,
274
+ path: sourcePath,
275
+ title: document.title || sourcePath,
276
+ text: extractDocumentText(document),
277
+ relation: "primary",
278
+ };
279
+ });
280
+
281
+ // No explicit --source filter: ground on all docs, unchanged (no expansion).
282
+ if (filters.length === 0) {
283
+ return mapped.slice(0, limit);
284
+ }
285
+
286
+ const primaries = mapped.filter((source) =>
287
+ filters.some((filter) => source.id === filter || source.path === filter)
288
+ );
289
+
290
+ // BK-269: append the filtered docs' hyperedge co-members (capped, labelled),
291
+ // so `--source <doc>` also grounds on its directly-related siblings.
292
+ const sourceById = new Map(mapped.map((source) => [source.id, source]));
293
+ const docsById = new Map();
294
+ for (const document of documents) {
295
+ if (!document || typeof document !== "object") {
296
+ continue;
297
+ }
298
+ const id = docId(document);
299
+ if (id && !docsById.has(id)) {
300
+ docsById.set(id, document);
301
+ }
302
+ }
303
+ const derived = deriveHyperedges(documents);
304
+ const nodeDegree = computeNodeDegree(graph, new Set(docsById.keys()));
305
+ const seedIds = primaries.map((source) => source.id);
306
+ const expansion = expandSourcesViaHyperedges(seedIds, derived, nodeDegree, docsById, {});
307
+
308
+ const result = primaries.slice();
309
+ const seen = new Set(seedIds);
310
+ for (const item of expansion) {
311
+ if (seen.has(item.id)) {
312
+ continue;
313
+ }
314
+ const base = sourceById.get(item.id);
315
+ if (!base) {
316
+ continue;
317
+ }
318
+ seen.add(item.id);
319
+ result.push({ ...base, relation: item.relation });
320
+ }
321
+ return result.slice(0, limit);
94
322
  }
95
323
 
96
324
  function normalizeCitations(citations) {
@@ -126,6 +354,7 @@ function normalizeAskResult(rawResult, context) {
126
354
  citations: normalizeCitations(result.citations),
127
355
  confidence: result.confidence,
128
356
  graphPath: context.graphPath,
357
+ expandedDocCount: Number.isInteger(context.expandedDocCount) ? context.expandedDocCount : 0,
129
358
  };
130
359
  }
131
360
 
@@ -139,7 +368,10 @@ async function runWikiAsk(options) {
139
368
  const graphInfo = assertWikiGraphReady(projectPath);
140
369
  const index = readJsonFile(graphInfo.indexPath, INDEX_FILE);
141
370
  const graph = readJsonFile(graphInfo.graphFilePath, GRAPH_FILE);
142
- const sources = buildSources(index, options.sources, options.maxSources);
371
+ const sources = buildSources(index, options.sources, options.maxSources, graph);
372
+ const expandedDocCount = sources.filter(
373
+ (source) => source.relation && source.relation !== "primary"
374
+ ).length;
143
375
 
144
376
  const handlerState = await loadWikiAskHandler();
145
377
  if (!handlerState.ok) {
@@ -155,6 +387,7 @@ async function runWikiAsk(options) {
155
387
  graphFilePath: graphInfo.graphFilePath,
156
388
  graph,
157
389
  sources,
390
+ expandedDocCount,
158
391
  maxSources: options.maxSources,
159
392
  };
160
393
 
@@ -170,6 +403,8 @@ async function runWikiAsk(options) {
170
403
 
171
404
  module.exports = {
172
405
  buildSources,
406
+ deriveHyperedges,
407
+ expandSourcesViaHyperedges,
173
408
  normalizeAskResult,
174
409
  runWikiAsk,
175
410
  };
@@ -71,7 +71,9 @@ function resolveWikiConfig(flags = {}) {
71
71
  ) {
72
72
  scanRoots = persisted.scanRoots.map((r) => path.resolve(r));
73
73
  } else {
74
- scanRoots = [projectPath];
74
+ const docsDir = path.join(projectPath, "docs");
75
+ const hasDocsDir = fs.existsSync(docsDir) && fs.statSync(docsDir).isDirectory();
76
+ scanRoots = [hasDocsDir ? docsDir : projectPath];
75
77
  }
76
78
 
77
79
  const excludes =