@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/session-store.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import path from "node:path";
|
|
|
19
19
|
import { cosineSimilarity, rankByBm25 } from "./storage/embeddings.mjs";
|
|
20
20
|
import { embedSync as embedText } from "./storage/embeddings-legacy.mjs";
|
|
21
21
|
import { withFileLockSync } from "./storage/file-lock.mjs";
|
|
22
|
+
import { ensureCxDir } from "./project-init-shared.mjs";
|
|
22
23
|
|
|
23
24
|
const SESSIONS_DIR = ".cx/sessions";
|
|
24
25
|
const INDEX_FILE = "index.json";
|
|
@@ -29,7 +30,8 @@ const MAX_DECISIONS = 20;
|
|
|
29
30
|
const MAX_FILES = 50;
|
|
30
31
|
const MAX_OPEN_QUESTIONS = 10;
|
|
31
32
|
|
|
32
|
-
function ensureDir(dir) {
|
|
33
|
+
function ensureDir(dir, rootDir = null) {
|
|
34
|
+
if (rootDir) ensureCxDir(rootDir);
|
|
33
35
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -52,7 +54,7 @@ function readVectors(rootDir) {
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
function writeVectors(rootDir, records) {
|
|
55
|
-
ensureDir(sessionsDir(rootDir));
|
|
57
|
+
ensureDir(sessionsDir(rootDir), rootDir);
|
|
56
58
|
const target = vectorsPath(rootDir);
|
|
57
59
|
withFileLockSync(target, () => {
|
|
58
60
|
fs.writeFileSync(target, JSON.stringify(records.slice(0, MAX_INDEX_ENTRIES), null, 2) + "\n");
|
|
@@ -86,7 +88,7 @@ function readIndex(rootDir) {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
function writeIndex(rootDir, entries) {
|
|
89
|
-
ensureDir(sessionsDir(rootDir));
|
|
91
|
+
ensureDir(sessionsDir(rootDir), rootDir);
|
|
90
92
|
const trimmed = entries.slice(0, MAX_INDEX_ENTRIES);
|
|
91
93
|
const target = indexPath(rootDir);
|
|
92
94
|
withFileLockSync(target, () => {
|
|
@@ -137,7 +139,7 @@ export function createSession(rootDir, {
|
|
|
137
139
|
taskSnapshot: [],
|
|
138
140
|
};
|
|
139
141
|
|
|
140
|
-
ensureDir(sessionsDir(rootDir));
|
|
142
|
+
ensureDir(sessionsDir(rootDir), rootDir);
|
|
141
143
|
fs.writeFileSync(
|
|
142
144
|
path.join(sessionsDir(rootDir), `${id}.json`),
|
|
143
145
|
JSON.stringify(record, null, 2) + "\n"
|
package/lib/setup.mjs
CHANGED
|
@@ -420,7 +420,7 @@ function ensureOpenCodeConfig() {
|
|
|
420
420
|
// repo but never executed by git. Idempotent: leaves a user-set value alone
|
|
421
421
|
// rather than clobbering it.
|
|
422
422
|
|
|
423
|
-
function ensureGitHooksPath({ cwd = process.cwd() } = {}) {
|
|
423
|
+
export function ensureGitHooksPath({ cwd = process.cwd() } = {}) {
|
|
424
424
|
const hooksDir = path.join(cwd, '.beads', 'hooks');
|
|
425
425
|
if (!fs.existsSync(path.join(hooksDir, 'pre-commit'))) {
|
|
426
426
|
return { status: 'skipped', reason: 'no .beads/hooks/pre-commit in this directory' };
|
|
@@ -435,10 +435,21 @@ function ensureGitHooksPath({ cwd = process.cwd() } = {}) {
|
|
|
435
435
|
if (currentValue === desired) {
|
|
436
436
|
return { status: 'ok', message: 'core.hooksPath already wired to .beads/hooks' };
|
|
437
437
|
}
|
|
438
|
-
|
|
438
|
+
|
|
439
|
+
// Treat the git default (`.git/hooks` or an absolute path to it) as
|
|
440
|
+
// equivalent to unset. A user who has accepted the default has not made an
|
|
441
|
+
// active choice that justifies "leave alone" semantics; without this branch
|
|
442
|
+
// an install that happens to land while `.git/hooks` is already in play
|
|
443
|
+
// leaves policy gates inactive permanently.
|
|
444
|
+
const isGitDefault =
|
|
445
|
+
!currentValue ||
|
|
446
|
+
currentValue === '.git/hooks' ||
|
|
447
|
+
currentValue === path.join(cwd, '.git', 'hooks') ||
|
|
448
|
+
currentValue.replace(/\/+$/, '').endsWith(`${path.sep}.git${path.sep}hooks`);
|
|
449
|
+
if (!isGitDefault) {
|
|
439
450
|
return {
|
|
440
451
|
status: 'warning',
|
|
441
|
-
message: `core.hooksPath is set to '${currentValue}'. Leaving alone
|
|
452
|
+
message: `core.hooksPath is set to '${currentValue}'. Leaving alone. Set to '.beads/hooks' to activate Construct policy gates.`,
|
|
442
453
|
};
|
|
443
454
|
}
|
|
444
455
|
const result = spawnSync('git', ['config', 'core.hooksPath', desired], { cwd, stdio: 'pipe', encoding: 'utf8' });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/
|
|
2
|
+
* lib/specialist-contracts-enforce.mjs — runtime contract enforcement.
|
|
3
3
|
*
|
|
4
|
-
* Wraps `validatePacket` from `lib/
|
|
4
|
+
* Wraps `validatePacket` from `lib/specialist-contracts.mjs` with three production
|
|
5
5
|
* concerns:
|
|
6
6
|
*
|
|
7
7
|
* 1. Block on violation. `enforcePacket` throws a `ContractViolationError`
|
|
@@ -27,8 +27,8 @@ import { existsSync, mkdirSync, statSync, readFileSync, appendFileSync } from 'n
|
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
29
|
import { createHash } from 'node:crypto';
|
|
30
|
-
import { getContractById, validatePacket } from './
|
|
31
|
-
import { validatePostconditions } from './
|
|
30
|
+
import { getContractById, validatePacket } from './specialist-contracts.mjs';
|
|
31
|
+
import { validatePostconditions } from './specialists/postconditions.mjs';
|
|
32
32
|
|
|
33
33
|
const CX_DIR = join(homedir(), '.cx');
|
|
34
34
|
const LOG_FILE = join(CX_DIR, 'contract-violations.jsonl');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/
|
|
2
|
+
* lib/specialist-contracts.mjs — loader + query layer for specialists/contracts.json.
|
|
3
3
|
*
|
|
4
4
|
* Agent contracts are explicit service contracts between Construct roles —
|
|
5
5
|
* "when cx-product-manager hands off to cx-architect, the packet MUST contain
|
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
20
20
|
|
|
21
21
|
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
22
22
|
const REPO_ROOT = resolve(MODULE_DIR, '..');
|
|
23
|
-
const CONTRACTS_PATH = join(REPO_ROOT, '
|
|
23
|
+
const CONTRACTS_PATH = join(REPO_ROOT, 'specialists', 'contracts.json');
|
|
24
24
|
|
|
25
25
|
let _cache = null;
|
|
26
26
|
|
|
@@ -157,10 +157,10 @@ export function resolveContractChain(ctx = {}) {
|
|
|
157
157
|
return chain;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
function
|
|
161
|
-
const value = packet?.[key];
|
|
160
|
+
function isPresent(value) {
|
|
162
161
|
if (value === undefined || value === null) return false;
|
|
163
162
|
if (typeof value === 'string' && value.trim() === '') return false;
|
|
163
|
+
if (Array.isArray(value) && value.length === 0) return false;
|
|
164
164
|
return true;
|
|
165
165
|
}
|
|
166
166
|
|
|
@@ -187,14 +187,14 @@ export function validatePacket(contractId, packet, direction = 'input') {
|
|
|
187
187
|
|
|
188
188
|
if (Array.isArray(spec.mustContain)) {
|
|
189
189
|
for (const key of spec.mustContain) {
|
|
190
|
-
if (!
|
|
190
|
+
if (!isPresent(packet?.[key])) missing.push(key);
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
if (Array.isArray(spec.mustContainOneOf)) {
|
|
195
195
|
for (const group of spec.mustContainOneOf) {
|
|
196
196
|
if (!Array.isArray(group) || group.length === 0) continue;
|
|
197
|
-
const anyPresent = group.some((key) =>
|
|
197
|
+
const anyPresent = group.some((key) => isPresent(packet?.[key]));
|
|
198
198
|
if (!anyPresent) missing.push(group.join('|'));
|
|
199
199
|
}
|
|
200
200
|
}
|
|
@@ -202,8 +202,9 @@ export function validatePacket(contractId, packet, direction = 'input') {
|
|
|
202
202
|
if (spec.mustMatchEnum && typeof spec.mustMatchEnum === 'object') {
|
|
203
203
|
for (const [key, allowed] of Object.entries(spec.mustMatchEnum)) {
|
|
204
204
|
if (!Array.isArray(allowed) || allowed.length === 0) continue;
|
|
205
|
-
|
|
206
|
-
if (
|
|
205
|
+
const value = packet?.[key];
|
|
206
|
+
if (value === undefined || value === null) continue;
|
|
207
|
+
if (!allowed.includes(value)) missing.push(`${key}!=${allowed.join('|')}`);
|
|
207
208
|
}
|
|
208
209
|
}
|
|
209
210
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/
|
|
2
|
+
* lib/specialists/postconditions.mjs — hard binary postconditions per producer persona.
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
4
|
+
* `specialists/contracts.json` carries free-text postconditions for documentation
|
|
5
5
|
* purposes. This module adds binary, programmatically-validated assertions
|
|
6
6
|
* that the persona's output packet must satisfy before a handoff is allowed
|
|
7
7
|
* to proceed. Failure throws `ContractViolationError` with reason
|
|
@@ -40,7 +40,7 @@ function isLaterOrEqual(a, b) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Rule table. Keyed by producer name (matching
|
|
43
|
+
* Rule table. Keyed by producer name (matching specialists/registry.json).
|
|
44
44
|
* Each rule:
|
|
45
45
|
* id — stable identifier for violation logs
|
|
46
46
|
* description — single-line documentation
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/
|
|
2
|
+
* lib/specialists/schema.mjs — validation contract for specialists/registry.json entries.
|
|
3
3
|
*
|
|
4
4
|
* Catches drift before it ships: missing description, unknown tool names,
|
|
5
5
|
* promptFile pointing at a path that does not resolve, unrecognized
|
|
@@ -90,13 +90,13 @@ export function validateAgentRecord(record, { rootDir, registryKey } = {}) {
|
|
|
90
90
|
* Validate every agent in a registry. Returns { errors: string[], agentCount }.
|
|
91
91
|
*/
|
|
92
92
|
export function validateRegistry(registry, { rootDir } = {}) {
|
|
93
|
-
if (!registry || !Array.isArray(registry.
|
|
94
|
-
return { errors: ['registry.
|
|
93
|
+
if (!registry || !Array.isArray(registry.specialists)) {
|
|
94
|
+
return { errors: ['registry.specialists is missing or not an array'], agentCount: 0 };
|
|
95
95
|
}
|
|
96
96
|
const errors = [];
|
|
97
97
|
const seenNames = new Set();
|
|
98
|
-
for (let i = 0; i < registry.
|
|
99
|
-
const record = registry.
|
|
98
|
+
for (let i = 0; i < registry.specialists.length; i += 1) {
|
|
99
|
+
const record = registry.specialists[i];
|
|
100
100
|
const recordErrors = validateAgentRecord(record, { rootDir, registryKey: i });
|
|
101
101
|
errors.push(...recordErrors);
|
|
102
102
|
if (record?.name) {
|
|
@@ -104,7 +104,7 @@ export function validateRegistry(registry, { rootDir } = {}) {
|
|
|
104
104
|
seenNames.add(record.name);
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
-
return { errors, agentCount: registry.
|
|
107
|
+
return { errors, agentCount: registry.specialists.length };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|
package/lib/status.mjs
CHANGED
|
@@ -725,19 +725,21 @@ export async function buildStatus({
|
|
|
725
725
|
executionContractModel,
|
|
726
726
|
});
|
|
727
727
|
|
|
728
|
-
const personas =
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
728
|
+
const personas = registry.orchestrator
|
|
729
|
+
? {
|
|
730
|
+
name: registry.orchestrator.name,
|
|
731
|
+
displayName: registry.orchestrator.displayName ?? registry.orchestrator.name,
|
|
732
|
+
role: registry.orchestrator.role ?? '',
|
|
733
|
+
description: registry.orchestrator.description ?? '',
|
|
734
|
+
modelTier: registry.orchestrator.modelTier ?? 'standard',
|
|
735
|
+
}
|
|
736
|
+
: null;
|
|
735
737
|
|
|
736
738
|
const prefix = `${registry.prefix ?? 'cx'}-`;
|
|
737
|
-
const specialists = (registry.
|
|
738
|
-
name: `${prefix}${
|
|
739
|
-
description:
|
|
740
|
-
modelTier:
|
|
739
|
+
const specialists = (registry.specialists ?? []).map((specialist) => ({
|
|
740
|
+
name: `${prefix}${specialist.name}`,
|
|
741
|
+
description: specialist.description ?? '',
|
|
742
|
+
modelTier: specialist.modelTier ?? 'standard',
|
|
741
743
|
}));
|
|
742
744
|
|
|
743
745
|
const hooks = [];
|
package/lib/storage/backup.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* observations/ — ~/.cx/observations/*.json
|
|
9
9
|
* sessions/ — ~/.cx/sessions/*.json
|
|
10
10
|
* config.env — ~/.construct/config.env (secrets stripped unless opts.includeSecrets)
|
|
11
|
-
* registry.json —
|
|
11
|
+
* registry.json — specialists/registry.json snapshot
|
|
12
12
|
*
|
|
13
13
|
* The manifest uses SHA-256 checksums over each included file so
|
|
14
14
|
* `backup verify` can detect corruption without extracting.
|
|
@@ -72,7 +72,7 @@ function archiveName() {
|
|
|
72
72
|
*
|
|
73
73
|
* @param {object} [opts]
|
|
74
74
|
* @param {boolean} [opts.includeSecrets] - include raw secret values in config.env
|
|
75
|
-
* @param {string} [opts.registryPath] - path to
|
|
75
|
+
* @param {string} [opts.registryPath] - path to specialists/registry.json
|
|
76
76
|
* @param {string} [opts.destDir] - destination directory (default: ~/.construct/backups)
|
|
77
77
|
* @returns {{ path: string, manifest: object }}
|
|
78
78
|
*/
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tags/lifecycle.mjs — tag lifecycle management for project and repo vocabularies.
|
|
3
|
+
*
|
|
4
|
+
* Pure file-read/write functions for the CLI to manage the tag
|
|
5
|
+
* controlled vocabulary over time: proposing new tags, adding project-local
|
|
6
|
+
* overrides, deprecating or archiving tags, and auditing tag usage across
|
|
7
|
+
* knowledge files.
|
|
8
|
+
*
|
|
9
|
+
* No LLM calls. No database calls. All functions are synchronous and
|
|
10
|
+
* operate on the filesystem via node:fs and node:path.
|
|
11
|
+
*
|
|
12
|
+
* File locations:
|
|
13
|
+
* .cx/tags/proposed.jsonl — append-only proposals from agents or mining
|
|
14
|
+
* .cx/tags/vocabulary-overrides.json — project-local tag additions/overrides
|
|
15
|
+
* config/tag-vocabulary.json — repo-wide vocabulary (edited by deprecateTag/archiveTag)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { loadVocabulary } from './vocabulary.mjs';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Internal helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function ensureDir(filePath) {
|
|
27
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJsonFile(filePath) {
|
|
31
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeJsonFile(filePath, data) {
|
|
36
|
+
ensureDir(filePath);
|
|
37
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function appendJsonlLine(filePath, record) {
|
|
41
|
+
ensureDir(filePath);
|
|
42
|
+
fs.appendFileSync(filePath, JSON.stringify(record) + '\n', 'utf8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function proposedPath(rootDir) {
|
|
46
|
+
return path.join(rootDir, '.cx', 'tags', 'proposed.jsonl');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function overridesPath(rootDir) {
|
|
50
|
+
return path.join(rootDir, '.cx', 'tags', 'vocabulary-overrides.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function repoVocabPath(rootDir) {
|
|
54
|
+
return path.join(rootDir, 'config', 'tag-vocabulary.json');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadOverrides(rootDir) {
|
|
58
|
+
const p = overridesPath(rootDir);
|
|
59
|
+
if (!fs.existsSync(p)) return { version: 1, tags: [], facets: {} };
|
|
60
|
+
return readJsonFile(p);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function updateRepoVocabTag(rootDir, id, updates) {
|
|
64
|
+
const p = repoVocabPath(rootDir);
|
|
65
|
+
const vocab = readJsonFile(p);
|
|
66
|
+
const idx = vocab.tags.findIndex(t => t.id === id);
|
|
67
|
+
if (idx === -1) throw new Error(`tag not found in repo vocab: ${id}`);
|
|
68
|
+
vocab.tags[idx] = { ...vocab.tags[idx], ...updates };
|
|
69
|
+
writeJsonFile(p, vocab);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Public API
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Appends a draft proposal entry to .cx/tags/proposed.jsonl.
|
|
78
|
+
* Does not validate against existing vocab: proposals may be genuinely new.
|
|
79
|
+
*/
|
|
80
|
+
export function proposeTag(id, { facet, label, autoThreshold, rootDir }) {
|
|
81
|
+
const record = {
|
|
82
|
+
id,
|
|
83
|
+
facet,
|
|
84
|
+
label,
|
|
85
|
+
auto_threshold: autoThreshold ?? null,
|
|
86
|
+
proposed_at: new Date().toISOString(),
|
|
87
|
+
status: 'proposed',
|
|
88
|
+
};
|
|
89
|
+
appendJsonlLine(proposedPath(rootDir), record);
|
|
90
|
+
return record;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Adds a project-local tag entry to .cx/tags/vocabulary-overrides.json.
|
|
95
|
+
* If the file does not exist it is created. If the tag id already exists in
|
|
96
|
+
* overrides it is replaced.
|
|
97
|
+
*/
|
|
98
|
+
export function addTag(id, { facet, label, scope = 'project', rootDir }) {
|
|
99
|
+
const overrides = loadOverrides(rootDir);
|
|
100
|
+
const entry = { id, facet, label, scope, status: 'active', added_at: new Date().toISOString() };
|
|
101
|
+
const existing = overrides.tags.findIndex(t => t.id === id);
|
|
102
|
+
if (existing !== -1) {
|
|
103
|
+
overrides.tags[existing] = entry;
|
|
104
|
+
} else {
|
|
105
|
+
overrides.tags.push(entry);
|
|
106
|
+
}
|
|
107
|
+
writeJsonFile(overridesPath(rootDir), overrides);
|
|
108
|
+
return entry;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Marks a tag as deprecated in the vocabulary.
|
|
113
|
+
* For repo-level tags (present in config/tag-vocabulary.json), edits that file.
|
|
114
|
+
* For project-local tags, edits .cx/tags/vocabulary-overrides.json.
|
|
115
|
+
*/
|
|
116
|
+
export function deprecateTag(id, { reason, gracePeriodDays = 90, replacedBy = null, rootDir }) {
|
|
117
|
+
const updates = {
|
|
118
|
+
status: 'deprecated',
|
|
119
|
+
deprecated_at: new Date().toISOString(),
|
|
120
|
+
deprecation_reason: reason,
|
|
121
|
+
grace_period_days: gracePeriodDays,
|
|
122
|
+
replaced_by: replacedBy,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const repoVocab = readJsonFile(repoVocabPath(rootDir));
|
|
126
|
+
const inRepo = repoVocab.tags.some(t => t.id === id);
|
|
127
|
+
|
|
128
|
+
if (inRepo) {
|
|
129
|
+
updateRepoVocabTag(rootDir, id, updates);
|
|
130
|
+
return { source: 'repo', id, ...updates };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const overrides = loadOverrides(rootDir);
|
|
134
|
+
const idx = overrides.tags.findIndex(t => t.id === id);
|
|
135
|
+
if (idx !== -1) {
|
|
136
|
+
overrides.tags[idx] = { ...overrides.tags[idx], ...updates };
|
|
137
|
+
} else {
|
|
138
|
+
overrides.tags.push({ id, facet: 'unknown', label: id, ...updates });
|
|
139
|
+
}
|
|
140
|
+
writeJsonFile(overridesPath(rootDir), overrides);
|
|
141
|
+
return { source: 'project', id, ...updates };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Marks a tag as archived in the vocabulary.
|
|
146
|
+
* Same source-selection logic as deprecateTag.
|
|
147
|
+
*/
|
|
148
|
+
export function archiveTag(id, { rootDir }) {
|
|
149
|
+
const updates = { status: 'archived', archived_at: new Date().toISOString() };
|
|
150
|
+
|
|
151
|
+
const repoVocab = readJsonFile(repoVocabPath(rootDir));
|
|
152
|
+
const inRepo = repoVocab.tags.some(t => t.id === id);
|
|
153
|
+
|
|
154
|
+
if (inRepo) {
|
|
155
|
+
updateRepoVocabTag(rootDir, id, updates);
|
|
156
|
+
return { source: 'repo', id, ...updates };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const overrides = loadOverrides(rootDir);
|
|
160
|
+
const idx = overrides.tags.findIndex(t => t.id === id);
|
|
161
|
+
if (idx !== -1) {
|
|
162
|
+
overrides.tags[idx] = { ...overrides.tags[idx], ...updates };
|
|
163
|
+
} else {
|
|
164
|
+
overrides.tags.push({ id, facet: 'unknown', label: id, ...updates });
|
|
165
|
+
}
|
|
166
|
+
writeJsonFile(overridesPath(rootDir), overrides);
|
|
167
|
+
return { source: 'project', id, ...updates };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reads .cx/tags/proposed.jsonl and returns an array of proposal records.
|
|
172
|
+
* Returns [] if the file does not exist.
|
|
173
|
+
*/
|
|
174
|
+
export function listProposed(rootDir) {
|
|
175
|
+
const p = proposedPath(rootDir);
|
|
176
|
+
if (!fs.existsSync(p)) return [];
|
|
177
|
+
const lines = fs.readFileSync(p, 'utf8').split('\n').filter(l => l.trim() !== '');
|
|
178
|
+
return lines.map(l => JSON.parse(l));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Scans .cx/knowledge/**\/*.md frontmatter for tags and cross-references them
|
|
183
|
+
* against the vocabulary. Returns:
|
|
184
|
+
* { active, overBudget, deprecated, unknown, orphan }
|
|
185
|
+
*
|
|
186
|
+
* active: tags in use that are valid and current
|
|
187
|
+
* overBudget: tags that exceed their expected_cardinality (checked per window)
|
|
188
|
+
* deprecated: tags in use that are deprecated
|
|
189
|
+
* unknown: tags in use that are not in vocabulary
|
|
190
|
+
* orphan: tags in vocabulary with no usage in any document
|
|
191
|
+
*/
|
|
192
|
+
export function auditTags(rootDir) {
|
|
193
|
+
const vocab = loadVocabulary(rootDir);
|
|
194
|
+
const knowledgeDir = path.join(rootDir, '.cx', 'knowledge');
|
|
195
|
+
|
|
196
|
+
const usageCounts = new Map();
|
|
197
|
+
|
|
198
|
+
if (fs.existsSync(knowledgeDir)) {
|
|
199
|
+
collectTagsFromDir(knowledgeDir, usageCounts);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const active = [];
|
|
203
|
+
const overBudget = [];
|
|
204
|
+
const deprecated = [];
|
|
205
|
+
const unknown = [];
|
|
206
|
+
|
|
207
|
+
for (const [tagId, count] of usageCounts) {
|
|
208
|
+
const entry = vocab._tagMap ? vocab._tagMap.get(tagId) : vocab.tags.find(t => t.id === tagId);
|
|
209
|
+
if (!entry) {
|
|
210
|
+
unknown.push({ id: tagId, count });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (entry.status === 'deprecated') {
|
|
214
|
+
deprecated.push({ id: tagId, count });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (entry.expected_cardinality && count > entry.expected_cardinality) {
|
|
218
|
+
overBudget.push({ id: tagId, count, budget: entry.expected_cardinality });
|
|
219
|
+
} else {
|
|
220
|
+
active.push({ id: tagId, count });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const orphan = vocab.tags
|
|
225
|
+
.filter(t => t.status === 'active' && !usageCounts.has(t.id))
|
|
226
|
+
.map(t => ({ id: t.id, facet: t.facet }));
|
|
227
|
+
|
|
228
|
+
return { active, overBudget, deprecated, unknown, orphan };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Internal: recursive directory scanner for frontmatter tags
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
function collectTagsFromDir(dir, usageCounts) {
|
|
236
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
const full = path.join(dir, entry.name);
|
|
239
|
+
if (entry.isDirectory()) {
|
|
240
|
+
collectTagsFromDir(full, usageCounts);
|
|
241
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
242
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
243
|
+
const tags = extractFrontmatterTags(content);
|
|
244
|
+
for (const tag of tags) {
|
|
245
|
+
usageCounts.set(tag, (usageCounts.get(tag) ?? 0) + 1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function extractFrontmatterTags(content) {
|
|
252
|
+
if (!content.startsWith('---\n')) return [];
|
|
253
|
+
const closeIdx = content.indexOf('\n---\n', 4);
|
|
254
|
+
if (closeIdx === -1) return [];
|
|
255
|
+
const block = content.slice(4, closeIdx);
|
|
256
|
+
const tagLine = block.split('\n').find(l => l.startsWith('tags:'));
|
|
257
|
+
if (!tagLine) return [];
|
|
258
|
+
|
|
259
|
+
const inlineMatch = tagLine.match(/^tags:\s*\[(.+)\]/);
|
|
260
|
+
if (inlineMatch) {
|
|
261
|
+
return inlineMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const indented = [];
|
|
265
|
+
const lines = block.split('\n');
|
|
266
|
+
let inTagsBlock = false;
|
|
267
|
+
for (const line of lines) {
|
|
268
|
+
if (line.startsWith('tags:')) { inTagsBlock = true; continue; }
|
|
269
|
+
if (inTagsBlock) {
|
|
270
|
+
if (/^\s+-\s+/.test(line)) {
|
|
271
|
+
indented.push(line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, ''));
|
|
272
|
+
} else if (line.trim() !== '' && !/^\s/.test(line)) {
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return indented;
|
|
278
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tags/vocabulary.mjs — controlled tag vocabulary loader and validator.
|
|
3
|
+
*
|
|
4
|
+
* Loads the repo-wide controlled vocabulary from config/tag-vocabulary.json
|
|
5
|
+
* and optionally merges project-local overrides from .cx/tags/vocabulary-overrides.json.
|
|
6
|
+
*
|
|
7
|
+
* Exports:
|
|
8
|
+
* loadVocabulary(rootDir) — loads and merges vocab files, returns vocab object
|
|
9
|
+
* validateTags(tagArray, vocab) — validates tags against vocab, returns classified result
|
|
10
|
+
* lookupTag(id, vocab) — returns tag entry or null
|
|
11
|
+
* getTagsByFacet(facet, vocab) — returns all active tags in a facet
|
|
12
|
+
* isTagDeprecated(id, vocab) — boolean
|
|
13
|
+
* isTagArchived(id, vocab) — boolean
|
|
14
|
+
*
|
|
15
|
+
* Merge semantics: project overrides may add new tags or change status fields
|
|
16
|
+
* on existing tags. They cannot remove repo-wide tags, only shadow/supersede them.
|
|
17
|
+
* Tags with the same id in both sources: the override entry wins entirely.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Internal helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function readJsonFile(filePath) {
|
|
28
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
29
|
+
return JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildTagMap(tags) {
|
|
33
|
+
const map = new Map();
|
|
34
|
+
for (const tag of tags) {
|
|
35
|
+
map.set(tag.id, tag);
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Public API
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Loads config/tag-vocabulary.json from rootDir and merges any project-local
|
|
46
|
+
* overrides from .cx/tags/vocabulary-overrides.json if the file exists.
|
|
47
|
+
* Returns the merged vocabulary object with a `_tagMap` cache attached.
|
|
48
|
+
*/
|
|
49
|
+
export function loadVocabulary(rootDir) {
|
|
50
|
+
const repoVocabPath = path.join(rootDir, 'config', 'tag-vocabulary.json');
|
|
51
|
+
const vocab = readJsonFile(repoVocabPath);
|
|
52
|
+
|
|
53
|
+
const overridePath = path.join(rootDir, '.cx', 'tags', 'vocabulary-overrides.json');
|
|
54
|
+
if (fs.existsSync(overridePath)) {
|
|
55
|
+
const overrides = readJsonFile(overridePath);
|
|
56
|
+
if (Array.isArray(overrides.tags)) {
|
|
57
|
+
const overrideById = buildTagMap(overrides.tags);
|
|
58
|
+
const merged = vocab.tags.map(t => overrideById.has(t.id) ? overrideById.get(t.id) : t);
|
|
59
|
+
for (const [id, entry] of overrideById) {
|
|
60
|
+
if (!vocab.tags.some(t => t.id === id)) {
|
|
61
|
+
merged.push(entry);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
vocab.tags = merged;
|
|
65
|
+
}
|
|
66
|
+
if (overrides.facets) {
|
|
67
|
+
vocab.facets = { ...vocab.facets, ...overrides.facets };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
vocab._tagMap = buildTagMap(vocab.tags);
|
|
72
|
+
return vocab;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validates an array of tag IDs against the loaded vocabulary.
|
|
77
|
+
* Returns { valid: string[], unknown: string[], duplicateFacets: string[] }.
|
|
78
|
+
*
|
|
79
|
+
* unknown: tag IDs not present in vocab at all.
|
|
80
|
+
* duplicateFacets: tag IDs that violate an exclusive facet constraint (second
|
|
81
|
+
* and subsequent tags from the same exclusive facet are listed here).
|
|
82
|
+
*/
|
|
83
|
+
export function validateTags(tagArray, vocab) {
|
|
84
|
+
const valid = [];
|
|
85
|
+
const unknown = [];
|
|
86
|
+
const duplicateFacets = [];
|
|
87
|
+
const seenExclusiveFacets = new Map();
|
|
88
|
+
|
|
89
|
+
for (const id of tagArray) {
|
|
90
|
+
const entry = vocab._tagMap ? vocab._tagMap.get(id) : vocab.tags.find(t => t.id === id);
|
|
91
|
+
if (!entry) {
|
|
92
|
+
unknown.push(id);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const facetDef = vocab.facets[entry.facet];
|
|
97
|
+
if (facetDef && facetDef.exclusive) {
|
|
98
|
+
if (seenExclusiveFacets.has(entry.facet)) {
|
|
99
|
+
duplicateFacets.push(id);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
seenExclusiveFacets.set(entry.facet, id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
valid.push(id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { valid, unknown, duplicateFacets };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns the tag entry for the given id, or null if not found.
|
|
113
|
+
*/
|
|
114
|
+
export function lookupTag(id, vocab) {
|
|
115
|
+
if (vocab._tagMap) return vocab._tagMap.get(id) ?? null;
|
|
116
|
+
return vocab.tags.find(t => t.id === id) ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns all active tags belonging to the given facet.
|
|
121
|
+
*/
|
|
122
|
+
export function getTagsByFacet(facet, vocab) {
|
|
123
|
+
return vocab.tags.filter(t => t.facet === facet && t.status === 'active');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Returns true if the tag with the given id has status 'deprecated'.
|
|
128
|
+
*/
|
|
129
|
+
export function isTagDeprecated(id, vocab) {
|
|
130
|
+
const entry = lookupTag(id, vocab);
|
|
131
|
+
return entry !== null && entry.status === 'deprecated';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns true if the tag with the given id has status 'archived'.
|
|
136
|
+
*/
|
|
137
|
+
export function isTagArchived(id, vocab) {
|
|
138
|
+
const entry = lookupTag(id, vocab);
|
|
139
|
+
return entry !== null && entry.status === 'archived';
|
|
140
|
+
}
|