@1agh/maude 0.49.2 → 0.50.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 (34) 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 +284 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +26 -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 +236 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/dist/client.bundle.js +960 -958
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/http.ts +113 -0
  15. package/apps/studio/inspect.ts +49 -0
  16. package/apps/studio/server.ts +15 -0
  17. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  18. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  19. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  20. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  21. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  22. package/apps/studio/whats-new.json +18 -0
  23. package/cli/bin/maude.mjs +1 -0
  24. package/cli/commands/hub-workspace.mjs +294 -7
  25. package/cli/commands/init.mjs +33 -2
  26. package/cli/commands/kg.mjs +129 -4
  27. package/cli/commands/share.mjs +184 -0
  28. package/cli/lib/ddr-to-kgai.mjs +310 -82
  29. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  30. package/cli/lib/share-plan.mjs +96 -0
  31. package/cli/lib/share-plan.test.mjs +57 -0
  32. package/cli/lib/workspace-plan.mjs +130 -12
  33. package/cli/lib/workspace-plan.test.mjs +160 -0
  34. package/package.json +8 -8
@@ -25,7 +25,7 @@ import {
25
25
  writeFileSync,
26
26
  } from 'node:fs';
27
27
  import { tmpdir } from 'node:os';
28
- import { join } from 'node:path';
28
+ import { basename, join } from 'node:path';
29
29
  import { parseArgs } from './argv.mjs';
30
30
 
31
31
  const REF_RANK = { references: 0, extends: 1, overrides: 2, supersedes: 3 };
@@ -71,6 +71,24 @@ function parseTags(raw) {
71
71
  * Classify cross-refs. Typed markers win over bare mentions; strongest kind per
72
72
  * target is kept. Returns { 'NNN': 'supersedes'|'overrides'|'extends'|'references' }.
73
73
  */
74
+ // DDR numbering is PER-REPO, but kgai identity is `hash(kind:name)` and therefore
75
+ // GLOBAL. On a shared company store every repo's `decision:DDR-001` collapses into
76
+ // ONE node, so two unrelated decisions become competing heads on it (`kg conflicts`).
77
+ // Namespacing the name by `scope.repo` keeps each repo's numbering intact while
78
+ // making the node unique. `area:`/`topic:` are deliberately NOT namespaced — a
79
+ // concept like `area:security` SHOULD converge across repos; that is the whole
80
+ // point of a cross-repo graph.
81
+ function ddrRef(num, scope = {}) {
82
+ return scope.repo ? `${scope.repo}/DDR-${num}` : `DDR-${num}`;
83
+ }
84
+
85
+ // Same collision class as ddrRef, one level up: milestone slugs are built from a
86
+ // DATE (`progress-2026-07-02`) or a date+phase, both of which repeat across repos.
87
+ // Two teams shipping on the same day would otherwise share one milestone node.
88
+ function scopedSlug(slug, scope = {}) {
89
+ return scope.repo ? `${scope.repo}/${slug}` : slug;
90
+ }
91
+
74
92
  function crossRefs(text, selfNum) {
75
93
  const out = {};
76
94
  const reversed = [];
@@ -194,8 +212,12 @@ export function buildDdrBatch(decisionsDir, scope = {}, only = null) {
194
212
  }
195
213
  stats.crossrefs += Object.keys(refs).length;
196
214
 
197
- const primary = tags[0] || 'general';
198
- const self = `DDR-${num}`;
215
+ // A real tag (`security`, `infra`) SHOULD converge across repos — that is the
216
+ // cross-repo value. The `general` FALLBACK is not a concept, it means "this DDR
217
+ // had no tags", which is repo-local noise; left shared it makes every untagged
218
+ // decision in the company a competing head on one junk node.
219
+ const primary = tags[0] || scopedSlug('general', scope);
220
+ const self = ddrRef(num, scope);
199
221
  const muts = [
200
222
  { op: 'upsert_element', kind: 'area', name: primary, props: { last_ddr: self } },
201
223
  {
@@ -222,21 +244,21 @@ export function buildDdrBatch(decisionsDir, scope = {}, only = null) {
222
244
  muts.push({ op: 'add_link', from: `area:${primary}`, to: `topic:${tg}`, link: 'TOUCHES' });
223
245
  }
224
246
  for (const [tgt, kind] of Object.entries(refs)) {
225
- muts.push({ op: 'upsert_element', kind: 'decision', name: `DDR-${tgt}` });
247
+ muts.push({ op: 'upsert_element', kind: 'decision', name: ddrRef(tgt, scope) });
226
248
  muts.push({
227
249
  op: 'add_link',
228
250
  from: `decision:${self}`,
229
- to: `decision:DDR-${tgt}`,
251
+ to: `decision:${ddrRef(tgt, scope)}`,
230
252
  link: kind.toUpperCase(),
231
253
  });
232
254
  }
233
255
  // Passive-voice mentions ("Superseded by DDR-191") — the MENTION supersedes
234
256
  // SELF, so the edge points the other way.
235
257
  for (const [tgt, kind] of revRefs) {
236
- muts.push({ op: 'upsert_element', kind: 'decision', name: `DDR-${tgt}` });
258
+ muts.push({ op: 'upsert_element', kind: 'decision', name: ddrRef(tgt, scope) });
237
259
  muts.push({
238
260
  op: 'add_link',
239
- from: `decision:DDR-${tgt}`,
261
+ from: `decision:${ddrRef(tgt, scope)}`,
240
262
  to: `decision:${self}`,
241
263
  link: kind.toUpperCase(),
242
264
  });
@@ -261,40 +283,109 @@ export function buildDdrBatch(decisionsDir, scope = {}, only = null) {
261
283
  * copy, so we keep a much larger excerpt than the DDR path does (which can afford
262
284
  * to be a thin index because its prose is versioned).
263
285
  */
286
+ /**
287
+ * Directory name → node kind. Exported because `maude kg record-log` infers the
288
+ * kind from a file's parent dir, and an inference that disagreed with the bulk
289
+ * importer would fork the corpus in two (`rca:x` from migration, `logs:x` from
290
+ * a live run) — the whole point is that a verdict recorded today lands on the
291
+ * same shelf as the 120 the migration put there.
292
+ */
293
+ export const LOG_KINDS = {
294
+ rca: 'rca',
295
+ 'system-reviews': 'system-review',
296
+ 'code-reviews': 'code-review',
297
+ 'security-reviews': 'security-review',
298
+ 'execution-reports': 'execution-report',
299
+ a11y: 'a11y-audit',
300
+ visual: 'visual-review',
301
+ '.': 'log', // loose .md sitting at the logs root (e.g. a one-off perf note)
302
+ };
303
+
304
+ /** repo:/dept: anchors + their edges — identical for every node kind. */
305
+ function scopeMutations(name, kind, scope = {}) {
306
+ const m = [];
307
+ if (scope.repo) {
308
+ m.push({ op: 'upsert_element', kind: 'repo', name: scope.repo });
309
+ m.push({ op: 'add_link', from: `${kind}:${name}`, to: `repo:${scope.repo}`, link: 'IN_REPO' });
310
+ }
311
+ if (scope.dept) {
312
+ m.push({ op: 'upsert_element', kind: 'dept', name: scope.dept });
313
+ m.push({ op: 'add_link', from: `${kind}:${name}`, to: `dept:${scope.dept}`, link: 'IN_DEPT' });
314
+ }
315
+ return m;
316
+ }
317
+
318
+ /**
319
+ * Build ONE decision envelope from ONE verdict file.
320
+ *
321
+ * The single source of truth for "a markdown verdict becomes a graph node",
322
+ * shared by the bulk importer (`maude kg import`) and the per-file recorder
323
+ * (`maude kg record-log`, which the flow/design commands call as they write).
324
+ * Keeping one function is what guarantees a `/flow:bug-rca` run tomorrow
325
+ * produces a node shaped exactly like the ones migration created — same slug
326
+ * rule, same props, same ABOUT/scope/EVIDENCE_FOR edges.
327
+ *
328
+ * @param {string} absPath file on disk (read in full — see the full-body note above)
329
+ * @param {string} kind node kind (see LOG_KINDS)
330
+ * @param {object} scope { repo, dept }
331
+ * @param {object} [opts]
332
+ * @param {string} [opts.pathRel] the `path` prop; defaults to absPath
333
+ * @param {string} [opts.about] attach to this element instead of `area:<kind>`
334
+ * (design verdicts hang off `canvas:<slug>`)
335
+ * @param {string} [opts.link] edge kind to `about` (default `ABOUT`)
336
+ * @param {string} [opts.slug] override the derived slug
337
+ */
338
+ export function buildLogDecision(absPath, kind, scope = {}, opts = {}) {
339
+ const body = readFileSync(absPath, 'utf8');
340
+ const base = basename(absPath);
341
+ // Log slugs come from FILENAMES (`rc-1.3.3.md`), which repeat across repos.
342
+ const slug = scopedSlug(opts.slug ?? base.replace(/\.md$/, '').replace(/\//g, '-'), scope);
343
+ const title = (body.match(/^#\s*(.+)$/m) || [null, slug])[1].trim();
344
+ // `**Date:**` when the author supplied one, else the file's own mtime — these
345
+ // files are gitignored, so git has no creation date to fall back on either.
346
+ const explicitDate = normDate(field(body, 'Date'));
347
+ const date = explicitDate || statSync(absPath).mtime.toISOString().slice(0, 10);
348
+ const about = opts.about ?? `area:${kind}`;
349
+ const [aboutKind, ...aboutRest] = about.split(':');
350
+
351
+ const mutations = [
352
+ {
353
+ op: 'upsert_element',
354
+ kind,
355
+ name: slug,
356
+ props: { title: title.slice(0, 160), path: opts.pathRel ?? absPath, date },
357
+ },
358
+ { op: 'upsert_element', kind: aboutKind, name: aboutRest.join(':') },
359
+ { op: 'add_link', from: `${kind}:${slug}`, to: about, link: opts.link ?? 'ABOUT' },
360
+ ...scopeMutations(slug, kind, scope),
361
+ ];
362
+
363
+ // Evidence edges — a review/RCA that cites a DDR is evidence ABOUT it.
364
+ const cited = new Set([...body.matchAll(/DDR-(\d+)/g)].map((m) => m[1].padStart(3, '0')));
365
+ for (const num of cited) {
366
+ mutations.push({ op: 'upsert_element', kind: 'decision', name: ddrRef(num, scope) });
367
+ mutations.push({
368
+ op: 'add_link',
369
+ from: `${kind}:${slug}`,
370
+ to: `decision:${ddrRef(num, scope)}`,
371
+ link: 'EVIDENCE_FOR',
372
+ });
373
+ }
374
+ // Full body — these files are gitignored, so the graph is the ONLY copy;
375
+ // truncating would destroy the evidence it exists to preserve.
376
+ return {
377
+ decision: { title, date, rationale: body.trim(), mutations },
378
+ slug,
379
+ citedCount: cited.size,
380
+ hasExplicitDate: Boolean(explicitDate),
381
+ };
382
+ }
383
+
264
384
  export function buildLogBatch(logsDir, scope = {}) {
265
385
  const logsRel = logsDir.includes('/archive/') ? '.ai/archive/logs' : '.ai/logs';
266
- const KINDS = {
267
- rca: 'rca',
268
- 'system-reviews': 'system-review',
269
- 'code-reviews': 'code-review',
270
- 'security-reviews': 'security-review',
271
- 'execution-reports': 'execution-report',
272
- '.': 'log', // loose .md sitting at the logs root (e.g. a one-off perf note)
273
- };
386
+ const KINDS = LOG_KINDS;
274
387
  const decisions = [];
275
388
  const stats = { files: 0, withDate: 0, cited: 0, byKind: {} };
276
- const scopeMuts = (name, kind) => {
277
- const m = [];
278
- if (scope.repo) {
279
- m.push({ op: 'upsert_element', kind: 'repo', name: scope.repo });
280
- m.push({
281
- op: 'add_link',
282
- from: `${kind}:${name}`,
283
- to: `repo:${scope.repo}`,
284
- link: 'IN_REPO',
285
- });
286
- }
287
- if (scope.dept) {
288
- m.push({ op: 'upsert_element', kind: 'dept', name: scope.dept });
289
- m.push({
290
- op: 'add_link',
291
- from: `${kind}:${name}`,
292
- to: `dept:${scope.dept}`,
293
- link: 'IN_DEPT',
294
- });
295
- }
296
- return m;
297
- };
298
389
 
299
390
  for (const [dir, kind] of Object.entries(KINDS)) {
300
391
  const abs = join(logsDir, dir);
@@ -316,47 +407,132 @@ export function buildLogBatch(logsDir, scope = {}) {
316
407
  );
317
408
  for (const f of listing.sort()) {
318
409
  const path = join(abs, f);
319
- const t = readFileSync(path, 'utf8');
320
410
  stats.files++;
321
411
  stats.byKind[kind] = (stats.byKind[kind] ?? 0) + 1;
322
- const slug = f.replace(/\.md$/, '').replace(/\//g, '-');
323
- const title = (t.match(/^#\s*(.+)$/m) || [null, slug])[1].trim();
324
- // Only 34/123 carry a `**Date:**`; the rest are untracked so git has no
325
- // creation date either — fall back to the file's own mtime.
326
- const date = normDate(field(t, 'Date')) || statSync(path).mtime.toISOString().slice(0, 10);
327
- if (normDate(field(t, 'Date'))) stats.withDate++;
328
- // Full body — these files are gitignored, so the graph is the ONLY copy;
329
- // truncating would destroy the evidence it exists to preserve. (An earlier
330
- // cut extracted just the Summary/Verdict/Root-cause section.)
331
- const rationale = t.trim();
412
+ // Slug is derived from the path RELATIVE to the kind dir, so a nested
413
+ // `archive/foo.md` stays `archive-foo` — buildLogDecision's basename-only
414
+ // default would collapse it onto a sibling. Everything else is shared.
415
+ const built = buildLogDecision(path, kind, scope, {
416
+ pathRel: `${logsRel}/${dir}/${f}`,
417
+ slug: f.replace(/\.md$/, '').replace(/\//g, '-'),
418
+ });
419
+ if (built.hasExplicitDate) stats.withDate++;
420
+ stats.cited += built.citedCount;
421
+ decisions.push(built.decision);
422
+ }
423
+ }
424
+ return { batch: { decisions }, stats };
425
+ }
332
426
 
333
- const muts = [
334
- {
335
- op: 'upsert_element',
336
- kind,
337
- name: slug,
338
- props: { title: title.slice(0, 160), path: `${logsRel}/${dir}/${f}`, date },
339
- },
340
- { op: 'upsert_element', kind: 'area', name: kind },
341
- { op: 'add_link', from: `${kind}:${slug}`, to: `area:${kind}`, link: 'ABOUT' },
342
- ...scopeMuts(slug, kind),
343
- ];
344
- // Evidence edges — a review/RCA that cites a DDR is evidence ABOUT it.
345
- const cited = new Set([...t.matchAll(/DDR-(\d+)/g)].map((m) => m[1].padStart(3, '0')));
346
- for (const num of cited) {
347
- muts.push({ op: 'upsert_element', kind: 'decision', name: `DDR-${num}` });
348
- muts.push({
349
- op: 'add_link',
350
- from: `${kind}:${slug}`,
351
- to: `decision:DDR-${num}`,
352
- link: 'EVIDENCE_FOR',
353
- });
354
- stats.cited++;
427
+ /**
428
+ * The thin STATE.md a migrated repo keeps. Byte-identical to the stub
429
+ * `maude init --kg` writes (cli/commands/init.mjs KG_STATE_STUB) — a repo that
430
+ * migrated and a repo that started on the graph must not end up with two
431
+ * different-looking breadcrumbs.
432
+ */
433
+ const KG_STATE_STUB = `# Workflow State
434
+
435
+ > **kgai-active repo** — decision history + working context live in the knowledge graph, not this file.
436
+ > The \`flow:workflow-state\` skill reads/writes the graph via \`flow:kgai-backend\`.
437
+
438
+ **Status:** ready
439
+ **Active plan:** —
440
+
441
+ ## Where the history went
442
+
443
+ - **Decisions / "why is X so":** \`maude kg search "<topic>"\` (start here) · \`maude kg context --about "<element>"\`
444
+ - **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"\`
445
+ - **Conflicts:** \`maude kg conflicts\`
446
+
447
+ The pre-migration file is preserved verbatim under \`.ai/archive/state/\` — never auto-deleted.
448
+ `;
449
+
450
+ /**
451
+ * `--archive` — the cleanup half of a migration.
452
+ *
453
+ * Ingest alone leaves the repo carrying both stores: the graph AND the tree it
454
+ * replaced. This moves what the graph took over under `.ai/archive/`, which is
455
+ * what makes "switching to kgai simplifies `.ai/`" true rather than aspirational.
456
+ *
457
+ * Rules it will not break:
458
+ * - **Never deletes.** Every source is MOVED under `.ai/archive/` (DDR-044).
459
+ * - **Only what the graph replaced.** `plans/`, `scenarios/`, `docs/`,
460
+ * `context/`, `dev-logs/`, `business/` stay live — they are narrative or
461
+ * procedural, not an append-only event stream the graph now owns.
462
+ * - **STATE.md is snapshotted, not moved.** Flow commands still read the path,
463
+ * so the original goes to `archive/state/` and a pointer-stub takes its place.
464
+ * - **Idempotent.** A second run finds the sources gone and reports "nothing to
465
+ * archive" instead of clobbering the archive with an empty tree.
466
+ */
467
+ function archiveMigratedSources(projectRoot, { dryRun = false, today } = {}) {
468
+ const ai = join(projectRoot, '.ai');
469
+ const arch = join(ai, 'archive');
470
+ const moved = [];
471
+ const plan = [];
472
+
473
+ const moveInto = (srcDir, destDir, filter = () => true) => {
474
+ if (!existsSync(srcDir)) return;
475
+ const entries = readdirSync(srcDir, { withFileTypes: true }).filter((e) => filter(e));
476
+ if (!entries.length) return;
477
+ for (const e of entries) {
478
+ const from = join(srcDir, e.name);
479
+ const to = join(destDir, e.name);
480
+ plan.push(`${from.replace(`${projectRoot}/`, '')} → ${to.replace(`${projectRoot}/`, '')}`);
481
+ if (dryRun) continue;
482
+ mkdirSync(destDir, { recursive: true });
483
+ // An entry already in the archive (a re-run, or a name collision across
484
+ // nested dirs) must not be silently overwritten — keep both.
485
+ renameSync(from, existsSync(to) ? `${to}.${Date.now()}` : to);
486
+ moved.push(e.name);
487
+ }
488
+ };
489
+
490
+ // A — decisions + their index. Under an active graph the README index has no
491
+ // job left: `maude kg search` answers what it answered, and ddr-keeper no
492
+ // longer appends to it.
493
+ moveInto(join(ai, 'decisions'), join(arch, 'decisions'));
494
+ // A — log verdicts (gitignored, so the graph is now their only inheritable copy).
495
+ moveInto(join(ai, 'logs'), join(arch, 'logs'), (e) => e.name !== 'README.md');
496
+ // A — template seeds that only exist to scaffold the two files the graph
497
+ // replaced. PROJECT.md rides along: it had zero references even classically.
498
+ moveInto(
499
+ join(ai, 'templates'),
500
+ join(arch, 'templates'),
501
+ (e) => e.isFile() && ['STATE.md', 'HANDOFF.md', 'PROJECT.md'].includes(e.name)
502
+ );
503
+
504
+ // STATE.md — snapshot + stub, because the path stays live.
505
+ const statePath = join(ai, 'state', 'STATE.md');
506
+ if (existsSync(statePath)) {
507
+ const body = readFileSync(statePath, 'utf8');
508
+ const alreadyStub = body.includes('kgai-active repo');
509
+ if (!alreadyStub) {
510
+ const dest = join(arch, 'state', `STATE-pre-kgai-${today}.md`);
511
+ plan.push(
512
+ `${statePath.replace(`${projectRoot}/`, '')} → ${dest.replace(`${projectRoot}/`, '')} (+ pointer-stub)`
513
+ );
514
+ if (!dryRun) {
515
+ mkdirSync(join(arch, 'state'), { recursive: true });
516
+ writeFileSync(dest, body);
517
+ writeFileSync(statePath, KG_STATE_STUB);
518
+ moved.push('STATE.md');
355
519
  }
356
- decisions.push({ title, date, rationale, mutations: muts });
357
520
  }
358
521
  }
359
- return { batch: { decisions }, stats };
522
+ // A stale HANDOFF.md would be read by `/flow:resume` as if it were current,
523
+ // and under the graph it never gets refreshed again — the worst kind of stale.
524
+ const handoff = join(ai, 'state', 'HANDOFF.md');
525
+ if (existsSync(handoff)) {
526
+ const dest = join(arch, 'state', `HANDOFF-pre-kgai-${today}.md`);
527
+ plan.push(`${handoff.replace(`${projectRoot}/`, '')} → ${dest.replace(`${projectRoot}/`, '')}`);
528
+ if (!dryRun) {
529
+ mkdirSync(join(arch, 'state'), { recursive: true });
530
+ renameSync(handoff, dest);
531
+ moved.push('HANDOFF.md');
532
+ }
533
+ }
534
+
535
+ return { plan, moved };
360
536
  }
361
537
 
362
538
  /** Entry — `maude kg import`. Dispatched from cli/commands/kg.mjs verbImport. */
@@ -399,6 +575,21 @@ export async function run({ args, state, projectRoot, runKg }) {
399
575
  return 1;
400
576
  }
401
577
 
578
+ // Scope is MANDATORY on a shared store, not decorative. `repo` is what makes
579
+ // `decision:<repo>/DDR-NNN` unique (see ddrRef) and `dept` is the search bias
580
+ // every read leans on; importing without either produces nodes that collide
581
+ // with a sibling repo's and cannot be filtered back apart afterwards — and the
582
+ // log is append-only, so there is no cleanup. Fail loudly instead.
583
+ const missingScope = ['repo', 'dept'].filter((k) => !state.scope?.[k]);
584
+ if (missingScope.length) {
585
+ process.stderr.write(
586
+ `maude kg import: knowledgeGraph.scope.${missingScope.join(' + .')} missing in .ai/workflows.config.json.\n` +
587
+ ` Every decision must carry repo + dept scope before it reaches a shared store.\n` +
588
+ ` Add e.g. "scope": { "repo": "<this-repo>", "dept": "dev" } and re-run.\n`
589
+ );
590
+ return 1;
591
+ }
592
+
402
593
  const { batch, stats } = buildDdrBatch(decisionsDir, state.scope, only);
403
594
 
404
595
  // `.ai/logs/**` rides the same import unless --no-logs. Deliberately NOT a
@@ -473,6 +664,17 @@ export async function run({ args, state, projectRoot, runKg }) {
473
664
  ` sample: ${sample.title}\n ${JSON.stringify(sample.mutations.slice(0, 4))}\n`
474
665
  );
475
666
  }
667
+ if (flags.archive) {
668
+ const { plan } = archiveMigratedSources(projectRoot, {
669
+ dryRun: true,
670
+ today: new Date().toISOString().slice(0, 10),
671
+ });
672
+ process.stdout.write(
673
+ plan.length
674
+ ? ` archive: ${plan.length} moves planned —\n${plan.map((l) => ` ${l}\n`).join('')}`
675
+ : ' archive: nothing to move (already archived)\n'
676
+ );
677
+ }
476
678
  process.stdout.write(
477
679
  ' (dry-run — nothing written. `.ai/archive/decisions/` is preserved as archive.)\n'
478
680
  );
@@ -501,6 +703,24 @@ export async function run({ args, state, projectRoot, runKg }) {
501
703
  process.stdout.write(
502
704
  ` ✓ ingested. Marker: ${marker} (re-import needs --force). Archive kept: ${decisionsDir}\n`
503
705
  );
706
+ // ONLY after a clean ingest. Archiving on a failed one would move the
707
+ // sources out from under a graph that never received them — the one way
708
+ // this migration could actually lose someone's decisions.
709
+ if (flags.archive) {
710
+ const { plan, moved } = archiveMigratedSources(projectRoot, {
711
+ today: new Date().toISOString().slice(0, 10),
712
+ });
713
+ if (moved.length) {
714
+ process.stdout.write(` ✓ archived ${moved.length} sources under .ai/archive/:\n`);
715
+ for (const line of plan) process.stdout.write(` ${line}\n`);
716
+ process.stdout.write(
717
+ ' Nothing was deleted. `plans/`, `scenarios/`, `docs/`, `context/` stay live.\n' +
718
+ ' Grep the repo for the old paths — this does NOT rewrite references.\n'
719
+ );
720
+ } else {
721
+ process.stdout.write(' · archive: nothing left to move (already archived).\n');
722
+ }
723
+ }
504
724
  }
505
725
  return status;
506
726
  }
@@ -540,7 +760,10 @@ export function buildStateBatch(statePath, scope = {}) {
540
760
  const header = body.split('\n')[0];
541
761
  const feature = (header.match(/(feature-[a-z0-9.-]+|phase-[a-z0-9.-]+)/i) || [])[1] || null;
542
762
  const date = (body.match(/(\d{4}-\d{2}-\d{2})/) || [])[1];
543
- const slug = `${feature || 'progress'}-${date || String(decisions.length).padStart(3, '0')}`;
763
+ const slug = scopedSlug(
764
+ `${feature || 'progress'}-${date || String(decisions.length).padStart(3, '0')}`,
765
+ scope
766
+ );
544
767
  const ref = `milestone:${slug}`;
545
768
  const muts = [
546
769
  {
@@ -552,8 +775,9 @@ export function buildStateBatch(statePath, scope = {}) {
552
775
  ...scopeMuts(ref),
553
776
  ];
554
777
  if (feature) {
555
- muts.push({ op: 'upsert_element', kind: 'plan', name: feature });
556
- muts.push({ op: 'add_link', from: ref, to: `plan:${feature}`, link: 'PROGRESS_ON' });
778
+ const planName = scopedSlug(feature, scope);
779
+ muts.push({ op: 'upsert_element', kind: 'plan', name: planName });
780
+ muts.push({ op: 'add_link', from: ref, to: `plan:${planName}`, link: 'PROGRESS_ON' });
557
781
  }
558
782
  const d = {
559
783
  title: header.replace(/\*\*/g, '').slice(0, 160),
@@ -568,10 +792,13 @@ export function buildStateBatch(statePath, scope = {}) {
568
792
  // History table rows: | YYYY-MM-DD | phase | note |
569
793
  for (const row of t.matchAll(/^\|\s*(\d{4}-\d{2}-\d{2})\s*\|([^|]*)\|([^|]*)\|(.*)$/gm)) {
570
794
  const [, date, phase, status, note] = row;
571
- const slug = `history-${date}-${phase
572
- .trim()
573
- .replace(/[^a-z0-9.-]+/gi, '-')
574
- .toLowerCase()}`.slice(0, 90);
795
+ const slug = scopedSlug(
796
+ `history-${date}-${phase
797
+ .trim()
798
+ .replace(/[^a-z0-9.-]+/gi, '-')
799
+ .toLowerCase()}`.slice(0, 90),
800
+ scope
801
+ );
575
802
  const ref = `milestone:${slug}`;
576
803
  decisions.push({
577
804
  title: `${date} · ${phase.trim()} · ${status.trim()}`.slice(0, 160),
@@ -612,7 +839,8 @@ export function buildDocsBatch(aiDir, scope = {}) {
612
839
  .filter((x) => x.endsWith('.md') && !SKIP.has(x))
613
840
  .sort()) {
614
841
  const t = readFileSync(join(abs, f), 'utf8');
615
- const slug = f.replace(/\.md$/, '');
842
+ // Doc names are filenames — every repo has a `PRD.md`.
843
+ const slug = scopedSlug(f.replace(/\.md$/, ''), scope);
616
844
  const title = (t.match(/^#\s*(.+)$/m) || [null, slug])[1].trim();
617
845
  const ref = `doc:${slug}`;
618
846
  const muts = [
@@ -633,8 +861,8 @@ export function buildDocsBatch(aiDir, scope = {}) {
633
861
  }
634
862
  // A doc that cites DDRs is context ABOUT them.
635
863
  for (const num of new Set([...t.matchAll(/DDR-(\d+)/g)].map((m) => m[1].padStart(3, '0')))) {
636
- muts.push({ op: 'upsert_element', kind: 'decision', name: `DDR-${num}` });
637
- muts.push({ op: 'add_link', from: ref, to: `decision:DDR-${num}`, link: 'REFERENCES' });
864
+ muts.push({ op: 'upsert_element', kind: 'decision', name: ddrRef(num, scope) });
865
+ muts.push({ op: 'add_link', from: ref, to: `decision:${ddrRef(num, scope)}`, link: 'REFERENCES' });
638
866
  }
639
867
  decisions.push({
640
868
  title: `Doc: ${title}`.slice(0, 160),
@@ -97,3 +97,23 @@ test('non-DDR files are ignored', () => {
97
97
  const { batch } = buildDdrBatch(fixtureDir({ 'DDR-001-x.md': DDR1, 'README.md': '# index' }), {});
98
98
  assert.equal(batch.decisions.length, 1);
99
99
  });
100
+
101
+ test('decision names are namespaced by repo so DDR numbers do not collide cross-repo', () => {
102
+ const files = { 'DDR-001-a.md': '# DDR-001: Alpha\n\n**Tags:** infra\n' };
103
+ const a = buildDdrBatch(fixtureDir(files), { repo: 'vantage', dept: 'marketing' });
104
+ const b = buildDdrBatch(fixtureDir(files), { repo: 'AI-StudyMate', dept: 'dev' });
105
+ const nameOf = (batch) =>
106
+ batch.decisions[0].mutations.find((m) => m.kind === 'decision').name;
107
+ assert.equal(nameOf(a.batch), 'vantage/DDR-001');
108
+ assert.equal(nameOf(b.batch), 'AI-StudyMate/DDR-001');
109
+ assert.notEqual(nameOf(a.batch), nameOf(b.batch));
110
+ });
111
+
112
+ test('area elements are NOT namespaced — concepts must converge across repos', () => {
113
+ const { batch } = buildDdrBatch(
114
+ fixtureDir({ 'DDR-001-a.md': '# DDR-001: Alpha\n\n**Tags:** security\n' }),
115
+ { repo: 'vantage', dept: 'marketing' }
116
+ );
117
+ const area = batch.decisions[0].mutations.find((m) => m.kind === 'area');
118
+ assert.equal(area.name, 'security');
119
+ });
@@ -0,0 +1,96 @@
1
+ // Publishing a shared view — the DECISION half. Cloud Phase 18 Task 1.
2
+ //
3
+ // WHO RENDERS. Snapshots are produced on a MEMBER's own machine and uploaded
4
+ // as finished pictures. The vendor never renders a canvas — that is the whole
5
+ // containment claim (DDR-193 §2 / DDR-197), and it is why publishing is a
6
+ // local command rather than a server feature. A "publish" button that made the
7
+ // server render would quietly delete the invariant.
8
+ //
9
+ // Pure: no filesystem, no network, no clock. What gets uploaded and under
10
+ // which key is the part that can leak one project's work into another's view,
11
+ // and that must be reviewable without credentials.
12
+
13
+ /** Formats the share view will serve. Must match apps/cells/share.mjs. */
14
+ const SHAREABLE = /\.(png|jpe?g|webp|avif)$/i;
15
+
16
+ /**
17
+ * SVG is excluded here for the same reason it is excluded there: an SVG is a
18
+ * document that can carry script, and the share origin must never serve one.
19
+ * Two lists that could disagree would be a hole, so this test names the reason
20
+ * rather than just the extensions.
21
+ */
22
+ export function isShareable(name) {
23
+ return SHAREABLE.test(String(name ?? ''));
24
+ }
25
+
26
+ /** Same charset the cell entrypoint enforces — the id becomes a storage prefix. */
27
+ export function validProjectId(raw) {
28
+ const id = String(raw ?? '').trim();
29
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) && id.length <= 63 ? id : null;
30
+ }
31
+
32
+ /**
33
+ * What to upload, and where.
34
+ *
35
+ * @param {string[]} files paths relative to the source directory
36
+ * @param {string} project
37
+ * @returns {{ uploads: {from: string, key: string}[], skipped: string[] }}
38
+ */
39
+ export function publishPlan(files, project) {
40
+ const id = validProjectId(project);
41
+ if (!id) throw new Error(`invalid project id: ${project}`);
42
+ const uploads = [];
43
+ const skipped = [];
44
+ for (const raw of files) {
45
+ const rel = String(raw).replace(/\\/g, '/').replace(/^\.\//, '');
46
+ // A path that could climb out of the source directory would upload
47
+ // something nobody chose to share.
48
+ if (rel.startsWith('/') || rel.split('/').includes('..')) {
49
+ skipped.push(rel);
50
+ continue;
51
+ }
52
+ if (!isShareable(rel)) {
53
+ skipped.push(rel);
54
+ continue;
55
+ }
56
+ uploads.push({ from: rel, key: `tenants/${id}/snapshots/${rel}` });
57
+ }
58
+ uploads.sort((a, b) => a.key.localeCompare(b.key));
59
+ return { uploads, skipped };
60
+ }
61
+
62
+ /** The marker whose ABSENCE means "not shared". Default-closed by construction. */
63
+ export function shareMarker(project, { enabled, name }) {
64
+ const id = validProjectId(project);
65
+ if (!id) throw new Error(`invalid project id: ${project}`);
66
+ return {
67
+ key: `tenants/${id}/share.json`,
68
+ body: JSON.stringify({ enabled: Boolean(enabled), name: String(name ?? id).slice(0, 80) }, null, 2),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * What the operator is told after a publish.
74
+ *
75
+ * Says the URL and says what it is NOT, because the single most likely
76
+ * misunderstanding is that a shared view is live.
77
+ */
78
+ export function publishSummary({ project, uploaded, skipped, zone = 'cloud.maude.sh' }) {
79
+ const lines = [
80
+ `Published ${uploaded} view${uploaded === 1 ? '' : 's'} of ${project}.`,
81
+ ``,
82
+ ` https://view-${project}.${zone}`,
83
+ ``,
84
+ `These are pictures taken on this machine just now. They do not update`,
85
+ `themselves — publish again after the design changes.`,
86
+ ];
87
+ if (skipped > 0) {
88
+ lines.push(
89
+ ``,
90
+ `${skipped} file${skipped === 1 ? ' was' : 's were'} not published: the shared view`,
91
+ `serves only PNG, JPEG, WebP and AVIF. SVG is excluded on purpose — it can`,
92
+ `carry script, and nothing the view serves is ever allowed to execute.`
93
+ );
94
+ }
95
+ return lines.join('\n');
96
+ }