@geraldmaron/construct 1.0.24 → 1.1.1
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/README.md +2 -0
- package/bin/construct +266 -13
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/audit-trail.mjs +77 -10
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +112 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/demo.mjs +245 -0
- package/lib/diagram.mjs +300 -0
- package/lib/doctor/index.mjs +1 -1
- package/lib/doctor/watchers/process-pressure.mjs +1 -1
- package/lib/doctor/watchers/service-health.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +85 -10
- package/lib/document-ingest.mjs +97 -7
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/embed/semantic.mjs +5 -2
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/handoffs/cleanup.mjs +1 -1
- package/lib/hooks/audit-trail.mjs +14 -28
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp-catalog.json +1 -1
- package/lib/model-router.mjs +52 -1
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/persona-sections.mjs +66 -0
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +14 -9
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/server/index.mjs +1 -1
- package/lib/server/langfuse-login.mjs +3 -3
- package/lib/service-manager.mjs +42 -4
- package/lib/session-store.mjs +1 -1
- package/lib/setup.mjs +58 -2
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/embeddings-local.mjs +6 -3
- package/lib/storage/file-lock.mjs +18 -11
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/package.json +4 -2
- package/personas/construct.md +12 -12
- package/platforms/capabilities.json +76 -0
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +277 -63
- package/skills/docs/init-project.md +1 -1
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/templates/docs/construct_guide.md +2 -2
- package/lib/ingest/chunker.mjs +0 -94
- package/lib/ingest/pipeline.mjs +0 -53
- package/lib/ingest/store.mjs +0 -82
- package/lib/mode-commands.mjs +0 -122
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/profiles/validate-custom.mjs +0 -114
- package/lib/services/telemetry-backend.mjs +0 -177
- package/lib/storage/fusion.mjs +0 -95
package/lib/config/schema.mjs
CHANGED
|
@@ -24,7 +24,7 @@ export const DEFAULT_PROFILE_ID = 'rnd';
|
|
|
24
24
|
|
|
25
25
|
export const SURFACES = ['claude', 'opencode', 'codex', 'copilot', 'vscode', 'cursor'];
|
|
26
26
|
|
|
27
|
-
export const INGEST_STRATEGIES = ['adapter', 'provider'];
|
|
27
|
+
export const INGEST_STRATEGIES = ['adapter', 'provider', 'docling-remote'];
|
|
28
28
|
export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
|
|
29
29
|
export const INGEST_ORCHESTRATIONS = ['prompt-only', 'orchestrated'];
|
|
30
30
|
|
package/lib/demo.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/demo.mjs — `construct demo` command, produces a reproducible terminal
|
|
3
|
+
* recording of a Construct workflow from a `.tape` script, rendering to
|
|
4
|
+
* GIF/MP4/WebM via VHS, degrading to asciinema, and finally to emitting the
|
|
5
|
+
* `.tape` source + install hint when no recorder is installed.
|
|
6
|
+
*
|
|
7
|
+
* Tool evaluation (2026-06), against: reproducible-from-code, headless CI
|
|
8
|
+
* render, distribution formats, license:
|
|
9
|
+
* - VHS (charm.sh, MIT): records a `.tape` script (declarative keystrokes +
|
|
10
|
+
* timing) to GIF/MP4/WebM headlessly via an embedded ttyd + ffmpeg. The
|
|
11
|
+
* `.tape` is deterministic and diffable — the source of truth lives in the
|
|
12
|
+
* repo, the artifact regenerates in CI. Chosen as PRIMARY: reproducible,
|
|
13
|
+
* embeddable in README/docs, multiple output formats.
|
|
14
|
+
* - asciinema (asciinema.org, GPL-3.0): records a terminal session to a
|
|
15
|
+
* `.cast` JSON. Reproducible and lightweight but interactive-capture by
|
|
16
|
+
* nature and GIF export needs the separate `agg` tool. Chosen as FALLBACK
|
|
17
|
+
* when VHS is absent; we drive it non-interactively via `asciinema rec -c`.
|
|
18
|
+
* - Playwright (dashboard): out of scope here — it is a dev-only browser
|
|
19
|
+
* driver for the web UI, not a terminal recorder, and lives with the
|
|
20
|
+
* dashboard tests rather than this command.
|
|
21
|
+
*
|
|
22
|
+
* Degradation contract: when neither vhs nor asciinema is on PATH the command
|
|
23
|
+
* still succeeds (exit 0) by writing the `.tape` SOURCE plus an install hint.
|
|
24
|
+
* No bundled binaries, no npm dependencies; recorders are detected at runtime,
|
|
25
|
+
* mirroring lib/runtime/whisper-bootstrap.mjs.
|
|
26
|
+
*
|
|
27
|
+
* Output: .cx/demos/<name>-<ts>.<ext> (recording) and the `.tape` source.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { join, resolve, dirname } from 'node:path';
|
|
32
|
+
|
|
33
|
+
const FORMATS = ['gif', 'mp4', 'webm'];
|
|
34
|
+
|
|
35
|
+
function timestamp() {
|
|
36
|
+
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function which(bin) {
|
|
40
|
+
const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' });
|
|
41
|
+
if (result.status !== 0) return null;
|
|
42
|
+
return result.stdout.trim().split('\n')[0] || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Recorder detection follows the optional-external-tool pattern: probe PATH,
|
|
46
|
+
// return a structured descriptor, never throw. VHS is preferred for its
|
|
47
|
+
// declarative `.tape` model and multi-format output; asciinema is the fallback.
|
|
48
|
+
|
|
49
|
+
export function locateRecorder() {
|
|
50
|
+
const vhs = which('vhs');
|
|
51
|
+
if (vhs) return { engine: 'vhs', binary: vhs };
|
|
52
|
+
const asciinema = which('asciinema');
|
|
53
|
+
if (asciinema) return { engine: 'asciinema', binary: asciinema };
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function installHint() {
|
|
58
|
+
if (process.platform === 'darwin') return 'brew install vhs (or: brew install asciinema)';
|
|
59
|
+
if (process.platform === 'linux') return 'See https://github.com/charmbracelet/vhs#installation (or: pip install asciinema)';
|
|
60
|
+
return 'See https://github.com/charmbracelet/vhs#installation';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Built-in workflow tapes. Each is a VHS `.tape`: declarative Type/Enter/Sleep
|
|
64
|
+
// directives that record a real walkthrough. Commands are illustrative and
|
|
65
|
+
// safe to replay; tapes are the source of truth, regenerated in CI.
|
|
66
|
+
|
|
67
|
+
const TAPES = {
|
|
68
|
+
quickstart: (out) => `# construct demo: quickstart — plan then build
|
|
69
|
+
Output ${out}
|
|
70
|
+
Set FontSize 18
|
|
71
|
+
Set Width 1100
|
|
72
|
+
Set Height 600
|
|
73
|
+
Set Theme "Dracula"
|
|
74
|
+
Set TypingSpeed 60ms
|
|
75
|
+
|
|
76
|
+
Type "construct plan 'add a rate limiter to the API'"
|
|
77
|
+
Sleep 500ms
|
|
78
|
+
Enter
|
|
79
|
+
Sleep 2s
|
|
80
|
+
|
|
81
|
+
Type "construct build"
|
|
82
|
+
Sleep 500ms
|
|
83
|
+
Enter
|
|
84
|
+
Sleep 2s
|
|
85
|
+
|
|
86
|
+
Type "construct ship status"
|
|
87
|
+
Sleep 500ms
|
|
88
|
+
Enter
|
|
89
|
+
Sleep 2s
|
|
90
|
+
`,
|
|
91
|
+
diagram: (out) => `# construct demo: diagram — render an architecture diagram
|
|
92
|
+
Output ${out}
|
|
93
|
+
Set FontSize 18
|
|
94
|
+
Set Width 1100
|
|
95
|
+
Set Height 600
|
|
96
|
+
Set Theme "Dracula"
|
|
97
|
+
Set TypingSpeed 60ms
|
|
98
|
+
|
|
99
|
+
Type "construct diagram 'web app: client -> api -> db'"
|
|
100
|
+
Sleep 500ms
|
|
101
|
+
Enter
|
|
102
|
+
Sleep 2s
|
|
103
|
+
`,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const TAPE_NAMES = Object.keys(TAPES);
|
|
107
|
+
|
|
108
|
+
export function generateTape(name, outputArtifact) {
|
|
109
|
+
const builder = TAPES[name];
|
|
110
|
+
if (!builder) return null;
|
|
111
|
+
return builder(outputArtifact);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// VHS reads the Output directive from the .tape, so the artifact path is baked
|
|
115
|
+
// into the source before rendering. The render is fully headless.
|
|
116
|
+
|
|
117
|
+
function renderWithVhs(binary, tapePath) {
|
|
118
|
+
return spawnSync(binary, [tapePath], { encoding: 'utf8', timeout: 180_000 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// asciinema can't replay a VHS .tape, so for the fallback we extract the
|
|
122
|
+
// `Type`/`Enter` lines into a single shell command string and capture it
|
|
123
|
+
// non-interactively into a .cast file. The .tape source is still written so
|
|
124
|
+
// the canonical, VHS-renderable form is preserved.
|
|
125
|
+
|
|
126
|
+
function tapeToShellCommand(tapeSource) {
|
|
127
|
+
const commands = [];
|
|
128
|
+
let pending = '';
|
|
129
|
+
for (const line of tapeSource.split('\n')) {
|
|
130
|
+
const typeMatch = line.match(/^Type\s+"(.*)"\s*$/);
|
|
131
|
+
if (typeMatch) { pending = typeMatch[1]; continue; }
|
|
132
|
+
if (/^Enter\s*$/.test(line) && pending) { commands.push(pending); pending = ''; }
|
|
133
|
+
}
|
|
134
|
+
return commands.join(' && ');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderWithAsciinema(binary, tapeSource, outPath) {
|
|
138
|
+
const command = tapeToShellCommand(tapeSource) || 'echo "construct demo"';
|
|
139
|
+
return spawnSync(binary, ['rec', '--overwrite', '-c', command, outPath], { encoding: 'utf8', timeout: 180_000 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function printHelp() {
|
|
143
|
+
console.log(`Usage: construct demo <name> [options]
|
|
144
|
+
|
|
145
|
+
Produces a reproducible terminal recording of a Construct workflow from a
|
|
146
|
+
.tape script. Renders to GIF/MP4/WebM via VHS (primary) or a .cast via
|
|
147
|
+
asciinema (fallback). When neither recorder is installed, writes the .tape
|
|
148
|
+
SOURCE plus an install hint and exits 0.
|
|
149
|
+
|
|
150
|
+
Tapes:
|
|
151
|
+
${TAPE_NAMES.join('\n ')}
|
|
152
|
+
|
|
153
|
+
Options:
|
|
154
|
+
--format <f> gif (default) | mp4 | webm (VHS only)
|
|
155
|
+
--out <path> Output path (default: .cx/demos/<name>-<ts>.<ext>)
|
|
156
|
+
--source-only Always write the .tape source; skip recording
|
|
157
|
+
-h, --help Show this message
|
|
158
|
+
|
|
159
|
+
Recorders (detected at runtime, never bundled):
|
|
160
|
+
VHS ${process.platform === 'darwin' ? 'brew install vhs' : 'https://github.com/charmbracelet/vhs#installation'}
|
|
161
|
+
asciinema ${process.platform === 'darwin' ? 'brew install asciinema' : 'pip install asciinema'}
|
|
162
|
+
|
|
163
|
+
Examples:
|
|
164
|
+
construct demo quickstart
|
|
165
|
+
construct demo diagram --format mp4
|
|
166
|
+
construct demo quickstart --source-only
|
|
167
|
+
`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseArgs(argv) {
|
|
171
|
+
const options = { format: 'gif', out: null, sourceOnly: false, name: null };
|
|
172
|
+
const positional = [];
|
|
173
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
174
|
+
const arg = argv[i];
|
|
175
|
+
if (arg === '--help' || arg === '-h') { options.help = true; continue; }
|
|
176
|
+
if (arg === '--source-only') { options.sourceOnly = true; continue; }
|
|
177
|
+
if (arg === '--format') { options.format = argv[++i]; continue; }
|
|
178
|
+
if (arg.startsWith('--format=')) { options.format = arg.slice(9); continue; }
|
|
179
|
+
if (arg === '--out') { options.out = argv[++i]; continue; }
|
|
180
|
+
if (arg.startsWith('--out=')) { options.out = arg.slice(6); continue; }
|
|
181
|
+
positional.push(arg);
|
|
182
|
+
}
|
|
183
|
+
options.name = positional[0] || null;
|
|
184
|
+
return options;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function runDemoCli(argv = [], { cwd = process.cwd() } = {}) {
|
|
188
|
+
const options = parseArgs(argv);
|
|
189
|
+
if (options.help || !options.name) {
|
|
190
|
+
printHelp();
|
|
191
|
+
if (!options.help && !options.name) process.exit(1);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!TAPE_NAMES.includes(options.name)) {
|
|
195
|
+
console.error(`Unknown demo: ${options.name}. Valid: ${TAPE_NAMES.join(', ')}`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
if (!FORMATS.includes(options.format)) {
|
|
199
|
+
console.error(`Unknown format: ${options.format}. Valid: ${FORMATS.join(', ')}`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const recorder = options.sourceOnly ? null : locateRecorder();
|
|
204
|
+
|
|
205
|
+
const dir = options.out ? dirname(resolve(cwd, options.out)) : join(cwd, '.cx', 'demos');
|
|
206
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
207
|
+
|
|
208
|
+
const baseName = options.out
|
|
209
|
+
? resolve(cwd, options.out).replace(/\.[^./]+$/, '')
|
|
210
|
+
: join(dir, `${options.name}-${timestamp()}`);
|
|
211
|
+
|
|
212
|
+
// asciinema emits .cast; VHS emits the requested image format. The Output
|
|
213
|
+
// directive baked into the tape must match the artifact VHS will write.
|
|
214
|
+
|
|
215
|
+
const artifactExt = recorder?.engine === 'asciinema' ? 'cast' : options.format;
|
|
216
|
+
const artifactPath = `${baseName}.${artifactExt}`;
|
|
217
|
+
const tapeSource = generateTape(options.name, artifactPath);
|
|
218
|
+
const tapePath = `${baseName}.tape`;
|
|
219
|
+
writeFileSync(tapePath, tapeSource, 'utf8');
|
|
220
|
+
|
|
221
|
+
if (!recorder) {
|
|
222
|
+
console.log(`Demo tape (${options.name}) written to:`);
|
|
223
|
+
console.log(` ${tapePath}`);
|
|
224
|
+
console.log(`\nNo terminal recorder found. To produce a recording, install one:`);
|
|
225
|
+
console.log(` ${installHint()}`);
|
|
226
|
+
console.log(`\nThen: vhs "${tapePath}"`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const render = recorder.engine === 'vhs'
|
|
231
|
+
? renderWithVhs(recorder.binary, tapePath)
|
|
232
|
+
: renderWithAsciinema(recorder.binary, tapeSource, artifactPath);
|
|
233
|
+
|
|
234
|
+
if (render.status === 0 && existsSync(artifactPath)) {
|
|
235
|
+
console.log(`Demo (${options.name}) recorded via ${recorder.engine} to:`);
|
|
236
|
+
console.log(` ${artifactPath}`);
|
|
237
|
+
console.log(`Tape: ${tapePath}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log(`Demo tape written to:`);
|
|
242
|
+
console.log(` ${tapePath}`);
|
|
243
|
+
console.log(`\nRecorder ${recorder.engine} failed (exit ${render.status}); tape preserved.`);
|
|
244
|
+
if (render.stderr) console.log(` ${render.stderr.trim().split('\n').slice(0, 3).join('\n ')}`);
|
|
245
|
+
}
|
package/lib/diagram.mjs
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/diagram.mjs — `construct diagram` command, generates code-driven
|
|
3
|
+
* architecture/flow diagrams from a natural-language description and renders
|
|
4
|
+
* them to SVG/PNG via an external diagram binary, degrading to source-only
|
|
5
|
+
* output when no renderer is installed.
|
|
6
|
+
*
|
|
7
|
+
* Tool evaluation (2026-06), against: visual distinctiveness, reproducible
|
|
8
|
+
* from code, headless Node/CI SVG+PNG render, theming, license:
|
|
9
|
+
* - D2 (terrastruct, MPL-2.0): single self-contained Go binary, distinctive
|
|
10
|
+
* sketch look plus first-class themes, headless `d2 in.d2 out.svg|png`,
|
|
11
|
+
* deterministic from a text file. Chosen as PRIMARY for visual
|
|
12
|
+
* distinctiveness and one-binary install.
|
|
13
|
+
* - Graphviz `dot` (EPL-1.0): ubiquitous, almost always already present on
|
|
14
|
+
* CI images and dev machines, headless `dot -Tsvg|-Tpng`. Chosen as
|
|
15
|
+
* FALLBACK for reach when D2 is absent.
|
|
16
|
+
* - Mermaid: renders natively in many viewers but headless image export
|
|
17
|
+
* needs mermaid-cli, which pulls a headless-Chromium npm tree — disallowed
|
|
18
|
+
* by ADR-0001 (zero-npm-core). Used only as the SOURCE we emit when no
|
|
19
|
+
* binary is present, reusing lib/wireframe.mjs's Mermaid generators so the
|
|
20
|
+
* description still yields a viewable diagram in GitHub/Notion/OpenCode.
|
|
21
|
+
*
|
|
22
|
+
* Degradation contract: when neither D2 nor dot is on PATH the command still
|
|
23
|
+
* succeeds (exit 0) by writing the diagram SOURCE (.d2 by default, or .mmd
|
|
24
|
+
* Mermaid for diagram kinds wireframe.mjs already models) plus an install
|
|
25
|
+
* hint. No bundled binaries, no npm dependencies; renderers are detected at
|
|
26
|
+
* runtime, mirroring lib/runtime/whisper-bootstrap.mjs.
|
|
27
|
+
*
|
|
28
|
+
* Output: .cx/diagrams/<slug>-<ts>.<ext>.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { join, resolve, dirname } from 'node:path';
|
|
33
|
+
|
|
34
|
+
import { generateWireframe } from './wireframe.mjs';
|
|
35
|
+
|
|
36
|
+
const TYPES = ['architecture', 'flow', 'sequence', 'state', 'er', 'class'];
|
|
37
|
+
const FORMATS = ['svg', 'png'];
|
|
38
|
+
|
|
39
|
+
function slugify(text) {
|
|
40
|
+
return String(text || '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
+
.replace(/^-+|-+$/g, '')
|
|
44
|
+
.slice(0, 60) || 'diagram';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function timestamp() {
|
|
48
|
+
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function which(bin) {
|
|
52
|
+
const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' });
|
|
53
|
+
if (result.status !== 0) return null;
|
|
54
|
+
return result.stdout.trim().split('\n')[0] || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Renderer detection follows the optional-external-tool pattern: probe PATH,
|
|
58
|
+
// return a structured descriptor, never throw. D2 is preferred for its
|
|
59
|
+
// distinctive look; dot is the ubiquitous fallback.
|
|
60
|
+
|
|
61
|
+
export function locateRenderer() {
|
|
62
|
+
const d2 = which('d2');
|
|
63
|
+
if (d2) return { engine: 'd2', binary: d2, sourceExt: 'd2' };
|
|
64
|
+
const dot = which('dot');
|
|
65
|
+
if (dot) return { engine: 'dot', binary: dot, sourceExt: 'dot' };
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function installHint() {
|
|
70
|
+
if (process.platform === 'darwin') return 'brew install d2 (or: brew install graphviz)';
|
|
71
|
+
if (process.platform === 'linux') return 'curl -fsSL https://d2lang.com/install.sh | sh (or install graphviz via your distro)';
|
|
72
|
+
return 'See https://d2lang.com/tour/install or https://graphviz.org/download/';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function inferType(description, explicit) {
|
|
76
|
+
if (explicit) return explicit;
|
|
77
|
+
const text = String(description || '').toLowerCase();
|
|
78
|
+
if (/\b(sequence|handshake|request|response|api call)\b/.test(text)) return 'sequence';
|
|
79
|
+
if (/\b(state|status|transition|machine|lifecycle)\b/.test(text)) return 'state';
|
|
80
|
+
if (/\b(entity|schema|database|table|relationship|foreign key)\b/.test(text)) return 'er';
|
|
81
|
+
if (/\b(class|inheritance|interface)\b/.test(text)) return 'class';
|
|
82
|
+
if (/\b(flow|funnel|step|decision|process)\b/.test(text)) return 'flow';
|
|
83
|
+
return 'architecture';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Parse `a -> b -> c` (or `a → b → c`) chains out of the description so a
|
|
87
|
+
// terse architecture prompt produces a real edge graph rather than a single
|
|
88
|
+
// node. Falls back to treating the whole description as one node.
|
|
89
|
+
|
|
90
|
+
function parseNodes(description) {
|
|
91
|
+
const text = String(description || '');
|
|
92
|
+
const arrowSplit = text.split(/\s*(?:->|→|=>)\s*/).map((s) => s.trim()).filter(Boolean);
|
|
93
|
+
if (arrowSplit.length >= 2) {
|
|
94
|
+
const stripped = arrowSplit.map((s) => s.replace(/^[a-z0-9\s]*:\s*/i, '').trim() || s);
|
|
95
|
+
return stripped.map((label) => label.split(/[,/]/)[0].trim()).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function nodeId(label, index) {
|
|
101
|
+
const id = String(label).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
102
|
+
return id || `n${index}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function d2Source(description, nodes) {
|
|
106
|
+
const header = `# ${description}\n# D2 source — render: d2 this.d2 out.svg (https://d2lang.com)\n`;
|
|
107
|
+
if (nodes.length >= 2) {
|
|
108
|
+
const lines = nodes.map((label, i) => `${nodeId(label, i)}: ${label}`);
|
|
109
|
+
for (let i = 0; i < nodes.length - 1; i += 1) {
|
|
110
|
+
lines.push(`${nodeId(nodes[i], i)} -> ${nodeId(nodes[i + 1], i + 1)}`);
|
|
111
|
+
}
|
|
112
|
+
return `${header}${lines.join('\n')}\n`;
|
|
113
|
+
}
|
|
114
|
+
return `${header}node: ${description || 'Component'}\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function dotSource(description, nodes) {
|
|
118
|
+
const header = `// ${description}\n// Graphviz DOT source — render: dot -Tsvg this.dot -o out.svg\n`;
|
|
119
|
+
const body = [];
|
|
120
|
+
if (nodes.length >= 2) {
|
|
121
|
+
nodes.forEach((label, i) => body.push(` ${nodeId(label, i)} [label="${label}"];`));
|
|
122
|
+
for (let i = 0; i < nodes.length - 1; i += 1) {
|
|
123
|
+
body.push(` ${nodeId(nodes[i], i)} -> ${nodeId(nodes[i + 1], i + 1)};`);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
body.push(` node [label="${description || 'Component'}"];`);
|
|
127
|
+
}
|
|
128
|
+
return `${header}digraph G {\n rankdir=LR;\n${body.join('\n')}\n}\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// For diagram kinds wireframe.mjs already models (flow/sequence/state/er) we
|
|
132
|
+
// reuse its Mermaid generators when emitting source-only output, so the
|
|
133
|
+
// description maps to a richer diagram than a bare D2 chain.
|
|
134
|
+
|
|
135
|
+
const WIREFRAME_KINDS = { flow: 'flow', sequence: 'sequence', state: 'state', er: 'er' };
|
|
136
|
+
|
|
137
|
+
export function generateDiagram({ description = '', type = null, theme = null } = {}) {
|
|
138
|
+
const kind = inferType(description, type);
|
|
139
|
+
const nodes = parseNodes(description);
|
|
140
|
+
|
|
141
|
+
let source;
|
|
142
|
+
let engineHint;
|
|
143
|
+
if (nodes.length >= 2 || kind === 'architecture' || kind === 'class') {
|
|
144
|
+
source = d2Source(description, nodes);
|
|
145
|
+
engineHint = 'd2';
|
|
146
|
+
} else {
|
|
147
|
+
const wf = generateWireframe({ description, type: WIREFRAME_KINDS[kind] || 'flow' });
|
|
148
|
+
source = wf.content;
|
|
149
|
+
engineHint = 'mermaid';
|
|
150
|
+
}
|
|
151
|
+
return { kind, nodes, source, engineHint, theme };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Render via the detected binary. D2 themes map to numeric ids; an invalid
|
|
155
|
+
// theme name is ignored rather than failing the render. Returns the render
|
|
156
|
+
// result so the caller can fall back to source-only on any non-zero exit.
|
|
157
|
+
|
|
158
|
+
function renderWithD2(binary, sourcePath, outPath, { format, theme }) {
|
|
159
|
+
const args = [];
|
|
160
|
+
if (theme) {
|
|
161
|
+
const themeId = D2_THEMES[theme.toLowerCase()];
|
|
162
|
+
if (themeId != null) args.push('--theme', String(themeId));
|
|
163
|
+
}
|
|
164
|
+
args.push(sourcePath, outPath);
|
|
165
|
+
return spawnSync(binary, args, { encoding: 'utf8', timeout: 60_000 });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const D2_THEMES = {
|
|
169
|
+
neutral: 0,
|
|
170
|
+
'neutral-grey': 1,
|
|
171
|
+
'flagship-terrastruct': 3,
|
|
172
|
+
'cool-classics': 4,
|
|
173
|
+
'mixed-berry-blue': 5,
|
|
174
|
+
'grape-soda': 6,
|
|
175
|
+
aubergine: 7,
|
|
176
|
+
'colorblind-clear': 8,
|
|
177
|
+
'vanilla-nitro-cola': 100,
|
|
178
|
+
'orange-creamsicle': 101,
|
|
179
|
+
sketch: 200,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
function renderWithDot(binary, sourcePath, outPath, { format }) {
|
|
183
|
+
return spawnSync(binary, [`-T${format}`, sourcePath, '-o', outPath], { encoding: 'utf8', timeout: 60_000 });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function printHelp() {
|
|
187
|
+
console.log(`Usage: construct diagram <description> [options]
|
|
188
|
+
|
|
189
|
+
Generates a code-driven diagram from a description and renders it to SVG/PNG
|
|
190
|
+
via D2 (primary) or Graphviz dot (fallback). When neither renderer is
|
|
191
|
+
installed, writes the diagram SOURCE plus an install hint and exits 0.
|
|
192
|
+
|
|
193
|
+
Options:
|
|
194
|
+
--type <t> architecture (default) | flow | sequence | state | er | class
|
|
195
|
+
--format <f> svg (default) | png
|
|
196
|
+
--theme <name> D2 theme name (e.g. neutral, sketch, cool-classics)
|
|
197
|
+
--out <path> Output path (default: .cx/diagrams/<slug>-<ts>.<ext>)
|
|
198
|
+
--source-only Always write the source file; skip rendering
|
|
199
|
+
-h, --help Show this message
|
|
200
|
+
|
|
201
|
+
Renderers (detected at runtime, never bundled):
|
|
202
|
+
D2 ${process.platform === 'darwin' ? 'brew install d2' : 'https://d2lang.com/tour/install'}
|
|
203
|
+
dot ${process.platform === 'darwin' ? 'brew install graphviz' : 'https://graphviz.org/download/'}
|
|
204
|
+
|
|
205
|
+
Examples:
|
|
206
|
+
construct diagram "web app: client -> api -> db"
|
|
207
|
+
construct diagram "auth flow" --type flow
|
|
208
|
+
construct diagram "client -> api -> db" --format png --theme sketch
|
|
209
|
+
`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseArgs(argv) {
|
|
213
|
+
const options = { type: null, format: 'svg', theme: null, out: null, sourceOnly: false, description: [] };
|
|
214
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
215
|
+
const arg = argv[i];
|
|
216
|
+
if (arg === '--help' || arg === '-h') { options.help = true; continue; }
|
|
217
|
+
if (arg === '--source-only') { options.sourceOnly = true; continue; }
|
|
218
|
+
if (arg === '--type') { options.type = argv[++i]; continue; }
|
|
219
|
+
if (arg.startsWith('--type=')) { options.type = arg.slice(7); continue; }
|
|
220
|
+
if (arg === '--format') { options.format = argv[++i]; continue; }
|
|
221
|
+
if (arg.startsWith('--format=')) { options.format = arg.slice(9); continue; }
|
|
222
|
+
if (arg === '--theme') { options.theme = argv[++i]; continue; }
|
|
223
|
+
if (arg.startsWith('--theme=')) { options.theme = arg.slice(8); continue; }
|
|
224
|
+
if (arg === '--out') { options.out = argv[++i]; continue; }
|
|
225
|
+
if (arg.startsWith('--out=')) { options.out = arg.slice(6); continue; }
|
|
226
|
+
options.description.push(arg);
|
|
227
|
+
}
|
|
228
|
+
options.description = options.description.join(' ').trim();
|
|
229
|
+
return options;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function runDiagramCli(argv = [], { cwd = process.cwd() } = {}) {
|
|
233
|
+
const options = parseArgs(argv);
|
|
234
|
+
if (options.help || !options.description) {
|
|
235
|
+
printHelp();
|
|
236
|
+
if (!options.help && !options.description) process.exit(1);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (options.type && !TYPES.includes(options.type)) {
|
|
240
|
+
console.error(`Unknown type: ${options.type}. Valid: ${TYPES.join(', ')}`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
if (!FORMATS.includes(options.format)) {
|
|
244
|
+
console.error(`Unknown format: ${options.format}. Valid: ${FORMATS.join(', ')}`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const result = generateDiagram({ description: options.description, type: options.type, theme: options.theme });
|
|
249
|
+
const renderer = options.sourceOnly ? null : locateRenderer();
|
|
250
|
+
|
|
251
|
+
const slug = slugify(options.description);
|
|
252
|
+
const dir = options.out ? dirname(resolve(cwd, options.out)) : join(cwd, '.cx', 'diagrams');
|
|
253
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
254
|
+
|
|
255
|
+
// Source is always written. When the source is Mermaid (reused wireframe
|
|
256
|
+
// output) the .d2/.dot renderers can't consume it, so we keep the source
|
|
257
|
+
// extension honest and skip the binary render in that case.
|
|
258
|
+
|
|
259
|
+
const renderableSource = result.engineHint !== 'mermaid';
|
|
260
|
+
const sourceExt = result.engineHint === 'mermaid' ? 'md' : (renderer ? renderer.sourceExt : 'd2');
|
|
261
|
+
const baseName = options.out
|
|
262
|
+
? resolve(cwd, options.out).replace(/\.[^./]+$/, '')
|
|
263
|
+
: join(dir, `${slug}-${timestamp()}`);
|
|
264
|
+
const sourcePath = `${baseName}.${sourceExt}`;
|
|
265
|
+
|
|
266
|
+
let source = result.source;
|
|
267
|
+
if (renderer && renderableSource) {
|
|
268
|
+
source = renderer.engine === 'dot' ? dotSource(options.description, result.nodes) : d2Source(options.description, result.nodes);
|
|
269
|
+
}
|
|
270
|
+
writeFileSync(sourcePath, source, 'utf8');
|
|
271
|
+
|
|
272
|
+
if (!renderer || !renderableSource) {
|
|
273
|
+
console.log(`Diagram source (${result.kind}, ${result.engineHint}) written to:`);
|
|
274
|
+
console.log(` ${sourcePath}`);
|
|
275
|
+
if (!renderer) {
|
|
276
|
+
console.log(`\nNo diagram renderer found. To render SVG/PNG, install one:`);
|
|
277
|
+
console.log(` ${installHint()}`);
|
|
278
|
+
} else {
|
|
279
|
+
console.log(`\nThis diagram kind renders as Mermaid; paste into any Mermaid-aware viewer (GitHub, Notion, OpenCode).`);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const outPath = `${baseName}.${options.format}`;
|
|
285
|
+
const render = renderer.engine === 'd2'
|
|
286
|
+
? renderWithD2(renderer.binary, sourcePath, outPath, { format: options.format, theme: options.theme })
|
|
287
|
+
: renderWithDot(renderer.binary, sourcePath, outPath, { format: options.format });
|
|
288
|
+
|
|
289
|
+
if (render.status === 0 && existsSync(outPath)) {
|
|
290
|
+
console.log(`Diagram (${result.kind}) rendered via ${renderer.engine} to:`);
|
|
291
|
+
console.log(` ${outPath}`);
|
|
292
|
+
console.log(`Source: ${sourcePath}`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
console.log(`Diagram source written to:`);
|
|
297
|
+
console.log(` ${sourcePath}`);
|
|
298
|
+
console.log(`\nRenderer ${renderer.engine} failed (exit ${render.status}); source preserved.`);
|
|
299
|
+
if (render.stderr) console.log(` ${render.stderr.trim().split('\n').slice(0, 3).join('\n ')}`);
|
|
300
|
+
}
|
package/lib/doctor/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/doctor/index.mjs — `construct-doctor` daemon main loop.
|
|
3
3
|
*
|
|
4
|
-
* Long-running Node process spawned by `construct
|
|
4
|
+
* Long-running Node process spawned by `construct dev`. Holds the L0 watchers
|
|
5
5
|
* for resource pressure, service health, disk + log rotation, and cost. Each
|
|
6
6
|
* watcher ticks on its own interval; the loop is bounded so a slow watcher
|
|
7
7
|
* never starves the others. Termination via SIGTERM/SIGINT triggers a clean
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* lib/doctor/watchers/process-pressure.mjs — continuous process pressure guard.
|
|
3
3
|
*
|
|
4
4
|
* Runs the runtime-pressure cleanup on a recurring tick so stale Construct
|
|
5
|
-
* helpers don't accumulate between `construct
|
|
5
|
+
* helpers don't accumulate between `construct dev` invocations. Any killed
|
|
6
6
|
* process is recorded; if the same service-class has been killed >=2 times
|
|
7
7
|
* in the last hour, escalates so cx-sre can investigate the underlying churn.
|
|
8
8
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/doctor/watchers/service-health.mjs — continuous service probes.
|
|
3
3
|
*
|
|
4
|
-
* Probes the services `construct
|
|
4
|
+
* Probes the services `construct dev` starts (postgres, dashboard,
|
|
5
5
|
* cm, opencode). On 2 consecutive probe failures, attempts a restart of
|
|
6
6
|
* docker-backed services (postgres). On 2 consecutive failed
|
|
7
7
|
* restarts, escalates `service.down` for cx-sre. Non-docker services log +
|
|
@@ -21,10 +21,11 @@ const SIDECAR_SCRIPT = path.resolve(path.dirname(fileURLToPath(import.meta.url))
|
|
|
21
21
|
const REQUEST_TIMEOUT_MS = 300_000;
|
|
22
22
|
|
|
23
23
|
let activeSidecar = null;
|
|
24
|
+
let sidecarStarting = null;
|
|
24
25
|
let requestSeq = 0;
|
|
25
26
|
|
|
26
|
-
function spawnSidecar({ runtimeDir } = {}) {
|
|
27
|
-
const { pythonBin } = ensureDoclingVenv({ runtimeDir });
|
|
27
|
+
async function spawnSidecar({ runtimeDir } = {}) {
|
|
28
|
+
const { pythonBin } = await ensureDoclingVenv({ runtimeDir });
|
|
28
29
|
const child = spawn(pythonBin, [SIDECAR_SCRIPT], {
|
|
29
30
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
31
|
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
@@ -89,14 +90,23 @@ function spawnSidecar({ runtimeDir } = {}) {
|
|
|
89
90
|
return sidecar;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
// Provisioning is now async, so two concurrent extracts could both spawn a
|
|
94
|
+
// sidecar. Cache the in-flight start promise so the first call wins and the rest
|
|
95
|
+
// await it; once resolved, the running sidecar is reused directly.
|
|
96
|
+
|
|
97
|
+
async function getSidecar() {
|
|
93
98
|
if (activeSidecar && activeSidecar.child.exitCode === null && !activeSidecar.child.killed) return activeSidecar;
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
if (!sidecarStarting) {
|
|
100
|
+
sidecarStarting = spawnSidecar().then(
|
|
101
|
+
(s) => { activeSidecar = s; sidecarStarting = null; return s; },
|
|
102
|
+
(e) => { sidecarStarting = null; throw e; },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return sidecarStarting;
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
export async function extractViaDocling(filePath) {
|
|
99
|
-
const sidecar = getSidecar();
|
|
109
|
+
const sidecar = await getSidecar();
|
|
100
110
|
const result = await sidecar.send('extract', { path: path.resolve(filePath) });
|
|
101
111
|
return result;
|
|
102
112
|
}
|
|
@@ -36,10 +36,40 @@ except ImportError as exc:
|
|
|
36
36
|
_converter = None
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
# By default docling emits a bare `<!-- image -->` marker and discards pixel data,
|
|
40
|
+
# so embedded figures are lost. Enable picture-image generation on the PDF
|
|
41
|
+
# pipeline and export markdown with images embedded as base64 data URIs; the Node
|
|
42
|
+
# side externalizes those into an assets/ directory. The configuration is wrapped
|
|
43
|
+
# so a docling API change degrades to the default converter instead of crashing.
|
|
44
|
+
|
|
45
|
+
def build_converter():
|
|
46
|
+
try:
|
|
47
|
+
from docling.datamodel.base_models import InputFormat
|
|
48
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
49
|
+
from docling.document_converter import PdfFormatOption
|
|
50
|
+
|
|
51
|
+
pdf_options = PdfPipelineOptions()
|
|
52
|
+
pdf_options.generate_picture_images = True
|
|
53
|
+
pdf_options.images_scale = 2.0
|
|
54
|
+
return DocumentConverter(
|
|
55
|
+
format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pdf_options)}
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
return DocumentConverter()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def export_markdown(doc):
|
|
62
|
+
try:
|
|
63
|
+
from docling_core.types.doc import ImageRefMode
|
|
64
|
+
return doc.export_to_markdown(image_mode=ImageRefMode.EMBEDDED)
|
|
65
|
+
except Exception:
|
|
66
|
+
return doc.export_to_markdown()
|
|
67
|
+
|
|
68
|
+
|
|
39
69
|
def get_converter():
|
|
40
70
|
global _converter
|
|
41
71
|
if _converter is None:
|
|
42
|
-
_converter =
|
|
72
|
+
_converter = build_converter()
|
|
43
73
|
return _converter
|
|
44
74
|
|
|
45
75
|
|
|
@@ -53,7 +83,7 @@ def extract(params):
|
|
|
53
83
|
|
|
54
84
|
result = get_converter().convert(str(path))
|
|
55
85
|
doc = result.document
|
|
56
|
-
markdown = doc
|
|
86
|
+
markdown = export_markdown(doc)
|
|
57
87
|
|
|
58
88
|
metadata = {
|
|
59
89
|
"format": result.input.format.value if hasattr(result.input, "format") else None,
|