@1agh/maude 0.22.2 → 0.24.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 (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,165 @@
1
+ // Phase 11 (flow ⇄ design integration) regression guard.
2
+ //
3
+ // The slash-command orchestration (/flow:plan canvas detection, /flow:done
4
+ // handoff sweep, codebase-intelligence design section, ddr-keeper canvas prompt)
5
+ // is LLM-driven markdown — not unit-testable. What IS deterministic, and what
6
+ // this test locks down, is the *contract* that wiring depends on:
7
+ //
8
+ // 1. The canvas `.meta.json` schema accepts the Phase 11 fields
9
+ // (status enum, handoffCommit, tags, brief_sha) and rejects a bad status.
10
+ // 2. The flow config schema accepts `paths.designRoot`, the real skeleton
11
+ // config validates, and the field stays OPTIONAL (no regression for
12
+ // projects without the design plugin).
13
+ // 3. The /flow:done status round-trip (ready-for-handoff → handed-off +
14
+ // handoffCommit) produces schema-valid output at both ends.
15
+ // 4. The markdown wiring for all four integrations is present (grep guard) —
16
+ // a future edit that deletes a section fails loudly here.
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { readFileSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+ import { test } from 'node:test';
22
+ import addFormats from 'ajv-formats';
23
+ import Ajv2020 from 'ajv/dist/2020.js';
24
+ import { lintConfig } from './config-lint.mjs';
25
+
26
+ const read = (p) => readFileSync(resolve(p), 'utf8');
27
+ const readJson = (p) => JSON.parse(read(p));
28
+
29
+ const META_SCHEMA = readJson('plugins/design/dev-server/canvas-meta.schema.json');
30
+ const FLOW_SCHEMA = readJson('plugins/flow/.claude-plugin/config.schema.json');
31
+ const SKELETON = readJson('plugins/flow/templates/ai-skeleton/workflows.config.json');
32
+
33
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
34
+ addFormats(ajv);
35
+ const validateMeta = ajv.compile(META_SCHEMA);
36
+
37
+ // ── 1. canvas-meta schema accepts the Phase 11 fields ──────────────────────
38
+
39
+ test('canvas-meta: accepts status + tags (ready-for-handoff)', () => {
40
+ const ok = validateMeta({
41
+ title: 'Dark Mode Toggle',
42
+ sections: [{ id: 'hero', label: 'Hero' }],
43
+ tags: ['dark-mode'],
44
+ status: 'ready-for-handoff',
45
+ });
46
+ assert.equal(ok, true, JSON.stringify(validateMeta.errors));
47
+ });
48
+
49
+ test('canvas-meta: accepts handed-off + handoffCommit + brief_sha', () => {
50
+ const ok = validateMeta({
51
+ title: 'Settings',
52
+ sections: [],
53
+ status: 'handed-off',
54
+ handoffCommit: '8dd832b',
55
+ brief_sha: 'abc123def456',
56
+ });
57
+ assert.equal(ok, true, JSON.stringify(validateMeta.errors));
58
+ });
59
+
60
+ test('canvas-meta: every status enum value is accepted', () => {
61
+ for (const status of ['draft', 'in-review', 'ready-for-handoff', 'handed-off']) {
62
+ const ok = validateMeta({ title: 'X', sections: [], status });
63
+ assert.equal(ok, true, `status=${status}: ${JSON.stringify(validateMeta.errors)}`);
64
+ }
65
+ });
66
+
67
+ test('canvas-meta: a bogus status enum is REJECTED', () => {
68
+ const ok = validateMeta({ title: 'X', sections: [], status: 'shipped' });
69
+ assert.equal(ok, false, 'expected status="shipped" to be rejected by the enum');
70
+ });
71
+
72
+ test('canvas-meta: back-compat — a sidecar without any Phase 11 field still validates', () => {
73
+ const ok = validateMeta({ title: 'Legacy', sections: [{ id: 's', label: 'S' }] });
74
+ assert.equal(ok, true, JSON.stringify(validateMeta.errors));
75
+ });
76
+
77
+ test('canvas-meta: tags must be unique strings', () => {
78
+ assert.equal(validateMeta({ title: 'X', sections: [], tags: ['a', 'a'] }), false);
79
+ assert.equal(validateMeta({ title: 'X', sections: [], tags: [1, 2] }), false);
80
+ });
81
+
82
+ // ── 2. flow config schema: paths.designRoot ────────────────────────────────
83
+
84
+ test('flow config: the real skeleton config (with paths.designRoot) validates', async () => {
85
+ const r = await lintConfig({ config: SKELETON, schema: FLOW_SCHEMA });
86
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
87
+ });
88
+
89
+ test('flow config: skeleton declares paths.designRoot = .design', () => {
90
+ assert.equal(SKELETON.paths.designRoot, '.design');
91
+ });
92
+
93
+ test('flow config: paths.designRoot is OPTIONAL (no-regression for non-design projects)', async () => {
94
+ const r = await lintConfig({
95
+ config: { name: 'x', paths: { prd: '.ai/x-prd.md' } },
96
+ schema: FLOW_SCHEMA,
97
+ });
98
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
99
+ });
100
+
101
+ test('flow config: a non-string paths.designRoot is rejected', async () => {
102
+ const r = await lintConfig({
103
+ config: { name: 'x', paths: { designRoot: 42 } },
104
+ schema: FLOW_SCHEMA,
105
+ });
106
+ assert.equal(r.ok, false);
107
+ });
108
+
109
+ test('flow config: paths.designRoot carries a default of .design', () => {
110
+ assert.equal(FLOW_SCHEMA.properties.paths.properties.designRoot.default, '.design');
111
+ });
112
+
113
+ // ── 3. /flow:done status round-trip stays schema-valid ─────────────────────
114
+
115
+ test('done sweep round-trip: ready-for-handoff → handed-off + handoffCommit both valid', () => {
116
+ const before = { title: 'Canvas', sections: [], status: 'ready-for-handoff', tags: ['feat'] };
117
+ assert.equal(validateMeta(before), true, `before: ${JSON.stringify(validateMeta.errors)}`);
118
+
119
+ // what /flow:done step 5b writes:
120
+ const after = { ...before, status: 'handed-off', handoffCommit: 'deadbeef0123' };
121
+ assert.equal(validateMeta(after), true, `after: ${JSON.stringify(validateMeta.errors)}`);
122
+ });
123
+
124
+ // ── 4. markdown wiring guards (a deleted section fails here) ────────────────
125
+
126
+ const PLAN = read('plugins/flow/commands/plan.md');
127
+ const DONE = read('plugins/flow/commands/done.md');
128
+ const MAP_CMD = read('plugins/flow/commands/setup-codebase-map.md');
129
+ const CI_SKILL = read('plugins/flow/skills/codebase-intelligence/SKILL.md');
130
+ const DDR_SKILL = read('plugins/flow/skills/ddr-keeper/SKILL.md');
131
+ const RECORD_DDR = read('plugins/flow/commands/record-ddr.md');
132
+
133
+ test('wiring: /flow:plan has Design Canvas Detection', () => {
134
+ assert.match(PLAN, /Design Canvas Detection/);
135
+ assert.match(PLAN, /paths\.designRoot/);
136
+ assert.match(PLAN, /### Design canvases/); // the Context References subsection
137
+ });
138
+
139
+ test('wiring: /flow:done has the handoff sweep + soft-gate DDR-066 reference', () => {
140
+ assert.match(DONE, /Design handoff sweep/);
141
+ assert.match(DONE, /ready-for-handoff/);
142
+ assert.match(DONE, /handed-off/);
143
+ assert.match(DONE, /handoffCommit/);
144
+ assert.match(DONE, /DDR-066/);
145
+ });
146
+
147
+ test('wiring: codebase map command + skill have the Design artifacts section', () => {
148
+ assert.match(MAP_CMD, /Map Design Artifacts/);
149
+ assert.match(MAP_CMD, /## Design artifacts/);
150
+ assert.match(CI_SKILL, /Design Artifact Scanning/);
151
+ assert.match(CI_SKILL, /### Design artifacts/);
152
+ });
153
+
154
+ test('wiring: ddr-keeper + record-ddr have the canvas-reference prompt', () => {
155
+ assert.match(DDR_SKILL, /Canvas reference for UI-affecting decisions/);
156
+ assert.match(DDR_SKILL, /Related canvas/);
157
+ assert.match(RECORD_DDR, /Related canvas/);
158
+ });
159
+
160
+ test('wiring: DDR-066 exists and is indexed', () => {
161
+ const ddr = read('.ai/decisions/DDR-066-soft-handoff-prompt-in-flow-done.md');
162
+ assert.match(ddr, /soft prompt/i);
163
+ const index = read('.ai/decisions/README.md');
164
+ assert.match(index, /DDR-066/);
165
+ });
@@ -0,0 +1,90 @@
1
+ // Maude design-runtime .gitignore block — Phase 9 Task 9 (gitignore strategy).
2
+ //
3
+ // Single `full` strategy in v1.1 (DDR-056): canvases + their JSON snapshots
4
+ // stay in git (cold backup, PR-reviewable .html diffs, bootstrap-from-clone),
5
+ // while regenerable per-machine runtime state is ignored — even in linked mode.
6
+ //
7
+ // The rules live between `# maude:begin` / `# maude:end` markers so the writer
8
+ // is idempotent: re-running replaces the block in place (never duplicates), and
9
+ // anything the user authored outside the markers is untouched. `maude design
10
+ // unlink` deliberately leaves the block — the rules are harmless in solo mode
11
+ // (the ignored files just don't get created).
12
+
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
15
+
16
+ export const BEGIN_MARKER = '# maude:begin';
17
+ export const END_MARKER = '# maude:end';
18
+
19
+ /**
20
+ * The runtime paths Maude ignores under the design root, relative to repo root.
21
+ * `designRel` defaults to `.design` (the only design root in v1.1 defaults).
22
+ */
23
+ export function buildBlock(designRel = '.design') {
24
+ const root = designRel.replace(/\/+$/, '');
25
+ const lines = [
26
+ BEGIN_MARKER,
27
+ '# Maude design plugin runtime — gitignored even in linked mode (DDR-056).',
28
+ `${root}/_state/`, // binary CRDT logs (regenerable from hub)
29
+ `${root}/_server.json`,
30
+ `${root}/_server.log`,
31
+ `${root}/_active.json`,
32
+ `${root}/_sync.json`, // linked-mode offline/sync status (Task 8)
33
+ `${root}/_history/`,
34
+ `${root}/_canvas-state/`, // per-machine canvas undo/redo + scratch state
35
+ `${root}/_chat/`, // ACP transcripts (per-machine)
36
+ END_MARKER,
37
+ ];
38
+ return `${lines.join('\n')}\n`;
39
+ }
40
+
41
+ /** True when the file already contains a maude-managed block. */
42
+ export function hasBlock(contents) {
43
+ return contents.includes(BEGIN_MARKER) && contents.includes(END_MARKER);
44
+ }
45
+
46
+ /**
47
+ * Replace the existing maude block in `contents`, or append a fresh one.
48
+ * Pure — returns the new file text. Preserves everything outside the markers.
49
+ */
50
+ export function applyBlock(contents, block) {
51
+ if (!hasBlock(contents)) {
52
+ // Append with a single blank-line separator (avoid leading newline on an
53
+ // empty file; avoid double-blank when the file already ends in a newline).
54
+ if (contents.length === 0) return block;
55
+ const sep = contents.endsWith('\n') ? '\n' : '\n\n';
56
+ return `${contents}${sep}${block}`;
57
+ }
58
+ const begin = contents.indexOf(BEGIN_MARKER);
59
+ const endIdx = contents.indexOf(END_MARKER);
60
+ const after = endIdx + END_MARKER.length;
61
+ // Consume one trailing newline after END so we don't accumulate blank lines.
62
+ const tail = contents.slice(after).replace(/^\n/, '');
63
+ const head = contents.slice(0, begin);
64
+ return `${head}${block}${tail ? `\n${tail}` : ''}`;
65
+ }
66
+
67
+ /**
68
+ * Write (or refresh) the maude block in `<repoRoot>/.gitignore`.
69
+ *
70
+ * @param {string} repoRoot
71
+ * @param {{ designRel?: string, dryRun?: boolean }} [opts]
72
+ * @returns {{ action: 'created' | 'updated' | 'unchanged', path: string }}
73
+ */
74
+ export function writeGitignoreBlock(repoRoot, { designRel = '.design', dryRun = false } = {}) {
75
+ const path = resolve(repoRoot, '.gitignore');
76
+ const existed = existsSync(path);
77
+ const current = existed ? readFileSync(path, 'utf8') : '';
78
+ const block = buildBlock(designRel);
79
+ const next = applyBlock(current, block);
80
+
81
+ let action;
82
+ if (!existed) action = 'created';
83
+ else if (next === current) action = 'unchanged';
84
+ else action = 'updated';
85
+
86
+ if (!dryRun && next !== current) {
87
+ writeFileSync(path, next, 'utf8');
88
+ }
89
+ return { action, path };
90
+ }
@@ -0,0 +1,123 @@
1
+ // gitignore-block helper tests — Phase 9 Task 9 (DDR-056).
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { test } from 'node:test';
8
+
9
+ import {
10
+ BEGIN_MARKER,
11
+ END_MARKER,
12
+ applyBlock,
13
+ buildBlock,
14
+ hasBlock,
15
+ writeGitignoreBlock,
16
+ } from './gitignore-block.mjs';
17
+
18
+ function withRepo(fn) {
19
+ const dir = mkdtempSync(join(tmpdir(), 'maude-gitignore-'));
20
+ try {
21
+ return fn(dir);
22
+ } finally {
23
+ rmSync(dir, { recursive: true, force: true });
24
+ }
25
+ }
26
+
27
+ test('buildBlock includes the runtime paths and is marker-wrapped', () => {
28
+ const block = buildBlock('.design');
29
+ assert.ok(block.startsWith(BEGIN_MARKER));
30
+ assert.ok(block.trimEnd().endsWith(END_MARKER));
31
+ for (const p of [
32
+ '.design/_state/',
33
+ '.design/_server.json',
34
+ '.design/_active.json',
35
+ '.design/_sync.json',
36
+ '.design/_history/',
37
+ '.design/_chat/',
38
+ ]) {
39
+ assert.ok(block.includes(p), `missing ${p}`);
40
+ }
41
+ // Committed artifacts must NOT be ignored.
42
+ assert.ok(!block.includes('.design/config.json'));
43
+ assert.ok(!/\.design\/\*\.html/.test(block));
44
+ });
45
+
46
+ test('buildBlock honors a custom design root', () => {
47
+ const block = buildBlock('design');
48
+ assert.ok(block.includes('design/_server.json'));
49
+ assert.ok(!block.includes('.design/_server.json'));
50
+ });
51
+
52
+ test('writeGitignoreBlock creates .gitignore when absent', () => {
53
+ withRepo((dir) => {
54
+ const { action, path } = writeGitignoreBlock(dir);
55
+ assert.equal(action, 'created');
56
+ const text = readFileSync(path, 'utf8');
57
+ assert.ok(hasBlock(text));
58
+ });
59
+ });
60
+
61
+ test('writeGitignoreBlock is idempotent — second run is unchanged', () => {
62
+ withRepo((dir) => {
63
+ writeGitignoreBlock(dir);
64
+ const first = readFileSync(join(dir, '.gitignore'), 'utf8');
65
+ const { action } = writeGitignoreBlock(dir);
66
+ const second = readFileSync(join(dir, '.gitignore'), 'utf8');
67
+ assert.equal(action, 'unchanged');
68
+ assert.equal(first, second);
69
+ // Exactly one block — no duplication.
70
+ assert.equal(first.split(BEGIN_MARKER).length - 1, 1);
71
+ });
72
+ });
73
+
74
+ test('writeGitignoreBlock preserves user content outside the markers', () => {
75
+ withRepo((dir) => {
76
+ const userContent = 'node_modules/\n*.log\n.env\n';
77
+ writeFileSync(join(dir, '.gitignore'), userContent);
78
+ writeGitignoreBlock(dir);
79
+ const text = readFileSync(join(dir, '.gitignore'), 'utf8');
80
+ assert.ok(text.includes('node_modules/'));
81
+ assert.ok(text.includes('.env'));
82
+ assert.ok(hasBlock(text));
83
+ });
84
+ });
85
+
86
+ test('writeGitignoreBlock updates an existing block in place (no duplication)', () => {
87
+ withRepo((dir) => {
88
+ // Seed a stale block with a different design root.
89
+ const stale = `keep-me\n${buildBlock('olddesign')}trailing\n`;
90
+ writeFileSync(join(dir, '.gitignore'), stale);
91
+ const { action } = writeGitignoreBlock(dir, { designRel: '.design' });
92
+ assert.equal(action, 'updated');
93
+ const text = readFileSync(join(dir, '.gitignore'), 'utf8');
94
+ assert.equal(text.split(BEGIN_MARKER).length - 1, 1, 'still exactly one block');
95
+ assert.ok(text.includes('.design/_server.json'));
96
+ assert.ok(!text.includes('olddesign/_server.json'));
97
+ assert.ok(text.includes('keep-me'));
98
+ assert.ok(text.includes('trailing'));
99
+ });
100
+ });
101
+
102
+ test('writeGitignoreBlock --dry-run does not touch disk', () => {
103
+ withRepo((dir) => {
104
+ const { action } = writeGitignoreBlock(dir, { dryRun: true });
105
+ assert.equal(action, 'created');
106
+ // File was not actually written.
107
+ assert.throws(() => readFileSync(join(dir, '.gitignore'), 'utf8'));
108
+ });
109
+ });
110
+
111
+ test('applyBlock appends to a file lacking a trailing newline cleanly', () => {
112
+ const out = applyBlock('a\nb', buildBlock('.design'));
113
+ assert.ok(out.includes('a\nb'));
114
+ assert.ok(hasBlock(out));
115
+ // No run-together: marker starts on its own line.
116
+ assert.ok(out.includes(`\n${BEGIN_MARKER}`));
117
+ });
118
+
119
+ test('hasBlock detects both markers', () => {
120
+ assert.ok(hasBlock(`x\n${BEGIN_MARKER}\ny\n${END_MARKER}\n`));
121
+ assert.ok(!hasBlock('just user content'));
122
+ assert.ok(!hasBlock(BEGIN_MARKER)); // begin without end
123
+ });
@@ -12,8 +12,15 @@
12
12
  // "linkedAt": 1716800000000
13
13
  // },
14
14
  // ...
15
- // }
15
+ // },
16
+ // "trusted": ["https://maude-hub-foo.fly.dev", ...]
16
17
  // }
18
+ //
19
+ // `trusted` is the per-machine trust allowlist for non-loopback hubs (the
20
+ // `maude design link` confirmation, DDR-054 F2). It lives here — NOT in a
21
+ // committable repo file — on purpose: a committable allowlist would let an
22
+ // attacker pre-seed trust via a PR and bypass the link-time confirmation
23
+ // (trust laundering). Trust is a per-machine decision, like `~/.ssh/known_hosts`.
17
24
 
18
25
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
26
  import { homedir } from 'node:os';
@@ -68,11 +75,20 @@ export function saveHubsConfig(config) {
68
75
  }
69
76
  }
70
77
 
71
- /** Upsert a hub entry. Same URL replaces the existing record. */
72
- export function addHub(url, token) {
78
+ /**
79
+ * Upsert a hub entry. Same URL replaces the existing record. `extra` merges
80
+ * additional per-machine attestations (e.g. `adoptedAt` — the timestamp this
81
+ * machine pushed its local state up via `--adopt`, used by the sync runtime to
82
+ * avoid re-adopting; DDR-054 F4).
83
+ *
84
+ * @param {string} url
85
+ * @param {string} token
86
+ * @param {{ adoptedAt?: number }} [extra]
87
+ */
88
+ export function addHub(url, token, extra = {}) {
73
89
  const norm = normalizeUrl(url);
74
90
  const cfg = loadHubsConfig();
75
- cfg.hubs[norm] = { token, linkedAt: Date.now() };
91
+ cfg.hubs[norm] = { token, linkedAt: Date.now(), ...extra };
76
92
  saveHubsConfig(cfg);
77
93
  return cfg.hubs[norm];
78
94
  }
@@ -98,6 +114,28 @@ export function getHub(url) {
98
114
  return cfg.hubs[norm] ?? null;
99
115
  }
100
116
 
117
+ /**
118
+ * Per-machine trust check for a non-loopback hub (DDR-054 F2). Trust is stored
119
+ * here, not in a committable repo file, so a malicious PR cannot pre-seed it.
120
+ */
121
+ export function isHubTrusted(url) {
122
+ const norm = normalizeUrl(url);
123
+ const cfg = loadHubsConfig();
124
+ return Array.isArray(cfg.trusted) && cfg.trusted.includes(norm);
125
+ }
126
+
127
+ /** Record a hub as trusted on THIS machine. Idempotent. */
128
+ export function trustHub(url) {
129
+ const norm = normalizeUrl(url);
130
+ const cfg = loadHubsConfig();
131
+ if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
132
+ if (!cfg.trusted.includes(norm)) {
133
+ cfg.trusted.push(norm);
134
+ saveHubsConfig(cfg);
135
+ }
136
+ return norm;
137
+ }
138
+
101
139
  /**
102
140
  * Normalize a hub URL so different spellings resolve to the same key:
103
141
  * - Trim trailing slash.
@@ -0,0 +1,56 @@
1
+ // DDR-061 + DDR-062 regression guard.
2
+ //
3
+ // DDR-061: a plugin must NEVER invoke a `cli/lib/*.mjs` module by a relative
4
+ // path unconditionally: in a marketplace install the plugin is copied alone into
5
+ // `cache/<marketplace>/<plugin>/<version>/`, so the sibling `cli/` does not
6
+ // exist and the call crashes with MODULE_NOT_FOUND. The reachable contract is
7
+ // the on-PATH `maude` binary (a declared plugin dependency).
8
+ //
9
+ // So any plugin file that runs `node … cli/lib/<x>.mjs` is only allowed when the
10
+ // SAME file also offers a `command -v maude` fallback (the sibling-first /
11
+ // maude-fallback pattern). This test fails loudly if a new straggler lands.
12
+ //
13
+ // DDR-062: plugin MARKDOWN must reach the dev-server bash helpers through
14
+ // `maude design <verb>`, never `bash "$CLAUDE_PLUGIN_ROOT/dev-server/bin/<x>.sh"`.
15
+ // The marketplace never copies the dev-server beside a plugin, and a flow
16
+ // command's $CLAUDE_PLUGIN_ROOT points at plugins/flow (no dev-server/ at all).
17
+ // Prose mentions of the path are fine; INVOCATIONS (bash/sh/exec/$( prefixed)
18
+ // are banned.
19
+
20
+ import assert from 'node:assert/strict';
21
+ import { execSync } from 'node:child_process';
22
+ import { readFileSync } from 'node:fs';
23
+ import { test } from 'node:test';
24
+
25
+ test('no plugin runs `node cli/lib/*.mjs` without an on-PATH maude fallback', () => {
26
+ const out = execSync(
27
+ String.raw`grep -rlE 'node[^\n]*cli/lib/[a-z0-9_-]+\.mjs' plugins/ || true`,
28
+ {
29
+ encoding: 'utf8',
30
+ }
31
+ );
32
+ const files = out.trim().split('\n').filter(Boolean);
33
+ const offenders = files.filter((f) => !/command -v maude/.test(readFileSync(f, 'utf8')));
34
+ assert.deepEqual(
35
+ offenders,
36
+ [],
37
+ `These plugin files invoke a relative cli/lib module with no \`command -v maude\` fallback — they will crash in a marketplace install (DDR-061). Route through \`maude <subcommand>\` or guard with a sibling-exists check + maude fallback:\n ${offenders.join('\n ')}`
38
+ );
39
+ });
40
+
41
+ test('no plugin markdown invokes a $CLAUDE_PLUGIN_ROOT/dev-server/bin/*.sh helper (DDR-062)', () => {
42
+ // INVOCATION = the bin path preceded by bash / sh / exec, or inside a $(…)
43
+ // command substitution. Prose mentions of the path (backtick-wrapped, no verb)
44
+ // are exempt — they don't run anything. Matches both the $CLAUDE_PLUGIN_ROOT
45
+ // form and the repo-relative `plugins/<x>/dev-server/bin/` form.
46
+ const pattern = String.raw`(bash|sh|exec) +"?(\$\{?CLAUDE_PLUGIN_ROOT\}?|plugins/[a-z]+)/dev-server/bin/[A-Za-z0-9_-]+\.sh|\$\("?\$?\{?CLAUDE_PLUGIN_ROOT\}?/dev-server/bin/[A-Za-z0-9_-]+\.sh`;
47
+ const out = execSync(`grep -rnE '${pattern}' plugins --include='*.md' || true`, {
48
+ encoding: 'utf8',
49
+ });
50
+ const hits = out.trim().split('\n').filter(Boolean);
51
+ assert.deepEqual(
52
+ hits,
53
+ [],
54
+ `These plugin markdown lines INVOKE a dev-server bin helper directly — they break in marketplace installs (the dev-server is never copied beside a plugin) and in flow commands (whose $CLAUDE_PLUGIN_ROOT has no dev-server/). Route through \`maude design <verb>\` instead (DDR-062):\n ${hits.join('\n ')}`
55
+ );
56
+ });
@@ -12,7 +12,7 @@
12
12
  // because every consumer wants the same table format.
13
13
 
14
14
  import { spawnSync } from 'node:child_process';
15
- import { readFile } from 'node:fs/promises';
15
+ import { readFile, writeFile } from 'node:fs/promises';
16
16
  import { resolve } from 'node:path';
17
17
 
18
18
  const SEM_RE = /(\d+)\.(\d+)\.(\d+)/;
@@ -172,10 +172,11 @@ function colorFor(g) {
172
172
  // ─── CLI entry — `node cli/lib/preflight.mjs --plugin design --json` ──────
173
173
 
174
174
  function parseFlags(args) {
175
- const out = { plugin: null, mode: 'text' };
175
+ const out = { plugin: null, mode: 'text', cache: null };
176
176
  for (let i = 0; i < args.length; i++) {
177
177
  const a = args[i];
178
178
  if (a === '--plugin') out.plugin = args[++i];
179
+ else if (a === '--cache') out.cache = args[++i];
179
180
  else if (a === '--json') out.mode = 'json';
180
181
  else if (a === '--shell-export') out.mode = 'shell-export';
181
182
  else if (a === '--quiet') out.mode = 'quiet';
@@ -184,17 +185,46 @@ function parseFlags(args) {
184
185
  return out;
185
186
  }
186
187
 
187
- function manifestPathForPlugin(pluginName) {
188
- return resolve(`plugins/${pluginName}/dependencies.json`);
188
+ // Cross-command short-circuit cache (Phase A, Task A15). Written after a
189
+ // successful check so sibling commands within the freshness window skip the
190
+ // whole preflight. Path is caller-supplied (design → <designRoot>/_preflight.json,
191
+ // flow → .ai/state/_preflight.json) because this lib runs with cwd = the maude
192
+ // package root when shelled from preflight.sh, not the target repo.
193
+ async function writeCache(cachePath, env) {
194
+ const payload = {
195
+ checked: new Date().toISOString(),
196
+ plugin: env.plugin,
197
+ all_hard_pass: env.summary.allHardPass,
198
+ soft_warnings: env.results
199
+ .filter((r) => r.hardness === 'soft' && r.status === 'missing')
200
+ .map((r) => r.id),
201
+ };
202
+ try {
203
+ await writeFile(resolve(cachePath), `${JSON.stringify(payload, null, 2)}\n`);
204
+ } catch {
205
+ // Cache is a best-effort optimization; a write failure (read-only FS,
206
+ // missing parent dir) must never break the preflight itself.
207
+ }
208
+ }
209
+
210
+ function manifestPathForPlugin(pluginName, pkgRoot = process.cwd()) {
211
+ return resolve(pkgRoot, 'plugins', pluginName, 'dependencies.json');
189
212
  }
190
213
 
191
- async function main(argv) {
192
- const flags = parseFlags(argv.slice(2));
214
+ // Shared runnable for both the `maude preflight` command and the direct
215
+ // `node cli/lib/preflight.mjs` entry. `pkgRoot` is where `plugins/<x>/
216
+ // dependencies.json` lives (the maude package root) — resolved independently
217
+ // of the caller's cwd, so the command works from any target repo. (DDR-061:
218
+ // the marketplace install has no sibling cli/, so callers reach this through
219
+ // the on-PATH `maude` binary, which passes its own package root as pkgRoot.)
220
+ export async function runPreflight({ args, pkgRoot = process.cwd() }) {
221
+ const flags = parseFlags(args);
193
222
  if (!flags.plugin) {
194
- process.stderr.write('preflight.mjs: pass --plugin <design|flow>\n');
223
+ process.stderr.write('preflight: pass --plugin <design|flow>\n');
195
224
  process.exit(2);
196
225
  }
197
- const env = await checkAll(manifestPathForPlugin(flags.plugin));
226
+ const env = await checkAll(manifestPathForPlugin(flags.plugin, pkgRoot));
227
+ if (flags.cache) await writeCache(flags.cache, env);
198
228
  if (flags.mode === 'json') {
199
229
  process.stdout.write(`${JSON.stringify(env, null, 2)}\n`);
200
230
  } else if (flags.mode === 'shell-export') {
@@ -223,9 +253,10 @@ async function main(argv) {
223
253
  process.exit(env.summary.allHardPass ? 0 : 1);
224
254
  }
225
255
 
226
- // Entry guard — only run main() when invoked directly.
256
+ // Entry guard — only run when invoked directly (`node cli/lib/preflight.mjs`).
257
+ // The package root is the cwd here (preflight.sh `cd`s to it before exec).
227
258
  if (import.meta.url === `file://${process.argv[1]}`) {
228
- main(process.argv).catch((err) => {
259
+ runPreflight({ args: process.argv.slice(2), pkgRoot: process.cwd() }).catch((err) => {
229
260
  process.stderr.write(`preflight: ${err.message}\n`);
230
261
  process.exit(1);
231
262
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.22.2",
3
+ "version": "0.24.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,13 +41,13 @@
41
41
  "prepublishOnly": "bash scripts/check-version-parity.sh && bash plugins/design/dev-server/bin/check-runtime-bundles.sh"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@1agh/maude-darwin-arm64": "0.22.2",
45
- "@1agh/maude-darwin-x64": "0.22.2",
46
- "@1agh/maude-linux-arm64": "0.22.2",
47
- "@1agh/maude-linux-arm64-musl": "0.22.2",
48
- "@1agh/maude-linux-x64": "0.22.2",
49
- "@1agh/maude-linux-x64-musl": "0.22.2",
50
- "@1agh/maude-win32-x64": "0.22.2"
44
+ "@1agh/maude-darwin-arm64": "0.24.0",
45
+ "@1agh/maude-darwin-x64": "0.24.0",
46
+ "@1agh/maude-linux-arm64": "0.24.0",
47
+ "@1agh/maude-linux-arm64-musl": "0.24.0",
48
+ "@1agh/maude-linux-x64": "0.24.0",
49
+ "@1agh/maude-linux-x64-musl": "0.24.0",
50
+ "@1agh/maude-win32-x64": "0.24.0"
51
51
  },
52
52
  "files": [
53
53
  "cli",
@@ -41,8 +41,8 @@ const BANNER_CSS = `
41
41
  align-items: center;
42
42
  gap: 10px;
43
43
  background: var(--maude-hud-accent-tint, oklch(92% 0.040 55));
44
- color: var(--fg-0, #111);
45
- border: 1px solid var(--border-default, rgba(0,0,0,0.16));
44
+ color: var(--maude-chrome-fg-0, #111);
45
+ border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16));
46
46
  border-radius: var(--radius-md, 4px);
47
47
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
48
48
  font-family: var(--font-sans, system-ui, -apple-system, sans-serif);