@geraldmaron/construct 1.5.1 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +7 -1
  2. package/bin/construct +354 -19
  3. package/lib/adapters-sync.mjs +1 -1
  4. package/lib/artifact-loop-core.mjs +26 -5
  5. package/lib/artifact-manifest-overlay.mjs +273 -0
  6. package/lib/artifact-manifest.mjs +13 -10
  7. package/lib/auto-docs.mjs +5 -1
  8. package/lib/cli-commands.mjs +51 -6
  9. package/lib/config/source-target-registry.mjs +1 -0
  10. package/lib/config/source-targets.mjs +30 -0
  11. package/lib/decisions/golden.mjs +17 -18
  12. package/lib/doc-stamp.mjs +12 -0
  13. package/lib/doctor/index.mjs +2 -1
  14. package/lib/doctor/source-target-health.mjs +101 -0
  15. package/lib/doctor/watchers/source-targets.mjs +44 -0
  16. package/lib/document-ingest.mjs +25 -3
  17. package/lib/embed/demand-fetch.mjs +30 -36
  18. package/lib/embed/providers/directory.mjs +117 -0
  19. package/lib/embed/providers/registry.mjs +7 -0
  20. package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
  21. package/lib/extensions/manifests/directory.manifest.json +24 -1
  22. package/lib/extensions/manifests/github.manifest.json +10 -0
  23. package/lib/extensions/manifests/linear.manifest.json +1 -1
  24. package/lib/hooks/session-start.mjs +18 -11
  25. package/lib/knowledge/rag.mjs +52 -11
  26. package/lib/knowledge/search.mjs +69 -6
  27. package/lib/knowledge/synthesis.mjs +157 -0
  28. package/lib/mcp/server.mjs +2 -2
  29. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  30. package/lib/mcp/tools/artifact-author.mjs +126 -13
  31. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  32. package/lib/mcp/tools/skills.mjs +17 -0
  33. package/lib/model-cheapest-provider.mjs +2 -1
  34. package/lib/model-policy.mjs +329 -0
  35. package/lib/model-router.mjs +10 -9
  36. package/lib/model-tiers.mjs +27 -0
  37. package/lib/models/catalog.mjs +5 -4
  38. package/lib/orchestration/classification.mjs +11 -0
  39. package/lib/orchestration/context-bindings.mjs +85 -0
  40. package/lib/orchestration/readiness.mjs +2 -1
  41. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  42. package/lib/orchestration/runtime.mjs +35 -1
  43. package/lib/orchestration/worker.mjs +9 -0
  44. package/lib/review-pr.mjs +102 -0
  45. package/lib/setup.mjs +2 -1
  46. package/lib/sources/content-roots.mjs +147 -0
  47. package/lib/sources/repo-cache.mjs +142 -0
  48. package/lib/template-registry.mjs +2 -0
  49. package/lib/tracker/contribute.mjs +266 -0
  50. package/lib/validator.mjs +2 -3
  51. package/package.json +1 -5
  52. package/registry/agent-manifest.json +0 -4
  53. package/registry/capabilities.json +20 -1
  54. package/scripts/sync-specialists.mjs +12 -3
  55. package/templates/docs/README.md +22 -0
  56. package/templates/docs/adhoc.md +12 -0
  57. package/templates/docs/strategy-comparison.md +19 -0
@@ -29,11 +29,12 @@ import * as graphStaleness from './watchers/graph-staleness.mjs';
29
29
  import * as providerBreaker from './watchers/provider-breaker.mjs';
30
30
  import * as oracleLiveness from './watchers/oracle-liveness.mjs';
31
31
  import * as orchestrationRuns from './watchers/orchestration-runs.mjs';
32
+ import * as sourceTargets from './watchers/source-targets.mjs';
32
33
  import { stateDir } from '../config/xdg.mjs';
33
34
  import { isMainModule } from '../roots.mjs';
34
35
 
35
36
  const STATE_PATH = join(stateDir(), 'doctor.json');
36
- const WATCHERS = [disk, cost, processPressure, serviceHealth, bdWatch, handoffs, consistency, mcpProtocol, cxBudget, graphStaleness, providerBreaker, oracleLiveness, orchestrationRuns];
37
+ const WATCHERS = [disk, cost, processPressure, serviceHealth, bdWatch, handoffs, consistency, mcpProtocol, cxBudget, graphStaleness, providerBreaker, oracleLiveness, orchestrationRuns, sourceTargets];
37
38
 
38
39
  let running = false;
39
40
  let timers = [];
@@ -0,0 +1,101 @@
1
+ /**
2
+ * lib/doctor/source-target-health.mjs — health of registered source targets
3
+ * (bead construct-760c.8, epic closer for multi-project context targets).
4
+ *
5
+ * Filesystem- and env-only: it resolves the project's `sources.targets[]` and
6
+ * reports three classes of problem without ever opening a socket, so the default
7
+ * `construct doctor` keeps its zero-outbound-fetch invariant (deep connectivity
8
+ * probes stay behind `--probe-providers`):
9
+ * - a directory target whose path does not resolve,
10
+ * - a corpus target whose local cache is missing or older than its TTL
11
+ * (actionable: `construct sources sync <id>`),
12
+ * - a network-backed target whose credential env var is not present (a
13
+ * presence check only, surfaced as a soft notice).
14
+ *
15
+ * With no targets configured it returns an empty finding set — the caller emits
16
+ * nothing, so a project that never registered a target sees no source-target
17
+ * noise (R2).
18
+ */
19
+
20
+ import { statSync } from 'node:fs';
21
+
22
+ import { loadProjectConfig } from '../config/project-config.mjs';
23
+ import { getSourceTargetDescriptor } from '../config/source-target-registry.mjs';
24
+ import { expandTilde, resolveEffectiveSourceTargetsFromConfig } from '../config/source-targets.mjs';
25
+ import { isCorpusTarget, corpusFreshness, DEFAULT_CORPUS_TTL_MS } from '../sources/repo-cache.mjs';
26
+
27
+ // Credential env var per network-backed provider — presence-only, never read for
28
+ // its value. Directory targets need none and are absent from the map.
29
+ const CREDENTIAL_ENV = {
30
+ github: 'GITHUB_TOKEN',
31
+ jira: 'JIRA_API_TOKEN',
32
+ confluence: 'CONFLUENCE_API_TOKEN',
33
+ slack: 'SLACK_BOT_TOKEN',
34
+ linear: 'LINEAR_API_KEY',
35
+ };
36
+
37
+ function directoryFinding(target) {
38
+ const descriptor = getSourceTargetDescriptor(target.provider);
39
+ const raw = target.selector?.[descriptor.selector.field];
40
+ const dir = expandTilde(String(raw ?? ''));
41
+ let ok = false;
42
+ try { ok = statSync(dir).isDirectory(); } catch { ok = false; }
43
+ return ok
44
+ ? { label: `Source target ${target.id} (directory) path resolves`, ok: true }
45
+ : { label: `Source target ${target.id} (directory) path missing: ${dir} — fix the path or run \`construct sources remove ${target.id}\``, ok: false };
46
+ }
47
+
48
+ function corpusFinding(target, { cwd, ttlMs, now }) {
49
+ const f = corpusFreshness(target, { projectRoot: cwd, ttlMs, now });
50
+ if (!f.cached) {
51
+ return { label: `Source target ${target.id} (corpus) not yet cached — run \`construct sources sync ${target.id}\``, ok: false, optional: true };
52
+ }
53
+ if (f.stale) {
54
+ return { label: `Source target ${target.id} (corpus) cache is stale — run \`construct sources sync ${target.id}\``, ok: false, optional: true };
55
+ }
56
+ return { label: `Source target ${target.id} (corpus) cache is fresh`, ok: true };
57
+ }
58
+
59
+ function credentialFinding(target, env) {
60
+ const varName = CREDENTIAL_ENV[target.provider];
61
+ if (!varName) return null;
62
+ const present = typeof env[varName] === 'string' && env[varName].trim() !== '';
63
+ return present
64
+ ? null
65
+ : { label: `Source target ${target.id} (${target.provider}) credential not detected (${varName}) — presence check only`, ok: false, optional: true };
66
+ }
67
+
68
+ /**
69
+ * Evaluate source-target health. Returns { configured, findings } where each
70
+ * finding is { label, ok, optional? }. An empty findings array (configured 0)
71
+ * means the caller should stay silent.
72
+ *
73
+ * @param {object} [opts]
74
+ * @param {string} [opts.cwd]
75
+ * @param {object} [opts.env]
76
+ * @param {number} [opts.ttlMs] corpus staleness threshold
77
+ * @param {number} [opts.now] injectable clock (ms) for testing
78
+ */
79
+ export function checkSourceTargetHealth({ cwd = process.cwd(), env = process.env, ttlMs = DEFAULT_CORPUS_TTL_MS, now = Date.now() } = {}) {
80
+ // Read the DECLARED targets from raw config, not the validated `config` — a
81
+ // directory target whose path was deleted fails config validation and would
82
+ // otherwise be sanitized away to an empty set, hiding the very problem this
83
+ // watcher exists to flag. `raw` preserves what the user actually registered.
84
+ const loaded = loadProjectConfig(cwd, env);
85
+ const sourceConfig = loaded.raw ?? loaded.config;
86
+ const targets = resolveEffectiveSourceTargetsFromConfig(sourceConfig, env);
87
+ if (!targets.length) return { configured: 0, findings: [] };
88
+
89
+ const findings = [];
90
+ for (const target of targets) {
91
+ const descriptor = getSourceTargetDescriptor(target.provider);
92
+ if (descriptor?.selector?.existsAs === 'directory') {
93
+ findings.push(directoryFinding(target));
94
+ } else if (isCorpusTarget(target)) {
95
+ findings.push(corpusFinding(target, { cwd, ttlMs, now }));
96
+ }
97
+ const cred = credentialFinding(target, env);
98
+ if (cred) findings.push(cred);
99
+ }
100
+ return { configured: targets.length, findings };
101
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * lib/doctor/watchers/source-targets.mjs — registered source-target health watcher.
3
+ *
4
+ * Ticks hourly. Reuses lib/doctor/source-target-health.mjs (filesystem + env
5
+ * only, zero outbound fetch) to surface directory targets whose path vanished
6
+ * and corpus caches past their TTL, recording an audit action per hard problem
7
+ * so doctor reports source-target drift between Oracle ticks. A project with no
8
+ * registered targets returns a silent pass (no noise).
9
+ */
10
+
11
+ import { record } from '../audit.mjs';
12
+ import { checkSourceTargetHealth } from '../source-target-health.mjs';
13
+
14
+ export const name = 'source-targets';
15
+ export const intervalMs = 60 * 60 * 1000;
16
+
17
+ function projectRoot() {
18
+ return process.env.CONSTRUCT_PROJECT_ROOT || process.cwd();
19
+ }
20
+
21
+ export async function tick() {
22
+ const actions = [];
23
+ const escalations = [];
24
+ const root = projectRoot();
25
+
26
+ const { configured, findings } = checkSourceTargetHealth({ cwd: root });
27
+ if (!configured) {
28
+ return { actions, escalations, notes: [{ configured: 0 }] };
29
+ }
30
+
31
+ const problems = findings.filter((f) => !f.ok && !f.optional);
32
+ for (const problem of problems) {
33
+ record({
34
+ kind: 'action',
35
+ watcher: name,
36
+ action: 'source-target-unhealthy',
37
+ target: 'sources.targets',
38
+ summary: problem.label,
39
+ });
40
+ actions.push({ type: 'source-target-unhealthy', target: 'sources.targets' });
41
+ }
42
+
43
+ return { actions, escalations, notes: [{ configured, problems: problems.length }] };
44
+ }
@@ -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
  }