@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.
- package/README.md +7 -1
- package/bin/construct +354 -19
- package/lib/adapters-sync.mjs +1 -1
- package/lib/artifact-loop-core.mjs +26 -5
- package/lib/artifact-manifest-overlay.mjs +273 -0
- package/lib/artifact-manifest.mjs +13 -10
- package/lib/auto-docs.mjs +5 -1
- package/lib/cli-commands.mjs +51 -6
- package/lib/config/source-target-registry.mjs +1 -0
- package/lib/config/source-targets.mjs +30 -0
- package/lib/decisions/golden.mjs +17 -18
- package/lib/doc-stamp.mjs +12 -0
- package/lib/doctor/index.mjs +2 -1
- package/lib/doctor/source-target-health.mjs +101 -0
- package/lib/doctor/watchers/source-targets.mjs +44 -0
- package/lib/document-ingest.mjs +25 -3
- package/lib/embed/demand-fetch.mjs +30 -36
- package/lib/embed/providers/directory.mjs +117 -0
- package/lib/embed/providers/registry.mjs +7 -0
- package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
- package/lib/extensions/manifests/directory.manifest.json +24 -1
- package/lib/extensions/manifests/github.manifest.json +10 -0
- package/lib/extensions/manifests/linear.manifest.json +1 -1
- package/lib/hooks/session-start.mjs +18 -11
- package/lib/knowledge/rag.mjs +52 -11
- package/lib/knowledge/search.mjs +69 -6
- package/lib/knowledge/synthesis.mjs +157 -0
- package/lib/mcp/server.mjs +2 -2
- package/lib/mcp/tool-definitions-workflow.mjs +35 -7
- package/lib/mcp/tools/artifact-author.mjs +126 -13
- package/lib/mcp/tools/orchestration-run.mjs +3 -0
- package/lib/mcp/tools/skills.mjs +17 -0
- package/lib/model-cheapest-provider.mjs +2 -1
- package/lib/model-policy.mjs +329 -0
- package/lib/model-router.mjs +10 -9
- package/lib/model-tiers.mjs +27 -0
- package/lib/models/catalog.mjs +5 -4
- package/lib/orchestration/classification.mjs +11 -0
- package/lib/orchestration/context-bindings.mjs +85 -0
- package/lib/orchestration/readiness.mjs +2 -1
- package/lib/orchestration/research-evidence-gate.mjs +104 -0
- package/lib/orchestration/runtime.mjs +35 -1
- package/lib/orchestration/worker.mjs +9 -0
- package/lib/review-pr.mjs +102 -0
- package/lib/setup.mjs +2 -1
- package/lib/sources/content-roots.mjs +147 -0
- package/lib/sources/repo-cache.mjs +142 -0
- package/lib/template-registry.mjs +2 -0
- package/lib/tracker/contribute.mjs +266 -0
- package/lib/validator.mjs +2 -3
- package/package.json +1 -5
- package/registry/agent-manifest.json +0 -4
- package/registry/capabilities.json +20 -1
- package/scripts/sync-specialists.mjs +12 -3
- package/templates/docs/README.md +22 -0
- package/templates/docs/adhoc.md +12 -0
- package/templates/docs/strategy-comparison.md +19 -0
package/lib/doctor/index.mjs
CHANGED
|
@@ -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
|
+
}
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
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
|
}
|