@geraldmaron/construct 1.0.23 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/bin/construct +189 -207
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- 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 +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.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 +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- 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/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- 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/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +4 -4
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- 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/runtime.mjs +3 -2
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reconcile/index.mjs +0 -2
- 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/service-manager.mjs +79 -259
- package/lib/setup.mjs +44 -425
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/personas/construct.md +1 -1
- package/platforms/capabilities.json +76 -0
- package/platforms/claude/settings.template.json +0 -7
- 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 +174 -40
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/specialists/registry.json +0 -8
- package/templates/docs/construct_guide.md +1 -1
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/reconcile/postgres-namespace.mjs +0 -102
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/unified-storage.mjs +0 -550
- package/lib/storage/vector-store.mjs +0 -100
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-rules.mjs — static reference audit for the rules/ corpus.
|
|
3
|
+
*
|
|
4
|
+
* Rules are not retrieved into agent context through any single runtime path the
|
|
5
|
+
* way skills are (no `logSkillCall` analog fits — they are named by path in prose
|
|
6
|
+
* the host's Read tool resolves, or read by enforcement tooling), so "which rules
|
|
7
|
+
* earn their keep" is a STATIC question. A rule is load-bearing two ways:
|
|
8
|
+
* 1. GLOB-SCOPED — its frontmatter declares `paths:` globs (the Cline/Cursor
|
|
9
|
+
* pattern), so it is intended to activate on a matching edit. Whether those
|
|
10
|
+
* globs are actually delivered to each host's rule config is a separate
|
|
11
|
+
* concern; here they mark the rule as intentionally scoped, not an orphan.
|
|
12
|
+
* 2. REFERENCED — the canonical `rules/<dir>/<name>` path token (with or without
|
|
13
|
+
* the .md) appears somewhere in the active surface (personas, CLAUDE.md,
|
|
14
|
+
* skills, all of lib/ except the minified dashboard bundles, registry/
|
|
15
|
+
* contracts, other rules, scripts, the CLI).
|
|
16
|
+
* A rule that is neither glob-activated nor referenced is a true orphan — a
|
|
17
|
+
* pruning candidate. A rule's own file never counts as referencing itself.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
|
|
25
|
+
// Directories and files that make up the "active surface" a rule could be cited
|
|
26
|
+
// from. Code and prose both count — a rule named in a hook or in bin/construct is
|
|
27
|
+
// as load-bearing as one named in a persona.
|
|
28
|
+
|
|
29
|
+
const SURFACE_DIRS = ['personas', 'skills', 'rules', 'lib', 'specialists', 'scripts'];
|
|
30
|
+
const SURFACE_FILES = ['CLAUDE.md', 'AGENTS.md', 'bin/construct', 'claude/settings.template.json'];
|
|
31
|
+
const TEXT_EXT = new Set(['.md', '.mjs', '.js', '.json', '.txt', '.mdx']);
|
|
32
|
+
|
|
33
|
+
// The minified Next.js dashboard bundles contain unrelated "rules/" tokens (the
|
|
34
|
+
// markdown lexer's own grammar) that would read as false references.
|
|
35
|
+
const SURFACE_SKIP = ['lib/server/static'];
|
|
36
|
+
|
|
37
|
+
function isSkipped(full, root) {
|
|
38
|
+
const rel = path.relative(root, full);
|
|
39
|
+
return SURFACE_SKIP.some((s) => rel === s || rel.startsWith(`${s}${path.sep}`));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A rule with `paths:` globs in its frontmatter is glob-activated by the host on a
|
|
43
|
+
// matching edit — load-bearing regardless of whether it is named anywhere.
|
|
44
|
+
function hasPathGlobs(absPath) {
|
|
45
|
+
try {
|
|
46
|
+
const head = fs.readFileSync(absPath, 'utf8').slice(0, 1200);
|
|
47
|
+
const fm = head.match(/^---\n([\s\S]*?)\n---/);
|
|
48
|
+
return Boolean(fm && /\n?paths:\s*\n\s*-\s+/.test(fm[1]));
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectRuleFiles(rulesDir) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const walk = (dir) => {
|
|
55
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
const full = path.join(dir, ent.name);
|
|
57
|
+
if (ent.isDirectory()) walk(full);
|
|
58
|
+
else if (ent.name.endsWith('.md')) out.push(path.relative(rulesDir, full).replace(/\.md$/, ''));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
if (fs.existsSync(rulesDir)) walk(rulesDir);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collectSurfaceText(root, ruleFileAbsSet) {
|
|
66
|
+
const texts = [];
|
|
67
|
+
const add = (full) => {
|
|
68
|
+
if (ruleFileAbsSet.has(full)) return;
|
|
69
|
+
if (!TEXT_EXT.has(path.extname(full))) return;
|
|
70
|
+
try { texts.push(fs.readFileSync(full, 'utf8')); } catch { /* unreadable — skip */ }
|
|
71
|
+
};
|
|
72
|
+
const walk = (dir) => {
|
|
73
|
+
let ents;
|
|
74
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
75
|
+
for (const ent of ents) {
|
|
76
|
+
const full = path.join(dir, ent.name);
|
|
77
|
+
if (isSkipped(full, root)) continue;
|
|
78
|
+
if (ent.isDirectory()) walk(full);
|
|
79
|
+
else add(full);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
for (const d of SURFACE_DIRS) walk(path.join(root, d));
|
|
83
|
+
for (const f of SURFACE_FILES) add(path.join(root, f));
|
|
84
|
+
return texts.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Audit which rules are referenced in the active surface.
|
|
89
|
+
* @returns {{ total: number, referenced: Array<{rule:string,refs:number}>, orphans: string[], issues: object[] }}
|
|
90
|
+
*/
|
|
91
|
+
export function auditRules({ rootDir, silent = false } = {}) {
|
|
92
|
+
const root = rootDir ?? REPO_ROOT;
|
|
93
|
+
const rulesDir = path.join(root, 'rules');
|
|
94
|
+
const ruleFiles = collectRuleFiles(rulesDir);
|
|
95
|
+
const ruleAbsSet = new Set(ruleFiles.map((r) => path.join(rulesDir, `${r}.md`)));
|
|
96
|
+
const surface = collectSurfaceText(root, ruleAbsSet);
|
|
97
|
+
|
|
98
|
+
const referenced = [];
|
|
99
|
+
const globScoped = [];
|
|
100
|
+
const orphans = [];
|
|
101
|
+
for (const rule of ruleFiles) {
|
|
102
|
+
// Count `rules/<rule>` occurrences (with or without the .md suffix) so a
|
|
103
|
+
// citation in any surface file marks the rule load-bearing.
|
|
104
|
+
const token = `rules/${rule}`;
|
|
105
|
+
const refs = surface.split(token).length - 1;
|
|
106
|
+
if (refs > 0) referenced.push({ rule, refs });
|
|
107
|
+
else if (hasPathGlobs(path.join(rulesDir, `${rule}.md`))) globScoped.push(rule);
|
|
108
|
+
else orphans.push(rule);
|
|
109
|
+
}
|
|
110
|
+
referenced.sort((a, b) => b.refs - a.refs);
|
|
111
|
+
|
|
112
|
+
const issues = [];
|
|
113
|
+
if (orphans.length > 0) issues.push({ kind: 'orphan-rules', items: orphans });
|
|
114
|
+
|
|
115
|
+
if (!silent) {
|
|
116
|
+
const line = (s) => process.stdout.write(`${s}\n`);
|
|
117
|
+
line(`Rule reference audit (${ruleFiles.length} rules: ${referenced.length} referenced, ${globScoped.length} glob-scoped, ${orphans.length} orphan):`);
|
|
118
|
+
if (orphans.length === 0) {
|
|
119
|
+
line(' ✓ Every rule is referenced or glob-scoped');
|
|
120
|
+
} else {
|
|
121
|
+
line(` ⚠ Rules neither referenced nor glob-scoped (${orphans.length}) — pruning candidates:`);
|
|
122
|
+
for (const r of orphans) line(` - rules/${r}.md`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { total: ruleFiles.length, referenced, globScoped, orphans, issues };
|
|
127
|
+
}
|
package/lib/audit-skills.mjs
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Reports: (a) skills with no agent owner, (b) agents with no skill bindings,
|
|
5
5
|
* (c) skill paths declared in registry but missing on disk.
|
|
6
6
|
* Called by 'construct audit skills' and incorporated into 'construct doctor'.
|
|
7
|
+
*
|
|
8
|
+
* A `roles/<base>[.<flavor>]` skill is owned when `<base>` is a specialist OR a role
|
|
9
|
+
* named in any profile (profiles/*.json) — a role skill loads when its specialist or
|
|
10
|
+
* profile role runs, even without a direct registry `skills:` binding. Counting only
|
|
11
|
+
* registry bindings over-reported orphans (it missed profile roles like `operator`
|
|
12
|
+
* from operations.json and conditional specialist flavors); see bead construct-ksfa.
|
|
7
13
|
*/
|
|
8
14
|
import fs from 'node:fs';
|
|
9
15
|
import path from 'node:path';
|
|
@@ -34,6 +40,34 @@ function collectSkillFiles(skillsDir) {
|
|
|
34
40
|
return results;
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
// Collect every role name a profile declares — top-level `roles` and nested
|
|
44
|
+
// `departments[].roles` — so a role skill counts as owned when a profile uses its
|
|
45
|
+
// base even if no specialist declares the skill path directly.
|
|
46
|
+
function collectProfileRoles(root) {
|
|
47
|
+
const dir = path.join(root, 'profiles');
|
|
48
|
+
const roles = new Set();
|
|
49
|
+
const scan = (node) => {
|
|
50
|
+
if (Array.isArray(node)) { node.forEach(scan); return; }
|
|
51
|
+
if (node && typeof node === 'object') {
|
|
52
|
+
if (Array.isArray(node.roles)) for (const r of node.roles) if (typeof r === 'string') roles.add(r);
|
|
53
|
+
for (const v of Object.values(node)) scan(v);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
for (const f of fs.readdirSync(dir)) {
|
|
58
|
+
if (!f.endsWith('.json')) continue;
|
|
59
|
+
try { scan(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); } catch { /* skip malformed */ }
|
|
60
|
+
}
|
|
61
|
+
} catch { /* no profiles dir */ }
|
|
62
|
+
return roles;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The base of a role skill: roles/architect.data → architect; roles/qa → qa.
|
|
66
|
+
function roleBase(skill) {
|
|
67
|
+
const m = skill.match(/^roles\/([^.]+)(?:\..*)?$/);
|
|
68
|
+
return m ? m[1] : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
export function auditSkills({ rootDir, silent = false } = {}) {
|
|
38
72
|
const root = rootDir ?? findConstructRoot();
|
|
39
73
|
const registryPath = path.join(root, 'specialists', 'registry.json');
|
|
@@ -59,7 +93,15 @@ export function auditSkills({ rootDir, silent = false } = {}) {
|
|
|
59
93
|
}
|
|
60
94
|
}
|
|
61
95
|
|
|
62
|
-
|
|
96
|
+
// Owning role bases = specialist names (registry) ∪ profile role names. A role
|
|
97
|
+
// skill is owned when its base is in this set, even without a direct binding.
|
|
98
|
+
const ownerBases = new Set([
|
|
99
|
+
...(registry.specialists ?? []).map((a) => a.name.replace(/^cx-/, '')),
|
|
100
|
+
...collectProfileRoles(root),
|
|
101
|
+
]);
|
|
102
|
+
const isOwned = (s) => declaredSkills.has(s) || (ownerBases.has(roleBase(s)));
|
|
103
|
+
|
|
104
|
+
const orphanSkills = [...allSkillFiles].filter((s) => !isOwned(s));
|
|
63
105
|
|
|
64
106
|
const issues = [];
|
|
65
107
|
if (agentsWithNoSkills.length > 0) issues.push({ kind: 'agents-no-skills', items: agentsWithNoSkills });
|
package/lib/beads-client.mjs
CHANGED
|
@@ -252,6 +252,15 @@ export async function runBd(args, options = {}) {
|
|
|
252
252
|
* Used as fallback when optimistic locking fails.
|
|
253
253
|
*/
|
|
254
254
|
async function runWithLegacyLock(args, opts, cwd, commandDesc) {
|
|
255
|
+
// Telemetry: every legacy-fallback firing is recorded so the lock+queue removal
|
|
256
|
+
// decision rests on observed data — if this never fires over a representative
|
|
257
|
+
// window, the whole exclusive-lock path is removable; if it fires, the entries
|
|
258
|
+
// name the bd errors that actually need handling (bead construct-nhn5).
|
|
259
|
+
try {
|
|
260
|
+
const { logBeadsFallback } = await import('./telemetry/beads-fallback.mjs');
|
|
261
|
+
logBeadsFallback({ command: commandDesc || args[0] || 'unknown' });
|
|
262
|
+
} catch { /* telemetry is best-effort */ }
|
|
263
|
+
|
|
255
264
|
// Clean up any stale locks before trying
|
|
256
265
|
cleanupStaleLock({ cwd });
|
|
257
266
|
cleanupStaleQueue({ cwd });
|
package/lib/beads-optimistic.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/beads-optimistic.mjs —
|
|
2
|
+
* lib/beads-optimistic.mjs — concurrent reads + retry-on-conflict writes for beads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Dolt commits are atomic, so it is the serializer: a write is executed and, on a
|
|
5
|
+
* transient conflict, retried with exponential backoff. There is no separate
|
|
6
|
+
* read-then-compare version check — that spanned two `bd` processes (a `bd show`
|
|
7
|
+
* to read the commit hash, then a `bd update`) and guarded nothing, since `bd
|
|
8
|
+
* update` accepts no expected version, so the window between the read and the
|
|
9
|
+
* write left the "optimistic lock" unable to actually detect a conflicting commit
|
|
10
|
+
* (bead construct-iufy). Reads run lock-free; `getBeadVersion` remains as an
|
|
11
|
+
* advisory reader, not a write-path primitive.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
@@ -34,8 +34,9 @@ const DEFAULT_RETRY_OPTIONS = {
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Read the current version of a bead from the database.
|
|
38
|
-
*
|
|
37
|
+
* Read the current version of a bead from the database (the Dolt commit hash).
|
|
38
|
+
* Advisory only — exposed for callers that want to observe a bead's version; the
|
|
39
|
+
* write path does not gate on it (see file header).
|
|
39
40
|
*/
|
|
40
41
|
export async function getBeadVersion(beadId, cwd = process.cwd()) {
|
|
41
42
|
try {
|
|
@@ -56,75 +57,37 @@ export async function getBeadVersion(beadId, cwd = process.cwd()) {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Execute a write
|
|
60
|
-
*
|
|
60
|
+
* Execute a bead write, retrying on a transient conflict. Dolt's atomic commit is
|
|
61
|
+
* the serializer; this does not pre-read or compare a version (see file header).
|
|
62
|
+
*
|
|
61
63
|
* @param {Object} options
|
|
62
|
-
* @param {string} options.beadId - The bead being modified
|
|
63
64
|
* @param {Function} options.execute - Async function that performs the write
|
|
64
|
-
* @param {string} options.expectedVersion - Version we expect the bead to have
|
|
65
65
|
* @param {Object} options.retry - Retry configuration
|
|
66
66
|
* @returns {Promise<{success: boolean, result: any, attempts: number}>}
|
|
67
67
|
*/
|
|
68
68
|
export async function optimisticWrite({
|
|
69
|
-
beadId,
|
|
70
69
|
execute,
|
|
71
|
-
expectedVersion,
|
|
72
70
|
retry = DEFAULT_RETRY_OPTIONS,
|
|
73
|
-
cwd = process.cwd(),
|
|
74
71
|
} = {}) {
|
|
75
72
|
let attempts = 0;
|
|
76
73
|
let delay = retry.baseDelayMs;
|
|
77
|
-
|
|
74
|
+
|
|
78
75
|
while (attempts < retry.maxRetries) {
|
|
79
76
|
attempts++;
|
|
80
|
-
|
|
81
|
-
// Verify version hasn't changed (optimistic check)
|
|
82
|
-
const currentVersion = await getBeadVersion(beadId, cwd);
|
|
83
|
-
|
|
84
|
-
if (expectedVersion && currentVersion !== expectedVersion) {
|
|
85
|
-
// Conflict detected - another writer modified the bead
|
|
86
|
-
if (attempts >= retry.maxRetries) {
|
|
87
|
-
return {
|
|
88
|
-
success: false,
|
|
89
|
-
error: `Optimistic lock conflict after ${attempts} attempts. Bead ${beadId} was modified by another process.`,
|
|
90
|
-
attempts,
|
|
91
|
-
currentVersion,
|
|
92
|
-
expectedVersion,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Wait with exponential backoff and jitter
|
|
97
|
-
const jitter = Math.random() * 50;
|
|
98
|
-
await new Promise(r => setTimeout(r, delay + jitter));
|
|
99
|
-
delay = Math.min(delay * retry.backoffMultiplier, retry.maxDelayMs);
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
77
|
try {
|
|
104
|
-
// Execute the write operation
|
|
105
78
|
const result = await execute();
|
|
106
79
|
return { success: true, result, attempts };
|
|
107
80
|
} catch (error) {
|
|
108
81
|
if (attempts >= retry.maxRetries) {
|
|
109
|
-
return {
|
|
110
|
-
success: false,
|
|
111
|
-
error: error.message,
|
|
112
|
-
attempts,
|
|
113
|
-
};
|
|
82
|
+
return { success: false, error: error.message, attempts };
|
|
114
83
|
}
|
|
115
|
-
|
|
116
|
-
// Retry on transient errors
|
|
117
84
|
const jitter = Math.random() * 50;
|
|
118
|
-
await new Promise(r => setTimeout(r, delay + jitter));
|
|
85
|
+
await new Promise((r) => setTimeout(r, delay + jitter));
|
|
119
86
|
delay = Math.min(delay * retry.backoffMultiplier, retry.maxDelayMs);
|
|
120
87
|
}
|
|
121
88
|
}
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
success: false,
|
|
125
|
-
error: `Max retries (${retry.maxRetries}) exceeded`,
|
|
126
|
-
attempts,
|
|
127
|
-
};
|
|
89
|
+
|
|
90
|
+
return { success: false, error: `Max retries (${retry.maxRetries}) exceeded`, attempts };
|
|
128
91
|
}
|
|
129
92
|
|
|
130
93
|
// ---------------------------------------------------------------------------
|
|
@@ -187,21 +150,10 @@ export async function updateBeadOptimistic(
|
|
|
187
150
|
options = {}
|
|
188
151
|
) {
|
|
189
152
|
const { actor = 'construct', cwd = process.cwd(), notes } = options;
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!currentVersion) {
|
|
195
|
-
return {
|
|
196
|
-
success: false,
|
|
197
|
-
error: `Bead ${beadId} not found`,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
153
|
+
|
|
154
|
+
// A `bd update` on a missing bead exits non-zero and surfaces as a failed
|
|
155
|
+
// result, so no separate existence pre-check is needed.
|
|
201
156
|
return optimisticWrite({
|
|
202
|
-
beadId,
|
|
203
|
-
expectedVersion: currentVersion,
|
|
204
|
-
cwd,
|
|
205
157
|
execute: async () => {
|
|
206
158
|
const args = ['update', beadId];
|
|
207
159
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lib/bridges/copilot-proxy.mjs — Minimal GitHub Copilot to OpenAI-compatible bridge.
|
|
4
|
+
*
|
|
5
|
+
* Translates standard OpenAI /v1/chat/completions requests to the Copilot API
|
|
6
|
+
* using the user's active `gh` CLI session.
|
|
7
|
+
*/
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
const PORT = parseInt(process.argv.find(a => a.startsWith('--port='))?.split('=')[1] || '5174', 10);
|
|
12
|
+
|
|
13
|
+
let cachedSession = null;
|
|
14
|
+
|
|
15
|
+
async function getCopilotToken() {
|
|
16
|
+
if (cachedSession && cachedSession.expires_at > (Date.now() / 1000) + 60) {
|
|
17
|
+
return cachedSession.token;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const ghToken = execSync('gh auth token', { encoding: 'utf8' }).trim();
|
|
22
|
+
const res = await fetch('https://api.github.com/copilot_internal/v2/token', {
|
|
23
|
+
headers: {
|
|
24
|
+
'Authorization': `Bearer ${ghToken}`,
|
|
25
|
+
'Accept': 'application/json',
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!res.ok) throw new Error(`Failed to get session token: ${res.statusText}`);
|
|
30
|
+
|
|
31
|
+
cachedSession = await res.json();
|
|
32
|
+
return cachedSession.token;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('Error fetching Copilot token:', err.message);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const server = http.createServer(async (req, res) => {
|
|
40
|
+
// CORS
|
|
41
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
42
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
43
|
+
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
44
|
+
|
|
45
|
+
if (req.method === 'OPTIONS') {
|
|
46
|
+
res.writeHead(204);
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
|
|
52
|
+
const token = await getCopilotToken();
|
|
53
|
+
if (!token) {
|
|
54
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
55
|
+
res.end(JSON.stringify({ error: 'Failed to authenticate with GitHub Copilot via gh CLI.' }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let body = '';
|
|
60
|
+
req.on('data', chunk => { body += chunk; });
|
|
61
|
+
req.on('end', async () => {
|
|
62
|
+
try {
|
|
63
|
+
const payload = JSON.parse(body);
|
|
64
|
+
|
|
65
|
+
// Map common model names to Copilot equivalents if needed
|
|
66
|
+
// For now, we pass them through or default to gpt-4o
|
|
67
|
+
const model = payload.model?.includes('gpt-4o') ? 'gpt-4o' :
|
|
68
|
+
payload.model?.includes('claude-3.5-sonnet') ? 'claude-3.5-sonnet' :
|
|
69
|
+
'gpt-4o';
|
|
70
|
+
|
|
71
|
+
const copilotRes = await fetch('https://api.githubcopilot.com/chat/completions', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Authorization': `Bearer ${token}`,
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
'Accept': 'application/json',
|
|
77
|
+
'X-Github-Api-Version': '2023-07-07',
|
|
78
|
+
'Editor-Version': 'vscode/1.90.0', // Spoof VS Code for compatibility
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
...payload,
|
|
82
|
+
model
|
|
83
|
+
})
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
res.writeHead(copilotRes.status, {
|
|
87
|
+
'Content-Type': copilotRes.headers.get('Content-Type'),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (payload.stream) {
|
|
91
|
+
const reader = copilotRes.body.getReader();
|
|
92
|
+
while (true) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done) break;
|
|
95
|
+
res.write(value);
|
|
96
|
+
}
|
|
97
|
+
res.end();
|
|
98
|
+
} else {
|
|
99
|
+
const data = await copilotRes.text();
|
|
100
|
+
res.end(data);
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
104
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end('Not Found');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
115
|
+
console.log(`Copilot Bridge listening on http://127.0.0.1:${PORT}`);
|
|
116
|
+
});
|
package/lib/cli-commands.mjs
CHANGED
|
@@ -61,13 +61,14 @@ export const CLI_COMMANDS = [
|
|
|
61
61
|
category: 'Core',
|
|
62
62
|
core: true,
|
|
63
63
|
description: 'Machine setup (scoped per ADR-0029): --scope=project|user|both, default project',
|
|
64
|
-
usage: 'construct install [--scope=project|user|both] [--yes] [--no-docker] [--no-launch-agent] [--reconfigure]',
|
|
64
|
+
usage: 'construct install [--scope=project|user|both] [--yes] [--no-docker] [--no-launch-agent] [--reconfigure] [--with-docling]',
|
|
65
65
|
options: [
|
|
66
66
|
{ flag: '--scope=<s>', desc: 'project (default, no-op + guidance) | user (writes ~/.construct/, MCP, ~/.claude/* via consent) | both' },
|
|
67
67
|
{ flag: '--yes', desc: 'Apply defaults without prompts (only meaningful with --scope=user|both)' },
|
|
68
68
|
{ flag: '--no-docker', desc: 'Skip Docker-based service setup (local Postgres)' },
|
|
69
69
|
{ flag: '--no-launch-agent', desc: 'Skip background macOS LaunchAgent registration' },
|
|
70
70
|
{ flag: '--reconfigure', desc: 'Re-prompt for service consent, ignoring cached answers' },
|
|
71
|
+
{ flag: '--with-docling', desc: 'Eagerly provision the docling document-extraction venv now (heavy, ~10 min; else lazy on first ingest)' },
|
|
71
72
|
],
|
|
72
73
|
},
|
|
73
74
|
{
|
|
@@ -936,6 +937,9 @@ export const CLI_COMMANDS = [
|
|
|
936
937
|
{ name: 'lint:contracts', category: 'Internal', core: false, internal: true, description: 'Internal lint: specialist contracts', usage: 'construct lint:contracts' },
|
|
937
938
|
{ name: 'lint:research', category: 'Internal', core: false, internal: true, description: 'Internal lint: research artifacts', usage: 'construct lint:research' },
|
|
938
939
|
{ name: 'lint:templates', category: 'Internal', core: false, internal: true, description: 'Internal lint: shipped templates', usage: 'construct lint:templates' },
|
|
940
|
+
{ name: 'lint:prompts', category: 'Internal', core: false, internal: true, description: 'Internal lint: specialist prompt frontmatter + sections', usage: 'construct lint:prompts' },
|
|
941
|
+
{ name: 'specialist', category: 'Internal', core: false, internal: true, description: 'Maintainer tool: scaffold, edit, and lint specialist prompts', usage: 'construct specialist <create|edit|lint>' },
|
|
942
|
+
{ name: 'registry:status', category: 'Internal', core: false, internal: true, description: 'Dev: capability-matrix registry inspector', usage: 'construct registry:status' },
|
|
939
943
|
{ name: 'evaluator:rubrics', category: 'Internal', core: false, internal: true, description: 'Internal: list registered evaluator rubrics', usage: 'construct evaluator:rubrics' },
|
|
940
944
|
{ name: 'activation:status', category: 'Internal', core: false, internal: true, description: 'Internal: agent activation telemetry', usage: 'construct activation:status' },
|
|
941
945
|
{ name: 'prune', category: 'Internal', core: false, internal: true, description: 'Internal: prune ephemeral storage entries', usage: 'construct prune' },
|
package/lib/comment-lint.mjs
CHANGED
|
@@ -86,7 +86,7 @@ function isConstructSelfRepo(rootDir) {
|
|
|
86
86
|
|
|
87
87
|
function requiresHeader(rel) {
|
|
88
88
|
const ext = path.extname(rel);
|
|
89
|
-
if (['.yaml', '.yml', '.json', '.jsonl', '.toml'].includes(ext)) return { required: false, type: null };
|
|
89
|
+
if (['.yaml', '.yml', '.json', '.jsonl', '.toml', '.txt'].includes(ext)) return { required: false, type: null };
|
|
90
90
|
const jsMatch = JS_HEADER_GLOBS.some(r => r.test(rel));
|
|
91
91
|
const mdMatch = MD_HEADER_GLOBS.some(r => r.test(rel));
|
|
92
92
|
// Exclude static assets under lib/server/static from requiring headers
|
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
|
|
|
@@ -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,
|