@geraldmaron/construct 1.5.1 → 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.
- package/README.md +3 -0
- package/bin/construct +336 -17
- 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/cli-commands.mjs +44 -4
- package/lib/config/source-target-registry.mjs +1 -0
- package/lib/config/source-targets.mjs +30 -0
- 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/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 -1
- package/registry/agent-manifest.json +0 -4
- package/registry/capabilities.json +20 -1
- package/templates/docs/README.md +22 -0
- package/templates/docs/adhoc.md +12 -0
- 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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
package/lib/cli-commands.mjs
CHANGED
|
@@ -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('');
|
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
|
+
}
|