@geraldmaron/construct 1.0.12 → 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 (88) hide show
  1. package/README.md +8 -2
  2. package/bin/construct +259 -11
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/lib/auto-docs.mjs +11 -7
  5. package/lib/dashboard-static.mjs +7 -4
  6. package/lib/doc-stamp.mjs +16 -0
  7. package/lib/docs-verify.mjs +1 -8
  8. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  9. package/lib/embed/daemon.mjs +20 -0
  10. package/lib/embed/docs-lifecycle.mjs +19 -0
  11. package/lib/embed/inbox.mjs +85 -2
  12. package/lib/embed/recommendation-store.mjs +29 -0
  13. package/lib/gates-audit.mjs +18 -12
  14. package/lib/hooks/_lib/input.mjs +52 -0
  15. package/lib/hooks/adaptive-lint.mjs +4 -0
  16. package/lib/hooks/agent-tracker.mjs +59 -15
  17. package/lib/hooks/audit-reads.mjs +83 -42
  18. package/lib/hooks/audit-trail.mjs +28 -18
  19. package/lib/hooks/bash-output-logger.mjs +8 -2
  20. package/lib/hooks/block-no-verify.mjs +4 -0
  21. package/lib/hooks/ci-status-check.mjs +4 -0
  22. package/lib/hooks/comment-lint.mjs +6 -4
  23. package/lib/hooks/config-protection.mjs +4 -0
  24. package/lib/hooks/context-watch.mjs +4 -0
  25. package/lib/hooks/context-window-recovery.mjs +4 -0
  26. package/lib/hooks/dep-audit.mjs +12 -5
  27. package/lib/hooks/doc-coupling-check.mjs +5 -1
  28. package/lib/hooks/edit-accumulator.mjs +25 -10
  29. package/lib/hooks/edit-error-recovery.mjs +4 -0
  30. package/lib/hooks/edit-guard.mjs +4 -0
  31. package/lib/hooks/guard-bash.mjs +4 -0
  32. package/lib/hooks/mcp-audit.mjs +4 -0
  33. package/lib/hooks/mcp-health-check.mjs +4 -0
  34. package/lib/hooks/model-fallback.mjs +7 -11
  35. package/lib/hooks/policy-engine.mjs +4 -0
  36. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  37. package/lib/hooks/post-merge-tracking.mjs +82 -0
  38. package/lib/hooks/pre-compact.mjs +4 -0
  39. package/lib/hooks/pre-push-gate.mjs +84 -231
  40. package/lib/hooks/proactive-activation.mjs +5 -2
  41. package/lib/hooks/readme-age-check.mjs +4 -0
  42. package/lib/hooks/registry-sync.mjs +35 -20
  43. package/lib/hooks/rule-verifier.mjs +3 -0
  44. package/lib/hooks/scan-secrets.mjs +4 -0
  45. package/lib/hooks/session-optimize.mjs +4 -0
  46. package/lib/hooks/session-reflect.mjs +4 -0
  47. package/lib/hooks/session-start.mjs +48 -1
  48. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  49. package/lib/hooks/stop-notify.mjs +13 -2
  50. package/lib/hooks/stop-typecheck.mjs +4 -0
  51. package/lib/hooks/test-watch.mjs +8 -4
  52. package/lib/init-unified.mjs +90 -23
  53. package/lib/intake/attribution.mjs +77 -0
  54. package/lib/intake/intake-config.mjs +3 -0
  55. package/lib/intake/manifest.mjs +107 -0
  56. package/lib/intake/poll-lock.mjs +136 -0
  57. package/lib/intake/prepare.mjs +42 -4
  58. package/lib/logging/rotate.mjs +394 -0
  59. package/lib/mcp/tools/project.mjs +2 -2
  60. package/lib/mcp/tools/telemetry.mjs +1 -1
  61. package/lib/opencode-config.mjs +10 -3
  62. package/lib/orchestration/routing-tables.mjs +176 -0
  63. package/lib/orchestration-policy.mjs +18 -106
  64. package/lib/parity.mjs +48 -7
  65. package/lib/profiles/lifecycle.mjs +19 -1
  66. package/lib/project-init-shared.mjs +11 -5
  67. package/lib/project-root.mjs +104 -0
  68. package/lib/roles/catalog.mjs +1 -1
  69. package/lib/roles/event-bus.mjs +29 -9
  70. package/lib/roles/router.mjs +8 -7
  71. package/lib/server/index.mjs +31 -9
  72. package/lib/server/static/index.html +1 -15
  73. package/lib/specialist-contracts-enforce.mjs +24 -10
  74. package/lib/status.mjs +1 -1
  75. package/lib/storage/backup.mjs +2 -2
  76. package/lib/telemetry/intent-verifications.mjs +16 -3
  77. package/lib/telemetry/skill-calls.mjs +12 -3
  78. package/lib/tracking-surfaces.mjs +375 -0
  79. package/lib/worker/trace.mjs +19 -2
  80. package/package.json +6 -2
  81. package/platforms/claude/settings.template.json +28 -27
  82. package/scripts/sync-specialists.mjs +269 -75
  83. package/specialists/registry.json +158 -13
  84. package/lib/hooks/env-check.mjs +0 -83
  85. package/lib/hooks/read-tracker.mjs +0 -61
  86. package/lib/policy/unified-gates.mjs +0 -96
  87. package/lib/server/static/assets/index-ab25c707.js +0 -70
  88. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
@@ -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) {
@@ -1,41 +1,56 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * lib/hooks/audit-reads.mjs — opt-in tamper-evident log of every Read tool call.
3
+ * lib/hooks/audit-reads.mjs — post-Read state tracker.
4
4
  *
5
- * Symmetric counterpart to audit-trail.mjs (which records mutations). Reads
6
- * are off by default because they happen much more often than writes; enable
7
- * with `CONSTRUCT_AUDIT_READS=1`. When enabled, every Read tool call writes
8
- * a single JSONL line to ~/.cx/audit-reads.jsonl with:
5
+ * Two responsibilities on every Read tool call:
9
6
  *
10
- * - timestamp ISO
11
- * - session_id (when supplied by the harness)
12
- * - tool_name (always Read)
13
- * - agent (from ~/.cx/last-agent.json falls back to "construct")
14
- * - cwd
15
- * - target (the file_path read)
16
- * - bytes (size of the file at read time)
17
- * - content_hash (sha256 of the file's first 64KiB at read time, truncated)
18
- * - prev_line_hash (chained tamper-evidence — hash of previous JSONL line)
7
+ * 1. ALWAYS — upsert a file-hash entry in ~/.cx/file-hashes.json keyed by
8
+ * absolute path, with the current sha256 + size + iso timestamp.
9
+ * Edit-guard reads the store to detect staleness on a subsequent Edit:
10
+ * mismatched on-disk content versus the stored hash means the Edit
11
+ * target needs a fresh Read. A read-tracker delta is recorded for the
12
+ * efficiency snapshot in the same pass.
19
13
  *
20
- * The prev_line_hash chain means any after-the-fact reordering or deletion
21
- * breaks the chain and is detectable by a simple replay (same approach as
22
- * the mutation audit).
14
+ * 2. OPT-IN when `CONSTRUCT_AUDIT_READS=1`, write a single JSONL line
15
+ * to <project>/.cx/audit-reads.jsonl (falls back to ~/.cx/ outside a
16
+ * project) with timestamp, session_id, agent, cwd, target, bytes,
17
+ * content_hash, and prev_line_hash. The prev_line_hash chain makes
18
+ * after-the-fact reordering or deletion detectable by simple replay
19
+ * (symmetric counterpart to audit-trail.mjs for mutations). Bounded
20
+ * via the `audit-reads` channel.
21
+ *
22
+ * Reads happen far more often than writes, so the audit log is opt-in.
23
+ * The hash store is always-on because edit-guard depends on it for
24
+ * correctness, not telemetry.
23
25
  *
24
26
  * @p95ms 8
25
27
  * @maxBlockingScope none (PostToolUse, non-blocking)
28
+ *
29
+ * @lifecycle PostToolUse
30
+ * @matcher Read
31
+ * @exits 0 = pass
26
32
  */
27
- import { readFileSync, appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
33
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
28
34
  import { createHash } from 'node:crypto';
29
- import { join, resolve } from 'node:path';
35
+ import { join, resolve, dirname } from 'node:path';
30
36
  import { homedir } from 'node:os';
31
37
  import { logHookFailure } from './_lib/log.mjs';
38
+ import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
39
+ import { resolveProjectScopedPath } from '../project-root.mjs';
40
+ import { flushReadTrackerDeltas, recordReadDelta } from '../read-tracker-store.mjs';
32
41
 
33
42
  const CX_DIR = join(homedir(), '.cx');
34
- const AUDIT_FILE = join(CX_DIR, 'audit-reads.jsonl');
43
+ // audit-reads.jsonl is PROJECT-SCOPED — reads happen in a specific project,
44
+ // not across the user's whole machine. resolveProjectScopedPath returns
45
+ // <project>/.cx/audit-reads.jsonl when the hook fires from inside a
46
+ // Construct project, falling back to the legacy ~/.cx path otherwise so
47
+ // existing standalone invocations keep working.
48
+
49
+ const AUDIT_FILE = resolveProjectScopedPath('audit-reads.jsonl', { ensureDir: false });
50
+ const HASH_STORE = join(CX_DIR, 'file-hashes.json');
35
51
  const LAST_AGENT = join(CX_DIR, 'last-agent.json');
36
52
  const HASH_PREFIX_BYTES = 64 * 1024;
37
-
38
- if (process.env.CONSTRUCT_AUDIT_READS !== '1') process.exit(0);
53
+ const HASH_RETENTION_MS = 2 * 60 * 60 * 1000;
39
54
 
40
55
  function sha256(input) { return createHash('sha256').update(input).digest('hex'); }
41
56
 
@@ -48,14 +63,9 @@ function readLastAgent() {
48
63
 
49
64
  function readPrevLineHash() {
50
65
  try {
51
- if (!existsSync(AUDIT_FILE)) return null;
52
- const size = statSync(AUDIT_FILE).size;
53
- if (size === 0) return null;
54
- const readFrom = Math.max(0, size - 2048);
55
- const tail = readFileSync(AUDIT_FILE, 'utf8').slice(readFrom);
56
- const lines = tail.split('\n').filter(Boolean);
57
- if (lines.length === 0) return null;
58
- return sha256(lines[lines.length - 1]);
66
+ const lastLine = readLastLineAcrossSegments(AUDIT_FILE);
67
+ if (lastLine === null) return null;
68
+ return sha256(lastLine);
59
69
  } catch { return null; }
60
70
  }
61
71
 
@@ -71,22 +81,53 @@ if (!filePath) process.exit(0);
71
81
 
72
82
  const absPath = filePath.startsWith('/') ? filePath : resolve(cwd, filePath);
73
83
 
84
+ // Stage 1 — always-on hash store update for edit-guard staleness detection.
85
+
86
+ let fullContent = null;
74
87
  let bytes = null;
75
- let contentHash = null;
88
+ let prefixHash = null;
76
89
  try {
77
90
  if (existsSync(absPath)) {
78
- bytes = statSync(absPath).size;
79
- const buf = Buffer.alloc(Math.min(bytes, HASH_PREFIX_BYTES));
80
- const fd = await import('node:fs').then((m) => m.openSync(absPath, 'r'));
81
- const fsModule = await import('node:fs');
82
- fsModule.readSync(fd, buf, 0, buf.length, 0);
83
- fsModule.closeSync(fd);
84
- contentHash = sha256(buf).slice(0, 32);
91
+ fullContent = readFileSync(absPath, 'utf8');
92
+ bytes = Buffer.byteLength(fullContent, 'utf8');
93
+ const prefix = Buffer.from(fullContent, 'utf8').slice(0, HASH_PREFIX_BYTES);
94
+ prefixHash = sha256(prefix).slice(0, 32);
95
+
96
+ const edgeHash = createHash('sha256').update(fullContent).digest('hex').slice(0, 16);
97
+ const nowIso = new Date().toISOString();
98
+ const requestedLimit = Number(input?.tool_input?.limit || 0);
99
+ const effectiveLimit = requestedLimit > 0 ? requestedLimit : 2000;
100
+
101
+ mkdirSync(CX_DIR, { recursive: true });
102
+ let store = {};
103
+ try { store = JSON.parse(readFileSync(HASH_STORE, 'utf8')); } catch { /* fresh store */ }
104
+ store[absPath] = { hash: edgeHash, ts: nowIso, size: fullContent.length };
105
+
106
+ const cutoff = Date.now() - HASH_RETENTION_MS;
107
+ for (const [k, v] of Object.entries(store)) {
108
+ if (new Date(v.ts).getTime() < cutoff) delete store[k];
109
+ }
110
+ writeFileSync(HASH_STORE, JSON.stringify(store, null, 2));
111
+
112
+ recordReadDelta({
113
+ path: absPath,
114
+ size: fullContent.length,
115
+ limit: effectiveLimit,
116
+ ts: nowIso,
117
+ }, process.env);
118
+
119
+ if (process.env.CONSTRUCT_READ_TRACKER_FLUSH === '1') {
120
+ flushReadTrackerDeltas({ nowIso, env: process.env });
121
+ }
85
122
  }
86
123
  } catch (err) {
87
- logHookFailure({ hook: 'audit-reads', err, phase: 'hash', input: { filePath: absPath } });
124
+ logHookFailure({ hook: 'audit-reads', err, phase: 'hash-store', input: { filePath: absPath } });
88
125
  }
89
126
 
127
+ // Stage 2 — opt-in tamper-evident audit log.
128
+
129
+ if (process.env.CONSTRUCT_AUDIT_READS !== '1') process.exit(0);
130
+
90
131
  const record = {
91
132
  ts: new Date().toISOString(),
92
133
  session_id: input?.session_id || null,
@@ -95,13 +136,13 @@ const record = {
95
136
  cwd,
96
137
  target: filePath,
97
138
  bytes,
98
- content_hash: contentHash,
139
+ content_hash: prefixHash,
99
140
  prev_line_hash: readPrevLineHash(),
100
141
  };
101
142
 
102
143
  try {
103
- mkdirSync(CX_DIR, { recursive: true });
104
- appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n', 'utf8');
144
+ mkdirSync(dirname(AUDIT_FILE), { recursive: true });
145
+ appendBounded('audit-reads', AUDIT_FILE, JSON.stringify(record) + '\n');
105
146
  } catch (err) {
106
147
  logHookFailure({ hook: 'audit-reads', err, phase: 'append' });
107
148
  }