@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
|
@@ -234,8 +234,8 @@ function todayStamp() {
|
|
|
234
234
|
return new Date().toISOString().slice(0, 10);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
function outputPathForType(artifactType, slug, cwd) {
|
|
238
|
-
const relDir = OUTPUT_DIR_BY_TYPE[artifactType] || 'docs/specs/prd';
|
|
237
|
+
function outputPathForType(artifactType, slug, cwd, entry = null) {
|
|
238
|
+
const relDir = OUTPUT_DIR_BY_TYPE[artifactType] || entry?.outputDir || 'docs/specs/prd';
|
|
239
239
|
const fileName = artifactType === 'adr'
|
|
240
240
|
? `ADR-draft-${slug}.md`
|
|
241
241
|
: `${todayStamp()}-${slug}.md`;
|
|
@@ -278,12 +278,29 @@ export function extractArtifactMarkdown(text) {
|
|
|
278
278
|
return doc;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
// An adhoc artifact has no fixed template: the supplied instructions become the
|
|
282
|
+
// scaffold body, marked [unverified] so it clears the citation gate as an
|
|
283
|
+
// unresearched draft. Free-form structure, still through the release gate.
|
|
284
|
+
|
|
285
|
+
function buildAdhocScaffold({ title, instructions }) {
|
|
286
|
+
const instr = String(instructions || '').trim();
|
|
287
|
+
const bodyText = instr
|
|
288
|
+
|| 'Free-form artifact. Expand this from the supplied instructions and evidence.';
|
|
289
|
+
return ensureFrontmatter(
|
|
290
|
+
`# ${title}\n\n${bodyText}\n\n> Scope: adhoc artifact — structure follows the instructions above.\n\n[unverified]\n`,
|
|
291
|
+
'adhoc',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildDraftBody({ artifactType, title, draftFromHost, draftMarkdown, rootDir, allowScaffold = true, instructions }) {
|
|
282
296
|
const source = draftMarkdown || (draftFromHost ? normalizeDraftMarkdown(draftFromHost) : null);
|
|
283
297
|
if (source && source.length >= 200 && /^#\s+/m.test(source)) {
|
|
284
298
|
return ensureFrontmatter(source, artifactType);
|
|
285
299
|
}
|
|
286
300
|
if (!allowScaffold) return null;
|
|
301
|
+
if (artifactType === 'adhoc') {
|
|
302
|
+
return buildAdhocScaffold({ title, instructions });
|
|
303
|
+
}
|
|
287
304
|
const tmpl = getTemplate({ name: artifactType }, { ROOT_DIR: rootDir });
|
|
288
305
|
if (tmpl?.content) {
|
|
289
306
|
return fillTemplate(tmpl.content, { title, date: todayStamp() });
|
|
@@ -300,17 +317,20 @@ export async function runConstructArtifactLoop({
|
|
|
300
317
|
artifactType: artifactTypeOverride,
|
|
301
318
|
draftMarkdown,
|
|
302
319
|
allowScaffold = true,
|
|
320
|
+
instructions,
|
|
321
|
+
titleOverride,
|
|
303
322
|
} = {}) {
|
|
304
323
|
const root = rootDir || findConstructRoot(cwd);
|
|
305
324
|
const draftFromHost = draftMarkdown ? null : lastAssistantBody(turnBlocks);
|
|
306
325
|
const resolvedDraft = draftMarkdown || extractArtifactMarkdown(draftFromHost);
|
|
307
326
|
const artifactType = artifactTypeOverride || resolveLoopArtifactType(text, turnBlocks);
|
|
308
|
-
const
|
|
327
|
+
const entry = getArtifactEntry(artifactType, { rootDir: root, cwd });
|
|
328
|
+
const title = titleOverride?.trim() || extractTitle(
|
|
309
329
|
resolvedDraft || draftFromHost,
|
|
310
330
|
explicit ? (extractLoopSubject(text) || 'draft-artifact') : 'draft-artifact',
|
|
311
331
|
);
|
|
312
332
|
const slug = slugify(title);
|
|
313
|
-
const outPath = outputPathForType(artifactType, slug, cwd);
|
|
333
|
+
const outPath = outputPathForType(artifactType, slug, cwd, entry);
|
|
314
334
|
const workflowType = WORKFLOW_BY_TYPE[artifactType] || 'prd-draft';
|
|
315
335
|
const input = [
|
|
316
336
|
`Artifact loop: ${artifactType}`,
|
|
@@ -335,6 +355,7 @@ export async function runConstructArtifactLoop({
|
|
|
335
355
|
draftMarkdown: resolvedDraft,
|
|
336
356
|
rootDir: root,
|
|
337
357
|
allowScaffold,
|
|
358
|
+
instructions,
|
|
338
359
|
});
|
|
339
360
|
if (!body) {
|
|
340
361
|
const relPath = path.relative(cwd, outPath);
|
|
@@ -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/auto-docs.mjs
CHANGED
|
@@ -114,9 +114,13 @@ const DIR_DESCRIPTIONS = {
|
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
function buildStructureSection(rootDir) {
|
|
117
|
+
// git stderr must be discarded: from the published package rootDir is not a
|
|
118
|
+
// repository, and execSync's default stdio prints "fatal: not a git repository"
|
|
119
|
+
// to the user's terminal before the catch fires (doctor and sync both hit this).
|
|
120
|
+
|
|
117
121
|
let trackedDirs;
|
|
118
122
|
try {
|
|
119
|
-
const out = execSync('git ls-tree --name-only HEAD', { cwd: rootDir, encoding: 'utf8' });
|
|
123
|
+
const out = execSync('git ls-tree --name-only HEAD', { cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
120
124
|
trackedDirs = new Set(out.trim().split('\n').filter(Boolean));
|
|
121
125
|
} catch {
|
|
122
126
|
trackedDirs = null;
|
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
|
{
|
|
@@ -575,8 +602,13 @@ export const CLI_COMMANDS = [
|
|
|
575
602
|
emoji: '📈',
|
|
576
603
|
category: 'Observability',
|
|
577
604
|
core: false,
|
|
578
|
-
description: '
|
|
579
|
-
usage: 'construct review [--
|
|
605
|
+
description: 'Agent performance review from telemetry (run|legacy), or a deterministic PR-diff review for CI (pr)',
|
|
606
|
+
usage: 'construct review [run|legacy|pr --base=<ref> [--output=<file>]]',
|
|
607
|
+
subcommands: [
|
|
608
|
+
{ name: 'run', desc: 'Generate the per-agent performance review from local session costs + telemetry' },
|
|
609
|
+
{ name: 'legacy', desc: 'Telemetry pipeline report (requires CONSTRUCT_TELEMETRY_* credentials)' },
|
|
610
|
+
{ name: 'pr', desc: 'Deterministic diff review vs a base ref — secret/quality heuristics, no model, no credentials (backs the CI review gate, ADR-0069)' },
|
|
611
|
+
],
|
|
580
612
|
},
|
|
581
613
|
{
|
|
582
614
|
name: 'optimize',
|
|
@@ -822,12 +854,25 @@ export const CLI_COMMANDS = [
|
|
|
822
854
|
category: 'Advanced',
|
|
823
855
|
core: false,
|
|
824
856
|
description: 'Manage typed integration source targets in construct.config.json',
|
|
825
|
-
usage: 'construct sources list|add|remove|validate',
|
|
857
|
+
usage: 'construct sources list|add|remove|validate|sync',
|
|
826
858
|
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)' },
|
|
859
|
+
{ name: 'list', desc: 'Show config targets, legacy env merge, corpus freshness, and effective set' },
|
|
860
|
+
{ name: 'add <provider> <id> <selector-json>', desc: 'Add a typed target (directory, github, jira, linear, slack)' },
|
|
829
861
|
{ name: 'remove <id>', desc: 'Remove a config target by id' },
|
|
830
862
|
{ name: 'validate', desc: 'Validate sources.targets in construct.config.json' },
|
|
863
|
+
{ name: 'sync [<id>]', desc: 'Clone/fetch the content cache for corpus targets' },
|
|
864
|
+
],
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
name: 'templates',
|
|
868
|
+
emoji: '📝',
|
|
869
|
+
category: 'Advanced',
|
|
870
|
+
core: false,
|
|
871
|
+
description: 'List doc templates and register custom document classes (project-tier overlay; builtin manifest untouched)',
|
|
872
|
+
usage: 'construct templates list|register <type>',
|
|
873
|
+
subcommands: [
|
|
874
|
+
{ name: 'list', desc: 'Show shipped templates and project overrides' },
|
|
875
|
+
{ 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
876
|
],
|
|
832
877
|
},
|
|
833
878
|
{
|
|
@@ -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/decisions/golden.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { loadRegistry } from '../registry/loader.mjs';
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { join, dirname, resolve } from 'node:path';
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
16
|
|
|
17
17
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
18
18
|
|
|
@@ -25,19 +25,18 @@ function hookName(command) {
|
|
|
25
25
|
return 'cmd';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function buildSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
28
|
+
export async function buildSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
29
29
|
const snapshot = { commands: [], agents: [], hooks: {} };
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
snapshot.commands.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
31
|
+
// The command surface comes from the CLI_COMMANDS registry module, not a
|
|
32
|
+
// text scan of its source: the earlier regex scan paired `subcommands:` item
|
|
33
|
+
// names with the NEXT command's `core:` field, minting phantom commands and
|
|
34
|
+
// swallowing real ones, so the pinned set never matched the true surface.
|
|
35
|
+
|
|
36
|
+
const { CLI_COMMANDS } = await import(pathToFileURL(join(repoRoot, 'lib', 'cli-commands.mjs')).href);
|
|
37
|
+
snapshot.commands = CLI_COMMANDS
|
|
38
|
+
.map((c) => ({ name: c.name, core: c.core === true }))
|
|
39
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
41
40
|
|
|
42
41
|
const registry = loadRegistry({ rootDir: repoRoot });
|
|
43
42
|
snapshot.agents = Object.values(registry.specialists || {}).map((s) => s.name).sort();
|
|
@@ -59,26 +58,26 @@ function snapshotPath(repoRoot) {
|
|
|
59
58
|
return join(repoRoot, 'tests', 'fixtures', 'golden', 'surface.json');
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
export function compareSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
61
|
+
export async function compareSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
63
62
|
const file = snapshotPath(repoRoot);
|
|
64
63
|
if (!existsSync(file)) return { ok: false, diffs: ['no committed snapshot — run: construct decisions golden --write'] };
|
|
65
64
|
const expected = JSON.stringify(JSON.parse(readFileSync(file, 'utf8')));
|
|
66
|
-
const actual = JSON.stringify(buildSurfaceSnapshot({ repoRoot }));
|
|
65
|
+
const actual = JSON.stringify(await buildSurfaceSnapshot({ repoRoot }));
|
|
67
66
|
if (expected === actual) return { ok: true, diffs: [] };
|
|
68
67
|
return { ok: false, diffs: ['surface snapshot drift — review the change, then regenerate with: construct decisions golden --write'] };
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
export function writeSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
72
|
-
writeFileSync(snapshotPath(repoRoot), JSON.stringify(buildSurfaceSnapshot({ repoRoot }), null, 2) + '\n');
|
|
70
|
+
export async function writeSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
71
|
+
writeFileSync(snapshotPath(repoRoot), JSON.stringify(await buildSurfaceSnapshot({ repoRoot }), null, 2) + '\n');
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
export async function runGoldenCli(args = []) {
|
|
76
75
|
if (args.includes('--write')) {
|
|
77
|
-
writeSurfaceSnapshot();
|
|
76
|
+
await writeSurfaceSnapshot();
|
|
78
77
|
process.stdout.write('✓ wrote surface golden snapshot\n');
|
|
79
78
|
return;
|
|
80
79
|
}
|
|
81
|
-
const { ok, diffs } = compareSurfaceSnapshot();
|
|
80
|
+
const { ok, diffs } = await compareSurfaceSnapshot();
|
|
82
81
|
if (ok) {
|
|
83
82
|
process.stdout.write('✓ surface matches the golden snapshot\n');
|
|
84
83
|
return;
|
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('');
|