@geraldmaron/construct 1.5.2 → 1.5.3
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 +4 -1
- package/bin/construct +18 -2
- package/lib/adapters-sync.mjs +1 -1
- package/lib/auto-docs.mjs +5 -1
- package/lib/cli-commands.mjs +7 -2
- package/lib/decisions/golden.mjs +17 -18
- package/lib/review-pr.mjs +102 -0
- package/package.json +1 -5
- package/scripts/sync-specialists.mjs +12 -3
package/README.md
CHANGED
|
@@ -22,6 +22,9 @@ Install the CLI (once per machine):
|
|
|
22
22
|
npm install -g @geraldmaron/construct
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
> [!NOTE]
|
|
26
|
+
> npm may print deprecation warnings for `boolean` and `node-domexception` during install. Both are transitive dependencies of upstream packages (`@huggingface/transformers` → `onnxruntime-node` → `global-agent` → `boolean`; `@lancedb/lancedb` → `openai@4.29.2` → `formdata-node` → `node-domexception`) and are harmless. They cannot be silenced from this package — `@lancedb/lancedb` pins `openai@4.29.2` exactly, and npm ignores a package's own `overrides` on end-user installs.
|
|
27
|
+
|
|
25
28
|
Bootstrap local services (once per machine, opt-in to machine-scope writes):
|
|
26
29
|
|
|
27
30
|
```bash
|
|
@@ -243,7 +246,7 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
|
|
|
243
246
|
| `construct improvement` | Governed improvement loop — review, approve, and record apply/rollback for proposals |
|
|
244
247
|
| `construct llm-judge` | Run LLM-as-a-judge evaluations on unscored traces for continuous quality feedback |
|
|
245
248
|
| `construct optimize` | Prompt optimization using telemetry trace quality scores |
|
|
246
|
-
| `construct review` |
|
|
249
|
+
| `construct review` | Agent performance review from telemetry (run\|legacy), or a deterministic PR-diff review for CI (pr) |
|
|
247
250
|
| `construct telemetry` | Query telemetry traces and latency data |
|
|
248
251
|
| `construct telemetry-backfill` | Backfill sparse traces with observations (trace backend) |
|
|
249
252
|
| `construct telemetry-setup` | Configure telemetry backend credentials and trace export (OTLP or Langfuse-compatible) |
|
package/bin/construct
CHANGED
|
@@ -1886,10 +1886,10 @@ async function cmdMigrate(args) {
|
|
|
1886
1886
|
}
|
|
1887
1887
|
|
|
1888
1888
|
async function cmdReview(args) {
|
|
1889
|
-
fs.mkdirSync(path.join(doctorRoot(), 'performance-reviews'), { recursive: true });
|
|
1890
1889
|
const sub = args[0];
|
|
1891
1890
|
|
|
1892
1891
|
if (!sub || sub === 'run') {
|
|
1892
|
+
fs.mkdirSync(path.join(doctorRoot(), 'performance-reviews'), { recursive: true });
|
|
1893
1893
|
const { runGenerator } = await import('../lib/performance/generate.mjs');
|
|
1894
1894
|
const result = await runGenerator({ env: process.env });
|
|
1895
1895
|
info(`Wrote ${result.filename} · ${result.agentCount} agent${result.agentCount === 1 ? '' : 's'}`);
|
|
@@ -1898,6 +1898,7 @@ async function cmdReview(args) {
|
|
|
1898
1898
|
}
|
|
1899
1899
|
|
|
1900
1900
|
if (sub === 'legacy') {
|
|
1901
|
+
fs.mkdirSync(path.join(doctorRoot(), 'performance-reviews'), { recursive: true });
|
|
1901
1902
|
if (!ENV.CONSTRUCT_TELEMETRY_PUBLIC_KEY || !ENV.CONSTRUCT_TELEMETRY_SECRET_KEY) {
|
|
1902
1903
|
errorln('Error: CONSTRUCT_TELEMETRY_PUBLIC_KEY and CONSTRUCT_TELEMETRY_SECRET_KEY must be set.');
|
|
1903
1904
|
errorln('Configure telemetry credentials in ~/.construct/config.env or .env.');
|
|
@@ -1907,7 +1908,22 @@ async function cmdReview(args) {
|
|
|
1907
1908
|
return;
|
|
1908
1909
|
}
|
|
1909
1910
|
|
|
1910
|
-
|
|
1911
|
+
if (sub === 'pr') {
|
|
1912
|
+
const { runReviewPrCli } = await import('../lib/review-pr.mjs');
|
|
1913
|
+
const { exitCode, error, result, json, outputPath } = runReviewPrCli(args.slice(1));
|
|
1914
|
+
if (error) {
|
|
1915
|
+
errorln(error);
|
|
1916
|
+
process.exit(exitCode);
|
|
1917
|
+
}
|
|
1918
|
+
if (outputPath) {
|
|
1919
|
+
info(`Wrote ${outputPath} · ${result.findings.length} finding${result.findings.length === 1 ? '' : 's'}`);
|
|
1920
|
+
} else {
|
|
1921
|
+
println(json);
|
|
1922
|
+
}
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
errorln('Usage: construct review [run|legacy|pr --base=<ref> [--output=<file>]]');
|
|
1911
1927
|
process.exit(1);
|
|
1912
1928
|
}
|
|
1913
1929
|
|
package/lib/adapters-sync.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const HOST_ID_MAP = {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
export function resolveAdapterHosts({ forceAll = false, extra = [] } = {}) {
|
|
30
|
-
if (forceAll) return ['claude', 'opencode', 'codex', 'vscode', 'cursor'];
|
|
30
|
+
if (forceAll) return ['claude', 'opencode', 'codex', 'vscode', 'cursor', 'copilot'];
|
|
31
31
|
const hosts = new Set(extra);
|
|
32
32
|
for (const entry of detectHostCapabilities()) {
|
|
33
33
|
if (entry.availability !== 'installed') continue;
|
package/lib/auto-docs.mjs
CHANGED
|
@@ -114,9 +114,13 @@ const DIR_DESCRIPTIONS = {
|
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
function buildStructureSection(rootDir) {
|
|
117
|
+
// git stderr must be discarded: from the published package rootDir is not a
|
|
118
|
+
// repository, and execSync's default stdio prints "fatal: not a git repository"
|
|
119
|
+
// to the user's terminal before the catch fires (doctor and sync both hit this).
|
|
120
|
+
|
|
117
121
|
let trackedDirs;
|
|
118
122
|
try {
|
|
119
|
-
const out = execSync('git ls-tree --name-only HEAD', { cwd: rootDir, encoding: 'utf8' });
|
|
123
|
+
const out = execSync('git ls-tree --name-only HEAD', { cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
120
124
|
trackedDirs = new Set(out.trim().split('\n').filter(Boolean));
|
|
121
125
|
} catch {
|
|
122
126
|
trackedDirs = null;
|
package/lib/cli-commands.mjs
CHANGED
|
@@ -602,8 +602,13 @@ export const CLI_COMMANDS = [
|
|
|
602
602
|
emoji: '📈',
|
|
603
603
|
category: 'Observability',
|
|
604
604
|
core: false,
|
|
605
|
-
description: '
|
|
606
|
-
usage: 'construct review [--
|
|
605
|
+
description: 'Agent performance review from telemetry (run|legacy), or a deterministic PR-diff review for CI (pr)',
|
|
606
|
+
usage: 'construct review [run|legacy|pr --base=<ref> [--output=<file>]]',
|
|
607
|
+
subcommands: [
|
|
608
|
+
{ name: 'run', desc: 'Generate the per-agent performance review from local session costs + telemetry' },
|
|
609
|
+
{ name: 'legacy', desc: 'Telemetry pipeline report (requires CONSTRUCT_TELEMETRY_* credentials)' },
|
|
610
|
+
{ name: 'pr', desc: 'Deterministic diff review vs a base ref — secret/quality heuristics, no model, no credentials (backs the CI review gate, ADR-0069)' },
|
|
611
|
+
],
|
|
607
612
|
},
|
|
608
613
|
{
|
|
609
614
|
name: 'optimize',
|
package/lib/decisions/golden.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { loadRegistry } from '../registry/loader.mjs';
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { join, dirname, resolve } from 'node:path';
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
16
|
|
|
17
17
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
18
18
|
|
|
@@ -25,19 +25,18 @@ function hookName(command) {
|
|
|
25
25
|
return 'cmd';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function buildSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
28
|
+
export async function buildSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
29
29
|
const snapshot = { commands: [], agents: [], hooks: {} };
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
snapshot.commands.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
31
|
+
// The command surface comes from the CLI_COMMANDS registry module, not a
|
|
32
|
+
// text scan of its source: the earlier regex scan paired `subcommands:` item
|
|
33
|
+
// names with the NEXT command's `core:` field, minting phantom commands and
|
|
34
|
+
// swallowing real ones, so the pinned set never matched the true surface.
|
|
35
|
+
|
|
36
|
+
const { CLI_COMMANDS } = await import(pathToFileURL(join(repoRoot, 'lib', 'cli-commands.mjs')).href);
|
|
37
|
+
snapshot.commands = CLI_COMMANDS
|
|
38
|
+
.map((c) => ({ name: c.name, core: c.core === true }))
|
|
39
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
41
40
|
|
|
42
41
|
const registry = loadRegistry({ rootDir: repoRoot });
|
|
43
42
|
snapshot.agents = Object.values(registry.specialists || {}).map((s) => s.name).sort();
|
|
@@ -59,26 +58,26 @@ function snapshotPath(repoRoot) {
|
|
|
59
58
|
return join(repoRoot, 'tests', 'fixtures', 'golden', 'surface.json');
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
export function compareSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
61
|
+
export async function compareSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
63
62
|
const file = snapshotPath(repoRoot);
|
|
64
63
|
if (!existsSync(file)) return { ok: false, diffs: ['no committed snapshot — run: construct decisions golden --write'] };
|
|
65
64
|
const expected = JSON.stringify(JSON.parse(readFileSync(file, 'utf8')));
|
|
66
|
-
const actual = JSON.stringify(buildSurfaceSnapshot({ repoRoot }));
|
|
65
|
+
const actual = JSON.stringify(await buildSurfaceSnapshot({ repoRoot }));
|
|
67
66
|
if (expected === actual) return { ok: true, diffs: [] };
|
|
68
67
|
return { ok: false, diffs: ['surface snapshot drift — review the change, then regenerate with: construct decisions golden --write'] };
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
export function writeSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
72
|
-
writeFileSync(snapshotPath(repoRoot), JSON.stringify(buildSurfaceSnapshot({ repoRoot }), null, 2) + '\n');
|
|
70
|
+
export async function writeSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
71
|
+
writeFileSync(snapshotPath(repoRoot), JSON.stringify(await buildSurfaceSnapshot({ repoRoot }), null, 2) + '\n');
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
export async function runGoldenCli(args = []) {
|
|
76
75
|
if (args.includes('--write')) {
|
|
77
|
-
writeSurfaceSnapshot();
|
|
76
|
+
await writeSurfaceSnapshot();
|
|
78
77
|
process.stdout.write('✓ wrote surface golden snapshot\n');
|
|
79
78
|
return;
|
|
80
79
|
}
|
|
81
|
-
const { ok, diffs } = compareSurfaceSnapshot();
|
|
80
|
+
const { ok, diffs } = await compareSurfaceSnapshot();
|
|
82
81
|
if (ok) {
|
|
83
82
|
process.stdout.write('✓ surface matches the golden snapshot\n');
|
|
84
83
|
return;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/review-pr.mjs — deterministic PR-diff review backing `construct review pr`
|
|
3
|
+
* and the CI `review` gate (.github/workflows/pr-review.yml).
|
|
4
|
+
*
|
|
5
|
+
* Reviews HEAD against a base ref under the constraint set of a fork-PR
|
|
6
|
+
* runner: zero repo secrets and zero model access. Diff shape comes from
|
|
7
|
+
* summarizeDiff; per-file findings come from scanFile's secret and quality
|
|
8
|
+
* heuristics over the added and modified files. Findings are advisory — the
|
|
9
|
+
* command exits non-zero only when the review itself cannot run (bad ref,
|
|
10
|
+
* not a repository), never on findings, because blocking enforcement belongs
|
|
11
|
+
* to the dedicated gates (secret scanning, lint suite). ADR-0069 records the
|
|
12
|
+
* decision that replaced the never-implemented cx-reviewer persona invocation
|
|
13
|
+
* with this backend.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { scanFile, summarizeDiff } from './mcp/tools/project.mjs';
|
|
20
|
+
|
|
21
|
+
const MAX_SCANNED_FILES = 200;
|
|
22
|
+
|
|
23
|
+
export function reviewPrDiff({ baseRef, cwd = process.cwd() } = {}) {
|
|
24
|
+
if (typeof baseRef !== 'string' || !baseRef.trim()) {
|
|
25
|
+
return { error: 'baseRef is required' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Three-dot semantics (merge-base..HEAD) so the review sees only the PR's
|
|
29
|
+
// own changes, never the base branch's drift since the fork point.
|
|
30
|
+
|
|
31
|
+
const base = baseRef.trim();
|
|
32
|
+
const diff = summarizeDiff({ base_ref: `${base}...HEAD`, cwd });
|
|
33
|
+
if (diff.error) return { error: diff.error };
|
|
34
|
+
|
|
35
|
+
const reviewable = diff.changes.filter((c) => c.status === 'A' || c.status === 'M');
|
|
36
|
+
const scanned = reviewable.slice(0, MAX_SCANNED_FILES);
|
|
37
|
+
const skipped = reviewable.length - scanned.length;
|
|
38
|
+
|
|
39
|
+
const findings = [];
|
|
40
|
+
for (const change of scanned) {
|
|
41
|
+
const scan = scanFile({ cwd, file_path: change.file });
|
|
42
|
+
if (scan.error) {
|
|
43
|
+
findings.push({ severity: 'info', file: change.file, message: `${change.file}: not scanned (${scan.error})` });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
for (const secret of scan.secrets ?? []) {
|
|
47
|
+
findings.push({
|
|
48
|
+
severity: 'high',
|
|
49
|
+
file: change.file,
|
|
50
|
+
line: secret.line,
|
|
51
|
+
message: `possible secret (${secret.pattern}) at ${change.file}:${secret.line}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const issue of scan.quality_issues ?? []) {
|
|
55
|
+
findings.push({
|
|
56
|
+
severity: 'info',
|
|
57
|
+
file: change.file,
|
|
58
|
+
...(issue.line ? { line: issue.line } : {}),
|
|
59
|
+
message: `${issue.type} in ${change.file}: ${issue.detail}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const high = findings.filter((f) => f.severity === 'high').length;
|
|
65
|
+
const counts = `${findings.length} finding${findings.length === 1 ? '' : 's'} (${high} high) across ${scanned.length} scanned file${scanned.length === 1 ? '' : 's'}.`;
|
|
66
|
+
const cap = skipped > 0 ? ` ${skipped} additional changed file${skipped === 1 ? '' : 's'} not scanned (cap: ${MAX_SCANNED_FILES}).` : '';
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
generated_by: 'construct review pr',
|
|
70
|
+
base_ref: base,
|
|
71
|
+
diff: { files_changed: diff.files_changed, insertions: diff.insertions, deletions: diff.deletions },
|
|
72
|
+
scanned_files: scanned.length,
|
|
73
|
+
skipped_files: skipped,
|
|
74
|
+
summary: `Deterministic diff review — ${diff.summary} ${counts}${cap}`,
|
|
75
|
+
findings,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function runReviewPrCli(argv, { cwd = process.cwd() } = {}) {
|
|
80
|
+
let baseRef = null;
|
|
81
|
+
let output = null;
|
|
82
|
+
for (const arg of argv) {
|
|
83
|
+
if (arg.startsWith('--base=')) baseRef = arg.slice('--base='.length);
|
|
84
|
+
else if (arg.startsWith('--output=')) output = arg.slice('--output='.length);
|
|
85
|
+
else return { exitCode: 1, error: `Unknown argument: ${arg}\nUsage: construct review pr --base=<ref> [--output=<file>]` };
|
|
86
|
+
}
|
|
87
|
+
if (!baseRef) {
|
|
88
|
+
return { exitCode: 1, error: 'Usage: construct review pr --base=<ref> [--output=<file>]' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const result = reviewPrDiff({ baseRef, cwd });
|
|
92
|
+
if (result.error) return { exitCode: 1, error: result.error };
|
|
93
|
+
|
|
94
|
+
const json = JSON.stringify(result, null, 2);
|
|
95
|
+
let outputPath = null;
|
|
96
|
+
if (output) {
|
|
97
|
+
outputPath = path.resolve(cwd, output);
|
|
98
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
99
|
+
fs.writeFileSync(outputPath, `${json}\n`);
|
|
100
|
+
}
|
|
101
|
+
return { exitCode: 0, result, json, outputPath };
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geraldmaron/construct",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "npm@11.5.1",
|
|
6
6
|
"description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
|
|
@@ -123,10 +123,6 @@
|
|
|
123
123
|
"unpdf": "^1.6.2",
|
|
124
124
|
"zod": "^4.4.3"
|
|
125
125
|
},
|
|
126
|
-
"overrides": {
|
|
127
|
-
"express-rate-limit": "8.5.1",
|
|
128
|
-
"undici": "6.27.0"
|
|
129
|
-
},
|
|
130
126
|
"devDependencies": {
|
|
131
127
|
"@eslint/js": "^9.39.4",
|
|
132
128
|
"c8": "^11.0.0",
|
|
@@ -1459,12 +1459,21 @@ ${buildPrompt(entry, allEntries, "copilot")}
|
|
|
1459
1459
|
}
|
|
1460
1460
|
|
|
1461
1461
|
function syncCopilot(entries, targetDir = null, wants = true) {
|
|
1462
|
+
// Deselected host: leave prior Copilot outputs untouched. Adapter pruning is
|
|
1463
|
+
// explicit-consent-only and `.github` is out of adapter-prune's scope
|
|
1464
|
+
// (lib/reconcile/adapter-prune.mjs), so a sync that did not select copilot
|
|
1465
|
+
// must never delete or degrade another run's files — the old empty-writeEntries
|
|
1466
|
+
// fall-through pruned .github/prompts and .github/agents and blanked the
|
|
1467
|
+
// instructions block on every copilot-less sync (construct-lqp4c).
|
|
1468
|
+
|
|
1469
|
+
if (!wants) return;
|
|
1470
|
+
|
|
1462
1471
|
const promptsDir = targetDir
|
|
1463
1472
|
? path.join(targetDir, ".github", "prompts")
|
|
1464
1473
|
: path.join(home, ".github", "prompts");
|
|
1465
|
-
if (!DRY_RUN
|
|
1474
|
+
if (!DRY_RUN) mkdirp(promptsDir);
|
|
1466
1475
|
|
|
1467
|
-
const writeEntries =
|
|
1476
|
+
const writeEntries = globalEntries(entries);
|
|
1468
1477
|
|
|
1469
1478
|
for (const entry of writeEntries) {
|
|
1470
1479
|
writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries), { stamp: false });
|
|
@@ -1483,7 +1492,7 @@ function syncCopilot(entries, targetDir = null, wants = true) {
|
|
|
1483
1492
|
const agentsDir = targetDir
|
|
1484
1493
|
? path.join(targetDir, ".github", "agents")
|
|
1485
1494
|
: path.join(home, ".github", "agents");
|
|
1486
|
-
if (!DRY_RUN
|
|
1495
|
+
if (!DRY_RUN) mkdirp(agentsDir);
|
|
1487
1496
|
for (const entry of writeEntries) {
|
|
1488
1497
|
writeFile(path.join(agentsDir, `${adapterName(entry)}.agent.md`), copilotAgentFile(entry, entries), { stamp: false });
|
|
1489
1498
|
}
|