@geraldmaron/construct 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +3 -0
  2. package/bin/construct +336 -17
  3. package/lib/artifact-loop-core.mjs +26 -5
  4. package/lib/artifact-manifest-overlay.mjs +273 -0
  5. package/lib/artifact-manifest.mjs +13 -10
  6. package/lib/cli-commands.mjs +44 -4
  7. package/lib/config/source-target-registry.mjs +1 -0
  8. package/lib/config/source-targets.mjs +30 -0
  9. package/lib/doc-stamp.mjs +12 -0
  10. package/lib/doctor/index.mjs +2 -1
  11. package/lib/doctor/source-target-health.mjs +101 -0
  12. package/lib/doctor/watchers/source-targets.mjs +44 -0
  13. package/lib/document-ingest.mjs +25 -3
  14. package/lib/embed/demand-fetch.mjs +30 -36
  15. package/lib/embed/providers/directory.mjs +117 -0
  16. package/lib/embed/providers/registry.mjs +7 -0
  17. package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
  18. package/lib/extensions/manifests/directory.manifest.json +24 -1
  19. package/lib/extensions/manifests/github.manifest.json +10 -0
  20. package/lib/extensions/manifests/linear.manifest.json +1 -1
  21. package/lib/hooks/session-start.mjs +18 -11
  22. package/lib/init-unified.mjs +2 -7
  23. package/lib/knowledge/rag.mjs +52 -11
  24. package/lib/knowledge/search.mjs +69 -6
  25. package/lib/knowledge/synthesis.mjs +157 -0
  26. package/lib/mcp/server.mjs +2 -2
  27. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  28. package/lib/mcp/tools/artifact-author.mjs +126 -13
  29. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  30. package/lib/mcp/tools/skills.mjs +17 -0
  31. package/lib/model-cheapest-provider.mjs +2 -1
  32. package/lib/model-policy.mjs +329 -0
  33. package/lib/model-router.mjs +10 -9
  34. package/lib/model-tiers.mjs +27 -0
  35. package/lib/models/catalog.mjs +5 -4
  36. package/lib/orchestration/classification.mjs +11 -0
  37. package/lib/orchestration/context-bindings.mjs +85 -0
  38. package/lib/orchestration/readiness.mjs +2 -1
  39. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  40. package/lib/orchestration/runtime.mjs +35 -1
  41. package/lib/orchestration/worker.mjs +9 -0
  42. package/lib/providers/directory/index.mjs +19 -7
  43. package/lib/setup.mjs +2 -1
  44. package/lib/sources/content-roots.mjs +147 -0
  45. package/lib/sources/repo-cache.mjs +142 -0
  46. package/lib/template-registry.mjs +2 -0
  47. package/lib/test-corpus-inventory.mjs +1 -0
  48. package/lib/tracker/contribute.mjs +266 -0
  49. package/lib/validator.mjs +2 -3
  50. package/package.json +1 -1
  51. package/registry/agent-manifest.json +0 -4
  52. package/registry/capabilities.json +20 -1
  53. package/templates/docs/README.md +22 -0
  54. package/templates/docs/adhoc.md +12 -0
  55. package/templates/docs/strategy-comparison.md +19 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * lib/artifact-manifest-overlay.mjs — project/user tier overlays and the
3
+ * sanctioned `adhoc` type for the artifact capability manifest.
4
+ *
5
+ * The builtin manifest (specialists/artifact-manifest.json) is the shipped
6
+ * source of truth and is never modified. A user may extend the registered
7
+ * document classes two ways without touching the builtin:
8
+ *
9
+ * - Register a custom type with `construct templates register <type>`, which
10
+ * writes a project-tier overlay entry here and a matching template file
11
+ * under .construct/templates/docs/<type>.md.
12
+ * - Author a one-off `adhoc` artifact, a code-level sanctioned type injected
13
+ * at load time so it resolves with zero prior registration.
14
+ *
15
+ * Overlays merge over the builtin by the same three-tier precedence the
16
+ * extension loader uses (builtin < user < project), deep-merged per type so an
17
+ * overlay may tweak a single field. The sanctioned adhoc entry sits below the
18
+ * builtin so a builtin type of the same name would always win.
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ // Project-local config dir, mirroring lib/extensions/loader.mjs (project tier
25
+ // = <rootDir>/.cx/). Kept as a local constant so this module has no dependency
26
+ // on the config-dir consolidation that postdates staging.
27
+
28
+ const PROJECT_CONFIG_DIR = '.cx';
29
+
30
+ function configPath(projectRoot, ...segments) {
31
+ return path.join(projectRoot, PROJECT_CONFIG_DIR, ...segments);
32
+ }
33
+
34
+ export const ADHOC_TYPE = 'adhoc';
35
+ export const OVERLAY_FILENAME = 'artifact-manifest.overlay.json';
36
+
37
+ // The adhoc type is instructions-driven: no fixed structure, so its release
38
+ // gate keeps the quality checks (structural lint runs against an empty section
39
+ // set, citation discipline, a one-paragraph prose floor) while leaving the
40
+ // document shape free-form. Free-form structure, not free-form quality.
41
+
42
+ export const SANCTIONED_ARTIFACTS = Object.freeze({
43
+ [ADHOC_TYPE]: {
44
+ template: 'templates/docs/adhoc.md',
45
+ documentClass: ADHOC_TYPE,
46
+ description: 'Sanctioned one-off artifact: structure follows the supplied instructions, quality gates still apply.',
47
+ primaryOwners: ['cx-product-manager', 'cx-operations'],
48
+ toneDefault: 'direct',
49
+ toneAllowed: ['direct', 'executive-concise', 'friendly'],
50
+ aliases: ['ad-hoc', 'free-form', 'freeform'],
51
+ structureRequirements: [],
52
+ visualRequirements: [],
53
+ researchProfile: null,
54
+ outputDir: 'docs/adhoc',
55
+ releaseGate: {
56
+ structuralLint: true,
57
+ citationLint: true,
58
+ proseMinimum: 1,
59
+ requiredReviewers: [],
60
+ optionalReviewers: [],
61
+ },
62
+ },
63
+ });
64
+
65
+ function homeFromEnv(homeDir) {
66
+ return homeDir ?? (process.env.HOME || process.env.USERPROFILE || '');
67
+ }
68
+
69
+ /**
70
+ * Canonical overlay file paths for the user and project tiers.
71
+ *
72
+ * @param {{ cwd?: string, homeDir?: string }} [opts]
73
+ * @returns {{ user: string|null, project: string }}
74
+ */
75
+ export function overlayPaths({ cwd = process.cwd(), homeDir } = {}) {
76
+ const home = homeFromEnv(homeDir);
77
+ return {
78
+ user: home ? path.join(home, '.config', 'construct', OVERLAY_FILENAME) : null,
79
+ project: configPath(cwd, OVERLAY_FILENAME),
80
+ };
81
+ }
82
+
83
+ function readOverlayArtifacts(filePath) {
84
+ if (!filePath || !fs.existsSync(filePath)) return null;
85
+ try {
86
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
87
+ return parsed && typeof parsed.artifacts === 'object' && parsed.artifacts ? parsed.artifacts : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function isPlainObject(value) {
94
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
95
+ }
96
+
97
+ // Deep-merge per artifact type so an overlay entry may override a single field
98
+ // (e.g. outputDir) without restating the whole entry. Arrays replace wholesale
99
+ // — a caller redefining structureRequirements means the full new list.
100
+
101
+ function mergeEntry(base, next) {
102
+ if (!isPlainObject(base)) return structuredClone(next);
103
+ if (!isPlainObject(next)) return structuredClone(next);
104
+ const out = { ...base };
105
+ for (const [key, value] of Object.entries(next)) {
106
+ out[key] = isPlainObject(value) && isPlainObject(out[key]) ? mergeEntry(out[key], value) : structuredClone(value);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function mergeArtifactLayers(...layers) {
112
+ const merged = {};
113
+ for (const layer of layers) {
114
+ if (!isPlainObject(layer)) continue;
115
+ for (const [type, entry] of Object.entries(layer)) {
116
+ merged[type] = mergeEntry(merged[type], entry);
117
+ }
118
+ }
119
+ return merged;
120
+ }
121
+
122
+ /**
123
+ * Merge the sanctioned, user-overlay, and project-overlay artifact maps onto a
124
+ * builtin manifest's artifacts, returning a new manifest object. The builtin
125
+ * top-level fields (version, description, workflowDefaults) are preserved.
126
+ *
127
+ * @param {object} builtinManifest
128
+ * @param {{ cwd?: string, homeDir?: string }} [opts]
129
+ * @returns {object}
130
+ */
131
+ export function applyArtifactOverlays(builtinManifest, { cwd = process.cwd(), homeDir } = {}) {
132
+ const paths = overlayPaths({ cwd, homeDir });
133
+ const userArtifacts = readOverlayArtifacts(paths.user);
134
+ const projectArtifacts = readOverlayArtifacts(paths.project);
135
+ const artifacts = mergeArtifactLayers(
136
+ SANCTIONED_ARTIFACTS,
137
+ builtinManifest.artifacts ?? {},
138
+ userArtifacts,
139
+ projectArtifacts,
140
+ );
141
+ return { ...builtinManifest, artifacts };
142
+ }
143
+
144
+ /**
145
+ * A cheap signature over the overlay files so the manifest cache invalidates
146
+ * when a user registers a type or edits an overlay within one long-running
147
+ * process (the MCP server). Missing files contribute a stable zero.
148
+ *
149
+ * @param {{ cwd?: string, homeDir?: string }} [opts]
150
+ * @returns {string}
151
+ */
152
+ export function overlaySignature({ cwd = process.cwd(), homeDir } = {}) {
153
+ const { user, project } = overlayPaths({ cwd, homeDir });
154
+ return [user, project]
155
+ .map((p) => {
156
+ if (!p) return 'x:0';
157
+ try {
158
+ const stat = fs.statSync(p);
159
+ return `${p}:${stat.mtimeMs}`;
160
+ } catch {
161
+ return `${p}:0`;
162
+ }
163
+ })
164
+ .join('|');
165
+ }
166
+
167
+ function slugType(type) {
168
+ return String(type ?? '')
169
+ .trim()
170
+ .toLowerCase()
171
+ .replace(/[^a-z0-9._-]/g, '-')
172
+ .replace(/^-+|-+$/g, '');
173
+ }
174
+
175
+ // Memo-like defaults per R2: a custom type resolves a sensible author/reviewer
176
+ // chain and a standard release gate unless the overlay entry overrides them.
177
+
178
+ function defaultOverlayEntry(type, { description, template } = {}) {
179
+ return {
180
+ template: template ?? `templates/docs/${type}.md`,
181
+ documentClass: type,
182
+ description: description || `Custom document class: ${type}`,
183
+ primaryOwners: ['cx-product-manager', 'cx-operations'],
184
+ toneDefault: 'direct',
185
+ toneAllowed: ['direct', 'executive-concise', 'friendly'],
186
+ structureRequirements: [],
187
+ visualRequirements: [],
188
+ researchProfile: null,
189
+ releaseGate: {
190
+ structuralLint: true,
191
+ citationLint: true,
192
+ proseMinimum: 1,
193
+ requiredReviewers: [],
194
+ optionalReviewers: [],
195
+ },
196
+ registeredBy: 'construct templates register',
197
+ registeredAt: new Date().toISOString().slice(0, 10),
198
+ };
199
+ }
200
+
201
+ const DEFAULT_TEMPLATE_SKELETON = (type, description) => `# {title}
202
+
203
+ <!-- ${description || `Custom ${type} document`} · registered via \`construct templates register ${type}\` -->
204
+
205
+ ## Summary
206
+
207
+ One-paragraph overview of what this document decides or communicates, in enough
208
+ detail that the release gate's prose floor is met. Replace this text.
209
+
210
+ ## Details
211
+
212
+ Expand the substance here. Cite sources with links or mark unverified claims
213
+ with [unverified] so the citation gate passes.
214
+
215
+ ## Next steps
216
+
217
+ - Action or decision requested
218
+ `;
219
+
220
+ /**
221
+ * Register a custom document class in the project tier: write the template file
222
+ * under .construct/templates/docs/<type>.md and add or update the type's entry
223
+ * in the project overlay. The builtin manifest is never touched.
224
+ *
225
+ * @param {object} args
226
+ * @param {string} args.type
227
+ * @param {string} [args.description]
228
+ * @param {string} [args.from] path to a template file to seed the override
229
+ * @param {string} [args.cwd]
230
+ * @param {string} [args.homeDir]
231
+ * @param {boolean} [args.force] overwrite an existing template file
232
+ * @returns {{ type: string, overlayPath: string, templatePath: string, existed: boolean }}
233
+ */
234
+ export function registerArtifactType({ type, description, from, cwd = process.cwd(), homeDir, force = false } = {}) {
235
+ const safeType = slugType(type);
236
+ if (!safeType) throw new Error('type is required (letters, digits, dot, dash, underscore)');
237
+
238
+ const templatePath = configPath(cwd, 'templates', 'docs', `${safeType}.md`);
239
+ const existed = fs.existsSync(templatePath);
240
+ fs.mkdirSync(path.dirname(templatePath), { recursive: true });
241
+
242
+ let templateBody;
243
+ if (from) {
244
+ const src = path.resolve(cwd, from);
245
+ if (!fs.existsSync(src)) throw new Error(`--from template not found: ${from}`);
246
+ templateBody = fs.readFileSync(src, 'utf8');
247
+ } else if (!existed || force) {
248
+ templateBody = DEFAULT_TEMPLATE_SKELETON(safeType, description);
249
+ }
250
+ if (templateBody !== undefined && (!existed || force || from)) {
251
+ fs.writeFileSync(templatePath, templateBody.endsWith('\n') ? templateBody : `${templateBody}\n`);
252
+ }
253
+
254
+ const { project: overlayPath } = overlayPaths({ cwd, homeDir });
255
+ fs.mkdirSync(path.dirname(overlayPath), { recursive: true });
256
+ let overlay = { version: 1, artifacts: {} };
257
+ if (fs.existsSync(overlayPath)) {
258
+ try {
259
+ const parsed = JSON.parse(fs.readFileSync(overlayPath, 'utf8'));
260
+ if (isPlainObject(parsed)) {
261
+ overlay = { version: parsed.version ?? 1, artifacts: isPlainObject(parsed.artifacts) ? parsed.artifacts : {} };
262
+ }
263
+ } catch {
264
+ overlay = { version: 1, artifacts: {} };
265
+ }
266
+ }
267
+ const prior = isPlainObject(overlay.artifacts[safeType]) ? overlay.artifacts[safeType] : null;
268
+ overlay.artifacts[safeType] = mergeEntry(defaultOverlayEntry(safeType, { description }), prior || {});
269
+ if (description) overlay.artifacts[safeType].description = description;
270
+ fs.writeFileSync(overlayPath, `${JSON.stringify(overlay, null, 2)}\n`);
271
+
272
+ return { type: safeType, overlayPath, templatePath, existed };
273
+ }
@@ -12,6 +12,7 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { resolveActiveScope } from './scopes/loader.mjs';
14
14
  import { COMPLETION_STATES, isCompletionState } from './artifact-completion-states.mjs';
15
+ import { applyArtifactOverlays, overlaySignature } from './artifact-manifest-overlay.mjs';
15
16
 
16
17
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
18
  const EMPTY_MANIFEST = { version: 1, artifacts: {} };
@@ -29,7 +30,7 @@ export const PLAIN_OUTPUT_FORMATS = Object.freeze(['txt', 'md', 'mdx']);
29
30
  export const GATE_LEVELS = Object.freeze(['fast', 'standard', 'render-smoke', 'full-certification', 'human-reviewed']);
30
31
 
31
32
  let cached = null;
32
- let cachedRoot = null;
33
+ let cachedKey = null;
33
34
 
34
35
  function manifestPathForRoot(root) {
35
36
  return path.join(root, 'specialists', 'artifact-manifest.json');
@@ -50,17 +51,19 @@ export function findConstructRoot(startPath = process.cwd()) {
50
51
 
51
52
  export function loadArtifactManifest({ rootDir, force = false, cwd = process.cwd() } = {}) {
52
53
  const resolvedRoot = rootDir ?? findConstructRoot(cwd) ?? PACKAGE_ROOT;
53
- if (cached && !force && cachedRoot === resolvedRoot) return cached;
54
54
 
55
- const p = manifestPathForRoot(resolvedRoot);
56
- if (!fs.existsSync(p)) {
57
- cached = EMPTY_MANIFEST;
58
- cachedRoot = resolvedRoot;
59
- return cached;
60
- }
55
+ // The builtin manifest is keyed by root, but project/user overlays and the
56
+ // sanctioned adhoc type resolve against cwd. Key the cache on both plus an
57
+ // overlay-file signature so a `templates register` earlier in a long-running
58
+ // process (the MCP server) invalidates a stale merged manifest.
61
59
 
62
- cached = JSON.parse(fs.readFileSync(p, 'utf8'));
63
- cachedRoot = resolvedRoot;
60
+ const key = `${resolvedRoot}|${cwd}|${overlaySignature({ cwd })}`;
61
+ if (cached && !force && cachedKey === key) return cached;
62
+
63
+ const p = manifestPathForRoot(resolvedRoot);
64
+ const builtin = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : EMPTY_MANIFEST;
65
+ cached = applyArtifactOverlays(builtin, { cwd });
66
+ cachedKey = key;
64
67
  return cached;
65
68
  }
66
69
 
@@ -215,6 +215,30 @@ export const CLI_COMMANDS = [
215
215
  { name: 'add --source=research --slug=<id> --topic="..." [--source-url=<url>]', desc: 'Persist a research finding into .cx/knowledge/external/research/' },
216
216
  ],
217
217
  },
218
+ {
219
+ name: 'synthesize',
220
+ emoji: '🔗',
221
+ category: 'Work',
222
+ core: false,
223
+ description: 'Cross-project synthesis: map each registered project, reduce to an origin-cited answer',
224
+ usage: 'construct synthesize --ask "<question>" [--projects=all|self|id,...] [--template <name>] [--dry-run] [--json]',
225
+ examples: [
226
+ { cmd: 'construct synthesize --ask "summarize each project\'s docs" --projects=all --dry-run', desc: 'Preview the assembled per-project context (no model call)' },
227
+ { cmd: 'construct synthesize --ask "how do these strategies converge" --projects=proj-app,proj-sdk', desc: 'Synthesize a convergence answer across two projects' },
228
+ ],
229
+ },
230
+ {
231
+ name: 'tracker',
232
+ emoji: '📮',
233
+ category: 'Models & Integrations',
234
+ core: false,
235
+ description: 'Analyze registered projects and contribute governed issue proposals to an external tracker (Jira)',
236
+ usage: 'construct tracker contribute --target <id> [--against <ids|all>] | --apply <proposal-id> [--approve <token>]',
237
+ subcommands: [
238
+ { name: 'contribute --target <id> [--against <ids|all>]', desc: 'Analyze corpora vs the tracker and emit an evidence-cited, deduped proposal artifact' },
239
+ { name: 'contribute --apply <proposal-id> [--approve <token>]', desc: 'Apply a proposal: dry-run by default; --approve executes the governed write batch' },
240
+ ],
241
+ },
218
242
  {
219
243
  name: 'sandbox',
220
244
  emoji: '🧪',
@@ -449,13 +473,16 @@ export const CLI_COMMANDS = [
449
473
  category: 'Models & Integrations',
450
474
  core: false,
451
475
  description: 'Show or update model tier assignments',
452
- usage: 'construct models <list|set|free|reset|resolve>',
476
+ usage: 'construct models <list|set|free|reset|resolve|policy|explain>',
453
477
  subcommands: [
454
478
  { name: 'list', desc: 'Show current tier assignments' },
455
479
  { name: 'set --tier=<reasoning|standard|fast> --model=<model>', desc: 'Set a model for a tier' },
456
480
  { name: 'free', desc: 'List available free models' },
457
481
  { name: 'reset', desc: 'Reset all tier assignments' },
458
482
  { name: 'resolve --json', desc: 'Resolve the model for an embedded workflow given host context' },
483
+ { name: 'policy show', desc: 'Show the effective policy: winning source per tier + work-category map' },
484
+ { name: 'policy set <budget|free|frontier|local>', desc: 'Compute a preset and persist it to specialists/org/models.json' },
485
+ { name: 'explain --role <specialist>', desc: 'Per-specialist model resolution trace' },
459
486
  ],
460
487
  },
461
488
  {
@@ -822,12 +849,25 @@ export const CLI_COMMANDS = [
822
849
  category: 'Advanced',
823
850
  core: false,
824
851
  description: 'Manage typed integration source targets in construct.config.json',
825
- usage: 'construct sources list|add|remove|validate',
852
+ usage: 'construct sources list|add|remove|validate|sync',
826
853
  subcommands: [
827
- { name: 'list', desc: 'Show config targets, legacy env merge, and effective set' },
828
- { name: 'add <provider> <id> <selector-json>', desc: 'Add a typed target (github, jira, linear, slack)' },
854
+ { name: 'list', desc: 'Show config targets, legacy env merge, corpus freshness, and effective set' },
855
+ { name: 'add <provider> <id> <selector-json>', desc: 'Add a typed target (directory, github, jira, linear, slack)' },
829
856
  { name: 'remove <id>', desc: 'Remove a config target by id' },
830
857
  { name: 'validate', desc: 'Validate sources.targets in construct.config.json' },
858
+ { name: 'sync [<id>]', desc: 'Clone/fetch the content cache for corpus targets' },
859
+ ],
860
+ },
861
+ {
862
+ name: 'templates',
863
+ emoji: '📝',
864
+ category: 'Advanced',
865
+ core: false,
866
+ description: 'List doc templates and register custom document classes (project-tier overlay; builtin manifest untouched)',
867
+ usage: 'construct templates list|register <type>',
868
+ subcommands: [
869
+ { name: 'list', desc: 'Show shipped templates and project overrides' },
870
+ { name: 'register <type> [--description "..."] [--from <file>] [--force]', desc: 'Register a custom doc class: writes .cx/templates/docs/<type>.md + a project artifact-manifest overlay entry' },
831
871
  ],
832
872
  },
833
873
  {
@@ -20,6 +20,7 @@
20
20
  * aliases — resolveKnownSourcesFromTargets
21
21
  * embed — targetsToEmbedSources
22
22
  * embedFilters — targetsToEmbedSourcesWithFilters
23
+ * content — lib/sources/repo-cache.mjs (corpus clone/fetch)
23
24
  * demandFetch.target* — lib/embed/demand-fetch.mjs buildReadCallsForTarget
24
25
  * demandFetch.query* — lib/embed/demand-fetch.mjs buildReadCalls
25
26
  *
@@ -13,6 +13,10 @@
13
13
  * drives.
14
14
  */
15
15
 
16
+ import { statSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
16
20
  import {
17
21
  SOURCE_TARGET_PROVIDERS,
18
22
  getSourceTargetDescriptor,
@@ -27,6 +31,16 @@ export const SLACK_INTENTS = Object.freeze(slackDescriptor?.secondaryField?.enum
27
31
 
28
32
  const ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
29
33
 
34
+ // Path selectors (directory) are stored portably with a leading `~`; the concrete
35
+ // home is resolved at validate/read time so a config survives moves between machines.
36
+
37
+ export function expandTilde(value, home = homedir()) {
38
+ const str = String(value ?? '');
39
+ if (str === '~') return home;
40
+ if (str.startsWith('~/')) return join(home, str.slice(2));
41
+ return str;
42
+ }
43
+
30
44
  function applyPatternTransform(value, mode) {
31
45
  if (mode === 'trimUpper') return value.trim().toUpperCase();
32
46
  return value.trim();
@@ -68,6 +82,22 @@ export function validateSourceTarget(target, index = 0) {
68
82
  errors.push(`${at}.selector.${field}: ${hint}`);
69
83
  }
70
84
 
85
+ // A descriptor whose selector declares `existsAs` (directory targets) must
86
+ // resolve to a real filesystem entry of that kind at validate time — a
87
+ // format-valid but nonexistent path is an actionable error, not silent.
88
+
89
+ if (valid && descriptor.selector.existsAs) {
90
+ const resolved = descriptor.selector.expand === 'tilde' ? expandTilde(raw.trim()) : raw.trim();
91
+ let onDisk = false;
92
+ try {
93
+ const st = statSync(resolved);
94
+ onDisk = descriptor.selector.existsAs === 'directory' ? st.isDirectory() : st.isFile();
95
+ } catch { onDisk = false; }
96
+ if (!onDisk) {
97
+ errors.push(`${at}.selector.${field}: ${descriptor.selector.existsHint ?? hint}`);
98
+ }
99
+ }
100
+
71
101
  if (descriptor.secondaryField) {
72
102
  const { field: secField, enum: secEnum, hint: secHint } = descriptor.secondaryField;
73
103
  if (sel[secField] !== undefined && !secEnum.includes(sel[secField])) {
package/lib/doc-stamp.mjs CHANGED
@@ -113,6 +113,7 @@ export function stampFrontmatter(content, {
113
113
  model = null,
114
114
  preserve_id = true,
115
115
  attribution = null,
116
+ extraFields = null,
116
117
  } = {}) {
117
118
  const existing = hasStamp(content) ? splitStamp(content) : null;
118
119
  const existingFields = existing ? parseStamp(content) : {};
@@ -145,6 +146,17 @@ export function stampFrontmatter(content, {
145
146
  }
146
147
  if (model) lines.push(`model: ${model}`);
147
148
  if (sessionId) lines.push(`session_id: ${sessionId}`);
149
+
150
+ // Caller-supplied provenance (e.g. ingest --as stamps origin_target_id /
151
+ // origin_provider). Existing values carry forward so a re-stamp never erases
152
+ // the source attribution the reader relies on to re-verify the document.
153
+ if (extraFields && typeof extraFields === 'object') {
154
+ for (const [key, value] of Object.entries(extraFields)) {
155
+ const carried = existingFields[key] ?? value;
156
+ if (carried != null && String(carried).trim() !== '') lines.push(`${key}: ${carried}`);
157
+ }
158
+ }
159
+
148
160
  lines.push(`body_hash: ${hash}`);
149
161
  lines.push(STAMP_CLOSE);
150
162
  lines.push('');
@@ -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
+ }