@geraldmaron/construct 1.0.7 → 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 (147) hide show
  1. package/README.md +20 -17
  2. package/bin/construct +1199 -69
  3. package/commands/work/optimize-prompts.md +1 -1
  4. package/db/schema/007_tags.sql +30 -0
  5. package/db/schema/008_skill_usage.sql +24 -0
  6. package/db/schema/009_scheduler.sql +14 -0
  7. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  8. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  9. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  10. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  11. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  12. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  13. package/examples/seed-observations/decisions.md +1 -1
  14. package/lib/audit-skills.mjs +3 -3
  15. package/lib/auto-docs.mjs +2 -2
  16. package/lib/cli-commands.mjs +276 -122
  17. package/lib/comment-lint.mjs +5 -1
  18. package/lib/completions.mjs +1 -1
  19. package/lib/config/schema.mjs +15 -1
  20. package/lib/contracts/validate.mjs +65 -17
  21. package/lib/distill.mjs +22 -4
  22. package/lib/doc-stamp.mjs +48 -0
  23. package/lib/doctor/watchers/consistency.mjs +62 -12
  24. package/lib/document-extract.mjs +221 -28
  25. package/lib/document-ingest.mjs +2 -0
  26. package/lib/embed/inbox.mjs +48 -3
  27. package/lib/embed/role-framing.mjs +3 -3
  28. package/lib/env-config.mjs +9 -3
  29. package/lib/extractors/calendar.mjs +173 -0
  30. package/lib/extractors/shared/drop-info.mjs +22 -0
  31. package/lib/extractors/transcript.mjs +0 -0
  32. package/lib/gates-audit.mjs +8 -2
  33. package/lib/git-hooks/prepare-commit-msg +1 -1
  34. package/lib/headhunt.mjs +2 -2
  35. package/lib/hooks/agent-tracker.mjs +6 -11
  36. package/lib/hooks/guard-bash.mjs +35 -14
  37. package/lib/hooks/mcp-audit.mjs +12 -0
  38. package/lib/hooks/pre-compact.mjs +92 -5
  39. package/lib/hooks/pre-push-gate.mjs +41 -8
  40. package/lib/hooks/registry-sync.mjs +2 -2
  41. package/lib/hooks/stop-notify.mjs +7 -6
  42. package/lib/host-capabilities.mjs +10 -1
  43. package/lib/hygiene/scan.mjs +141 -0
  44. package/lib/init-unified.mjs +18 -0
  45. package/lib/install/stage-project.mjs +4 -4
  46. package/lib/intake/classify.mjs +222 -55
  47. package/lib/intake/filesystem-queue.mjs +25 -5
  48. package/lib/intake/postgres-queue.mjs +45 -3
  49. package/lib/intake/prepare.mjs +3 -0
  50. package/lib/intake/quarantine.mjs +205 -0
  51. package/lib/knowledge/postgres-search.mjs +132 -0
  52. package/lib/knowledge/search.mjs +14 -4
  53. package/lib/mcp/server.mjs +9 -3
  54. package/lib/mcp/tools/skills.mjs +32 -11
  55. package/lib/mcp/tools/workflow.mjs +1 -1
  56. package/lib/migrations/index.mjs +1 -1
  57. package/lib/model-registry.mjs +1 -1
  58. package/lib/opencode-runtime-plugin.mjs +1 -1
  59. package/lib/orchestration-policy.mjs +4 -4
  60. package/lib/overrides/resolver.mjs +3 -3
  61. package/lib/parity.mjs +4 -4
  62. package/lib/policy/engine.mjs +2 -2
  63. package/lib/profiles/lifecycle.mjs +1 -1
  64. package/lib/prompt-metadata.mjs +4 -4
  65. package/lib/providers/circuit-breaker.mjs +14 -0
  66. package/lib/providers/contract.mjs +67 -3
  67. package/lib/providers/creds.mjs +219 -0
  68. package/lib/providers/scaffold.mjs +107 -0
  69. package/lib/role-preload.mjs +5 -0
  70. package/lib/roles/catalog.mjs +1 -1
  71. package/lib/roles/manifest.mjs +2 -2
  72. package/lib/scheduler/index.mjs +112 -0
  73. package/lib/scheduler/solo.mjs +183 -0
  74. package/lib/server/index.mjs +7 -1
  75. package/lib/server/insights.mjs +14 -12
  76. package/lib/server/langfuse-login.mjs +58 -0
  77. package/lib/server/static/assets/index-ab25c707.js +1 -1
  78. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  79. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  80. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  81. package/lib/{agents → specialists}/schema.mjs +6 -6
  82. package/lib/status.mjs +13 -11
  83. package/lib/storage/backup.mjs +2 -2
  84. package/lib/tags/lifecycle.mjs +278 -0
  85. package/lib/tags/vocabulary.mjs +140 -0
  86. package/lib/telemetry/otel-tracer.mjs +184 -0
  87. package/lib/telemetry/skill-calls.mjs +73 -12
  88. package/lib/uninstall/uninstall.mjs +1 -1
  89. package/lib/update.mjs +1 -1
  90. package/lib/validator.mjs +57 -56
  91. package/lib/workflows/instantiate.mjs +320 -0
  92. package/package.json +13 -3
  93. package/personas/construct.md +2 -2
  94. package/platforms/claude/CLAUDE.md +1 -1
  95. package/rules/common/no-fabrication.md +1 -1
  96. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
  97. package/skills/ai/prompt-optimizer.md +3 -3
  98. package/skills/exploration/dependency-graph-reading.md +98 -0
  99. package/skills/exploration/tracer-bullet-method.md +71 -0
  100. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  101. package/skills/operating/change-management.md +91 -0
  102. package/skills/operating/incident-response.md +75 -0
  103. package/skills/operating/oncall-rotation.md +95 -0
  104. package/skills/operating/orchestration-reference.md +2 -2
  105. package/skills/strategy/competitive-landscape.md +75 -0
  106. package/skills/strategy/market-research-methods.md +87 -0
  107. package/skills/strategy/narrative-arc.md +77 -0
  108. package/skills/strategy/pricing-positioning.md +94 -0
  109. package/{agents → specialists}/contracts.schema.json +1 -1
  110. package/specialists/policy-inventory.json +160 -0
  111. package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
  112. package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
  113. package/{agents → specialists}/prompts/cx-architect.md +1 -1
  114. package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
  115. package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
  116. package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
  117. package/{agents → specialists}/prompts/cx-debugger.md +1 -1
  118. package/{agents → specialists}/prompts/cx-designer.md +1 -1
  119. package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
  120. package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
  121. package/{agents → specialists}/prompts/cx-engineer.md +2 -2
  122. package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
  123. package/{agents → specialists}/prompts/cx-explorer.md +1 -1
  124. package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
  125. package/{agents → specialists}/prompts/cx-operations.md +1 -1
  126. package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
  127. package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
  128. package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
  129. package/{agents → specialists}/prompts/cx-qa.md +1 -1
  130. package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
  131. package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
  132. package/{agents → specialists}/prompts/cx-researcher.md +1 -1
  133. package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
  134. package/{agents → specialists}/prompts/cx-security.md +1 -1
  135. package/{agents → specialists}/prompts/cx-sre.md +2 -2
  136. package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
  137. package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
  138. package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
  139. package/{agents → specialists}/registry.json +651 -423
  140. package/{agents → specialists}/role-manifests.json +8 -8
  141. package/templates/provider-scaffold/health.test.mjs +30 -0
  142. package/templates/provider-scaffold/index.mjs +48 -0
  143. package/templates/workflows/cross-team-handoff.yml +85 -0
  144. package/templates/workflows/engineering-onboarding.yml +77 -0
  145. package/templates/workflows/new-feature.yml +53 -0
  146. /package/{agents → specialists}/contracts.json +0 -0
  147. /package/{agents → specialists}/teams.json +0 -0
@@ -38,11 +38,11 @@ export const SUPPORTED_CATEGORIES = Object.freeze([
38
38
 
39
39
  const CATEGORY_RULES = {
40
40
  personas: { originalDir: 'personas', defaultExt: '.md' },
41
- agents: { originalDir: 'agents/prompts', defaultExt: '.md' },
41
+ agents: { originalDir: 'specialists/prompts', defaultExt: '.md' },
42
42
  skills: { originalDir: 'skills', defaultExt: '.md' },
43
43
  rules: { originalDir: 'rules', defaultExt: '.md' },
44
- contracts: { originalDir: 'agents', defaultExt: '.json', singleFile: 'contracts.json' },
45
- 'role-manifests': { originalDir: 'agents', defaultExt: '.json', singleFile: 'role-manifests.json' },
44
+ contracts: { originalDir: 'specialists', defaultExt: '.json', singleFile: 'contracts.json' },
45
+ 'role-manifests': { originalDir: 'specialists', defaultExt: '.json', singleFile: 'role-manifests.json' },
46
46
  };
47
47
 
48
48
  function categoryRule(category) {
package/lib/parity.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * After `construct sync` writes adapters to multiple surfaces (Claude Code,
5
5
  * OpenCode, Codex, Copilot, VS Code, Cursor), the module diffs each surface's actual state against the
6
- * canonical `agents/registry.json`. Backs `construct doctor` for surfacing
6
+ * canonical `specialists/registry.json`. Backs `construct doctor` for surfacing
7
7
  * silent divergence — for instance, an agent added to the registry that
8
8
  * never made it to OpenCode because of a sync regression.
9
9
  *
@@ -31,7 +31,7 @@ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
31
31
  const ROOT_DIR = path.resolve(MODULE_DIR, '..');
32
32
 
33
33
  function loadRegistry(rootDir = ROOT_DIR) {
34
- const file = path.join(rootDir, 'agents', 'registry.json');
34
+ const file = path.join(rootDir, 'specialists', 'registry.json');
35
35
  return JSON.parse(fs.readFileSync(file, 'utf8'));
36
36
  }
37
37
 
@@ -42,8 +42,8 @@ function adapterName(entry, prefix) {
42
42
  function entriesForSurface(registry, surface) {
43
43
  const prefix = registry.prefix || 'cx';
44
44
  const entries = [
45
- ...(registry.personas || []).map((p) => ({ ...p, isPersona: true })),
46
- ...(registry.agents || []).map((a) => ({ ...a, isPersona: false })),
45
+ ...(registry.orchestrator ? [{ ...registry.orchestrator, isPersona: true }] : []),
46
+ ...(registry.specialists || []).map((s) => ({ ...s, isPersona: false })),
47
47
  ];
48
48
 
49
49
  return entries
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/policy/engine.mjs — role-based policy decisions for tool / action access.
3
3
  *
4
- * Reads role permissions from `agents/role-manifests.json` and decides
4
+ * Reads role permissions from `specialists/role-manifests.json` and decides
5
5
  * whether a given (role, project, tool, action, risk) tuple is allowed,
6
6
  * needs approval, or is denied outright. Powers the MCP broker for team
7
7
  * and enterprise deployments; solo mode leaves the broker off so this
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
23
- const DEFAULT_MANIFEST_PATH = path.join(MODULE_DIR, '..', '..', 'agents', 'role-manifests.json');
23
+ const DEFAULT_MANIFEST_PATH = path.join(MODULE_DIR, '..', '..', 'specialists', 'role-manifests.json');
24
24
 
25
25
  const HIGH_RISK_AUTONOMOUS = new Set(['security', 'sre', 'release-manager']);
26
26
 
@@ -76,7 +76,7 @@ Goal: define the role set and how it interlocks with the curated cx-* registry.
76
76
  reuse-existing (name the cx-* agent), create-new (specify scope), or compose-overlay (which base role + which flavor).
77
77
  - Identify cross-role handoffs. Where can ambiguity stall the loop? Name the orchestrator role.
78
78
  - Validate against the per-role cap (max 6 flavors per role per profile).
79
- - Acceptance: every declared role either exists in agents/registry.json or has a written scope statement.
79
+ - Acceptance: every declared role either exists in specialists/registry.json or has a written scope statement.
80
80
 
81
81
  ## 4. Validation (cx-evaluator)
82
82
 
@@ -25,9 +25,9 @@ function normalizeName(name) {
25
25
  function findEntry(registry, agentName) {
26
26
  const normalized = normalizeName(agentName);
27
27
  const entries = [
28
- ...(registry.personas ?? []),
29
- ...(registry.agents ?? []),
30
- ];
28
+ registry.orchestrator,
29
+ ...(registry.specialists ?? []),
30
+ ].filter(Boolean);
31
31
  return entries.find((entry) => {
32
32
  const entryName = normalizeName(entry.name);
33
33
  return entryName === normalized || `cx-${entryName}` === String(agentName ?? '').trim().toLowerCase();
@@ -36,7 +36,7 @@ function findEntry(registry, agentName) {
36
36
 
37
37
  export function resolvePromptEntry(agentName, { rootDir = process.cwd(), registry } = {}) {
38
38
  if (!agentName) return null;
39
- const loadedRegistry = registry ?? readJson(path.join(rootDir, 'agents', 'registry.json'));
39
+ const loadedRegistry = registry ?? readJson(path.join(rootDir, 'specialists', 'registry.json'));
40
40
  if (!loadedRegistry) return null;
41
41
  return findEntry(loadedRegistry, agentName);
42
42
  }
@@ -104,6 +104,20 @@ export function getBreaker(key, options) {
104
104
  return REGISTRY.get(key);
105
105
  }
106
106
 
107
+ export function getState(providerName) {
108
+ const b = REGISTRY.get(providerName);
109
+ if (!b) return null;
110
+ return {
111
+ state: b.state.toUpperCase(),
112
+ failures: b.failureCount,
113
+ lastFailure: b.openedAt ? new Date(b.openedAt) : null,
114
+ };
115
+ }
116
+
117
+ export function reset(providerName) {
118
+ REGISTRY.get(providerName)?.reset();
119
+ }
120
+
107
121
  export function describeBreakers() {
108
122
  return [...REGISTRY.values()].map((b) => ({
109
123
  key: b.key,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A provider integrates Construct with an external system that holds work
5
5
  * artefacts: GitHub repos, Jira projects, Confluence spaces, Slack channels,
6
- * Salesforce orgs, etc. Providers are stateless adapters durable state
6
+ * Salesforce orgs, etc. Providers are stateless adapters; durable state
7
7
  * (cached search results, last-seen markers, observations distilled from
8
8
  * fetched items) lives in core stores, not in the provider.
9
9
  *
@@ -29,11 +29,75 @@
29
29
  *
30
30
  * `assertProviderContract(provider)` runs at registry load time and refuses
31
31
  * to accept providers that miss required fields or declare capabilities
32
- * they don't implement. The check is deliberately loose providers can
33
- * extend the shape with extra methods but it gates the load-bearing
32
+ * they don't implement. The check is deliberately loose; providers can
33
+ * extend the shape with extra methods; but it gates the load-bearing
34
34
  * fields so a typo in a plugin can't take down the registry.
35
+ *
36
+ * Scope validation: `ALLOWLIST_SCHEMA` declares the allowlist fields
37
+ * supported per provider. `validateAllowlist(providerName, target, config)`
38
+ * checks whether `target` (a repo name, channel ID, project key, etc.) is
39
+ * permitted by the caller's config. When no allowlist fields are set the
40
+ * call is allowed through; when any allowlist field is set the target must
41
+ * satisfy it.
42
+ *
43
+ * Glob matching for `*Glob` fields follows minimatch-style single-segment
44
+ * patterns where `*` matches any sequence of non-slash characters.
35
45
  */
36
46
 
47
+ export const ALLOWLIST_SCHEMA = Object.freeze({
48
+ github: { fields: ['org', 'repoAllowlist', 'repoAllowGlob'] },
49
+ 'atlassian-jira': { fields: ['instance', 'projectAllowlist'] },
50
+ 'atlassian-confluence': { fields: ['instance', 'spaceAllowlist'] },
51
+ slack: { fields: ['workspace', 'channelAllowlist'] },
52
+ salesforce: { fields: ['instance', 'objectAllowlist'] },
53
+ });
54
+
55
+ function globMatch(pattern, value) {
56
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
57
+ return new RegExp(`^${escaped}$`).test(value);
58
+ }
59
+
60
+ export function validateAllowlist(providerName, target, config = {}) {
61
+ const schema = ALLOWLIST_SCHEMA[providerName];
62
+
63
+ // Unknown providers pass through; no schema means no restriction.
64
+ if (!schema) return { allowed: true, reason: 'no allowlist schema for provider' };
65
+
66
+ const listFields = schema.fields.filter((f) => f.endsWith('Allowlist'));
67
+ const globFields = schema.fields.filter((f) => f.endsWith('AllowGlob'));
68
+
69
+ const hasListConstraint = listFields.some((f) => Array.isArray(config[f]) && config[f].length > 0);
70
+ const hasGlobConstraint = globFields.some((f) => typeof config[f] === 'string' && config[f].length > 0);
71
+
72
+ // No allowlist configured: permit everything.
73
+ if (!hasListConstraint && !hasGlobConstraint) {
74
+ return { allowed: true, reason: 'no allowlist configured' };
75
+ }
76
+
77
+ // Check explicit list membership.
78
+ for (const field of listFields) {
79
+ if (Array.isArray(config[field]) && config[field].length > 0) {
80
+ if (config[field].includes(target)) {
81
+ return { allowed: true, reason: `matched ${field}` };
82
+ }
83
+ }
84
+ }
85
+
86
+ // Check glob patterns.
87
+ for (const field of globFields) {
88
+ if (typeof config[field] === 'string' && config[field].length > 0) {
89
+ if (globMatch(config[field], target)) {
90
+ return { allowed: true, reason: `matched ${field} pattern '${config[field]}'` };
91
+ }
92
+ }
93
+ }
94
+
95
+ return {
96
+ allowed: false,
97
+ reason: `'${target}' is not in the configured allowlist for ${providerName}`,
98
+ };
99
+ }
100
+
37
101
  export const CAPABILITIES = Object.freeze([
38
102
  'read',
39
103
  'search',
@@ -0,0 +1,219 @@
1
+ /**
2
+ * lib/providers/creds.mjs — credential management for provider integrations.
3
+ *
4
+ * Reads and writes provider credentials stored in `~/.construct/config.env`.
5
+ * Each provider block is delimited by comment markers so the file can be
6
+ * parsed without a full ini/dotenv library and can also be sourced by a shell.
7
+ *
8
+ * Block format (example for `github`):
9
+ *
10
+ * # CONSTRUCT_CREDS_GITHUB
11
+ * CONSTRUCT_CREDS_GITHUB_KEY=ghp_xxx
12
+ * CONSTRUCT_CREDS_GITHUB_ACCOUNT=myorg
13
+ * CONSTRUCT_CREDS_GITHUB_ROTATED_AT=2026-05-27
14
+ * # END_CONSTRUCT_CREDS_GITHUB
15
+ *
16
+ * Security: `writeCreds` sets the file mode to 0600 after every write.
17
+ * `checkCredsFileMode` reports whether the current mode is within policy.
18
+ *
19
+ * All functions are synchronous except those that need the filesystem and
20
+ * are documented as async. Currently all ops are sync; the async signature
21
+ * is reserved for future keychain-backend support.
22
+ */
23
+
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+
28
+ const CONSTRUCT_DIR = path.join(os.homedir(), '.construct');
29
+ const CONFIG_FILE = 'config.env';
30
+
31
+ export function credsFilePath() {
32
+ return path.join(CONSTRUCT_DIR, CONFIG_FILE);
33
+ }
34
+
35
+ function providerKey(provider) {
36
+ return provider.toUpperCase().replace(/-/g, '_');
37
+ }
38
+
39
+ function blockStart(key) {
40
+ return `# CONSTRUCT_CREDS_${key}`;
41
+ }
42
+
43
+ function blockEnd(key) {
44
+ return `# END_CONSTRUCT_CREDS_${key}`;
45
+ }
46
+
47
+ function readRaw() {
48
+ const fp = credsFilePath();
49
+ try {
50
+ return fs.readFileSync(fp, 'utf8');
51
+ } catch (err) {
52
+ if (err.code === 'ENOENT') return '';
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ function writeRaw(content) {
58
+ const fp = credsFilePath();
59
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
60
+ fs.writeFileSync(fp, content, { encoding: 'utf8', mode: 0o600 });
61
+
62
+ // Re-apply mode after write; some platforms reset on open.
63
+ fs.chmodSync(fp, 0o600);
64
+ }
65
+
66
+ function extractBlocks(raw) {
67
+ const blocks = {};
68
+ const lines = raw.split('\n');
69
+ let current = null;
70
+ let currentLines = [];
71
+
72
+ for (const line of lines) {
73
+ const startMatch = line.match(/^# CONSTRUCT_CREDS_([A-Z0-9_]+)$/);
74
+ const endMatch = line.match(/^# END_CONSTRUCT_CREDS_([A-Z0-9_]+)$/);
75
+
76
+ if (startMatch) {
77
+ current = startMatch[1];
78
+ currentLines = [line];
79
+ continue;
80
+ }
81
+ if (endMatch && current === endMatch[1]) {
82
+ currentLines.push(line);
83
+ blocks[current] = currentLines.join('\n');
84
+ current = null;
85
+ currentLines = [];
86
+ continue;
87
+ }
88
+ if (current) {
89
+ currentLines.push(line);
90
+ }
91
+ }
92
+
93
+ return blocks;
94
+ }
95
+
96
+ function parseBlock(blockText) {
97
+ const result = {};
98
+ for (const line of blockText.split('\n')) {
99
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
100
+ if (!m) continue;
101
+ result[m[1]] = m[2];
102
+ }
103
+ return result;
104
+ }
105
+
106
+ function buildBlock(key, { account, apiKey, rotatedAt }) {
107
+ const prefix = `CONSTRUCT_CREDS_${key}`;
108
+ const lines = [blockStart(key)];
109
+ if (apiKey !== undefined) lines.push(`${prefix}_KEY=${apiKey}`);
110
+ if (account !== undefined) lines.push(`${prefix}_ACCOUNT=${account}`);
111
+ if (rotatedAt !== undefined) lines.push(`${prefix}_ROTATED_AT=${rotatedAt}`);
112
+ lines.push(blockEnd(key));
113
+ return lines.join('\n');
114
+ }
115
+
116
+ function nextRotationDue(rotatedAtStr) {
117
+ if (!rotatedAtStr) return null;
118
+ const d = new Date(rotatedAtStr);
119
+ if (isNaN(d.getTime())) return null;
120
+ d.setDate(d.getDate() + 90);
121
+ return d.toISOString().slice(0, 10);
122
+ }
123
+
124
+ export function readCreds() {
125
+ const raw = readRaw();
126
+ const blocks = extractBlocks(raw);
127
+ const result = {};
128
+
129
+ for (const [upperKey, blockText] of Object.entries(blocks)) {
130
+ const prefix = `CONSTRUCT_CREDS_${upperKey}`;
131
+ const fields = parseBlock(blockText);
132
+ const rotatedAt = fields[`${prefix}_ROTATED_AT`] || null;
133
+ result[upperKey.toLowerCase().replace(/_/g, '-')] = {
134
+ account: fields[`${prefix}_ACCOUNT`] || null,
135
+ key: fields[`${prefix}_KEY`] || null,
136
+ rotatedAt,
137
+ nextRotationDue: nextRotationDue(rotatedAt),
138
+ };
139
+ }
140
+
141
+ return result;
142
+ }
143
+
144
+ export function writeCreds(provider, { key, account } = {}) {
145
+ const pKey = providerKey(provider);
146
+ const raw = readRaw();
147
+ const blocks = extractBlocks(raw);
148
+
149
+ const existing = blocks[pKey] ? parseBlock(blocks[pKey]) : {};
150
+ const prefix = `CONSTRUCT_CREDS_${pKey}`;
151
+
152
+ const rotatedAt = key !== undefined
153
+ ? new Date().toISOString().slice(0, 10)
154
+ : (existing[`${prefix}_ROTATED_AT`] || null);
155
+
156
+ const mergedAccount = account !== undefined ? account : (existing[`${prefix}_ACCOUNT`] || undefined);
157
+ const mergedKey = key !== undefined ? key : (existing[`${prefix}_KEY`] || undefined);
158
+
159
+ const newBlock = buildBlock(pKey, { account: mergedAccount, apiKey: mergedKey, rotatedAt });
160
+
161
+ let newRaw;
162
+ if (blocks[pKey]) {
163
+ const startIdx = raw.indexOf(blockStart(pKey));
164
+ const endIdx = raw.indexOf(blockEnd(pKey)) + blockEnd(pKey).length;
165
+ newRaw = raw.slice(0, startIdx) + newBlock + raw.slice(endIdx);
166
+ } else {
167
+ const trimmed = raw.trimEnd();
168
+ newRaw = trimmed ? `${trimmed}\n\n${newBlock}\n` : `${newBlock}\n`;
169
+ }
170
+
171
+ writeRaw(newRaw);
172
+ }
173
+
174
+ export function deleteCreds(provider) {
175
+ const pKey = providerKey(provider);
176
+ const raw = readRaw();
177
+ const start = blockStart(pKey);
178
+ const end = blockEnd(pKey);
179
+
180
+ const startIdx = raw.indexOf(start);
181
+ if (startIdx === -1) return;
182
+
183
+ const endIdx = raw.indexOf(end, startIdx);
184
+ if (endIdx === -1) return;
185
+
186
+ const before = raw.slice(0, startIdx).trimEnd();
187
+ const after = raw.slice(endIdx + end.length).trimStart();
188
+
189
+ const newRaw = before && after
190
+ ? `${before}\n\n${after}`
191
+ : before
192
+ ? `${before}\n`
193
+ : after || '';
194
+
195
+ writeRaw(newRaw);
196
+ }
197
+
198
+ export function checkCredsFileMode() {
199
+ const fp = credsFilePath();
200
+ try {
201
+ const stat = fs.statSync(fp);
202
+ const mode = stat.mode & 0o777;
203
+ const modeStr = '0' + mode.toString(8);
204
+ return { ok: mode <= 0o600, mode: modeStr };
205
+ } catch (err) {
206
+ if (err.code === 'ENOENT') return { ok: true, mode: 'absent' };
207
+ throw err;
208
+ }
209
+ }
210
+
211
+ export function listCreds() {
212
+ const all = readCreds();
213
+ return Object.entries(all).map(([provider, data]) => ({
214
+ provider,
215
+ account: data.account,
216
+ rotatedAt: data.rotatedAt,
217
+ nextRotationDue: data.nextRotationDue,
218
+ }));
219
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * lib/providers/scaffold.mjs — provider scaffold generator.
3
+ *
4
+ * Reads the template files from `templates/provider-scaffold/`, substitutes
5
+ * the %%PROVIDER_NAME%% and %%CAPABILITIES%% placeholders, and writes the
6
+ * result into `lib/providers/<name>/`. Returns the list of files created so
7
+ * the CLI can report them to the user.
8
+ *
9
+ * Supported capabilities: read, search, write, watch, webhook.
10
+ * The `read` and `search` methods are always included in the generated
11
+ * index.mjs; additional capabilities listed in `options.capabilities` add
12
+ * stub implementations for write, watch, and webhook.
13
+ *
14
+ * `scaffoldProvider` is idempotent with respect to the directory: it will
15
+ * not overwrite an existing file unless `options.force = true`. This prevents
16
+ * accidental loss of work when a developer reruns the command.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const TEMPLATES_DIR = path.resolve(__dirname, '..', '..', 'templates', 'provider-scaffold');
25
+
26
+ const ALL_CAPABILITIES = ['read', 'search', 'write', 'watch', 'webhook'];
27
+
28
+ function capabilityStub(cap) {
29
+ const stubs = {
30
+ write: `
31
+ async write(payload, config = {}) {
32
+ // Replace with a real write operation against the remote system. // construct-lint-ignore
33
+ throw new Error('not implemented');
34
+ },`,
35
+ watch: `
36
+ async watch(config = {}, callback) {
37
+ // Replace with a real subscription; call callback(item) on new events. // construct-lint-ignore
38
+ throw new Error('not implemented');
39
+ },`,
40
+ webhook: `
41
+ async webhook(config = {}, request) {
42
+ // Replace with real webhook signature verification and dispatch. // construct-lint-ignore
43
+ return { ok: false, error: 'not implemented' };
44
+ },`,
45
+ };
46
+ return stubs[cap] || '';
47
+ }
48
+
49
+ function renderIndex(name, capabilities) {
50
+ const capList = capabilities.filter((c) => ALL_CAPABILITIES.includes(c));
51
+ const capString = capList.join(', ');
52
+
53
+ const extraStubs = capList
54
+ .filter((c) => !['read', 'search'].includes(c))
55
+ .map(capabilityStub)
56
+ .join('\n');
57
+
58
+ const template = fs.readFileSync(path.join(TEMPLATES_DIR, 'index.mjs'), 'utf8');
59
+ let rendered = template
60
+ .replace(/%%PROVIDER_NAME%%/g, name)
61
+ .replace(/%%CAPABILITIES%%/g, capString);
62
+
63
+ if (extraStubs) {
64
+ rendered = rendered.replace(
65
+ /(\s+async search\(query, config = \{\}\) \{[\s\S]*?throw new Error\('not implemented'\);\n\s+\},)/,
66
+ `$1\n${extraStubs}`,
67
+ );
68
+ }
69
+
70
+ return rendered;
71
+ }
72
+
73
+ function renderTest(name) {
74
+ const template = fs.readFileSync(path.join(TEMPLATES_DIR, 'health.test.mjs'), 'utf8');
75
+ return template.replace(/%%PROVIDER_NAME%%/g, name);
76
+ }
77
+
78
+ export async function scaffoldProvider(name, options = {}) {
79
+ const {
80
+ capabilities = ['read', 'search'],
81
+ cwd = process.cwd(),
82
+ force = false,
83
+ } = options;
84
+
85
+ if (!name || typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
86
+ throw new Error(`scaffoldProvider: name must be a lowercase alphanumeric-and-hyphen string, got '${name}'`);
87
+ }
88
+
89
+ const providerDir = path.join(cwd, 'lib', 'providers', name);
90
+ fs.mkdirSync(providerDir, { recursive: true });
91
+
92
+ const files = [];
93
+
94
+ const indexPath = path.join(providerDir, 'index.mjs');
95
+ if (force || !fs.existsSync(indexPath)) {
96
+ fs.writeFileSync(indexPath, renderIndex(name, capabilities), 'utf8');
97
+ files.push(indexPath);
98
+ }
99
+
100
+ const testPath = path.join(providerDir, 'health.test.mjs');
101
+ if (force || !fs.existsSync(testPath)) {
102
+ fs.writeFileSync(testPath, renderTest(name), 'utf8');
103
+ files.push(testPath);
104
+ }
105
+
106
+ return { providerDir, files };
107
+ }
@@ -34,11 +34,16 @@ function stripTopHeading(body) {
34
34
  export function readRoleFile(root, name, opts = {}) {
35
35
  const p = path.join(root, "skills", "roles", `${name}.md`);
36
36
  if (!fs.existsSync(p)) return null;
37
+ const t0 = Date.now();
37
38
  const body = stripTopHeading(stripFrontmatter(fs.readFileSync(p, "utf8"))).trim();
38
39
  logSkillCall({
39
40
  skillId: `roles/${name}`,
40
41
  source: opts.source || 'role-preload',
41
42
  callerContext: opts.callerContext,
43
+ latencyMs: Date.now() - t0,
44
+ agentId: opts.agentId,
45
+ sessionId: opts.sessionId,
46
+ tokensReturned: Math.ceil(body.length / 4),
42
47
  });
43
48
  return body;
44
49
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/roles/catalog.mjs — role listing and display helpers.
3
3
  *
4
- * Reads agents/registry.json to produce a list of available roles with
4
+ * Reads specialists/registry.json to produce a list of available roles with
5
5
  * optional department grouping and consolidated-role views. Consumed by the
6
6
  * `roles:list` CLI command.
7
7
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/roles/manifest.mjs — role manifest loader.
3
3
  *
4
- * Reads agents/role-manifests.json. A persona is "onboarded" when its
4
+ * Reads specialists/role-manifests.json. A persona is "onboarded" when its
5
5
  * `events` array is non-empty. Empty entries reserve the slot for future
6
6
  * wiring without code change.
7
7
  */
@@ -11,7 +11,7 @@ import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
 
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
- const MANIFEST_PATH = join(__dirname, '..', '..', 'agents', 'role-manifests.json');
14
+ const MANIFEST_PATH = join(__dirname, '..', '..', 'specialists', 'role-manifests.json');
15
15
 
16
16
  let cached = null;
17
17