@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
|
@@ -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
|
|
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.
|
|
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-
|
|
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
|
|
1474
|
+
if (!DRY_RUN) mkdirp(promptsDir);
|
|
1466
1475
|
|
|
1467
|
-
const writeEntries =
|
|
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
|
|
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
|
}
|
package/templates/docs/README.md
CHANGED
|
@@ -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.
|