@1agh/maude 0.46.0 → 0.48.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 (81) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  3. package/apps/studio/acp/bridge.ts +121 -6
  4. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  5. package/apps/studio/annotations-layer.tsx +42 -0
  6. package/apps/studio/api.ts +417 -2
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  13. package/apps/studio/bin/curl-local.sh +28 -0
  14. package/apps/studio/build.ts +1 -1
  15. package/apps/studio/canvas-cursors.ts +6 -0
  16. package/apps/studio/canvas-edit.ts +678 -11
  17. package/apps/studio/canvas-icons.tsx +13 -0
  18. package/apps/studio/canvas-lib.tsx +3 -0
  19. package/apps/studio/canvas-shell.tsx +646 -26
  20. package/apps/studio/client/app.jsx +898 -52
  21. package/apps/studio/client/export-center.jsx +7 -6
  22. package/apps/studio/client/github.js +11 -4
  23. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  24. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  25. package/apps/studio/contextual-toolbar.tsx +5 -3
  26. package/apps/studio/dist/client.bundle.js +1103 -1103
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  29. package/apps/studio/dist/styles.css +1 -1
  30. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  31. package/apps/studio/exporters/pdf.ts +35 -1
  32. package/apps/studio/git/service.ts +4 -1
  33. package/apps/studio/grid-track-handles.ts +179 -0
  34. package/apps/studio/handoff.ts +35 -0
  35. package/apps/studio/http.ts +118 -0
  36. package/apps/studio/input-router.tsx +73 -17
  37. package/apps/studio/paths.ts +37 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  39. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  40. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  41. package/apps/studio/test/browse-posture.test.tsx +107 -0
  42. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  43. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  45. package/apps/studio/test/comment-mount.test.ts +2 -1
  46. package/apps/studio/test/component-map.test.ts +48 -0
  47. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  48. package/apps/studio/test/detach-component.test.ts +94 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  50. package/apps/studio/test/element-structural-api.test.ts +74 -0
  51. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  52. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  53. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  54. package/apps/studio/test/handoff.test.ts +48 -0
  55. package/apps/studio/test/input-router.test.ts +82 -8
  56. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  57. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  58. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  59. package/apps/studio/tool-palette.tsx +3 -1
  60. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  61. package/apps/studio/use-element-resize.tsx +3 -1
  62. package/apps/studio/use-grid-track-handles.tsx +364 -0
  63. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  64. package/apps/studio/use-tool-mode.tsx +30 -3
  65. package/apps/studio/web-overlay-content.tsx +52 -0
  66. package/apps/studio/whats-new.json +27 -0
  67. package/cli/bin/maude.mjs +1 -0
  68. package/cli/commands/design.mjs +16 -0
  69. package/cli/commands/init.mjs +80 -3
  70. package/cli/commands/kg.mjs +368 -0
  71. package/cli/commands/kg.test.mjs +118 -0
  72. package/cli/lib/ddr-to-kgai.mjs +648 -0
  73. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  74. package/cli/lib/flow-design-integration.test.mjs +2 -2
  75. package/cli/lib/gitignore-block.mjs +4 -0
  76. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  77. package/package.json +8 -8
  78. package/plugins/design/dependencies.json +17 -0
  79. package/plugins/design/templates/_shell.html +4 -0
  80. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  81. package/plugins/flow/dependencies.json +17 -0
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "figma-smart-select",
6
+ "version": "0.47.0",
7
+ "date": "2026-07-21",
8
+ "kind": "feature",
9
+ "title": "Click to select, like Figma",
10
+ "summary": "Your mock now opens live — click a button or link to actually try it. When you want to edit, press V: a plain click selects the object under your cursor, ⌘-click reaches the deepest element, double-click drills in one level, and Enter / Shift+Enter / Tab walk the layer tree. The toolbar's first two tools are Browse (live) and Select (V). The Layers panel grew up too: wrapper groups show at their real depth, component instances render purple with their component's name, you can lock a layer (locked layers can't be selected or dragged on the canvas), double-click a layer's name to rename it, and the tree auto-reveals whatever you pick on the canvas. And when a flex layout fights your manual tweaks, right-click the container → \"Convert children to absolute position\" freezes every child where it sits so you can drag them freely (one undo reverts it) — or convert a whole artboard at once (right-click the artboard, or when switching its kind to Digital/Print). And when a component instance needs its own look, hit Detach on the Inspector's Shared badge — edits stay local to that one instance. Text editing got Figma's exact rhythm too: drilling down to a text layer selects all of it (ready to overwrite), and once you're in, a click places the cursor, a double-click grabs a word, and a triple-click grabs everything.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "acp-safe-defaults-and-attention-notifications",
15
+ "version": "0.47.0",
16
+ "date": "2026-07-21",
17
+ "kind": "improvement",
18
+ "title": "Fewer prompts in chat, and a nudge when you're needed",
19
+ "summary": "The assistant chat now runs common design-workflow actions — browser checks, your own local dev-server requests, reading files, and web search — without asking every time. And when a chat is waiting on your approval or an answer, Maude now sends a system notification so you don't miss it.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "web-artboards",
24
+ "version": "0.47.0",
25
+ "date": "2026-07-21",
26
+ "kind": "feature",
27
+ "title": "Web artboards — breakpoints, duplicate, and a grid editor",
28
+ "summary": "Set an artboard's kind to Web and it becomes a responsive viewport: a small chip shows its current breakpoint, and \"Duplicate at width…\" (right-click or the Inspector) clones it at Mobile/Tablet/Laptop/Desktop widths for reflow testing. Grid containers also get a new Grid section in the Inspector — define columns and rows with px/%/fr/em/auto units, drag the gutters directly on the canvas, and place children into specific cells.",
29
+ "surface": "design-ui"
30
+ },
4
31
  {
5
32
  "id": "scene-aware-video-analyze",
6
33
  "version": "0.46.0",
package/cli/bin/maude.mjs CHANGED
@@ -18,6 +18,7 @@ const COMMANDS = {
18
18
  design: () => import('../commands/design.mjs'),
19
19
  'scenario-report': () => import('../commands/scenario-report.mjs'),
20
20
  doctor: () => import('../commands/doctor.mjs'),
21
+ kg: () => import('../commands/kg.mjs'),
21
22
  help: () => import('../commands/help.mjs'),
22
23
  hub: () => import('../commands/hub.mjs'),
23
24
  version: () => import('../commands/version.mjs'),
@@ -110,6 +110,22 @@ const BIN_VERBS = new Set([
110
110
  // user's ElevenLabs history before spending credits on a new music/SFX/VO
111
111
  // track. Runs server-side (key resolved by the sidecar); needs a dev server.
112
112
  'audio-search',
113
+ // DDR-185. `curl-local` is a loopback-only curl: resolves the request's
114
+ // target host and refuses to invoke real curl at all unless every resolved
115
+ // address is strictly loopback (127.0.0.0/8 or ::1). Exists so an ACP chat
116
+ // session can auto-approve "check my local dev server" without widening the
117
+ // session's Bash allow-list to unscoped curl (`Bash(curl:*)` would reach any
118
+ // host — see DDR-185's rejected alternatives). No dev server dependency —
119
+ // pure Node, no `<designRoot>` involvement.
120
+ 'curl-local',
121
+ // DDR-185 security addendum. `agent-browser-safe` replaces the bare
122
+ // `Bash(agent-browser:*)` allow-list entry a security review found was a
123
+ // zero-confirmation session-hijack primitive (agent-browser's own bundled
124
+ // skill recommends a PERSISTENT authenticated Chrome profile). Closed
125
+ // subcommand allow-list + forced domain scope + forced ephemeral profile —
126
+ // see `_agent-browser-safe.mjs` for the full rationale. No dev server
127
+ // dependency — pure Node.
128
+ 'agent-browser-safe',
113
129
  ]);
114
130
 
115
131
  // Bin verbs that boot the dev-server (directly, or by shelling into server-up.sh).
@@ -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
  }
@@ -0,0 +1,368 @@
1
+ // `maude kg <verb>` — the resolved dispatcher for the kgai knowledge-graph
2
+ // backend (feature-kgai-ecosystem-integration). Plugin markdown reaches kgai
3
+ // ONLY through here (DDR-062), never a raw `kg` binary path: this command
4
+ // resolves the pinned/bundled `kg`, reads the `knowledgeGraph.*` config, gates
5
+ // `active`, and injects the resolved store/scope env before spawning `kg`.
6
+ //
7
+ // The resolver lives here (not duplicated in bash/markdown) so the `kgai-backend`
8
+ // skill and every command gate identically via `maude kg resolve --json`.
9
+ //
10
+ // Capability-gated + opt-out (mirrors orchestration.mode:auto, DDR-130): when
11
+ // `kg` is absent / store unreachable / mode:off, verbs degrade to a clean no-op
12
+ // or an informative message — a command's classic `.ai/` path is unaffected.
13
+
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+ import { parseArgs } from '../lib/argv.mjs';
18
+
19
+ const CONFIG_PATH = '.ai/workflows.config.json';
20
+ const DEFAULT_ENGINE_VERSION = 'v0.1.9';
21
+ const KGAI_REPO = 'kgaidev/kgai';
22
+
23
+ const VERBS = new Set([
24
+ 'resolve',
25
+ 'doctor',
26
+ 'check-upstream',
27
+ 'session-sync',
28
+ 'sync',
29
+ 'context',
30
+ 'ingest',
31
+ 'scope',
32
+ 'import',
33
+ 'help',
34
+ ]);
35
+
36
+ /** Resolve the project root the SAME way the design dev-server does. */
37
+ function resolveProjectRoot(flags) {
38
+ return flags.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
39
+ }
40
+
41
+ /** Read the `knowledgeGraph` block with safe defaults (absent ⇒ auto). */
42
+ function readConfig(projectRoot) {
43
+ const path = resolve(projectRoot, CONFIG_PATH);
44
+ let raw = {};
45
+ try {
46
+ raw = JSON.parse(readFileSync(path, 'utf8'))?.knowledgeGraph ?? {};
47
+ } catch {
48
+ raw = {};
49
+ }
50
+ return {
51
+ mode: raw.mode ?? 'auto',
52
+ engine: raw.engine ?? 'kgai',
53
+ engineVersion: raw.engineVersion ?? DEFAULT_ENGINE_VERSION,
54
+ store: raw.store ?? '',
55
+ scope: raw.scope ?? {},
56
+ capture: { decisions: true, state: true, auto: true, ...(raw.capture ?? {}) },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * The staged desktop engine, resolved from maude's OWN package root (DDR-045 —
62
+ * never `import.meta.url`, which is `/$bunfs/root` inside the compiled binary).
63
+ * In the `.app`, pkgRoot is bridged to `Contents/Resources`, the `kg` sidecar
64
+ * lands in `Contents/MacOS/`, and `libkuzu` under `Resources/kgai/` (see
65
+ * apps/desktop/scripts/sync-kg.mjs). Returns `{ bin, lib }` or null elsewhere.
66
+ */
67
+ function resolveStagedKgai(pkgRoot) {
68
+ if (!pkgRoot) return null;
69
+ const lib = join(pkgRoot, 'kgai');
70
+ if (!existsSync(lib)) return null;
71
+ const exe = process.platform === 'win32' ? '.exe' : '';
72
+ for (const bin of [join(pkgRoot, '..', 'MacOS', `kg${exe}`), join(lib, `kg${exe}`)]) {
73
+ if (existsSync(bin)) return { bin, lib };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** KGAI_BIN env → desktop-staged sidecar → `kg` on PATH → null. */
79
+ function resolveKgBin(pkgRoot) {
80
+ if (process.env.KGAI_BIN && existsSync(process.env.KGAI_BIN)) return process.env.KGAI_BIN;
81
+ const staged = resolveStagedKgai(pkgRoot);
82
+ if (staged) {
83
+ // Make the sibling libkuzu reachable for this process's spawns (kgEnv folds
84
+ // it into DYLD_/LD_LIBRARY_PATH) without requiring the caller to plumb env.
85
+ process.env.KGAI_LIB ||= staged.lib;
86
+ return staged.bin;
87
+ }
88
+ const probe = spawnSync('sh', ['-c', 'command -v kg'], { encoding: 'utf8' });
89
+ const found = (probe.stdout || '').trim();
90
+ return probe.status === 0 && found ? found : null;
91
+ }
92
+
93
+ /** The capability gate — the single source of `active`. */
94
+ function resolveState(projectRoot, pkgRoot) {
95
+ const cfg = readConfig(projectRoot);
96
+ const kgBin = resolveKgBin(pkgRoot);
97
+ const localStore = existsSync(resolve(projectRoot, '.kgai', 'store'));
98
+ const storeResolvable = cfg.store !== '' || localStore;
99
+ let active;
100
+ if (cfg.mode === 'on') active = true;
101
+ else if (cfg.mode === 'off') active = false;
102
+ else active = Boolean(kgBin) && storeResolvable; // auto
103
+ return {
104
+ active,
105
+ mode: cfg.mode,
106
+ engine: cfg.engine,
107
+ engineVersion: cfg.engineVersion,
108
+ store: cfg.store,
109
+ scope: cfg.scope,
110
+ capture: cfg.capture,
111
+ kgBin,
112
+ kgPresent: Boolean(kgBin),
113
+ projectRoot,
114
+ };
115
+ }
116
+
117
+ /** Child env for a real `kg` spawn. libkuzu path (desktop); never override actor.
118
+ * NOTE: `config.knowledgeGraph.store` is the kgai REMOTE (s3://… / kgai://… / git),
119
+ * persisted into the store by `kg init --remote` and read by `kg sync` — it is NOT
120
+ * `KGAI_STORE` (which is the LOCAL store-root dir, default `<project>/.kgai/store`).
121
+ * So we deliberately do NOT export KGAI_STORE from the config store. */
122
+ function kgEnv() {
123
+ const env = { ...process.env };
124
+ // Desktop bundle stages libkuzu next to `kg` and points KGAI_LIB at its dir;
125
+ // fold it into DYLD_LIBRARY_PATH so the dylib resolves. No-op when unset.
126
+ if (process.env.KGAI_LIB) {
127
+ const key = process.platform === 'darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH';
128
+ env[key] = [process.env.KGAI_LIB, process.env[key]].filter(Boolean).join(':');
129
+ }
130
+ return env;
131
+ }
132
+
133
+ /** Spawn the resolved `kg` with the given args, inheriting stdio. Returns exit status. */
134
+ function runKg(state, kgArgs, { timeoutMs } = {}) {
135
+ if (!state.kgBin) {
136
+ process.stderr.write(
137
+ 'maude kg: the `kg` CLI is not available. kgai is capability-gated — install it (see docs/kgai-onboarding.md) or use classic `.ai/` mode.\n'
138
+ );
139
+ return 127;
140
+ }
141
+ const child = spawnSync(state.kgBin, kgArgs, {
142
+ stdio: 'inherit',
143
+ env: kgEnv(),
144
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
145
+ });
146
+ if (child.error) {
147
+ process.stderr.write(`maude kg: ${child.error.message}\n`);
148
+ return 1;
149
+ }
150
+ return child.status ?? 1;
151
+ }
152
+
153
+ // ── verb: resolve ──────────────────────────────────────────────────────────
154
+ function verbResolve(state, flags) {
155
+ const out = {
156
+ active: state.active,
157
+ mode: state.mode,
158
+ engine: state.engine,
159
+ engineVersion: state.engineVersion,
160
+ store: state.store,
161
+ scope: state.scope,
162
+ kgPresent: state.kgPresent,
163
+ };
164
+ if (flags.json ?? true)
165
+ process.stdout.write(`${JSON.stringify(out, null, flags.json ? 2 : 0)}\n`);
166
+ return 0;
167
+ }
168
+
169
+ // ── verb: doctor ───────────────────────────────────────────────────────────
170
+ function verbDoctor(state) {
171
+ const line = (label, val) => process.stdout.write(` ${label.padEnd(16)} ${val}\n`);
172
+ process.stdout.write('maude kg doctor\n\n');
173
+ line('kg binary', state.kgBin || '✗ missing (see docs/kgai-onboarding.md)');
174
+ line('mode', state.mode);
175
+ line('engineVersion', state.engineVersion);
176
+ line('store', state.store || '(local-only .kgai/store)');
177
+ line('scope', JSON.stringify(state.scope));
178
+ line('active', state.active ? '✓ yes' : '✗ no (classic .ai/ path)');
179
+ if (!state.active && state.mode === 'auto') {
180
+ const why = !state.kgPresent
181
+ ? 'kg not on PATH'
182
+ : 'no store configured and no local .kgai/store';
183
+ process.stdout.write(`\n auto ⇒ inactive: ${why}. Falls back to classic .ai/ file mode.\n`);
184
+ }
185
+ return 0;
186
+ }
187
+
188
+ // ── verb: check-upstream ───────────────────────────────────────────────────
189
+ async function verbCheckUpstream(state) {
190
+ const pinned = state.engineVersion;
191
+ process.stdout.write(`maude kg check-upstream\n\n pinned (engineVersion): ${pinned}\n`);
192
+ let latest = null;
193
+ let assets = [];
194
+ try {
195
+ const res = await fetch(`https://api.github.com/repos/${KGAI_REPO}/releases/latest`, {
196
+ headers: { 'user-agent': 'maude-kg', accept: 'application/vnd.github+json' },
197
+ signal: AbortSignal.timeout(15000),
198
+ });
199
+ if (res.ok) {
200
+ const rel = await res.json();
201
+ latest = rel.tag_name;
202
+ assets = (rel.assets ?? []).map((a) => a.name);
203
+ }
204
+ } catch {
205
+ /* offline — best-effort */
206
+ }
207
+ if (!latest) {
208
+ process.stdout.write(' latest: unknown (offline) — using pinned.\n');
209
+ return 0;
210
+ }
211
+ process.stdout.write(` latest release: ${latest}\n`);
212
+ process.stdout.write(
213
+ ` status: ${latest === pinned ? '✓ up to date' : `⚠ upstream moved ${pinned} → ${latest}`}\n`
214
+ );
215
+ // Capability diff — flags/commands the plan's assumptions hinge on.
216
+ const hasDarwin = assets.some((a) => /kg-darwin/.test(a));
217
+ const hasKuzu = assets.some((a) => /libkuzu/.test(a));
218
+ process.stdout.write('\n prebuilt assets:\n');
219
+ process.stdout.write(` macOS kg binary: ${hasDarwin ? '✓' : '✗'}\n`);
220
+ process.stdout.write(` libkuzu dylib/so: ${hasKuzu ? '✓' : '✗'}\n`);
221
+ if (latest !== pinned) {
222
+ process.stdout.write(
223
+ '\n → Re-scan the capability surface (native --scope filter? kg import? Stop-hook/guessActor changes?),\n' +
224
+ ' re-run scripts/kgai-smoke/run.sh pinned to the new tag, reconcile the plan, then bump\n' +
225
+ ' config.knowledgeGraph.engineVersion deliberately (never float — supply-chain surface, DDR-054/056).\n'
226
+ );
227
+ }
228
+ return 0;
229
+ }
230
+
231
+ // ── verb: session-sync (SessionStart hook — non-blocking pull) ──────────────
232
+ function verbSessionSync(state, flags) {
233
+ // Silent no-op unless active AND a remote store is set (a pull needs a remote).
234
+ if (!state.active || !state.store) return 0;
235
+ const status = runKg(state, ['sync'], { timeoutMs: 20000 });
236
+ if (status !== 0 && flags['warn-only']) {
237
+ process.stderr.write('maude kg: session sync-pull failed — working on the local cache.\n');
238
+ return 0; // never block session start
239
+ }
240
+ return flags['warn-only'] ? 0 : status;
241
+ }
242
+
243
+ // ── verb: sync (done/pause push) ───────────────────────────────────────────
244
+ function verbSync(state, flags) {
245
+ if (!state.active) return 0; // inactive ⇒ nothing to sync (classic path)
246
+ if (!state.store) return 0; // local-only ⇒ no remote to push
247
+ const status = runKg(state, ['sync'], { timeoutMs: 60000 });
248
+ if (status !== 0 && flags['warn-only']) {
249
+ process.stderr.write('maude kg: sync failed — local append-only log is intact; will retry.\n');
250
+ return 0;
251
+ }
252
+ return status;
253
+ }
254
+
255
+ // ── verb: scope ────────────────────────────────────────────────────────────
256
+ function verbScope(state) {
257
+ process.stdout.write(`${JSON.stringify(state.scope, null, 2)}\n`);
258
+ return 0;
259
+ }
260
+
261
+ // ── verb: import (migration — productionized in Phase 5) ────────────────────
262
+ async function verbImport(state, args, pkgRoot) {
263
+ const libPath = join(pkgRoot, 'cli', 'lib', 'ddr-to-kgai.mjs');
264
+ if (!existsSync(libPath)) {
265
+ process.stderr.write(
266
+ 'maude kg import: the migration importer (cli/lib/ddr-to-kgai.mjs) is not yet available in this build.\n'
267
+ );
268
+ return 1;
269
+ }
270
+ const mod = await import(libPath);
271
+ return mod.run({
272
+ args,
273
+ state,
274
+ projectRoot: state.projectRoot,
275
+ runKg: (a, o) => runKg(state, a, o),
276
+ });
277
+ }
278
+
279
+ // ── passthrough verbs (context / ingest) ───────────────────────────────────
280
+ function verbPassthrough(verb, state, args) {
281
+ if (!state.active) {
282
+ process.stderr.write(
283
+ `maude kg ${verb}: kgai inactive here (mode=${state.mode}) — the caller should use its classic .ai/ path.\n`
284
+ );
285
+ return 0; // inactive is not an error; the command's else-branch owns the write
286
+ }
287
+ // Everything after the verb token passes straight to `kg`, MINUS maude-owned
288
+ // flags (`--root <path>` selects the project; it is not a `kg` flag).
289
+ const raw = args.slice(args.indexOf(verb) + 1);
290
+ const rest = [];
291
+ for (let i = 0; i < raw.length; i++) {
292
+ if (raw[i] === '--root') {
293
+ i++; // skip its value
294
+ continue;
295
+ }
296
+ if (raw[i].startsWith('--root=')) continue;
297
+ rest.push(raw[i]);
298
+ }
299
+ return runKg(state, [verb, ...rest]);
300
+ }
301
+
302
+ function usage() {
303
+ return `maude kg <verb> [options]
304
+
305
+ resolve [--json] Print the resolved {active, mode, store, scope} gate (JSON).
306
+ doctor Human report: kg presence, mode, store, scope, active.
307
+ check-upstream Compare pinned engineVersion vs the latest kgai release + capability diff.
308
+ session-sync [--warn-only] Non-blocking SessionStart pull (no-op unless active + remote store).
309
+ sync [--warn-only] Push the local log to the remote store (done/pause). No-op when inactive/local-only.
310
+ context <args…> Scope-biased read (passthrough to \`kg context\`).
311
+ ingest <args…> Record a decision + scope tags (passthrough to \`kg ingest\`).
312
+ scope Print the resolved scope ({repo, dept}).
313
+ import [--dry-run …] Migrate .ai/decisions/ + .design/ into kgai (Phase 5).
314
+ --root <path> Project root (default $CLAUDE_PROJECT_DIR or cwd).
315
+
316
+ kgai is capability-gated + opt-out. When \`kg\` is absent or mode:off, verbs no-op cleanly
317
+ and the classic .ai/ path is unchanged. See the \`flow:kgai-backend\` skill for the contract.
318
+ `;
319
+ }
320
+
321
+ export async function run({ args, pkgRoot }) {
322
+ const verb = args[0];
323
+ if (!verb || verb === 'help' || verb === '--help' || verb === '-h') {
324
+ process.stdout.write(usage());
325
+ return;
326
+ }
327
+ if (!VERBS.has(verb)) {
328
+ process.stderr.write(`maude kg: unknown verb "${verb}".\n${usage()}`);
329
+ process.exit(2);
330
+ }
331
+ const flags = parseArgs(args.slice(1), {
332
+ booleans: ['json', 'warn-only', 'dry-run', 'design', 'all-scopes', 'force'],
333
+ }).flags;
334
+ const projectRoot = resolveProjectRoot(flags);
335
+ const state = resolveState(projectRoot, pkgRoot);
336
+
337
+ let status = 0;
338
+ switch (verb) {
339
+ case 'resolve':
340
+ status = verbResolve(state, flags);
341
+ break;
342
+ case 'doctor':
343
+ status = verbDoctor(state);
344
+ break;
345
+ case 'check-upstream':
346
+ status = await verbCheckUpstream(state);
347
+ break;
348
+ case 'session-sync':
349
+ status = verbSessionSync(state, flags);
350
+ break;
351
+ case 'sync':
352
+ status = verbSync(state, flags);
353
+ break;
354
+ case 'scope':
355
+ status = verbScope(state);
356
+ break;
357
+ case 'import':
358
+ status = await verbImport(state, args.slice(1), pkgRoot);
359
+ break;
360
+ case 'context':
361
+ case 'ingest':
362
+ status = verbPassthrough(verb, state, args);
363
+ break;
364
+ default:
365
+ status = 2;
366
+ }
367
+ if (status && status !== 0) process.exit(status);
368
+ }
@@ -0,0 +1,118 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const REPO_ROOT = resolve(__dirname, '..', '..');
11
+ const MAUDE_BIN = resolve(REPO_ROOT, 'cli', 'bin', 'maude.mjs');
12
+
13
+ function kg(args, cwd) {
14
+ return spawnSync('node', [MAUDE_BIN, 'kg', ...args], {
15
+ cwd,
16
+ encoding: 'utf8',
17
+ env: { ...process.env, NO_COLOR: '1' },
18
+ });
19
+ }
20
+
21
+ /** Temp repo carrying a `.ai/workflows.config.json` with the given knowledgeGraph block. */
22
+ function repoWith(knowledgeGraph) {
23
+ const root = mkdtempSync(join(tmpdir(), 'kg-test-'));
24
+ mkdirSync(join(root, '.ai'), { recursive: true });
25
+ const config = { name: 'fixture' };
26
+ if (knowledgeGraph !== undefined) config.knowledgeGraph = knowledgeGraph;
27
+ writeFileSync(join(root, '.ai', 'workflows.config.json'), JSON.stringify(config));
28
+ return root;
29
+ }
30
+
31
+ function resolveJson(cwd) {
32
+ const r = kg(['resolve', '--json'], cwd);
33
+ assert.equal(r.status ?? 0, 0, r.stderr);
34
+ return JSON.parse(r.stdout);
35
+ }
36
+
37
+ test('absent block ⇒ mode auto (opt-out default)', () => {
38
+ const out = resolveJson(repoWith(undefined));
39
+ assert.equal(out.mode, 'auto');
40
+ // auto with no store ⇒ inactive regardless of whether kg is on this PATH.
41
+ assert.equal(out.active, false);
42
+ });
43
+
44
+ test('mode:off ⇒ inactive even if kg present + store set', () => {
45
+ const out = resolveJson(repoWith({ mode: 'off', store: 's3://x/store' }));
46
+ assert.equal(out.mode, 'off');
47
+ assert.equal(out.active, false);
48
+ });
49
+
50
+ test('mode:on ⇒ active (forced, independent of kg presence)', () => {
51
+ const out = resolveJson(repoWith({ mode: 'on' }));
52
+ assert.equal(out.active, true);
53
+ });
54
+
55
+ test('mode:auto + store ⇒ active tracks kg presence', () => {
56
+ const out = resolveJson(repoWith({ mode: 'auto', store: 's3://co/store' }));
57
+ // storeResolvable is true, so the only remaining gate is the kg binary.
58
+ assert.equal(out.active, out.kgPresent);
59
+ });
60
+
61
+ test('store + scope surface in resolve', () => {
62
+ const out = resolveJson(
63
+ repoWith({ store: 's3://studyfi-kg/store', scope: { repo: 'maude', dept: 'dev' } })
64
+ );
65
+ assert.equal(out.store, 's3://studyfi-kg/store');
66
+ assert.deepEqual(out.scope, { repo: 'maude', dept: 'dev' });
67
+ });
68
+
69
+ test('engineVersion defaults to the pinned release when absent', () => {
70
+ const out = resolveJson(repoWith(undefined));
71
+ assert.match(out.engineVersion, /^v\d+\.\d+\.\d+$/);
72
+ });
73
+
74
+ test('doctor prints the active line', () => {
75
+ const r = kg(['doctor'], repoWith({ mode: 'off' }));
76
+ assert.equal(r.status ?? 0, 0);
77
+ assert.match(r.stdout, /active/);
78
+ assert.match(r.stdout, /classic \.ai\/ path/);
79
+ });
80
+
81
+ test('scope verb prints the resolved scope', () => {
82
+ const r = kg(['scope'], repoWith({ scope: { repo: 'x', dept: 'finance' } }));
83
+ assert.equal(r.status ?? 0, 0);
84
+ assert.deepEqual(JSON.parse(r.stdout), { repo: 'x', dept: 'finance' });
85
+ });
86
+
87
+ test('session-sync is a silent no-op when inactive', () => {
88
+ const r = kg(['session-sync', '--warn-only'], repoWith({ mode: 'off' }));
89
+ assert.equal(r.status ?? 0, 0);
90
+ assert.equal(r.stdout.trim(), '');
91
+ });
92
+
93
+ test('unknown verb exits 2', () => {
94
+ const r = kg(['frobnicate'], repoWith(undefined));
95
+ assert.equal(r.status, 2);
96
+ });
97
+
98
+ test('help exits 0 and prints usage', () => {
99
+ const r = kg(['help'], repoWith(undefined));
100
+ assert.equal(r.status ?? 0, 0);
101
+ assert.match(r.stdout, /maude kg <verb>/);
102
+ });
103
+
104
+ test('passthrough strips the maude-owned --root flag from the kg argv', () => {
105
+ // A stub kg that echoes its argv so we can assert --root never leaks through.
106
+ const root = repoWith({ mode: 'on' });
107
+ const stub = join(root, 'kg-stub.sh');
108
+ writeFileSync(stub, '#!/usr/bin/env bash\necho "ARGV:$*"\n');
109
+ spawnSync('chmod', ['+x', stub]);
110
+ const r = spawnSync('node', [MAUDE_BIN, 'kg', 'context', '--root', root, '--about', 'x'], {
111
+ cwd: root,
112
+ encoding: 'utf8',
113
+ env: { ...process.env, NO_COLOR: '1', KGAI_BIN: stub },
114
+ });
115
+ assert.equal(r.status ?? 0, 0, r.stderr);
116
+ assert.match(r.stdout, /ARGV:context --about x/);
117
+ assert.doesNotMatch(r.stdout, /--root/);
118
+ });