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.
@@ -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; 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');
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
- // 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';
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
- 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
- }
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 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
+ 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
- export function loadVault(docs) {
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 ['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') });
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 f of VAULT_FOLDERS) if (existsSync(join(docs, f))) walk(join(docs, f), `${f}/`, files);
355
- return buildVault(files);
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
- // Everything the viewer shows, as one object: the same pages the CLI reads, plus the log's text.
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 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 };
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
- // 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;
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
- let last = Date.now();
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
- last = Date.now();
403
- const port = server.address().port;
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)) { res.writeHead(403).end(); return; }
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 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();
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(statePath, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}/`, pid: process.pid }) + '\n');
615
+ writeFileSync(serverStatePath(docs), JSON.stringify({ url: `http://127.0.0.1:${server.address().port}/`, pid: process.pid }) + '\n');
416
616
  });
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;
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
- 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;
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
- 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;
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
- 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'));
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
- 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;
694
+ const entry = COMMANDS[command];
695
+ if (!entry) {
696
+ console.error(`unknown command ${command}\n\n${HELP}`);
697
+ return 2;
503
698
  }
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;
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
- 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;
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.error(`unknown command ${cmd}\n\n${HELP}`);
519
- return 2;
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(fileURLToPath(import.meta.url))) { const code = main(process.argv.slice(2)); if (code != null) process.exitCode = code; }
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
  -->