@1agh/maude 0.47.0 → 0.49.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.
Files changed (58) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bridge.ts +8 -1
  3. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  4. package/apps/studio/api.ts +18 -2
  5. package/apps/studio/assets-s3.ts +291 -0
  6. package/apps/studio/build.ts +1 -1
  7. package/apps/studio/client/export-center.jsx +7 -6
  8. package/apps/studio/client/github.js +11 -4
  9. package/apps/studio/collab/origins.ts +150 -0
  10. package/apps/studio/collab/protocol.ts +36 -9
  11. package/apps/studio/collab/room.ts +124 -0
  12. package/apps/studio/context.ts +9 -0
  13. package/apps/studio/dist/client.bundle.js +282 -282
  14. package/apps/studio/dist/comment-mount.js +2 -2
  15. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  16. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  17. package/apps/studio/exporters/pdf.ts +35 -1
  18. package/apps/studio/git/service.ts +4 -1
  19. package/apps/studio/http.ts +45 -0
  20. package/apps/studio/paths.ts +37 -1
  21. package/apps/studio/server.ts +75 -2
  22. package/apps/studio/sync/autocommit.ts +299 -0
  23. package/apps/studio/sync/doc-name.ts +228 -0
  24. package/apps/studio/sync/index.ts +95 -1
  25. package/apps/studio/sync/workspace-signin.ts +301 -0
  26. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  27. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  28. package/apps/studio/test/assets-s3.test.ts +249 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  30. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  31. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  32. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  33. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  34. package/apps/studio/test/workspace-containment.test.ts +258 -0
  35. package/apps/studio/test/workspace-signin.test.ts +270 -0
  36. package/apps/studio/use-collab.tsx +28 -1
  37. package/apps/studio/workspace-mode.ts +210 -0
  38. package/apps/studio/ws.ts +11 -2
  39. package/cli/bin/maude.mjs +1 -0
  40. package/cli/commands/hub-workspace.mjs +341 -0
  41. package/cli/commands/hub.mjs +325 -3
  42. package/cli/commands/hub.test.mjs +14 -1
  43. package/cli/commands/init.mjs +80 -3
  44. package/cli/commands/kg.mjs +368 -0
  45. package/cli/commands/kg.test.mjs +118 -0
  46. package/cli/lib/cell-plan.mjs +302 -0
  47. package/cli/lib/cell-plan.test.mjs +225 -0
  48. package/cli/lib/ddr-to-kgai.mjs +648 -0
  49. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  50. package/cli/lib/flow-design-integration.test.mjs +2 -2
  51. package/cli/lib/gitignore-block.mjs +16 -1
  52. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  53. package/cli/lib/workspace-plan.mjs +422 -0
  54. package/cli/lib/workspace-plan.test.mjs +223 -0
  55. package/package.json +8 -8
  56. package/plugins/design/dependencies.json +17 -0
  57. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  58. package/plugins/flow/dependencies.json +17 -0
@@ -5,12 +5,30 @@
5
5
 
6
6
  import { spawn } from 'node:child_process';
7
7
  import { randomBytes } from 'node:crypto';
8
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import {
9
+ copyFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
9
17
  import { resolve } from 'node:path';
10
18
 
11
19
  import { parseArgs } from '../lib/argv.mjs';
12
20
 
13
- const SUBCOMMANDS = new Set(['serve', 'token', 'status', 'deploy', 'help']);
21
+ const SUBCOMMANDS = new Set([
22
+ 'serve',
23
+ 'token',
24
+ 'status',
25
+ 'deploy',
26
+ 'backup',
27
+ 'restore-drill',
28
+ 'asset-check',
29
+ 'workspace-up',
30
+ 'help',
31
+ ]);
14
32
 
15
33
  export async function run({ args, pkgRoot }) {
16
34
  const { positional } = parseArgs(args);
@@ -29,10 +47,17 @@ export async function run({ args, pkgRoot }) {
29
47
  if (sub === 'token') return runToken({ args, pkgRoot });
30
48
  if (sub === 'status') return runStatus({ args });
31
49
  if (sub === 'deploy') return runDeploy({ args, pkgRoot });
50
+ if (sub === 'backup') return runBackupNow({ args, pkgRoot });
51
+ if (sub === 'restore-drill') return runRestoreDrill({ args, pkgRoot });
52
+ if (sub === 'asset-check') return runAssetCheck({ args, pkgRoot });
53
+ if (sub === 'workspace-up') {
54
+ const mod = await import('./hub-workspace.mjs');
55
+ return mod.run({ args, pkgRoot });
56
+ }
32
57
  }
33
58
 
34
59
  function usage() {
35
- return `maude hub <serve|token|status|deploy> [options]
60
+ return `maude hub <serve|token|status|deploy|backup|restore-drill|asset-check|workspace-up> [options]
36
61
 
37
62
  serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
38
63
  Start the self-hostable Yjs sync hub in the current process tree.
@@ -76,6 +101,47 @@ function usage() {
76
101
  HTTP GET <url>/health, print uptime/version/token-count/peers. URL
77
102
  defaults to http://localhost:1234. --json emits the raw response.
78
103
 
104
+ backup [--data PATH] [--target file://DIR] [--keep N]
105
+ Take one snapshot generation now (VACUUM INTO → gzip → target) and
106
+ prune to the retention limit. Target defaults to $MAUDE_BACKUP_TARGET,
107
+ or the MAUDE_S3_* env set (R2 / MinIO / S3).
108
+
109
+ restore-drill [--target file://DIR] [--sentinel DOCNAME] [--keep-dir] [--json]
110
+ Restore the NEWEST complete backup generation into a throwaway
111
+ directory and verify it: SQLite integrity_check, document count, and
112
+ (with --sentinel) that one named document came back with a non-empty
113
+ payload. Never touches the live data dir. Exits non-zero on failure so
114
+ it can be a CI step.
115
+
116
+ Run this on a schedule. A backup nobody has restored is a hypothesis:
117
+ a database that restores readable-but-empty looks exactly like a
118
+ working one until the day you need it.
119
+
120
+ asset-check [--root PATH] [--json]
121
+ Every 'assets/<sha8>' reference in the project must resolve — locally,
122
+ in the bucket, or both. Reports DANGLING references (referenced by a
123
+ canvas, present in neither) and, with a bucket configured, assets that
124
+ exist locally but were never mirrored.
125
+
126
+ A dangling reference is a permanently broken canvas: the 'assets/'
127
+ prefix is NEVER garbage-collected, and bucket lifecycle/expiry rules
128
+ must be OFF for it, because a canvas in git history can reference an
129
+ asset no current canvas does. Exits non-zero when anything dangles.
130
+
131
+ workspace-up [--domain HOST] [--admin-email EMAIL] [--s3-* ...] [--dry-run]
132
+ Stand up a self-hosted WORKSPACE — a hub that owns the project, commits
133
+ autosaves, and stores media in object storage — and verify it works
134
+ before saying so. Renders compose + Caddyfile + .env (0600), boots the
135
+ stack, then runs a verification plan (health, admin credential, sign-in,
136
+ canvas round-trip, git commit, object storage + no-expiry, restore
137
+ drill). Re-running is the upgrade path and REUSES existing secrets.
138
+
139
+ It scaffolds and verifies once — it does not operate the deployment.
140
+ Rotation, backups, upgrades and the bill stay with you; the run prints
141
+ that list rather than saying "done".
142
+
143
+ Run 'maude hub workspace-up --help' for every option.
144
+
79
145
  deploy <fly|docker> [--name NAME] [--region CODE] [--tag TAG] [--out DIR] [--force]
80
146
  Emit the deploy templates for the chosen target into the current
81
147
  directory (or --out DIR) with placeholders substituted, then print the
@@ -468,3 +534,259 @@ function formatDuration(seconds) {
468
534
  const h = Math.floor(m / 60);
469
535
  return `${h}h${(m % 60).toString().padStart(2, '0')}m${s.toString().padStart(2, '0')}s`;
470
536
  }
537
+
538
+ // --------------------------------------------------------- backup + drill
539
+
540
+ /**
541
+ * Resolve the hub's backup engine. It lives in apps/hub (it is hub-internal,
542
+ * not part of the published npm surface), so it is imported by path rather
543
+ * than as a package — the same way runServe reaches the hub entry point.
544
+ */
545
+ async function loadBackupEngine(pkgRoot) {
546
+ const candidates = [
547
+ resolve(pkgRoot, 'apps/hub/src/backup.mjs'),
548
+ resolve(pkgRoot, '../apps/hub/src/backup.mjs'),
549
+ ];
550
+ for (const candidate of candidates) {
551
+ if (existsSync(candidate)) return import(`file://${candidate}`);
552
+ }
553
+ process.stderr.write(
554
+ 'maude hub: the backup engine (apps/hub/src/backup.mjs) was not found.\n' +
555
+ 'This verb runs from a full checkout or the hub image, not from a plain npm install.\n'
556
+ );
557
+ process.exit(2);
558
+ }
559
+
560
+ function resolveTarget(engine, flags) {
561
+ const explicit = flags.target;
562
+ const target = explicit
563
+ ? explicit.startsWith('file://')
564
+ ? engine.fileTarget(explicit)
565
+ : null
566
+ : engine.targetFromEnv();
567
+ if (!target) {
568
+ process.stderr.write(
569
+ 'maude hub: no backup target configured.\n' +
570
+ ' --target file:///path/to/dir, or set MAUDE_BACKUP_TARGET,\n' +
571
+ ' or the MAUDE_S3_{ENDPOINT,BUCKET,ACCESS_KEY_ID,SECRET_ACCESS_KEY} env set.\n'
572
+ );
573
+ process.exit(2);
574
+ }
575
+ return target;
576
+ }
577
+
578
+ async function runBackupNow({ args, pkgRoot }) {
579
+ const { flags } = parseArgs(args);
580
+ const engine = await loadBackupEngine(pkgRoot);
581
+ const dataDir = resolve(flags.data ?? process.env.DATA_DIR ?? 'data');
582
+ const target = resolveTarget(engine, flags);
583
+ const keep = Number(flags.keep ?? 14);
584
+
585
+ try {
586
+ const result = await engine.runBackup({ dataDir, target, keep });
587
+ process.stdout.write(`backed up ${dataDir} → ${target.describe}\n ${result.prefix}\n`);
588
+ for (const f of result.files) {
589
+ process.stdout.write(` ${f.name.padEnd(12)} ${(f.bytes / 1024).toFixed(1)} KB gz\n`);
590
+ }
591
+ if (result.pruned.length > 0) {
592
+ process.stdout.write(` pruned ${result.pruned.length} old generation(s)\n`);
593
+ }
594
+ } catch (err) {
595
+ process.stderr.write(`maude hub backup: ${err.message}\n`);
596
+ process.exit(1);
597
+ }
598
+ }
599
+
600
+ async function runRestoreDrill({ args, pkgRoot }) {
601
+ const { flags } = parseArgs(args);
602
+ const engine = await loadBackupEngine(pkgRoot);
603
+ const target = resolveTarget(engine, flags);
604
+ const scratchDir = resolve(
605
+ flags['scratch-dir'] ?? `${process.env.TMPDIR ?? '/tmp'}/maude-restore-drill-${process.pid}`
606
+ );
607
+
608
+ let verdict;
609
+ try {
610
+ verdict = await engine.restoreDrill({
611
+ target,
612
+ scratchDir,
613
+ sentinel: flags.sentinel,
614
+ which: flags.generation,
615
+ });
616
+ } catch (err) {
617
+ if (flags.json) process.stdout.write(`${JSON.stringify({ ok: false, error: err.message })}\n`);
618
+ else process.stderr.write(`maude hub restore-drill: ${err.message}\n`);
619
+ process.exit(1);
620
+ }
621
+
622
+ if (flags.json) {
623
+ process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`);
624
+ } else {
625
+ process.stdout.write(
626
+ `restore drill — ${target.describe}\n` +
627
+ ` generation ${verdict.generation}\n` +
628
+ ` restored ${verdict.restored.join(', ')}\n` +
629
+ ` integrity ${verdict.integrity}\n` +
630
+ ` documents ${verdict.documents}\n` +
631
+ (verdict.sentinel
632
+ ? ` sentinel ${verdict.sentinel.name} — ${verdict.sentinel.present ? `${verdict.sentinel.bytes} bytes` : 'ABSENT'}\n`
633
+ : '') +
634
+ ` ${verdict.ok ? 'PASS' : 'FAIL'}\n`
635
+ );
636
+ for (const p of verdict.problems) process.stderr.write(` ! ${p}\n`);
637
+ }
638
+
639
+ if (!flags['keep-dir']) {
640
+ try {
641
+ rmSync(scratchDir, { recursive: true, force: true });
642
+ } catch {
643
+ /* best effort */
644
+ }
645
+ }
646
+ if (!verdict.ok) process.exit(1);
647
+ }
648
+
649
+ // ------------------------------------------------------- asset integrity
650
+
651
+ /**
652
+ * Every `assets/<sha8>` a canvas points at must resolve somewhere.
653
+ *
654
+ * The failure this catches is quiet and permanent: a reference whose bytes
655
+ * exist on nobody's disk and in no bucket renders as a broken image forever,
656
+ * and no amount of syncing fixes it. Content addressing means we can check it
657
+ * cheaply — the reference IS the identity.
658
+ */
659
+ async function runAssetCheck({ args, pkgRoot }) {
660
+ const { flags } = parseArgs(args);
661
+ const root = resolve(flags.root ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
662
+ const designRoot = resolveDesignRoot(root);
663
+ if (!designRoot) {
664
+ process.stderr.write(`maude hub asset-check: no .design/ found under ${root}\n`);
665
+ process.exit(2);
666
+ }
667
+
668
+ // Scan every text file under the design root for asset references. Regex over
669
+ // the whole tree rather than parsing TSX: a reference is a reference whether
670
+ // it appears in JSX, a meta sidecar, or a CSS url().
671
+ const referenced = new Map(); // key -> Set(files that reference it)
672
+ const REF = /assets\/([0-9a-f]{8})(?:\.[A-Za-z0-9]{1,8})?/g;
673
+ const SKIP_DIRS = new Set(['assets', 'node_modules', '.git']);
674
+ const walk = (dir) => {
675
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
676
+ if (entry.name.startsWith('.') && entry.name !== '.design') continue;
677
+ // Per-machine runtime state (DDR-115's `_*` taxonomy) is not a canvas
678
+ // reference — `_generate-history.json` recording an asset it once made
679
+ // is not a broken canvas, and scanning it would report noise as damage.
680
+ if (entry.name.startsWith('_')) continue;
681
+ const abs = resolve(dir, entry.name);
682
+ if (entry.isDirectory()) {
683
+ if (!SKIP_DIRS.has(entry.name)) walk(abs);
684
+ continue;
685
+ }
686
+ if (!/\.(tsx|jsx|ts|js|json|css|svg|md|html)$/i.test(entry.name)) continue;
687
+ let text;
688
+ try {
689
+ text = readFileSync(abs, 'utf8');
690
+ } catch {
691
+ continue;
692
+ }
693
+ for (const m of text.matchAll(REF)) {
694
+ const key = m[0].slice('assets/'.length);
695
+ if (!referenced.has(key)) referenced.set(key, new Set());
696
+ referenced.get(key).add(abs.slice(root.length + 1));
697
+ }
698
+ }
699
+ };
700
+ walk(designRoot);
701
+
702
+ // Local presence, keyed by the LEADING 8 hex chars of the filename.
703
+ //
704
+ // Splitting on '.' looks equivalent and is not: the real corpus contains
705
+ // `<sha8>-<label>.<ext>` (ingested footage) and `<sha8>.<part>.json`
706
+ // (sidecars), so `name.split('.')[0]` yields `deadbeef-cloud` and the asset
707
+ // reads as missing. That produced a false DANGLING report against this repo's
708
+ // own design root — the reference was fine and the index was wrong.
709
+ const assetsDir = resolve(designRoot, 'assets');
710
+ const localBySha = new Map();
711
+ if (existsSync(assetsDir)) {
712
+ for (const name of readdirSync(assetsDir)) {
713
+ const sha = name.match(/^([0-9a-f]{8})(?:[-.]|$)/)?.[1];
714
+ if (sha && !localBySha.has(sha)) localBySha.set(sha, name);
715
+ }
716
+ }
717
+
718
+ const engine = await loadBackupEngine(pkgRoot);
719
+ const s3mod = await import(`file://${resolve(pkgRoot, 'apps/hub/src/s3.mjs')}`).catch(() => null);
720
+ const s3 = s3mod?.s3ConfigFromEnv?.() ?? null;
721
+ void engine;
722
+
723
+ const dangling = [];
724
+ const localOnly = [];
725
+ let inBucket = 0;
726
+
727
+ for (const [key, files] of referenced) {
728
+ const sha = key.split('.')[0];
729
+ const local = localBySha.has(sha);
730
+ let remote = false;
731
+ if (s3) {
732
+ try {
733
+ remote = !!(await s3mod.headObject(s3, `assets/${localBySha.get(sha) ?? key}`));
734
+ } catch {
735
+ remote = false;
736
+ }
737
+ }
738
+ if (remote) inBucket++;
739
+ if (!local && !remote) dangling.push({ key, files: [...files] });
740
+ else if (local && s3 && !remote) localOnly.push({ key, files: [...files] });
741
+ }
742
+
743
+ const report = {
744
+ designRoot: designRoot.slice(root.length + 1),
745
+ referenced: referenced.size,
746
+ local: localBySha.size,
747
+ bucket: s3 ? `s3://${s3.bucket}` : null,
748
+ inBucket: s3 ? inBucket : null,
749
+ dangling,
750
+ notMirrored: s3 ? localOnly : null,
751
+ ok: dangling.length === 0,
752
+ };
753
+
754
+ if (flags.json) {
755
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
756
+ } else {
757
+ process.stdout.write(
758
+ `asset check — ${report.designRoot}\n` +
759
+ ` referenced ${report.referenced}\n` +
760
+ ` on disk ${report.local}\n` +
761
+ (s3
762
+ ? ` in bucket ${inBucket}/${report.referenced} (${report.bucket})\n`
763
+ : ' bucket not configured (set MAUDE_S3_* to check the mirror)\n')
764
+ );
765
+ for (const d of dangling) {
766
+ process.stderr.write(` DANGLING assets/${d.key} — referenced by ${d.files.join(', ')}\n`);
767
+ }
768
+ if (localOnly.length > 0) {
769
+ process.stdout.write(
770
+ ` ${localOnly.length} asset(s) exist locally but are NOT mirrored — ` +
771
+ 'a second machine cannot resolve them yet.\n'
772
+ );
773
+ }
774
+ process.stdout.write(` ${report.ok ? 'OK' : 'FAILED'}\n`);
775
+ }
776
+
777
+ if (!report.ok) process.exit(1);
778
+ }
779
+
780
+ /** `.design/` under `root`, honouring a config-declared designRoot. */
781
+ function resolveDesignRoot(root) {
782
+ const configured = (() => {
783
+ for (const candidate of ['.design/config.json', '.maude/config.json']) {
784
+ const abs = resolve(root, candidate);
785
+ if (existsSync(abs)) return resolve(root, candidate, '..');
786
+ }
787
+ return null;
788
+ })();
789
+ if (configured) return configured;
790
+ const fallback = resolve(root, '.design');
791
+ return existsSync(fallback) ? fallback : null;
792
+ }
@@ -32,7 +32,20 @@ function withDataDir(fn) {
32
32
  test('hub help prints subcommand summary on stdout', () => {
33
33
  const res = runCli(['hub', 'help']);
34
34
  assert.equal(res.status, 0, res.stderr);
35
- assert.match(res.stdout, /maude hub <serve\|token\|status\|deploy>/);
35
+ // Every verb must appear in the usage header — a subcommand that exists but
36
+ // isn't listed is one nobody finds.
37
+ assert.match(res.stdout, /maude hub <[a-z|-]+> \[options\]/);
38
+ for (const verb of [
39
+ 'serve',
40
+ 'token',
41
+ 'status',
42
+ 'deploy',
43
+ 'backup',
44
+ 'restore-drill',
45
+ 'asset-check',
46
+ ]) {
47
+ assert.ok(res.stdout.includes(verb), `usage must mention "${verb}"`);
48
+ }
36
49
  assert.match(res.stdout, /token generate --label NAME/);
37
50
  assert.match(res.stdout, /token rotate --label NAME/);
38
51
  assert.match(res.stdout, /deploy <fly\|docker>/);
@@ -1,8 +1,29 @@
1
- import { stat } from 'node:fs/promises';
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { stat, writeFile } from 'node:fs/promises';
2
4
  import { basename, resolve } from 'node:path';
3
5
  import { parseArgs } from '../lib/argv.mjs';
4
6
  import { copyTree } from '../lib/copy-tree.mjs';
5
7
 
8
+ // Thin STATE.md written under --kg: the knowledge graph is the history authority,
9
+ // so STATE.md shrinks to a human breadcrumb (Open fork #2 — stub, not removal).
10
+ const KG_STATE_STUB = `# Workflow State
11
+
12
+ > **kgai-active repo** — decision history + working context live in the knowledge graph, not this file.
13
+ > The \`flow:workflow-state\` skill reads/writes the graph via \`flow:kgai-backend\`.
14
+
15
+ **Status:** ready
16
+ **Active plan:** —
17
+
18
+ ## Where the history went
19
+
20
+ - **Decisions / "why is X so":** \`maude kg context --about "<area>"\`
21
+ - **Recent movements:** \`maude kg query "MATCH (d:Decision) WHERE d.author='<you>' RETURN d.title, d.recorded_at ORDER BY d.recorded_at DESC LIMIT 10"\`
22
+ - **Conflicts:** \`maude kg conflicts\`
23
+
24
+ The old \`.ai/decisions/\` archive (if any) is preserved read-only — never auto-deleted.
25
+ `;
26
+
6
27
  const PLACEHOLDER = 'PROJECT_NAME';
7
28
  // Files in the skeleton that contain the project-name placeholder and should
8
29
  // be templated on copy.
@@ -43,10 +64,12 @@ const CHANGELOG_STUBS = {
43
64
  const VALID_PROVIDERS = new Set(['changesets', 'git-cliff', 'conventional', 'custom', 'none']);
44
65
 
45
66
  export async function run({ args, pkgRoot }) {
46
- const { flags } = parseArgs(args, { booleans: ['force', 'dry-run', 'help'] });
67
+ const { flags } = parseArgs(args, { booleans: ['force', 'dry-run', 'help', 'kg'] });
47
68
  if (flags.help) {
48
69
  process.stdout.write(
49
- 'maude init [--name <project>] [--provider <changesets|git-cliff|conventional|custom|none>] [--force] [--dry-run]\n'
70
+ 'maude init [--name <project>] [--provider <changesets|git-cliff|conventional|custom|none>] [--kg] [--force] [--dry-run]\n' +
71
+ ' --kg opt into the kgai knowledge-graph backend: write a thin STATE.md pointer-stub\n' +
72
+ ' and bootstrap a local store via `kg init` (no-op when `kg` is not installed).\n'
50
73
  );
51
74
  return;
52
75
  }
@@ -136,10 +159,64 @@ export async function run({ args, pkgRoot }) {
136
159
  (await pathExists(resolve(cwd, 'CLAUDE.md'))) ||
137
160
  (await pathExists(resolve(cwd, '.claude', 'CLAUDE.md')));
138
161
 
162
+ // --kg (opt-in): the knowledge graph becomes the history authority. Replace the
163
+ // scaffolded STATE.md with a thin pointer-stub and bootstrap a local store.
164
+ // The `knowledgeGraph` config block stays ABSENT (bias-free skeleton ⇒ auto via
165
+ // the schema default — onboarding / `maude doctor --fix` fills store + scope).
166
+ if (flags.kg && !flags['dry-run']) {
167
+ const statePath = resolve(aiDir, 'state', 'STATE.md');
168
+ await writeFile(statePath, KG_STATE_STUB, 'utf8');
169
+ process.stdout.write(' kgai: wrote thin STATE.md pointer-stub (history lives in the graph)\n');
170
+ const kgBin = resolveKgBin();
171
+ if (kgBin) {
172
+ // No --root: kgai defaults the store to `<cwd>/.kgai/store` (which the
173
+ // resolver auto-detects and the gitignore block ignores). Passing --root
174
+ // would place the store's loose files at cwd root instead.
175
+ const r = spawnSync(kgBin, ['init'], {
176
+ cwd,
177
+ stdio: 'ignore',
178
+ env: kgInitEnv(),
179
+ });
180
+ process.stdout.write(
181
+ r.status === 0
182
+ ? ' kgai: bootstrapped local store via `kg init` (git-author actor captured)\n'
183
+ : ' kgai: `kg init` did not complete — run it manually (see docs/kgai-onboarding.md)\n'
184
+ );
185
+ } else {
186
+ process.stdout.write(
187
+ ' kgai: `kg` not installed — store not bootstrapped. See docs/kgai-onboarding.md.\n'
188
+ );
189
+ }
190
+ process.stdout.write(
191
+ ' kgai: set `knowledgeGraph.store` + `scope` per docs/kgai-onboarding.md\n'
192
+ );
193
+ } else if (flags.kg) {
194
+ process.stdout.write(' kgai: (dry-run) would write STATE.md stub + bootstrap the store\n');
195
+ }
196
+
139
197
  printSummary(result);
140
198
  printNextSteps(projectName, claudeMdExists);
141
199
  }
142
200
 
201
+ /** KGAI_BIN (desktop-staged sidecar) → `kg` on PATH → null. Mirrors kg.mjs. */
202
+ function resolveKgBin() {
203
+ if (process.env.KGAI_BIN && existsSync(process.env.KGAI_BIN)) return process.env.KGAI_BIN;
204
+ const probe = spawnSync('sh', ['-c', 'command -v kg'], { encoding: 'utf8' });
205
+ const found = (probe.stdout || '').trim();
206
+ return probe.status === 0 && found ? found : null;
207
+ }
208
+
209
+ /** Fold KGAI_LIB into DYLD_LIBRARY_PATH so a staged libkuzu resolves (desktop). */
210
+ function kgInitEnv() {
211
+ const env = { ...process.env };
212
+ if (process.env.KGAI_LIB) {
213
+ env.DYLD_LIBRARY_PATH = [process.env.KGAI_LIB, process.env.DYLD_LIBRARY_PATH]
214
+ .filter(Boolean)
215
+ .join(':');
216
+ }
217
+ return env;
218
+ }
219
+
143
220
  function isValidName(s) {
144
221
  return /^[a-z0-9._-]+$/i.test(s);
145
222
  }