@1agh/maude 0.49.2 → 0.51.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 (39) hide show
  1. package/apps/studio/api.ts +751 -52
  2. package/apps/studio/canvas-artifacts.ts +153 -0
  3. package/apps/studio/canvas-create.ts +23 -0
  4. package/apps/studio/canvas-slug.ts +30 -0
  5. package/apps/studio/client/app.jsx +547 -90
  6. package/apps/studio/client/panels/CloudBar.jsx +388 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +28 -0
  8. package/apps/studio/client/tree-row-menu.jsx +132 -0
  9. package/apps/studio/client/use-tree-drag.js +120 -0
  10. package/apps/studio/cloud/endpoints.ts +284 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/debug-bundle.ts +144 -0
  13. package/apps/studio/dist/client.bundle.js +1235 -1228
  14. package/apps/studio/dist/styles.css +1 -1
  15. package/apps/studio/http.ts +205 -1
  16. package/apps/studio/inspect.ts +49 -0
  17. package/apps/studio/server.ts +15 -0
  18. package/apps/studio/sync/workspace-signin.ts +15 -0
  19. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  20. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  21. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  23. package/apps/studio/test/cloud-endpoints.test.ts +130 -0
  24. package/apps/studio/test/debug-bundle.test.ts +105 -0
  25. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  26. package/apps/studio/test/workspace-signin.test.ts +19 -0
  27. package/apps/studio/whats-new.json +27 -0
  28. package/cli/bin/maude.mjs +1 -0
  29. package/cli/commands/hub-workspace.mjs +294 -7
  30. package/cli/commands/init.mjs +33 -2
  31. package/cli/commands/kg.mjs +129 -4
  32. package/cli/commands/share.mjs +184 -0
  33. package/cli/lib/ddr-to-kgai.mjs +310 -82
  34. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  35. package/cli/lib/share-plan.mjs +96 -0
  36. package/cli/lib/share-plan.test.mjs +57 -0
  37. package/cli/lib/workspace-plan.mjs +130 -12
  38. package/cli/lib/workspace-plan.test.mjs +160 -0
  39. package/package.json +9 -8
@@ -13,7 +13,7 @@
13
13
  // It does NOT claim to own the deployment afterwards — see `operatorDuties`.
14
14
 
15
15
  import { spawn } from 'node:child_process';
16
- import { randomBytes } from 'node:crypto';
16
+ import { createHash, randomBytes } from 'node:crypto';
17
17
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
18
  import { resolve } from 'node:path';
19
19
 
@@ -26,6 +26,7 @@ import {
26
26
  renderEnv,
27
27
  validateWorkspaceConfig,
28
28
  verificationPlan,
29
+ workspaceBaseUrl,
29
30
  } from '../lib/workspace-plan.mjs';
30
31
 
31
32
  export function usage() {
@@ -45,6 +46,13 @@ export function usage() {
45
46
  --s3-secret-access-key SECRET
46
47
  --s3-region REGION default "auto"
47
48
  --dev-minio run a local MinIO under the compose 'dev' profile
49
+ --local TESTING: serve plain HTTP on localhost, no
50
+ certificate, no ACME. --domain defaults to
51
+ "localhost" and must stay a loopback NAME.
52
+ Lets you exercise the whole stack — and every
53
+ verification step — on a laptop, with no domain and
54
+ no paid account. Never serve a real workspace this
55
+ way: sign-in passwords would travel in the clear.
48
56
  --seed-repo URL clone an existing project; omit to start fresh
49
57
  --image-tag TAG default "latest" — pin it before you rely on this
50
58
  --config FILE read all of the above from a JSON file
@@ -63,7 +71,9 @@ export function usage() {
63
71
  }
64
72
 
65
73
  export async function run({ args, pkgRoot }) {
66
- const { flags } = parseArgs(args, { booleans: ['dry-run', 'json', 'dev-minio'] });
74
+ const { flags } = parseArgs(args, {
75
+ booleans: ['help', 'dry-run', 'json', 'dev-minio', 'local'],
76
+ });
67
77
  if (flags.help) {
68
78
  process.stdout.write(usage());
69
79
  return;
@@ -79,6 +89,7 @@ export async function run({ args, pkgRoot }) {
79
89
  ? { adminPassword: flags['admin-password'] ?? raw.adminPassword }
80
90
  : {}),
81
91
  devMinio: flags['dev-minio'] === true || raw.devMinio === true,
92
+ local: flags.local === true || raw.local === true,
82
93
  seedRepo: flags['seed-repo'] ?? raw.seedRepo,
83
94
  imageTag: flags['image-tag'] ?? raw.imageTag,
84
95
  ...(flags['s3-endpoint'] || raw.s3
@@ -169,12 +180,27 @@ export async function run({ args, pkgRoot }) {
169
180
 
170
181
  process.stdout.write(`Wrote ${files.map((f) => f.name).join(', ')} to ${outDir}\n`);
171
182
  process.stdout.write('Starting the stack…\n');
172
- const up = await sh('docker', ['compose', 'up', '-d'], { cwd: outDir });
183
+ // `--dev-minio` renders MinIO behind the `dev` compose profile, and a profile
184
+ // service does NOT start on a plain `compose up`. Rendering the bucket into
185
+ // the hub's config while never starting the bucket is the shape of failure
186
+ // that reports "storage configured" and then cannot store anything.
187
+ const composeArgs = config.s3?.dev
188
+ ? ['compose', '--profile', 'dev', 'up', '-d']
189
+ : ['compose', 'up', '-d'];
190
+ const up = await sh('docker', composeArgs, { cwd: outDir });
173
191
  if (up.code !== 0) {
174
192
  process.stderr.write(`docker compose up failed:\n${up.stderr}\n`);
175
193
  process.exit(1);
176
194
  }
177
195
 
196
+ // `compose up -d` returns when the containers are STARTED, not when they are
197
+ // SERVING. Verifying immediately reported "the workspace answers — ✗
198
+ // unreachable" against a stack that was healthy three seconds later: a false
199
+ // failure, which corrodes trust in the suite exactly as fast as a false pass.
200
+ // Bounded, and it gives up rather than waiting forever — a stack that never
201
+ // comes up must still be reported.
202
+ await waitForHealth(workspaceBaseUrl(config));
203
+
178
204
  // Verification is the deliverable. A URL printed without a proven round-trip
179
205
  // tells the operator something this command does not know.
180
206
  process.stdout.write('\nVerifying — this is the part that matters:\n');
@@ -202,7 +228,7 @@ export async function run({ args, pkgRoot }) {
202
228
  printDuties(duties);
203
229
  process.stdout.write(
204
230
  failed === 0
205
- ? `\nWorkspace verified: https://${config.domain}\n`
231
+ ? `\nWorkspace verified: ${workspaceBaseUrl(config)}\n`
206
232
  : `\n${failed} check(s) did NOT pass. The stack is running but is not proven — fix and re-run.\n`
207
233
  );
208
234
  }
@@ -260,6 +286,29 @@ function sh(cmd, args, opts = {}) {
260
286
  });
261
287
  }
262
288
 
289
+ /**
290
+ * Poll `/health` until the stack is serving, or give up.
291
+ *
292
+ * Deliberately silent on success and NEVER fatal: if the wait expires, the
293
+ * verification steps run anyway and report the real failure. This function
294
+ * removes a timing artefact; it must not become a second place that decides
295
+ * whether the deployment is good.
296
+ */
297
+ async function waitForHealth(base, { timeoutMs = 45_000, intervalMs = 1_000 } = {}) {
298
+ const deadline = Date.now() + timeoutMs;
299
+ let announced = false;
300
+ while (Date.now() < deadline) {
301
+ const res = await tryFetch(`${base}/health`);
302
+ if (res.ok) return true;
303
+ if (!announced) {
304
+ process.stdout.write('Waiting for the stack to answer…\n');
305
+ announced = true;
306
+ }
307
+ await new Promise((r) => setTimeout(r, intervalMs));
308
+ }
309
+ return false;
310
+ }
311
+
263
312
  async function which(bin) {
264
313
  const res = await sh(process.platform === 'win32' ? 'where' : 'which', [bin]);
265
314
  return res.code === 0;
@@ -272,8 +321,8 @@ async function which(bin) {
272
321
  * NEVER reports success. Counting an unrun check as passed is the single
273
322
  * fastest way to make a verification suite worthless.
274
323
  */
275
- async function runVerification(step, { config, hubSecret }) {
276
- const base = `https://${config.domain}`;
324
+ async function runVerification(step, { config, hubSecret, adminPassword, outDir, pkgRoot }) {
325
+ const base = workspaceBaseUrl(config);
277
326
  switch (step.id) {
278
327
  case 'health': {
279
328
  const res = await tryFetch(`${base}/health`);
@@ -285,6 +334,16 @@ async function runVerification(step, { config, hubSecret }) {
285
334
  });
286
335
  return res.ok ? { ok: true } : { ok: false, note: res.note };
287
336
  }
337
+ case 'user-signin':
338
+ return verifySignin(base, config.adminEmail, adminPassword);
339
+ case 'git-commit':
340
+ return verifyGitHistory(outDir);
341
+ case 's3-object':
342
+ return verifyBucketRoundTrip(config, pkgRoot);
343
+ case 's3-no-expiry':
344
+ return verifyNoLifecycle(config, pkgRoot);
345
+ case 'restore-drill':
346
+ return verifyRestoreDrill(config);
288
347
  default:
289
348
  return {
290
349
  ok: false,
@@ -294,6 +353,234 @@ async function runVerification(step, { config, hubSecret }) {
294
353
  }
295
354
  }
296
355
 
356
+ /**
357
+ * The credential the operator is about to be handed actually works.
358
+ *
359
+ * This is the check that would have caught the shipped bug where a provisioned
360
+ * workspace had no users at all: every other check passed, the URL printed,
361
+ * and the first person to try the login was the one who found out.
362
+ */
363
+ async function verifySignin(base, email, password) {
364
+ if (!password) return { ok: false, note: 'no admin password was generated' };
365
+ try {
366
+ const res = await fetch(`${base}/auth/login`, {
367
+ method: 'POST',
368
+ headers: { 'Content-Type': 'application/json' },
369
+ body: JSON.stringify({ email, password }),
370
+ signal: AbortSignal.timeout(10_000),
371
+ });
372
+ if (!res.ok) return { ok: false, note: `HTTP ${res.status} — the first user cannot sign in` };
373
+ const body = await res.json().catch(() => null);
374
+ return body?.token ? { ok: true } : { ok: false, note: 'login returned no session token' };
375
+ } catch (err) {
376
+ return { ok: false, note: err.name === 'TimeoutError' ? 'timed out' : 'unreachable' };
377
+ }
378
+ }
379
+
380
+ /**
381
+ * The server-side checkout has real commits (Cloud Phase 16).
382
+ *
383
+ * Read from INSIDE the container, because that is where the history lives.
384
+ * Checking a path on the operator's laptop would pass on a machine that
385
+ * happens to have a repo and tell us nothing about the deployment.
386
+ */
387
+ async function verifyGitHistory(outDir) {
388
+ const probe = await sh(
389
+ 'docker',
390
+ ['compose', 'exec', '-T', 'hub', 'git', '-C', '/repo', 'log', '-1', '--format=%H %an'],
391
+ { cwd: outDir }
392
+ );
393
+ if (probe.code !== 0) {
394
+ const err = `${probe.stderr}`.toLowerCase();
395
+ // A fresh workspace has no commits because nobody has edited anything yet.
396
+ // That is the NORMAL state five seconds after provisioning, and reporting
397
+ // it as a failure trains the operator to ignore a red mark — which is
398
+ // precisely what makes a real one invisible later. Skipped says the truth:
399
+ // this was not proven, and here is what would prove it.
400
+ if (err.includes('does not have any commits') || err.includes('bad default revision')) {
401
+ return {
402
+ ok: false,
403
+ skipped: true,
404
+ note: 'the checkout is ready but empty — edit a canvas, then re-run to prove autosave commits',
405
+ };
406
+ }
407
+ if (err.includes('not a git repository')) {
408
+ return {
409
+ ok: false,
410
+ note: 'the workspace has no checkout — server-side history is not running',
411
+ };
412
+ }
413
+ if (err.includes('executable file not found') || err.includes('not found')) {
414
+ return { ok: false, note: 'git is missing from the hub image — history cannot be kept' };
415
+ }
416
+ return { ok: false, note: `git log failed: ${probe.stderr.trim().slice(0, 120)}` };
417
+ }
418
+ const line = probe.stdout.trim();
419
+ if (!line) {
420
+ return {
421
+ ok: false,
422
+ skipped: true,
423
+ note: 'the checkout is ready but empty — edit a canvas, then re-run',
424
+ };
425
+ }
426
+ return { ok: true, note: `HEAD by ${line.split(' ').slice(1).join(' ') || 'unknown'}` };
427
+ }
428
+
429
+ /** Load the hub's S3 client from the installed package. */
430
+ async function loadS3(pkgRoot) {
431
+ for (const candidate of [
432
+ resolve(pkgRoot, 'apps/hub/src/s3.mjs'),
433
+ resolve(pkgRoot, '../apps/hub/src/s3.mjs'),
434
+ ]) {
435
+ const mod = await import(`file://${candidate}`).catch(() => null);
436
+ if (mod) return mod;
437
+ }
438
+ return null;
439
+ }
440
+
441
+ function s3ConfigFrom(config) {
442
+ const s = config.s3;
443
+ // The dev MinIO endpoint (`http://minio:9000`) is a compose-network name.
444
+ // These checks run on the OPERATOR's machine, which cannot resolve it — but
445
+ // the compose file publishes the port, so loopback is the same bucket.
446
+ const endpoint = s.dev ? s.endpoint.replace('//minio:', '//127.0.0.1:') : s.endpoint;
447
+ return {
448
+ endpoint,
449
+ bucket: s.bucket,
450
+ accessKeyId: s.accessKeyId,
451
+ secretAccessKey: s.secretAccessKey,
452
+ region: s.region ?? 'auto',
453
+ };
454
+ }
455
+
456
+ /**
457
+ * A real object goes into the bucket and comes back out.
458
+ *
459
+ * Content-addressed, so the sentinel is indistinguishable from a genuine
460
+ * asset — and it is removed afterwards, because a verification step that
461
+ * litters a customer's bucket is a verification step people turn off.
462
+ */
463
+ async function verifyBucketRoundTrip(config, pkgRoot) {
464
+ if (!config.s3) return { ok: false, skipped: true, note: 'no object storage configured' };
465
+ const s3 = await loadS3(pkgRoot);
466
+ if (!s3)
467
+ return { ok: false, skipped: true, note: 'the hub S3 client was not found in this install' };
468
+ const cfg = s3ConfigFrom(config);
469
+ const bytes = Buffer.from(`maude workspace-up sentinel\n`);
470
+ const key = `assets/${createHash('sha256').update(bytes).digest('hex').slice(0, 8)}.bin`;
471
+ try {
472
+ // The stack was declared healthy by the HUB's health check; object storage
473
+ // is a different container and may still be starting. A one-shot attempt
474
+ // here reported "fetch failed" against a MinIO that was serving four
475
+ // seconds later — a false failure, which corrodes the suite as fast as a
476
+ // false pass.
477
+ await retry(() => s3.putObject(cfg, key, bytes), { attempts: 6, delayMs: 2000 });
478
+ const head = await s3.headObject(cfg, key);
479
+ if (!head) return { ok: false, note: 'the object was written but could not be read back' };
480
+ if (head.size !== bytes.length) {
481
+ return { ok: false, note: `read back ${head.size} bytes, wrote ${bytes.length}` };
482
+ }
483
+ return { ok: true };
484
+ } catch (err) {
485
+ if (/NoSuchBucket/i.test(err.message)) {
486
+ return {
487
+ ok: false,
488
+ note: `the bucket "${cfg.bucket}" does not exist at ${cfg.endpoint} — create it, then re-run`,
489
+ };
490
+ }
491
+ if (/InvalidAccessKeyId|SignatureDoesNotMatch/i.test(err.message)) {
492
+ return { ok: false, note: 'object storage rejected the credentials' };
493
+ }
494
+ return { ok: false, note: `bucket rejected the round trip: ${err.message.slice(0, 120)}` };
495
+ } finally {
496
+ await s3.deleteObject(s3ConfigFrom(config), key).catch(() => {});
497
+ }
498
+ }
499
+
500
+ /**
501
+ * No lifecycle rule can expire the media.
502
+ *
503
+ * The quiet catastrophe this guards: assets are content-addressed and
504
+ * referenced from git history forever, so an expiry rule deletes objects that
505
+ * canvases still point at, with no recovery path and no error at the time.
506
+ *
507
+ * A bucket with NO lifecycle configuration answers 404 — that is the pass.
508
+ */
509
+ async function verifyNoLifecycle(config, pkgRoot) {
510
+ if (!config.s3) return { ok: false, skipped: true, note: 'no object storage configured' };
511
+ const s3 = await loadS3(pkgRoot);
512
+ if (!s3?.signRequest) {
513
+ return { ok: false, skipped: true, note: 'the hub S3 client was not found in this install' };
514
+ }
515
+ const cfg = s3ConfigFrom(config);
516
+ try {
517
+ const signed = s3.signRequest(cfg, { method: 'GET', key: '', query: { lifecycle: '' } });
518
+ const res = await fetch(signed.url, {
519
+ method: 'GET',
520
+ headers: signed.headers,
521
+ signal: AbortSignal.timeout(15_000),
522
+ });
523
+ if (res.status === 404) return { ok: true, note: 'no lifecycle configuration' };
524
+ if (!res.ok) {
525
+ // Cannot read the config ⇒ cannot claim it is safe. Skipped, never passed.
526
+ return {
527
+ ok: false,
528
+ skipped: true,
529
+ note: `could not read lifecycle config (HTTP ${res.status})`,
530
+ };
531
+ }
532
+ const xml = await res.text();
533
+ const rules = xml.match(/<Rule>/g)?.length ?? 0;
534
+ if (rules === 0) return { ok: true, note: 'no lifecycle rules' };
535
+ // Any rule at all is reported. Deciding which prefixes a rule matches from
536
+ // its XML is exactly the kind of parsing that is wrong in the one case
537
+ // that matters, so this reports rather than adjudicates.
538
+ return {
539
+ ok: false,
540
+ note: `${rules} lifecycle rule(s) on this bucket — confirm none can expire assets/`,
541
+ };
542
+ } catch (err) {
543
+ return {
544
+ ok: false,
545
+ skipped: true,
546
+ note: `lifecycle check failed: ${err.message.slice(0, 100)}`,
547
+ };
548
+ }
549
+ }
550
+
551
+ /** A backup nobody has restored is a hypothesis. Runs the real drill. */
552
+ async function verifyRestoreDrill(config) {
553
+ if (!config.s3) return { ok: false, skipped: true, note: 'no backup target configured' };
554
+ // The drill needs the hub's own data dir, which lives inside the container.
555
+ // Deliberately left to the operator's `maude hub restore-drill` rather than
556
+ // reaching into a volume from out here: a half-run drill that reports
557
+ // success is worse than an honest skip, and this is the one check whose
558
+ // whole point is that somebody actually did it.
559
+ return {
560
+ ok: false,
561
+ skipped: true,
562
+ note: 'run `maude hub restore-drill` against this deployment — it needs the hub data dir',
563
+ };
564
+ }
565
+
566
+ /** Retry a flaky-at-startup operation. Rethrows the LAST error, so the
567
+ * reported cause is the real one rather than "timed out". */
568
+ async function retry(fn, { attempts, delayMs }) {
569
+ let last;
570
+ for (let i = 0; i < attempts; i++) {
571
+ try {
572
+ return await fn();
573
+ } catch (err) {
574
+ last = err;
575
+ // A definitive answer from the service is not worth retrying — only the
576
+ // "not listening yet" shape is.
577
+ if (!/fetch failed|ECONNREFUSED|socket hang up/i.test(err.message)) throw err;
578
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
579
+ }
580
+ }
581
+ throw last;
582
+ }
583
+
297
584
  async function tryFetch(url, init) {
298
585
  try {
299
586
  const ctrl = new AbortController();
@@ -312,7 +599,7 @@ async function tryFetch(url, init) {
312
599
  function printDryRun({ config, outDir, files, plan, duties, reusedSecret }) {
313
600
  process.stdout.write(
314
601
  `maude hub workspace-up — DRY RUN, nothing was written\n\n` +
315
- ` workspace https://${config.domain}\n` +
602
+ ` workspace ${workspaceBaseUrl(config)}${config.local ? ' (LOCAL — plain HTTP)' : ''}\n` +
316
603
  ` first user ${config.adminEmail}\n` +
317
604
  ` storage ${config.s3 ? `${config.s3.bucket} @ ${config.s3.endpoint}${config.s3.dev ? ' (dev MinIO)' : ''}` : 'none — media stays in git'}\n` +
318
605
  ` project ${config.seedRepo ?? 'starts fresh'}\n` +
@@ -195,7 +195,7 @@ export async function run({ args, pkgRoot }) {
195
195
  }
196
196
 
197
197
  printSummary(result);
198
- printNextSteps(projectName, claudeMdExists);
198
+ printNextSteps(projectName, claudeMdExists, Boolean(resolveKgBin()), Boolean(flags.kg));
199
199
  }
200
200
 
201
201
  /** KGAI_BIN (desktop-staged sidecar) → `kg` on PATH → null. Mirrors kg.mjs. */
@@ -242,7 +242,7 @@ function printSummary({ created, replaced, skipped }) {
242
242
  }
243
243
  }
244
244
 
245
- function printNextSteps(name, claudeMdExists) {
245
+ function printNextSteps(name, claudeMdExists, kgAvailable = false, usedKg = false) {
246
246
  process.stdout.write('\nNext steps:\n');
247
247
  process.stdout.write(' 1. In Claude Code: /plugin marketplace add 1aGh/maude\n');
248
248
  process.stdout.write(' /plugin install flow@maude\n');
@@ -261,4 +261,35 @@ function printNextSteps(name, claudeMdExists) {
261
261
  }
262
262
  process.stdout.write(` 4. Create .ai/${name}-prd.md with your product brief.\n`);
263
263
  process.stdout.write(' 5. /flow:status to see where you are; /flow:plan to start work.\n');
264
+ printKgOffer(kgAvailable, usedKg);
265
+ }
266
+
267
+ /**
268
+ * Surface the knowledge-graph choice at scaffold time.
269
+ *
270
+ * Without this, `--kg` is discoverable only by reading `--help` — so a fresh
271
+ * user never learns the option exists at the one moment it is cheapest to take
272
+ * (an empty `.ai/` needs no migration). Deliberately NOT shown when `kg` is
273
+ * absent: advertising a backend the user would first have to go install is
274
+ * noise, and `mode:auto` picks it up by itself if they ever do.
275
+ */
276
+ function printKgOffer(kgAvailable, usedKg) {
277
+ if (!kgAvailable) return;
278
+ if (usedKg) {
279
+ process.stdout.write(
280
+ '\nkgai: this workspace uses the knowledge graph as its decision memory.\n' +
281
+ ' `maude kg doctor` to confirm · `maude kg search "<topic>"` to read it back.\n'
282
+ );
283
+ return;
284
+ }
285
+ process.stdout.write(
286
+ '\nkgai: `kg` is installed on this machine, and this workspace was scaffolded\n' +
287
+ ' CLASSIC (markdown decisions + STATE.md history). Both work; the graph\n' +
288
+ ' makes "what did we decide about X" a query instead of a grep, and gives\n' +
289
+ ' the gitignored .ai/logs/ verdicts a copy that travels.\n' +
290
+ ' To switch now (empty workspace — nothing to migrate):\n' +
291
+ ' maude init --kg --force\n' +
292
+ ' Later, once you have decisions on disk, use the migration instead:\n' +
293
+ ' maude kg import --dry-run --archive (then drop --dry-run)\n'
294
+ );
264
295
  }
@@ -12,8 +12,9 @@
12
12
  // or an informative message — a command's classic `.ai/` path is unaffected.
13
13
 
14
14
  import { spawnSync } from 'node:child_process';
15
- import { existsSync, readFileSync } from 'node:fs';
16
- import { join, resolve } from 'node:path';
15
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { basename, join, resolve } from 'node:path';
17
18
  import { parseArgs } from '../lib/argv.mjs';
18
19
 
19
20
  const CONFIG_PATH = '.ai/workflows.config.json';
@@ -30,6 +31,7 @@ const VERBS = new Set([
30
31
  'ingest',
31
32
  'scope',
32
33
  'import',
34
+ 'record-log',
33
35
  'help',
34
36
  ]);
35
37
 
@@ -131,15 +133,19 @@ function kgEnv() {
131
133
  }
132
134
 
133
135
  /** Spawn the resolved `kg` with the given args, inheriting stdio. Returns exit status. */
134
- function runKg(state, kgArgs, { timeoutMs } = {}) {
136
+ function runKg(state, kgArgs, { timeoutMs, swallowStdout } = {}) {
135
137
  if (!state.kgBin) {
136
138
  process.stderr.write(
137
139
  '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
140
  );
139
141
  return 127;
140
142
  }
143
+ // `swallowStdout` is for verbs that print their OWN one-line summary — `kg
144
+ // ingest` dumps a ~25-line JSON receipt per call, and a command that records
145
+ // a verdict on every run would bury the agent's real output in it. stderr
146
+ // still passes through, so a genuine failure is never hidden.
141
147
  const child = spawnSync(state.kgBin, kgArgs, {
142
- stdio: 'inherit',
148
+ stdio: swallowStdout ? ['inherit', 'pipe', 'inherit'] : 'inherit',
143
149
  env: kgEnv(),
144
150
  ...(timeoutMs ? { timeout: timeoutMs } : {}),
145
151
  });
@@ -276,6 +282,117 @@ async function verbImport(state, args, pkgRoot) {
276
282
  });
277
283
  }
278
284
 
285
+ // ── verb: record-log (keep the graph fed as verdicts are written) ───────────
286
+ //
287
+ // WHY this exists rather than a JSON blob per command: `.ai/logs/**` is
288
+ // GITIGNORED, so for RCAs / reviews / audits the graph is the only inheritable
289
+ // copy. The Phase-5 migration put 120 of them in — but nothing kept feeding it,
290
+ // so the corpus began decaying the moment migration finished. Seven flow
291
+ // commands and six design ones now call this as they write.
292
+ //
293
+ // It delegates to `buildLogDecision`, the SAME builder the bulk importer uses,
294
+ // because a second hand-rolled shape would fork the corpus: a node recorded
295
+ // today has to land on the same shelf as the ones migration created (same slug
296
+ // rule, props, ABOUT/scope/EVIDENCE_FOR edges) or `kg search` returns half an
297
+ // answer. Re-recording is safe — identity is `hash(kind:name)`, props MERGE.
298
+ async function verbRecordLog(state, args, pkgRoot) {
299
+ const { flags } = parseArgs(args, { booleans: ['dry-run', 'quiet'] });
300
+ // Inactive is the COMMON case downstream, and it must be a clean no-op so a
301
+ // command can call this unconditionally instead of re-deriving the gate.
302
+ if (!state.active) return 0;
303
+
304
+ const file = flags.file;
305
+ if (!file) {
306
+ process.stderr.write('maude kg record-log: --file <path> is required.\n');
307
+ return 1;
308
+ }
309
+ const abs = resolve(state.projectRoot, file);
310
+ if (!existsSync(abs)) {
311
+ // A verdict the caller says it wrote but didn't is a caller bug, not a
312
+ // reason to fail the command that produced real work — warn and move on.
313
+ process.stderr.write(`maude kg record-log: no such file: ${abs} — nothing recorded.\n`);
314
+ return 0;
315
+ }
316
+
317
+ const libPath = join(pkgRoot, 'cli', 'lib', 'ddr-to-kgai.mjs');
318
+ if (!existsSync(libPath)) {
319
+ process.stderr.write('maude kg record-log: builder unavailable in this build.\n');
320
+ return 0;
321
+ }
322
+ const { LOG_KINDS, buildLogDecision } = await import(libPath);
323
+
324
+ // Kind: explicit wins; else infer from the parent dir via the SAME table the
325
+ // importer keys on, so `.ai/logs/rca/x.md` → `rca` either way.
326
+ const parent = abs.split('/').slice(-2, -1)[0] ?? '';
327
+ const kind = flags.kind || LOG_KINDS[parent];
328
+ if (!kind) {
329
+ process.stderr.write(
330
+ `maude kg record-log: cannot infer kind from "${parent}/" — pass --kind (known: ${Object.values(
331
+ LOG_KINDS
332
+ ).join(', ')}).\n`
333
+ );
334
+ return 1;
335
+ }
336
+
337
+ const rel = abs.startsWith(`${state.projectRoot}/`)
338
+ ? abs.slice(state.projectRoot.length + 1)
339
+ : abs;
340
+
341
+ // Slug collision guard. Identity is `hash(kind:name)`, so two files sharing a
342
+ // basename become ONE node and the second silently overwrites the first's
343
+ // props — measured: recording `_history/settings/critique/001-PANEL.md` then
344
+ // `_history/login/critique/001-PANEL.md` left a single node pointing at login,
345
+ // with the settings critique gone. Flow logs are safe (their basenames are
346
+ // already unique across `.ai/logs/<kind>/`) and must keep the bare slug to
347
+ // match the migrated corpus — but anything attached to a specific element
348
+ // (`--about canvas:foo`) is per-element by nature, so qualify it with that
349
+ // element's name. Doing it HERE rather than in each caller means six design
350
+ // commands can't each forget it.
351
+ const aboutName = flags.about ? String(flags.about).split(':').slice(1).join(':') : '';
352
+ const derivedSlug =
353
+ flags.slug ||
354
+ (aboutName
355
+ ? `${aboutName}-${basename(abs).replace(/\.md$/, '')}`.replace(/\//g, '-')
356
+ : undefined);
357
+
358
+ const built = buildLogDecision(abs, kind, state.scope, {
359
+ pathRel: rel,
360
+ about: flags.about, // e.g. canvas:<slug> for a design verdict
361
+ link: flags.link, // e.g. EVALUATES
362
+ slug: derivedSlug,
363
+ });
364
+
365
+ if (flags['dry-run']) {
366
+ process.stdout.write(`${JSON.stringify({ decisions: [built.decision] }, null, 2)}\n`);
367
+ return 0;
368
+ }
369
+ // Temp file + `kg ingest --file`, matching the importer: the same plumbing
370
+ // that ingested 310 decisions during migration, so no new stdin path to get
371
+ // wrong (and a verdict body is far past comfortable argv size anyway).
372
+ const tmp = join(tmpdir(), `kg-record-${kind}-${built.slug}.json`);
373
+ writeFileSync(tmp, JSON.stringify({ decisions: [built.decision] }));
374
+ const status = runKg(state, ['ingest', '--file', tmp], {
375
+ timeoutMs: 30000,
376
+ swallowStdout: true,
377
+ });
378
+ try {
379
+ rmSync(tmp, { force: true });
380
+ } catch {
381
+ /* best-effort temp cleanup */
382
+ }
383
+ if (status !== 0) {
384
+ // Same contract as `sync`: never fail the caller's real work over memory.
385
+ process.stderr.write(`maude kg record-log: ingest failed for ${rel} — the file is on disk.\n`);
386
+ return 0;
387
+ }
388
+ if (!flags.quiet) {
389
+ process.stdout.write(
390
+ `[kg] recorded ${kind}:${built.slug}${built.citedCount ? ` (${built.citedCount} EVIDENCE_FOR)` : ''}\n`
391
+ );
392
+ }
393
+ return 0;
394
+ }
395
+
279
396
  // ── passthrough verbs (context / ingest) ───────────────────────────────────
280
397
  function verbPassthrough(verb, state, args) {
281
398
  if (!state.active) {
@@ -311,6 +428,11 @@ function usage() {
311
428
  ingest <args…> Record a decision + scope tags (passthrough to \`kg ingest\`).
312
429
  scope Print the resolved scope ({repo, dept}).
313
430
  import [--dry-run …] Migrate .ai/decisions/ + .design/ into kgai (Phase 5).
431
+ record-log --file F Record ONE verdict file (RCA / review / audit / critique) as a
432
+ [--kind K] graph node, shaped exactly like the migrated corpus. Kind is
433
+ [--about E --link L] inferred from the parent dir; --about/--link attach a design
434
+ [--slug S] [--quiet] verdict to canvas:<slug> instead of area:<kind>. Silent no-op
435
+ [--dry-run] when inactive, so callers invoke it unconditionally.
314
436
  --root <path> Project root (default $CLAUDE_PROJECT_DIR or cwd).
315
437
 
316
438
  kgai is capability-gated + opt-out. When \`kg\` is absent or mode:off, verbs no-op cleanly
@@ -357,6 +479,9 @@ export async function run({ args, pkgRoot }) {
357
479
  case 'import':
358
480
  status = await verbImport(state, args.slice(1), pkgRoot);
359
481
  break;
482
+ case 'record-log':
483
+ status = await verbRecordLog(state, args.slice(1), pkgRoot);
484
+ break;
360
485
  case 'context':
361
486
  case 'ingest':
362
487
  status = verbPassthrough(verb, state, args);