@geraldmaron/construct 1.0.18 → 1.0.19
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 +6 -4
- package/bin/construct +26 -3
- package/db/schema/003_observation_reconciliation.sql +14 -0
- package/lib/bootstrap/resources.mjs +0 -1
- package/lib/cli-commands.mjs +33 -5
- package/lib/comment-lint.mjs +44 -0
- package/lib/contracts/validate.mjs +106 -0
- package/lib/decisions/enforced-baseline.json +23 -0
- package/lib/decisions/golden.mjs +87 -0
- package/lib/decisions/precedence.mjs +46 -0
- package/lib/decisions/registry.mjs +469 -0
- package/lib/deployment/parity-contract.mjs +148 -0
- package/lib/embed/cli.mjs +11 -0
- package/lib/embed/conflict-detection.mjs +4 -4
- package/lib/embed/customer-profiles.mjs +1 -1
- package/lib/embed/reconcile.mjs +60 -0
- package/lib/gates-audit.mjs +2 -2
- package/lib/hooks/config-protection.mjs +22 -3
- package/lib/hooks/guard-bash.mjs +1 -1
- package/lib/init-docs.mjs +1 -0
- package/lib/mode-commands.mjs +6 -8
- package/lib/observation-store.mjs +16 -2
- package/lib/opencode-telemetry.mjs +1 -1
- package/lib/roles/cli.mjs +10 -2
- package/lib/roles/gateway.mjs +50 -1
- package/lib/scheduler/index.mjs +31 -0
- package/lib/server/index.mjs +13 -3
- package/lib/server/static/index.html +1 -1
- package/lib/setup.mjs +6 -0
- package/lib/storage/hybrid-query.mjs +49 -38
- package/lib/storage/rrf.mjs +42 -0
- package/lib/storage/vector-client.mjs +18 -3
- package/lib/telemetry/backends/local.mjs +1 -1
- package/lib/telemetry/skill-calls.mjs +1 -1
- package/lib/templates/visual-requirements.mjs +84 -0
- package/package.json +9 -1
- package/rules/common/comments.md +3 -0
- package/rules/common/no-fabrication.md +3 -0
- package/rules/common/precedence.md +19 -0
- package/rules/common/research-sources.md +32 -0
- package/rules/common/research.md +59 -2
- package/skills/roles/data-engineer.pipeline.md +13 -1
- package/skills/roles/debugger.md +9 -0
- package/skills/roles/designer.accessibility.md +13 -3
- package/skills/roles/designer.md +10 -0
- package/skills/roles/engineer.platform.md +8 -0
- package/skills/roles/operator.md +10 -1
- package/skills/roles/operator.release.md +8 -0
- package/skills/roles/operator.sre.md +10 -1
- package/skills/roles/orchestrator.md +9 -2
- package/skills/roles/product-manager.business-strategy.md +10 -1
- package/skills/roles/researcher.explorer.md +12 -1
- package/skills/roles/researcher.ux.md +13 -1
- package/skills/roles/reviewer.devil-advocate.md +14 -2
- package/skills/roles/reviewer.evaluator.md +17 -4
- package/skills/roles/reviewer.trace.md +12 -1
- package/skills/roles/security.legal-compliance.md +8 -0
- package/skills/roles/security.md +11 -0
- package/specialists/contracts.json +18 -0
- package/specialists/prompts/cx-researcher.md +4 -2
- package/templates/docs/backlog-proposal.md +1 -1
- package/templates/docs/customer-profile.md +1 -1
- package/templates/docs/evidence-brief.md +5 -1
- package/templates/docs/incident-report.md +37 -21
- package/templates/docs/prfaq.md +2 -2
- package/templates/docs/product-intelligence-report.md +1 -1
- package/templates/docs/research-brief.md +8 -6
- package/templates/docs/research-finding.md +32 -7
- package/templates/docs/rfc.md +13 -1
- package/templates/docs/runbook.md +20 -1
- package/templates/docs/signal-brief.md +4 -1
- package/templates/docs/skill-artifact.md +27 -7
- package/templates/docs/strategy.md +23 -2
- package/lib/bootstrap/lazy-install.mjs +0 -161
- package/lib/embed/jobs/vector-sync.mjs +0 -198
- package/lib/knowledge/postgres-search.mjs +0 -132
- package/lib/services/pattern-promotion-service.mjs +0 -167
- package/lib/storage/unified-storage.mjs +0 -550
package/README.md
CHANGED
|
@@ -177,16 +177,16 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
|
|
|
177
177
|
| Command | What it does |
|
|
178
178
|
|---|---|
|
|
179
179
|
| `construct efficiency` | Show read efficiency, repeated files, and context-budget guidance |
|
|
180
|
-
| `construct eval-datasets` | Sync scored
|
|
180
|
+
| `construct eval-datasets` | Sync scored traces from the telemetry backend into eval datasets for prompt regression testing |
|
|
181
181
|
| `construct evals` | Show evaluator catalog for prompt and agent experiments |
|
|
182
182
|
| `construct feedback:history` | Show recorded outcome ratings |
|
|
183
183
|
| `construct feedback:record` | Record an outcome rating for a recent specialist invocation |
|
|
184
184
|
| `construct llm-judge` | Run LLM-as-a-judge evaluations on unscored traces for continuous quality feedback |
|
|
185
|
-
| `construct optimize` | Prompt optimization using
|
|
186
|
-
| `construct review` | Generate agent performance review from
|
|
185
|
+
| `construct optimize` | Prompt optimization using telemetry trace quality scores |
|
|
186
|
+
| `construct review` | Generate agent performance review from the configured telemetry trace backend |
|
|
187
187
|
| `construct telemetry` | Query telemetry traces and latency data |
|
|
188
188
|
| `construct telemetry-backfill` | Backfill sparse traces with observations (trace backend) |
|
|
189
|
-
| `construct telemetry-setup` | Configure
|
|
189
|
+
| `construct telemetry-setup` | Configure telemetry backend credentials and trace export (OTLP or Langfuse-compatible) |
|
|
190
190
|
|
|
191
191
|
### Diagnostics
|
|
192
192
|
|
|
@@ -212,6 +212,8 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
|
|
|
212
212
|
| `construct ci` | Local CI mirror: run CI jobs locally or view recent run status |
|
|
213
213
|
| `construct completions` | Shell completion scripts |
|
|
214
214
|
| `construct config` | Deployment mode configuration |
|
|
215
|
+
| `construct decisions` | Index load-bearing decisions and their enforcement bindings |
|
|
216
|
+
| `construct deployment` | Deployment posture tools (capability parity contract) |
|
|
215
217
|
| `construct diff` | Show agent changes since HEAD |
|
|
216
218
|
| `construct embed` | Embed mode management |
|
|
217
219
|
| `construct gates:audit` | Audit policy gates |
|
package/bin/construct
CHANGED
|
@@ -1441,9 +1441,19 @@ async function cmdSearch(args) {
|
|
|
1441
1441
|
async function cmdStorage(args) {
|
|
1442
1442
|
const [action, ...rest] = args;
|
|
1443
1443
|
if (!action) {
|
|
1444
|
-
errorln('Usage: construct storage <sync|status|reset|delete-ingested|repair-migrations|migrations>');
|
|
1444
|
+
errorln('Usage: construct storage <sync|status|reset|reconcile|delete-ingested|repair-migrations|migrations>');
|
|
1445
1445
|
process.exit(1);
|
|
1446
1446
|
}
|
|
1447
|
+
if (action === 'reconcile') {
|
|
1448
|
+
const { reconcileObservationEmbeddings } = await import('../lib/embed/reconcile.mjs');
|
|
1449
|
+
const result = await reconcileObservationEmbeddings(process.cwd());
|
|
1450
|
+
if (result.skipped) {
|
|
1451
|
+
println(`Reconcile skipped: ${result.skipped} (no Postgres backend — local index is authoritative).`);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
println(`Reconciled observations: re-embedded ${result.reembedded} of ${result.checked} (model ${result.model}).`);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1447
1457
|
if (action === 'migrations') {
|
|
1448
1458
|
const { describeMigrations } = await import('../lib/storage/migrations.mjs');
|
|
1449
1459
|
const sqlClient = createSqlClient(process.env);
|
|
@@ -4097,7 +4107,7 @@ async function cmdCiPreview(args) {
|
|
|
4097
4107
|
const jobsSection = jobsStart >= 0 ? yml.slice(jobsStart + 1) : '';
|
|
4098
4108
|
|
|
4099
4109
|
// Find all top-level job keys (2-space indent) and slice blocks between them.
|
|
4100
|
-
const jobKeyPattern = /^
|
|
4110
|
+
const jobKeyPattern = /^ {2}(\w[\w-]*):\s*$/gm;
|
|
4101
4111
|
const jobPositions = [];
|
|
4102
4112
|
let km;
|
|
4103
4113
|
while ((km = jobKeyPattern.exec(jobsSection)) !== null) {
|
|
@@ -4111,7 +4121,7 @@ async function cmdCiPreview(args) {
|
|
|
4111
4121
|
const end = jobPositions[i + 1]?.start ?? jobsSection.length;
|
|
4112
4122
|
const block = jobsSection.slice(start, end);
|
|
4113
4123
|
|
|
4114
|
-
const nameMatch = block.match(/^
|
|
4124
|
+
const nameMatch = block.match(/^ {4}name:\s*(.+)\s*$/m);
|
|
4115
4125
|
const displayName = nameMatch
|
|
4116
4126
|
? nameMatch[1].replace(/\$\{\{[^}]+\}\}/g, '<matrix>').trim()
|
|
4117
4127
|
: id;
|
|
@@ -5173,6 +5183,19 @@ const handlers = new Map([
|
|
|
5173
5183
|
['lint:research', cmdLintResearch],
|
|
5174
5184
|
['lint:agents', cmdLintAgents],
|
|
5175
5185
|
['lint:contracts', cmdLintContracts],
|
|
5186
|
+
['decisions', async (args) => {
|
|
5187
|
+
const { runDecisionsCli } = await import('../lib/decisions/registry.mjs');
|
|
5188
|
+
return runDecisionsCli(args);
|
|
5189
|
+
}],
|
|
5190
|
+
['deployment', async (args) => {
|
|
5191
|
+
const sub = args[0];
|
|
5192
|
+
if (sub === 'parity') {
|
|
5193
|
+
const { runDeploymentParityCli } = await import('../lib/deployment/parity-contract.mjs');
|
|
5194
|
+
return runDeploymentParityCli(args.slice(1));
|
|
5195
|
+
}
|
|
5196
|
+
errorln(`Unknown deployment subcommand: ${sub || '(none)'}. Available: parity`);
|
|
5197
|
+
process.exit(1);
|
|
5198
|
+
}],
|
|
5176
5199
|
['audit', async (args) => {
|
|
5177
5200
|
const sub = args[0];
|
|
5178
5201
|
if (sub === 'skills') return runAuditSkillsCli(args.slice(1));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- db/schema/003_observation_reconciliation.sql
|
|
2
|
+
-- Stamp each observation with the content hash and embedding model that
|
|
3
|
+
-- produced its vector. An idempotent reconciliation pass uses (content_hash,
|
|
4
|
+
-- model) to find rows whose embedding is missing or stale — content edited, or
|
|
5
|
+
-- the embedding model changed — and re-embed only those, rather than relying on
|
|
6
|
+
-- the inline write at creation time always succeeding. Mirrors the
|
|
7
|
+
-- (content_hash, model) tracking construct_embeddings already carries for
|
|
8
|
+
-- documents. Fully idempotent: safe to re-apply.
|
|
9
|
+
|
|
10
|
+
ALTER TABLE construct_observations ADD COLUMN IF NOT EXISTS content_hash text;
|
|
11
|
+
ALTER TABLE construct_observations ADD COLUMN IF NOT EXISTS model text;
|
|
12
|
+
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_observations_content_hash ON construct_observations (content_hash);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_observations_model ON construct_observations (model);
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
* The registry is consulted by:
|
|
18
18
|
* - `construct init` walks every resource, asks consent, installs
|
|
19
19
|
* - `construct doctor --bootstrap` re-probes every resource verbosely
|
|
20
|
-
* - lazy-install paths in hooks (consult the cached consent silently)
|
|
21
20
|
*
|
|
22
21
|
* Resources are "additive" — anyone can register a new resource via
|
|
23
22
|
* `registerResource()` so plugins (or future built-ins) can extend the set
|
package/lib/cli-commands.mjs
CHANGED
|
@@ -373,7 +373,7 @@ export const CLI_COMMANDS = [
|
|
|
373
373
|
emoji: '📈',
|
|
374
374
|
category: 'Observability',
|
|
375
375
|
core: false,
|
|
376
|
-
description: 'Generate agent performance review from
|
|
376
|
+
description: 'Generate agent performance review from the configured telemetry trace backend',
|
|
377
377
|
usage: 'construct review [--agent=<id>] [--days=N]',
|
|
378
378
|
},
|
|
379
379
|
{
|
|
@@ -381,7 +381,7 @@ export const CLI_COMMANDS = [
|
|
|
381
381
|
emoji: '⚙️',
|
|
382
382
|
category: 'Observability',
|
|
383
383
|
core: false,
|
|
384
|
-
description: 'Prompt optimization using
|
|
384
|
+
description: 'Prompt optimization using telemetry trace quality scores',
|
|
385
385
|
usage: 'construct optimize <agent>',
|
|
386
386
|
},
|
|
387
387
|
{
|
|
@@ -397,7 +397,7 @@ export const CLI_COMMANDS = [
|
|
|
397
397
|
emoji: '📊',
|
|
398
398
|
category: 'Observability',
|
|
399
399
|
core: false,
|
|
400
|
-
description: 'Sync scored
|
|
400
|
+
description: 'Sync scored traces from the telemetry backend into eval datasets for prompt regression testing',
|
|
401
401
|
usage: 'construct eval-datasets',
|
|
402
402
|
},
|
|
403
403
|
{
|
|
@@ -580,7 +580,7 @@ export const CLI_COMMANDS = [
|
|
|
580
580
|
category: 'Advanced',
|
|
581
581
|
core: false,
|
|
582
582
|
description: 'Role framework management',
|
|
583
|
-
usage: 'construct role <list|
|
|
583
|
+
usage: 'construct role <list|latest|show|status|resolve|prune|reset>',
|
|
584
584
|
},
|
|
585
585
|
{
|
|
586
586
|
name: 'embed',
|
|
@@ -646,6 +646,34 @@ export const CLI_COMMANDS = [
|
|
|
646
646
|
description: 'Audit policy gates',
|
|
647
647
|
usage: 'construct gates:audit',
|
|
648
648
|
},
|
|
649
|
+
{
|
|
650
|
+
name: 'decisions',
|
|
651
|
+
emoji: '🧭',
|
|
652
|
+
category: 'Advanced',
|
|
653
|
+
core: false,
|
|
654
|
+
description: 'Index load-bearing decisions and their enforcement bindings',
|
|
655
|
+
usage: 'construct decisions [list|validate|json|check|baseline|golden]',
|
|
656
|
+
options: [
|
|
657
|
+
{ flag: 'list', desc: 'Show decisions with status and enforcement (default)' },
|
|
658
|
+
{ flag: 'validate', desc: 'Validate registry structure; exit 1 on error' },
|
|
659
|
+
{ flag: 'check', desc: 'Fail on dangling markers, enforcement/supersede/linkage/precedence drift' },
|
|
660
|
+
{ flag: 'baseline', desc: 'Print the enforced baseline; --write to regenerate it' },
|
|
661
|
+
{ flag: 'golden', desc: 'Check the CLI/agent/hook surface snapshot; --write to regenerate it' },
|
|
662
|
+
{ flag: 'json', desc: 'Emit the full registry as JSON' },
|
|
663
|
+
],
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
name: 'deployment',
|
|
667
|
+
emoji: '🛰️',
|
|
668
|
+
category: 'Advanced',
|
|
669
|
+
core: false,
|
|
670
|
+
description: 'Deployment posture tools (capability parity contract)',
|
|
671
|
+
usage: 'construct deployment parity',
|
|
672
|
+
options: [
|
|
673
|
+
{ flag: 'parity', desc: 'Show and validate capability parity across solo/team/enterprise' },
|
|
674
|
+
{ flag: '--json', desc: 'Emit the parity contract as JSON' },
|
|
675
|
+
],
|
|
676
|
+
},
|
|
649
677
|
{
|
|
650
678
|
name: 'policy',
|
|
651
679
|
emoji: '🔒',
|
|
@@ -838,7 +866,7 @@ export const CLI_COMMANDS = [
|
|
|
838
866
|
emoji: '🩹',
|
|
839
867
|
category: 'Observability',
|
|
840
868
|
core: false,
|
|
841
|
-
description: 'Configure
|
|
869
|
+
description: 'Configure telemetry backend credentials and trace export (OTLP or Langfuse-compatible)',
|
|
842
870
|
usage: 'construct telemetry-setup',
|
|
843
871
|
},
|
|
844
872
|
// ── Internal — callable but not advertised in help or completions ────
|
package/lib/comment-lint.mjs
CHANGED
|
@@ -223,6 +223,49 @@ function hasCitationNearby(lines, idx) {
|
|
|
223
223
|
return false;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// A citation marker proves nothing if it resolves to nothing. Two marker kinds
|
|
227
|
+
// are mechanically verifiable offline: a `[^n]` footnote must have a matching
|
|
228
|
+
// `[^n]:` definition, and a `[source: <repo-path>]` must point at a file that
|
|
229
|
+
// exists. URLs and free-text sources are left alone — unverifiable here, and
|
|
230
|
+
// flagging them would punish honest prose. Skip code fences and tables so regex
|
|
231
|
+
// examples and tabular data never read as dangling citations.
|
|
232
|
+
|
|
233
|
+
function looksLikeRepoPath(value) {
|
|
234
|
+
return /^[\w./@-]+$/.test(value) && value.includes('/') && !/^https?:/i.test(value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function findDanglingCitations(content, { rootDir = process.cwd() } = {}) {
|
|
238
|
+
const lines = content.split('\n');
|
|
239
|
+
const skip = buildSkipMap(lines);
|
|
240
|
+
const defs = new Set();
|
|
241
|
+
lines.forEach((line, i) => {
|
|
242
|
+
if (skip[i]) return;
|
|
243
|
+
const d = line.match(/^\s*\[\^([^\]]+)\]:/);
|
|
244
|
+
if (d) defs.add(d[1]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const violations = [];
|
|
248
|
+
lines.forEach((line, i) => {
|
|
249
|
+
if (skip[i] || line.includes('construct-lint-ignore')) return;
|
|
250
|
+
const scan = line.replace(/`[^`]*`/g, '');
|
|
251
|
+
let m;
|
|
252
|
+
const refRe = /\[\^([^\]]+)\](?!:)/g;
|
|
253
|
+
while ((m = refRe.exec(scan)) !== null) {
|
|
254
|
+
if (!defs.has(m[1])) {
|
|
255
|
+
violations.push({ line: i + 1, label: `dangling footnote citation [^${m[1]}] has no definition`, kind: 'artifact' });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const srcRe = /\[source:\s*([^\]]+)\]/gi;
|
|
259
|
+
while ((m = srcRe.exec(scan)) !== null) {
|
|
260
|
+
const value = m[1].trim();
|
|
261
|
+
if (looksLikeRepoPath(value) && !fs.existsSync(path.resolve(rootDir, value))) {
|
|
262
|
+
violations.push({ line: i + 1, label: `citation source path not found: ${value}`, kind: 'artifact' });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
return violations;
|
|
267
|
+
}
|
|
268
|
+
|
|
226
269
|
function checkArtifactBanned(content, rel) {
|
|
227
270
|
if (!isArtifactPath(rel)) return [];
|
|
228
271
|
const lines = content.split('\n');
|
|
@@ -316,6 +359,7 @@ export function lintFile(filePath, { rootDir = process.cwd(), fix = false } = {}
|
|
|
316
359
|
// kind:'artifact' so the hook can let the edit through while still surfacing
|
|
317
360
|
// the finding.
|
|
318
361
|
const artifactHits = checkArtifactBanned(content, rel);
|
|
362
|
+
if (isArtifactPath(rel)) artifactHits.push(...findDanglingCitations(content, { rootDir }));
|
|
319
363
|
if (artifactHits.length > 0) {
|
|
320
364
|
const mode = process.env.CONSTRUCT_ARTIFACT_LINT_MODE || 'warn';
|
|
321
365
|
const bucket = mode === 'block' ? errors : warnings;
|
|
@@ -300,6 +300,77 @@ function hasSection(body, sectionTitle) {
|
|
|
300
300
|
return false;
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
// A fenced ```mermaid block, optionally of a required diagram kind (the kind is
|
|
304
|
+
// matched against the first keyword inside the fence, e.g. flowchart, sequenceDiagram).
|
|
305
|
+
|
|
306
|
+
function hasMermaid(body, kind) {
|
|
307
|
+
const re = /```mermaid\s*\n([\s\S]*?)```/g;
|
|
308
|
+
let m;
|
|
309
|
+
while ((m = re.exec(body))) {
|
|
310
|
+
if (!kind) return true;
|
|
311
|
+
if (new RegExp(`\\b${kind}\\b`, 'i').test(m[1])) return true;
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function hasTable(body) {
|
|
317
|
+
const lines = body.split('\n');
|
|
318
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
319
|
+
if (/\|/.test(lines[i]) && /^\s*\|?[\s:-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Header rows of every GFM table in the body, each as a lowercased cell list.
|
|
327
|
+
// A table is a `| ... |` line immediately followed by a `|---|` separator row.
|
|
328
|
+
|
|
329
|
+
function tableHeaderRows(body) {
|
|
330
|
+
const lines = body.split('\n');
|
|
331
|
+
const headers = [];
|
|
332
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
333
|
+
if (/\|/.test(lines[i]) && /^\s*\|?[\s:-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
334
|
+
const cells = lines[i].split('|').map((c) => c.trim().toLowerCase()).filter(Boolean);
|
|
335
|
+
headers.push({ line: i + 1, cells, rowsBelow: countRows(lines, i + 2) });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return headers;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function countRows(lines, start) {
|
|
342
|
+
let n = 0;
|
|
343
|
+
for (let i = start; i < lines.length; i++) {
|
|
344
|
+
if (/^\s*\|/.test(lines[i]) || (/\|/.test(lines[i]) && lines[i].trim())) n += 1;
|
|
345
|
+
else break;
|
|
346
|
+
}
|
|
347
|
+
return n;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function tableWithColumns(body, columns) {
|
|
351
|
+
const want = columns.map((c) => c.trim().toLowerCase());
|
|
352
|
+
for (const header of tableHeaderRows(body)) {
|
|
353
|
+
if (want.every((w) => header.cells.includes(w))) return header;
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function sectionIsNonEmpty(body, sectionTitle) {
|
|
359
|
+
const target = sectionTitle.trim().toLowerCase();
|
|
360
|
+
const lines = body.split('\n');
|
|
361
|
+
let inSection = false;
|
|
362
|
+
for (const line of lines) {
|
|
363
|
+
const heading = line.match(/^#{1,6}\s+(.+)$/);
|
|
364
|
+
if (heading) {
|
|
365
|
+
if (inSection) return false;
|
|
366
|
+
inSection = heading[1].trim().toLowerCase() === target;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (inSection && line.trim() && !line.trim().startsWith('<!--')) return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
303
374
|
function claimsHaveCitations(body) {
|
|
304
375
|
const lines = body.split('\n');
|
|
305
376
|
const offenders = [];
|
|
@@ -367,6 +438,41 @@ export function validateArtifactPostconditions({ contract, artifactPath }) {
|
|
|
367
438
|
}
|
|
368
439
|
break;
|
|
369
440
|
}
|
|
441
|
+
case 'artifact-has-mermaid': {
|
|
442
|
+
if (!hasMermaid(body, pc.diagram)) {
|
|
443
|
+
errors.push(`[${tag}] artifact missing required mermaid diagram${pc.diagram ? ` (${pc.diagram})` : ''}`);
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
case 'artifact-has-table': {
|
|
448
|
+
if (!hasTable(body)) {
|
|
449
|
+
errors.push(`[${tag}] artifact missing a required table`);
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
case 'artifact-table-has-columns': {
|
|
454
|
+
if (!Array.isArray(pc.columns) || pc.columns.length === 0) {
|
|
455
|
+
errors.push(`[${tag}] postcondition missing 'columns' for artifact-table-has-columns`);
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
const match = tableWithColumns(body, pc.columns);
|
|
459
|
+
if (!match) {
|
|
460
|
+
errors.push(`[${tag}] no table has the required columns: ${pc.columns.join(', ')}`);
|
|
461
|
+
} else if (match.rowsBelow === 0) {
|
|
462
|
+
errors.push(`[${tag}] required table (${pc.columns.join(', ')}) has no data rows`);
|
|
463
|
+
}
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case 'artifact-section-nonempty': {
|
|
467
|
+
if (!pc.section) {
|
|
468
|
+
errors.push(`[${tag}] postcondition missing 'section' for artifact-section-nonempty`);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
if (!sectionIsNonEmpty(body, pc.section)) {
|
|
472
|
+
errors.push(`[${tag}] required section is empty or missing: "${pc.section}"`);
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
370
476
|
default:
|
|
371
477
|
errors.push(`[${tag}] unknown postcondition check: ${pc.check}`);
|
|
372
478
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enforced": [
|
|
3
|
+
"ADR-0015",
|
|
4
|
+
"ADR-0016",
|
|
5
|
+
"ADR-0017",
|
|
6
|
+
"ADR-0018",
|
|
7
|
+
"any-to-business-strategist",
|
|
8
|
+
"any-to-explorer",
|
|
9
|
+
"architect-to-data-engineer",
|
|
10
|
+
"architect-to-evaluator",
|
|
11
|
+
"architect-to-legal-compliance",
|
|
12
|
+
"construct-to-rd-lead",
|
|
13
|
+
"product-manager-to-architect",
|
|
14
|
+
"product-manager-to-data-analyst",
|
|
15
|
+
"product-manager-to-ux-researcher",
|
|
16
|
+
"qa-to-test-automation",
|
|
17
|
+
"researcher-to-product-manager",
|
|
18
|
+
"rule:common/comments",
|
|
19
|
+
"rule:common/no-fabrication",
|
|
20
|
+
"rule:common/precedence",
|
|
21
|
+
"rule:common/research-sources"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/decisions/golden.mjs — behavioral golden snapshot of core surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Pins the surfaces whose silent change is a drift hazard: the CLI command set,
|
|
5
|
+
* the specialist roster, and hook execution order. buildSurfaceSnapshot computes
|
|
6
|
+
* the live surface from source; a committed snapshot (tests/fixtures/golden/
|
|
7
|
+
* surface.json) is the expectation. A mismatch fails the suite until the snapshot
|
|
8
|
+
* is regenerated on purpose (`construct decisions golden --write`) — the same
|
|
9
|
+
* conscious-update discipline as the enforced baseline (bead wvbf.5).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, dirname, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
17
|
+
|
|
18
|
+
const HOOK_EVENTS = ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop'];
|
|
19
|
+
|
|
20
|
+
function hookName(command) {
|
|
21
|
+
const m = command.match(/hooks\/([\w-]+)\.mjs/);
|
|
22
|
+
if (m) return m[1];
|
|
23
|
+
if (/node\s+-e/.test(command)) return 'inline';
|
|
24
|
+
return 'cmd';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
28
|
+
const snapshot = { commands: [], agents: [], hooks: {} };
|
|
29
|
+
|
|
30
|
+
const cli = readFileSync(join(repoRoot, 'lib', 'cli-commands.mjs'), 'utf8');
|
|
31
|
+
const nameRe = /name:\s*'([^']+)'[\s\S]*?core:\s*(true|false)/g;
|
|
32
|
+
let m;
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
while ((m = nameRe.exec(cli)) !== null) {
|
|
35
|
+
if (seen.has(m[1])) continue;
|
|
36
|
+
seen.add(m[1]);
|
|
37
|
+
snapshot.commands.push({ name: m[1], core: m[2] === 'true' });
|
|
38
|
+
}
|
|
39
|
+
snapshot.commands.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
40
|
+
|
|
41
|
+
const registry = JSON.parse(readFileSync(join(repoRoot, 'specialists', 'registry.json'), 'utf8'));
|
|
42
|
+
snapshot.agents = (registry.specialists || []).map((s) => s.name).sort();
|
|
43
|
+
|
|
44
|
+
const settings = JSON.parse(readFileSync(join(repoRoot, 'platforms', 'claude', 'settings.template.json'), 'utf8'));
|
|
45
|
+
for (const event of HOOK_EVENTS) {
|
|
46
|
+
const entries = settings.hooks?.[event] || [];
|
|
47
|
+
const ordered = [];
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
for (const h of entry.hooks || []) ordered.push(hookName(h.command));
|
|
50
|
+
}
|
|
51
|
+
snapshot.hooks[event] = ordered;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return snapshot;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function snapshotPath(repoRoot) {
|
|
58
|
+
return join(repoRoot, 'tests', 'fixtures', 'golden', 'surface.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function compareSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
62
|
+
const file = snapshotPath(repoRoot);
|
|
63
|
+
if (!existsSync(file)) return { ok: false, diffs: ['no committed snapshot — run: construct decisions golden --write'] };
|
|
64
|
+
const expected = JSON.stringify(JSON.parse(readFileSync(file, 'utf8')));
|
|
65
|
+
const actual = JSON.stringify(buildSurfaceSnapshot({ repoRoot }));
|
|
66
|
+
if (expected === actual) return { ok: true, diffs: [] };
|
|
67
|
+
return { ok: false, diffs: ['surface snapshot drift — review the change, then regenerate with: construct decisions golden --write'] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
71
|
+
writeFileSync(snapshotPath(repoRoot), JSON.stringify(buildSurfaceSnapshot({ repoRoot }), null, 2) + '\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runGoldenCli(args = []) {
|
|
75
|
+
if (args.includes('--write')) {
|
|
76
|
+
writeSurfaceSnapshot();
|
|
77
|
+
process.stdout.write('✓ wrote surface golden snapshot\n');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const { ok, diffs } = compareSurfaceSnapshot();
|
|
81
|
+
if (ok) {
|
|
82
|
+
process.stdout.write('✓ surface matches the golden snapshot\n');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const d of diffs) process.stderr.write(`✗ ${d}\n`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/decisions/precedence.mjs — explicit precedence for conflicting guidance.
|
|
3
|
+
*
|
|
4
|
+
* When two rules give contradictory direction, the conflict must resolve the same
|
|
5
|
+
* way every time, not by whichever the model read last. PRECEDENCE_TIERS is the
|
|
6
|
+
* canonical order (index 0 wins); a rule declares its tier in frontmatter
|
|
7
|
+
* (`precedence_tier`), and resolvePrecedence answers which of two tiers governs.
|
|
8
|
+
*
|
|
9
|
+
* Scope is deliberate: this provides deterministic TIER-based precedence, not
|
|
10
|
+
* natural-language contradiction detection. It makes the resolution order
|
|
11
|
+
* explicit and validates that declared tiers are real; judging whether two rules
|
|
12
|
+
* actually contradict remains human work. See rules/common/precedence.md.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const PRECEDENCE_TIERS = ['safety', 'security', 'correctness', 'durability', 'performance', 'style'];
|
|
16
|
+
|
|
17
|
+
export function tierRank(tier) {
|
|
18
|
+
const i = PRECEDENCE_TIERS.indexOf(tier);
|
|
19
|
+
return i === -1 ? Number.POSITIVE_INFINITY : i;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve which tier governs. Returns -1 if a wins, 1 if b wins, 0 if equal rank.
|
|
24
|
+
* A lower index is higher priority (safety beats style).
|
|
25
|
+
*/
|
|
26
|
+
export function resolvePrecedence(a, b) {
|
|
27
|
+
const ra = tierRank(a);
|
|
28
|
+
const rb = tierRank(b);
|
|
29
|
+
if (ra < rb) return -1;
|
|
30
|
+
if (ra > rb) return 1;
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate declared precedence tiers over a decisions array (bead wvbf.8): any
|
|
36
|
+
* rule that declares a `precedence_tier` must use one from PRECEDENCE_TIERS.
|
|
37
|
+
*/
|
|
38
|
+
export function validatePrecedenceTiersOn(decisions) {
|
|
39
|
+
const violations = [];
|
|
40
|
+
for (const d of decisions) {
|
|
41
|
+
if (d.precedenceTier && !PRECEDENCE_TIERS.includes(d.precedenceTier)) {
|
|
42
|
+
violations.push(`${d.id}: unknown precedence_tier "${d.precedenceTier}" (expected one of ${PRECEDENCE_TIERS.join(', ')})`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { ok: violations.length === 0, violations };
|
|
46
|
+
}
|