@geraldmaron/construct 1.0.11 → 1.0.14

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 (90) hide show
  1. package/README.md +55 -18
  2. package/bin/construct +259 -11
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/lib/auto-docs.mjs +23 -22
  5. package/lib/cli-commands.mjs +2 -0
  6. package/lib/dashboard-static.mjs +7 -4
  7. package/lib/doc-stamp.mjs +16 -0
  8. package/lib/docs-verify.mjs +1 -8
  9. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  10. package/lib/embed/daemon.mjs +20 -0
  11. package/lib/embed/docs-lifecycle.mjs +19 -0
  12. package/lib/embed/inbox.mjs +85 -2
  13. package/lib/embed/recommendation-store.mjs +29 -0
  14. package/lib/gates-audit.mjs +18 -12
  15. package/lib/hooks/_lib/input.mjs +52 -0
  16. package/lib/hooks/adaptive-lint.mjs +4 -0
  17. package/lib/hooks/agent-tracker.mjs +59 -15
  18. package/lib/hooks/audit-reads.mjs +83 -42
  19. package/lib/hooks/audit-trail.mjs +28 -18
  20. package/lib/hooks/bash-output-logger.mjs +8 -2
  21. package/lib/hooks/block-no-verify.mjs +4 -0
  22. package/lib/hooks/ci-status-check.mjs +4 -0
  23. package/lib/hooks/comment-lint.mjs +6 -4
  24. package/lib/hooks/config-protection.mjs +4 -0
  25. package/lib/hooks/context-watch.mjs +4 -0
  26. package/lib/hooks/context-window-recovery.mjs +4 -0
  27. package/lib/hooks/dep-audit.mjs +12 -5
  28. package/lib/hooks/doc-coupling-check.mjs +5 -1
  29. package/lib/hooks/edit-accumulator.mjs +25 -10
  30. package/lib/hooks/edit-error-recovery.mjs +4 -0
  31. package/lib/hooks/edit-guard.mjs +4 -0
  32. package/lib/hooks/guard-bash.mjs +4 -0
  33. package/lib/hooks/mcp-audit.mjs +4 -0
  34. package/lib/hooks/mcp-health-check.mjs +4 -0
  35. package/lib/hooks/model-fallback.mjs +7 -11
  36. package/lib/hooks/policy-engine.mjs +4 -0
  37. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  38. package/lib/hooks/post-merge-tracking.mjs +82 -0
  39. package/lib/hooks/pre-compact.mjs +4 -0
  40. package/lib/hooks/pre-push-gate.mjs +84 -231
  41. package/lib/hooks/proactive-activation.mjs +5 -2
  42. package/lib/hooks/readme-age-check.mjs +4 -0
  43. package/lib/hooks/registry-sync.mjs +35 -20
  44. package/lib/hooks/rule-verifier.mjs +3 -0
  45. package/lib/hooks/scan-secrets.mjs +4 -0
  46. package/lib/hooks/session-optimize.mjs +4 -0
  47. package/lib/hooks/session-reflect.mjs +4 -0
  48. package/lib/hooks/session-start.mjs +48 -1
  49. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  50. package/lib/hooks/stop-notify.mjs +13 -2
  51. package/lib/hooks/stop-typecheck.mjs +4 -0
  52. package/lib/hooks/test-watch.mjs +8 -4
  53. package/lib/init-unified.mjs +90 -23
  54. package/lib/intake/attribution.mjs +77 -0
  55. package/lib/intake/intake-config.mjs +3 -0
  56. package/lib/intake/manifest.mjs +107 -0
  57. package/lib/intake/poll-lock.mjs +136 -0
  58. package/lib/intake/prepare.mjs +42 -4
  59. package/lib/logging/rotate.mjs +394 -0
  60. package/lib/mcp/tools/project.mjs +2 -2
  61. package/lib/mcp/tools/telemetry.mjs +1 -1
  62. package/lib/opencode-config.mjs +10 -3
  63. package/lib/orchestration/routing-tables.mjs +176 -0
  64. package/lib/orchestration-policy.mjs +18 -106
  65. package/lib/parity.mjs +48 -7
  66. package/lib/profiles/lifecycle.mjs +19 -1
  67. package/lib/project-init-shared.mjs +11 -5
  68. package/lib/project-root.mjs +104 -0
  69. package/lib/roles/catalog.mjs +1 -1
  70. package/lib/roles/event-bus.mjs +29 -9
  71. package/lib/roles/router.mjs +8 -7
  72. package/lib/server/index.mjs +31 -9
  73. package/lib/server/static/index.html +1 -15
  74. package/lib/specialist-contracts-enforce.mjs +24 -10
  75. package/lib/status.mjs +1 -1
  76. package/lib/storage/backup.mjs +2 -2
  77. package/lib/sync/skill-frontmatter.mjs +71 -0
  78. package/lib/telemetry/intent-verifications.mjs +16 -3
  79. package/lib/telemetry/skill-calls.mjs +12 -3
  80. package/lib/tracking-surfaces.mjs +375 -0
  81. package/lib/worker/trace.mjs +19 -2
  82. package/package.json +6 -2
  83. package/platforms/claude/settings.template.json +28 -27
  84. package/scripts/sync-specialists.mjs +296 -94
  85. package/specialists/registry.json +158 -13
  86. package/lib/hooks/env-check.mjs +0 -83
  87. package/lib/hooks/read-tracker.mjs +0 -61
  88. package/lib/policy/unified-gates.mjs +0 -96
  89. package/lib/server/static/assets/index-ab25c707.js +0 -70
  90. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * lib/dashboard-static.mjs — build and sync the dashboard static bundle.
3
3
  *
4
- * Keeps `dashboard/dist/` and `lib/server/static/` aligned so the HTTP server
5
- * serves the current dashboard build from repo-owned assets.
4
+ * Keeps the Next.js static export at `apps/dashboard/out/` and the served
5
+ * tree at `lib/server/static/` aligned so the HTTP server picks up the
6
+ * current dashboard build without code changes. Pre-2.0 the dashboard
7
+ * lived at `dashboard/dist/`; the rebuild to Next.js (commit 84cd721)
8
+ * moved the source of truth into `apps/dashboard/out/`.
6
9
  */
7
10
 
8
11
  import fs from 'node:fs';
@@ -78,8 +81,8 @@ function removeEmptyDirs(dirPath, stopAt) {
78
81
  export function getDashboardPaths({ rootDir = process.cwd() } = {}) {
79
82
  return {
80
83
  rootDir,
81
- dashboardDir: path.join(rootDir, 'dashboard'),
82
- sourceDir: path.join(rootDir, 'dashboard', 'dist'),
84
+ dashboardDir: path.join(rootDir, 'apps', 'dashboard'),
85
+ sourceDir: path.join(rootDir, 'apps', 'dashboard', 'out'),
83
86
  targetDir: path.join(rootDir, 'lib', 'server', 'static'),
84
87
  };
85
88
  }
package/lib/doc-stamp.mjs CHANGED
@@ -102,12 +102,17 @@ function splitStamp(content) {
102
102
  * sessionId {string} - Construct session ID if available
103
103
  * model {string} - Model name/version if available
104
104
  * preserve_id {boolean} - Keep existing cx_doc_id if present (default: true)
105
+ * attribution {object} - Optional { createdBy, createdByAgent } pair. When
106
+ * provided, written into the stamp and (on stamp
107
+ * refresh) carried forward via existing_fields so a
108
+ * later write does not erase the original author.
105
109
  */
106
110
  export function stampFrontmatter(content, {
107
111
  generator = 'construct',
108
112
  sessionId = null,
109
113
  model = null,
110
114
  preserve_id = true,
115
+ attribution = null,
111
116
  } = {}) {
112
117
  const existing = hasStamp(content) ? splitStamp(content) : null;
113
118
  const existingFields = existing ? parseStamp(content) : {};
@@ -116,6 +121,11 @@ export function stampFrontmatter(content, {
116
121
  const createdAt = existingFields.created_at || new Date().toISOString();
117
122
  const updatedAt = new Date().toISOString();
118
123
 
124
+ const createdBy = existingFields.created_by ?? attribution?.createdBy ?? null;
125
+ const createdByAgent = existingFields.created_by_agent ?? attribution?.createdByAgent ?? null;
126
+ const lastModifiedBy = attribution?.createdBy ?? existingFields.last_modified_by ?? null;
127
+ const lastModifiedByAgent = attribution?.createdByAgent ?? existingFields.last_modified_by_agent ?? null;
128
+
119
129
  // Body is everything after stamp (or all content if no prior stamp)
120
130
  const body = existing ? existing.body : content;
121
131
  const hash = bodyHash(body);
@@ -127,6 +137,12 @@ export function stampFrontmatter(content, {
127
137
  `updated_at: ${updatedAt}`,
128
138
  `generator: ${generator}`,
129
139
  ];
140
+ if (createdBy) lines.push(`created_by: ${createdBy}`);
141
+ if (createdByAgent) lines.push(`created_by_agent: ${createdByAgent}`);
142
+ if (lastModifiedBy && lastModifiedBy !== createdBy) lines.push(`last_modified_by: ${lastModifiedBy}`);
143
+ if (lastModifiedByAgent && lastModifiedByAgent !== createdByAgent) {
144
+ lines.push(`last_modified_by_agent: ${lastModifiedByAgent}`);
145
+ }
130
146
  if (model) lines.push(`model: ${model}`);
131
147
  if (sessionId) lines.push(`session_id: ${sessionId}`);
132
148
  lines.push(`body_hash: ${hash}`);
@@ -52,18 +52,11 @@ if (stagedMode) {
52
52
  if (changedCode.length === 0) process.exit(0);
53
53
  if (changedDocs.length > 0) process.exit(0);
54
54
 
55
- if (process.env.CONSTRUCT_SKIP_DOCS === "1") {
56
- process.stderr.write(
57
- `[docs:verify --staged] CONSTRUCT_SKIP_DOCS=1 — skipping doc-coupling check for ${changedCode.length} code file(s).\n`,
58
- );
59
- process.exit(0);
60
- }
61
-
62
55
  const sample = changedCode.slice(0, 6).map((f) => ` - ${f}`).join("\n");
63
56
  process.stderr.write(
64
57
  `[docs:verify --staged] ${changedCode.length} code file(s) staged but no CHANGELOG.md / docs/* / .cx/context.* updates.\n` +
65
58
  `${sample}${changedCode.length > 6 ? `\n ... (${changedCode.length - 6} more)` : ""}\n` +
66
- `Update docs in this commit, or override with CONSTRUCT_SKIP_DOCS=1.\n`,
59
+ `Update docs in this commit before pushing.\n`,
67
60
  );
68
61
  process.exit(1);
69
62
  }
@@ -12,8 +12,10 @@
12
12
  * as a label, regardless of how it got there (manual, ai, automated).
13
13
  */
14
14
 
15
- import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
16
  import { spawnSync } from 'node:child_process';
17
+ import { appendBounded } from '../../logging/rotate.mjs';
18
+ import { resolveProjectScope } from '../../project-root.mjs';
17
19
  import { homedir } from 'node:os';
18
20
  import { join } from 'node:path';
19
21
 
@@ -86,6 +88,7 @@ export async function tick() {
86
88
  if (seen[key]) continue;
87
89
  seen[key] = { ts: Date.now(), id: issue.id };
88
90
  const manifest = loadManifest(personaId);
91
+ const scope = resolveProjectScope();
89
92
  const entry = {
90
93
  ts: Date.now(),
91
94
  personaId,
@@ -96,8 +99,9 @@ export async function tick() {
96
99
  summary: `bd ${issue.id} labeled next:cx-${personaId} — ${(issue.title || '').slice(0, 120)}`,
97
100
  killSwitchEnv: manifest?.killSwitchEnv || '',
98
101
  source: 'bd-watch',
102
+ ...(scope?.projectId ? { projectId: scope.projectId } : {}),
99
103
  };
100
- appendFileSync(PENDING_PATH, JSON.stringify(entry) + '\n');
104
+ appendBounded('role-pending', PENDING_PATH, JSON.stringify(entry) + '\n');
101
105
  record({
102
106
  kind: 'action',
103
107
  watcher: name,
@@ -30,6 +30,7 @@ import { dirname, join } from 'node:path';
30
30
 
31
31
  import { loadEmbedConfig } from './config.mjs';
32
32
  import { Scheduler } from './scheduler.mjs';
33
+ import { rotateIfOversized } from '../logging/rotate.mjs';
33
34
  import { SnapshotEngine, renderMarkdown } from './snapshot.mjs';
34
35
  import { ApprovalQueue } from './approval-queue.mjs';
35
36
  import { dispatchOutputs } from './output.mjs';
@@ -594,6 +595,25 @@ export class EmbedDaemon {
594
595
  },
595
596
  );
596
597
 
598
+ // ── Job 6: daemon-log-rotation ────────────────────────────────────────────
599
+ // OS supervisors (launchd/systemd/schtasks) redirect daemon stdout to
600
+ // ~/.cx/runtime/embed-daemon.log with no rotation. A stuck telemetry
601
+ // log line filled it to 34 GB on one local install; rotate proactively.
602
+ // CONSTRUCT_EMBED_LOG_MAX_MB overrides the default 50 MB cap.
603
+
604
+ this.#scheduler.register(
605
+ 'daemon-log-rotation',
606
+ 60_000,
607
+ async () => {
608
+ const logPath = join(homedir(), '.cx', 'runtime', 'embed-daemon.log');
609
+ const capMb = Number(process.env.CONSTRUCT_EMBED_LOG_MAX_MB) || 50;
610
+ const maxBytes = Math.floor(capMb * 1024 * 1024);
611
+ const maxSegments = Number(process.env.CONSTRUCT_EMBED_LOG_MAX_SEGMENTS) || 5;
612
+ await rotateIfOversized(logPath, { maxBytes, maxSegments, gzip: true });
613
+ },
614
+ { runImmediately: true },
615
+ );
616
+
597
617
  // ── Job 0: telemetry-setup ───────────────────────────────────────────────────
598
618
  // On startup — ensure annotation queues and eval configs exist (idempotent)
599
619
  this.#scheduler.register(
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Responsibilities:
5
5
  * - Detect stale, missing, or outdated docs across targets
6
+ * - Emit document.stale lifecycle events for each stale gap surfaced
7
+ * (consumed by cx-docs-keeper and any project-bound subscribers)
6
8
  * - Generate/update docs as artifacts in the appropriate docs lane
7
9
  * - Route writes through the authority guard (direct-write for low-risk, approval-queued for high-risk)
8
10
  * - Treat ALL doc types uniformly: adrs, prds, memos, notes, intake, roadmaps
@@ -23,6 +25,7 @@ import { WORKSPACE_DOCS_LANES } from './config.mjs';
23
25
  import { recommendArtifacts } from './artifact.mjs';
24
26
  import { createRecommendation, autoSuppressStale, recommendationStats, isRecommendationActive } from './recommendation-store.mjs';
25
27
  import { detectConflicts } from './conflict-detection.mjs';
28
+ import { emitBestEffort as emitRoleEvent } from '../roles/event-bus.mjs';
26
29
 
27
30
  /**
28
31
  * Risk classification for doc operations.
@@ -232,6 +235,22 @@ export async function runDocsLifecycle(opts) {
232
235
  const gaps = detectDocGaps(target, { snapshot, roleLens });
233
236
  allGaps.push(...gaps.map((g) => ({ ...g, target: targetLabel(target) })));
234
237
 
238
+ // Surface staleness as lifecycle events for any specialist subscribed
239
+ // to document.stale (cx-docs-keeper by default).
240
+
241
+ for (const gap of gaps) {
242
+ if (gap.type !== 'stale') continue;
243
+ emitRoleEvent('document.stale', {
244
+ summary: gap.summary,
245
+ context: {
246
+ lane: gap.lane,
247
+ file: gap.file,
248
+ ageMs: gap.age,
249
+ target: targetLabel(target),
250
+ },
251
+ });
252
+ }
253
+
235
254
  // Check intake patterns for artifact recommendations (uses recommendation store for dedup)
236
255
  const intakeGaps = detectIntakeDocGaps(target.path);
237
256
  for (const gap of intakeGaps) {
@@ -33,10 +33,14 @@ import { shouldCreateCx } from '../project-detection.mjs';
33
33
  import { ensureCxDir } from '../project-init-shared.mjs';
34
34
  import { prepareIntakeForIngestedFile } from '../intake/prepare.mjs';
35
35
  import { loadIntakeConfig, INTAKE_DEFAULT_MAX_DEPTH } from '../intake/intake-config.mjs';
36
+ import { loadManifest, recordFile, hasFile, sha256Of, MANIFEST_REL_PATH } from '../intake/manifest.mjs';
37
+ import { gatherAttribution } from '../intake/attribution.mjs';
38
+ import { acquirePollLock, releasePollLock } from '../intake/poll-lock.mjs';
36
39
 
37
40
  const STATE_FILE = '.cx/runtime/inbox-state.json';
38
41
  const DEFAULT_INBOX_SUBDIR = '.cx/inbox';
39
42
  const DOCS_INTAKE_SUBDIR = 'docs/intake';
43
+ const ARCHETYPE_INBOX_SUBDIR = 'inbox';
40
44
  const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB hard cap
41
45
  const STALE_STATE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
42
46
 
@@ -155,6 +159,16 @@ export function resolveInboxDirs(rootDir, env = process.env) {
155
159
  if (existsSync(docsIntake)) dirs.push(docsIntake);
156
160
  }
157
161
 
162
+ // Archetype inbox is a profile-driven drop zone for raw source files
163
+ // (capabilities.intake.inbox). Off by default; init flips it on when the
164
+ // active profile declares the capability so non-archetype projects keep
165
+ // their pre-Piece-C behavior.
166
+
167
+ if (config.includeArchetypeInbox) {
168
+ const archetypeInbox = join(rootDir, ARCHETYPE_INBOX_SUBDIR);
169
+ if (existsSync(archetypeInbox)) dirs.push(archetypeInbox);
170
+ }
171
+
158
172
  for (const candidate of config.parentDirs) {
159
173
  if (!candidate) continue;
160
174
  if (!existsSync(candidate)) continue;
@@ -298,8 +312,31 @@ export class InboxWatcher {
298
312
  /**
299
313
  * Scan all inbox dirs, ingest new files, record observations.
300
314
  * Returns { processed: [...], skipped: number, errors: [...] }
315
+ *
316
+ * @param {object} [opts]
317
+ * @param {number} [opts.waitMs] - When >0, block up to this many ms for
318
+ * the intake poll lock. Default 0 (fail-fast). The CLI sets this when
319
+ * the operator opts into waiting; the daemon's tick keeps the default
320
+ * so a stuck poll does not stall the next scheduled iteration.
321
+ * @param {string} [opts.actor] - Stamped onto the lock for diagnostics.
301
322
  */
302
- async poll() {
323
+ async poll({ waitMs = 0, actor = 'inbox-watcher' } = {}) {
324
+ let lockHeld = false;
325
+ try {
326
+ await acquirePollLock({
327
+ rootDir: this.#rootDir,
328
+ actor,
329
+ command: 'InboxWatcher.poll',
330
+ waitMs,
331
+ });
332
+ lockHeld = true;
333
+ return await this.#pollLocked();
334
+ } finally {
335
+ if (lockHeld) releasePollLock(this.#rootDir);
336
+ }
337
+ }
338
+
339
+ async #pollLocked() {
303
340
  const config = loadIntakeConfig(this.#rootDir, this.#env);
304
341
  const dirs = resolveInboxDirs(this.#rootDir, this.#env);
305
342
  const state = readState(this.#rootDir);
@@ -307,6 +344,15 @@ export class InboxWatcher {
307
344
  const errors = [];
308
345
  let skipped = 0;
309
346
 
347
+ // Capability detection: SHA-256 dedup is enabled when the project's
348
+ // intake manifest exists (init scaffolds it for the archetype). Hashing
349
+ // is only paid when the manifest is in use, so non-archetype projects
350
+ // keep the original fast path.
351
+
352
+ const manifestPath = join(this.#rootDir, MANIFEST_REL_PATH);
353
+ const manifestEnabled = existsSync(manifestPath);
354
+ const attribution = manifestEnabled ? gatherAttribution({ cwd: this.#rootDir }) : null;
355
+
310
356
  for (const dir of dirs) {
311
357
  const candidates = scanDir(dir, config.maxDepth);
312
358
  for (const candidate of candidates) {
@@ -319,6 +365,27 @@ export class InboxWatcher {
319
365
  continue;
320
366
  }
321
367
 
368
+ // Manifest dedup: a file with identical content under any path is
369
+ // a duplicate. Catches renames, copies, and re-drops the state
370
+ // check misses. Skipped here without consuming the ingest budget.
371
+
372
+ let candidateSha = null;
373
+ if (manifestEnabled) {
374
+ try {
375
+ const bytes = readFileSync(candidate.path);
376
+ candidateSha = sha256Of(bytes);
377
+ if (hasFile(this.#rootDir, candidateSha)) {
378
+ skipped += 1;
379
+ state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), dedupHash: candidateSha };
380
+ continue;
381
+ }
382
+ } catch (err) {
383
+ if (this.#env.CONSTRUCT_DEBUG_INTAKE === '1') {
384
+ process.stderr.write(`[inbox] sha256 hashing failed for ${candidate.path}: ${err.message}\n`);
385
+ }
386
+ }
387
+ }
388
+
322
389
  try {
323
390
  const { category, knowledgeSubdir } = inferFileClassification(candidate.path);
324
391
  const result = await ingestDocuments([candidate.path], {
@@ -381,7 +448,23 @@ export class InboxWatcher {
381
448
  }
382
449
 
383
450
  state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), outputPath: fileResult.outputPath };
384
- processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId });
451
+
452
+ if (manifestEnabled && candidateSha) {
453
+ try {
454
+ recordFile(this.#rootDir, candidateSha, {
455
+ sourcePath: candidate.path,
456
+ intakeId,
457
+ createdBy: attribution?.createdBy ?? null,
458
+ createdByAgent: attribution?.createdByAgent ?? null,
459
+ });
460
+ } catch (err) {
461
+ if (this.#env.CONSTRUCT_DEBUG_INTAKE === '1') {
462
+ process.stderr.write(`[inbox] manifest record failed for ${candidate.path}: ${err.message}\n`);
463
+ }
464
+ }
465
+ }
466
+
467
+ processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId, dedupHash: candidateSha });
385
468
  }
386
469
  } catch (err) {
387
470
  // Audio/video files cannot be text-extracted without ASR. Route to
@@ -18,11 +18,20 @@
18
18
  * Prioritization formula:
19
19
  * score = (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus)
20
20
  * P0: score >= 10, P1: >= 7, P2: >= 4, P3: < 4
21
+ *
22
+ * Enrichment state machine: every new record starts with state: 'raw'. The
23
+ * enrichment loop (subscriber to recommendation.generated, lifecycle event
24
+ * emitted from createRecommendation on the new-record path only) transitions
25
+ * raw → enriching → enriched | enrichment_failed and writes enrichedAt /
26
+ * enrichedBy / framingNotes back to the index. Dedup-update path does not
27
+ * re-emit so a steady drip of signals against the same recommendation does
28
+ * not retrigger enrichment.
21
29
  */
22
30
 
23
31
  import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
24
32
  import { join } from 'node:path';
25
33
  import { randomUUID } from 'node:crypto';
34
+ import { emitBestEffort } from '../roles/event-bus.mjs';
26
35
  import { cxDir } from '../paths.mjs';
27
36
  import { hasSqlStore } from '../storage/sql-store.mjs';
28
37
  import { createSqlClient } from '../storage/backend.mjs';
@@ -161,6 +170,9 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
161
170
  supersededAt: null,
162
171
  supersededById: null,
163
172
  suppressedUntil: null,
173
+ state: 'raw',
174
+ enrichedAt: null,
175
+ enrichedBy: null,
164
176
  };
165
177
 
166
178
  // Append to log
@@ -171,6 +183,23 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
171
183
  writeIndex(index);
172
184
 
173
185
  createRecommendationPgBestEffort(rec, project, env);
186
+
187
+ // Surfaces the new recommendation as a lifecycle event so the enrichment
188
+ // loop (and any project-bound subscribers in .cx/specialists/) can route
189
+ // the matched specialist's contract chain against it.
190
+
191
+ emitBestEffort('recommendation.generated', {
192
+ project: project || '',
193
+ summary: `${rec.type}: ${rec.title}`,
194
+ context: {
195
+ recommendationId: rec.id,
196
+ dedupKey: key,
197
+ priority: rec.priority,
198
+ score: rec.score,
199
+ artifactType: rec.type,
200
+ },
201
+ });
202
+
174
203
  return { id, dedupKey: key, priority: rec.priority, score, existing: false };
175
204
  }
176
205
 
@@ -41,18 +41,24 @@ const DEFAULT_ROOT_DIR = join(MODULE_DIR, '..');
41
41
  const MERGE_AGGREGATOR = 'ci-required';
42
42
 
43
43
  const GATE_DEFINITIONS = [
44
- { ciJob: 'test (ubuntu-latest / node 22)', prePushLabel: 'tests', critical: true, requireMergeVia: MERGE_AGGREGATOR },
45
- { ciJob: 'test (macos-latest / node 22)', prePushLabel: 'tests', critical: true, requireMergeVia: MERGE_AGGREGATOR },
46
- { ciJob: 'test (ubuntu-latest / node 20)', prePushLabel: 'tests', critical: true, requireMergeVia: MERGE_AGGREGATOR },
47
- { ciJob: 'test (macos-latest / node 20)', prePushLabel: 'tests', critical: true, requireMergeVia: MERGE_AGGREGATOR },
48
- { ciJob: 'retrieval evals', prePushLabel: 'evals', critical: true, requireMergeVia: MERGE_AGGREGATOR },
49
- { ciJob: 'dependency CVE audit', prePushLabel: 'audit', critical: true, requireMergeVia: MERGE_AGGREGATOR },
50
- { ciJob: 'secret scanning', prePushLabel: null, preCommitCheck: 'ECC secret scan', critical: true, note: 'pre-commit ECC scan covers a subset of gitleaks rules' },
51
- { ciJob: 'postgres + pgvector integration', prePushLabel: null, preCommitCheck: null, critical: false, note: 'CI-only: requires Docker Postgres + pgvector container; not practical locally', localMirror: 'ci-only', requireMergeVia: MERGE_AGGREGATOR },
52
- { ciJob: 'docs drift check', prePushLabel: 'docs', critical: true, requireMergeVia: MERGE_AGGREGATOR },
53
- { ciJob: 'comment policy', prePushLabel: null, preCommitCheck: 'Construct comment-lint', critical: true, note: 'pre-commit Construct policy section calls `lint:comments` across the full worktree', requireMergeVia: MERGE_AGGREGATOR },
54
- { ciJob: 'template policy', prePushLabel: null, preCommitGhPrIntercept: true, critical: true, note: 'pre-push-gate intercepts `gh pr create` / `gh pr edit` and lints the body', requireMergeVia: MERGE_AGGREGATOR },
55
- { ciJob: MERGE_AGGREGATOR, prePushLabel: null, preCommitCheck: null, critical: true, note: 'aggregator: succeeds iff every wrapped conditional job in ci.yml ended in success or skipped', localMirror: 'ci-only' },
44
+ // Test, build, audit, evals, docs-drift, comment-policy, prose, profiles
45
+ // all CI-only. The local pre-push gate was shrunk to claude/* refusal +
46
+ // SHA-aware red-CI re-push check + PR body lint; CI is the source of
47
+ // truth for everything else (see CHANGELOG entry "Pre-push hooks shrunk
48
+ // to local-only signals; CI is the source of truth" and the inverted
49
+ // contract enforced by tests/ci-parity.test.mjs).
50
+ { ciJob: 'test (ubuntu-latest / node 22)', prePushLabel: null, critical: true, requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
51
+ { ciJob: 'test (macos-latest / node 22)', prePushLabel: null, critical: true, requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
52
+ { ciJob: 'test (ubuntu-latest / node 20)', prePushLabel: null, critical: true, requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
53
+ { ciJob: 'test (macos-latest / node 20)', prePushLabel: null, critical: true, requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
54
+ { ciJob: 'retrieval evals', prePushLabel: null, critical: true, requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
55
+ { ciJob: 'dependency CVE audit', prePushLabel: null, critical: true, requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
56
+ { ciJob: 'secret scanning', prePushLabel: null, preCommitCheck: 'ECC secret scan', critical: true, note: 'pre-commit ECC scan covers a subset of gitleaks rules' },
57
+ { ciJob: 'postgres + pgvector integration', prePushLabel: null, preCommitCheck: null, critical: false, note: 'CI-only: requires Docker Postgres + pgvector container; not practical locally', localMirror: 'ci-only', requireMergeVia: MERGE_AGGREGATOR },
58
+ { ciJob: 'docs drift check', prePushLabel: null, critical: true, requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
59
+ { ciJob: 'comment policy', prePushLabel: null, preCommitCheck: 'Construct comment-lint', critical: true, note: 'pre-commit Construct policy section calls `lint:comments` across the full worktree', requireMergeVia: MERGE_AGGREGATOR },
60
+ { ciJob: 'template policy', prePushLabel: null, preCommitGhPrIntercept: true, critical: true, note: 'pre-push-gate intercepts `gh pr create` / `gh pr edit` and lints the body', requireMergeVia: MERGE_AGGREGATOR },
61
+ { ciJob: MERGE_AGGREGATOR, prePushLabel: null, preCommitCheck: null, critical: true, note: 'aggregator: succeeds iff every wrapped conditional job in ci.yml ended in success or skipped', localMirror: 'ci-only' },
56
62
  ];
57
63
 
58
64
  function parseCIJobs(rootDir) {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * lib/hooks/_lib/input.mjs — shared hook input parsing.
3
+ *
4
+ * Claude Code passes hook input as JSON on stdin. Codex and OpenCode (when
5
+ * they ship hook support) pass via TOOL_INPUT_FILE_PATH env or argv. This
6
+ * helper normalizes all three so a hook reads one shape regardless of the
7
+ * host transport. Hooks should call `readHookInput()` once at startup and
8
+ * branch on `input.tool_name` from there.
9
+ *
10
+ * Returns an object with at minimum `tool_name` and `tool_input` fields,
11
+ * plus any fields present in the source payload. Empty object on parse
12
+ * failure (the hook is then free to exit 0 silently if no useful input
13
+ * landed — matches the existing convention).
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs';
17
+
18
+ export function readHookInput({ env = process.env, argv = process.argv } = {}) {
19
+ const stdin = tryReadStdin();
20
+ if (stdin) {
21
+ const parsed = tryParseJson(stdin);
22
+ if (parsed && typeof parsed === 'object') return parsed;
23
+ }
24
+
25
+ const envPath = env.TOOL_INPUT_FILE_PATH;
26
+ if (envPath) {
27
+ try {
28
+ const raw = readFileSync(envPath, 'utf8');
29
+ const parsed = tryParseJson(raw);
30
+ if (parsed && typeof parsed === 'object') return parsed;
31
+ return { tool_input: { file_path: envPath } };
32
+ } catch { /* fall through */ }
33
+ }
34
+
35
+ const argvJsonPos = argv.findIndex((a) => a === '--input-json');
36
+ if (argvJsonPos >= 0 && argv[argvJsonPos + 1]) {
37
+ const parsed = tryParseJson(argv[argvJsonPos + 1]);
38
+ if (parsed && typeof parsed === 'object') return parsed;
39
+ }
40
+
41
+ return {};
42
+ }
43
+
44
+ function tryReadStdin() {
45
+ try { return readFileSync(0, 'utf8'); }
46
+ catch { return null; }
47
+ }
48
+
49
+ function tryParseJson(s) {
50
+ try { return JSON.parse(s); }
51
+ catch { return null; }
52
+ }
@@ -8,6 +8,10 @@
8
8
  *
9
9
  * @p95ms 800
10
10
  * @maxBlockingScope none (PostToolUse, non-blocking)
11
+ *
12
+ * @lifecycle PostToolUse
13
+ * @matcher Write|Edit
14
+ * @exits 0 = pass
11
15
  */
12
16
  import { execSync } from 'child_process';
13
17
  import { existsSync, readFileSync } from 'fs';
@@ -1,16 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * lib/hooks/agent-tracker.mjs — Agent task lifecycle hook tracks task start, completion, and handoffs.
3
+ * lib/hooks/agent-tracker.mjs — Task tool lifecycle hook: records dispatch +
4
+ * outcome and enqueues `next:cx-<role>` handoffs.
4
5
  *
5
- * Runs as PostToolUse after Agent tool calls. Records agent invocations and their outcomes to ~/.cx/agent-log.json for telemetry and performance review.
6
- * Also records observations for learning system to identify patterns/anti-patterns.
6
+ * Runs as PostToolUse after Task. Writes the agent-log to the project-scoped
7
+ * .cx/agent-log.jsonl, captures success/failure observations under
8
+ * .cx/observations/, and enqueues handoffs into ~/.cx/role-pending.jsonl
9
+ * (kept user-scope because a handoff may legitimately span projects; each
10
+ * entry carries a projectId tag for attribution).
11
+ *
12
+ * Handoffs also emit a `handoff.received` lifecycle event via the role
13
+ * event-bus for observability. The direct role-pending write stays the
14
+ * source of truth until the gateway becomes an event-bus consumer; the
15
+ * emit is the bridge that makes the dispatch observable through the same
16
+ * surface as every other lifecycle event.
7
17
  *
8
18
  * @p95ms 10
9
19
  * @maxBlockingScope none (PostToolUse, non-blocking)
20
+ *
21
+ * @lifecycle PostToolUse
22
+ * @matcher Task
23
+ * @exits 0 = pass
10
24
  */
11
- import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from 'fs';
12
- import { join } from 'path';
13
- import { homedir } from 'os';
25
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { homedir } from 'node:os';
28
+ import { appendBounded } from '../logging/rotate.mjs';
29
+ import { resolveProjectScopedPath } from '../project-root.mjs';
30
+ import { emit as emitRoleEvent } from '../roles/event-bus.mjs';
31
+ import { logHookFailure } from './_lib/log.mjs';
14
32
 
15
33
  let input = {};
16
34
  try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
@@ -67,8 +85,11 @@ try {
67
85
  const safeName = agentName.replace(/[^a-z0-9._-]/gi, '_');
68
86
  writeFileSync(join(cxDir, `last-agent-${safeName}.json`), JSON.stringify(agentEntry));
69
87
 
70
- // Append to agent log for telemetry
71
- const agentLogFile = join(cxDir, 'agent-log.jsonl');
88
+ // Per-project agent log (was ~/.cx/agent-log.jsonl which mixed every
89
+ // project's dispatches into one stream). Falls back to ~/.cx/ when the
90
+ // hook fires outside a Construct project.
91
+
92
+ const agentLogFile = resolveProjectScopedPath('agent-log.jsonl', { ensureDir: false });
72
93
  const logEntry = {
73
94
  timestamp: new Date().toISOString(),
74
95
  agent: agentName,
@@ -79,7 +100,7 @@ try {
79
100
  toolInputKeys: Object.keys(toolInput).filter(k => !k.includes('secret') && !k.includes('password') && !k.includes('token'))
80
101
  };
81
102
 
82
- appendFileSync(agentLogFile, JSON.stringify(logEntry) + '\n');
103
+ appendBounded('agent-log', agentLogFile, JSON.stringify(logEntry) + '\n');
83
104
 
84
105
  // Record observation for pattern learning (only if we have meaningful outcome)
85
106
  if (outcome !== 'unknown' && description.length > 10) {
@@ -150,9 +171,8 @@ try {
150
171
  }
151
172
  }
152
173
 
153
- } catch (error) {
154
- // Non-critical - best effort tracking
155
- console.error(`Agent tracker error: ${error.message}`);
174
+ } catch (err) {
175
+ logHookFailure({ hook: 'agent-tracker', err, phase: 'main' });
156
176
  }
157
177
 
158
178
  // Auto-enqueue handoff when a completing persona's result references
@@ -165,12 +185,18 @@ try {
165
185
  const unique = [...new Set(matches)];
166
186
  if (unique.length > 0) {
167
187
  const { isOnboarded, loadManifest } = await import('../roles/manifest.mjs');
188
+ // role-pending stays at user scope (a handoff might span projects),
189
+ // but each entry gets a projectId tag so a reader can attribute it.
190
+
168
191
  const pendingPath = join(homedir(), '.cx', 'role-pending.jsonl');
192
+ const { resolveProjectScope } = await import('../project-root.mjs');
193
+ const scope = resolveProjectScope();
169
194
  const bdMatch = /\b(construct-[a-z0-9]+)\b/i.exec(resultText) || [];
170
195
  const bdIssueId = bdMatch[1] || null;
171
196
  for (const targetId of unique) {
172
197
  if (!isOnboarded(targetId)) continue;
173
198
  const manifest = loadManifest(targetId);
199
+ const summary = `Handoff from ${agentName}${bdIssueId ? ` (re ${bdIssueId})` : ''}`;
174
200
  const entry = {
175
201
  ts: Date.now(),
176
202
  personaId: targetId,
@@ -178,15 +204,33 @@ try {
178
204
  bdIssueId,
179
205
  fingerprint: `handoff-${agentName}-${targetId}-${Date.now().toString(36)}`,
180
206
  eventType: 'handoff.received',
181
- summary: `Handoff from ${agentName}${bdIssueId ? ` (re ${bdIssueId})` : ''}`,
207
+ summary,
182
208
  killSwitchEnv: manifest?.killSwitchEnv || '',
183
209
  handoffFrom: agentName,
210
+ ...(scope?.projectId ? { projectId: scope.projectId } : {}),
184
211
  };
185
- appendFileSync(pendingPath, JSON.stringify(entry) + '\n');
212
+ appendBounded('role-pending', pendingPath, JSON.stringify(entry) + '\n');
213
+
214
+ // Mirror the handoff to the event bus so the dispatch is observable
215
+ // alongside every other lifecycle event. Best-effort: a bus failure
216
+ // does not affect the role-pending source of truth above.
217
+ try {
218
+ emitRoleEvent('handoff.received', {
219
+ project: scope?.projectId || '',
220
+ summary,
221
+ context: {
222
+ targetCxId: `cx-${targetId}`,
223
+ handoffFrom: agentName,
224
+ bdIssueId,
225
+ },
226
+ });
227
+ } catch { /* swallow */ }
186
228
  }
187
229
  }
188
230
  }
189
- } catch { /* best effort — handoff enqueue never blocks */ }
231
+ } catch (err) {
232
+ logHookFailure({ hook: 'agent-tracker', err, phase: 'handoff' });
233
+ }
190
234
 
191
235
  // Helper functions for learning system
192
236
  function extractKeywords(text) {