@geraldmaron/construct 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +3 -0
  2. package/bin/construct +336 -17
  3. package/lib/artifact-loop-core.mjs +26 -5
  4. package/lib/artifact-manifest-overlay.mjs +273 -0
  5. package/lib/artifact-manifest.mjs +13 -10
  6. package/lib/cli-commands.mjs +44 -4
  7. package/lib/config/source-target-registry.mjs +1 -0
  8. package/lib/config/source-targets.mjs +30 -0
  9. package/lib/doc-stamp.mjs +12 -0
  10. package/lib/doctor/index.mjs +2 -1
  11. package/lib/doctor/source-target-health.mjs +101 -0
  12. package/lib/doctor/watchers/source-targets.mjs +44 -0
  13. package/lib/document-ingest.mjs +25 -3
  14. package/lib/embed/demand-fetch.mjs +30 -36
  15. package/lib/embed/providers/directory.mjs +117 -0
  16. package/lib/embed/providers/registry.mjs +7 -0
  17. package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
  18. package/lib/extensions/manifests/directory.manifest.json +24 -1
  19. package/lib/extensions/manifests/github.manifest.json +10 -0
  20. package/lib/extensions/manifests/linear.manifest.json +1 -1
  21. package/lib/hooks/session-start.mjs +18 -11
  22. package/lib/init-unified.mjs +2 -7
  23. package/lib/knowledge/rag.mjs +52 -11
  24. package/lib/knowledge/search.mjs +69 -6
  25. package/lib/knowledge/synthesis.mjs +157 -0
  26. package/lib/mcp/server.mjs +2 -2
  27. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  28. package/lib/mcp/tools/artifact-author.mjs +126 -13
  29. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  30. package/lib/mcp/tools/skills.mjs +17 -0
  31. package/lib/model-cheapest-provider.mjs +2 -1
  32. package/lib/model-policy.mjs +329 -0
  33. package/lib/model-router.mjs +10 -9
  34. package/lib/model-tiers.mjs +27 -0
  35. package/lib/models/catalog.mjs +5 -4
  36. package/lib/orchestration/classification.mjs +11 -0
  37. package/lib/orchestration/context-bindings.mjs +85 -0
  38. package/lib/orchestration/readiness.mjs +2 -1
  39. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  40. package/lib/orchestration/runtime.mjs +35 -1
  41. package/lib/orchestration/worker.mjs +9 -0
  42. package/lib/providers/directory/index.mjs +19 -7
  43. package/lib/setup.mjs +2 -1
  44. package/lib/sources/content-roots.mjs +147 -0
  45. package/lib/sources/repo-cache.mjs +142 -0
  46. package/lib/template-registry.mjs +2 -0
  47. package/lib/test-corpus-inventory.mjs +1 -0
  48. package/lib/tracker/contribute.mjs +266 -0
  49. package/lib/validator.mjs +2 -3
  50. package/package.json +1 -1
  51. package/registry/agent-manifest.json +0 -4
  52. package/registry/capabilities.json +20 -1
  53. package/templates/docs/README.md +22 -0
  54. package/templates/docs/adhoc.md +12 -0
  55. package/templates/docs/strategy-comparison.md +19 -0
@@ -0,0 +1,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.0",
3
+ "version": "1.5.2",
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",
@@ -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",
@@ -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.