@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.
Files changed (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
@@ -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
- if (currentValue && currentValue !== desired) {
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 set to '.beads/hooks' to activate Construct policy gates.`,
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/agent-contracts-enforce.mjs — runtime contract enforcement.
2
+ * lib/specialist-contracts-enforce.mjs — runtime contract enforcement.
3
3
  *
4
- * Wraps `validatePacket` from `lib/agent-contracts.mjs` with three production
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 './agent-contracts.mjs';
31
- import { validatePostconditions } from './agents/postconditions.mjs';
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/agent-contracts.mjs — loader + query layer for agents/contracts.json.
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, 'agents', 'contracts.json');
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 fieldPresent(packet, key) {
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 (!fieldPresent(packet, key)) missing.push(key);
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) => fieldPresent(packet, 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
- if (!fieldPresent(packet, key)) continue;
206
- if (!allowed.includes(packet[key])) missing.push(`${key}!=${allowed.join('|')}`);
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/agents/postconditions.mjs — hard binary postconditions per producer persona.
2
+ * lib/specialists/postconditions.mjs — hard binary postconditions per producer persona.
3
3
  *
4
- * `agents/contracts.json` carries free-text postconditions for documentation
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 agents/registry.json).
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/agents/schema.mjs — validation contract for agents/registry.json entries.
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.agents)) {
94
- return { errors: ['registry.agents is missing or not an array'], agentCount: 0 };
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.agents.length; i += 1) {
99
- const record = registry.agents[i];
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.agents.length };
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 = (registry.personas ?? []).map((persona) => ({
729
- name: persona.name,
730
- displayName: persona.displayName ?? persona.name,
731
- role: persona.role ?? '',
732
- description: persona.description ?? '',
733
- modelTier: persona.modelTier ?? 'standard',
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.agents ?? []).map((agent) => ({
738
- name: `${prefix}${agent.name}`,
739
- description: agent.description ?? '',
740
- modelTier: agent.modelTier ?? 'standard',
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 = [];
@@ -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 — agents/registry.json snapshot
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 agents/registry.json
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
+ }