@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
@@ -0,0 +1,22 @@
1
+ /**
2
+ * lib/extractors/shared/drop-info.mjs — shared drop-info factory and envelope builder.
3
+ *
4
+ * Every extractor returns the same { text, structured, droppedInfo } envelope so
5
+ * callers can always read droppedInfo[] without format-specific branching. An empty
6
+ * droppedInfo array means nothing was silently dropped, not that nothing was structured.
7
+ *
8
+ * Valid `kind` values mirror the MIME world: 'attachment', 'inline-image', 'table',
9
+ * 'formula', 'comment', 'html-part', 'animation', 'speaker-notes', 'scanned-pdf'.
10
+ */
11
+
12
+ export function makeDropInfo({ kind, count, reason, recoverable = false }) {
13
+ return { kind, count, reason, recoverable };
14
+ }
15
+
16
+ /**
17
+ * Wrap extraction results in the universal envelope.
18
+ * Callers that produce no structured data pass `structured: null` (the default).
19
+ */
20
+ export function makeEnvelope({ text = '', structured = null, droppedInfo = [] } = {}) {
21
+ return { text, structured, droppedInfo };
22
+ }
Binary file
@@ -47,11 +47,17 @@ const GATE_DEFINITIONS = [
47
47
  ];
48
48
 
49
49
  function parseCIJobs(rootDir) {
50
+ // Extracts both job-level names and step-level names. Consolidated jobs
51
+ // (e.g. the `lint suite` job carrying comment policy + docs drift + gates
52
+ // audit as steps) surface each step name as a gate signal. The audit's
53
+ // job-name comparison treats jobs and steps as a flat union so gate
54
+ // definitions stay decoupled from CI structure.
50
55
  const path = join(rootDir, '.github', 'workflows', 'ci.yml');
51
56
  if (!existsSync(path)) return [];
52
57
  const yml = readFileSync(path, 'utf8');
53
- return [...yml.matchAll(/^ name:\s*(.+?)\s*$/gm)]
54
- .map((m) => m[1].trim())
58
+ const jobNames = [...yml.matchAll(/^ name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
59
+ const stepNames = [...yml.matchAll(/^ - name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
60
+ return [...jobNames, ...stepNames]
55
61
  .map((n) => n.replace(/\$\{\{\s*matrix\.(\w+)\s*\}\}/g, (_, k) => `<${k}>`));
56
62
  }
57
63
 
@@ -6,7 +6,7 @@
6
6
  * Follows the RFC 9562 / git-interpret-trailers convention.
7
7
  *
8
8
  * Trailers added (when env vars are present):
9
- * AI-Generator: construct/sync-agents
9
+ * AI-Generator: construct/sync-specialists
10
10
  * AI-Model: claude-sonnet-4-6
11
11
  * AI-Session: 019dbb90-5885-...
12
12
  *
package/lib/headhunt.mjs CHANGED
@@ -16,7 +16,7 @@ import { selectOption } from './tty-prompts.mjs';
16
16
 
17
17
  const OVERLAY_DIRNAME = 'domain-overlays';
18
18
  const PROMOTION_DIRNAME = 'promotion-requests';
19
- const TEAMS_FILE = 'agents/teams.json';
19
+ const TEAMS_FILE = 'specialists/teams.json';
20
20
 
21
21
  function loadTeamTemplates(root) {
22
22
  try {
@@ -342,7 +342,7 @@ export async function runHeadhunt({ args = [], cwd = process.cwd(), homeDir = os
342
342
  if (!templateName) { listTeamTemplates(cwd); return; }
343
343
  const root = findConstructRoot(cwd);
344
344
  const teams = loadTeamTemplates(root);
345
- if (!teams) throw new Error('agents/teams.json not found. Run from the Construct root.');
345
+ if (!teams) throw new Error('specialists/teams.json not found. Run from the Construct root.');
346
346
  const tpl = teams.templates[templateName];
347
347
  if (!tpl) throw new Error(`Unknown template: ${templateName}. Run 'construct headhunt template' to list available templates.`);
348
348
  const objective = typeof flags.for === 'string' ? flags.for.trim() : rest.slice(1).join(' ').trim();
@@ -60,17 +60,12 @@ try {
60
60
  const cxDir = join(home, '.cx');
61
61
  mkdirSync(cxDir, { recursive: true });
62
62
 
63
- // Update last-agent file for coordination
64
- writeFileSync(
65
- join(cxDir, 'last-agent.json'),
66
- JSON.stringify({
67
- agent: agentName,
68
- coordination: 'tracker-plus-plan',
69
- ts: new Date().toISOString(),
70
- outcome,
71
- description: description.slice(0, 200)
72
- }),
73
- );
63
+ // Update last-agent files for coordination (shared + per-agent)
64
+ const agentEntry = { agent: agentName, coordination: 'tracker-plus-plan', ts: new Date().toISOString(), outcome, description: description.slice(0, 200) };
65
+ writeFileSync(join(cxDir, 'last-agent.json'), JSON.stringify(agentEntry));
66
+ // Per-agent file prevents one agent dispatch from resetting another's fence window
67
+ const safeName = agentName.replace(/[^a-z0-9._-]/gi, '_');
68
+ writeFileSync(join(cxDir, `last-agent-${safeName}.json`), JSON.stringify(agentEntry));
74
69
 
75
70
  // Append to agent log for telemetry
76
71
  const agentLogFile = join(cxDir, 'agent-log.jsonl');
@@ -40,8 +40,24 @@ const rootDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
40
40
  const result = lintFile(filePath, { rootDir });
41
41
  if (!result.errors.length && !result.warnings.length) process.exit(0);
42
42
 
43
+ // Artifact-prose violations are advisory at write-time (no-fabrication policy
44
+ // enforces them at CI/release-gate time via CONSTRUCT_ARTIFACT_LINT_MODE=block).
45
+ // Surface them on stderr but do not block the edit.
46
+ const artifactOnly = (
47
+ result.errors.length === 0 &&
48
+ result.warnings.every((w) => w.kind === 'artifact')
49
+ );
50
+
43
51
  const { output } = formatResults([result]);
44
52
  process.stderr.write(output);
53
+
54
+ if (artifactOnly) {
55
+ process.stderr.write(
56
+ '\nArtifact-lint advisory (no-fabrication). The edit is not blocked, but these will fail the release gate. Fix before push.\n',
57
+ );
58
+ process.exit(0);
59
+ }
60
+
45
61
  process.stderr.write(
46
62
  '\nComment policy blocked this edit. Remove the violations or set CONSTRUCT_SKIP_COMMENT_LINT=1.\n',
47
63
  );
@@ -95,41 +95,62 @@ for (const { pattern, reason } of WARN_PATTERNS) {
95
95
 
96
96
  if (process.env.CONSTRUCT_ROLES !== 'off') {
97
97
  try {
98
- const lastAgentPath = join(homedir(), '.cx', 'last-agent.json');
99
- if (existsSync(lastAgentPath)) {
100
- const last = JSON.parse(readFileSync(lastAgentPath, 'utf8'));
101
- const lastTs = last?.ts ? Date.parse(last.ts) : 0;
98
+ const cxDir = join(homedir(), '.cx');
99
+ const id = String(process.env.CONSTRUCT_AGENT_ID || '').replace(/^cx-/, '');
100
+ let agentData = null;
101
+
102
+ // Prefer per-agent timestamp file (prevents cross-agent fence window reset)
103
+ if (id) {
104
+ const perAgentPath = join(cxDir, `last-agent-${id.replace(/[^a-z0-9._-]/gi, '_')}.json`);
105
+ if (existsSync(perAgentPath)) {
106
+ agentData = JSON.parse(readFileSync(perAgentPath, 'utf8'));
107
+ }
108
+ }
109
+ // Fall back to shared last-agent.json
110
+ if (!agentData) {
111
+ const sharedPath = join(cxDir, 'last-agent.json');
112
+ if (existsSync(sharedPath)) {
113
+ agentData = JSON.parse(readFileSync(sharedPath, 'utf8'));
114
+ }
115
+ }
116
+
117
+ if (agentData) {
118
+ const lastTs = agentData?.ts ? Date.parse(agentData.ts) : 0;
102
119
  const fresh = lastTs && (Date.now() - lastTs) < 10 * 60 * 1000;
103
- const id = String(last?.agent || '').replace(/^cx-/, '');
104
- if (fresh && id) {
120
+ const personaId = id || String(agentData?.agent || '').replace(/^cx-/, '');
121
+ if (fresh && personaId) {
105
122
  const { isOnboarded } = await import('../roles/manifest.mjs');
106
- if (isOnboarded(id)) {
123
+ if (isOnboarded(personaId)) {
107
124
  const { checkAction } = await import('../roles/fence.mjs');
108
125
  const trimmed = command.trim();
109
126
  let verdict;
110
127
  if (/^git\s+commit\b/.test(trimmed)) {
111
- verdict = checkAction({ personaId: id, action: 'commit', target: '' });
128
+ verdict = checkAction({ personaId, action: 'commit', target: '' });
112
129
  } else if (/^git\s+push\b/.test(trimmed)) {
113
- verdict = checkAction({ personaId: id, action: 'push', target: '' });
130
+ verdict = checkAction({ personaId, action: 'push', target: '' });
114
131
  } else {
115
- verdict = checkAction({ personaId: id, action: 'bash', target: trimmed });
132
+ verdict = checkAction({ personaId, action: 'bash', target: trimmed });
116
133
  }
117
134
  if (!verdict.allowed && verdict.reason === 'outside-fence') {
135
+ const allowedList = (() => {
136
+ try { const { loadManifest } = await import('../roles/manifest.mjs'); const m = loadManifest(personaId); return m?.fence?.allowedCommands?.join(', ') || 'see role-manifests.json'; } catch { return 'see role-manifests.json'; }
137
+ })();
118
138
  process.stderr.write(
119
- `[fence] cx-${id} cannot run this command — outside declared fence.\n` +
139
+ `[fence] cx-${personaId} cannot run this command — outside declared fence.\n` +
120
140
  `Command: ${trimmed.slice(0, 200)}\n` +
121
- `Allowed commands prefix list: see agents/role-manifests.json → ${id} → fence.allowedCommands.\n`
141
+ `Allowed commands: ${allowedList}\n` +
142
+ `To bypass: CONSTRUCT_ROLES=off <command>\n`
122
143
  );
123
144
  process.exit(2);
124
145
  }
125
146
  if (!verdict.allowed && verdict.approval) {
126
147
  process.stderr.write(
127
- `[fence] cx-${id} running this requires user approval (${verdict.reason}).\n` +
148
+ `[fence] cx-${personaId} running this requires user approval (${verdict.reason}).\n` +
128
149
  `Command: ${trimmed.slice(0, 200)}\n`
129
150
  );
130
151
  const { recordApprovalRequest } = await import('../roles/approval-surface.mjs');
131
152
  const actionLabel = /^git\s+commit\b/.test(trimmed) ? 'commit' : /^git\s+push\b/.test(trimmed) ? 'push' : 'bash';
132
- await recordApprovalRequest({ personaId: id, action: actionLabel, target: trimmed, reason: verdict.reason || 'needs-approval' });
153
+ await recordApprovalRequest({ personaId, action: actionLabel, target: trimmed, reason: verdict.reason || 'needs-approval' });
133
154
  }
134
155
  }
135
156
  }
@@ -3,6 +3,7 @@
3
3
  * lib/hooks/mcp-audit.mjs — MCP audit hook — logs all MCP tool calls for observability and review.
4
4
  *
5
5
  * Runs as PostToolUse on MCP tool calls. Records tool name, input, and output summary to ~/.cx/mcp-audit.json for telemetry and security review.
6
+ * Also emits a gen_ai.client.tool.call span when OTel is configured (OTEL_EXPORTER_OTLP_ENDPOINT set).
6
7
  *
7
8
  * @p95ms 10
8
9
  * @maxBlockingScope none (PostToolUse, non-blocking)
@@ -10,6 +11,8 @@
10
11
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
11
12
  import { join } from 'path';
12
13
  import { shouldCreateCx } from '../project-detection.mjs';
14
+ import { ensureCxDir } from '../project-init-shared.mjs';
15
+ import { GenAiAttrs, withGenAiSpan } from '../telemetry/otel-tracer.mjs';
13
16
 
14
17
  let input = {};
15
18
  try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
@@ -47,9 +50,19 @@ if (audit[mcpServer].length > 200) audit[mcpServer] = audit[mcpServer].slice(-20
47
50
 
48
51
  try {
49
52
  if (shouldCreateCxDir) {
50
- mkdirSync(join(cwd, '.cx'), { recursive: true });
53
+ ensureCxDir(cwd);
51
54
  writeFileSync(auditPath, JSON.stringify(audit, null, 2));
52
55
  }
53
56
  } catch { /* best effort */ }
54
57
 
58
+ // Fire-and-forget OTel span for the tool call — non-blocking.
59
+ if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT && process.env.CONSTRUCT_OTEL !== 'off') {
60
+ withGenAiSpan('tool_call', {
61
+ [GenAiAttrs.TOOL_NAME]: mcpTool,
62
+ [GenAiAttrs.MCP_METHOD]: toolName,
63
+ [GenAiAttrs.MCP_TRANSPORT]: 'stdio',
64
+ 'construct.mcp_server': mcpServer,
65
+ }, async (span) => span).catch(() => {});
66
+ }
67
+
55
68
  process.exit(0);
@@ -4,15 +4,87 @@
4
4
  *
5
5
  * Runs before context compaction. Writes a structured summary of context state and pending tasks to preserve continuity across the compaction boundary.
6
6
  *
7
+ * Manual sections in context.md (any heading not in AUTO_SECTION_NAMES) are
8
+ * detected, preserved verbatim, and re-appended after the auto-generated
9
+ * sections so that hand-written decisions, active-work notes, and architecture
10
+ * context survive every compaction cycle.
11
+ *
7
12
  * @p95ms 100
8
13
  * @maxBlockingScope PreCompact
9
14
  */
10
15
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
11
16
  import { homedir } from 'os';
12
17
  import { join } from 'path';
13
- import { writeContextState } from '../context-state.mjs';
18
+ import { writeContextState, contextMarkdownPath, contextJsonPath } from '../context-state.mjs';
14
19
  import { flushReadTrackerDeltas } from '../read-tracker-store.mjs';
15
20
 
21
+ // Section headings that are auto-generated by this hook. Any other heading in
22
+ // the existing context.md is treated as manually authored and preserved.
23
+ const AUTO_SECTION_NAMES = new Set([
24
+ 'what was in progress',
25
+ 'files changed this session',
26
+ 'decisions captured',
27
+ 'pending todos',
28
+ 'session efficiency snapshot',
29
+ 'open issues',
30
+ 'session context',
31
+ ]);
32
+
33
+ /**
34
+ * Parse a markdown string into `[{heading, level, body}]` sections.
35
+ * Returns an empty array for empty/missing input.
36
+ */
37
+ function parseSections(md) {
38
+ if (!md) return [];
39
+ const lines = md.split('\n');
40
+ const sections = [];
41
+ let current = null;
42
+ for (const line of lines) {
43
+ const hm = line.match(/^(#{1,6})\s+(.+)$/);
44
+ if (hm) {
45
+ if (current) sections.push(current);
46
+ current = { heading: line, level: hm[1].length, title: hm[2].trim(), body: [] };
47
+ } else if (current) {
48
+ current.body.push(line);
49
+ }
50
+ }
51
+ if (current) sections.push(current);
52
+ return sections;
53
+ }
54
+
55
+ /**
56
+ * Extract sections from an existing context.md that were NOT auto-generated.
57
+ * Returns a markdown string (may be empty) to append after the auto content.
58
+ */
59
+ function extractManualSections(mdPath) {
60
+ if (!existsSync(mdPath)) return '';
61
+ let existing = '';
62
+ try { existing = readFileSync(mdPath, 'utf8'); } catch { return ''; }
63
+ const sections = parseSections(existing);
64
+ const manual = sections.filter((s) => !AUTO_SECTION_NAMES.has(s.title.toLowerCase()));
65
+ if (!manual.length) return '';
66
+ return manual
67
+ .map((s) => [s.heading, ...s.body].join('\n').trimEnd())
68
+ .join('\n\n') + '\n';
69
+ }
70
+
71
+ /**
72
+ * Merge non-empty arrays from existing context.json into the new state to
73
+ * prevent the hook from discarding decisions/filesChanged captured manually.
74
+ */
75
+ function mergeExistingJson(jsonPath, newState) {
76
+ if (!existsSync(jsonPath)) return newState;
77
+ let existing = {};
78
+ try { existing = JSON.parse(readFileSync(jsonPath, 'utf8')); } catch { return newState; }
79
+ const merged = { ...newState };
80
+ for (const key of ['decisions', 'filesChanged', 'pendingTodos']) {
81
+ if ((!merged[key] || !merged[key].length) && Array.isArray(existing[key]) && existing[key].length) {
82
+ merged[key] = existing[key];
83
+ }
84
+ }
85
+ return merged;
86
+ }
87
+
16
88
  let input = {};
17
89
  try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
18
90
 
@@ -156,6 +228,15 @@ contextLines.push('## Open issues');
156
228
  contextLines.push(warnFlags || 'None');
157
229
 
158
230
  const content = contextLines.join('\n') + '\n';
231
+
232
+ const projectCxDir = join(cwd, '.cx');
233
+ try { mkdirSync(projectCxDir, { recursive: true }); } catch { /* exists */ }
234
+
235
+ // Preserve manually-written sections from the existing context.md.
236
+ const projectMdPath = contextMarkdownPath(cwd);
237
+ const manualSections = extractManualSections(projectMdPath);
238
+ const finalContent = content + (manualSections ? '\n' + manualSections : '');
239
+
159
240
  const contextJson = {
160
241
  source: 'pre-compact',
161
242
  projectName,
@@ -167,9 +248,11 @@ const contextJson = {
167
248
  warnFlags: warnFlags || 'None',
168
249
  };
169
250
 
170
- const projectCxDir = join(cwd, '.cx');
171
- try { mkdirSync(projectCxDir, { recursive: true }); } catch { /* exists */ }
172
- try { writeContextState(cwd, { ...contextJson, contextSummary: lastSummary }, { markdown: content }); } catch { /* best effort */ }
251
+ // Merge non-empty arrays from the existing JSON so manually recorded state
252
+ // is not silently dropped when the transcript scan finds nothing new.
253
+ const mergedJson = mergeExistingJson(contextJsonPath(cwd), contextJson);
254
+
255
+ try { writeContextState(cwd, { ...mergedJson, contextSummary: lastSummary }, { markdown: finalContent }); } catch { /* best effort */ }
173
256
  if (decisions.length) {
174
257
  const decisionsDir = join(projectCxDir, 'decisions');
175
258
  const slug = `${date.replace(/[^0-9]/g, '')}-session-decisions`;
@@ -190,6 +273,10 @@ if (decisions.length) {
190
273
 
191
274
  const globalCxDir = join(homedir(), '.cx');
192
275
  try { mkdirSync(globalCxDir, { recursive: true }); } catch { /* exists */ }
193
- try { writeContextState(homedir(), { ...contextJson, contextSummary: lastSummary }, { markdown: content }); } catch { /* best effort */ }
276
+ const globalMdPath = contextMarkdownPath(homedir());
277
+ const globalManualSections = extractManualSections(globalMdPath);
278
+ const globalFinalContent = content + (globalManualSections ? '\n' + globalManualSections : '');
279
+ const globalMergedJson = mergeExistingJson(contextJsonPath(homedir()), contextJson);
280
+ try { writeContextState(homedir(), { ...globalMergedJson, contextSummary: lastSummary }, { markdown: globalFinalContent }); } catch { /* best effort */ }
194
281
 
195
282
  process.exit(0);
@@ -198,8 +198,13 @@ function runNpmGate(projectDir) {
198
198
  jobs.push({ label: 'audit', cmd: runner, args: auditArgs, timeout: 30_000 });
199
199
 
200
200
  if (existsSync(join(projectDir, 'bin/construct'))) {
201
- jobs.push({ label: 'evals', cmd: 'node', args: ['bin/construct', 'evals', 'retrieval'], timeout: 60_000 });
202
- jobs.push({ label: 'docs', cmd: 'node', args: ['bin/construct', 'docs:verify'], timeout: 15_000 });
201
+ jobs.push({ label: 'evals', cmd: 'node', args: ['bin/construct', 'evals', 'retrieval'], timeout: 60_000 });
202
+ jobs.push({ label: 'docs', cmd: 'node', args: ['bin/construct', 'docs:verify'], timeout: 15_000 });
203
+ jobs.push({ label: 'docs drift', cmd: 'node', args: ['bin/construct', 'docs:update', '--check'], timeout: 15_000 });
204
+ jobs.push({ label: 'dashboard drift', cmd: 'node', args: ['bin/construct', 'dashboard:sync', '--check'], timeout: 15_000 });
205
+ jobs.push({ label: 'comment policy', cmd: 'node', args: ['bin/construct', 'lint:comments'], timeout: 30_000 });
206
+ jobs.push({ label: 'agents registry', cmd: 'node', args: ['bin/construct', 'lint:agents'], timeout: 15_000 });
207
+ jobs.push({ label: 'contracts schema', cmd: 'node', args: ['bin/construct', 'lint:contracts'], timeout: 15_000 });
203
208
  }
204
209
 
205
210
  if (existsSync(join(projectDir, 'scripts/lint-prose.mjs'))) {
@@ -274,15 +279,43 @@ function runJob(job) {
274
279
 
275
280
  const results = await Promise.all(gate.jobs.map(runJob));
276
281
  const failures = [];
282
+
283
+ // Auto-fix docs drift: if docs:update --check fails, run docs:update to regenerate
284
+ const docsDriftResult = results.find((r) => r.label === 'docs drift');
285
+ if (docsDriftResult && docsDriftResult.status !== 0) {
286
+ process.stderr.write('[pre-push-gate] Docs drift detected — auto-running docs:update...\n');
287
+ try {
288
+ const fix = spawnSync('node', ['bin/construct', 'docs:update'], {
289
+ cwd: gate.projectDir,
290
+ stdio: ['ignore', 'pipe', 'pipe'],
291
+ timeout: 30_000,
292
+ });
293
+ if (fix.status === 0) {
294
+ process.stderr.write('[pre-push-gate] docs:update succeeded — re-checking...\n');
295
+ const recheck = spawnSync('node', ['bin/construct', 'docs:update', '--check'], {
296
+ cwd: gate.projectDir,
297
+ stdio: ['ignore', 'pipe', 'pipe'],
298
+ timeout: 15_000,
299
+ });
300
+ if (recheck.status === 0) {
301
+ process.stderr.write('[pre-push-gate] Docs are now clean after auto-fix.\n');
302
+ results.splice(results.indexOf(docsDriftResult), 1); // remove from failure candidates
303
+ } else {
304
+ process.stderr.write(`[pre-push-gate] Auto-fix attempted but docs still drift — manual intervention needed.\n`);
305
+ docsDriftResult.stdout = (docsDriftResult.stdout || '') + '\n[auto-fix attempted but still dirty]';
306
+ }
307
+ } else {
308
+ process.stderr.write(`[pre-push-gate] docs:update auto-fix failed (exit ${fix.status}): ${fix.stderr.slice(0, 300)}\n`);
309
+ }
310
+ } catch (e) {
311
+ process.stderr.write(`[pre-push-gate] docs:update auto-fix threw: ${e.message}\n`);
312
+ }
313
+ }
314
+
277
315
  for (const result of results) {
278
316
  if (result.status === 0) continue;
279
317
  const detail = (result.stderr || result.stdout || '').trim();
280
- const firstError = detail.split('\n')
281
- .map((l) => l.trim())
282
- .filter((l) => l && !/^>/.test(l) && !/^\s*at /.test(l))
283
- .slice(0, 3)
284
- .join(' · ');
285
- failures.push({ label: result.label, detail: firstError || `exited ${result.status}` });
318
+ failures.push({ label: result.label, detail: detail || `exited ${result.status}` });
286
319
  }
287
320
 
288
321
  if (failures.length === 0) { echo(); }
@@ -239,20 +239,6 @@ export function onScheduleCheck() {
239
239
  return events;
240
240
  }
241
241
 
242
- /**
243
- * Get all pending activations for a specialist.
244
- */
245
- export function getPendingActivations(specialist) {
246
- // In a full implementation, this would query a pending queue
247
- // For now, return recent activation history
248
- const state = getActivationState(specialist);
249
- return {
250
- pending: 0,
251
- recent: state.activations.length,
252
- total: state.totalActivations,
253
- };
254
- }
255
-
256
242
  /**
257
243
  * Clear activation state (for testing or manual reset).
258
244
  */
@@ -13,11 +13,11 @@ import { logHookFailure } from './_lib/log.mjs';
13
13
  import { constructDir } from '../paths.mjs';
14
14
 
15
15
  const filePath = process.env.TOOL_INPUT_FILE_PATH || '';
16
- if (!filePath.endsWith(path.join('agents', 'registry.json')) && !filePath.endsWith('/agents/registry.json')) process.exit(0);
16
+ if (!filePath.endsWith(path.join('agents', 'registry.json')) && !filePath.endsWith('/specialists/registry.json')) process.exit(0);
17
17
 
18
18
  const toolkitDir = constructDir();
19
19
  try {
20
- execSync('node scripts/sync-agents.mjs', {
20
+ execSync('node scripts/sync-specialists.mjs', {
21
21
  cwd: toolkitDir,
22
22
  stdio: 'pipe',
23
23
  env: { ...process.env, CX_TOOLKIT_DIR: toolkitDir },