@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
@@ -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
+ });