superwiki 0.1.0 → 0.1.2
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/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +63 -23
- package/package.json +1 -1
- package/skills/sw-config/SKILL.md +5 -5
- package/skills/sw-config/assets/implementer.md +39 -10
- package/skills/sw-config/assets/planner.md +52 -17
- package/skills/sw-config/scripts/config.mjs +161 -86
- package/skills/sw-implement/SKILL.md +24 -14
- package/skills/sw-init/assets/agents-block.md +22 -5
- package/skills/sw-init/assets/sw.mjs +360 -165
- package/skills/sw-init/assets/templates/guide.md +32 -0
- package/skills/sw-init/assets/templates/page.md +1 -0
- package/skills/sw-init/assets/templates/plan.md +11 -4
- package/skills/sw-init/assets/viewer.html +7 -2
- package/skills/sw-init/scripts/init.mjs +1 -1
- package/skills/sw-migrate/SKILL.md +2 -2
- package/skills/sw-plan/SKILL.md +19 -17
|
@@ -200,8 +200,8 @@ export function lint(vault) {
|
|
|
200
200
|
if (!p.data.type) add('error', 'missing-field', p.path, 'frontmatter `type` is missing');
|
|
201
201
|
if (!p.data.summary) add('warn', 'missing-field', p.path, 'frontmatter `summary` is missing');
|
|
202
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;
|
|
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');
|
|
203
|
+
// A source summary is reachable from the index and need not be cited yet; an area guide is found by its area, not by links.
|
|
204
|
+
if (p.data.type !== 'source' && p.data.type !== 'guide' && !p.inbound.some(q => q !== vault.index && q !== vault.log)) add('warn', 'orphan-page', p.path, 'no page links here');
|
|
205
205
|
}
|
|
206
206
|
if (p.folder === 'plans') {
|
|
207
207
|
const id = /-plan$/i.test(p.name) ? p.name.replace(/-plan$/i, '') : null;
|
|
@@ -309,12 +309,18 @@ export function search(vault, query, limit = 8) {
|
|
|
309
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
310
|
}
|
|
311
311
|
|
|
312
|
-
//
|
|
313
|
-
|
|
312
|
+
// The wiki page that tells agents how to work in a task area: `type: guide`, `area: <AREA>`.
|
|
313
|
+
export function guideFor(vault, area) {
|
|
314
|
+
return vault.pages.find(p => p.folder === 'wiki' && p.data.type === 'guide' && key(p.data.area ?? '') === key(area)) || null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Superwiki CLI. Lives in a project at docs/.sw/sw.mjs and prints short answers,
|
|
318
|
+
// so agents do not have to read the vault to get them.
|
|
319
|
+
import { spawn } from 'node:child_process';
|
|
320
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
321
|
+
import { createServer } from 'node:http';
|
|
314
322
|
import { basename, dirname, join, resolve as resolvePath } from 'node:path';
|
|
315
323
|
import { fileURLToPath } from 'node:url';
|
|
316
|
-
import { createServer } from 'node:http';
|
|
317
|
-
import { spawn } from 'node:child_process';
|
|
318
324
|
|
|
319
325
|
const HELP = `sw <command> [--docs <dir>] [--json]
|
|
320
326
|
|
|
@@ -328,196 +334,385 @@ const HELP = `sw <command> [--docs <dir>] [--json]
|
|
|
328
334
|
serve [--open] start (or reuse) a local viewer at http://127.0.0.1:<port>/ that reads the files live
|
|
329
335
|
snapshot write docs/.sw/data.js so docs/viewer.html opens as a file, frozen at this moment`;
|
|
330
336
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
337
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
338
|
+
const ROOT_FILES = ['index.md', 'log.md'];
|
|
339
|
+
const SERVER_IDLE_MS = 2 * 60 * 60 * 1000;
|
|
340
|
+
const LOG_HITS_SHOWN = 6;
|
|
341
|
+
|
|
342
|
+
// ---------- Reading the vault ----------
|
|
338
343
|
|
|
339
344
|
function walk(dir, rel, out) {
|
|
340
|
-
for (const
|
|
341
|
-
if (
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
344
|
-
else if (
|
|
345
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
346
|
+
if (entry.name.startsWith('.')) continue;
|
|
347
|
+
const path = join(dir, entry.name);
|
|
348
|
+
if (entry.isDirectory()) walk(path, `${rel}${entry.name}/`, out);
|
|
349
|
+
else if (entry.name.endsWith('.md')) out.push({ path: rel + entry.name, text: readFileSync(path, 'utf8') });
|
|
345
350
|
}
|
|
346
351
|
}
|
|
347
352
|
|
|
348
|
-
|
|
353
|
+
// The vault's markdown files as [{ path, text }]. The log only ever grows, so commands that do not
|
|
354
|
+
// show it record its presence without reading it.
|
|
355
|
+
function readFiles(docs, { withLog }) {
|
|
349
356
|
const files = [];
|
|
350
|
-
for (const name of
|
|
351
|
-
|
|
352
|
-
|
|
357
|
+
for (const name of ROOT_FILES) {
|
|
358
|
+
if (!existsSync(join(docs, name))) continue;
|
|
359
|
+
const skip = name === 'log.md' && !withLog;
|
|
360
|
+
files.push({ path: name, text: skip ? '' : readFileSync(join(docs, name), 'utf8') });
|
|
353
361
|
}
|
|
354
|
-
for (const
|
|
355
|
-
|
|
362
|
+
for (const folder of VAULT_FOLDERS) {
|
|
363
|
+
if (existsSync(join(docs, folder))) walk(join(docs, folder), `${folder}/`, files);
|
|
364
|
+
}
|
|
365
|
+
return files;
|
|
356
366
|
}
|
|
357
367
|
|
|
358
|
-
|
|
368
|
+
export function loadVault(docs) {
|
|
369
|
+
return buildVault(readFiles(docs, { withLog: false }));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function readConfig(docs) {
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(readFileSync(join(docs, '.sw', 'config.json'), 'utf8'));
|
|
375
|
+
} catch {
|
|
376
|
+
return {};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Everything the viewer shows, as one object.
|
|
359
381
|
export function vaultData(docs) {
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
382
|
+
const config = readConfig(docs);
|
|
383
|
+
return {
|
|
384
|
+
name: config.name || basename(dirname(docs)),
|
|
385
|
+
generated: new Date().toISOString(),
|
|
386
|
+
config,
|
|
387
|
+
files: readFiles(docs, { withLog: true }),
|
|
388
|
+
};
|
|
366
389
|
}
|
|
390
|
+
|
|
367
391
|
// "<" is escaped so page text can never close the script element the viewer loads this with.
|
|
368
392
|
export const dataScript = data => `window.SW_DATA = ${JSON.stringify(data).replace(/</g, '\\u003c')};\n`;
|
|
369
393
|
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
394
|
+
// ---------- Commands ----------
|
|
395
|
+
// Each command gets { docs, vault, args, flags } and returns { data, text, code? }.
|
|
396
|
+
// `data` is what --json prints; `text` is the default output.
|
|
397
|
+
|
|
398
|
+
function status({ vault }) {
|
|
399
|
+
const s = summary(vault);
|
|
400
|
+
const row = (name, c) =>
|
|
401
|
+
`${name.padEnd(8)} total ${c.total} ready ${c.ready} in-progress ${c.progress} blocked ${c.blocked} done ${c.done} cancelled ${c.cancelled}`;
|
|
402
|
+
const rows = [...s.areas].map(([area, counts]) => row(area || '(none)', counts));
|
|
403
|
+
if (s.areas.size > 1) rows.push(row('all', s.total));
|
|
404
|
+
return {
|
|
405
|
+
data: { areas: Object.fromEntries(s.areas), total: s.total, wikiPages: s.wikiPages },
|
|
406
|
+
text: [...(rows.length ? rows : ['no tasks']), `wiki pages ${s.wikiPages}`].join('\n'),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function ready({ vault }) {
|
|
411
|
+
const inProgress = tasksIn(vault, 'progress');
|
|
412
|
+
const startable = tasksIn(vault, 'ready');
|
|
413
|
+
const brief = t => ({ id: t.id, title: t.title, milestone: t.milestone, priority: t.priority });
|
|
414
|
+
const line = t => `${t.id} ${t.title}${t.milestone ? ` [${t.milestone}]` : ''}${t.priority != null ? ` p${t.priority}` : ''}`;
|
|
415
|
+
return {
|
|
416
|
+
data: { inProgress: inProgress.map(brief), ready: startable.map(brief) },
|
|
417
|
+
text: [`in progress (${inProgress.length})`, ...inProgress.map(line), `ready (${startable.length})`, ...startable.map(line)].join('\n'),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function taskArg({ vault, args }) {
|
|
422
|
+
const task = args[0] && taskOf(vault, args[0]);
|
|
423
|
+
return task || { error: `no task ${args[0] ?? ''}` };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function check(ctx) {
|
|
427
|
+
const t = taskArg(ctx);
|
|
428
|
+
if (t.error) return { error: t.error };
|
|
429
|
+
const openSoftDeps = t.openSoftDeps.filter(id => taskOf(ctx.vault, id));
|
|
430
|
+
const canStart = t.status === 'todo' && !t.openDeps.length;
|
|
431
|
+
const canFinish = !t.openDeps.length && !t.openSoftDeps.length;
|
|
432
|
+
const plan = t.plan ? `docs/${t.plan.path}` : null;
|
|
433
|
+
const openDeps = t.openDeps.length ? ` open deps: ${t.openDeps.join(', ')}` : '';
|
|
434
|
+
const startLine = t.status === 'todo'
|
|
435
|
+
? `can start: ${canStart ? 'yes' : `no${openDeps}`}`
|
|
436
|
+
: `can start: n/a, status is ${t.status}${openDeps}`;
|
|
437
|
+
const draft = t.plan?.data.status === 'draft' ? ' (draft, not approved)' : '';
|
|
438
|
+
return {
|
|
439
|
+
data: { id: t.id, status: t.status, canStart, canFinish, openDeps: t.openDeps, openSoftDeps, plan },
|
|
440
|
+
text: [
|
|
441
|
+
`${t.id} ${t.status} ${t.title}`,
|
|
442
|
+
startLine,
|
|
443
|
+
`can finish: ${canFinish ? 'yes' : 'no'}${openSoftDeps.length ? ` open soft deps: ${openSoftDeps.join(', ')}` : ''}`,
|
|
444
|
+
`plan: ${plan ? plan + draft : 'none'}`,
|
|
445
|
+
].join('\n'),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function explain(ctx) {
|
|
450
|
+
const { vault } = ctx;
|
|
451
|
+
const t = taskArg(ctx);
|
|
452
|
+
if (t.error) return { error: t.error };
|
|
453
|
+
const describe = id => {
|
|
454
|
+
const dep = taskOf(vault, id);
|
|
455
|
+
return dep ? `${dep.id} (${dep.state}) ${dep.title}` : `${id} (unknown)`;
|
|
456
|
+
};
|
|
457
|
+
const section = (label, ids) => (ids.length ? [`${label}:`, ...ids.map(id => ` ${describe(id)}`)] : [`${label}: none`]);
|
|
458
|
+
const linked = [...new Set(t.page.links.map(l => resolve(vault, l.target)).filter(p => p && p.folder === 'wiki'))];
|
|
459
|
+
const unblocks = unblockedBy(vault, t.id);
|
|
460
|
+
const guide = guideFor(vault, t.area);
|
|
461
|
+
const plan = t.plan ? `docs/${t.plan.path}` : null;
|
|
462
|
+
const guidePath = guide ? `docs/${guide.path}` : null;
|
|
463
|
+
const facts = [
|
|
464
|
+
`state: ${t.state} (status: ${t.status})`,
|
|
465
|
+
t.milestone && `milestone: ${t.milestone}`,
|
|
466
|
+
t.priority != null && `priority: ${t.priority}`,
|
|
467
|
+
t.started && `started: ${t.started}`,
|
|
468
|
+
t.finished && `finished: ${t.finished}`,
|
|
469
|
+
].filter(Boolean);
|
|
470
|
+
return {
|
|
471
|
+
data: {
|
|
472
|
+
id: t.id, title: t.title, status: t.status, state: t.state, milestone: t.milestone, priority: t.priority,
|
|
473
|
+
started: t.started, finished: t.finished, deps: t.deps, softDeps: t.softDeps, dependents: t.dependents, unblocks,
|
|
474
|
+
plan, guide: guidePath,
|
|
475
|
+
linked: linked.map(p => ({ path: `docs/${p.path}`, type: p.data.type || '', summary: p.data.summary || '' })),
|
|
476
|
+
},
|
|
477
|
+
text: [
|
|
478
|
+
`${t.id} ${t.title}`,
|
|
479
|
+
facts.join(' '),
|
|
480
|
+
...section('depends on', t.deps),
|
|
481
|
+
...(t.softDeps.length ? section('soft depends on', t.softDeps) : []),
|
|
482
|
+
...section('blocks', t.dependents),
|
|
483
|
+
`finishing it makes ready: ${unblocks.join(', ') || 'nothing yet'}`,
|
|
484
|
+
`task: docs/${t.page.path}`,
|
|
485
|
+
`plan: ${plan ?? 'none'}`,
|
|
486
|
+
`area guide: ${guidePath ?? 'none'}`,
|
|
487
|
+
...(linked.length
|
|
488
|
+
? ['linked pages:', ...linked.map(p => ` docs/${p.path} [${p.data.type || '?'}] ${p.data.summary || ''}`)]
|
|
489
|
+
: ['linked pages: none']),
|
|
490
|
+
].join('\n'),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Log entries (heading plus first body line) that mention any of the terms. The log is not part of
|
|
495
|
+
// the loaded vault, so it is scanned here.
|
|
496
|
+
function searchLog(docs, terms) {
|
|
497
|
+
const path = join(docs, 'log.md');
|
|
498
|
+
if (!existsSync(path)) return [];
|
|
499
|
+
const entries = [];
|
|
500
|
+
let entry = null;
|
|
501
|
+
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
|
502
|
+
if (line.startsWith('## [')) {
|
|
503
|
+
entry = { title: line.slice(3), text: '', hit: false };
|
|
504
|
+
entries.push(entry);
|
|
505
|
+
} else if (entry && line.trim() && !entry.text) {
|
|
506
|
+
entry.text = line.trim().slice(0, 160);
|
|
507
|
+
}
|
|
508
|
+
if (entry && terms.some(term => line.toLowerCase().includes(term))) entry.hit = true;
|
|
509
|
+
}
|
|
510
|
+
return entries.filter(e => e.hit).map(e => `${e.title}${e.text ? `\n ${e.text}` : ''}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function searchCommand({ docs, vault, args }) {
|
|
514
|
+
const query = args.join(' ');
|
|
515
|
+
if (!query.trim()) return { error: 'usage: sw search <words>' };
|
|
516
|
+
const hits = search(vault, query);
|
|
517
|
+
const logHits = searchLog(docs, query.toLowerCase().split(/\s+/).filter(w => w.length > 1));
|
|
518
|
+
const shownLog = logHits.slice(-LOG_HITS_SHOWN);
|
|
519
|
+
// Plans have no summary; their first heading says what they are.
|
|
520
|
+
const about = p => p.data.summary || p.data.title || (p.body.match(/^#+\s+(.*)$/m) || [])[1] || '';
|
|
521
|
+
const kind = p => (p.folder === 'tasks' ? `task ${taskOf(vault, p.data.id || p.name)?.state ?? ''}` : p.data.type || p.folder);
|
|
522
|
+
return {
|
|
523
|
+
data: {
|
|
524
|
+
pages: hits.map(h => ({ path: `docs/${h.page.path}`, type: kind(h.page), summary: about(h.page), termsMatched: h.matched, line: h.line })),
|
|
525
|
+
log: shownLog,
|
|
526
|
+
},
|
|
527
|
+
text: [
|
|
528
|
+
`pages (${hits.length}), best match first; a page matching one common word is a weak match`,
|
|
529
|
+
...hits.map(h => `docs/${h.page.path} [${kind(h.page)}] ${about(h.page)}${h.line ? `\n ${h.line}` : ''}`),
|
|
530
|
+
`log entries (${logHits.length}${logHits.length > LOG_HITS_SHOWN ? `, last ${LOG_HITS_SHOWN} shown` : ''})`,
|
|
531
|
+
...shownLog,
|
|
532
|
+
].join('\n'),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function nextIdCommand({ vault, args }) {
|
|
537
|
+
const area = args[0];
|
|
538
|
+
if (!area || !/^[A-Za-z][A-Za-z0-9]*$/.test(area)) return { error: 'usage: sw next-id <AREA>' };
|
|
539
|
+
const id = nextId(vault, area);
|
|
540
|
+
return { data: { id }, text: id };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function lintCommand({ vault }) {
|
|
544
|
+
const findings = lint(vault);
|
|
545
|
+
const errors = findings.filter(f => f.level === 'error').length;
|
|
546
|
+
return {
|
|
547
|
+
data: findings,
|
|
548
|
+
text: [
|
|
549
|
+
...findings.map(f => `${f.level === 'error' ? 'E' : 'W'} ${f.code} docs/${f.path} ${f.message}`),
|
|
550
|
+
`${errors} errors, ${findings.length - errors} warnings`,
|
|
551
|
+
].join('\n'),
|
|
552
|
+
code: errors ? 1 : 0,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function snapshot({ docs }) {
|
|
557
|
+
const data = vaultData(docs);
|
|
558
|
+
mkdirSync(join(docs, '.sw'), { recursive: true });
|
|
559
|
+
writeFileSync(join(docs, '.sw', 'data.js'), dataScript(data));
|
|
560
|
+
const text = `snapshot: ${data.files.length} files -> docs/.sw/data.js`;
|
|
561
|
+
return { data: { files: data.files.length }, text };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------- Viewer server ----------
|
|
565
|
+
// A file:// page cannot read local files unless the user picks a folder. Served from localhost it
|
|
566
|
+
// can: every Refresh asks this process, which reads the files as they are now.
|
|
567
|
+
|
|
568
|
+
const serverStatePath = docs => join(docs, '.sw', 'server.json');
|
|
569
|
+
|
|
570
|
+
function readServerState(docs) {
|
|
571
|
+
try {
|
|
572
|
+
return JSON.parse(readFileSync(serverStatePath(docs), 'utf8'));
|
|
573
|
+
} catch {
|
|
574
|
+
return null;
|
|
398
575
|
}
|
|
576
|
+
}
|
|
399
577
|
|
|
400
|
-
|
|
578
|
+
async function servesDocs(url, docs) {
|
|
579
|
+
try {
|
|
580
|
+
return (await (await fetch(`${url}.sw/ping`)).text()) === docs;
|
|
581
|
+
} catch {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function runServer(docs) {
|
|
587
|
+
let lastRequest = Date.now();
|
|
401
588
|
const server = createServer((req, res) => {
|
|
402
|
-
|
|
403
|
-
const port = server.address()
|
|
589
|
+
lastRequest = Date.now();
|
|
590
|
+
const { port } = server.address();
|
|
404
591
|
// 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)) {
|
|
592
|
+
if (![`127.0.0.1:${port}`, `localhost:${port}`].includes(req.headers.host)) {
|
|
593
|
+
res.writeHead(403).end();
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const send = (type, body) => {
|
|
597
|
+
res.writeHead(200, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store' });
|
|
598
|
+
res.end(body);
|
|
599
|
+
};
|
|
406
600
|
const path = (req.url || '/').split('?')[0];
|
|
407
|
-
const
|
|
408
|
-
if (path === '/' || path === '/viewer.html')
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
601
|
+
const viewer = join(docs, 'viewer.html');
|
|
602
|
+
if (path === '/' || path === '/viewer.html') {
|
|
603
|
+
if (existsSync(viewer)) send('text/html', readFileSync(viewer));
|
|
604
|
+
else res.writeHead(404).end('docs/viewer.html is missing; run sw-init');
|
|
605
|
+
} else if (path === '/.sw/data.js') {
|
|
606
|
+
send('text/javascript', dataScript({ ...vaultData(docs), live: true }));
|
|
607
|
+
} else if (path === '/.sw/ping') {
|
|
608
|
+
send('text/plain', docs);
|
|
609
|
+
} else {
|
|
610
|
+
res.writeHead(404).end();
|
|
611
|
+
}
|
|
412
612
|
});
|
|
413
613
|
server.listen(0, '127.0.0.1', () => {
|
|
414
614
|
mkdirSync(join(docs, '.sw'), { recursive: true });
|
|
415
|
-
writeFileSync(
|
|
615
|
+
writeFileSync(serverStatePath(docs), JSON.stringify({ url: `http://127.0.0.1:${server.address().port}/`, pid: process.pid }) + '\n');
|
|
416
616
|
});
|
|
417
|
-
setInterval(() => {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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;
|
|
617
|
+
setInterval(() => {
|
|
618
|
+
if (Date.now() - lastRequest > SERVER_IDLE_MS) process.exit(0);
|
|
619
|
+
}, 60 * 1000);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// The URL of this project's viewer server, starting a detached one if none is running.
|
|
623
|
+
async function ensureServer(docs) {
|
|
624
|
+
const running = readServerState(docs)?.url;
|
|
625
|
+
if (running && (await servesDocs(running, docs))) return running;
|
|
626
|
+
spawn(process.execPath, [SELF, 'serve', '--foreground', '--docs', docs], { detached: true, stdio: 'ignore' }).unref();
|
|
627
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
628
|
+
await new Promise(done => setTimeout(done, 100));
|
|
629
|
+
const url = readServerState(docs)?.url;
|
|
630
|
+
if (url && (await servesDocs(url, docs))) return url;
|
|
448
631
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function openInBrowser(url) {
|
|
636
|
+
const [bin, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
637
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
638
|
+
: ['xdg-open', [url]];
|
|
639
|
+
spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function serve({ docs, flags }) {
|
|
643
|
+
if (flags.foreground) {
|
|
644
|
+
runServer(docs);
|
|
645
|
+
return null; // keeps running; no result to print
|
|
456
646
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
647
|
+
const url = await ensureServer(docs);
|
|
648
|
+
if (!url) return { error: 'could not start the viewer server; use `snapshot` and open docs/viewer.html instead', code: 1 };
|
|
649
|
+
if (flags.open) openInBrowser(url);
|
|
650
|
+
return { data: { url }, text: url };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---------- Entry point ----------
|
|
654
|
+
|
|
655
|
+
const COMMANDS = {
|
|
656
|
+
status: { run: status, needsVault: true },
|
|
657
|
+
ready: { run: ready, needsVault: true },
|
|
658
|
+
check: { run: check, needsVault: true },
|
|
659
|
+
explain: { run: explain, needsVault: true },
|
|
660
|
+
search: { run: searchCommand, needsVault: true },
|
|
661
|
+
'next-id': { run: nextIdCommand, needsVault: true },
|
|
662
|
+
lint: { run: lintCommand, needsVault: true },
|
|
663
|
+
snapshot: { run: snapshot, needsVault: false },
|
|
664
|
+
serve: { run: serve, needsVault: false },
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
function parseArgs(argv) {
|
|
668
|
+
const flags = { json: false, open: false, foreground: false, docs: null };
|
|
669
|
+
const positional = [];
|
|
670
|
+
for (let i = 0; i < argv.length; i++) {
|
|
671
|
+
const arg = argv[i];
|
|
672
|
+
if (arg === '--docs') flags.docs = argv[++i] || '.';
|
|
673
|
+
else if (arg === '--json') flags.json = true;
|
|
674
|
+
else if (arg === '--open') flags.open = true;
|
|
675
|
+
else if (arg === '--foreground') flags.foreground = true;
|
|
676
|
+
else positional.push(arg);
|
|
475
677
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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'));
|
|
678
|
+
return { command: positional[0], args: positional.slice(1), flags };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// --docs wins; otherwise the docs folder this script was installed into; otherwise ./docs.
|
|
682
|
+
function docsDir(flags) {
|
|
683
|
+
if (flags.docs) return resolvePath(flags.docs);
|
|
684
|
+
const installedIn = join(dirname(SELF), '..');
|
|
685
|
+
return existsSync(join(installedIn, 'index.md')) ? installedIn : resolvePath('docs');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function main(argv) {
|
|
689
|
+
const { command, args, flags } = parseArgs(argv);
|
|
690
|
+
if (!command || command === 'help' || command === '--help') {
|
|
691
|
+
console.log(HELP);
|
|
497
692
|
return 0;
|
|
498
693
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
return
|
|
694
|
+
const entry = COMMANDS[command];
|
|
695
|
+
if (!entry) {
|
|
696
|
+
console.error(`unknown command ${command}\n\n${HELP}`);
|
|
697
|
+
return 2;
|
|
503
698
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
console.log(`snapshot: ${data.files.length} files -> docs/.sw/data.js`);
|
|
509
|
-
return 0;
|
|
699
|
+
const docs = docsDir(flags);
|
|
700
|
+
if (!existsSync(docs)) {
|
|
701
|
+
console.error(`no docs folder at ${docs}; run sw-init or pass --docs`);
|
|
702
|
+
return 2;
|
|
510
703
|
}
|
|
511
|
-
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
return errors ? 1 : 0;
|
|
704
|
+
const result = await entry.run({ docs, args, flags, vault: entry.needsVault ? loadVault(docs) : null });
|
|
705
|
+
if (result === null) return null;
|
|
706
|
+
if (result.error) {
|
|
707
|
+
console.error(result.error);
|
|
708
|
+
return result.code ?? 2;
|
|
517
709
|
}
|
|
518
|
-
console.
|
|
519
|
-
return
|
|
710
|
+
console.log(flags.json ? JSON.stringify(result.data, null, 2) : result.text);
|
|
711
|
+
return result.code ?? 0;
|
|
520
712
|
}
|
|
521
713
|
|
|
522
714
|
// 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(
|
|
715
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(SELF)) {
|
|
716
|
+
const code = await main(process.argv.slice(2));
|
|
717
|
+
if (code !== null) process.exitCode = code;
|
|
718
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: guide
|
|
3
|
+
area: T
|
|
4
|
+
summary: Where things are in this area, the patterns to follow and how to verify.
|
|
5
|
+
updated: YYYY-MM-DD
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Area guide: T
|
|
9
|
+
|
|
10
|
+
## Layout
|
|
11
|
+
|
|
12
|
+
- path: what lives there.
|
|
13
|
+
|
|
14
|
+
## Patterns
|
|
15
|
+
|
|
16
|
+
- When you add X, copy Y.
|
|
17
|
+
|
|
18
|
+
## Verify
|
|
19
|
+
|
|
20
|
+
- command: what it proves.
|
|
21
|
+
|
|
22
|
+
## Gotchas
|
|
23
|
+
|
|
24
|
+
- What went wrong before and how to avoid it.
|
|
25
|
+
|
|
26
|
+
<!--
|
|
27
|
+
Optional. At most one guide per task area: docs/wiki/guide-<area, lowercase>.md, listed in index.md.
|
|
28
|
+
Start one when several tasks in an area have needed the same facts. Where a guide exists,
|
|
29
|
+
planners and implementers read it first and report what it was missing, and sw-implement
|
|
30
|
+
adds those lines. One line per fact, 60 lines at most: replace stale lines, do not append
|
|
31
|
+
forever. Delete this comment.
|
|
32
|
+
-->
|
|
@@ -13,6 +13,7 @@ Body. Link other pages as [[file-name]]. Cite sources as [title](../raw/file.md)
|
|
|
13
13
|
type is free text. Common values: source (summary of one raw file), entity, concept,
|
|
14
14
|
decision (dated; say what it supersedes), analysis (an answer worth keeping),
|
|
15
15
|
lesson (a problem that happened: Symptom, Cause, Fix, How to notice it earlier).
|
|
16
|
+
guide (how to work in one task area; see templates/guide.md).
|
|
16
17
|
File name: lowercase-with-hyphens.md, unique across the vault. Decisions: YYYY-MM-DD-topic.md.
|
|
17
18
|
Delete this comment.
|
|
18
19
|
-->
|