superwiki 0.1.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/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +17 -0
- package/.codex-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/bin/superwiki.mjs +116 -0
- package/commands/config.md +5 -0
- package/commands/explain.md +5 -0
- package/commands/implement.md +5 -0
- package/commands/ingest.md +5 -0
- package/commands/init.md +5 -0
- package/commands/lint.md +5 -0
- package/commands/migrate.md +5 -0
- package/commands/plan.md +5 -0
- package/commands/triage.md +5 -0
- package/commands/visualize.md +5 -0
- package/install.sh +27 -0
- package/package.json +45 -0
- package/skills/sw-config/SKILL.md +39 -0
- package/skills/sw-config/assets/implementer.md +15 -0
- package/skills/sw-config/assets/planner.md +17 -0
- package/skills/sw-config/scripts/config.mjs +104 -0
- package/skills/sw-explain/SKILL.md +30 -0
- package/skills/sw-implement/SKILL.md +46 -0
- package/skills/sw-ingest/SKILL.md +43 -0
- package/skills/sw-init/SKILL.md +52 -0
- package/skills/sw-init/assets/agents-block.md +29 -0
- package/skills/sw-init/assets/sw.mjs +523 -0
- package/skills/sw-init/assets/templates/page.md +18 -0
- package/skills/sw-init/assets/templates/plan.md +25 -0
- package/skills/sw-init/assets/templates/task.md +33 -0
- package/skills/sw-init/assets/viewer.html +1660 -0
- package/skills/sw-init/scripts/init.mjs +118 -0
- package/skills/sw-lint/SKILL.md +61 -0
- package/skills/sw-migrate/SKILL.md +61 -0
- package/skills/sw-migrate/scripts/migrate.mjs +225 -0
- package/skills/sw-plan/SKILL.md +46 -0
- package/skills/sw-triage/SKILL.md +42 -0
- package/skills/sw-visualize/SKILL.md +28 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generated by scripts/build.mjs from src/core.js and src/cli.js. Do not edit.
|
|
3
|
+
// Superwiki core: vault model, derived task state and lint. Pure: no fs, no DOM.
|
|
4
|
+
// Runs in Node (docs/.sw/sw.mjs) and inlined in the viewer, so both report the same findings.
|
|
5
|
+
|
|
6
|
+
export const STATUSES = ['todo', 'in-progress', 'done', 'cancelled'];
|
|
7
|
+
export const VAULT_FOLDERS = ['wiki', 'tasks', 'plans'];
|
|
8
|
+
const ROOT_PAGES = ['index', 'log'];
|
|
9
|
+
|
|
10
|
+
// ---------- Frontmatter (the YAML subset Superwiki writes: scalars, [inline, lists], "- block" lists) ----------
|
|
11
|
+
function scalar(s) {
|
|
12
|
+
let v = String(s).trim();
|
|
13
|
+
if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
14
|
+
else if (v.length > 1 && v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1).replace(/''/g, "'");
|
|
15
|
+
if (v.startsWith('[[') && v.endsWith(']]')) v = v.slice(2, -2).trim();
|
|
16
|
+
return v;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stripComment(line) {
|
|
20
|
+
return /["']/.test(line) ? line : line.replace(/(^|\s)#.*$/, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseFrontmatter(text) {
|
|
24
|
+
const src = String(text ?? '').replace(/^/, '');
|
|
25
|
+
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(src);
|
|
26
|
+
if (!m) return { data: null, body: src, bodyLine: 1 };
|
|
27
|
+
const data = {};
|
|
28
|
+
let key = null;
|
|
29
|
+
for (const raw of m[1].split(/\r?\n/)) {
|
|
30
|
+
const line = stripComment(raw);
|
|
31
|
+
if (!line.trim()) continue;
|
|
32
|
+
const item = /^\s*-\s+(.*)$/.exec(line);
|
|
33
|
+
if (item && key) {
|
|
34
|
+
if (!Array.isArray(data[key])) data[key] = [];
|
|
35
|
+
data[key].push(scalar(item[1]));
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
|
|
39
|
+
if (!kv) continue;
|
|
40
|
+
key = kv[1];
|
|
41
|
+
const v = kv[2].trim();
|
|
42
|
+
if (v === '') data[key] = null;
|
|
43
|
+
else if (v.startsWith('[') && v.endsWith(']') && !v.startsWith('[[')) {
|
|
44
|
+
data[key] = v.slice(1, -1).split(',').map(scalar).filter(Boolean);
|
|
45
|
+
} else data[key] = scalar(v);
|
|
46
|
+
}
|
|
47
|
+
return { data, body: src.slice(m[0].length), bodyLine: m[0].split('\n').length };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function asList(v) {
|
|
51
|
+
if (v == null || v === '') return [];
|
|
52
|
+
return Array.isArray(v) ? v : [v];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- Wikilinks ----------
|
|
56
|
+
const WIKILINK = /(!?)\[\[([^\[\]|#]+)(#[^\[\]|]*)?(?:\|([^\[\]]*))?\]\]/g;
|
|
57
|
+
const isAttachment = target => /\.[A-Za-z0-9]{2,5}$/.test(target) && !/\.md$/i.test(target);
|
|
58
|
+
|
|
59
|
+
// Links in a markdown body. Fenced and inline code is skipped; `line` is 1-based within the body.
|
|
60
|
+
export function extractWikilinks(body) {
|
|
61
|
+
const out = [];
|
|
62
|
+
let fenced = false;
|
|
63
|
+
String(body ?? '').split(/\r?\n/).forEach((raw, i) => {
|
|
64
|
+
if (/^\s*(```|~~~)/.test(raw)) { fenced = !fenced; return; }
|
|
65
|
+
if (fenced) return;
|
|
66
|
+
const line = raw.replace(/`[^`]*`/g, '');
|
|
67
|
+
for (const m of line.matchAll(WIKILINK)) {
|
|
68
|
+
const target = m[2].trim().replace(/\.md$/i, '');
|
|
69
|
+
if (m[1] === '!' || isAttachment(m[2].trim())) continue;
|
|
70
|
+
out.push({ target, heading: m[3] ? m[3].slice(1) : null, alias: m[4] ?? null, line: i + 1 });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- Vault ----------
|
|
77
|
+
const key = name => String(name).toLowerCase();
|
|
78
|
+
const areaOf = id => (String(id).includes('-') ? String(id).slice(0, String(id).lastIndexOf('-')) : '');
|
|
79
|
+
|
|
80
|
+
// files: [{ path, text }] with paths relative to docs/ ("wiki/foo.md"). Anything outside
|
|
81
|
+
// index.md, log.md, wiki/, tasks/ and plans/ belongs to other tools and is ignored.
|
|
82
|
+
export function buildVault(files) {
|
|
83
|
+
const pages = [];
|
|
84
|
+
const names = new Map();
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
const path = String(f.path).replace(/\\/g, '/').replace(/^\.?\//, '');
|
|
87
|
+
if (!/\.md$/i.test(path)) continue;
|
|
88
|
+
const parts = path.split('/');
|
|
89
|
+
const name = parts[parts.length - 1].replace(/\.md$/i, '');
|
|
90
|
+
let folder;
|
|
91
|
+
if (parts.length === 1) { if (!ROOT_PAGES.includes(name)) continue; folder = 'root'; }
|
|
92
|
+
else if (VAULT_FOLDERS.includes(parts[0])) folder = parts[0];
|
|
93
|
+
else continue;
|
|
94
|
+
const fm = parseFrontmatter(f.text);
|
|
95
|
+
const page = { path, folder, name, nested: parts.length > 2, data: fm.data || {}, hasFrontmatter: !!fm.data, body: fm.body, bodyLine: fm.bodyLine, links: extractWikilinks(fm.body), inbound: [] };
|
|
96
|
+
pages.push(page);
|
|
97
|
+
if (!names.has(key(name))) names.set(key(name), []);
|
|
98
|
+
names.get(key(name)).push(page);
|
|
99
|
+
}
|
|
100
|
+
pages.sort((a, b) => a.path.localeCompare(b.path));
|
|
101
|
+
const vault = { pages, names, tasks: new Map(), plans: new Map(), index: null, log: null };
|
|
102
|
+
vault.index = pages.find(p => p.folder === 'root' && p.name === 'index') || null;
|
|
103
|
+
vault.log = pages.find(p => p.folder === 'root' && p.name === 'log') || null;
|
|
104
|
+
for (const p of pages) for (const l of p.links) {
|
|
105
|
+
const t = resolve(vault, l.target);
|
|
106
|
+
if (t && t !== p && !t.inbound.includes(p)) t.inbound.push(p);
|
|
107
|
+
}
|
|
108
|
+
for (const p of pages) {
|
|
109
|
+
if (p.folder === 'plans') vault.plans.set(key(p.name.replace(/-plan$/i, '')), p);
|
|
110
|
+
if (p.folder !== 'tasks') continue;
|
|
111
|
+
const d = p.data;
|
|
112
|
+
const id = String(d.id || p.name);
|
|
113
|
+
vault.tasks.set(key(id), {
|
|
114
|
+
id, page: p, area: areaOf(id), title: d.title || '', status: d.status || '',
|
|
115
|
+
deps: asList(d.deps).map(String), softDeps: asList(d.soft_deps).map(String),
|
|
116
|
+
milestone: d.milestone || '', priority: d.priority == null || d.priority === '' ? null : Number(d.priority),
|
|
117
|
+
started: d.started || '', finished: d.finished || '',
|
|
118
|
+
state: null, wave: 0, dependents: [], plan: null,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
derive(vault);
|
|
122
|
+
return vault;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolve(vault, target) {
|
|
126
|
+
const hit = vault.names.get(key(String(target).split('/').pop().replace(/\.md$/i, '')));
|
|
127
|
+
return hit ? hit[0] : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const taskOf = (vault, id) => vault.tasks.get(key(id)) || null;
|
|
131
|
+
|
|
132
|
+
// state: done | cancelled | progress | ready | blocked. wave: longest hard-dependency chain below the task.
|
|
133
|
+
function derive(vault) {
|
|
134
|
+
const depth = new Map();
|
|
135
|
+
const waveOf = (t, trail) => {
|
|
136
|
+
if (depth.has(t)) return depth.get(t);
|
|
137
|
+
if (trail.has(t)) return 0;
|
|
138
|
+
trail.add(t);
|
|
139
|
+
let w = 0;
|
|
140
|
+
for (const id of t.deps) { const d = taskOf(vault, id); if (d) w = Math.max(w, waveOf(d, trail) + 1); }
|
|
141
|
+
trail.delete(t);
|
|
142
|
+
depth.set(t, w);
|
|
143
|
+
return w;
|
|
144
|
+
};
|
|
145
|
+
for (const t of vault.tasks.values()) {
|
|
146
|
+
t.plan = vault.plans.get(key(t.id)) || null;
|
|
147
|
+
t.wave = waveOf(t, new Set());
|
|
148
|
+
const open = t.deps.filter(id => taskOf(vault, id)?.status !== 'done');
|
|
149
|
+
t.openDeps = open;
|
|
150
|
+
t.openSoftDeps = t.softDeps.filter(id => taskOf(vault, id)?.status !== 'done');
|
|
151
|
+
t.state = t.status === 'done' ? 'done' : t.status === 'cancelled' ? 'cancelled' : t.status === 'in-progress' ? 'progress' : open.length ? 'blocked' : 'ready';
|
|
152
|
+
for (const id of [...t.deps, ...t.softDeps]) {
|
|
153
|
+
const d = taskOf(vault, id);
|
|
154
|
+
if (d && !d.dependents.includes(t.id)) d.dependents.push(t.id);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function cycles(vault) {
|
|
160
|
+
const found = [];
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
const state = new Map();
|
|
163
|
+
const visit = (t, trail) => {
|
|
164
|
+
state.set(t, 1);
|
|
165
|
+
trail.push(t.id);
|
|
166
|
+
for (const id of [...t.deps, ...t.softDeps]) {
|
|
167
|
+
const d = taskOf(vault, id);
|
|
168
|
+
if (!d) continue;
|
|
169
|
+
if (state.get(d) === 1) {
|
|
170
|
+
const loop = trail.slice(trail.indexOf(d.id));
|
|
171
|
+
const sig = [...loop].sort().join(',');
|
|
172
|
+
if (!seen.has(sig)) { seen.add(sig); found.push([...loop, d.id]); }
|
|
173
|
+
} else if (!state.get(d)) visit(d, trail);
|
|
174
|
+
}
|
|
175
|
+
trail.pop();
|
|
176
|
+
state.set(t, 2);
|
|
177
|
+
};
|
|
178
|
+
for (const t of vault.tasks.values()) if (!state.get(t)) visit(t, []);
|
|
179
|
+
return found;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------- Lint ----------
|
|
183
|
+
// Finding: { level: 'error' | 'warn', code, path, message }.
|
|
184
|
+
export function lint(vault) {
|
|
185
|
+
const out = [];
|
|
186
|
+
const add = (level, code, path, message) => out.push({ level, code, path, message });
|
|
187
|
+
|
|
188
|
+
for (const [, list] of vault.names) {
|
|
189
|
+
if (list.length > 1) add('error', 'duplicate-name', list[0].path, `file name is not unique: ${list.map(p => p.path).join(', ')}`);
|
|
190
|
+
}
|
|
191
|
+
if (!vault.index) add('error', 'missing-index', 'index.md', 'docs/index.md is missing');
|
|
192
|
+
|
|
193
|
+
for (const p of vault.pages) {
|
|
194
|
+
if (p.nested) add('warn', 'nested-page', p.path, `${p.folder}/ is flat; move the file up`);
|
|
195
|
+
// The log is history: entries keep the names pages had when they were written.
|
|
196
|
+
if (p !== vault.log) for (const l of p.links) {
|
|
197
|
+
if (!resolve(vault, l.target)) add('error', 'broken-link', p.path, `line ${p.bodyLine + l.line - 1}: [[${l.target}]] has no target`);
|
|
198
|
+
}
|
|
199
|
+
if (p.folder === 'wiki') {
|
|
200
|
+
if (!p.data.type) add('error', 'missing-field', p.path, 'frontmatter `type` is missing');
|
|
201
|
+
if (!p.data.summary) add('warn', 'missing-field', p.path, 'frontmatter `summary` is missing');
|
|
202
|
+
if (vault.index && !vault.index.links.some(l => resolve(vault, l.target) === p)) add('warn', 'not-in-index', p.path, 'page is not listed in index.md');
|
|
203
|
+
// A source summary is reachable from the index and need not be cited yet; ingest stays a three-file change.
|
|
204
|
+
if (p.data.type !== 'source' && !p.inbound.some(q => q !== vault.index && q !== vault.log)) add('warn', 'orphan-page', p.path, 'no page links here');
|
|
205
|
+
}
|
|
206
|
+
if (p.folder === 'plans') {
|
|
207
|
+
const id = /-plan$/i.test(p.name) ? p.name.replace(/-plan$/i, '') : null;
|
|
208
|
+
if (!id) add('error', 'plan-name', p.path, 'plan files are named <ID>-plan.md');
|
|
209
|
+
else if (!taskOf(vault, id)) add('error', 'orphan-plan', p.path, `no task ${id} for this plan`);
|
|
210
|
+
else if (p.data.task && key(p.data.task) !== key(id)) add('error', 'plan-task-mismatch', p.path, `frontmatter task ${p.data.task} does not match file name`);
|
|
211
|
+
if (p.data.type !== 'plan') add('warn', 'missing-field', p.path, 'frontmatter `type: plan` is missing');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const t of vault.tasks.values()) {
|
|
216
|
+
const path = t.page.path;
|
|
217
|
+
if (t.page.data.type !== 'task') add('warn', 'missing-field', path, 'frontmatter `type: task` is missing');
|
|
218
|
+
if (!t.page.data.id) add('error', 'missing-field', path, 'frontmatter `id` is missing');
|
|
219
|
+
else if (key(t.id) !== key(t.page.name)) add('error', 'id-mismatch', path, `id ${t.id} does not match file name`);
|
|
220
|
+
if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(t.id)) add('error', 'bad-id', path, `id ${t.id} is not <AREA>-<number>`);
|
|
221
|
+
if (!t.title) add('error', 'missing-field', path, 'frontmatter `title` is missing');
|
|
222
|
+
if (!STATUSES.includes(t.status)) add('error', 'bad-status', path, `status "${t.status}" is not one of ${STATUSES.join(', ')}`);
|
|
223
|
+
for (const id of [...t.deps, ...t.softDeps]) {
|
|
224
|
+
const d = taskOf(vault, id);
|
|
225
|
+
if (!d) add('error', 'unknown-dep', path, `depends on ${id}, which does not exist`);
|
|
226
|
+
else if (d.status === 'cancelled' && t.status !== 'cancelled' && t.status !== 'done') add('warn', 'cancelled-dep', path, `depends on cancelled task ${id}`);
|
|
227
|
+
}
|
|
228
|
+
const hardOpen = t.openDeps.filter(id => taskOf(vault, id));
|
|
229
|
+
const softOpen = t.openSoftDeps.filter(id => taskOf(vault, id));
|
|
230
|
+
if (t.status === 'in-progress' && hardOpen.length) add('error', 'started-before-deps', path, `in-progress but not done: ${hardOpen.join(', ')}`);
|
|
231
|
+
if (t.status === 'done' && (hardOpen.length || softOpen.length)) add('error', 'done-before-deps', path, `done but not done: ${[...hardOpen, ...softOpen].join(', ')}`);
|
|
232
|
+
if ((t.status === 'in-progress' || t.status === 'done') && !t.started) add('warn', 'missing-date', path, '`started` is empty');
|
|
233
|
+
if (t.status === 'done' && !t.finished) add('warn', 'missing-date', path, '`finished` is empty');
|
|
234
|
+
}
|
|
235
|
+
for (const loop of cycles(vault)) add('error', 'dep-cycle', taskOf(vault, loop[0]).page.path, `dependency cycle: ${loop.join(' -> ')}`);
|
|
236
|
+
|
|
237
|
+
const rank = { error: 0, warn: 1 };
|
|
238
|
+
return out.sort((a, b) => rank[a.level] - rank[b.level] || a.path.localeCompare(b.path) || a.code.localeCompare(b.code));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------- Queries ----------
|
|
242
|
+
const STATES = ['ready', 'progress', 'blocked', 'done', 'cancelled'];
|
|
243
|
+
const idOrder = (a, b) => a.area.localeCompare(b.area) || Number(a.id.split('-').pop()) - Number(b.id.split('-').pop());
|
|
244
|
+
// Runnable order: fewest unfinished layers first, then explicit priority, then id.
|
|
245
|
+
export const taskOrder = (a, b) => a.wave - b.wave || (a.priority ?? Infinity) - (b.priority ?? Infinity) || idOrder(a, b);
|
|
246
|
+
|
|
247
|
+
export function summary(vault) {
|
|
248
|
+
const zero = () => Object.fromEntries([['total', 0], ...STATES.map(s => [s, 0])]);
|
|
249
|
+
const areas = new Map();
|
|
250
|
+
const total = zero();
|
|
251
|
+
for (const t of [...vault.tasks.values()].sort(idOrder)) {
|
|
252
|
+
if (!areas.has(t.area)) areas.set(t.area, zero());
|
|
253
|
+
for (const c of [areas.get(t.area), total]) { c.total++; c[t.state]++; }
|
|
254
|
+
}
|
|
255
|
+
return { areas, total, wikiPages: vault.pages.filter(p => p.folder === 'wiki').length };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function tasksIn(vault, state) {
|
|
259
|
+
return [...vault.tasks.values()].filter(t => t.state === state).sort(taskOrder);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Numbers are never reused: cancelled tasks stay on disk and keep theirs.
|
|
263
|
+
export function nextId(vault, area) {
|
|
264
|
+
let max = 0;
|
|
265
|
+
let width = 2;
|
|
266
|
+
for (const t of vault.tasks.values()) {
|
|
267
|
+
if (key(t.area) !== key(area)) continue;
|
|
268
|
+
const n = t.id.split('-').pop();
|
|
269
|
+
if (/^\d+$/.test(n)) { max = Math.max(max, Number(n)); width = Math.max(width, n.length); }
|
|
270
|
+
}
|
|
271
|
+
return `${area}-${String(max + 1).padStart(width, '0')}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Hard dependents that become ready the moment this task is done.
|
|
275
|
+
export function unblockedBy(vault, id) {
|
|
276
|
+
const t = taskOf(vault, id);
|
|
277
|
+
if (!t) return [];
|
|
278
|
+
return t.dependents.map(d => taskOf(vault, d))
|
|
279
|
+
.filter(d => d && d.status === 'todo' && d.deps.includes(t.id) && d.openDeps.every(o => o === t.id))
|
|
280
|
+
.map(d => d.id);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Keyword search over the vault. Pages matching more distinct terms come first; then lessons; a hit
|
|
284
|
+
// in the name, title or summary outweighs hits in the body. Returns [{ page, score, line }].
|
|
285
|
+
export function search(vault, query, limit = 8) {
|
|
286
|
+
const terms = [...new Set(String(query).toLowerCase().split(/[^\p{L}\p{N}_-]+/u).filter(w => w.length > 1))];
|
|
287
|
+
if (!terms.length) return [];
|
|
288
|
+
// A term matches at the start of a word, so "sync" finds "syncing" but "hang" does not find "change".
|
|
289
|
+
const res = terms.map(t => new RegExp(`(?<![\\p{L}\\p{N}])${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'u'));
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const p of vault.pages) {
|
|
292
|
+
if (p === vault.index || p === vault.log) continue;
|
|
293
|
+
const head = `${p.name} ${p.data.title || ''} ${p.data.summary || ''}`.toLowerCase();
|
|
294
|
+
const body = p.body.toLowerCase();
|
|
295
|
+
let matched = 0;
|
|
296
|
+
let score = 0;
|
|
297
|
+
for (const re of res) {
|
|
298
|
+
const inHead = re.test(head);
|
|
299
|
+
const hits = Math.min((body.match(new RegExp(re.source, 'gu')) || []).length, 5);
|
|
300
|
+
if (inHead || hits) matched++;
|
|
301
|
+
score += (inHead ? 5 : 0) + hits;
|
|
302
|
+
}
|
|
303
|
+
if (!matched) continue;
|
|
304
|
+
const line = p.body.split(/\r?\n/).find(l => res.some(re => re.test(l.toLowerCase()))) || '';
|
|
305
|
+
out.push({ page: p, matched, score, line: line.trim().slice(0, 140) });
|
|
306
|
+
}
|
|
307
|
+
// Among pages matching the same number of terms, a recorded lesson is the most useful thing to read first.
|
|
308
|
+
const lesson = h => (h.page.data.type === 'lesson' ? 1 : 0);
|
|
309
|
+
return out.sort((a, b) => b.matched - a.matched || lesson(b) - lesson(a) || b.score - a.score || a.page.path.localeCompare(b.page.path)).slice(0, limit);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Superwiki CLI. Lives in a project at docs/.sw/sw.mjs; prints short answers so agents do not read the vault to get them.
|
|
313
|
+
import { readFileSync, readdirSync, existsSync, realpathSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
314
|
+
import { basename, dirname, join, resolve as resolvePath } from 'node:path';
|
|
315
|
+
import { fileURLToPath } from 'node:url';
|
|
316
|
+
import { createServer } from 'node:http';
|
|
317
|
+
import { spawn } from 'node:child_process';
|
|
318
|
+
|
|
319
|
+
const HELP = `sw <command> [--docs <dir>] [--json]
|
|
320
|
+
|
|
321
|
+
status task counts per area and wiki page count
|
|
322
|
+
ready tasks that can start now, and tasks in progress
|
|
323
|
+
check <ID> can this task start / finish? lists what is open
|
|
324
|
+
explain <ID> a task's dependencies, what it blocks and unblocks, its plan and linked pages
|
|
325
|
+
search <words> pages and log entries that mention the words, best match first
|
|
326
|
+
next-id <AREA> next free task id for an area (numbers are never reused)
|
|
327
|
+
lint structural checks; exit code 1 on errors
|
|
328
|
+
serve [--open] start (or reuse) a local viewer at http://127.0.0.1:<port>/ that reads the files live
|
|
329
|
+
snapshot write docs/.sw/data.js so docs/viewer.html opens as a file, frozen at this moment`;
|
|
330
|
+
|
|
331
|
+
function docsDir(args) {
|
|
332
|
+
const i = args.indexOf('--docs');
|
|
333
|
+
if (i >= 0) return resolvePath(args[i + 1] || '.');
|
|
334
|
+
const own = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
335
|
+
if (existsSync(join(own, 'index.md'))) return own;
|
|
336
|
+
return resolvePath('docs');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function walk(dir, rel, out) {
|
|
340
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
341
|
+
if (e.name.startsWith('.')) continue;
|
|
342
|
+
const p = join(dir, e.name);
|
|
343
|
+
if (e.isDirectory()) walk(p, `${rel}${e.name}/`, out);
|
|
344
|
+
else if (e.name.endsWith('.md')) out.push({ path: rel + e.name, text: readFileSync(p, 'utf8') });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function loadVault(docs) {
|
|
349
|
+
const files = [];
|
|
350
|
+
for (const name of ['index.md', 'log.md']) {
|
|
351
|
+
// log.md only ever grows; its links are not worth the read, so only its presence is recorded.
|
|
352
|
+
if (existsSync(join(docs, name))) files.push({ path: name, text: name === 'log.md' ? '' : readFileSync(join(docs, name), 'utf8') });
|
|
353
|
+
}
|
|
354
|
+
for (const f of VAULT_FOLDERS) if (existsSync(join(docs, f))) walk(join(docs, f), `${f}/`, files);
|
|
355
|
+
return buildVault(files);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Everything the viewer shows, as one object: the same pages the CLI reads, plus the log's text.
|
|
359
|
+
export function vaultData(docs) {
|
|
360
|
+
const files = [];
|
|
361
|
+
for (const name of ['index.md', 'log.md']) if (existsSync(join(docs, name))) files.push({ path: name, text: readFileSync(join(docs, name), 'utf8') });
|
|
362
|
+
for (const f of VAULT_FOLDERS) if (existsSync(join(docs, f))) walk(join(docs, f), `${f}/`, files);
|
|
363
|
+
let config = {};
|
|
364
|
+
try { config = JSON.parse(readFileSync(join(docs, '.sw', 'config.json'), 'utf8')); } catch {}
|
|
365
|
+
return { name: config.name || basename(dirname(docs)), generated: new Date().toISOString(), config, files };
|
|
366
|
+
}
|
|
367
|
+
// "<" is escaped so page text can never close the script element the viewer loads this with.
|
|
368
|
+
export const dataScript = data => `window.SW_DATA = ${JSON.stringify(data).replace(/</g, '\\u003c')};\n`;
|
|
369
|
+
|
|
370
|
+
// The viewer cannot read files from a file:// page without the user picking a folder. Served from
|
|
371
|
+
// localhost it can: every Refresh asks this process, which reads the files as they are now.
|
|
372
|
+
const IDLE_MS = 2 * 60 * 60 * 1000;
|
|
373
|
+
function serve(docs, argv) {
|
|
374
|
+
const statePath = join(docs, '.sw', 'server.json');
|
|
375
|
+
const state = () => { try { return JSON.parse(readFileSync(statePath, 'utf8')); } catch { return null; } };
|
|
376
|
+
const alive = async url => { try { return (await (await fetch(`${url}.sw/ping`)).text()) === docs; } catch { return false; } };
|
|
377
|
+
|
|
378
|
+
if (!argv.includes('--foreground')) {
|
|
379
|
+
(async () => {
|
|
380
|
+
let url = state()?.url;
|
|
381
|
+
if (!url || !(await alive(url))) {
|
|
382
|
+
url = null;
|
|
383
|
+
spawn(process.execPath, [fileURLToPath(import.meta.url), 'serve', '--foreground', '--docs', docs], { detached: true, stdio: 'ignore' }).unref();
|
|
384
|
+
for (let i = 0; i < 50 && !url; i++) {
|
|
385
|
+
await new Promise(r => setTimeout(r, 100));
|
|
386
|
+
const u = state()?.url;
|
|
387
|
+
if (u && (await alive(u))) url = u;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!url) { console.error('could not start the viewer server; use `snapshot` and open docs/viewer.html instead'); process.exitCode = 1; return; }
|
|
391
|
+
console.log(url);
|
|
392
|
+
if (argv.includes('--open')) {
|
|
393
|
+
const [bin, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
394
|
+
spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
|
|
395
|
+
}
|
|
396
|
+
})();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let last = Date.now();
|
|
401
|
+
const server = createServer((req, res) => {
|
|
402
|
+
last = Date.now();
|
|
403
|
+
const port = server.address().port;
|
|
404
|
+
// Only this machine's browser, addressed by its loopback name, gets an answer.
|
|
405
|
+
if (![`127.0.0.1:${port}`, `localhost:${port}`].includes(req.headers.host)) { res.writeHead(403).end(); return; }
|
|
406
|
+
const path = (req.url || '/').split('?')[0];
|
|
407
|
+
const send = (type, body) => { res.writeHead(200, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store' }); res.end(body); };
|
|
408
|
+
if (path === '/' || path === '/viewer.html') return existsSync(join(docs, 'viewer.html')) ? send('text/html', readFileSync(join(docs, 'viewer.html'))) : res.writeHead(404).end('docs/viewer.html is missing; run sw-init');
|
|
409
|
+
if (path === '/.sw/data.js') return send('text/javascript', dataScript({ ...vaultData(docs), live: true }));
|
|
410
|
+
if (path === '/.sw/ping') return send('text/plain', docs);
|
|
411
|
+
res.writeHead(404).end();
|
|
412
|
+
});
|
|
413
|
+
server.listen(0, '127.0.0.1', () => {
|
|
414
|
+
mkdirSync(join(docs, '.sw'), { recursive: true });
|
|
415
|
+
writeFileSync(statePath, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}/`, pid: process.pid }) + '\n');
|
|
416
|
+
});
|
|
417
|
+
setInterval(() => { if (Date.now() - last > IDLE_MS) process.exit(0); }, 60 * 1000);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const line = t => `${t.id} ${t.title}${t.milestone ? ` [${t.milestone}]` : ''}${t.priority != null ? ` p${t.priority}` : ''}`;
|
|
421
|
+
|
|
422
|
+
function main(argv) {
|
|
423
|
+
const args = argv.filter(a => a !== '--json');
|
|
424
|
+
const json = argv.includes('--json');
|
|
425
|
+
const di = args.indexOf('--docs');
|
|
426
|
+
if (di >= 0) args.splice(di, 2);
|
|
427
|
+
const [cmd, arg] = args;
|
|
428
|
+
if (!cmd || cmd === 'help' || cmd === '--help') { console.log(HELP); return 0; }
|
|
429
|
+
const docs = docsDir(argv);
|
|
430
|
+
if (!existsSync(docs)) { console.error(`no docs folder at ${docs}; run sw-init or pass --docs`); return 2; }
|
|
431
|
+
const vault = loadVault(docs);
|
|
432
|
+
const print = (data, text) => console.log(json ? JSON.stringify(data, null, 2) : text);
|
|
433
|
+
|
|
434
|
+
if (cmd === 'status') {
|
|
435
|
+
const s = summary(vault);
|
|
436
|
+
const row = (name, c) => `${name.padEnd(8)} total ${c.total} ready ${c.ready} in-progress ${c.progress} blocked ${c.blocked} done ${c.done} cancelled ${c.cancelled}`;
|
|
437
|
+
const rows = [...s.areas].map(([a, c]) => row(a || '(none)', c));
|
|
438
|
+
if (s.areas.size > 1) rows.push(row('all', s.total));
|
|
439
|
+
print({ areas: Object.fromEntries(s.areas), total: s.total, wikiPages: s.wikiPages }, [...(rows.length ? rows : ['no tasks']), `wiki pages ${s.wikiPages}`].join('\n'));
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
if (cmd === 'ready') {
|
|
443
|
+
const prog = tasksIn(vault, 'progress');
|
|
444
|
+
const ready = tasksIn(vault, 'ready');
|
|
445
|
+
const pick = t => ({ id: t.id, title: t.title, milestone: t.milestone, priority: t.priority });
|
|
446
|
+
print({ inProgress: prog.map(pick), ready: ready.map(pick) }, [`in progress (${prog.length})`, ...prog.map(line), `ready (${ready.length})`, ...ready.map(line)].join('\n'));
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
if (cmd === 'check') {
|
|
450
|
+
const t = arg && taskOf(vault, arg);
|
|
451
|
+
if (!t) { console.error(`no task ${arg ?? ''}`); return 2; }
|
|
452
|
+
const known = ids => ids.filter(id => taskOf(vault, id));
|
|
453
|
+
const data = { id: t.id, status: t.status, canStart: t.status === 'todo' && !t.openDeps.length, canFinish: !t.openDeps.length && !t.openSoftDeps.length, openDeps: t.openDeps, openSoftDeps: known(t.openSoftDeps), plan: t.plan ? `docs/${t.plan.path}` : null };
|
|
454
|
+
print(data, [`${t.id} ${t.status} ${t.title}`, t.status === 'todo' ? `can start: ${data.canStart ? 'yes' : `no open deps: ${t.openDeps.join(', ')}`}` : `can start: n/a, status is ${t.status}${t.openDeps.length ? ` open deps: ${t.openDeps.join(', ')}` : ''}`, `can finish: ${data.canFinish ? 'yes' : 'no'}${data.openSoftDeps.length ? ` open soft deps: ${data.openSoftDeps.join(', ')}` : ''}`, `plan: ${data.plan ?? 'none'}`].join('\n'));
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
if (cmd === 'explain') {
|
|
458
|
+
const t = arg && taskOf(vault, arg);
|
|
459
|
+
if (!t) { console.error(`no task ${arg ?? ''}`); return 2; }
|
|
460
|
+
const ref = id => { const d = taskOf(vault, id); return d ? `${d.id} (${d.state}) ${d.title}` : `${id} (unknown)`; };
|
|
461
|
+
const list = (label, ids) => (ids.length ? [`${label}:`, ...ids.map(id => ` ${ref(id)}`)] : [`${label}: none`]);
|
|
462
|
+
const linked = [...new Set(t.page.links.map(l => resolve(vault, l.target)).filter(p => p && p.folder === 'wiki'))];
|
|
463
|
+
const unblocks = unblockedBy(vault, t.id);
|
|
464
|
+
const data = { id: t.id, title: t.title, status: t.status, state: t.state, milestone: t.milestone, priority: t.priority, started: t.started, finished: t.finished, deps: t.deps, softDeps: t.softDeps, dependents: t.dependents, unblocks, plan: t.plan ? `docs/${t.plan.path}` : null, linked: linked.map(p => ({ path: `docs/${p.path}`, type: p.data.type || '', summary: p.data.summary || '' })) };
|
|
465
|
+
print(data, [
|
|
466
|
+
`${t.id} ${t.title}`,
|
|
467
|
+
`state: ${t.state} (status: ${t.status})${t.milestone ? ` milestone: ${t.milestone}` : ''}${t.priority != null ? ` priority: ${t.priority}` : ''}${t.started ? ` started: ${t.started}` : ''}${t.finished ? ` finished: ${t.finished}` : ''}`,
|
|
468
|
+
...list('depends on', t.deps), ...(t.softDeps.length ? list('soft depends on', t.softDeps) : []),
|
|
469
|
+
...list('blocks', t.dependents),
|
|
470
|
+
`finishing it makes ready: ${unblocks.join(', ') || 'nothing yet'}`,
|
|
471
|
+
`task: docs/${t.page.path}`, `plan: ${data.plan ?? 'none'}`,
|
|
472
|
+
...(linked.length ? ['linked pages:', ...linked.map(p => ` docs/${p.path} [${p.data.type || '?'}] ${p.data.summary || ''}`)] : ['linked pages: none']),
|
|
473
|
+
].join('\n'));
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
if (cmd === 'search') {
|
|
477
|
+
const query = args.slice(1).join(' ');
|
|
478
|
+
if (!query.trim()) { console.error('usage: sw search <words>'); return 2; }
|
|
479
|
+
const hits = search(vault, query);
|
|
480
|
+
// The log is not loaded into the vault; scan it here and report the entries, not the lines.
|
|
481
|
+
const terms = query.toLowerCase().split(/\s+/).filter(w => w.length > 1);
|
|
482
|
+
const entries = [];
|
|
483
|
+
if (existsSync(join(docs, 'log.md'))) {
|
|
484
|
+
let head = null;
|
|
485
|
+
for (const l of readFileSync(join(docs, 'log.md'), 'utf8').split(/\r?\n/)) {
|
|
486
|
+
if (l.startsWith('## [')) { head = { title: l.slice(3), text: '', hit: false }; entries.push(head); }
|
|
487
|
+
else if (head && l.trim() && !head.text) head.text = l.trim().slice(0, 160);
|
|
488
|
+
if (head && terms.some(t => l.toLowerCase().includes(t))) head.hit = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const logHits = entries.filter(e => e.hit).map(e => `${e.title}${e.text ? `\n ${e.text}` : ''}`);
|
|
492
|
+
// Plans have no summary; their first heading says what they are.
|
|
493
|
+
const about = p => p.data.summary || p.data.title || (p.body.match(/^#+\s+(.*)$/m) || [])[1] || '';
|
|
494
|
+
const label = p => (p.folder === 'tasks' ? `task ${taskOf(vault, p.data.id || p.name)?.state ?? ''}` : p.data.type || p.folder);
|
|
495
|
+
print({ pages: hits.map(h => ({ path: `docs/${h.page.path}`, type: label(h.page), summary: about(h.page), termsMatched: h.matched, line: h.line })), log: logHits.slice(-6) },
|
|
496
|
+
[`pages (${hits.length}), best match first; a page matching one common word is a weak match`, ...hits.map(h => `docs/${h.page.path} [${label(h.page)}] ${about(h.page)}${h.line ? `\n ${h.line}` : ''}`), `log entries (${logHits.length}${logHits.length > 6 ? ', last 6 shown' : ''})`, ...logHits.slice(-6)].join('\n'));
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
499
|
+
if (cmd === 'next-id') {
|
|
500
|
+
if (!arg || !/^[A-Za-z][A-Za-z0-9]*$/.test(arg)) { console.error('usage: sw next-id <AREA>'); return 2; }
|
|
501
|
+
print({ id: nextId(vault, arg) }, nextId(vault, arg));
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
if (cmd === 'snapshot') {
|
|
505
|
+
const data = vaultData(docs);
|
|
506
|
+
mkdirSync(join(docs, '.sw'), { recursive: true });
|
|
507
|
+
writeFileSync(join(docs, '.sw', 'data.js'), dataScript(data));
|
|
508
|
+
console.log(`snapshot: ${data.files.length} files -> docs/.sw/data.js`);
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
511
|
+
if (cmd === 'serve') { serve(docs, argv); return null; }
|
|
512
|
+
if (cmd === 'lint') {
|
|
513
|
+
const found = lint(vault);
|
|
514
|
+
const errors = found.filter(f => f.level === 'error').length;
|
|
515
|
+
print(found, [...found.map(f => `${f.level === 'error' ? 'E' : 'W'} ${f.code} docs/${f.path} ${f.message}`), `${errors} errors, ${found.length - errors} warnings`].join('\n'));
|
|
516
|
+
return errors ? 1 : 0;
|
|
517
|
+
}
|
|
518
|
+
console.error(`unknown command ${cmd}\n\n${HELP}`);
|
|
519
|
+
return 2;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// realpath: the script may be reached through a symlinked path (macOS /tmp, linked skills folders).
|
|
523
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { const code = main(process.argv.slice(2)); if (code != null) process.exitCode = code; }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: concept
|
|
3
|
+
summary: One line; this is what index.md shows.
|
|
4
|
+
sources: []
|
|
5
|
+
updated: YYYY-MM-DD
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Page title
|
|
9
|
+
|
|
10
|
+
Body. Link other pages as [[file-name]]. Cite sources as [title](../raw/file.md).
|
|
11
|
+
|
|
12
|
+
<!--
|
|
13
|
+
type is free text. Common values: source (summary of one raw file), entity, concept,
|
|
14
|
+
decision (dated; say what it supersedes), analysis (an answer worth keeping),
|
|
15
|
+
lesson (a problem that happened: Symptom, Cause, Fix, How to notice it earlier).
|
|
16
|
+
File name: lowercase-with-hyphens.md, unique across the vault. Decisions: YYYY-MM-DD-topic.md.
|
|
17
|
+
Delete this comment.
|
|
18
|
+
-->
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: plan
|
|
3
|
+
task: T-01
|
|
4
|
+
updated: YYYY-MM-DD
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Plan: T-01
|
|
8
|
+
|
|
9
|
+
## Approach
|
|
10
|
+
|
|
11
|
+
The chosen approach in a few sentences, and what was ruled out.
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
1. Step with the files it touches and how to verify it.
|
|
16
|
+
|
|
17
|
+
## Verification
|
|
18
|
+
|
|
19
|
+
Commands or checks that prove the task's "Done when" list.
|
|
20
|
+
|
|
21
|
+
<!--
|
|
22
|
+
File: docs/plans/<ID>-plan.md, one plan per task. Rewrite in place; git keeps old versions.
|
|
23
|
+
Design shared by several tasks is a `type: decision` wiki page that the plans link to.
|
|
24
|
+
Delete this comment.
|
|
25
|
+
-->
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: task
|
|
3
|
+
id: T-01
|
|
4
|
+
title: Short imperative title
|
|
5
|
+
status: todo
|
|
6
|
+
deps: []
|
|
7
|
+
soft_deps: []
|
|
8
|
+
milestone:
|
|
9
|
+
priority:
|
|
10
|
+
started:
|
|
11
|
+
finished:
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Goal
|
|
15
|
+
|
|
16
|
+
What exists when this is done, and why it matters.
|
|
17
|
+
|
|
18
|
+
## Done when
|
|
19
|
+
|
|
20
|
+
- Checkable condition.
|
|
21
|
+
|
|
22
|
+
## Sources
|
|
23
|
+
|
|
24
|
+
- [[page-name]]
|
|
25
|
+
|
|
26
|
+
## Notes
|
|
27
|
+
|
|
28
|
+
<!--
|
|
29
|
+
id: from `node docs/.sw/sw.mjs next-id <AREA>`; the file is docs/tasks/<id>.md.
|
|
30
|
+
status: todo | in-progress | done | cancelled. Cancelled tasks stay; ids are never reused.
|
|
31
|
+
deps: must be done before this starts. soft_deps: may start, cannot finish before them.
|
|
32
|
+
Steps go in docs/plans/<id>-plan.md, not here. Delete this comment.
|
|
33
|
+
-->
|