@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.
Files changed (57) hide show
  1. package/README.md +7 -1
  2. package/bin/construct +354 -19
  3. package/lib/adapters-sync.mjs +1 -1
  4. package/lib/artifact-loop-core.mjs +26 -5
  5. package/lib/artifact-manifest-overlay.mjs +273 -0
  6. package/lib/artifact-manifest.mjs +13 -10
  7. package/lib/auto-docs.mjs +5 -1
  8. package/lib/cli-commands.mjs +51 -6
  9. package/lib/config/source-target-registry.mjs +1 -0
  10. package/lib/config/source-targets.mjs +30 -0
  11. package/lib/decisions/golden.mjs +17 -18
  12. package/lib/doc-stamp.mjs +12 -0
  13. package/lib/doctor/index.mjs +2 -1
  14. package/lib/doctor/source-target-health.mjs +101 -0
  15. package/lib/doctor/watchers/source-targets.mjs +44 -0
  16. package/lib/document-ingest.mjs +25 -3
  17. package/lib/embed/demand-fetch.mjs +30 -36
  18. package/lib/embed/providers/directory.mjs +117 -0
  19. package/lib/embed/providers/registry.mjs +7 -0
  20. package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
  21. package/lib/extensions/manifests/directory.manifest.json +24 -1
  22. package/lib/extensions/manifests/github.manifest.json +10 -0
  23. package/lib/extensions/manifests/linear.manifest.json +1 -1
  24. package/lib/hooks/session-start.mjs +18 -11
  25. package/lib/knowledge/rag.mjs +52 -11
  26. package/lib/knowledge/search.mjs +69 -6
  27. package/lib/knowledge/synthesis.mjs +157 -0
  28. package/lib/mcp/server.mjs +2 -2
  29. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  30. package/lib/mcp/tools/artifact-author.mjs +126 -13
  31. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  32. package/lib/mcp/tools/skills.mjs +17 -0
  33. package/lib/model-cheapest-provider.mjs +2 -1
  34. package/lib/model-policy.mjs +329 -0
  35. package/lib/model-router.mjs +10 -9
  36. package/lib/model-tiers.mjs +27 -0
  37. package/lib/models/catalog.mjs +5 -4
  38. package/lib/orchestration/classification.mjs +11 -0
  39. package/lib/orchestration/context-bindings.mjs +85 -0
  40. package/lib/orchestration/readiness.mjs +2 -1
  41. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  42. package/lib/orchestration/runtime.mjs +35 -1
  43. package/lib/orchestration/worker.mjs +9 -0
  44. package/lib/review-pr.mjs +102 -0
  45. package/lib/setup.mjs +2 -1
  46. package/lib/sources/content-roots.mjs +147 -0
  47. package/lib/sources/repo-cache.mjs +142 -0
  48. package/lib/template-registry.mjs +2 -0
  49. package/lib/tracker/contribute.mjs +266 -0
  50. package/lib/validator.mjs +2 -3
  51. package/package.json +1 -5
  52. package/registry/agent-manifest.json +0 -4
  53. package/registry/capabilities.json +20 -1
  54. package/scripts/sync-specialists.mjs +12 -3
  55. package/templates/docs/README.md +22 -0
  56. package/templates/docs/adhoc.md +12 -0
  57. package/templates/docs/strategy-comparison.md +19 -0
@@ -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 ['reasoning', 'standard', 'fast']) {
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 ['reasoning', 'standard', 'fast']) {
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 ['reasoning', 'standard', 'fast']) {
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
- ['reasoning', 'standard', 'fast'].some((tier) => (p.options[tier] ?? []).includes(modelId)),
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 ['reasoning', 'standard', 'fast']) {
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 ['reasoning', 'standard', 'fast']) {
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
+ }