@geraldmaron/construct 1.5.0 → 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 (55) 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/init-unified.mjs +2 -7
  23. package/lib/knowledge/rag.mjs +52 -11
  24. package/lib/knowledge/search.mjs +69 -6
  25. package/lib/knowledge/synthesis.mjs +157 -0
  26. package/lib/mcp/server.mjs +2 -2
  27. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  28. package/lib/mcp/tools/artifact-author.mjs +126 -13
  29. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  30. package/lib/mcp/tools/skills.mjs +17 -0
  31. package/lib/model-cheapest-provider.mjs +2 -1
  32. package/lib/model-policy.mjs +329 -0
  33. package/lib/model-router.mjs +10 -9
  34. package/lib/model-tiers.mjs +27 -0
  35. package/lib/models/catalog.mjs +5 -4
  36. package/lib/orchestration/classification.mjs +11 -0
  37. package/lib/orchestration/context-bindings.mjs +85 -0
  38. package/lib/orchestration/readiness.mjs +2 -1
  39. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  40. package/lib/orchestration/runtime.mjs +35 -1
  41. package/lib/orchestration/worker.mjs +9 -0
  42. package/lib/providers/directory/index.mjs +19 -7
  43. package/lib/setup.mjs +2 -1
  44. package/lib/sources/content-roots.mjs +147 -0
  45. package/lib/sources/repo-cache.mjs +142 -0
  46. package/lib/template-registry.mjs +2 -0
  47. package/lib/test-corpus-inventory.mjs +1 -0
  48. package/lib/tracker/contribute.mjs +266 -0
  49. package/lib/validator.mjs +2 -3
  50. package/package.json +1 -1
  51. package/registry/agent-manifest.json +0 -4
  52. package/registry/capabilities.json +20 -1
  53. package/templates/docs/README.md +22 -0
  54. package/templates/docs/adhoc.md +12 -0
  55. package/templates/docs/strategy-comparison.md +19 -0
@@ -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
  }
@@ -10,14 +10,18 @@
10
10
  *
11
11
  * Config (per call):
12
12
  * - root: absolute or relative path to the directory root (required)
13
- * - include: array of minimatch glob patterns (optional, defaults to ["**\/*"])
14
- * - exclude: array of minimatch glob patterns to filter out (optional)
13
+ * - include: array of glob patterns (optional, defaults to ["**\/*"])
14
+ * - exclude: array of glob patterns to filter out (optional)
15
15
  * - maxFileKB: integer max file size in KB (default 1024, max 10240)
16
+ *
17
+ * Glob support is the in-tree subset (ADR-0001, lib/rules-delivery.mjs):
18
+ * `**\/` spans directories, `*` stays within a segment, and a pattern without
19
+ * a slash matches basenames (`*.md` matches nested files).
16
20
  */
17
21
 
18
22
  import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs';
19
23
  import { resolve, relative, isAbsolute } from 'node:path';
20
- import { minimatch } from 'minimatch';
24
+ import { globToRegExp } from '../../rules-delivery.mjs';
21
25
 
22
26
  /**
23
27
  * Verify that path is within root and not a symlink escape.
@@ -74,6 +78,14 @@ function resolvePath(root, candidate) {
74
78
  return { ok: true, path: resolved, root: rootPath };
75
79
  }
76
80
 
81
+ // A pattern without a slash tests the basename so `*.md` matches nested files,
82
+ // mirroring the matchBase behavior read() and search() have always documented.
83
+
84
+ function compileMatchers(patterns) {
85
+ const compiled = patterns.map((pat) => ({ re: globToRegExp(pat), baseOnly: !pat.includes('/') }));
86
+ return (relativePath, name) => compiled.some(({ re, baseOnly }) => re.test(baseOnly ? name : relativePath));
87
+ }
88
+
77
89
  /**
78
90
  * Walk directory recursively, matching include/exclude globs.
79
91
  */
@@ -82,6 +94,9 @@ function walkDir(dirPath, { include = ['**/*'], exclude = [] }) {
82
94
  const stat = statSync(dirPath);
83
95
  if (!stat.isDirectory()) return results;
84
96
 
97
+ const includes = compileMatchers(include);
98
+ const excludes = compileMatchers(exclude);
99
+
85
100
  function walk(dir, prefix = '') {
86
101
  let entries;
87
102
  try {
@@ -97,10 +112,7 @@ function walkDir(dirPath, { include = ['**/*'], exclude = [] }) {
97
112
  if (entry.isDirectory()) {
98
113
  walk(fullPath, relativePath);
99
114
  } else if (entry.isFile()) {
100
- const matches = include.some((pat) => minimatch(relativePath, pat, { matchBase: true }));
101
- const excludes = exclude.some((pat) => minimatch(relativePath, pat, { matchBase: true }));
102
-
103
- if (matches && !excludes) {
115
+ if (includes(relativePath, entry.name) && !excludes(relativePath, entry.name)) {
104
116
  results.push({ path: fullPath, relativePath, name: entry.name });
105
117
  }
106
118
  }
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
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * lib/sources/repo-cache.mjs — local content cache for corpus source targets.
3
+ *
4
+ * A source target opts into a full-content corpus by declaring its provider
5
+ * manifest's `content` descriptor shape in the selector (github: `content:
6
+ * {mode:"corpus", ref?}`). Eligibility is keyed off that manifest `content`
7
+ * descriptor, never a hardcoded `provider === 'github'` check, so any future
8
+ * git-hosted provider that declares a `content` block gets caching for free
9
+ * (bead construct-760c.1 R5).
10
+ *
11
+ * The checkout lives only under the ADR-0066 machine state root
12
+ * (`~/.construct/projects/<key>/context-repos/<targetId>/`, resolved through
13
+ * lib/state-root.mjs) — never in the project tree, so `construct init` never
14
+ * scaffolds it. A sibling `<targetId>.meta.json` records the remote, ref,
15
+ * resolved HEAD, sync mode, and last-fetch timestamp for freshness/TTL
16
+ * reporting. The first sync clones shallow (`--depth 1`); every subsequent
17
+ * sync fetches into the existing `.git` (incremental, re-run-safe — R4).
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ import { resolveStateDir, resolveStatePath } from '../state-root.mjs';
25
+ import { getSourceTargetDescriptor, renderTemplate } from '../config/source-target-registry.mjs';
26
+
27
+ export const DEFAULT_CORPUS_TTL_MS = 24 * 60 * 60 * 1000;
28
+
29
+ function contentDescriptor(target) {
30
+ const descriptor = getSourceTargetDescriptor(target?.provider);
31
+ return descriptor?.content ? { descriptor, content: descriptor.content } : null;
32
+ }
33
+
34
+ /**
35
+ * Is this target opted into corpus caching? True only when its provider
36
+ * manifest declares a `content` descriptor and the target's selector sets that
37
+ * descriptor's mode field to the corpus value.
38
+ */
39
+ export function isCorpusTarget(target) {
40
+ const found = contentDescriptor(target);
41
+ if (!found) return false;
42
+ const block = target.selector?.[found.content.field];
43
+ return !!block && block[found.content.modeField] === found.content.modeValue;
44
+ }
45
+
46
+ /**
47
+ * Resolve the git remote URL for a corpus target: an explicit `remote` in the
48
+ * content selector — a local `file://` bare repo, say — wins over the
49
+ * manifest's `remoteTemplate` rendered from the selector value.
50
+ */
51
+ export function resolveCorpusRemote(target) {
52
+ const found = contentDescriptor(target);
53
+ if (!found) return null;
54
+ const { descriptor, content } = found;
55
+ const block = target.selector?.[content.field] ?? {};
56
+ if (content.remoteField && block[content.remoteField]) return String(block[content.remoteField]);
57
+ const value = target.selector?.[descriptor.selector.field];
58
+ if (!value || !content.remoteTemplate) return null;
59
+ return renderTemplate(content.remoteTemplate, { value });
60
+ }
61
+
62
+ function corpusRef(target) {
63
+ const found = contentDescriptor(target);
64
+ const block = target.selector?.[found.content.field] ?? {};
65
+ return block[found.content.refField] || found.content.defaultRef;
66
+ }
67
+
68
+ export function corpusCacheDir(target, { projectRoot = process.cwd(), ensureDir = false } = {}) {
69
+ return resolveStateDir(projectRoot, 'context-repos', target.id, { ensureDir });
70
+ }
71
+
72
+ function metaPathFor(target, projectRoot, { ensureDir = false } = {}) {
73
+ return resolveStatePath(projectRoot, 'context-repos', `${target.id}.meta.json`, { ensureDir });
74
+ }
75
+
76
+ export function readCorpusMeta(target, { projectRoot = process.cwd() } = {}) {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(metaPathFor(target, projectRoot), 'utf8'));
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Freshness view for a corpus target: whether a cache exists, its last-fetch
86
+ * timestamp, and whether it is older than `ttlMs`. Drives `sources list`.
87
+ */
88
+ export function corpusFreshness(target, { projectRoot = process.cwd(), ttlMs = DEFAULT_CORPUS_TTL_MS, now = Date.now() } = {}) {
89
+ const dir = corpusCacheDir(target, { projectRoot });
90
+ const cached = fs.existsSync(path.join(dir, '.git'));
91
+ const meta = readCorpusMeta(target, { projectRoot });
92
+ const lastFetch = meta?.lastFetch ?? null;
93
+ const ageMs = lastFetch ? now - Date.parse(lastFetch) : null;
94
+ const stale = ageMs == null ? cached : ageMs > ttlMs;
95
+ return { cached, lastFetch, ageMs, stale, ref: meta?.ref ?? corpusRef(target), head: meta?.head ?? null, dir };
96
+ }
97
+
98
+ /**
99
+ * Clone (first run) or fetch (subsequent runs) a corpus target's content into
100
+ * its state-root cache and record freshness metadata. Incremental and
101
+ * re-run-safe: an existing `.git` takes the fetch path, so objects are reused
102
+ * rather than re-cloned. `git` is injectable purely for testing; production
103
+ * uses the real git binary via execFileSync.
104
+ */
105
+ export function syncCorpusTarget(target, { projectRoot = process.cwd(), git = runGit, now = () => new Date().toISOString() } = {}) {
106
+ if (!isCorpusTarget(target)) {
107
+ throw new Error(`target ${target.id} is not a corpus target`);
108
+ }
109
+ const remote = resolveCorpusRemote(target);
110
+ if (!remote) throw new Error(`target ${target.id} has no resolvable corpus remote`);
111
+ const ref = corpusRef(target);
112
+ const dir = corpusCacheDir(target, { projectRoot });
113
+ const gitDir = path.join(dir, '.git');
114
+
115
+ let mode;
116
+ if (fs.existsSync(gitDir)) {
117
+ git(['-C', dir, 'fetch', '--depth', '1', 'origin', ref]);
118
+ git(['-C', dir, 'checkout', '-f', 'FETCH_HEAD']);
119
+ mode = 'fetch';
120
+ } else {
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ git(['clone', '--depth', '1', '--branch', ref, remote, dir]);
123
+ mode = 'clone';
124
+ }
125
+
126
+ const head = git(['-C', dir, 'rev-parse', 'HEAD']).trim();
127
+ const meta = {
128
+ targetId: target.id,
129
+ provider: target.provider,
130
+ remote,
131
+ ref,
132
+ head,
133
+ mode,
134
+ lastFetch: now(),
135
+ };
136
+ fs.writeFileSync(metaPathFor(target, projectRoot, { ensureDir: true }), `${JSON.stringify(meta, null, 2)}\n`);
137
+ return { ...meta, dir };
138
+ }
139
+
140
+ function runGit(args) {
141
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
142
+ }
@@ -54,6 +54,7 @@ export const TEMPLATE_OWNERSHIP_EXEMPTIONS = new Set([
54
54
  'construct_guide',
55
55
  'onboarding',
56
56
  'memo',
57
+ 'adhoc',
57
58
  'skill-artifact',
58
59
  'persona-artifact',
59
60
  'customer-profile',
@@ -69,4 +70,5 @@ export const TEMPLATE_OWNERSHIP_EXEMPTIONS = new Set([
69
70
  'prd-platform',
70
71
  'prd-business',
71
72
  'README',
73
+ 'strategy-comparison',
72
74
  ]);
@@ -80,6 +80,7 @@ export function defaultCorpusInventoryPath(rootDir = process.cwd()) {
80
80
  }
81
81
 
82
82
  function listTestFiles(testsDir) {
83
+ if (!fs.existsSync(testsDir)) return [];
83
84
  const files = [];
84
85
  const walk = (dir) => {
85
86
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {