@geraldmaron/construct 1.0.6 → 1.0.8
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 +39 -34
- package/bin/construct +1403 -96
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +2 -2
- package/commands/work/parallel-review.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +8 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +139 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +371 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +360 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +420 -17
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +50 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/env-config.mjs +9 -3
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +14 -1
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +73 -65
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +309 -58
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +31 -6
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -1
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +11 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/model-registry.mjs +1 -1
- package/lib/observation-store.mjs +9 -4
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/reflect.mjs +49 -12
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +29 -29
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/client.mjs +5 -1
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +16 -3
- package/personas/construct.md +5 -3
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/specialists/contracts.json +1002 -0
- package/specialists/contracts.schema.json +83 -0
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-architect.md +3 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-debugger.md +3 -1
- package/{agents → specialists}/prompts/cx-designer.md +3 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
- package/{agents → specialists}/prompts/cx-engineer.md +4 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
- package/{agents → specialists}/prompts/cx-explorer.md +3 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
- package/{agents → specialists}/prompts/cx-operations.md +3 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-qa.md +3 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-researcher.md +3 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
- package/{agents → specialists}/prompts/cx-security.md +3 -1
- package/{agents → specialists}/prompts/cx-sre.md +4 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
- package/{agents → specialists}/registry.json +651 -418
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/docs/construct_guide.md +6 -6
- package/templates/docs/skill-artifact.md +1 -1
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- package/agents/contracts.json +0 -387
- /package/{agents → specialists}/teams.json +0 -0
package/lib/comment-lint.mjs
CHANGED
|
@@ -18,7 +18,7 @@ const JS_HEADER_GLOBS = [
|
|
|
18
18
|
/^lib\/server\//,
|
|
19
19
|
/^lib\/mcp\//,
|
|
20
20
|
/^lib\/metrics\//,
|
|
21
|
-
/^sync-
|
|
21
|
+
/^sync-specialists\.mjs$/,
|
|
22
22
|
/^tests\//,
|
|
23
23
|
];
|
|
24
24
|
|
|
@@ -29,10 +29,29 @@ const MD_HEADER_GLOBS = [
|
|
|
29
29
|
/^commands\//,
|
|
30
30
|
];
|
|
31
31
|
|
|
32
|
+
// Artifact paths receive an additional prose-lint pass for fabrication patterns
|
|
33
|
+
// (manufactured confidence, hand-wave percentages, mind-reading, speculation).
|
|
34
|
+
// Scope is deliberately narrow — READMEs, cookbook, concepts, and reference are
|
|
35
|
+
// unaffected. The patterns live in BANNED_ARTIFACT below; the severity (block
|
|
36
|
+
// vs warn) is controlled by CONSTRUCT_ARTIFACT_LINT_MODE.
|
|
37
|
+
const ARTIFACT_PATH_GLOBS = [
|
|
38
|
+
/^docs\/prd\//,
|
|
39
|
+
/^docs\/adr\//,
|
|
40
|
+
/^docs\/rfc\//,
|
|
41
|
+
/^docs\/research\//,
|
|
42
|
+
/^\.cx\/knowledge\//,
|
|
43
|
+
/^\.cx\/handoffs\//,
|
|
44
|
+
/^\.cx\/research\//,
|
|
45
|
+
];
|
|
46
|
+
|
|
32
47
|
function relPath(rootDir, absPath) {
|
|
33
48
|
return path.relative(rootDir, absPath).replace(/\\/g, '/');
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
function isArtifactPath(rel) {
|
|
52
|
+
return ARTIFACT_PATH_GLOBS.some((r) => r.test(rel));
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
function requiresHeader(rel) {
|
|
37
56
|
const ext = path.extname(rel);
|
|
38
57
|
if (['.yaml', '.yml', '.json', '.jsonl', '.toml'].includes(ext)) return { required: false, type: null };
|
|
@@ -42,6 +61,10 @@ function requiresHeader(rel) {
|
|
|
42
61
|
if (rel.startsWith('lib/server/static/')) {
|
|
43
62
|
return { required: false, type: null };
|
|
44
63
|
}
|
|
64
|
+
// Exclude test fixture data files — these are project artifacts, not source files.
|
|
65
|
+
if (rel.startsWith('tests/fixtures/projects/')) {
|
|
66
|
+
return { required: false, type: null };
|
|
67
|
+
}
|
|
45
68
|
// Markdown files always get markdown-style headers, even if the directory
|
|
46
69
|
// glob (e.g. ^tests/) primarily targets JS sources. Without this, .md docs
|
|
47
70
|
// co-located with .mjs tests were forced into /** */ format.
|
|
@@ -88,6 +111,13 @@ const BANNED = [
|
|
|
88
111
|
// step markers — only flag in `//` comments. Ordered lists are
|
|
89
112
|
// conventional inside JSDoc decision-tree and flow documentation.
|
|
90
113
|
{ pattern: /\/\/\s+\d+\.\s+\w/, label: 'step marker: use function names or block structure instead of "// 1. Step"' },
|
|
114
|
+
// deferred-implementation wording: signals incomplete work that should
|
|
115
|
+
// either ship or be removed before release.
|
|
116
|
+
{ pattern: new RegExp(`${CMT}.*\\bphase\\s+[abc]\\s+follow-?up\\b`, 'i'), label: 'deferred implementation: "Phase X follow-up" — ship it or remove it' },
|
|
117
|
+
{ pattern: new RegExp(`${CMT}.*\\bin a real implementation\\b`, 'i'), label: 'deferred implementation: "in a real implementation" — implement the contract honestly' },
|
|
118
|
+
{ pattern: new RegExp(`${CMT}.*\\bwould go here\\b`, 'i'), label: 'deferred implementation: "would go here" — implement or delete the placeholder' },
|
|
119
|
+
{ pattern: new RegExp(`${CMT}.*\\bcoming soon\\b`, 'i'), label: 'deferred implementation: "coming soon" — not a release-ready statement' },
|
|
120
|
+
{ pattern: new RegExp(`${CMT}.*\\bnot yet supported\\b`, 'i'), label: 'deferred implementation: "not yet supported" — document the supported contract instead' },
|
|
91
121
|
// markdown equivalents
|
|
92
122
|
{ pattern: /<!--.*\b(?:added for|added recently|just added|new:)\b.*-->/i, label: 'point-in-time in markdown comment' },
|
|
93
123
|
{ pattern: /<!--.*\b(?:used by|called from|only consumer)\b.*-->/i, label: 'caller reference in markdown comment' },
|
|
@@ -102,16 +132,111 @@ const BANNED = [
|
|
|
102
132
|
const BANNED_CHECK_SKIP = [
|
|
103
133
|
'lib/comment-lint.mjs',
|
|
104
134
|
'rules/common/comments.md',
|
|
135
|
+
'rules/common/no-fabrication.md',
|
|
105
136
|
'tests/comment-lint.test.mjs',
|
|
106
137
|
'commands/work/clean.md',
|
|
107
138
|
'skills/utility/clean-code.md',
|
|
108
139
|
];
|
|
109
140
|
|
|
141
|
+
// Artifact-prose patterns. Scoped to ARTIFACT_PATH_GLOBS. Each entry can mark
|
|
142
|
+
// requireCitation: true — in that case the violation is suppressed when a
|
|
143
|
+
// citation (`[source: …]`, http URL, or footnote ref) appears in the same line
|
|
144
|
+
// or within 2 lines of context.
|
|
145
|
+
const BANNED_ARTIFACT = [
|
|
146
|
+
{
|
|
147
|
+
pattern: /\b(?:clearly|obviously|undoubtedly|definitely|certainly|surely)\b/i,
|
|
148
|
+
label: 'manufactured confidence: "clearly/obviously/undoubtedly/etc" smuggles certainty without evidence (no-fabrication §4)',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
pattern: /\b\d{1,3}\s*%/,
|
|
152
|
+
label: 'unattributed percentage: quantitative claims require a [source: …] citation (no-fabrication §4)',
|
|
153
|
+
requireCitation: true,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
pattern: /\b(?:users|customers|everyone)\s+(?:want|expect|prefer|agree|need)\b/i,
|
|
157
|
+
label: 'customer mind-reading: "users want / customers expect / everyone agrees" requires a [source: …] citation (no-fabrication §5)',
|
|
158
|
+
requireCitation: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
pattern: /\b(?:will\s+(?:likely|probably)|should\s+improve|expected\s+to\s+(?:improve|reduce|increase|drop|rise))\b/i,
|
|
162
|
+
label: 'speculative projection: future-tense claims need a baseline + projection source (no-fabrication §3, §4)',
|
|
163
|
+
requireCitation: true,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
110
167
|
function isBannedCheckSkipped(filePath) {
|
|
111
168
|
if (!filePath) return false;
|
|
112
169
|
return BANNED_CHECK_SKIP.some((suffix) => filePath.endsWith(suffix));
|
|
113
170
|
}
|
|
114
171
|
|
|
172
|
+
// Track fenced code blocks ( ``` … ``` ) and YAML frontmatter ( --- … --- )
|
|
173
|
+
// so artifact-prose patterns inside code or metadata are not flagged.
|
|
174
|
+
function buildSkipMap(lines) {
|
|
175
|
+
const skip = new Array(lines.length).fill(false);
|
|
176
|
+
let inFence = false;
|
|
177
|
+
let inFrontmatter = false;
|
|
178
|
+
let frontmatterClosed = false;
|
|
179
|
+
for (let i = 0; i < lines.length; i++) {
|
|
180
|
+
const line = lines[i];
|
|
181
|
+
if (i === 0 && line.trim() === '---') {
|
|
182
|
+
inFrontmatter = true;
|
|
183
|
+
skip[i] = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (inFrontmatter) {
|
|
187
|
+
skip[i] = true;
|
|
188
|
+
if (line.trim() === '---') {
|
|
189
|
+
inFrontmatter = false;
|
|
190
|
+
frontmatterClosed = true;
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (/^```/.test(line)) {
|
|
195
|
+
inFence = !inFence;
|
|
196
|
+
skip[i] = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (inFence) {
|
|
200
|
+
skip[i] = true;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) skip[i] = true;
|
|
204
|
+
// Markdown table rows (start and end with `|`) are typically targets,
|
|
205
|
+
// specs, or reference lookups, not narrative prose. Skip to keep the
|
|
206
|
+
// pattern bank focused on the prose surface where embellishment lives.
|
|
207
|
+
if (/^\s*\|.*\|\s*$/.test(line)) skip[i] = true;
|
|
208
|
+
}
|
|
209
|
+
void frontmatterClosed;
|
|
210
|
+
return skip;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function hasCitationNearby(lines, idx) {
|
|
214
|
+
const lo = Math.max(0, idx - 2);
|
|
215
|
+
const hi = Math.min(lines.length, idx + 3);
|
|
216
|
+
for (let i = lo; i < hi; i++) {
|
|
217
|
+
if (/\[source:|\bhttps?:\/\/|\[\^\d+\]/i.test(lines[i])) return true;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function checkArtifactBanned(content, rel) {
|
|
223
|
+
if (!isArtifactPath(rel)) return [];
|
|
224
|
+
const lines = content.split('\n');
|
|
225
|
+
const skip = buildSkipMap(lines);
|
|
226
|
+
const violations = [];
|
|
227
|
+
for (let i = 0; i < lines.length; i++) {
|
|
228
|
+
if (skip[i]) continue;
|
|
229
|
+
if (lines[i].includes('construct-lint-ignore')) continue;
|
|
230
|
+
for (const entry of BANNED_ARTIFACT) {
|
|
231
|
+
if (entry.pattern.test(lines[i])) {
|
|
232
|
+
if (entry.requireCitation && hasCitationNearby(lines, i)) continue;
|
|
233
|
+
violations.push({ line: i + 1, label: entry.label, kind: 'artifact' });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return violations;
|
|
238
|
+
}
|
|
239
|
+
|
|
115
240
|
function checkBanned(content, filePath) {
|
|
116
241
|
if (isBannedCheckSkipped(filePath)) return [];
|
|
117
242
|
|
|
@@ -180,6 +305,19 @@ export function lintFile(filePath, { rootDir = process.cwd(), fix = false } = {}
|
|
|
180
305
|
const banned = checkBanned(content, filePath);
|
|
181
306
|
for (const w of banned) warnings.push(w);
|
|
182
307
|
|
|
308
|
+
// Artifact-prose lint applies to markdown files under intake-fed artifact
|
|
309
|
+
// paths (PRD, ADR, RFC, research, knowledge, handoffs). Severity is governed
|
|
310
|
+
// by CONSTRUCT_ARTIFACT_LINT_MODE: 'block' (CLI / release-gate) routes hits
|
|
311
|
+
// to errors[]; 'warn' (PostToolUse hook default) routes to warnings[] with
|
|
312
|
+
// kind:'artifact' so the hook can let the edit through while still surfacing
|
|
313
|
+
// the finding.
|
|
314
|
+
const artifactHits = checkArtifactBanned(content, rel);
|
|
315
|
+
if (artifactHits.length > 0) {
|
|
316
|
+
const mode = process.env.CONSTRUCT_ARTIFACT_LINT_MODE || 'warn';
|
|
317
|
+
const bucket = mode === 'block' ? errors : warnings;
|
|
318
|
+
for (const v of artifactHits) bucket.push(v);
|
|
319
|
+
}
|
|
320
|
+
|
|
183
321
|
return { path: rel, errors, warnings };
|
|
184
322
|
}
|
|
185
323
|
|
package/lib/completions.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* completions.mjs — generates bash and zsh completion scripts for construct.
|
|
3
3
|
*
|
|
4
|
-
* Called by scripts/sync-
|
|
4
|
+
* Called by scripts/sync-specialists.mjs after each sync.
|
|
5
5
|
* Outputs to a platform-appropriate per-user completions directory.
|
|
6
6
|
*/
|
|
7
7
|
|
package/lib/config/schema.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* before npm install completes in some bootstrap paths.
|
|
7
7
|
*
|
|
8
8
|
* Schema v1 covers: alias, deployment, providers, autoEmbed, telemetry.
|
|
9
|
-
* Tier model selection lives in `
|
|
9
|
+
* Tier model selection lives in `specialists/registry.json` only — this config
|
|
10
10
|
* file is intentionally not a second edit surface for model assignments.
|
|
11
11
|
* Later phases (6, 7b) extend it with `resources` and `costs` blocks
|
|
12
12
|
* without bumping the version — additive keys are allowed.
|
|
@@ -22,6 +22,8 @@ export const DEPLOYMENT_MODES = ['solo', 'team', 'enterprise'];
|
|
|
22
22
|
export const MCP_BROKER_VALUES = ['auto', 'on', 'off'];
|
|
23
23
|
export const DEFAULT_PROFILE_ID = 'rnd';
|
|
24
24
|
|
|
25
|
+
export const SURFACES = ['claude', 'opencode', 'codex', 'copilot', 'vscode', 'cursor'];
|
|
26
|
+
|
|
25
27
|
export const DEFAULT_PROJECT_CONFIG = Object.freeze({
|
|
26
28
|
version: CONFIG_SCHEMA_VERSION,
|
|
27
29
|
alias: 'Construct',
|
|
@@ -42,6 +44,7 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
|
|
|
42
44
|
secondary: null,
|
|
43
45
|
perConversationOverride: true,
|
|
44
46
|
}),
|
|
47
|
+
hosts: Object.freeze(Object.fromEntries(SURFACES.map((s) => [s, Object.freeze({ enabled: true })]))),
|
|
45
48
|
resources: Object.freeze({
|
|
46
49
|
disk: Object.freeze({
|
|
47
50
|
tracesMaxDays: 30,
|
|
@@ -98,6 +101,17 @@ export const FIELD_RULES = {
|
|
|
98
101
|
},
|
|
99
102
|
},
|
|
100
103
|
resources: { type: 'object', required: false },
|
|
104
|
+
hosts: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
required: false,
|
|
107
|
+
fields: Object.fromEntries(SURFACES.map((s) => [s, {
|
|
108
|
+
type: 'object',
|
|
109
|
+
required: false,
|
|
110
|
+
fields: {
|
|
111
|
+
enabled: { type: 'boolean', required: false },
|
|
112
|
+
},
|
|
113
|
+
}])),
|
|
114
|
+
},
|
|
101
115
|
costs: {
|
|
102
116
|
type: 'object',
|
|
103
117
|
required: false,
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/contracts/validate.mjs — Validate specialists/contracts.json at sync time + at handoff.
|
|
3
|
+
*
|
|
4
|
+
* Three validation tiers:
|
|
5
|
+
* 1. Schema shape: contracts.json conforms to contracts.schema.json (minimal
|
|
6
|
+
* validator — top-level required fields, contract entry required fields).
|
|
7
|
+
* 2. Cross-file refs: every output.schema points to a real file in lib/schemas/,
|
|
8
|
+
* every producer/consumer name resolves to an agent or persona in
|
|
9
|
+
* specialists/registry.json, every well-known event/intake string is reachable.
|
|
10
|
+
* 3. Runtime handoff: a single artifact validated against the schema referenced
|
|
11
|
+
* by a producer→consumer contract, with mustContain post-conditions.
|
|
12
|
+
*
|
|
13
|
+
* Surfaces:
|
|
14
|
+
* - scripts/sync-specialists.mjs invokes validateContractsFile at sync time.
|
|
15
|
+
* - bin/construct lint:contracts invokes the same path in CI.
|
|
16
|
+
* - workflowContractValidate (runtime) invokes validateHandoff per handoff.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join, dirname, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
24
|
+
const CONTRACTS_PATH = join(REPO_ROOT, 'specialists', 'contracts.json');
|
|
25
|
+
const CONTRACTS_SCHEMA_PATH = join(REPO_ROOT, 'specialists', 'contracts.schema.json');
|
|
26
|
+
const REGISTRY_PATH = join(REPO_ROOT, 'specialists', 'registry.json');
|
|
27
|
+
|
|
28
|
+
const WELL_KNOWN_PRODUCERS = new Set(['user', 'oncall', 'incident-system', '*']);
|
|
29
|
+
const WELL_KNOWN_CONSUMERS = new Set(['user', 'construct']);
|
|
30
|
+
|
|
31
|
+
function readJson(path) {
|
|
32
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate specialists/contracts.json shape and cross-file references.
|
|
37
|
+
* Returns { ok, errors[] } — errors is empty on success.
|
|
38
|
+
*/
|
|
39
|
+
export function validateContractsFile({ contractsPath, registryPath, repoRoot = REPO_ROOT } = {}) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
|
|
42
|
+
// When repoRoot is supplied but explicit paths are not, derive both from it
|
|
43
|
+
// so test fixtures and the consistency watcher can validate slices in a
|
|
44
|
+
// tmpdir without pointing at the live repo.
|
|
45
|
+
const cPath = contractsPath || join(repoRoot, 'specialists', 'contracts.json');
|
|
46
|
+
const rPath = registryPath || join(repoRoot, 'specialists', 'registry.json');
|
|
47
|
+
const schemaPath = join(repoRoot, 'specialists', 'contracts.schema.json');
|
|
48
|
+
|
|
49
|
+
if (!existsSync(cPath)) {
|
|
50
|
+
return { ok: false, errors: [`contracts file not found: ${cPath}`] };
|
|
51
|
+
}
|
|
52
|
+
if (!existsSync(schemaPath)) {
|
|
53
|
+
errors.push(`contracts schema missing: ${schemaPath}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let contracts;
|
|
57
|
+
try { contracts = readJson(cPath); }
|
|
58
|
+
catch (err) { return { ok: false, errors: [`contracts.json parse error: ${err.message}`] }; }
|
|
59
|
+
|
|
60
|
+
for (const key of ['version', 'terminalStates', 'severities', 'contracts']) {
|
|
61
|
+
if (!(key in contracts)) errors.push(`contracts.json missing top-level field: ${key}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const knownNames = collectAgentNames(rPath);
|
|
65
|
+
|
|
66
|
+
if (Array.isArray(contracts.contracts)) {
|
|
67
|
+
const ids = new Set();
|
|
68
|
+
contracts.contracts.forEach((c, idx) => {
|
|
69
|
+
const where = `contracts[${idx}]${c.id ? ` (${c.id})` : ''}`;
|
|
70
|
+
if (!c.id) errors.push(`${where}: missing id`);
|
|
71
|
+
else if (!/^[a-z0-9][a-z0-9-]*$/.test(c.id)) errors.push(`${where}: id must be kebab-case`);
|
|
72
|
+
else if (ids.has(c.id)) errors.push(`${where}: duplicate id`);
|
|
73
|
+
else ids.add(c.id);
|
|
74
|
+
|
|
75
|
+
if (!c.producer) errors.push(`${where}: missing producer`);
|
|
76
|
+
if (!c.consumer) errors.push(`${where}: missing consumer`);
|
|
77
|
+
if (!c.input) errors.push(`${where}: missing input`);
|
|
78
|
+
|
|
79
|
+
if (c.producer && !nameResolves(c.producer, knownNames, 'producer')) {
|
|
80
|
+
errors.push(`${where}: producer '${c.producer}' is not an agent/persona in registry.json and is not a well-known producer`);
|
|
81
|
+
}
|
|
82
|
+
if (c.consumer && !nameResolves(c.consumer, knownNames, 'consumer')) {
|
|
83
|
+
errors.push(`${where}: consumer '${c.consumer}' is not an agent/persona in registry.json and is not a well-known consumer`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const schemaRef = c.output?.schema;
|
|
87
|
+
if (schemaRef) {
|
|
88
|
+
const outputSchemaPath = join(repoRoot, schemaRef);
|
|
89
|
+
if (!existsSync(outputSchemaPath)) {
|
|
90
|
+
errors.push(`${where}: output.schema '${schemaRef}' does not exist on disk`);
|
|
91
|
+
} else {
|
|
92
|
+
try { readJson(outputSchemaPath); }
|
|
93
|
+
catch (err) { errors.push(`${where}: output.schema '${schemaRef}' is not valid JSON: ${err.message}`); }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
errors.push('contracts.json: contracts must be an array');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { ok: errors.length === 0, errors };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function nameResolves(name, knownNames, role) {
|
|
105
|
+
if (WELL_KNOWN_PRODUCERS.has(name) && role === 'producer') return true;
|
|
106
|
+
if (WELL_KNOWN_CONSUMERS.has(name) && role === 'consumer') return true;
|
|
107
|
+
if (knownNames.has(name)) return true;
|
|
108
|
+
// Persona registry stores names without the cx- prefix; contracts.json
|
|
109
|
+
// conventionally uses the cx-prefixed form. Normalize both directions.
|
|
110
|
+
const stripped = name.startsWith('cx-') ? name.slice(3) : `cx-${name}`;
|
|
111
|
+
return knownNames.has(stripped);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function collectAgentNames(registryPath) {
|
|
115
|
+
const names = new Set();
|
|
116
|
+
if (!existsSync(registryPath)) return names;
|
|
117
|
+
try {
|
|
118
|
+
const registry = readJson(registryPath);
|
|
119
|
+
if (registry.orchestrator?.name) names.add(registry.orchestrator.name);
|
|
120
|
+
if (registry.orchestrator?.displayName) names.add(registry.orchestrator.displayName);
|
|
121
|
+
for (const s of registry.specialists || []) {
|
|
122
|
+
if (s?.name) names.add(s.name);
|
|
123
|
+
if (s?.displayName) names.add(s.displayName);
|
|
124
|
+
}
|
|
125
|
+
} catch { /* fall through with whatever names we collected */ }
|
|
126
|
+
return names;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Look up a contract by producer/consumer pair (and optional id).
|
|
131
|
+
*/
|
|
132
|
+
export function findContract({ producer, consumer, id, contractsPath = CONTRACTS_PATH }) {
|
|
133
|
+
if (!existsSync(contractsPath)) return null;
|
|
134
|
+
let contracts;
|
|
135
|
+
try { contracts = readJson(contractsPath); } catch { return null; }
|
|
136
|
+
const list = contracts.contracts || [];
|
|
137
|
+
if (id) return list.find((c) => c.id === id) || null;
|
|
138
|
+
return list.find((c) => c.producer === producer && c.consumer === consumer) || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate a single artifact against its contract at handoff time.
|
|
143
|
+
*
|
|
144
|
+
* Returns one of:
|
|
145
|
+
* { ok: true, contract }
|
|
146
|
+
* { ok: false, status: 'BLOCKED_CONTRACT', errors[], contract }
|
|
147
|
+
*
|
|
148
|
+
* Enforcement defaults to the value of process.env.CONSTRUCT_CONTRACT_ENFORCEMENT
|
|
149
|
+
* ('warn' or 'block'), and may be overridden by the caller. In warn mode the
|
|
150
|
+
* result includes errors but ok stays true so a session is not blocked.
|
|
151
|
+
*/
|
|
152
|
+
export function validateHandoff({
|
|
153
|
+
producer,
|
|
154
|
+
consumer,
|
|
155
|
+
id,
|
|
156
|
+
artifact,
|
|
157
|
+
contractsPath = CONTRACTS_PATH,
|
|
158
|
+
repoRoot = REPO_ROOT,
|
|
159
|
+
enforcement = process.env.CONSTRUCT_CONTRACT_ENFORCEMENT || 'warn',
|
|
160
|
+
}) {
|
|
161
|
+
const contract = findContract({ producer, consumer, id, contractsPath });
|
|
162
|
+
if (!contract) {
|
|
163
|
+
return enforcement === 'block'
|
|
164
|
+
? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`], contract: null }
|
|
165
|
+
: { ok: true, warnings: [`no contract found for ${producer}→${consumer}`], contract: null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const errors = [];
|
|
169
|
+
|
|
170
|
+
// A handoff carries a producer's output, which is the consumer's input. The
|
|
171
|
+
// input contract is what the consumer expects to receive; check mustContain
|
|
172
|
+
// against that first. Output.schema validation only applies when the artifact
|
|
173
|
+
// declares the matching `type` (e.g. the consumer is forwarding its own
|
|
174
|
+
// produced artifact downstream).
|
|
175
|
+
const inputMustContain = contract.input?.mustContain || [];
|
|
176
|
+
for (const field of inputMustContain) {
|
|
177
|
+
if (!hasField(artifact, field)) {
|
|
178
|
+
errors.push(`artifact missing required field: ${field}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const outputType = contract.output?.type;
|
|
183
|
+
const schemaRef = contract.output?.schema;
|
|
184
|
+
if (schemaRef && outputType && artifact && artifact.type === outputType) {
|
|
185
|
+
const schemaPath = join(repoRoot, schemaRef);
|
|
186
|
+
if (!existsSync(schemaPath)) {
|
|
187
|
+
errors.push(`contract output.schema '${schemaRef}' does not exist on disk`);
|
|
188
|
+
} else {
|
|
189
|
+
const schema = readJson(schemaPath);
|
|
190
|
+
for (const field of schema.required || []) {
|
|
191
|
+
if (!hasField(artifact, field)) errors.push(`artifact missing schema-required field: ${field}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Postcondition checks run against the artifact file on disk when the
|
|
197
|
+
// handoff envelope declares `artifactPath`. Resolved relative to repoRoot.
|
|
198
|
+
const declaredArtifactPath = artifact?.artifactPath || artifact?.output?.artifactPath;
|
|
199
|
+
if (declaredArtifactPath) {
|
|
200
|
+
const absPath = isAbsolutePath(declaredArtifactPath)
|
|
201
|
+
? declaredArtifactPath
|
|
202
|
+
: join(repoRoot, declaredArtifactPath);
|
|
203
|
+
const pcErrors = validatePostconditions({ contract, artifactPath: absPath });
|
|
204
|
+
for (const e of pcErrors) errors.push(e);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (errors.length === 0) return { ok: true, contract };
|
|
208
|
+
if (enforcement === 'block') return { ok: false, status: 'BLOCKED_CONTRACT', errors, contract };
|
|
209
|
+
return { ok: true, warnings: errors, contract };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isAbsolutePath(p) {
|
|
213
|
+
return p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function hasField(obj, field) {
|
|
217
|
+
if (obj == null) return false;
|
|
218
|
+
if (typeof obj !== 'object') return false;
|
|
219
|
+
return Object.prototype.hasOwnProperty.call(obj, field) && obj[field] != null && obj[field] !== '';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
223
|
+
|
|
224
|
+
function parseArtifactFrontmatter(text) {
|
|
225
|
+
const m = text.match(FRONTMATTER_RE);
|
|
226
|
+
if (!m) return { frontmatter: {}, body: text };
|
|
227
|
+
const fm = {};
|
|
228
|
+
for (const line of m[1].split('\n')) {
|
|
229
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
230
|
+
const idx = line.indexOf(':');
|
|
231
|
+
if (idx === -1) continue;
|
|
232
|
+
const key = line.slice(0, idx).trim();
|
|
233
|
+
const rawValue = line.slice(idx + 1).trim();
|
|
234
|
+
if (key) fm[key] = rawValue.replace(/^["']|["']$/g, '');
|
|
235
|
+
}
|
|
236
|
+
return { frontmatter: fm, body: text.slice(m[0].length) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function hasSection(body, sectionTitle) {
|
|
240
|
+
const target = sectionTitle.trim().toLowerCase();
|
|
241
|
+
const headingRe = /^#{1,6}\s+(.+)$/gm;
|
|
242
|
+
let m;
|
|
243
|
+
while ((m = headingRe.exec(body))) {
|
|
244
|
+
if (m[1].trim().toLowerCase() === target) return true;
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function claimsHaveCitations(body) {
|
|
250
|
+
const lines = body.split('\n');
|
|
251
|
+
const offenders = [];
|
|
252
|
+
let inFence = false;
|
|
253
|
+
let inTable = false;
|
|
254
|
+
for (let i = 0; i < lines.length; i++) {
|
|
255
|
+
const line = lines[i];
|
|
256
|
+
if (/^```/.test(line)) { inFence = !inFence; continue; }
|
|
257
|
+
if (inFence) continue;
|
|
258
|
+
inTable = /^\s*\|.*\|\s*$/.test(line);
|
|
259
|
+
if (inTable) continue;
|
|
260
|
+
if (/\b\d{1,3}\s*%/.test(line) || /\b\d{2,}x\b/i.test(line)) {
|
|
261
|
+
const window = [lines[i - 1] || '', line, lines[i + 1] || ''].join('\n');
|
|
262
|
+
if (!/\[source:|\bhttps?:\/\/|\[\^\d+\]/i.test(window)) {
|
|
263
|
+
offenders.push({ line: i + 1, snippet: line.trim().slice(0, 80) });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return offenders;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Run structured postcondition checks against a handoff's output artifact on
|
|
272
|
+
* disk. Postconditions can be strings (descriptive, ignored here) or objects
|
|
273
|
+
* with { id, check, field?, section? }. Returns an array of error strings.
|
|
274
|
+
*/
|
|
275
|
+
export function validatePostconditions({ contract, artifactPath }) {
|
|
276
|
+
if (!contract?.postconditions?.length) return [];
|
|
277
|
+
if (!artifactPath || !existsSync(artifactPath)) {
|
|
278
|
+
const structured = contract.postconditions.filter((p) => typeof p === 'object');
|
|
279
|
+
if (structured.length === 0) return [];
|
|
280
|
+
return [`postcondition checks declared but artifact not found on disk: ${artifactPath}`];
|
|
281
|
+
}
|
|
282
|
+
const text = readFileSync(artifactPath, 'utf8');
|
|
283
|
+
const { frontmatter, body } = parseArtifactFrontmatter(text);
|
|
284
|
+
const errors = [];
|
|
285
|
+
for (const pc of contract.postconditions) {
|
|
286
|
+
if (typeof pc !== 'object') continue;
|
|
287
|
+
const tag = pc.id || pc.check;
|
|
288
|
+
switch (pc.check) {
|
|
289
|
+
case 'artifact-has-frontmatter-field': {
|
|
290
|
+
if (!pc.field) {
|
|
291
|
+
errors.push(`[${tag}] postcondition missing 'field' for artifact-has-frontmatter-field`);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
if (!frontmatter[pc.field]) {
|
|
295
|
+
errors.push(`[${tag}] artifact missing frontmatter field: ${pc.field}`);
|
|
296
|
+
}
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case 'artifact-has-section': {
|
|
300
|
+
if (!pc.section) {
|
|
301
|
+
errors.push(`[${tag}] postcondition missing 'section' for artifact-has-section`);
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
if (!hasSection(body, pc.section)) {
|
|
305
|
+
errors.push(`[${tag}] artifact missing required section: "${pc.section}"`);
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case 'artifact-claims-cited': {
|
|
310
|
+
const offenders = claimsHaveCitations(body);
|
|
311
|
+
for (const o of offenders) {
|
|
312
|
+
errors.push(`[${tag}] uncited numeric claim at line ${o.line}: ${o.snippet}`);
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
default:
|
|
317
|
+
errors.push(`[${tag}] unknown postcondition check: ${pc.check}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return errors;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Minimum evidence required: either a PR reference (#N or PR #N) or a file
|
|
324
|
+
// reference of the form `path:<line>` or `file:<line>` where line is a number.
|
|
325
|
+
const CLOSE_REASON_PR_PATTERN = /#\d+/;
|
|
326
|
+
const CLOSE_REASON_FILE_PATTERN = /[\w./\\-]+(?:\.(?:mjs|ts|js|tsx|jsx|py|go|rs|java|md|json|yml|yaml)):[0-9]+/i;
|
|
327
|
+
const CLOSE_REASON_MIN_LENGTH = 20;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Validate a beads close reason for evidence quality.
|
|
331
|
+
*
|
|
332
|
+
* Returns { ok: boolean, message: string|null }. On failure, `message` describes
|
|
333
|
+
* what is missing. The caller decides whether to warn or block based on
|
|
334
|
+
* CONSTRUCT_BEADS_HYGIENE (warn | block). Default behavior is warn.
|
|
335
|
+
*
|
|
336
|
+
* A passing reason must:
|
|
337
|
+
* - Be at least 20 characters.
|
|
338
|
+
* - Contain a PR reference (#N) OR a file:line reference (path.mjs:42).
|
|
339
|
+
*/
|
|
340
|
+
export function validateBeadsCloseReason(reason) {
|
|
341
|
+
if (!reason || typeof reason !== 'string') {
|
|
342
|
+
return { ok: false, message: 'Close reason is empty. Add evidence: PR reference (#N) or file:line reference.' };
|
|
343
|
+
}
|
|
344
|
+
const trimmed = reason.trim();
|
|
345
|
+
if (trimmed.length < CLOSE_REASON_MIN_LENGTH) {
|
|
346
|
+
return { ok: false, message: `Close reason too short (${trimmed.length} chars). Minimum: ${CLOSE_REASON_MIN_LENGTH}. Add evidence.` };
|
|
347
|
+
}
|
|
348
|
+
const hasPr = CLOSE_REASON_PR_PATTERN.test(trimmed);
|
|
349
|
+
const hasFile = CLOSE_REASON_FILE_PATTERN.test(trimmed);
|
|
350
|
+
if (!hasPr && !hasFile) {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
message: `Close reason lacks evidence. Include a PR reference (#N) or a file:line reference (e.g. lib/foo.mjs:42). Got: "${trimmed.slice(0, 80)}"`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return { ok: true, message: null };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Apply the beads close-reason policy: warn or block based on
|
|
361
|
+
* CONSTRUCT_BEADS_HYGIENE env var. Returns exit code (0 = ok, 1 = block).
|
|
362
|
+
*/
|
|
363
|
+
export function applyBeadsHygienePolicy(reason, { stderr = process.stderr } = {}) {
|
|
364
|
+
const { ok, message } = validateBeadsCloseReason(reason);
|
|
365
|
+
if (ok) return 0;
|
|
366
|
+
|
|
367
|
+
const mode = (process.env.CONSTRUCT_BEADS_HYGIENE || 'warn').toLowerCase();
|
|
368
|
+
const prefix = mode === 'block' ? '[beads-hygiene] BLOCK' : '[beads-hygiene] WARN';
|
|
369
|
+
stderr.write(`${prefix}: ${message}\n`);
|
|
370
|
+
return mode === 'block' ? 1 : 0;
|
|
371
|
+
}
|