@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
@@ -0,0 +1,142 @@
1
+ /**
2
+ * lib/sources/repo-cache.mjs — local content cache for corpus source targets.
3
+ *
4
+ * A source target opts into a full-content corpus by declaring its provider
5
+ * manifest's `content` descriptor shape in the selector (github: `content:
6
+ * {mode:"corpus", ref?}`). Eligibility is keyed off that manifest `content`
7
+ * descriptor, never a hardcoded `provider === 'github'` check, so any future
8
+ * git-hosted provider that declares a `content` block gets caching for free
9
+ * (bead construct-760c.1 R5).
10
+ *
11
+ * The checkout lives only under the ADR-0066 machine state root
12
+ * (`~/.construct/projects/<key>/context-repos/<targetId>/`, resolved through
13
+ * lib/state-root.mjs) — never in the project tree, so `construct init` never
14
+ * scaffolds it. A sibling `<targetId>.meta.json` records the remote, ref,
15
+ * resolved HEAD, sync mode, and last-fetch timestamp for freshness/TTL
16
+ * reporting. The first sync clones shallow (`--depth 1`); every subsequent
17
+ * sync fetches into the existing `.git` (incremental, re-run-safe — R4).
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ import { resolveStateDir, resolveStatePath } from '../state-root.mjs';
25
+ import { getSourceTargetDescriptor, renderTemplate } from '../config/source-target-registry.mjs';
26
+
27
+ export const DEFAULT_CORPUS_TTL_MS = 24 * 60 * 60 * 1000;
28
+
29
+ function contentDescriptor(target) {
30
+ const descriptor = getSourceTargetDescriptor(target?.provider);
31
+ return descriptor?.content ? { descriptor, content: descriptor.content } : null;
32
+ }
33
+
34
+ /**
35
+ * Is this target opted into corpus caching? True only when its provider
36
+ * manifest declares a `content` descriptor and the target's selector sets that
37
+ * descriptor's mode field to the corpus value.
38
+ */
39
+ export function isCorpusTarget(target) {
40
+ const found = contentDescriptor(target);
41
+ if (!found) return false;
42
+ const block = target.selector?.[found.content.field];
43
+ return !!block && block[found.content.modeField] === found.content.modeValue;
44
+ }
45
+
46
+ /**
47
+ * Resolve the git remote URL for a corpus target: an explicit `remote` in the
48
+ * content selector — a local `file://` bare repo, say — wins over the
49
+ * manifest's `remoteTemplate` rendered from the selector value.
50
+ */
51
+ export function resolveCorpusRemote(target) {
52
+ const found = contentDescriptor(target);
53
+ if (!found) return null;
54
+ const { descriptor, content } = found;
55
+ const block = target.selector?.[content.field] ?? {};
56
+ if (content.remoteField && block[content.remoteField]) return String(block[content.remoteField]);
57
+ const value = target.selector?.[descriptor.selector.field];
58
+ if (!value || !content.remoteTemplate) return null;
59
+ return renderTemplate(content.remoteTemplate, { value });
60
+ }
61
+
62
+ function corpusRef(target) {
63
+ const found = contentDescriptor(target);
64
+ const block = target.selector?.[found.content.field] ?? {};
65
+ return block[found.content.refField] || found.content.defaultRef;
66
+ }
67
+
68
+ export function corpusCacheDir(target, { projectRoot = process.cwd(), ensureDir = false } = {}) {
69
+ return resolveStateDir(projectRoot, 'context-repos', target.id, { ensureDir });
70
+ }
71
+
72
+ function metaPathFor(target, projectRoot, { ensureDir = false } = {}) {
73
+ return resolveStatePath(projectRoot, 'context-repos', `${target.id}.meta.json`, { ensureDir });
74
+ }
75
+
76
+ export function readCorpusMeta(target, { projectRoot = process.cwd() } = {}) {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(metaPathFor(target, projectRoot), 'utf8'));
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Freshness view for a corpus target: whether a cache exists, its last-fetch
86
+ * timestamp, and whether it is older than `ttlMs`. Drives `sources list`.
87
+ */
88
+ export function corpusFreshness(target, { projectRoot = process.cwd(), ttlMs = DEFAULT_CORPUS_TTL_MS, now = Date.now() } = {}) {
89
+ const dir = corpusCacheDir(target, { projectRoot });
90
+ const cached = fs.existsSync(path.join(dir, '.git'));
91
+ const meta = readCorpusMeta(target, { projectRoot });
92
+ const lastFetch = meta?.lastFetch ?? null;
93
+ const ageMs = lastFetch ? now - Date.parse(lastFetch) : null;
94
+ const stale = ageMs == null ? cached : ageMs > ttlMs;
95
+ return { cached, lastFetch, ageMs, stale, ref: meta?.ref ?? corpusRef(target), head: meta?.head ?? null, dir };
96
+ }
97
+
98
+ /**
99
+ * Clone (first run) or fetch (subsequent runs) a corpus target's content into
100
+ * its state-root cache and record freshness metadata. Incremental and
101
+ * re-run-safe: an existing `.git` takes the fetch path, so objects are reused
102
+ * rather than re-cloned. `git` is injectable purely for testing; production
103
+ * uses the real git binary via execFileSync.
104
+ */
105
+ export function syncCorpusTarget(target, { projectRoot = process.cwd(), git = runGit, now = () => new Date().toISOString() } = {}) {
106
+ if (!isCorpusTarget(target)) {
107
+ throw new Error(`target ${target.id} is not a corpus target`);
108
+ }
109
+ const remote = resolveCorpusRemote(target);
110
+ if (!remote) throw new Error(`target ${target.id} has no resolvable corpus remote`);
111
+ const ref = corpusRef(target);
112
+ const dir = corpusCacheDir(target, { projectRoot });
113
+ const gitDir = path.join(dir, '.git');
114
+
115
+ let mode;
116
+ if (fs.existsSync(gitDir)) {
117
+ git(['-C', dir, 'fetch', '--depth', '1', 'origin', ref]);
118
+ git(['-C', dir, 'checkout', '-f', 'FETCH_HEAD']);
119
+ mode = 'fetch';
120
+ } else {
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ git(['clone', '--depth', '1', '--branch', ref, remote, dir]);
123
+ mode = 'clone';
124
+ }
125
+
126
+ const head = git(['-C', dir, 'rev-parse', 'HEAD']).trim();
127
+ const meta = {
128
+ targetId: target.id,
129
+ provider: target.provider,
130
+ remote,
131
+ ref,
132
+ head,
133
+ mode,
134
+ lastFetch: now(),
135
+ };
136
+ fs.writeFileSync(metaPathFor(target, projectRoot, { ensureDir: true }), `${JSON.stringify(meta, null, 2)}\n`);
137
+ return { ...meta, dir };
138
+ }
139
+
140
+ function runGit(args) {
141
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
142
+ }
@@ -54,6 +54,7 @@ export const TEMPLATE_OWNERSHIP_EXEMPTIONS = new Set([
54
54
  'construct_guide',
55
55
  'onboarding',
56
56
  'memo',
57
+ 'adhoc',
57
58
  'skill-artifact',
58
59
  'persona-artifact',
59
60
  'customer-profile',
@@ -69,4 +70,5 @@ export const TEMPLATE_OWNERSHIP_EXEMPTIONS = new Set([
69
70
  'prd-platform',
70
71
  'prd-business',
71
72
  'README',
73
+ 'strategy-comparison',
72
74
  ]);
@@ -0,0 +1,266 @@
1
+ /**
2
+ * lib/tracker/contribute.mjs — analyze → propose → dedupe → apply pipeline for
3
+ * contributing governed issue proposals back to an external tracker (bead
4
+ * construct-760c.6). Jira first, but provider-generic: the write goes through the
5
+ * existing governed `provider_write` path, so a tracker's eligibility is its
6
+ * manifest `write` capability, not a hardcoded name here.
7
+ *
8
+ * Five stages:
9
+ * 1. FETCH existing issues from the tracker (injectable; default reads via
10
+ * the jira embed provider's JQL).
11
+ * 2. ANALYZE the registered project corpora (B2 content roots) for work the
12
+ * tracker does not yet cover — deterministic, retrieval-only, so the
13
+ * proposal set is stable and CI-testable without a model.
14
+ * 3. PROPOSE a proposal artifact (JSON + markdown): each proposed issue carries
15
+ * a summary, an evidence-cited description (origin.targetId +
16
+ * relPath — no fabrication), a suggested type, and a stable
17
+ * idempotency key. The artifact is the human-review anchor.
18
+ * 4. DEDUPE proposed vs existing issue summaries by token similarity;
19
+ * near-duplicates are suppressed and reported with the matched key.
20
+ * 5. APPLY batch through provider_write — dry_run by default (writes
21
+ * nothing), an approval token required to execute. Idempotent:
22
+ * an issue already recorded created for this proposal is skipped, so
23
+ * a second apply is a no-op.
24
+ *
25
+ * Beads are never touched (R5): bd stays the internal tracker; this pipeline only
26
+ * proposes to the external one.
27
+ */
28
+
29
+ import crypto from 'node:crypto';
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+
33
+ import { loadProjectConfig } from '../config/project-config.mjs';
34
+ import { resolveEffectiveSourceTargetsFromConfig } from '../config/source-targets.mjs';
35
+ import { resolveContentRoots, expandProjectsFilter } from '../sources/content-roots.mjs';
36
+ import { buildCorpus } from '../knowledge/rag.mjs';
37
+
38
+ const DEFAULT_PROPOSAL_LIMIT = 10;
39
+ const DEDUPE_THRESHOLD = 0.6;
40
+
41
+ const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'in', 'on', 'with', 'track', 'add', 'document', 'implement']);
42
+
43
+ function tokenize(text) {
44
+ return new Set(String(text).toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter((t) => t.length > 2 && !STOP.has(t)));
45
+ }
46
+
47
+ function jaccard(a, b) {
48
+ if (!a.size || !b.size) return 0;
49
+ let inter = 0;
50
+ for (const t of a) if (b.has(t)) inter++;
51
+ return inter / (a.size + b.size - inter);
52
+ }
53
+
54
+ function proposalsDir(cwd) {
55
+ return path.join(cwd, '.cx', 'tracker', 'proposals');
56
+ }
57
+
58
+ function stableId(projectKey, items) {
59
+ const h = crypto.createHash('sha256');
60
+ h.update(projectKey);
61
+ for (const it of items) h.update(`|${it.key}`);
62
+ return `prop-${h.digest('hex').slice(0, 12)}`;
63
+ }
64
+
65
+ // A tracker source target names its project via the manifest selector field
66
+ // (jira: `project`). Resolve the requested target id to that key, or throw a
67
+ // hard error naming the known tracker targets.
68
+ function resolveTrackerTarget(targets, targetId) {
69
+ const target = targets.find((t) => t.id === targetId);
70
+ if (!target) {
71
+ const known = targets.map((t) => t.id).join(', ') || '(none registered)';
72
+ throw new Error(`unknown tracker target "${targetId}" — known targets: ${known}`);
73
+ }
74
+ const projectKey = target.selector?.project || target.selector?.[Object.keys(target.selector || {})[0]];
75
+ if (!projectKey) throw new Error(`tracker target "${targetId}" has no resolvable project key`);
76
+ return { target, projectKey };
77
+ }
78
+
79
+ // Deterministic analyze: each source doc in a bound project's corpus is a
80
+ // candidate work item to track, summarized from its title and cited to its
81
+ // origin. Ordering is stable (project id, then relPath) so proposals reproduce.
82
+ function buildCandidates(roots, cwd, limit) {
83
+ const candidates = [];
84
+ for (const root of roots) {
85
+ const chunks = buildCorpus(cwd, { roots: [root] })
86
+ .filter((c) => c.origin?.targetId === root.origin.targetId && c.origin?.relPath)
87
+ .sort((a, b) => a.origin.relPath.localeCompare(b.origin.relPath));
88
+ const seen = new Set();
89
+ for (const c of chunks) {
90
+ if (seen.has(c.origin.relPath)) continue;
91
+ seen.add(c.origin.relPath);
92
+ const summary = `Track: ${c.title} (${c.origin.projectKey})`;
93
+ candidates.push({
94
+ key: `${c.origin.targetId}:${c.origin.relPath}`,
95
+ summary,
96
+ issueType: 'Task',
97
+ evidence: [{ targetId: c.origin.targetId, projectKey: c.origin.projectKey, relPath: c.origin.relPath }],
98
+ title: c.title,
99
+ });
100
+ }
101
+ }
102
+ candidates.sort((a, b) => a.key.localeCompare(b.key));
103
+ return candidates.slice(0, limit);
104
+ }
105
+
106
+ function renderDescription(candidate) {
107
+ const cite = candidate.evidence.map((e) => `\`${e.projectKey}:${e.relPath}\``).join(', ');
108
+ return [
109
+ `Proposed from analysis of registered project content.`,
110
+ ``,
111
+ `Source: ${cite}`,
112
+ ``,
113
+ `Scope [unverified]: derive concrete acceptance criteria from the cited source before starting — details beyond the source title are not yet verified.`,
114
+ ].join('\n');
115
+ }
116
+
117
+ function dedupe(candidates, existingIssues) {
118
+ const existingTok = existingIssues.map((i) => ({ key: i.key, tokens: tokenize(i.summary || i.title || '') }));
119
+ const proposals = [];
120
+ const suppressed = [];
121
+ for (const c of candidates) {
122
+ const ctok = tokenize(c.summary);
123
+ let best = null;
124
+ for (const e of existingTok) {
125
+ const score = jaccard(ctok, e.tokens);
126
+ if (score >= DEDUPE_THRESHOLD && (!best || score > best.score)) best = { key: e.key, score };
127
+ }
128
+ if (best) {
129
+ suppressed.push({ key: c.key, summary: c.summary, matchedIssueKey: best.key, similarity: Math.round(best.score * 100) / 100 });
130
+ } else {
131
+ proposals.push(c);
132
+ }
133
+ }
134
+ return { proposals, suppressed };
135
+ }
136
+
137
+ /**
138
+ * Default issue fetch: read the tracker target's existing issues through the
139
+ * jira embed provider. Injectable via deps.fetchIssues for tests.
140
+ */
141
+ async function defaultFetchIssues({ projectKey, env }) {
142
+ const { JiraProvider } = await import('../embed/providers/jira.mjs');
143
+ const provider = new JiraProvider({ env });
144
+ const items = await provider.read('issues', { project: projectKey });
145
+ return (items || []).filter((i) => i && i.key).map((i) => ({ key: i.key, summary: i.title || i.summary || '' }));
146
+ }
147
+
148
+ function renderProposalMarkdown(proposal) {
149
+ const lines = [
150
+ `# Tracker contribution proposal — ${proposal.projectKey}`,
151
+ ``,
152
+ `Proposal id: \`${proposal.id}\` · target: \`${proposal.targetId}\` · generated ${proposal.createdAt}`,
153
+ ``,
154
+ `## Proposed issues (${proposal.proposals.length})`,
155
+ ``,
156
+ ];
157
+ for (const p of proposal.proposals) {
158
+ lines.push(`### ${p.summary}`, ``, `- type: ${p.issueType}`, `- idempotency key: \`${p.key}\``, ``, renderDescription(p), ``);
159
+ }
160
+ lines.push(`## Dedupe report (${proposal.suppressed.length} suppressed)`, ``);
161
+ if (proposal.suppressed.length) {
162
+ lines.push(`| Proposed | Matched issue | Similarity |`, `|---|---|---|`);
163
+ for (const s of proposal.suppressed) lines.push(`| ${s.summary} | ${s.matchedIssueKey} | ${s.similarity} |`);
164
+ } else {
165
+ lines.push(`_No near-duplicates suppressed._`);
166
+ }
167
+ return lines.join('\n') + '\n';
168
+ }
169
+
170
+ function saveProposal(cwd, proposal) {
171
+ const dir = proposalsDir(cwd);
172
+ fs.mkdirSync(dir, { recursive: true });
173
+ fs.writeFileSync(path.join(dir, `${proposal.id}.json`), `${JSON.stringify(proposal, null, 2)}\n`);
174
+ fs.writeFileSync(path.join(dir, `${proposal.id}.md`), renderProposalMarkdown(proposal));
175
+ return { json: path.join(dir, `${proposal.id}.json`), md: path.join(dir, `${proposal.id}.md`) };
176
+ }
177
+
178
+ export function loadProposal(cwd, proposalId) {
179
+ try {
180
+ return JSON.parse(fs.readFileSync(path.join(proposalsDir(cwd), `${proposalId}.json`), 'utf8'));
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Stages 1–4: fetch existing issues, analyze bound project corpora, propose
188
+ * evidence-cited issues, dedupe against existing, and persist the proposal
189
+ * artifact. No writes to the tracker.
190
+ */
191
+ export async function analyzeAndPropose({ target, against, cwd = process.cwd(), env = process.env, limit = DEFAULT_PROPOSAL_LIMIT, deps = {} } = {}) {
192
+ const { config } = loadProjectConfig(cwd, env);
193
+ const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
194
+ const { projectKey } = resolveTrackerTarget(targets, target);
195
+
196
+ let selectedIds;
197
+ try {
198
+ selectedIds = expandProjectsFilter(against ?? 'all', targets).ids;
199
+ } catch (err) {
200
+ return { ok: false, message: err.message };
201
+ }
202
+ const roots = resolveContentRoots(targets, { projectRoot: cwd })
203
+ .filter((r) => selectedIds.has(r.origin.targetId));
204
+ if (!roots.length) {
205
+ return { ok: false, message: `no content-capable projects resolved for "${against ?? 'all'}" to analyze against` };
206
+ }
207
+
208
+ const fetchIssues = deps.fetchIssues ?? defaultFetchIssues;
209
+ const existing = await fetchIssues({ projectKey, env });
210
+
211
+ const candidates = buildCandidates(roots, cwd, limit);
212
+ const { proposals, suppressed } = dedupe(candidates, existing);
213
+
214
+ const now = deps.now ?? (() => new Date().toISOString());
215
+ const proposal = {
216
+ id: stableId(projectKey, candidates),
217
+ targetId: target,
218
+ projectKey,
219
+ createdAt: now(),
220
+ existingIssueCount: existing.length,
221
+ proposals: proposals.map((p) => ({ key: p.key, summary: p.summary, issueType: p.issueType, evidence: p.evidence, description: renderDescription(p) })),
222
+ suppressed,
223
+ };
224
+ const paths = saveProposal(cwd, proposal);
225
+ return { ok: true, proposal, paths };
226
+ }
227
+
228
+ /**
229
+ * Stage 5: apply a proposal through the governed write path. Without
230
+ * approveToken every write is a dry-run (renders payloads, writes nothing).
231
+ * Idempotent: an issue already recorded created for this proposal is skipped, so
232
+ * re-apply never creates a duplicate (R3/AC5).
233
+ */
234
+ export async function applyProposal({ proposalId, proposal: inline, approveToken = null, cwd = process.cwd(), env = process.env, deps = {} } = {}) {
235
+ const proposal = inline || loadProposal(cwd, proposalId);
236
+ if (!proposal) return { ok: false, message: `proposal not found: ${proposalId}` };
237
+
238
+ const write = deps.providerWrite ?? (async (args) => (await import('../mcp/tools/provider-write.mjs')).providerWrite(args, { rootDir: cwd, env }));
239
+ const dryRun = !approveToken;
240
+
241
+ const auditPath = path.join(proposalsDir(cwd), `${proposal.id}.audit.json`);
242
+ let audit = [];
243
+ try { audit = JSON.parse(fs.readFileSync(auditPath, 'utf8')); } catch { audit = []; }
244
+ const alreadyCreated = new Set(audit.map((a) => a.key));
245
+
246
+ const results = [];
247
+ for (const p of proposal.proposals) {
248
+ if (!dryRun && alreadyCreated.has(p.key)) {
249
+ results.push({ key: p.key, status: 'skipped-idempotent' });
250
+ continue;
251
+ }
252
+ const item = { type: 'issue', project: proposal.projectKey, issueType: p.issueType, summary: p.summary, description: p.description };
253
+ const res = await write({ provider: 'jira', item, dry_run: dryRun, idempotency_key: `${proposal.id}:${p.key}`, approval_token: approveToken });
254
+ results.push({ key: p.key, status: res.status, dryRun });
255
+ if (!dryRun && res.status && !String(res.status).startsWith('denied')) {
256
+ const issueKey = res.envelope?.result?.key || res.envelope?.issueKey || null;
257
+ audit.push({ key: p.key, proposalId: proposal.id, issueKey, at: (deps.now ?? (() => new Date().toISOString()))() });
258
+ }
259
+ }
260
+
261
+ if (!dryRun) {
262
+ fs.mkdirSync(proposalsDir(cwd), { recursive: true });
263
+ fs.writeFileSync(auditPath, `${JSON.stringify(audit, null, 2)}\n`);
264
+ }
265
+ return { ok: true, proposalId: proposal.id, dryRun, results, audit: dryRun ? undefined : audit };
266
+ }
package/lib/validator.mjs CHANGED
@@ -10,8 +10,7 @@ import { loadRegistry } from './registry/loader.mjs';
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { packageRoot, isMainModule } from './roots.mjs';
13
-
14
- const VALID_TIERS = new Set(['reasoning', 'standard', 'fast']);
13
+ import { MODEL_TIERS, MODEL_TIER_SET as VALID_TIERS } from './model-tiers.mjs';
15
14
  const VALID_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
16
15
 
17
16
  const PROMPT_HARD_CAP_WORDS = 4000;
@@ -221,7 +220,7 @@ export function validateRegistry(reg, options = {}) {
221
220
  if (!reg.models || typeof reg.models !== 'object') {
222
221
  errors.push('registry: models is missing');
223
222
  } else {
224
- for (const tier of ['reasoning', 'standard', 'fast']) {
223
+ for (const tier of MODEL_TIERS) {
225
224
  const t = reg.models[tier];
226
225
  if (!t || typeof t !== 'object') {
227
226
  errors.push(`models.${tier}: tier object is missing`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "type": "module",
5
5
  "packageManager": "npm@11.5.1",
6
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
@@ -123,10 +123,6 @@
123
123
  "unpdf": "^1.6.2",
124
124
  "zod": "^4.4.3"
125
125
  },
126
- "overrides": {
127
- "express-rate-limit": "8.5.1",
128
- "undici": "6.27.0"
129
- },
130
126
  "devDependencies": {
131
127
  "@eslint/js": "^9.39.4",
132
128
  "c8": "^11.0.0",
@@ -70,10 +70,6 @@
70
70
  "name": "artifact_workflow",
71
71
  "use": "Plan a manifest-backed artifact workflow; validate or export locally."
72
72
  },
73
- {
74
- "name": "workflow_invoke",
75
- "use": "Invoke an embedded Construct workflow (prd-draft, architecture-review, …)."
76
- },
77
73
  {
78
74
  "name": "triage_recommend",
79
75
  "use": "Recommend intake triage — type, owner, and routing — for a new signal."
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "catalog": {
4
- "generatedAt": "2026-07-06T22:40:56.862Z",
4
+ "generatedAt": "2026-07-09T04:29:22.395Z",
5
5
  "npmScripts": [
6
6
  "adapters",
7
7
  "audit:published",
@@ -11,6 +11,7 @@
11
11
  "catalog:regen",
12
12
  "catalog:validate",
13
13
  "certify:demos",
14
+ "ci:local",
14
15
  "clean:artifacts",
15
16
  "coverage",
16
17
  "deps:check",
@@ -746,6 +747,12 @@
746
747
  "core": true,
747
748
  "internal": false
748
749
  },
750
+ {
751
+ "name": "synthesize",
752
+ "category": "Work",
753
+ "core": false,
754
+ "internal": false
755
+ },
749
756
  {
750
757
  "name": "tags",
751
758
  "category": "Work",
@@ -788,12 +795,24 @@
788
795
  "core": false,
789
796
  "internal": false
790
797
  },
798
+ {
799
+ "name": "templates",
800
+ "category": "Advanced",
801
+ "core": false,
802
+ "internal": false
803
+ },
791
804
  {
792
805
  "name": "tools",
793
806
  "category": "Work",
794
807
  "core": false,
795
808
  "internal": false
796
809
  },
810
+ {
811
+ "name": "tracker",
812
+ "category": "Models & Integrations",
813
+ "core": false,
814
+ "internal": false
815
+ },
797
816
  {
798
817
  "name": "uninstall",
799
818
  "category": "Advanced",
@@ -1459,12 +1459,21 @@ ${buildPrompt(entry, allEntries, "copilot")}
1459
1459
  }
1460
1460
 
1461
1461
  function syncCopilot(entries, targetDir = null, wants = true) {
1462
+ // Deselected host: leave prior Copilot outputs untouched. Adapter pruning is
1463
+ // explicit-consent-only and `.github` is out of adapter-prune's scope
1464
+ // (lib/reconcile/adapter-prune.mjs), so a sync that did not select copilot
1465
+ // must never delete or degrade another run's files — the old empty-writeEntries
1466
+ // fall-through pruned .github/prompts and .github/agents and blanked the
1467
+ // instructions block on every copilot-less sync (construct-lqp4c).
1468
+
1469
+ if (!wants) return;
1470
+
1462
1471
  const promptsDir = targetDir
1463
1472
  ? path.join(targetDir, ".github", "prompts")
1464
1473
  : path.join(home, ".github", "prompts");
1465
- if (!DRY_RUN && wants) mkdirp(promptsDir);
1474
+ if (!DRY_RUN) mkdirp(promptsDir);
1466
1475
 
1467
- const writeEntries = wants ? globalEntries(entries) : [];
1476
+ const writeEntries = globalEntries(entries);
1468
1477
 
1469
1478
  for (const entry of writeEntries) {
1470
1479
  writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries), { stamp: false });
@@ -1483,7 +1492,7 @@ function syncCopilot(entries, targetDir = null, wants = true) {
1483
1492
  const agentsDir = targetDir
1484
1493
  ? path.join(targetDir, ".github", "agents")
1485
1494
  : path.join(home, ".github", "agents");
1486
- if (!DRY_RUN && wants) mkdirp(agentsDir);
1495
+ if (!DRY_RUN) mkdirp(agentsDir);
1487
1496
  for (const entry of writeEntries) {
1488
1497
  writeFile(path.join(agentsDir, `${adapterName(entry)}.agent.md`), copilotAgentFile(entry, entries), { stamp: false });
1489
1498
  }
@@ -62,6 +62,28 @@ cp templates/docs/prd.md .cx/templates/docs/prd.md
62
62
 
63
63
  Ask Construct for a PRD; it'll follow the new shape.
64
64
 
65
+ ### Registering a new document class
66
+
67
+ Overriding a template reshapes an *existing* class. To generate a class the builtin manifest never registered, register it — this writes the template plus a project-tier manifest overlay, and never touches the builtin `specialists/artifact-manifest.json`:
68
+
69
+ ```bash
70
+ construct templates register convergence-brief \
71
+ --description "Cross-project strategy convergence brief" \
72
+ [--from .cx/templates/docs/convergence-brief.md]
73
+ ```
74
+
75
+ That writes `.cx/templates/docs/convergence-brief.md` (a starter, or your `--from` file) and adds `convergence-brief` to `.cx/artifact-manifest.overlay.json`. The overlay merges over the builtin by three tiers (builtin → user `~/.config/construct/` → project `.cx/`), so the registered class now resolves everywhere: `get_template("convergence-brief")` returns the project override, and `author_artifact {type:"convergence-brief", ...}` drafts from it and runs the release gate. A registered class inherits memo-like author/reviewer defaults; override them (including `outputDir`) by editing its overlay entry.
76
+
77
+ ### One-off adhoc documents
78
+
79
+ For a genuinely one-off document with no fixed shape, skip registration entirely and use the sanctioned `adhoc` type:
80
+
81
+ ```
82
+ author_artifact {type:"adhoc", title:"Q3 strategy convergence", instructions:"..."}
83
+ ```
84
+
85
+ `adhoc` needs an explicit `title` and `instructions`; its structure follows the instructions, but it still runs the full release gate — free-form structure, not free-form quality. It is not a bypass for a registered class: naming a known type through `adhoc` is redirected to that class. An unknown, unregistered non-adhoc class still returns a classification/registration prompt rather than becoming a PRD.
86
+
65
87
  ## Role Anti-Patterns
66
88
 
67
89
  Each specialist is cognitively rooted in a **role** (product-manager, engineer, architect, etc.) with a core set of failure modes to avoid. Flavored specialists extend the core with an overlay.
@@ -0,0 +1,12 @@
1
+ # {title}
2
+
3
+ <!-- Adhoc artifact: structure follows your instructions, not a fixed shape. Keep only the sections you need; add whatever the subject demands. Quality gates still apply — cite sources or mark [unverified]. -->
4
+
5
+ ## Summary
6
+ <!-- One paragraph. What this document decides, communicates, or captures, and for whom. -->
7
+
8
+ ## Detail
9
+ <!-- The substance, in whatever structure the instructions imply: prose, sections, tables, or a mix. -->
10
+
11
+ ## Next steps
12
+ <!-- Actions, decisions, or open questions this leaves. Omit if not applicable. -->
@@ -0,0 +1,19 @@
1
+ # {title}
2
+
3
+ _Generated {YYYY-MM-DD}. Every cross-project claim must cite its source as `project:path`._
4
+
5
+ ## Per-project summary
6
+
7
+ One attributed subsection per project — what each project's own documents say, cited to origin.
8
+
9
+ ## Convergence
10
+
11
+ Where the projects agree — shared strategy, common patterns, aligned bets. Each claim cites the projects it draws from.
12
+
13
+ ## Divergence
14
+
15
+ Where the projects differ — conflicting choices, gaps, incompatible assumptions. Cite both sides.
16
+
17
+ ## Recommendation
18
+
19
+ The synthesis: what the convergence and divergence imply. Mark any forward-looking claim `[unverified]` when it is not grounded in a cited source.