@geraldmaron/construct 1.0.7 → 1.0.9

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} +130 -58
  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
@@ -0,0 +1,173 @@
1
+ /**
2
+ * lib/extractors/calendar.mjs — RFC 5545 iCalendar (.ics) structured extraction.
3
+ *
4
+ * Parses VCALENDAR / VEVENT blocks without external dependencies. Handles
5
+ * multi-line property continuation (lines starting with whitespace), extracts
6
+ * SUMMARY, DTSTART, DTEND, ORGANIZER, ATTENDEE, DESCRIPTION, LOCATION, and
7
+ * preserves RRULE as a raw string for recurring events.
8
+ *
9
+ * Returns the universal { text, structured, droppedInfo } envelope where
10
+ * structured = { events: [{ summary, dtstart, dtend, organizer,
11
+ * attendees: [], description, location, rrule }] }.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { makeEnvelope, makeDropInfo } from './shared/drop-info.mjs';
15
+
16
+ // ─── RFC 5545 helpers ─────────────────────────────────────────────────────────
17
+
18
+ // Unfold continuation lines (RFC 5545 §3.1 — a line beginning with SPACE or TAB
19
+ // is a continuation of the previous property line).
20
+ function unfoldLines(raw) {
21
+ return raw.replace(/\r\n/g, '\n').replace(/\n[ \t]/g, '');
22
+ }
23
+
24
+ // Split a property line into its name, params object, and value.
25
+ // e.g. "DTSTART;TZID=America/New_York:20240101T090000" → name=DTSTART, params={TZID:...}, value=...
26
+ function parseProperty(line) {
27
+ const colonIdx = line.indexOf(':');
28
+ if (colonIdx === -1) return { name: line.toUpperCase(), params: {}, value: '' };
29
+ const nameAndParams = line.slice(0, colonIdx);
30
+ const value = line.slice(colonIdx + 1).trim();
31
+ const parts = nameAndParams.split(';');
32
+ const name = (parts[0] || '').toUpperCase().trim();
33
+ const params = {};
34
+ for (const part of parts.slice(1)) {
35
+ const eqIdx = part.indexOf('=');
36
+ if (eqIdx === -1) continue;
37
+ params[part.slice(0, eqIdx).toUpperCase()] = part.slice(eqIdx + 1);
38
+ }
39
+ return { name, params, value };
40
+ }
41
+
42
+ // Extract a human-readable name from an ORGANIZER or ATTENDEE value.
43
+ // Tries the CN param first, then strips mailto: prefix.
44
+ function extractName(value, params) {
45
+ if (params.CN) return params.CN.replace(/^["']|["']$/g, '');
46
+ return value.replace(/^mailto:/i, '');
47
+ }
48
+
49
+ // Parse an iCal date/datetime string into an ISO string. Tolerant of both
50
+ // date-only (YYYYMMDD) and datetime (YYYYMMDDTHHMMSS[Z]) forms.
51
+ function parseIcalDate(value) {
52
+ if (!value) return null;
53
+ const v = value.replace('Z', '').trim();
54
+ if (v.length === 8) {
55
+ // Date-only: YYYYMMDD
56
+ return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
57
+ }
58
+ if (v.length >= 15 && v[8] === 'T') {
59
+ // Datetime: YYYYMMDDTHHMMSS
60
+ return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}T${v.slice(9, 11)}:${v.slice(11, 13)}:${v.slice(13, 15)}${value.endsWith('Z') ? 'Z' : ''}`;
61
+ }
62
+ return value;
63
+ }
64
+
65
+ // ─── VEVENT block parser ──────────────────────────────────────────────────────
66
+
67
+ function parseVEvent(lines) {
68
+ const event = {
69
+ summary: null,
70
+ dtstart: null,
71
+ dtend: null,
72
+ organizer: null,
73
+ attendees: [],
74
+ description: null,
75
+ location: null,
76
+ rrule: null,
77
+ };
78
+
79
+ for (const line of lines) {
80
+ if (!line.includes(':')) continue;
81
+ const { name, params, value } = parseProperty(line);
82
+ switch (name) {
83
+ case 'SUMMARY': event.summary = value; break;
84
+ case 'DTSTART': event.dtstart = parseIcalDate(value); break;
85
+ case 'DTEND': event.dtend = parseIcalDate(value); break;
86
+ case 'DTDUE': event.dtend = event.dtend ?? parseIcalDate(value); break;
87
+ case 'ORGANIZER': event.organizer = extractName(value, params); break;
88
+ case 'ATTENDEE': event.attendees.push(extractName(value, params)); break;
89
+ case 'DESCRIPTION': event.description = value.replace(/\\n/g, '\n').replace(/\\,/g, ','); break;
90
+ case 'LOCATION': event.location = value; break;
91
+ case 'RRULE': event.rrule = value; break;
92
+ }
93
+ }
94
+ return event;
95
+ }
96
+
97
+ // ─── VCALENDAR parser ─────────────────────────────────────────────────────────
98
+
99
+ function parseIcs(raw) {
100
+ const unfolded = unfoldLines(raw);
101
+ const lines = unfolded.split('\n').map((l) => l.trim()).filter(Boolean);
102
+
103
+ if (!lines.some((l) => l === 'BEGIN:VCALENDAR')) {
104
+ return makeEnvelope({
105
+ text: raw.replace(/\r\n/g, '\n').trim(),
106
+ structured: null,
107
+ droppedInfo: [makeDropInfo({ kind: 'attachment', count: 1, reason: 'No BEGIN:VCALENDAR found; treating as plain text', recoverable: false })],
108
+ });
109
+ }
110
+
111
+ const events = [];
112
+ let inEvent = false;
113
+ let eventLines = [];
114
+ let droppedTodosVjournals = 0;
115
+ let inOther = false;
116
+
117
+ for (const line of lines) {
118
+ if (line === 'BEGIN:VEVENT') {
119
+ inEvent = true;
120
+ eventLines = [];
121
+ } else if (line === 'END:VEVENT') {
122
+ events.push(parseVEvent(eventLines));
123
+ inEvent = false;
124
+ } else if (line === 'BEGIN:VTODO' || line === 'BEGIN:VJOURNAL') {
125
+ inOther = true;
126
+ droppedTodosVjournals++;
127
+ } else if (line === 'END:VTODO' || line === 'END:VJOURNAL') {
128
+ inOther = false;
129
+ } else if (inEvent) {
130
+ eventLines.push(line);
131
+ }
132
+ }
133
+
134
+ const droppedInfo = [];
135
+ if (droppedTodosVjournals > 0) {
136
+ droppedInfo.push(makeDropInfo({
137
+ kind: 'attachment',
138
+ count: droppedTodosVjournals,
139
+ reason: 'VTODO and VJOURNAL components omitted (only VEVENT extracted)',
140
+ recoverable: false,
141
+ }));
142
+ }
143
+
144
+ // Build text representation
145
+ const textParts = events.map((e) => {
146
+ const parts = [];
147
+ if (e.summary) parts.push(`Event: ${e.summary}`);
148
+ if (e.dtstart) parts.push(`Start: ${e.dtstart}`);
149
+ if (e.dtend) parts.push(`End: ${e.dtend}`);
150
+ if (e.organizer) parts.push(`Organizer: ${e.organizer}`);
151
+ if (e.attendees.length) parts.push(`Attendees: ${e.attendees.join(', ')}`);
152
+ if (e.location) parts.push(`Location: ${e.location}`);
153
+ if (e.description) parts.push(`\n${e.description}`);
154
+ return parts.join('\n');
155
+ });
156
+
157
+ return makeEnvelope({
158
+ text: textParts.join('\n\n').trim(),
159
+ structured: { events },
160
+ droppedInfo,
161
+ });
162
+ }
163
+
164
+ // ─── Public API ───────────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Extract structured calendar data from a .ics file path.
168
+ * Returns the universal { text, structured, droppedInfo } envelope.
169
+ */
170
+ export function extractCalendar(filePath) {
171
+ const raw = readFileSync(filePath, 'utf8');
172
+ return parseIcs(raw);
173
+ }
@@ -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');
@@ -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)
@@ -11,6 +12,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
11
12
  import { join } from 'path';
12
13
  import { shouldCreateCx } from '../project-detection.mjs';
13
14
  import { ensureCxDir } from '../project-init-shared.mjs';
15
+ import { GenAiAttrs, withGenAiSpan } from '../telemetry/otel-tracer.mjs';
14
16
 
15
17
  let input = {};
16
18
  try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
@@ -53,4 +55,14 @@ try {
53
55
  }
54
56
  } catch { /* best effort */ }
55
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
+
56
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(); }
@@ -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 },
@@ -161,7 +161,11 @@ try {
161
161
  }
162
162
  } catch { /* non-critical */ }
163
163
 
164
- let costNote = '';
164
+ // Pricing readout is intentionally suppressed in user-facing notifications.
165
+ // The session-cost ledger is still written (lines below) so OTel + dashboard
166
+ // consumers can pick the data up when the observability surfaces are wired.
167
+ // Do not reintroduce a costNote variable here without a deliberate decision
168
+ // about whether to surface pricing to the user.
165
169
  let totalCostUsd = 0;
166
170
  let turnsLogged = 0;
167
171
  try {
@@ -232,17 +236,14 @@ try {
232
236
 
233
237
  saveTranscriptCheckpoints(checkpointPath, checkpoints);
234
238
 
235
- if (totalCostUsd > 0) {
236
- const label = turnsLogged > 1 ? `${turnsLogged} turns` : 'this response';
237
- costNote = `~$${totalCostUsd.toFixed(2)} ${label}`;
238
- }
239
+ // totalCostUsd and turnsLogged stay as ledger-write accumulators only.
240
+ // Downstream telemetry consumers read the session-cost.jsonl ledger.
239
241
  } catch { /* non-critical */ }
240
242
 
241
243
  const parts = [];
242
244
  if (fileCount > 0) parts.push(`${fileCount} file${fileCount !== 1 ? 's' : ''} updated`);
243
245
  if (tsResult === 'pass') parts.push('TS OK');
244
246
  else if (tsResult !== 'unchecked') parts.push(`TS: ${tsResult}`);
245
- if (costNote) parts.push(costNote);
246
247
 
247
248
  const hasWarnings = warnings.length > 0;
248
249
  const title = hasWarnings ? 'Construct — Review needed' : 'Construct';
@@ -215,6 +215,10 @@ export function detectHostCapabilities() {
215
215
  ];
216
216
  }
217
217
 
218
+ export function formatHostCapabilitiesJson(hosts = detectHostCapabilities()) {
219
+ return JSON.stringify(hosts, null, 2) + '\n';
220
+ }
221
+
218
222
  export function printHostCapabilities(hosts = detectHostCapabilities()) {
219
223
  console.log("Construct orchestration host capabilities:");
220
224
  for (const host of hosts) {
@@ -229,5 +233,10 @@ export function printHostCapabilities(hosts = detectHostCapabilities()) {
229
233
  }
230
234
 
231
235
  if (import.meta.url === `file://${process.argv[1]}`) {
232
- printHostCapabilities();
236
+ const args = new Set(process.argv.slice(2));
237
+ if (args.has('--json')) {
238
+ process.stdout.write(formatHostCapabilitiesJson());
239
+ } else {
240
+ printHostCapabilities();
241
+ }
233
242
  }