@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
@@ -15,6 +15,7 @@ import { purgeExpiredData } from './storage/admin.mjs';
15
15
  import { stampFrontmatter } from './doc-stamp.mjs';
16
16
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
17
17
  import { loadProjectConfig } from './config/project-config.mjs';
18
+ import { resolveEffectiveSourceTargetsFromConfig } from './config/source-targets.mjs';
18
19
  import { resolveIngestStrategy, INGEST_STRATEGIES, INGEST_ORCHESTRATION_STRATEGIES } from './ingest/strategy.mjs';
19
20
  import { extractViaProvider } from './ingest/provider-extract.mjs';
20
21
  import { extractViaDoclingRemote } from './ingest/docling-remote.mjs';
@@ -261,6 +262,7 @@ export async function ingestDocuments(inputPaths, {
261
262
  outputPath = null,
262
263
  outputDir = null,
263
264
  target = 'knowledge/internal',
265
+ as = null,
264
266
  sync = false,
265
267
  strict = false,
266
268
  highFidelity = true,
@@ -281,6 +283,23 @@ export async function ingestDocuments(inputPaths, {
281
283
  }
282
284
 
283
285
  const { config } = loadProjectConfig(cwd, env);
286
+
287
+ // `--as=<targetId>` binds ingested knowledge to a registered source target so
288
+ // the written files carry re-verifiable provenance (origin_target_id /
289
+ // origin_provider in the stamp) rather than landing unattributed. An unknown
290
+ // id is a hard error — silently dropping the binding would defeat the point.
291
+ let originFields = null;
292
+ if (as) {
293
+ const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
294
+ const bound = targets.find((t) => t.id === as);
295
+ if (!bound) {
296
+ const known = targets.map((t) => t.id).join(', ') || '(none registered)';
297
+ const err = new Error(`ingest --as: unknown source target "${as}". Known targets: ${known}`);
298
+ err.code = 'INGEST_UNKNOWN_TARGET';
299
+ throw err;
300
+ }
301
+ originFields = { origin_target_id: bound.id, origin_provider: bound.provider };
302
+ }
284
303
  const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy, orchestrationOverride: orchestration, cwd });
285
304
 
286
305
  const results = [];
@@ -318,7 +337,7 @@ export async function ingestDocuments(inputPaths, {
318
337
  cwd,
319
338
  metadata,
320
339
  });
321
- writeFileSync(finalPath, stampFrontmatter(markdown, { generator: 'construct/ingest' }));
340
+ writeFileSync(finalPath, stampFrontmatter(markdown, { generator: 'construct/ingest', extraFields: originFields }));
322
341
  results.push({
323
342
  sourcePath,
324
343
  outputPath: finalPath,
@@ -390,6 +409,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
390
409
  let outputPath = null;
391
410
  let outputDir = null;
392
411
  let target = 'knowledge/internal';
412
+ let as = null;
393
413
  let sync = false;
394
414
  let strict = false;
395
415
  let fidelity = 'high';
@@ -400,6 +420,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
400
420
  if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
401
421
  else if (arg.startsWith('--out-dir=')) outputDir = arg.split('=').slice(1).join('=');
402
422
  else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
423
+ else if (arg.startsWith('--as=')) as = arg.split('=').slice(1).join('=');
403
424
  else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
404
425
  else if (arg === '--strategy') strategy = '';
405
426
  else if (arg.startsWith('--orchestration=')) orchestration = arg.split('=').slice(1).join('=');
@@ -421,7 +442,8 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
421
442
  if (inputs.length === 0) {
422
443
  throw new Error(
423
444
  `Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
424
- `[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--sync] [--strict] [--fidelity=fast|high]\n` +
445
+ `[--target=sibling|knowledge/<subdir>] [--as=<targetId>] [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--sync] [--strict] [--fidelity=fast|high]\n` +
446
+ ` --as: bind ingested knowledge to a registered source target; stamps origin provenance\n` +
425
447
  ` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
426
448
  ` --orchestration: prompt-only (deterministic extraction) or orchestrated (engage the specialist chain)\n` +
427
449
  ` --strict: exit non-zero if any extraction drops occur\n` +
@@ -443,5 +465,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
443
465
  );
444
466
  }
445
467
 
446
- return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, orchestration: orchestration || null, env });
468
+ return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, as: as || null, sync, strict, highFidelity, strategy: strategy || null, orchestration: orchestration || null, env });
447
469
  }
@@ -28,7 +28,7 @@ import {
28
28
  resolveEffectiveSourceTargetsFromConfig,
29
29
  resolveKnownSourcesFromTargets,
30
30
  } from '../config/source-targets.mjs';
31
- import { getSourceTargetDescriptor, renderTemplate } from '../config/source-target-registry.mjs';
31
+ import { getSourceTargetDescriptor, listSourceTargetDescriptors, renderTemplate } from '../config/source-target-registry.mjs';
32
32
 
33
33
  // ─── Self-query detection ────────────────────────────────────────────────────
34
34
 
@@ -79,41 +79,22 @@ export function resolveKnownSources(env = process.env, cwd = process.cwd()) {
79
79
  return resolveKnownSourcesFromTargets(targets);
80
80
  }
81
81
 
82
+ // No configured or legacy-env targets resolved — which means every list-valued
83
+ // legacy var (GITHUB_REPOS, JIRA_PROJECTS, LINEAR_TEAMS, SLACK_CHANNELS) was
84
+ // empty, so the descriptor path above already covered those. What remains is a
85
+ // credential-only source: a provider reachable via a bare credential env
86
+ // (JIRA_BASE_URL, LINEAR_API_KEY) with no explicit list. Each such provider
87
+ // declares that gate as aliases.catchAllCredentialEnv on its manifest, so this
88
+ // stays descriptor-driven — no provider name, env var, or display prefix is
89
+ // hardcoded here, and a provider whose descriptor omits the gate (slack) is
90
+ // deliberately not advertised without an explicit channel.
82
91
  const sources = [];
83
-
84
- // GitHub repos
85
- const repos = (env.GITHUB_REPOS ?? '').split(',').map(r => r.trim()).filter(Boolean);
86
- for (const repo of repos) {
87
- const short = repo.split('/').pop(); // "project-iverson" from "hashicorp/project-iverson"
88
- sources.push({ id: repo.toLowerCase(), provider: 'github', ref: repo, display: repo });
89
- sources.push({ id: short.toLowerCase(), provider: 'github', ref: repo, display: repo });
90
- // Normalised: "projectiverson", "iverson"
91
- sources.push({ id: short.replace(/-/g, '').toLowerCase(), provider: 'github', ref: repo, display: repo });
92
- // Last word: "iverson" from "project-iverson"
93
- const words = short.split('-');
94
- if (words.length > 1) {
95
- sources.push({ id: words[words.length - 1].toLowerCase(), provider: 'github', ref: repo, display: repo });
96
- }
97
- }
98
-
99
- // Jira projects
100
- const jiraUrl = (env.JIRA_BASE_URL ?? '').trim();
101
- if (jiraUrl) {
102
- // JIRA_PROJECTS=PLAT,INF,ENG — optional explicit list
103
- const projects = (env.JIRA_PROJECTS ?? '').split(',').map(p => p.trim()).filter(Boolean);
104
- for (const proj of projects) {
105
- sources.push({ id: proj.toLowerCase(), provider: 'jira', ref: proj, display: `Jira/${proj}` });
106
- }
107
- // Always add a generic "jira" entry
108
- sources.push({ id: 'jira', provider: 'jira', ref: null, display: 'Jira' });
109
- }
110
-
111
- // Linear
112
- if (env.LINEAR_API_KEY) {
113
- sources.push({ id: 'linear', provider: 'linear', ref: null, display: 'Linear' });
114
- const teams = (env.LINEAR_TEAMS ?? '').split(',').map(t => t.trim()).filter(Boolean);
115
- for (const team of teams) {
116
- sources.push({ id: team.toLowerCase(), provider: 'linear', ref: team, display: `Linear/${team}` });
92
+ for (const descriptor of listSourceTargetDescriptors()) {
93
+ const gate = descriptor.aliases?.catchAllCredentialEnv;
94
+ if (!descriptor.aliases?.catchAll || !Array.isArray(gate)) continue;
95
+ const credentialed = gate.some((name) => String(env[name] ?? '').trim());
96
+ if (credentialed) {
97
+ sources.push({ id: descriptor.provider, provider: descriptor.provider, ref: null, display: descriptor.aliases.displayPrefix });
117
98
  }
118
99
  }
119
100
 
@@ -413,9 +394,21 @@ async function demandFetchAll({ query, rootDir, env, cwd } = {}) {
413
394
  const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
414
395
  const scoped = targets.length > 0;
415
396
 
397
+ // The unscoped fallback ("pull everything fresh") only drives providers that
398
+ // can be discovered from env/poll config — those whose descriptor declares an
399
+ // `embed` or `legacyEnv` block. A target-only provider (directory) has no
400
+ // env-derived default source, so it is excluded here rather than fetched with
401
+ // an empty selector; it participates through the scoped path once a target is
402
+ // configured. Dispatch is on the descriptor, never a provider name.
403
+
404
+ const envFetchable = new Set(
405
+ listSourceTargetDescriptors()
406
+ .filter((d) => d.embed || d.legacyEnv)
407
+ .map((d) => d.provider),
408
+ );
416
409
  const providerNames = scoped
417
410
  ? [...new Set(targets.map((t) => t.provider))].filter((name) => registry.get(name))
418
- : registry.names();
411
+ : [...envFetchable].filter((name) => registry.get(name));
419
412
 
420
413
  if (!providerNames.length) {
421
414
  return {
@@ -579,6 +572,7 @@ function buildReadCalls(match, env, cwd = process.cwd()) {
579
572
  }
580
573
 
581
574
  if (df.queryOptsKind === 'scalar') {
575
+ if (match.ref == null && !df.queryOptsNullable) return [];
582
576
  const value = df.queryOptsNullable ? (match.ref ?? null) : match.ref;
583
577
  return [{ ref: df.queryRefs[0], opts: { [df.queryOptsField]: value, limit: df.queryDefaultLimit }, display: match.display }];
584
578
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * lib/embed/providers/directory.mjs — local directory provider for embed mode.
3
+ *
4
+ * Reads markdown/doc files from a registered local directory target so a
5
+ * checked-out repository or docs folder can act as a context source. Purely
6
+ * local: it walks the filesystem and never opens a socket, satisfying the
7
+ * directory-targets-never-touch-the-network invariant (bead construct-760c.1
8
+ * R6). Requires no credentials, so ProviderRegistry always registers it.
9
+ *
10
+ * Supported refs:
11
+ * docs Doc/markdown files under the path (bounded depth, doc extensions)
12
+ * readme The top-level README, when present
13
+ * meta A one-line summary of the directory (path + doc file count)
14
+ */
15
+
16
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { basename, extname, join, relative } from 'node:path';
18
+
19
+ import { expandTilde } from '../../config/source-targets.mjs';
20
+
21
+ const DOC_EXTS = new Set(['.md', '.mdx', '.markdown', '.txt', '.rst', '.adoc']);
22
+
23
+ // Directories that are noise for a docs corpus: version-control internals,
24
+ // dependency trees, and build output dwarf the actual documentation.
25
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.construct', '.cx', 'dist', 'build', 'coverage', '.next', 'vendor']);
26
+
27
+ const DOC_CONTENT_LIMIT = 8_000;
28
+ const MAX_DEPTH = 10;
29
+
30
+ function isReadme(name) {
31
+ return /^readme(\.\w+)?$/i.test(name);
32
+ }
33
+
34
+ function collectDocFiles(rootDir) {
35
+ const files = [];
36
+ const stack = [{ dir: rootDir, depth: 0 }];
37
+ while (stack.length > 0) {
38
+ const { dir, depth } = stack.pop();
39
+ if (depth >= MAX_DEPTH) continue;
40
+ let entries;
41
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
42
+ for (const entry of entries) {
43
+ if (entry.name.startsWith('.') && entry.isDirectory()) continue;
44
+ const full = join(dir, entry.name);
45
+ if (entry.isDirectory()) {
46
+ if (SKIP_DIRS.has(entry.name)) continue;
47
+ stack.push({ dir: full, depth: depth + 1 });
48
+ continue;
49
+ }
50
+ if (entry.isFile() && DOC_EXTS.has(extname(entry.name).toLowerCase())) files.push(full);
51
+ }
52
+ }
53
+ return files.sort((a, b) => a.localeCompare(b));
54
+ }
55
+
56
+ function readDoc(rootDir, filePath) {
57
+ const rel = relative(rootDir, filePath);
58
+ let content = '';
59
+ try { content = readFileSync(filePath, 'utf8').slice(0, DOC_CONTENT_LIMIT); } catch { return null; }
60
+ return {
61
+ type: 'doc',
62
+ source: 'directory',
63
+ path: rel,
64
+ content,
65
+ summary: `Doc ${rel}`,
66
+ };
67
+ }
68
+
69
+ export class DirectoryProvider {
70
+ /**
71
+ * @param {string} ref - 'docs' | 'readme' | 'meta'
72
+ * @param {object} opts - { path, limit }
73
+ * @returns {Promise<Item[]>}
74
+ */
75
+ async read(ref, opts = {}) {
76
+ const rawPath = opts.path ?? opts.dir;
77
+ if (!rawPath) throw new Error('Directory source requires a "path" field');
78
+ const rootDir = expandTilde(String(rawPath));
79
+
80
+ let stat;
81
+ try { stat = statSync(rootDir); } catch {
82
+ return [{ type: 'error', source: 'directory', path: rootDir, ref, message: `path not found: ${rootDir}` }];
83
+ }
84
+ if (!stat.isDirectory()) {
85
+ return [{ type: 'error', source: 'directory', path: rootDir, ref, message: `not a directory: ${rootDir}` }];
86
+ }
87
+
88
+ const limit = Number(opts.limit ?? 100);
89
+
90
+ if (ref === 'meta') {
91
+ const count = collectDocFiles(rootDir).length;
92
+ return [{
93
+ type: 'meta',
94
+ source: 'directory',
95
+ path: rootDir,
96
+ description: `Local directory ${basename(rootDir)} (${count} doc file(s))`,
97
+ summary: `Directory ${rootDir}: ${count} doc file(s)`,
98
+ }];
99
+ }
100
+
101
+ const files = collectDocFiles(rootDir);
102
+
103
+ if (ref === 'readme') {
104
+ const readme = files.find((f) => isReadme(basename(f)));
105
+ if (!readme) return [];
106
+ const rec = readDoc(rootDir, readme);
107
+ return rec ? [rec] : [];
108
+ }
109
+
110
+ const records = [];
111
+ for (const file of files.slice(0, limit)) {
112
+ const rec = readDoc(rootDir, file);
113
+ if (rec) records.push(rec);
114
+ }
115
+ return records;
116
+ }
117
+ }
@@ -37,8 +37,15 @@ import { loadManifestsFromDir, mergeManifests, resolveManifestDirs } from '../..
37
37
  * `credentials` is one of:
38
38
  * { anyOf: [...] } — usable if at least one listed env var is set (GitHub)
39
39
  * { allOf: [...] } — usable only if every listed env var is set (Jira)
40
+ * null — no credentials required, always usable (directory)
40
41
  */
41
42
  const ADAPTERS = {
43
+ directory: {
44
+ aliases: ['directory', 'dir'],
45
+ credentials: null,
46
+ load: () => import('./directory.mjs'),
47
+ build: (mod) => new mod.DirectoryProvider(),
48
+ },
42
49
  github: {
43
50
  aliases: ['github', 'gh'],
44
51
  credentials: { anyOf: ['GITHUB_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'GH_TOKEN'] },
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "signature": { "template": "jira:project:{value}", "valueMode": "upperOrEmpty" },
22
22
  "legacyEnv": { "vars": ["JIRA_PROJECTS"], "parse": "csv", "valueTransform": "upper" },
23
- "aliases": { "strategy": "simple", "displayPrefix": "Jira", "catchAll": true },
23
+ "aliases": { "strategy": "simple", "displayPrefix": "Jira", "catchAll": true, "catchAllCredentialEnv": ["JIRA_BASE_URL"] },
24
24
  "embed": {
25
25
  "mode": "perTarget",
26
26
  "valueField": "project",
@@ -8,5 +8,28 @@
8
8
  "securityClassification": "trusted",
9
9
  "owner": "construct-core",
10
10
  "compatVersion": 1,
11
- "installSource": "builtin"
11
+ "installSource": "builtin",
12
+ "sourceTarget": {
13
+ "provider": "directory",
14
+ "selector": {
15
+ "field": "path",
16
+ "hint": "required existing directory path (tilde-expanded)",
17
+ "normalize": "trim",
18
+ "expand": "tilde",
19
+ "existsAs": "directory",
20
+ "existsHint": "path must exist and be a directory"
21
+ },
22
+ "signature": { "template": "directory:path:{value}", "valueMode": "path" },
23
+ "aliases": { "strategy": "path", "displayPrefix": null, "catchAll": false },
24
+ "demandFetch": {
25
+ "targetRefs": ["docs"],
26
+ "targetOptsField": "path",
27
+ "targetOptsKind": "scalar",
28
+ "targetDefaultLimit": 100,
29
+ "queryRefs": ["docs"],
30
+ "queryOptsField": "path",
31
+ "queryOptsKind": "scalar",
32
+ "queryDefaultLimit": 100
33
+ }
34
+ }
12
35
  }
@@ -19,6 +19,16 @@
19
19
  "normalize": "trim"
20
20
  },
21
21
  "signature": { "template": "github:repo:{value}", "valueMode": "optionalLower" },
22
+ "content": {
23
+ "field": "content",
24
+ "modeField": "mode",
25
+ "modeValue": "corpus",
26
+ "refField": "ref",
27
+ "defaultRef": "main",
28
+ "remoteField": "remote",
29
+ "remoteTemplate": "https://github.com/{value}.git",
30
+ "clone": "git"
31
+ },
22
32
  "legacyEnv": { "vars": ["GITHUB_REPOS", "GITHUB_REPO"], "parse": "csv" },
23
33
  "aliases": { "strategy": "repoWords", "displayPrefix": null, "catchAll": false },
24
34
  "embed": {
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "signature": { "template": "linear:team:{value}", "valueMode": "lowerOrEmpty" },
22
22
  "legacyEnv": { "vars": ["LINEAR_TEAMS"], "parse": "csv" },
23
- "aliases": { "strategy": "simple", "displayPrefix": "Linear", "catchAll": true },
23
+ "aliases": { "strategy": "simple", "displayPrefix": "Linear", "catchAll": true, "catchAllCredentialEnv": ["LINEAR_API_KEY"] },
24
24
  "embed": {
25
25
  "mode": "perTarget",
26
26
  "valueField": "team",
@@ -269,19 +269,26 @@ let embedStatusNote = '';
269
269
  try {
270
270
  const { loadProjectConfig } = await import('../config/project-config.mjs');
271
271
  const { resolveEffectiveSourceTargetsFromConfig } = await import('../config/source-targets.mjs');
272
+ const { listSourceTargetDescriptors } = await import('../config/source-target-registry.mjs');
272
273
  const { config } = loadProjectConfig(process.cwd(), process.env);
273
274
  const targets = resolveEffectiveSourceTargetsFromConfig(config, process.env);
274
- const repos = targets.filter((t) => t.provider === 'github').map((t) => t.selector.repo);
275
- const jiraTargets = targets.filter((t) => t.provider === 'jira');
276
- const linearTargets = targets.filter((t) => t.provider === 'linear');
277
- const slackTargets = targets.filter((t) => t.provider === 'slack');
278
- const hasSources = targets.length > 0;
279
- if (hasSources) {
280
- const parts = [];
281
- if (repos.length > 0) parts.push(`GitHub repos: ${repos.join(', ')}`);
282
- if (jiraTargets.length > 0) parts.push(`Jira projects: ${jiraTargets.map((t) => t.selector.project).join(', ')}`);
283
- if (linearTargets.length > 0) parts.push(`Linear teams: ${linearTargets.map((t) => t.selector.team).join(', ')}`);
284
- if (slackTargets.length > 0) parts.push(`Slack channels: ${slackTargets.map((t) => t.selector.channel).join(', ')}`);
275
+
276
+ // Iterate the manifest-declared descriptors instead of naming providers here,
277
+ // so a newly manifest-registered provider_fetch source appears in the hint
278
+ // automatically rather than being silently dropped (construct-j745). Gate on
279
+ // `embed`: the network provider_fetch targets declare it, while a content-only
280
+ // `directory` target does not — and `provider_fetch` does not apply to it.
281
+
282
+ const parts = [];
283
+ for (const d of listSourceTargetDescriptors()) {
284
+ const field = d.embed ? d.selector?.field : null;
285
+ if (!field) continue;
286
+ const values = targets.filter((t) => t.provider === d.provider).map((t) => t.selector?.[field]).filter(Boolean);
287
+ if (values.length === 0) continue;
288
+ const label = d.aliases?.displayPrefix || (d.provider.charAt(0).toUpperCase() + d.provider.slice(1));
289
+ parts.push(`${label} ${field}s: ${values.join(', ')}`);
290
+ }
291
+ if (parts.length > 0) {
285
292
  sourcesNote = '\nProvider sources wired: ' + parts.join(' · ') +
286
293
  '. Use `provider_fetch` for any question about them.\n';
287
294
  }
@@ -1234,15 +1234,10 @@ async function main() {
1234
1234
  try {
1235
1235
  const { startServices } = await import('./service-manager.mjs');
1236
1236
  const { homedir } = await import('node:os');
1237
- const { detectDockerCompose } = await import('./setup.mjs');
1238
-
1239
- const homeDir = homedir();
1240
- const composeRunner = detectDockerCompose();
1241
-
1237
+
1242
1238
  const { results } = await startServices({
1243
1239
  rootDir: target,
1244
- homeDir,
1245
- detectDockerComposeFn: () => composeRunner,
1240
+ homeDir: homedir(),
1246
1241
  });
1247
1242
 
1248
1243
  if (!quiet) {
@@ -68,27 +68,47 @@ function loadObservationChunks(rootDir) {
68
68
  }
69
69
  }
70
70
 
71
+ // The host project is the reserved origin: no target id, a `self` project key.
72
+ // Registered content targets carry their own origin (targetId, provider,
73
+ // projectKey, ref); the corpus builder stamps per-file `relPath` onto both.
74
+
75
+ const SELF_ORIGIN = Object.freeze({ targetId: null, provider: 'self', projectKey: 'self', ref: null });
76
+
77
+ function withOrigin(chunk, origin, relPath) {
78
+ return { ...chunk, origin: { ...origin, relPath: relPath ?? chunk.origin?.relPath ?? null } };
79
+ }
80
+
71
81
  /**
72
- * Load markdown files from a directory tree as indexable chunks.
82
+ * Load markdown files from a directory tree as indexable chunks. `origin`, when
83
+ * given, tags every chunk with its source project and per-file relative path so
84
+ * cross-project retrieval can attribute and filter the result; `baseDir`
85
+ * anchors the relative path (defaults to `dir`).
73
86
  */
74
- function loadMarkdownChunks(dir, source) {
87
+ function loadMarkdownChunks(dir, source, { origin = null, baseDir = dir } = {}) {
75
88
  if (!fs.existsSync(dir)) return [];
76
89
  const chunks = [];
77
90
  const walk = (d) => {
78
91
  for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
79
92
  const full = path.join(d, entry.name);
80
- if (entry.isDirectory()) { walk(full); continue; }
93
+ if (entry.isDirectory()) {
94
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
95
+ walk(full);
96
+ continue;
97
+ }
81
98
  if (!entry.name.endsWith('.md')) continue;
82
99
  try {
83
100
  const content = fs.readFileSync(full, 'utf8');
84
101
  const titleMatch = content.match(/^#\s+(.+)/m);
102
+ const relPath = path.relative(baseDir, full);
103
+ const idScope = origin?.targetId ? `${origin.targetId}:` : '';
85
104
  chunks.push({
86
- id: `${source}:${path.relative(process.cwd(), full)}`,
105
+ id: `${source}:${idScope}${path.relative(process.cwd(), full)}`,
87
106
  source,
88
107
  title: titleMatch ? titleMatch[1].trim() : entry.name,
89
108
  body: content,
90
109
  filePath: full,
91
110
  createdAt: fs.statSync(full).mtime.toISOString(),
111
+ ...(origin ? { origin: { ...origin, relPath } } : {}),
92
112
  });
93
113
  } catch { /* skip unreadable */ }
94
114
  }
@@ -174,15 +194,33 @@ function loadKnowledgeChunks(rootDir) {
174
194
 
175
195
  /**
176
196
  * Build a full in-memory corpus from all sources.
177
- * Each chunk gets an embedding vector for cosine scoring.
197
+ * Each chunk gets an embedding vector for cosine scoring and an `origin` tag.
198
+ *
199
+ * The single-`rootDir` signature is preserved for existing callers: passing a
200
+ * string (or nothing) indexes only the host project, whose chunks carry the
201
+ * reserved self origin. Passing `roots` — content-capable target roots resolved
202
+ * via lib/sources/content-roots.mjs — folds each registered project's markdown
203
+ * into the same corpus, every chunk attributed to its source project so
204
+ * retrieval can cite and filter across projects (bead construct-760c.2).
205
+ *
206
+ * @param {string} [rootDir]
207
+ * @param {object} [opts]
208
+ * @param {{dir: string, origin: object}[]} [opts.roots] — extra content roots
178
209
  */
179
- export function buildCorpus(rootDir = process.cwd()) {
180
- const chunks = [
210
+ export function buildCorpus(rootDir = process.cwd(), { roots = [] } = {}) {
211
+ const hostChunks = [
181
212
  ...loadObservationChunks(rootDir),
182
213
  ...loadArtifactChunks(rootDir),
183
214
  ...loadSnapshotChunks(rootDir),
184
215
  ...loadKnowledgeChunks(rootDir),
185
- ];
216
+ ].map((chunk) => withOrigin(chunk, SELF_ORIGIN, chunk.filePath ? path.relative(rootDir, chunk.filePath) : null));
217
+
218
+ const rootChunks = [];
219
+ for (const { dir, origin } of roots) {
220
+ rootChunks.push(...loadMarkdownChunks(dir, 'target', { origin, baseDir: dir }));
221
+ }
222
+
223
+ const chunks = [...hostChunks, ...rootChunks];
186
224
 
187
225
  // Embed each chunk
188
226
  return chunks.map((chunk) => ({
@@ -261,8 +299,11 @@ export function assembleContext(chunks) {
261
299
 
262
300
  for (const chunk of chunks) {
263
301
  const preview = chunk.body?.slice(0, CHUNK_PREVIEW) || '';
302
+ const projectKey = chunk.origin?.projectKey;
264
303
  const meta = [
265
304
  chunk.source && `source:${chunk.source}`,
305
+ projectKey && projectKey !== 'self' && `project:${projectKey}`,
306
+ chunk.origin?.relPath && `path:${chunk.origin.relPath}`,
266
307
  chunk.role && `role:${chunk.role}`,
267
308
  chunk.category && `category:${chunk.category}`,
268
309
  chunk.createdAt && `date:${chunk.createdAt.slice(0, 10)}`,
@@ -302,7 +343,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
302
343
  if (dryRun) {
303
344
  return {
304
345
  answer: null,
305
- sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
346
+ sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
306
347
  query: question,
307
348
  };
308
349
  }
@@ -333,7 +374,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
333
374
  .join('\n\n');
334
375
  return {
335
376
  answer: `[Claude CLI unavailable — showing retrieved context]\n\n${fallback}`,
336
- sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
377
+ sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
337
378
  query: question,
338
379
  cliMissing: true,
339
380
  };
@@ -341,7 +382,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
341
382
 
342
383
  return {
343
384
  answer: (result.stdout || '').trim(),
344
- sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
385
+ sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
345
386
  query: question,
346
387
  };
347
388
  }