@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.
- package/apps/studio/api.ts +751 -52
- package/apps/studio/canvas-artifacts.ts +153 -0
- package/apps/studio/canvas-create.ts +23 -0
- package/apps/studio/canvas-slug.ts +30 -0
- package/apps/studio/client/app.jsx +547 -90
- package/apps/studio/client/panels/CloudBar.jsx +388 -0
- package/apps/studio/client/styles/3-shell-maude.css +28 -0
- package/apps/studio/client/tree-row-menu.jsx +132 -0
- package/apps/studio/client/use-tree-drag.js +120 -0
- package/apps/studio/cloud/endpoints.ts +284 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/debug-bundle.ts +144 -0
- package/apps/studio/dist/client.bundle.js +1235 -1228
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +205 -1
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/sync/workspace-signin.ts +15 -0
- package/apps/studio/test/canvas-artifacts.test.ts +125 -0
- package/apps/studio/test/canvas-create-api.test.ts +159 -1
- package/apps/studio/test/canvas-move-api.test.ts +556 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/cloud-endpoints.test.ts +130 -0
- package/apps/studio/test/debug-bundle.test.ts +105 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/test/workspace-signin.test.ts +19 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +294 -7
- package/cli/commands/init.mjs +33 -2
- package/cli/commands/kg.mjs +129 -4
- package/cli/commands/share.mjs +184 -0
- package/cli/lib/ddr-to-kgai.mjs +310 -82
- package/cli/lib/ddr-to-kgai.test.mjs +20 -0
- package/cli/lib/share-plan.mjs +96 -0
- package/cli/lib/share-plan.test.mjs +57 -0
- package/cli/lib/workspace-plan.mjs +130 -12
- package/cli/lib/workspace-plan.test.mjs +160 -0
- package/package.json +9 -8
package/cli/lib/ddr-to-kgai.mjs
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
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:
|
|
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
|
|
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:
|
|
258
|
+
muts.push({ op: 'upsert_element', kind: 'decision', name: ddrRef(tgt, scope) });
|
|
237
259
|
muts.push({
|
|
238
260
|
op: 'add_link',
|
|
239
|
-
from: `decision
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
556
|
-
muts.push({ op: '
|
|
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 =
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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:
|
|
637
|
-
muts.push({ op: 'add_link', from: ref, to: `decision
|
|
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
|
+
}
|