@geraldmaron/construct 1.5.1 → 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 +7 -1
- package/bin/construct +354 -19
- package/lib/adapters-sync.mjs +1 -1
- package/lib/artifact-loop-core.mjs +26 -5
- package/lib/artifact-manifest-overlay.mjs +273 -0
- package/lib/artifact-manifest.mjs +13 -10
- package/lib/auto-docs.mjs +5 -1
- package/lib/cli-commands.mjs +51 -6
- package/lib/config/source-target-registry.mjs +1 -0
- package/lib/config/source-targets.mjs +30 -0
- package/lib/decisions/golden.mjs +17 -18
- package/lib/doc-stamp.mjs +12 -0
- package/lib/doctor/index.mjs +2 -1
- package/lib/doctor/source-target-health.mjs +101 -0
- package/lib/doctor/watchers/source-targets.mjs +44 -0
- package/lib/document-ingest.mjs +25 -3
- package/lib/embed/demand-fetch.mjs +30 -36
- package/lib/embed/providers/directory.mjs +117 -0
- package/lib/embed/providers/registry.mjs +7 -0
- package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
- package/lib/extensions/manifests/directory.manifest.json +24 -1
- package/lib/extensions/manifests/github.manifest.json +10 -0
- package/lib/extensions/manifests/linear.manifest.json +1 -1
- package/lib/hooks/session-start.mjs +18 -11
- package/lib/knowledge/rag.mjs +52 -11
- package/lib/knowledge/search.mjs +69 -6
- package/lib/knowledge/synthesis.mjs +157 -0
- package/lib/mcp/server.mjs +2 -2
- package/lib/mcp/tool-definitions-workflow.mjs +35 -7
- package/lib/mcp/tools/artifact-author.mjs +126 -13
- package/lib/mcp/tools/orchestration-run.mjs +3 -0
- package/lib/mcp/tools/skills.mjs +17 -0
- package/lib/model-cheapest-provider.mjs +2 -1
- package/lib/model-policy.mjs +329 -0
- package/lib/model-router.mjs +10 -9
- package/lib/model-tiers.mjs +27 -0
- package/lib/models/catalog.mjs +5 -4
- package/lib/orchestration/classification.mjs +11 -0
- package/lib/orchestration/context-bindings.mjs +85 -0
- package/lib/orchestration/readiness.mjs +2 -1
- package/lib/orchestration/research-evidence-gate.mjs +104 -0
- package/lib/orchestration/runtime.mjs +35 -1
- package/lib/orchestration/worker.mjs +9 -0
- package/lib/review-pr.mjs +102 -0
- package/lib/setup.mjs +2 -1
- package/lib/sources/content-roots.mjs +147 -0
- package/lib/sources/repo-cache.mjs +142 -0
- package/lib/template-registry.mjs +2 -0
- package/lib/tracker/contribute.mjs +266 -0
- package/lib/validator.mjs +2 -3
- package/package.json +1 -5
- package/registry/agent-manifest.json +0 -4
- package/registry/capabilities.json +20 -1
- package/scripts/sync-specialists.mjs +12 -3
- package/templates/docs/README.md +22 -0
- package/templates/docs/adhoc.md +12 -0
- package/templates/docs/strategy-comparison.md +19 -0
package/lib/models/catalog.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { loadRegistry } from '../registry/loader.mjs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import { pollFreeModels } from '../model-free-selector.mjs';
|
|
14
|
+
import { MODEL_TIERS } from '../model-tiers.mjs';
|
|
14
15
|
import { resolveFirstSecret, hasAnySecret } from '../providers/secret-resolver.mjs';
|
|
15
16
|
import { loadProjectConfig } from '../config/project-config.mjs';
|
|
16
17
|
import { doctorRoot } from '../config/xdg.mjs';
|
|
@@ -105,7 +106,7 @@ export function providerFamilyEnabled(providerId, visibility) {
|
|
|
105
106
|
|
|
106
107
|
export function collectTierDefaultIds(registryModels = {}) {
|
|
107
108
|
const ids = [];
|
|
108
|
-
for (const tier of
|
|
109
|
+
for (const tier of MODEL_TIERS) {
|
|
109
110
|
const def = registryModels[tier];
|
|
110
111
|
if (typeof def === 'string') ids.push(def);
|
|
111
112
|
else if (def && typeof def === 'object') {
|
|
@@ -176,11 +177,11 @@ export function applyModelVisibilityFilter(catalog, {
|
|
|
176
177
|
.filter((provider) => providerFamilyEnabled(provider.id, visibility))
|
|
177
178
|
.map((provider) => {
|
|
178
179
|
const options = {};
|
|
179
|
-
for (const tier of
|
|
180
|
+
for (const tier of MODEL_TIERS) {
|
|
180
181
|
options[tier] = (provider.options?.[tier] ?? []).filter((id) => modelAllowed(id, provider.id));
|
|
181
182
|
}
|
|
182
183
|
const tiers = { ...provider.tiers };
|
|
183
|
-
for (const tier of
|
|
184
|
+
for (const tier of MODEL_TIERS) {
|
|
184
185
|
if (tiers[tier] && !modelAllowed(tiers[tier], provider.id)) {
|
|
185
186
|
tiers[tier] = options[tier]?.[0] ?? null;
|
|
186
187
|
}
|
|
@@ -260,6 +261,6 @@ export function isModelVisible(modelId, {
|
|
|
260
261
|
{ visibility, registryModels, activeModelId },
|
|
261
262
|
);
|
|
262
263
|
return filtered.providers.some((p) =>
|
|
263
|
-
|
|
264
|
+
MODEL_TIERS.some((tier) => (p.options[tier] ?? []).includes(modelId)),
|
|
264
265
|
);
|
|
265
266
|
}
|
|
@@ -49,6 +49,17 @@ const DOC_AUTHORING_PATTERNS = [
|
|
|
49
49
|
{ pattern: /\bchangelog/i, docType: 'changelog' },
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
+
// The docType each pattern maps to must exist in the routing registry's
|
|
53
|
+
// knownDocTypes(), or detectDocAuthoringIntent returns a docType with a null
|
|
54
|
+
// owner and the request silently loses its canonical author. A drift-guard test
|
|
55
|
+
// (tests/orchestration-doc-authoring-patterns.test.mjs) asserts the subset,
|
|
56
|
+
// since the mapping cannot be generated from the registry (the regex is
|
|
57
|
+
// natural-language, only the docType string is registry-owned) — construct-v1wk.
|
|
58
|
+
|
|
59
|
+
export function docAuthoringDocTypes() {
|
|
60
|
+
return DOC_AUTHORING_PATTERNS.map(({ docType }) => docType);
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
const AUTHORING_VERBS = /\b(write|writing|draft|drafting|create|creating|author|authoring|produce|producing|compose|composing|prepare|preparing|generate|generating)\b/i;
|
|
53
64
|
|
|
54
65
|
/**
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/context-bindings.mjs — resolve per-run context-target
|
|
3
|
+
* bindings for orchestration runs (bead construct-760c.4).
|
|
4
|
+
*
|
|
5
|
+
* An orchestration run can name which registered source targets it should draw
|
|
6
|
+
* context from — `contextTargets: [{id, role?}]`. This module validates those
|
|
7
|
+
* ids against the project's effective `sources.targets[]` at PLAN time and
|
|
8
|
+
* resolves each to a binding record persisted on the run for audit and picked up
|
|
9
|
+
* by the run's retrieval path. An unknown id is a hard error before any task is
|
|
10
|
+
* built (never a silent skip), so a run never plans against context it can't
|
|
11
|
+
* reach.
|
|
12
|
+
*
|
|
13
|
+
* `role` is an optional free-form hint (e.g. "tracker", "reference") threaded
|
|
14
|
+
* verbatim onto the binding — there is no role enum in core. Content-capable
|
|
15
|
+
* targets (directory / synced corpus, via lib/sources/content-roots.mjs) also
|
|
16
|
+
* carry a resolved `contentRoot` so a directory-bound run can read the docs
|
|
17
|
+
* directly; targets with no local content (a tracker like jira) resolve to a
|
|
18
|
+
* binding with `contentRoot: null` and provider identity only.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { resolveEffectiveSourceTargetsFromConfig } from '../config/source-targets.mjs';
|
|
22
|
+
import { resolveContentRoots } from '../sources/content-roots.mjs';
|
|
23
|
+
|
|
24
|
+
export class ContextTargetError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'ContextTargetError';
|
|
28
|
+
this.code = 'CONTEXT_TARGET_UNKNOWN';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeRequests(contextTargets) {
|
|
33
|
+
if (contextTargets == null) return [];
|
|
34
|
+
const list = Array.isArray(contextTargets) ? contextTargets : [contextTargets];
|
|
35
|
+
return list
|
|
36
|
+
.map((entry) => (typeof entry === 'string' ? { id: entry } : entry))
|
|
37
|
+
.filter((entry) => entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim())
|
|
38
|
+
.map((entry) => ({ id: entry.id.trim(), role: entry.role != null ? String(entry.role) : null }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve requested context targets into binding records, or throw
|
|
43
|
+
* ContextTargetError naming the offending id and the known ids. Returns an empty
|
|
44
|
+
* array when nothing is requested — the caller then falls back to today's
|
|
45
|
+
* implicit source resolution, unchanged.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<{id: string, role?: string}>|string[]} contextTargets
|
|
48
|
+
* @param {object} opts
|
|
49
|
+
* @param {object} opts.config loaded project config
|
|
50
|
+
* @param {object} [opts.env]
|
|
51
|
+
* @param {string} [opts.cwd] project root, for content-root resolution
|
|
52
|
+
* @returns {{id, provider, role, resolution, contentRoot, ref}[]}
|
|
53
|
+
*/
|
|
54
|
+
export function resolveContextBindings(contextTargets, { config, env = process.env, cwd = process.cwd() } = {}) {
|
|
55
|
+
const requests = normalizeRequests(contextTargets);
|
|
56
|
+
if (!requests.length) return [];
|
|
57
|
+
|
|
58
|
+
const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
|
|
59
|
+
const byId = new Map(targets.map((t) => [t.id, t]));
|
|
60
|
+
const contentRoots = resolveContentRoots(targets, { projectRoot: cwd });
|
|
61
|
+
const rootByTargetId = new Map(contentRoots.map((r) => [r.origin.targetId, r]));
|
|
62
|
+
|
|
63
|
+
const bindings = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const req of requests) {
|
|
66
|
+
if (seen.has(req.id)) continue;
|
|
67
|
+
seen.add(req.id);
|
|
68
|
+
|
|
69
|
+
const target = byId.get(req.id);
|
|
70
|
+
if (!target) {
|
|
71
|
+
const known = [...byId.keys()].join(', ') || '(none registered)';
|
|
72
|
+
throw new ContextTargetError(`unknown context target "${req.id}" — known targets: ${known}`);
|
|
73
|
+
}
|
|
74
|
+
const root = rootByTargetId.get(req.id) || null;
|
|
75
|
+
bindings.push({
|
|
76
|
+
id: target.id,
|
|
77
|
+
provider: target.provider,
|
|
78
|
+
role: req.role,
|
|
79
|
+
resolution: 'resolved',
|
|
80
|
+
contentRoot: root ? root.dir : null,
|
|
81
|
+
ref: root ? root.origin.ref : null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return bindings;
|
|
85
|
+
}
|
|
@@ -12,6 +12,7 @@ import { dirname, join } from 'node:path';
|
|
|
12
12
|
import { getInstalledVersion } from '../version.mjs';
|
|
13
13
|
import { CONTRACT_VERSION, MIN_CLIENT_CONTRACT_VERSION } from '../embedded-contract/contract-version.mjs';
|
|
14
14
|
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
15
|
+
import { MODEL_TIERS } from '../model-tiers.mjs';
|
|
15
16
|
import { doctorRoot } from '../config/xdg.mjs';
|
|
16
17
|
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
17
18
|
import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
|
|
@@ -165,7 +166,7 @@ export function buildOrchestrationReadiness(input = {}, { env = process.env, cwd
|
|
|
165
166
|
const workerBackend = resolveWorkerBackend({ config });
|
|
166
167
|
const modelResolved = {};
|
|
167
168
|
let firstUnresolvedTier = null;
|
|
168
|
-
for (const tier of
|
|
169
|
+
for (const tier of MODEL_TIERS) {
|
|
169
170
|
const resolved = resolveEmbeddedModel({ requestedTier: tier }, { env });
|
|
170
171
|
modelResolved[tier] = resolved.resolutionSource !== 'config-error';
|
|
171
172
|
if (!modelResolved[tier] && !firstUnresolvedTier) firstUnresolvedTier = tier;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/research-evidence-gate.mjs — deterministic evidence gate for
|
|
3
|
+
* research task output.
|
|
4
|
+
*
|
|
5
|
+
* The cx-researcher persona forbids fabrication and mandates honest
|
|
6
|
+
* insufficient-evidence, but that is honor-system enforcement (a persona prompt)
|
|
7
|
+
* — a weak model, including any free-tier host model, ignores it and ships a
|
|
8
|
+
* confident answer with no sources (construct-6yo6o: a Free-Models-Router session
|
|
9
|
+
* fabricated a whole "research" answer, zero citations, every web fetch failed).
|
|
10
|
+
*
|
|
11
|
+
* The deterministic backstop applies wherever a research task's output is
|
|
12
|
+
* finalized (provider AND host backends), independent of model tier — validating
|
|
13
|
+
* the OUTPUT, never the choice of model, so the user's free-model choice is
|
|
14
|
+
* honored (no paid escalation). Complements findUnverifiedCitations (which flags
|
|
15
|
+
* a cited URL absent from governed webEvidence) by catching the opposite gap: an
|
|
16
|
+
* answer that cites NOTHING of the evidence kind its own research mode requires.
|
|
17
|
+
*
|
|
18
|
+
* A substantial research answer that carries no citation of its expected kind is
|
|
19
|
+
* unverifiable and is flagged degraded rather than presented as done. It stays a
|
|
20
|
+
* signal, not a hard failure: the task still completes (the output exists), but a
|
|
21
|
+
* surface can see it was never grounded. An honestly short or self-declared
|
|
22
|
+
* insufficient-evidence answer is never penalized — that is the behavior the
|
|
23
|
+
* persona asks for.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const RESEARCH_ROLES = new Set(['researcher', 'cx-researcher']);
|
|
27
|
+
|
|
28
|
+
// Shorter answers are too brief to be a load-bearing research claim — an honest
|
|
29
|
+
// "insufficient evidence" reply falls here and must pass untouched.
|
|
30
|
+
const SUBSTANTIVE_MIN_CHARS = 500;
|
|
31
|
+
|
|
32
|
+
const CODEBASE_HINTS = /\b(codebase|repo|repository|source code|source file|implementation|the code|this file|which function|call site|grep|trace (the|through)|execution path)\b/i;
|
|
33
|
+
const UX_HINTS = /\b(user research|ux research|usability|interview|transcript|user journey|friction points?|jobs.to.be.done|personas?)\b/i;
|
|
34
|
+
|
|
35
|
+
// An answer that declares its own limits is honest degradation, not fabrication,
|
|
36
|
+
// and passes at any length.
|
|
37
|
+
const SELF_DEGRADED = /insufficient evidence|could not (reach|verify|access|retrieve|confirm)|no (live )?web access|unable to (verify|reach|retrieve)|\[unverified\]|no verifiable sources?|without (live |web )?access/i;
|
|
38
|
+
|
|
39
|
+
const URL_RE = /\bhttps?:\/\/[^\s)\]<>"']+/g;
|
|
40
|
+
const DOI_ARXIV_RE = /\b(?:doi:\s*10\.\d{4,}|arxiv:\s*\d{4}\.\d{4,})/gi;
|
|
41
|
+
const FILELINE_RE = /\b[\w./-]+\.[a-z0-9]{1,6}:\d+\b/gi;
|
|
42
|
+
const TRANSCRIPT_RE = /\b(?:transcript|recording|interview|participant|session)\b[^.\n]{0,60}(?:\d{4}-\d{2}|#\d+|P\d+|\.(?:txt|md|vtt|srt|json))/gi;
|
|
43
|
+
|
|
44
|
+
function countMatches(text, re) {
|
|
45
|
+
const m = String(text).match(re);
|
|
46
|
+
return m ? m.length : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Infer which evidence kind a research request expects. Codebase questions cite
|
|
51
|
+
* file:line, user/UX questions cite transcripts, everything else cites external
|
|
52
|
+
* verifiable sources (URLs / DOIs / arXiv ids).
|
|
53
|
+
*/
|
|
54
|
+
export function inferEvidenceKind(request = '') {
|
|
55
|
+
const text = String(request || '');
|
|
56
|
+
if (CODEBASE_HINTS.test(text)) return 'codebase';
|
|
57
|
+
if (UX_HINTS.test(text)) return 'ux';
|
|
58
|
+
return 'external';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function countEvidenceForKind(output, kind) {
|
|
62
|
+
if (kind === 'codebase') return countMatches(output, FILELINE_RE);
|
|
63
|
+
if (kind === 'ux') return countMatches(output, TRANSCRIPT_RE);
|
|
64
|
+
return countMatches(output, URL_RE) + countMatches(output, DOI_ARXIV_RE);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const KIND_REMEDIATION = {
|
|
68
|
+
external: 'cite at least one verifiable primary source (a fetched URL, DOI, or arXiv id)',
|
|
69
|
+
codebase: 'cite at least one file:line reference from a read you performed',
|
|
70
|
+
ux: 'cite at least one transcript, recording, or participant reference',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Gate a research task's output on evidence presence.
|
|
75
|
+
*
|
|
76
|
+
* @param {{output?: string, role?: string, request?: string}} params
|
|
77
|
+
* @returns {{applicable: boolean, ok: boolean, kind: string|null, citationCount: number, reason: string|null}}
|
|
78
|
+
*/
|
|
79
|
+
export function gateResearchEvidence({ output = '', role = '', request = '' } = {}) {
|
|
80
|
+
const normalizedRole = String(role || '').replace(/^cx-/, '');
|
|
81
|
+
if (!RESEARCH_ROLES.has(normalizedRole) && !RESEARCH_ROLES.has(String(role || ''))) {
|
|
82
|
+
return { applicable: false, ok: true, kind: null, citationCount: 0, reason: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const text = String(output || '');
|
|
86
|
+
const kind = inferEvidenceKind(request);
|
|
87
|
+
|
|
88
|
+
if (text.trim().length < SUBSTANTIVE_MIN_CHARS || SELF_DEGRADED.test(text)) {
|
|
89
|
+
return { applicable: true, ok: true, kind, citationCount: countEvidenceForKind(text, kind), reason: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const citationCount = countEvidenceForKind(text, kind);
|
|
93
|
+
if (citationCount > 0) {
|
|
94
|
+
return { applicable: true, ok: true, kind, citationCount, reason: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
applicable: true,
|
|
99
|
+
ok: false,
|
|
100
|
+
kind,
|
|
101
|
+
citationCount: 0,
|
|
102
|
+
reason: `${kind}-research output carries no verifiable citation — ${KIND_REMEDIATION[kind]}, or return insufficient-evidence instead of an unsourced answer`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -59,9 +59,11 @@ import { resolveRunStore, projectKey } from './store.mjs';
|
|
|
59
59
|
import { resolveTraceStore } from './trace-store.mjs';
|
|
60
60
|
import { runTaskViaProvider, materializeTaskPrompt, INLINE, PROVIDER, HOST, WORKER_BACKEND_SET } from './worker.mjs';
|
|
61
61
|
import { roleHoldsWebCapability } from './web-capability.mjs';
|
|
62
|
+
import { gateResearchEvidence } from './research-evidence-gate.mjs';
|
|
62
63
|
import { emitRunEvent, isCancelRequested, clearCancel } from './events.mjs';
|
|
63
64
|
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
64
65
|
import { resolveTenantContext } from '../tenant/context.mjs';
|
|
66
|
+
import { resolveContextBindings } from './context-bindings.mjs';
|
|
65
67
|
|
|
66
68
|
export const WORKER_BACKENDS = WORKER_BACKEND_SET;
|
|
67
69
|
export const DEFAULT_WORKER_BACKEND = INLINE;
|
|
@@ -203,10 +205,16 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
|
|
|
203
205
|
const {
|
|
204
206
|
request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
|
|
205
207
|
host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
|
|
206
|
-
workerBackend: explicitWorkerBackend = null,
|
|
208
|
+
workerBackend: explicitWorkerBackend = null, contextTargets = null,
|
|
207
209
|
} = request;
|
|
208
210
|
|
|
209
211
|
const { config, configWarnings } = loadConfigWithWarnings(cwd, env);
|
|
212
|
+
|
|
213
|
+
// Context-target bindings (B4) are validated here, before any task is built or
|
|
214
|
+
// any run record is saved, so an unknown id fails the whole plan closed rather
|
|
215
|
+
// than persisting a run bound to context it cannot reach. Omission resolves to
|
|
216
|
+
// an empty list and leaves today's implicit source resolution untouched.
|
|
217
|
+
const contextBindings = resolveContextBindings(contextTargets, { config, env, cwd });
|
|
210
218
|
const { store, warnings: storeWarnings, degraded: storeDegraded, degradedReason: storeDegradedReason } = resolveRunStore({ config, env, cwd });
|
|
211
219
|
if (storeDegraded) {
|
|
212
220
|
process.stderr.write(`[construct] orchestration store degraded (${storeDegradedReason ?? 'unknown'}): ${storeWarnings.join('; ')}\n`);
|
|
@@ -261,6 +269,9 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
|
|
|
261
269
|
warnings: [...(execData.warnings || []), ...storeWarnings, ...configWarnings],
|
|
262
270
|
semantics: RUNTIME_SEMANTICS,
|
|
263
271
|
executionSemantics: execData.semantics,
|
|
272
|
+
// Only present when the run was given explicit context targets, so a run
|
|
273
|
+
// without them stays byte-identical to a pre-B4 record (R1/AC3).
|
|
274
|
+
...(contextBindings.length ? { contextBindings } : {}),
|
|
264
275
|
};
|
|
265
276
|
|
|
266
277
|
await store.saveRun(run);
|
|
@@ -289,6 +300,23 @@ function prepareTaskInline(task) {
|
|
|
289
300
|
if (roleHoldsWebCapability(task.role)) task.webCapability = 'prepare-only';
|
|
290
301
|
}
|
|
291
302
|
|
|
303
|
+
// Deterministic evidence gate on a finalized research task, applied to both the
|
|
304
|
+
// provider and host backends so the model tier is irrelevant. A substantial
|
|
305
|
+
// cx-researcher answer with no citation of its expected kind is marked degraded
|
|
306
|
+
// (evidenceGate.ok=false) rather than presented as verified — the persona's
|
|
307
|
+
// no-fabrication contract is honor-system, so the gate is the enforcement.
|
|
308
|
+
|
|
309
|
+
function applyResearchEvidenceGate(task, run) {
|
|
310
|
+
const verdict = gateResearchEvidence({
|
|
311
|
+
output: task.output,
|
|
312
|
+
role: task.role,
|
|
313
|
+
request: run?.request?.summary || run?.request || '',
|
|
314
|
+
});
|
|
315
|
+
if (!verdict.applicable) return;
|
|
316
|
+
task.evidenceGate = { ok: verdict.ok, kind: verdict.kind, citationCount: verdict.citationCount };
|
|
317
|
+
if (!verdict.ok) task.evidenceGate.reason = verdict.reason;
|
|
318
|
+
}
|
|
319
|
+
|
|
292
320
|
// The provider backend executes one task against the configured model. A failed
|
|
293
321
|
// task is recorded (status `failed`, task.error) and does not abort the run, so
|
|
294
322
|
// one specialist failure cannot lose the work of the others.
|
|
@@ -305,6 +333,7 @@ async function executeTaskViaProvider(task, run, env, fetchImpl, chainOfThought,
|
|
|
305
333
|
task.executor = `provider:${result.provider}:${result.model}`;
|
|
306
334
|
task.status = 'done';
|
|
307
335
|
task.finishedAt = new Date().toISOString();
|
|
336
|
+
applyResearchEvidenceGate(task, run);
|
|
308
337
|
|
|
309
338
|
// Web-capable execution records which grant mode reached (or did not reach) the web
|
|
310
339
|
// and the F08-governed evidence gathered, so a host never has to infer it (ADR-0050).
|
|
@@ -695,6 +724,7 @@ export async function submitHostTaskResult(cwd, runId, taskId, result = {}, { en
|
|
|
695
724
|
task.status = 'done';
|
|
696
725
|
task.finishedAt = new Date().toISOString();
|
|
697
726
|
task.executionState = 'executed';
|
|
727
|
+
applyResearchEvidenceGate(task, run);
|
|
698
728
|
// Host-reported, never independently verified — the distinguishing marker a
|
|
699
729
|
// reader needs so this never looks like a construct-verified provider
|
|
700
730
|
// execution (which carries no provenanceSource field at all).
|
|
@@ -807,10 +837,14 @@ export function hostAdapterMetadata(run) {
|
|
|
807
837
|
// a host-reported one.
|
|
808
838
|
...(t.provenanceSource ? { provenanceSource: t.provenanceSource } : {}),
|
|
809
839
|
...(t.hostPrompt ? { hostPrompt: t.hostPrompt } : {}),
|
|
840
|
+
...(t.evidenceGate ? { evidenceGate: t.evidenceGate } : {}),
|
|
810
841
|
})),
|
|
811
842
|
warnings: run.warnings || [],
|
|
812
843
|
semantics: run.semantics,
|
|
813
844
|
executionSemantics: run.executionSemantics,
|
|
845
|
+
// Resolved context-target bindings (B4): a typed absence for a run planned
|
|
846
|
+
// without them, so an inspecting host never infers bindings that weren't set.
|
|
847
|
+
contextBindings: run.contextBindings ?? [],
|
|
814
848
|
};
|
|
815
849
|
}
|
|
816
850
|
|
|
@@ -143,6 +143,15 @@ function classifyWorkerProvider(provider, model) {
|
|
|
143
143
|
const p = (provider || '').toLowerCase();
|
|
144
144
|
if (p === 'github-copilot' || /^github-copilot\//.test(model || '')) return 'github-copilot';
|
|
145
145
|
if (p === 'openai') return 'openai';
|
|
146
|
+
|
|
147
|
+
// A model served through OpenRouter carries an explicit `openrouter/` slug (or
|
|
148
|
+
// the caller names openrouter), so it dispatches to OpenRouter even when the
|
|
149
|
+
// underlying family is Anthropic/Claude — the prefix wins over the isAnthropic
|
|
150
|
+
// substring heuristic, symmetric with how `openai/*` slugs stay on OpenRouter
|
|
151
|
+
// unless provider is explicitly `openai`. Without this, openrouter/anthropic/
|
|
152
|
+
// claude-* misroutes to the direct Anthropic endpoint (construct-olpf).
|
|
153
|
+
|
|
154
|
+
if (/^openrouter\//.test(model || '') || p.startsWith('openrouter')) return 'openrouter';
|
|
146
155
|
if (isAnthropic(provider, model)) return 'anthropic';
|
|
147
156
|
return 'openrouter';
|
|
148
157
|
}
|
|
@@ -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/lib/setup.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import path from 'node:path';
|
|
|
26
26
|
import { spawn, spawnSync } from 'node:child_process';
|
|
27
27
|
|
|
28
28
|
import { ensureUserConfigDir, getUserEnvPath, writeEnvValues } from './env-config.mjs';
|
|
29
|
+
import { MODEL_TIERS } from './model-tiers.mjs';
|
|
29
30
|
import { migrateLegacyModelConfig, migrateLegacyCredentialConfig } from './config/legacy-config-migration.mjs';
|
|
30
31
|
import { configDir, stateDir, doctorRoot } from './config/xdg.mjs';
|
|
31
32
|
import { DEFAULT_WORKSPACE_PATH, WORKSPACE_DOCS_LANES } from './embed/config.mjs';
|
|
@@ -536,7 +537,7 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
536
537
|
const { applyToEnv } = await import('./model-router.mjs');
|
|
537
538
|
const selections = await selectCheapestForAllTiers({ env: process.env });
|
|
538
539
|
const applied = {};
|
|
539
|
-
for (const tier of
|
|
540
|
+
for (const tier of MODEL_TIERS) {
|
|
540
541
|
if (selections[tier]?.modelId) applied[tier] = selections[tier].modelId;
|
|
541
542
|
}
|
|
542
543
|
if (Object.keys(applied).length > 0) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/sources/content-roots.mjs — resolve content-capable source targets to
|
|
3
|
+
* on-disk roots for multi-root knowledge corpus builders (bead construct-760c.2).
|
|
4
|
+
*
|
|
5
|
+
* A target contributes a filesystem content root when its provider manifest
|
|
6
|
+
* declares content capability and the concrete content exists on disk:
|
|
7
|
+
* - directory targets — the manifest selector declares `existsAs: 'directory'`,
|
|
8
|
+
* so the tilde-expanded path IS the root (no cache, always local).
|
|
9
|
+
* - corpus targets — the manifest declares a `content` descriptor and the
|
|
10
|
+
* selector opts into corpus mode (github: content.mode === 'corpus'); the
|
|
11
|
+
* root is the state-root repo cache populated by `construct sources sync`.
|
|
12
|
+
* A corpus target with no cache yet resolves to nothing (nothing to index).
|
|
13
|
+
*
|
|
14
|
+
* Capability is keyed off manifest descriptors, never a hardcoded provider name,
|
|
15
|
+
* matching the registry pattern in lib/config/source-target-registry.mjs. Adding
|
|
16
|
+
* a future git-hosted content provider means declaring its manifest `content`
|
|
17
|
+
* block, not editing this file.
|
|
18
|
+
*
|
|
19
|
+
* Every root carries an `origin` — {targetId, provider, projectKey, ref} — which
|
|
20
|
+
* the corpus builders stamp onto each chunk (adding per-file `relPath`) so
|
|
21
|
+
* cross-project retrieval can attribute and filter results by source project.
|
|
22
|
+
* The host project itself is the reserved origin: targetId null, projectKey
|
|
23
|
+
* `self`. `--projects=self` selects it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { statSync } from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { getSourceTargetDescriptor } from '../config/source-target-registry.mjs';
|
|
30
|
+
import { expandTilde, resolveEffectiveSourceTargetsFromConfig } from '../config/source-targets.mjs';
|
|
31
|
+
import { isCorpusTarget, corpusCacheDir, corpusFreshness } from './repo-cache.mjs';
|
|
32
|
+
|
|
33
|
+
export const SELF_PROJECT_KEY = 'self';
|
|
34
|
+
|
|
35
|
+
function isDirectoryTarget(target) {
|
|
36
|
+
const descriptor = getSourceTargetDescriptor(target?.provider);
|
|
37
|
+
return descriptor?.selector?.existsAs === 'directory';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Does this target declare any content capability at all (directory or corpus)?
|
|
42
|
+
* Expands `--projects=all` to the content-eligible target set.
|
|
43
|
+
*/
|
|
44
|
+
export function isContentCapableTarget(target) {
|
|
45
|
+
return isDirectoryTarget(target) || isCorpusTarget(target);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function directoryRoot(target) {
|
|
49
|
+
const descriptor = getSourceTargetDescriptor(target.provider);
|
|
50
|
+
const raw = target.selector?.[descriptor.selector.field];
|
|
51
|
+
if (!raw) return null;
|
|
52
|
+
const dir = expandTilde(String(raw));
|
|
53
|
+
try {
|
|
54
|
+
if (!statSync(dir).isDirectory()) return null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return { dir, ref: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function corpusRoot(target, projectRoot) {
|
|
62
|
+
const freshness = corpusFreshness(target, { projectRoot });
|
|
63
|
+
if (!freshness.cached) return null;
|
|
64
|
+
return { dir: corpusCacheDir(target, { projectRoot }), ref: freshness.ref ?? null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve content-capable targets to concrete on-disk roots. Targets whose
|
|
69
|
+
* content is not present (a directory that vanished, a corpus never synced) are
|
|
70
|
+
* silently omitted — there is nothing to index — so callers get only roots they
|
|
71
|
+
* can actually walk.
|
|
72
|
+
*
|
|
73
|
+
* @param {object[]} targets effective source targets (post-merge)
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* @param {string} [opts.projectRoot]
|
|
76
|
+
* @returns {{dir: string, origin: {targetId: string, provider: string, projectKey: string, ref: string|null}}[]}
|
|
77
|
+
*/
|
|
78
|
+
export function resolveContentRoots(targets = [], { projectRoot = process.cwd() } = {}) {
|
|
79
|
+
const roots = [];
|
|
80
|
+
for (const target of targets) {
|
|
81
|
+
let resolved = null;
|
|
82
|
+
if (isDirectoryTarget(target)) resolved = directoryRoot(target);
|
|
83
|
+
else if (isCorpusTarget(target)) resolved = corpusRoot(target, projectRoot);
|
|
84
|
+
if (!resolved) continue;
|
|
85
|
+
roots.push({
|
|
86
|
+
dir: resolved.dir,
|
|
87
|
+
origin: {
|
|
88
|
+
targetId: target.id,
|
|
89
|
+
provider: target.provider,
|
|
90
|
+
projectKey: target.id,
|
|
91
|
+
ref: resolved.ref,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return roots;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convenience wrapper: resolve content roots straight from a loaded project
|
|
100
|
+
* config (merging in legacy env targets exactly as the rest of the pipeline does).
|
|
101
|
+
*/
|
|
102
|
+
export function resolveContentRootsFromConfig(config, { projectRoot = process.cwd(), env = process.env } = {}) {
|
|
103
|
+
const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
|
|
104
|
+
return resolveContentRoots(targets, { projectRoot });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Expand a `--projects=` / `projects` filter spec into the set of origin
|
|
109
|
+
* targetIds it selects. Accepts a CSV string or an array. Semantics:
|
|
110
|
+
* - `all` → every content-capable target id (host not included).
|
|
111
|
+
* - `self` → the reserved host project key.
|
|
112
|
+
* - a target id → that id, which must exist and be content-capable.
|
|
113
|
+
* An unknown or non-content-capable id is a hard error (never a silent empty
|
|
114
|
+
* result), per R3.
|
|
115
|
+
*
|
|
116
|
+
* @returns {{ ids: Set<string>, includeSelf: boolean }}
|
|
117
|
+
* @throws {Error} on an unknown id, with a message listing the known ids.
|
|
118
|
+
*/
|
|
119
|
+
export function expandProjectsFilter(spec, targets = []) {
|
|
120
|
+
const tokens = (Array.isArray(spec) ? spec : String(spec ?? '').split(','))
|
|
121
|
+
.map((s) => String(s).trim())
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
|
|
124
|
+
const contentTargets = targets.filter(isContentCapableTarget);
|
|
125
|
+
const knownById = new Map(contentTargets.map((t) => [t.id, t]));
|
|
126
|
+
|
|
127
|
+
const ids = new Set();
|
|
128
|
+
let includeSelf = false;
|
|
129
|
+
|
|
130
|
+
for (const token of tokens) {
|
|
131
|
+
if (token === 'all') {
|
|
132
|
+
for (const t of contentTargets) ids.add(t.id);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (token === SELF_PROJECT_KEY) {
|
|
136
|
+
includeSelf = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!knownById.has(token)) {
|
|
140
|
+
const known = [SELF_PROJECT_KEY, 'all', ...knownById.keys()];
|
|
141
|
+
throw new Error(`unknown project "${token}" — known projects: ${known.join(', ')}`);
|
|
142
|
+
}
|
|
143
|
+
ids.add(token);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ids, includeSelf };
|
|
147
|
+
}
|