@geraldmaron/construct 1.5.1 → 1.5.2

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 (52) hide show
  1. package/README.md +3 -0
  2. package/bin/construct +336 -17
  3. package/lib/artifact-loop-core.mjs +26 -5
  4. package/lib/artifact-manifest-overlay.mjs +273 -0
  5. package/lib/artifact-manifest.mjs +13 -10
  6. package/lib/cli-commands.mjs +44 -4
  7. package/lib/config/source-target-registry.mjs +1 -0
  8. package/lib/config/source-targets.mjs +30 -0
  9. package/lib/doc-stamp.mjs +12 -0
  10. package/lib/doctor/index.mjs +2 -1
  11. package/lib/doctor/source-target-health.mjs +101 -0
  12. package/lib/doctor/watchers/source-targets.mjs +44 -0
  13. package/lib/document-ingest.mjs +25 -3
  14. package/lib/embed/demand-fetch.mjs +30 -36
  15. package/lib/embed/providers/directory.mjs +117 -0
  16. package/lib/embed/providers/registry.mjs +7 -0
  17. package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
  18. package/lib/extensions/manifests/directory.manifest.json +24 -1
  19. package/lib/extensions/manifests/github.manifest.json +10 -0
  20. package/lib/extensions/manifests/linear.manifest.json +1 -1
  21. package/lib/hooks/session-start.mjs +18 -11
  22. package/lib/knowledge/rag.mjs +52 -11
  23. package/lib/knowledge/search.mjs +69 -6
  24. package/lib/knowledge/synthesis.mjs +157 -0
  25. package/lib/mcp/server.mjs +2 -2
  26. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  27. package/lib/mcp/tools/artifact-author.mjs +126 -13
  28. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  29. package/lib/mcp/tools/skills.mjs +17 -0
  30. package/lib/model-cheapest-provider.mjs +2 -1
  31. package/lib/model-policy.mjs +329 -0
  32. package/lib/model-router.mjs +10 -9
  33. package/lib/model-tiers.mjs +27 -0
  34. package/lib/models/catalog.mjs +5 -4
  35. package/lib/orchestration/classification.mjs +11 -0
  36. package/lib/orchestration/context-bindings.mjs +85 -0
  37. package/lib/orchestration/readiness.mjs +2 -1
  38. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  39. package/lib/orchestration/runtime.mjs +35 -1
  40. package/lib/orchestration/worker.mjs +9 -0
  41. package/lib/setup.mjs +2 -1
  42. package/lib/sources/content-roots.mjs +147 -0
  43. package/lib/sources/repo-cache.mjs +142 -0
  44. package/lib/template-registry.mjs +2 -0
  45. package/lib/tracker/contribute.mjs +266 -0
  46. package/lib/validator.mjs +2 -3
  47. package/package.json +1 -1
  48. package/registry/agent-manifest.json +0 -4
  49. package/registry/capabilities.json +20 -1
  50. package/templates/docs/README.md +22 -0
  51. package/templates/docs/adhoc.md +12 -0
  52. package/templates/docs/strategy-comparison.md +19 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * lib/model-policy.mjs — user-facing model policy: presets, the effective-policy
3
+ * view, and per-specialist resolution traces, layered on the existing resolution
4
+ * chain (lib/model-router.mjs + lib/embedded-contract/model-resolve.mjs).
5
+ *
6
+ * Design constraint (construct-760c.7): the single edit surface for model
7
+ * assignment is specialists/org/models.json. config.schema is deliberately not a
8
+ * second surface. Presets are COMPUTED here from the cost/free selectors
9
+ * (model-cheapest-provider / model-free-selector / model-pricing) and then
10
+ * PERSISTED to models.json, which already sits below env pins in the chain — so
11
+ * CX_MODEL_<TIER> pins keep overriding and nothing new enters the chain.
12
+ *
13
+ * Budget invariant: computeBudgetTiers never treats a missing price as $0. An
14
+ * unknown or unreachable price ranks a candidate LAST, and flagship ids are
15
+ * hard-excluded — so with only an OpenRouter credential no frontier model can be
16
+ * selected for any tier, even when live pricing is unreachable.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import {
22
+ defaultModelRegistryPath,
23
+ getProviderModelCatalog,
24
+ resolveModelTiers,
25
+ MODEL_TIER_BY_WORK_CATEGORY,
26
+ } from './model-router.mjs';
27
+ import { getPricingForModels } from './model-pricing.mjs';
28
+ import { pollFreeModels, selectForTier, isFreeModel } from './model-free-selector.mjs';
29
+ import { getSpecialist } from './registry/loader.mjs';
30
+ import { MODEL_TIERS } from './model-tiers.mjs';
31
+
32
+ export const POLICY_PRESETS = ['budget', 'free', 'frontier', 'local'];
33
+ export const POLICY_TIERS = MODEL_TIERS;
34
+
35
+ // Flagship, high-cost models a budget/free policy must never resolve to. The set
36
+ // mirrors the reasoning/standard flagships declared in PROVIDER_FAMILY_TIERS so a
37
+ // preset can hard-exclude them regardless of whether live pricing was reachable;
38
+ // the regex catches the same families' newer point releases.
39
+
40
+ const FRONTIER_MODEL_IDS = new Set([
41
+ 'anthropic/claude-opus-4-6',
42
+ 'anthropic/claude-sonnet-4-6',
43
+ 'openrouter/anthropic/claude-opus-4-6',
44
+ 'openrouter/anthropic/claude-sonnet-4-6',
45
+ 'openai/gpt-5.4',
46
+ 'openai/gpt-5.1',
47
+ 'openrouter/openai/gpt-5.4',
48
+ 'openrouter/openai/gpt-5.1',
49
+ 'openrouter/google/gemini-2.5-pro',
50
+ 'openrouter/meta-llama/llama-3.1-405b-instruct',
51
+ 'github-copilot/gpt-5.5',
52
+ 'github-copilot/gpt-5.4',
53
+ ]);
54
+
55
+ const FRONTIER_PATTERN = /(claude-opus|claude-sonnet|gpt-5\.(4|5)|gemini-2\.5-pro|llama-3\.1-405b|405b-instruct)/i;
56
+
57
+ export function isFrontierModel(modelId) {
58
+ if (!modelId || typeof modelId !== 'string') return false;
59
+ if (isFreeModel(modelId)) return false;
60
+ return FRONTIER_MODEL_IDS.has(modelId) || FRONTIER_PATTERN.test(modelId);
61
+ }
62
+
63
+ function uniq(values) {
64
+ return [...new Set(values.filter((v) => typeof v === 'string' && v.trim()))];
65
+ }
66
+
67
+ function configuredProviders(env) {
68
+ const catalog = getProviderModelCatalog({ env });
69
+ return catalog.providers.filter((p) => p.configured);
70
+ }
71
+
72
+ function tierDefinition(primary, fallback = []) {
73
+ return { primary, fallback: uniq(fallback.filter((id) => id !== primary)) };
74
+ }
75
+
76
+ // Candidate models for a tier from every configured provider: the tier pin plus
77
+ // the shipped option list. Frontier ids are dropped so a budget/free preset can
78
+ // never rank one, whatever the pricing outcome.
79
+
80
+ function budgetCandidates(providers, tier) {
81
+ const ids = [];
82
+ const local = new Set();
83
+ for (const p of providers) {
84
+ const list = uniq([p.tiers?.[tier], ...(p.options?.[tier] ?? [])]);
85
+ for (const id of list) {
86
+ ids.push(id);
87
+ if (p.local === true) local.add(id);
88
+ }
89
+ }
90
+ return { ids: uniq(ids).filter((id) => !isFrontierModel(id)), local };
91
+ }
92
+
93
+ /**
94
+ * Compute the budget tier assignments: the cheapest eligible model per tier from
95
+ * configured providers. Unknown/unreachable prices rank LAST (never $0), so the
96
+ * budget invariant holds even when the pricing fetch fails.
97
+ */
98
+ export async function computeBudgetTiers({ env = process.env, getPricing = getPricingForModels } = {}) {
99
+ const providers = configuredProviders(env);
100
+ const warnings = [];
101
+ const models = {};
102
+ let pricingDegraded = false;
103
+
104
+ for (const tier of POLICY_TIERS) {
105
+ const { ids, local } = budgetCandidates(providers, tier);
106
+ if (ids.length === 0) {
107
+ models[tier] = tierDefinition(null, []);
108
+ continue;
109
+ }
110
+
111
+ let pricing = {};
112
+ try {
113
+ pricing = await getPricing(ids);
114
+ } catch {
115
+ pricing = {};
116
+ }
117
+
118
+ const scored = ids.map((id) => {
119
+ if (local.has(id)) return { id, cost: 0, priced: true };
120
+ const entry = pricing[id];
121
+ if (entry && Number.isFinite(Number(entry.input)) && Number.isFinite(Number(entry.output))) {
122
+ return { id, cost: Number(entry.input) + Number(entry.output), priced: true };
123
+ }
124
+ return { id, cost: Number.POSITIVE_INFINITY, priced: false };
125
+ });
126
+
127
+ const priced = scored.filter((s) => s.priced);
128
+ if (priced.length === 0) {
129
+ // No candidate had a reachable price. Fall back to a deterministic static
130
+ // ordering — a :free slug first, else the first non-frontier candidate —
131
+ // and say so rather than silently ranking on missing data.
132
+ pricingDegraded = true;
133
+ const free = ids.find((id) => isFreeModel(id));
134
+ const primary = free ?? ids[0];
135
+ models[tier] = tierDefinition(primary, ids.filter((id) => id !== primary).slice(0, 2));
136
+ continue;
137
+ }
138
+
139
+ scored.sort((a, b) => a.cost - b.cost);
140
+ const primary = scored[0].id;
141
+ const fallback = scored.slice(1).filter((s) => Number.isFinite(s.cost)).map((s) => s.id).slice(0, 2);
142
+ models[tier] = tierDefinition(primary, fallback);
143
+ }
144
+
145
+ if (pricingDegraded) {
146
+ warnings.push('Live pricing was unreachable for at least one tier; used static ordering (a :free slug, else the first eligible candidate).');
147
+ }
148
+ return { models, warnings };
149
+ }
150
+
151
+ /**
152
+ * Compute the free tier assignments: :free OpenRouter slugs only. A tier with no
153
+ * available free model is left null and reported, never silently substituted.
154
+ */
155
+ export async function computeFreeTiers({ env = process.env, apiKey = null, poll = pollFreeModels } = {}) {
156
+ const warnings = [];
157
+ const models = {};
158
+ const key = apiKey || env.OPENROUTER_API_KEY || env.OPEN_ROUTER_API_KEY || null;
159
+ const freeModels = key ? await poll(key) : [];
160
+
161
+ for (const tier of POLICY_TIERS) {
162
+ const id = selectForTier(freeModels, tier, []);
163
+ if (id && isFreeModel(id)) {
164
+ models[tier] = tierDefinition(id, []);
165
+ } else {
166
+ models[tier] = tierDefinition(null, []);
167
+ warnings.push(`No free model available for the ${tier} tier; left unset (run \`construct models free\` to inspect the live catalog).`);
168
+ }
169
+ }
170
+ return { models, warnings };
171
+ }
172
+
173
+ // Best-available (flagship) per tier from configured providers, ranked by a fixed
174
+ // capability order. This is the deliberately-expensive preset, so frontier ids are
175
+ // not excluded here.
176
+
177
+ const FRONTIER_PROVIDER_ORDER = [
178
+ 'anthropic',
179
+ 'openai',
180
+ 'openrouter-anthropic',
181
+ 'openrouter-openai',
182
+ 'github-copilot',
183
+ 'openrouter-google',
184
+ 'openrouter-deepseek',
185
+ 'openrouter-llama',
186
+ 'openrouter-qwen',
187
+ 'openrouter',
188
+ ];
189
+
190
+ export function computeFrontierTiers({ env = process.env } = {}) {
191
+ const providers = configuredProviders(env);
192
+ const ranked = [...providers].sort((a, b) => {
193
+ const ia = FRONTIER_PROVIDER_ORDER.indexOf(a.id);
194
+ const ib = FRONTIER_PROVIDER_ORDER.indexOf(b.id);
195
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
196
+ });
197
+ const models = {};
198
+ for (const tier of POLICY_TIERS) {
199
+ const provider = ranked.find((p) => p.tiers?.[tier]);
200
+ const primary = provider?.tiers?.[tier] ?? null;
201
+ const fallback = ranked.filter((p) => p !== provider).map((p) => p.tiers?.[tier]).filter(Boolean);
202
+ models[tier] = tierDefinition(primary, fallback.slice(0, 2));
203
+ }
204
+ return { models, warnings: [] };
205
+ }
206
+
207
+ export function computeLocalTiers({ env = process.env } = {}) {
208
+ const providers = configuredProviders(env).filter((p) => p.local === true);
209
+ const models = {};
210
+ for (const tier of POLICY_TIERS) {
211
+ const provider = providers.find((p) => p.tiers?.[tier]);
212
+ const primary = provider?.tiers?.[tier] ?? null;
213
+ const fallback = providers.filter((p) => p !== provider).map((p) => p.tiers?.[tier]).filter(Boolean);
214
+ models[tier] = tierDefinition(primary, fallback.slice(0, 2));
215
+ }
216
+ return { models, warnings: [] };
217
+ }
218
+
219
+ /**
220
+ * Compute a preset's per-tier assignments. Refuses (ok:false) with a clear
221
+ * message when no provider credential can back the preset, so a preset never
222
+ * silently writes an unbacked frontier default.
223
+ */
224
+ export async function computePolicyPreset(preset, { env = process.env, apiKey = null, getPricing, poll } = {}) {
225
+ if (!POLICY_PRESETS.includes(preset)) {
226
+ return { ok: false, refusal: `Unknown preset '${preset}'. Choose one of: ${POLICY_PRESETS.join(', ')}.` };
227
+ }
228
+
229
+ const providers = configuredProviders(env);
230
+ const localProviders = providers.filter((p) => p.local === true);
231
+ const hasOpenRouter = Boolean(env.OPENROUTER_API_KEY || env.OPEN_ROUTER_API_KEY);
232
+
233
+ if (preset === 'free' && !hasOpenRouter) {
234
+ return { ok: false, refusal: 'The `free` preset needs an OpenRouter credential (OPENROUTER_API_KEY). Set one, then rerun.' };
235
+ }
236
+ if (preset === 'local' && localProviders.length === 0) {
237
+ return { ok: false, refusal: 'The `local` preset needs a local provider (Ollama or a local OpenAI-compatible server). Start one, then rerun.' };
238
+ }
239
+ if ((preset === 'budget' || preset === 'frontier') && providers.length === 0) {
240
+ return { ok: false, refusal: `The \`${preset}\` preset needs at least one configured provider credential. Set an API key (e.g. OPENROUTER_API_KEY / ANTHROPIC_API_KEY), then rerun.` };
241
+ }
242
+
243
+ let result;
244
+ if (preset === 'budget') result = await computeBudgetTiers({ env, ...(getPricing ? { getPricing } : {}) });
245
+ else if (preset === 'free') result = await computeFreeTiers({ env, apiKey, ...(poll ? { poll } : {}) });
246
+ else if (preset === 'frontier') result = computeFrontierTiers({ env });
247
+ else result = computeLocalTiers({ env });
248
+
249
+ const anyResolved = POLICY_TIERS.some((tier) => result.models[tier]?.primary);
250
+ if (!anyResolved) {
251
+ return { ok: false, refusal: `No model could be computed for the \`${preset}\` preset with the current credentials.`, warnings: result.warnings };
252
+ }
253
+
254
+ return { ok: true, preset, models: result.models, warnings: result.warnings ?? [] };
255
+ }
256
+
257
+ /**
258
+ * Persist computed tier assignments to the models.json registry — the single
259
+ * edit surface. Writes only `models`; env pins keep overriding at resolution.
260
+ */
261
+ export function writeModelRegistry(registryPath, models, { preset = null } = {}) {
262
+ const payload = {
263
+ _comment: `Written by \`construct models policy set${preset ? ` ${preset}` : ''}\` (construct-760c.7). Tier defaults below fill only tiers no CX_MODEL_<TIER> env pin sets; env pins always win. Edit via \`construct models policy set <preset>\` or \`construct models set\`.`,
264
+ models: {},
265
+ };
266
+ for (const tier of POLICY_TIERS) {
267
+ const def = models[tier];
268
+ if (def?.primary) payload.models[tier] = { primary: def.primary, fallback: def.fallback ?? [] };
269
+ }
270
+ fs.mkdirSync(path.dirname(registryPath), { recursive: true });
271
+ fs.writeFileSync(registryPath, `${JSON.stringify(payload, null, 2)}\n`);
272
+ return payload;
273
+ }
274
+
275
+ /**
276
+ * Effective-policy view: per-tier resolved model and the winning source, plus the
277
+ * work-category → tier map. Reads through resolveModelTiers so an env pin is
278
+ * attributed as the winning source over a registry default.
279
+ */
280
+ export function readPolicyView({ env = process.env, registryPath = null } = {}) {
281
+ const resolvedRegistryPath = registryPath ?? defaultModelRegistryPath(env);
282
+ const resolution = resolveModelTiers({ env, registryPath: resolvedRegistryPath });
283
+ const tiers = POLICY_TIERS.map((tier) => ({
284
+ tier,
285
+ model: resolution.models[tier],
286
+ source: resolution.sources[tier],
287
+ envPin: env[`CX_MODEL_${tier.toUpperCase()}`] || env[`CONSTRUCT_MODEL_${tier.toUpperCase()}`] || null,
288
+ }));
289
+ return {
290
+ registryPath: resolvedRegistryPath,
291
+ tiers,
292
+ workCategoryMap: { ...MODEL_TIER_BY_WORK_CATEGORY },
293
+ configured: resolution.configured,
294
+ complete: resolution.complete,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Per-specialist resolution trace: the specialist's declared modelTier/model, the
300
+ * tier it maps to, and the winning rule for that tier. Mirrors what
301
+ * `construct models resolve --json --tier <tier>` reports for the same tier.
302
+ */
303
+ export function explainRole(roleId, { env = process.env, registryPath = null, rootDir = null } = {}) {
304
+ const specialist = getSpecialist(roleId, rootDir ? { rootDir } : {});
305
+ if (!specialist) {
306
+ return { ok: false, error: `Unknown specialist '${roleId}'.` };
307
+ }
308
+
309
+ const declaredModel = specialist.model || null;
310
+ const declaredTier = specialist.modelTier || null;
311
+ const tier = declaredTier || 'standard';
312
+
313
+ const resolvedRegistryPath = registryPath ?? defaultModelRegistryPath(env);
314
+ const resolution = resolveModelTiers({ env, registryPath: resolvedRegistryPath });
315
+
316
+ const resolvedModel = declaredModel || resolution.models[tier];
317
+ const source = declaredModel ? 'specialist model pin' : resolution.sources[tier];
318
+
319
+ return {
320
+ ok: true,
321
+ role: specialist.name ? `cx-${specialist.name}` : roleId,
322
+ declaredModel,
323
+ declaredTier,
324
+ tier,
325
+ resolvedModel,
326
+ source,
327
+ registryPath: resolvedRegistryPath,
328
+ };
329
+ }
@@ -26,6 +26,7 @@ import path from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { spawnSync } from "child_process";
28
28
  import { hasAnySecret } from "./providers/secret-resolver.mjs";
29
+ import { MODEL_TIERS } from "./model-tiers.mjs";
29
30
  import { isOllamaModelInstalled, toOllamaNativeModelId } from "./ollama/installed-models.mjs";
30
31
  import { readRawFromOpenCodeProvider } from "./providers/credential-sources.mjs";
31
32
  import { findOpenCodeConfigPath } from "./opencode-config.mjs";
@@ -535,9 +536,9 @@ export function getPickerModelAllowlist({
535
536
  if (!providerFamilyEnabled(family.id, vis)) continue;
536
537
 
537
538
  const tiers = family.resolve({});
538
- const pins = uniqueStrings(['reasoning', 'standard', 'fast'].map((tier) => tiers[tier]));
539
+ const pins = uniqueStrings(MODEL_TIERS.map((tier) => tiers[tier]));
539
540
  const shipped = uniqueStrings(
540
- ['reasoning', 'standard', 'fast'].flatMap((tier) => family.options?.[tier] || []),
541
+ MODEL_TIERS.flatMap((tier) => family.options?.[tier] || []),
541
542
  );
542
543
 
543
544
  if (vis.mode === 'explicit') {
@@ -675,7 +676,7 @@ export function listAvailableModels({ env = process.env, cwd = process.cwd(), ac
675
676
  const models = [];
676
677
  const seen = new Set();
677
678
  for (const provider of providers) {
678
- for (const tier of ['reasoning', 'standard', 'fast']) {
679
+ for (const tier of MODEL_TIERS) {
679
680
  for (const id of provider.options?.[tier] || []) {
680
681
  if (seen.has(id)) continue;
681
682
  seen.add(id);
@@ -1135,7 +1136,7 @@ function resolveTierAssignments(envValues = {}, registryModels = {}) {
1135
1136
  const defaults = getRegistryDefaults(registryModels);
1136
1137
  const tiers = {};
1137
1138
 
1138
- for (const tier of ["reasoning", "standard", "fast"]) {
1139
+ for (const tier of MODEL_TIERS) {
1139
1140
  if (explicitSources[tier]) {
1140
1141
  tiers[tier] = { model: normalizedEnv[tier] ?? defaults[tier], source: explicitSources[tier] };
1141
1142
  } else if (normalizedEnv[tier]) {
@@ -1224,7 +1225,7 @@ export function applyFreeSameFamilyPreferenceToTierSet(tierSet, selectedModel) {
1224
1225
 
1225
1226
  const sameFamily = family.resolve({ reasoning: null, standard: null, fast: null });
1226
1227
  const next = { ...tierSet };
1227
- for (const tier of ["reasoning", "standard", "fast"]) {
1228
+ for (const tier of MODEL_TIERS) {
1228
1229
  if (tierSet[tier] === selectedModel) continue;
1229
1230
  const candidate = sameFamily[tier];
1230
1231
  if (candidate && isFreeModel(candidate)) next[tier] = candidate;
@@ -1341,13 +1342,13 @@ export function resolveModelTiers(options = {}) {
1341
1342
  const tiers = resolveTierAssignments(normalizeEnvAssignments(env), registryModels);
1342
1343
  const models = {};
1343
1344
  const sources = {};
1344
- for (const tier of ['reasoning', 'standard', 'fast']) {
1345
+ for (const tier of MODEL_TIERS) {
1345
1346
  models[tier] = tiers[tier].model;
1346
1347
  sources[tier] = tiers[tier].source;
1347
1348
  }
1348
1349
  const configured = Object.values(models).filter(Boolean).length;
1349
1350
  const errors = strict && configured < 3
1350
- ? [`Missing configuration for tiers: ${['reasoning', 'standard', 'fast'].filter((t) => !models[t]).join(', ')}`]
1351
+ ? [`Missing configuration for tiers: ${MODEL_TIERS.filter((t) => !models[t]).join(', ')}`]
1351
1352
  : null;
1352
1353
 
1353
1354
  return {
@@ -1386,7 +1387,7 @@ export function validateModelTiers(options = {}) {
1386
1387
  export function formatModelStatus(options = {}) {
1387
1388
  const resolved = resolveModelTiers(options);
1388
1389
  let output = 'Model Configuration:\n\n';
1389
- for (const tier of ['reasoning', 'standard', 'fast']) {
1390
+ for (const tier of MODEL_TIERS) {
1390
1391
  const model = resolved.models[tier];
1391
1392
  const source = resolved.sources[tier];
1392
1393
  const icon = source === 'not configured' ? '!' : 'ok';
@@ -1407,7 +1408,7 @@ export function readCurrentModels(envPath, registryModels = {}) {
1407
1408
  };
1408
1409
  const tiers = resolveTierAssignments(mergedAssignments, registryModels);
1409
1410
  const result = { sources: {} };
1410
- for (const tier of ["reasoning", "standard", "fast"]) {
1411
+ for (const tier of MODEL_TIERS) {
1411
1412
  result[tier] = tiers[tier].model;
1412
1413
  result.sources[tier] = tiers[tier].source;
1413
1414
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * lib/model-tiers.mjs — the canonical model-tier vocabulary.
3
+ *
4
+ * `reasoning`, `standard`, and `fast` are the three work tiers every
5
+ * model-resolution surface keys off: the router's family tables, the registry
6
+ * validator's accept-list, cost selection, policy presets, the CLI, and the
7
+ * MCP tool schemas. They lived as a `['reasoning', 'standard', 'fast']` literal
8
+ * re-typed in ~30 places, so adding or renaming a tier meant the accept-lists
9
+ * could silently diverge from what the router actually resolves (construct-v1wk).
10
+ *
11
+ * Single source of truth for that vocabulary — deliberately zero-dependency so
12
+ * the lowest-level consumers (the registry validator, setup) can import it
13
+ * without pulling the router's provider probes or model-policy's
14
+ * pricing/registry imports. A JSON-schema `enum` or any caller that needs a
15
+ * fresh mutable array spreads it: `[...MODEL_TIERS]`.
16
+ *
17
+ * The intentionally self-contained `lib/embedded-contract/` bundle keeps its
18
+ * own copy on purpose — it must not import `lib/` internals.
19
+ */
20
+
21
+ export const MODEL_TIERS = Object.freeze(['reasoning', 'standard', 'fast']);
22
+
23
+ export const MODEL_TIER_SET = Object.freeze(new Set(MODEL_TIERS));
24
+
25
+ export function isModelTier(value) {
26
+ return MODEL_TIER_SET.has(value);
27
+ }
@@ -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;