@geraldmaron/construct 1.5.1 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +7 -1
  2. package/bin/construct +354 -19
  3. package/lib/adapters-sync.mjs +1 -1
  4. package/lib/artifact-loop-core.mjs +26 -5
  5. package/lib/artifact-manifest-overlay.mjs +273 -0
  6. package/lib/artifact-manifest.mjs +13 -10
  7. package/lib/auto-docs.mjs +5 -1
  8. package/lib/cli-commands.mjs +51 -6
  9. package/lib/config/source-target-registry.mjs +1 -0
  10. package/lib/config/source-targets.mjs +30 -0
  11. package/lib/decisions/golden.mjs +17 -18
  12. package/lib/doc-stamp.mjs +12 -0
  13. package/lib/doctor/index.mjs +2 -1
  14. package/lib/doctor/source-target-health.mjs +101 -0
  15. package/lib/doctor/watchers/source-targets.mjs +44 -0
  16. package/lib/document-ingest.mjs +25 -3
  17. package/lib/embed/demand-fetch.mjs +30 -36
  18. package/lib/embed/providers/directory.mjs +117 -0
  19. package/lib/embed/providers/registry.mjs +7 -0
  20. package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
  21. package/lib/extensions/manifests/directory.manifest.json +24 -1
  22. package/lib/extensions/manifests/github.manifest.json +10 -0
  23. package/lib/extensions/manifests/linear.manifest.json +1 -1
  24. package/lib/hooks/session-start.mjs +18 -11
  25. package/lib/knowledge/rag.mjs +52 -11
  26. package/lib/knowledge/search.mjs +69 -6
  27. package/lib/knowledge/synthesis.mjs +157 -0
  28. package/lib/mcp/server.mjs +2 -2
  29. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  30. package/lib/mcp/tools/artifact-author.mjs +126 -13
  31. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  32. package/lib/mcp/tools/skills.mjs +17 -0
  33. package/lib/model-cheapest-provider.mjs +2 -1
  34. package/lib/model-policy.mjs +329 -0
  35. package/lib/model-router.mjs +10 -9
  36. package/lib/model-tiers.mjs +27 -0
  37. package/lib/models/catalog.mjs +5 -4
  38. package/lib/orchestration/classification.mjs +11 -0
  39. package/lib/orchestration/context-bindings.mjs +85 -0
  40. package/lib/orchestration/readiness.mjs +2 -1
  41. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  42. package/lib/orchestration/runtime.mjs +35 -1
  43. package/lib/orchestration/worker.mjs +9 -0
  44. package/lib/review-pr.mjs +102 -0
  45. package/lib/setup.mjs +2 -1
  46. package/lib/sources/content-roots.mjs +147 -0
  47. package/lib/sources/repo-cache.mjs +142 -0
  48. package/lib/template-registry.mjs +2 -0
  49. package/lib/tracker/contribute.mjs +266 -0
  50. package/lib/validator.mjs +2 -3
  51. package/package.json +1 -5
  52. package/registry/agent-manifest.json +0 -4
  53. package/registry/capabilities.json +20 -1
  54. package/scripts/sync-specialists.mjs +12 -3
  55. package/templates/docs/README.md +22 -0
  56. package/templates/docs/adhoc.md +12 -0
  57. package/templates/docs/strategy-comparison.md +19 -0
@@ -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
- function buildDraftBody({ artifactType, title, draftFromHost, draftMarkdown, rootDir, allowScaffold = true }) {
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 title = extractTitle(
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 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
 
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;
@@ -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: 'Generate agent performance review from the configured telemetry trace backend',
579
- usage: 'construct review [--agent=<id>] [--days=N]',
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])) {
@@ -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
- const cli = readFileSync(join(repoRoot, 'lib', 'cli-commands.mjs'), 'utf8');
32
- const nameRe = /name:\s*'([^']+)'[\s\S]*?core:\s*(true|false)/g;
33
- let m;
34
- const seen = new Set();
35
- while ((m = nameRe.exec(cli)) !== null) {
36
- if (seen.has(m[1])) continue;
37
- seen.add(m[1]);
38
- snapshot.commands.push({ name: m[1], core: m[2] === 'true' });
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('');