@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
@@ -0,0 +1,176 @@
1
+ /**
2
+ * lib/orchestration/routing-tables.mjs — declarative routing resolver.
3
+ *
4
+ * Reads specialist subscriptions, doc-artifact ownership, and watch-condition
5
+ * references from specialists/registry.json (with optional .cx/specialists/
6
+ * overlays) and exposes the forward lookups the orchestration layer needs.
7
+ *
8
+ * The resolver is the single source of truth for event/doc/watch routing.
9
+ * Projects override routing without patching library code by dropping JSON
10
+ * files into .cx/specialists/ — each overlay's fields apply over the
11
+ * canonical registry entry for the same specialist.
12
+ *
13
+ * Watch conditions are referenced by name. The implementations live in
14
+ * WATCHERS below — small predicates over the requestSignals shape. The
15
+ * registry decides WHICH specialist a named watcher routes to; this file
16
+ * decides WHAT condition triggers it. Project overlays can re-bind a
17
+ * watcher's specialist; adding a brand-new watcher requires code.
18
+ */
19
+
20
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { findProjectRoot } from '../project-root.mjs';
24
+
25
+ const REGISTRY_PATH = fileURLToPath(new URL('../../specialists/registry.json', import.meta.url));
26
+
27
+ // Watch-condition predicates. Each is a pure function from requestSignals
28
+ // (see orchestration-policy.requestSignals) to boolean. Registry entries
29
+ // reference these by key. Keep predicates small and side-effect free.
30
+
31
+ const WATCHERS = {
32
+ 'high-ambiguity-deep-work': (s) =>
33
+ s.ambiguityScore > 0.5 && s.workCategory === 'deep',
34
+ 'visual-or-ui-risk': (s) =>
35
+ Boolean(s.visualDeliverable) || Boolean(s.riskFlags?.ui),
36
+ 'auth-payments-non-narrow': (s) =>
37
+ Boolean(s.authOrPayments) && s.blastRadius !== 'narrow',
38
+ 'architecture-without-metric': (s) =>
39
+ Boolean(s.riskFlags?.architecture) && !s.hasSuccessMetric,
40
+ 'wide-blast-radius': (s) => s.blastRadius === 'wide',
41
+ };
42
+
43
+ let cache = null;
44
+
45
+ function readJsonSafe(path) {
46
+ try {
47
+ return JSON.parse(readFileSync(path, 'utf8'));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function loadOverlays() {
54
+ const root = findProjectRoot();
55
+ if (!root) return [];
56
+ const overlayDir = join(root, '.cx', 'specialists');
57
+ if (!existsSync(overlayDir)) return [];
58
+ const out = [];
59
+ for (const name of readdirSync(overlayDir)) {
60
+ if (!name.endsWith('.json')) continue;
61
+ const data = readJsonSafe(join(overlayDir, name));
62
+ if (data && typeof data === 'object') out.push(data);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function buildTables() {
68
+ const registry = readJsonSafe(REGISTRY_PATH);
69
+ if (!registry || !Array.isArray(registry.specialists)) {
70
+ throw new Error('routing-tables: registry.json missing or malformed');
71
+ }
72
+
73
+ // Forward maps. Last writer wins for overlays; the resolver validates
74
+ // duplicate ownership inside the canonical registry, so a project overlay
75
+ // is the only way to re-bind a single event/doc/watcher.
76
+
77
+ const eventToOwner = new Map();
78
+ const docToOwner = new Map();
79
+ const watcherToOwner = new Map();
80
+ const watcherToReason = new Map();
81
+
82
+ const errors = [];
83
+
84
+ function apply(entry, source) {
85
+ const cxId = entry.name?.startsWith('cx-') ? entry.name : `cx-${entry.name}`;
86
+ if (Array.isArray(entry.subscriptions)) {
87
+ for (const event of entry.subscriptions) {
88
+ if (eventToOwner.has(event) && eventToOwner.get(event) !== cxId && source === 'registry') {
89
+ errors.push(`duplicate event ownership: ${event} → ${eventToOwner.get(event)} vs ${cxId}`);
90
+ }
91
+ eventToOwner.set(event, cxId);
92
+ }
93
+ }
94
+ if (Array.isArray(entry.docArtifacts)) {
95
+ for (const docType of entry.docArtifacts) {
96
+ if (docToOwner.has(docType) && docToOwner.get(docType) !== cxId && source === 'registry') {
97
+ errors.push(`duplicate doc ownership: ${docType} → ${docToOwner.get(docType)} vs ${cxId}`);
98
+ }
99
+ docToOwner.set(docType, cxId);
100
+ }
101
+ }
102
+ if (Array.isArray(entry.watchConditions)) {
103
+ for (const watch of entry.watchConditions) {
104
+ const name = typeof watch === 'string' ? watch : watch.watcher;
105
+ const reason = typeof watch === 'string' ? null : watch.reason;
106
+ if (!WATCHERS[name]) {
107
+ errors.push(`unknown watchCondition: ${name} (referenced by ${cxId})`);
108
+ continue;
109
+ }
110
+ watcherToOwner.set(name, cxId);
111
+ if (reason) watcherToReason.set(name, reason);
112
+ }
113
+ }
114
+ }
115
+
116
+ for (const entry of registry.specialists) apply(entry, 'registry');
117
+ for (const overlay of loadOverlays()) apply(overlay, 'overlay');
118
+
119
+ if (errors.length > 0) {
120
+ throw new Error(`routing-tables: ${errors.join('; ')}`);
121
+ }
122
+
123
+ return {
124
+ eventToOwner,
125
+ docToOwner,
126
+ watcherToOwner,
127
+ watcherToReason,
128
+ };
129
+ }
130
+
131
+ function tables() {
132
+ if (!cache) cache = buildTables();
133
+ return cache;
134
+ }
135
+
136
+ export function ownerForEvent(eventType) {
137
+ if (!eventType) return null;
138
+ return tables().eventToOwner.get(eventType) ?? null;
139
+ }
140
+
141
+ export function ownerForDoc(docType) {
142
+ if (!docType) return null;
143
+ return tables().docToOwner.get(docType) ?? null;
144
+ }
145
+
146
+ export function evaluateWatchConditions(signals) {
147
+ const triggers = [];
148
+ const t = tables();
149
+ for (const [name, predicate] of Object.entries(WATCHERS)) {
150
+ const owner = t.watcherToOwner.get(name);
151
+ if (!owner) continue;
152
+ if (!predicate(signals)) continue;
153
+ triggers.push({
154
+ specialist: owner,
155
+ reason: t.watcherToReason.get(name) ?? name,
156
+ watcher: name,
157
+ });
158
+ }
159
+ return triggers;
160
+ }
161
+
162
+ export function knownEventTypes() {
163
+ return Array.from(tables().eventToOwner.keys());
164
+ }
165
+
166
+ export function knownDocTypes() {
167
+ return Array.from(tables().docToOwner.keys());
168
+ }
169
+
170
+ export function knownWatchers() {
171
+ return Object.keys(WATCHERS);
172
+ }
173
+
174
+ export function _resetCache() {
175
+ cache = null;
176
+ }
@@ -7,14 +7,17 @@
7
7
  * 3. contract chain (resolveContractChain) (what the typed handoffs are)
8
8
  *
9
9
  * Agent-to-agent contracts are defined in specialists/contracts.json and loaded via
10
- * lib/specialist-contracts.mjs. That file is the single source of truth for
11
- * producer→consumer expectations, replacing the scattered DOC_OWNERSHIP map,
12
- * SPECIALIST_MAP, and informal "collaborators" lists for anything contract-
13
- * shaped. The older maps remain for quick lookups but defer to contracts.json
14
- * for authoritative semantics.
10
+ * lib/specialist-contracts.mjs. That file is the source of truth for
11
+ * producer→consumer typed handoffs.
12
+ *
13
+ * Event ownership, doc-artifact ownership, and watch-condition routing live
14
+ * declaratively on specialist entries in specialists/registry.json and are
15
+ * resolved by lib/orchestration/routing-tables.mjs. Hardcoded maps here
16
+ * would create a second source of truth.
15
17
  */
16
18
  import { resolveContractChain } from './specialist-contracts.mjs';
17
19
  import { verifyRoute } from './intent-classifier.mjs';
20
+ import { ownerForEvent, ownerForDoc, evaluateWatchConditions, knownDocTypes } from './orchestration/routing-tables.mjs';
18
21
 
19
22
  export const EXECUTION_TRACKS = {
20
23
  immediate: 'immediate',
@@ -40,86 +43,11 @@ export const WORK_CATEGORIES = {
40
43
 
41
44
  export const TERMINAL_STATES = ['DONE', 'BLOCKED', 'NEEDS_MAIN_INPUT'];
42
45
 
43
- /**
44
- * Maps document types to the specialist that owns authoring them.
45
- * Construct (or any general persona) must route to the owner rather than
46
- * authoring these directly — authoring is how the owner's domain checks
47
- * (framing, research, trade-off analysis) actually fire.
48
- *
49
- * Keep in sync with rules/common/doc-ownership.md.
50
- */
51
- export const DOC_OWNERSHIP = {
52
- prd: 'cx-product-manager',
53
- 'meta-prd': 'cx-product-manager',
54
- 'prd-platform': 'cx-product-manager',
55
- 'prd-business': 'cx-product-manager',
56
- prfaq: 'cx-product-manager',
57
- 'one-pager': 'cx-product-manager',
58
- 'backlog-proposal': 'cx-product-manager',
59
- 'customer-profile': 'cx-product-manager',
60
- adr: 'cx-architect',
61
- rfc: 'cx-architect',
62
- 'rfc-platform': 'cx-architect',
63
- 'architecture-overview': 'cx-architect',
64
- 'system-design': 'cx-architect',
65
- 'research-brief': 'cx-researcher',
66
- 'evidence-brief': 'cx-researcher',
67
- 'signal-brief': 'cx-researcher',
68
- 'product-intelligence-report': 'cx-researcher',
69
- runbook: 'cx-sre',
70
- 'incident-report': 'cx-sre',
71
- postmortem: 'cx-sre',
72
- 'test-plan': 'cx-qa',
73
- 'qa-strategy': 'cx-qa',
74
- 'security-review': 'cx-security',
75
- 'threat-model': 'cx-security',
76
- memo: 'cx-docs-keeper',
77
- changelog: 'cx-docs-keeper',
78
- };
46
+ // Event and doc-artifact ownership are resolved from specialists/registry.json
47
+ // via routing-tables.mjs. Re-exported here so callers that historically
48
+ // imported from this module keep working.
79
49
 
80
- // Event ownership for the role framework (lib/roles/**). When a hook emits
81
- // an event of one of these types, the gateway routes the invocation to the
82
- // listed persona. Empty owners mean the event is recorded but no persona
83
- // fires until onboarded. Keep in sync with specialists/role-manifests.json.
84
-
85
- export const EVENT_OWNERSHIP = {
86
- 'push_gate.fail': 'cx-sre',
87
- 'service.down': 'cx-sre',
88
- 'mcp.unhealthy.persistent': 'cx-sre',
89
- 'edit_loop.stuck': 'cx-sre',
90
- 'test.fail': 'cx-qa',
91
- 'test.flake': 'cx-qa',
92
- 'coverage.drop': 'cx-qa',
93
- 'dep.cve': 'cx-security',
94
- 'secrets.detected': 'cx-security',
95
- 'config.protection.violation': 'cx-security',
96
- 'pr.merged.no-docs': 'cx-docs-keeper',
97
- 'changelog.missing': 'cx-docs-keeper',
98
- 'readme.stale': 'cx-docs-keeper',
99
- 'adr.requested': 'cx-architect',
100
- 'arch.boundary.violated': 'cx-architect',
101
- 'regression.detected': 'cx-debugger',
102
- 'hang.detected': 'cx-debugger',
103
- 'release.candidate': 'cx-release-manager',
104
- 'version.bump.needed': 'cx-release-manager',
105
- 'backlog.stale': 'cx-product-manager',
106
- 'prd.requested': 'cx-product-manager',
107
- 'pr.opened': 'cx-reviewer',
108
- 'pr.ready-for-review': 'cx-reviewer',
109
- 'infra.change.requested': 'cx-platform-engineer',
110
- 'service.scale.event': 'cx-platform-engineer',
111
- 'design.requested': 'cx-designer',
112
- 'a11y.violation': 'cx-accessibility',
113
- 'research.requested': 'cx-researcher',
114
- 'evidence.requested': 'cx-researcher',
115
- 'eval.regression': 'cx-evaluator',
116
- 'trace.anomaly': 'cx-trace-reviewer',
117
- 'dep.license': 'cx-legal-compliance',
118
- 'privacy-policy.review': 'cx-legal-compliance',
119
- 'strategy.required': 'cx-business-strategist',
120
- 'plan.requested': 'cx-operations',
121
- 'research.gate.required': 'cx-rd-lead',
122
- };
50
+ export { ownerForEvent, ownerForDoc } from './orchestration/routing-tables.mjs';
123
51
 
124
52
  const DOC_AUTHORING_PATTERNS = [
125
53
  { pattern: /\b(adr|architecture decision record)s?\b/i, docType: 'adr' },
@@ -160,7 +88,7 @@ export function detectDocAuthoringIntent(request = '') {
160
88
  if (!AUTHORING_VERBS.test(text)) return null;
161
89
  for (const { pattern, docType } of DOC_AUTHORING_PATTERNS) {
162
90
  if (pattern.test(text)) {
163
- return { docType, owner: DOC_OWNERSHIP[docType] ?? null };
91
+ return { docType, owner: ownerForDoc(docType) };
164
92
  }
165
93
  }
166
94
  return null;
@@ -617,29 +545,13 @@ export function requestSignals(request = '', context = {}) {
617
545
 
618
546
  // Signal-driven proactive triggers. Returns a list of { specialist, reason }
619
547
  // pairs for specialists that should engage PRE-DISPATCH based on signals —
620
- // separate from the keyword-only paths in selectSpecialists. Empty when no
621
- // signal trips.
548
+ // separate from the keyword-only paths in selectSpecialists. The watch
549
+ // predicates and their specialist owners are declared in
550
+ // specialists/registry.json (watchConditions) and evaluated by
551
+ // orchestration/routing-tables.mjs.
622
552
 
623
553
  export function proactiveTriggers(signals) {
624
- const triggers = [];
625
- const { ambiguityScore, workCategory, authOrPayments, riskFlags, blastRadius, hasSuccessMetric, visualDeliverable } = signals;
626
-
627
- if (ambiguityScore > 0.5 && workCategory === WORK_CATEGORIES.deep) {
628
- triggers.push({ specialist: 'cx-product-manager', reason: 'clarify acceptance criteria (high ambiguity)' });
629
- }
630
- if (visualDeliverable || riskFlags.ui) {
631
- triggers.push({ specialist: 'cx-designer', reason: 'visual deliverable / UI risk surface' });
632
- }
633
- if (authOrPayments && blastRadius !== 'narrow') {
634
- triggers.push({ specialist: 'cx-security', reason: 'pre-dispatch threat model (auth/payments/PII + non-narrow blast radius)' });
635
- }
636
- if (riskFlags.architecture && !hasSuccessMetric) {
637
- triggers.push({ specialist: 'cx-devil-advocate', reason: 'architecture change without stated success metric' });
638
- }
639
- if (blastRadius === 'wide') {
640
- triggers.push({ specialist: 'cx-sre', reason: 'wide blast radius — operational readiness review' });
641
- }
642
- return triggers;
554
+ return evaluateWatchConditions(signals).map(({ specialist, reason }) => ({ specialist, reason }));
643
555
  }
644
556
 
645
557
  // User-visible dispatch summary: one line per specialist with the reason it
package/lib/parity.mjs CHANGED
@@ -39,12 +39,22 @@ function adapterName(entry, prefix) {
39
39
  return entry.isPersona ? entry.name : `${prefix}-${entry.name}`;
40
40
  }
41
41
 
42
- function entriesForSurface(registry, surface) {
42
+ function entriesForSurface(registry, surface, { scope = 'global' } = {}) {
43
43
  const prefix = registry.prefix || 'cx';
44
- const entries = [
45
- ...(registry.orchestrator ? [{ ...registry.orchestrator, isPersona: true }] : []),
46
- ...(registry.specialists || []).map((s) => ({ ...s, isPersona: false })),
47
- ];
44
+
45
+ // User-scope expects only the `construct` front-door agent. Specialists
46
+ // live with each Construct-managed project (apps/dashboard, apps/docs,
47
+ // and every other repo that runs `construct init`) — see the two-tier
48
+ // sync contract in scripts/sync-specialists.mjs.
49
+
50
+ const entries = scope === 'project'
51
+ ? [
52
+ ...(registry.orchestrator ? [{ ...registry.orchestrator, isPersona: true }] : []),
53
+ ...(registry.specialists || []).map((s) => ({ ...s, isPersona: false })),
54
+ ]
55
+ : registry.orchestrator
56
+ ? [{ ...registry.orchestrator, isPersona: true }]
57
+ : [];
48
58
 
49
59
  return entries
50
60
  .filter((e) => {
@@ -217,12 +227,31 @@ function checkCursor(registry, { homeDir = os.homedir() } = {}) {
217
227
  };
218
228
  }
219
229
 
230
+ // Names a v1.0.10 install would have populated at user scope (claude, codex,
231
+ // copilot) before the two-tier sync contract moved specialists to project
232
+ // scope. Pulled from the live registry so the roster auto-updates as new
233
+ // specialists land — anything in this set is "expected legacy state during
234
+ // an upgrade," not real drift.
235
+ function legacyUserScopeRoster(registry) {
236
+ const prefix = registry.prefix || 'cx';
237
+ const specialists = (registry.specialists || []).map((s) => `${prefix}-${s.name}`);
238
+ return new Set(specialists);
239
+ }
240
+
220
241
  /**
221
242
  * Run parity checks across every supported surface. Never throws — returns a
222
243
  * structured report so callers can render it however they like.
244
+ *
245
+ * A surface that reports `drift` is reclassified to `legacy-install` when the
246
+ * only divergence is extras that all match the v1.0.10 user-scope roster — a
247
+ * dev box mid-upgrade from v1.0.10 (which populated cx-* specialists at user
248
+ * scope) to v1.0.13+ (project scope only). `legacy-install` rolls up to the
249
+ * same overall-ok bucket as `absent` so it doesn't hard-fail the gate; the
250
+ * summary still surfaces the `--fix-legacy-agents` hint.
223
251
  */
224
252
  export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {}) {
225
253
  const registry = loadRegistry(rootDir);
254
+ const legacyRoster = legacyUserScopeRoster(registry);
226
255
  const surfaces = [
227
256
  checkClaude(registry, { homeDir }),
228
257
  checkOpenCode(registry, { homeDir }),
@@ -230,13 +259,16 @@ export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {})
230
259
  checkCopilot(registry, { homeDir }),
231
260
  checkVSCode(registry, { homeDir }),
232
261
  checkCursor(registry, { homeDir }),
233
- ];
262
+ ].map((s) => reclassifyLegacy(s, legacyRoster));
234
263
 
235
- const ok = surfaces.every((s) => s.status === 'ok' || s.status === 'absent');
264
+ const ok = surfaces.every((s) => s.status === 'ok' || s.status === 'absent' || s.status === 'legacy-install');
236
265
  const summary = surfaces.map((s) => {
237
266
  if (s.status === 'absent') return `${s.surface}: not installed`;
238
267
  if (s.status === 'unreadable') return `${s.surface}: unreadable (${s.error})`;
239
268
  if (s.status === 'ok') return `${s.surface}: ok (${s.actualCount}/${s.expectedCount} ${s.kind})`;
269
+ if (s.status === 'legacy-install') {
270
+ return `${s.surface}: legacy v1.0.10 install — ${s.extra.length} stale ${s.kind} (run \`construct doctor --fix-legacy-agents\`)`;
271
+ }
240
272
  const parts = [];
241
273
  if (s.missing.length) parts.push(`missing: ${s.missing.join(', ')}`);
242
274
  if (s.extra.length) parts.push(`extra: ${s.extra.join(', ')}`);
@@ -245,3 +277,12 @@ export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {})
245
277
 
246
278
  return { ok, surfaces, summary };
247
279
  }
280
+
281
+ function reclassifyLegacy(surface, legacyRoster) {
282
+ if (surface.status !== 'drift') return surface;
283
+ if ((surface.missing?.length ?? 0) !== 0) return surface;
284
+ if (!surface.extra?.length) return surface;
285
+ const allLegacy = surface.extra.every((name) => legacyRoster.has(name));
286
+ if (!allLegacy) return surface;
287
+ return { ...surface, status: 'legacy-install' };
288
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A profile is a curated description of an org's work loop, intake taxonomy,
5
5
  * role set, and rebrand language. Creating one is a research task, not a
6
- * code task. This module enforces that by producing a draft + a requirements
6
+ * code task. The module enforces that by producing a draft + a requirements
7
7
  * brief that names which existing Construct specialists are expected to
8
8
  * complete each section. Operators dispatch those specialists, collect the
9
9
  * answers, then promote the draft.
@@ -16,12 +16,17 @@
16
16
  *
17
17
  * Health: per-profile observation and outcome counts pulled from the existing
18
18
  * stores, so profile health travels alongside the rest of the learning loops.
19
+ *
20
+ * Lifecycle events: createDraftProfile and archiveProfile each fire a
21
+ * profile.updated role event so subscribers can react to taxonomy changes
22
+ * without polling the filesystem.
19
23
  */
20
24
  import fs from 'node:fs';
21
25
  import path from 'node:path';
22
26
  import { fileURLToPath } from 'node:url';
23
27
 
24
28
  import { listProfiles, loadProfile } from './loader.mjs';
29
+ import { emitBestEffort as emitRoleEvent } from '../roles/event-bus.mjs';
25
30
 
26
31
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
27
32
  const REPO_ROOT = path.resolve(MODULE_DIR, '..', '..');
@@ -256,9 +261,21 @@ export function createDraftProfile({ cwd, id, displayName, seedRoles = [], seedD
256
261
  departmentPaths.push(p);
257
262
  }
258
263
 
264
+ emitProfileUpdated({ id, stage: 'draft', dir });
259
265
  return { dir, briefPath, draftPath, personaPaths, departmentPaths };
260
266
  }
261
267
 
268
+ // Lifecycle bridge: emits profile.updated whenever the curated catalog gains
269
+ // or loses an entry, so subscribers (none by default; project overlays can
270
+ // bind) can react to taxonomy changes.
271
+
272
+ function emitProfileUpdated(context) {
273
+ emitRoleEvent('profile.updated', {
274
+ summary: `profile ${context.stage}: ${context.id}`,
275
+ context,
276
+ });
277
+ }
278
+
262
279
  /**
263
280
  * List drafts under .cx/profiles/. Returns [{ id, dir, hasProfile, hasBrief }].
264
281
  */
@@ -320,6 +337,7 @@ export function archiveProfile({ id, reason }) {
320
337
  'Observations and outcomes recorded under this profile remain in `.cx/observations/` and `.cx/outcomes/` and continue to be searchable. The intake table and profile JSON were moved into this directory. To restore: move the files back to their original paths and re-run `npm run lint:profiles`.',
321
338
  '',
322
339
  ].join('\n'));
340
+ emitProfileUpdated({ id, stage: 'archived', dir: dstDir, reason: reason.trim() });
323
341
  return { archived: dstDir };
324
342
  }
325
343
 
@@ -11,14 +11,14 @@ import path from "node:path";
11
11
 
12
12
  import { stampFrontmatter } from "./doc-stamp.mjs";
13
13
 
14
- export function writeStampedIfMissing({ targetRoot, created, skipped, filePath, content, generator }) {
14
+ export function writeStampedIfMissing({ targetRoot, created, skipped, filePath, content, generator, attribution = null }) {
15
15
  if (fs.existsSync(filePath)) {
16
16
  skipped.push(path.relative(targetRoot, filePath));
17
17
  return false;
18
18
  }
19
19
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
20
20
  const stamped = filePath.endsWith(".md")
21
- ? stampFrontmatter(content, { generator })
21
+ ? stampFrontmatter(content, { generator, attribution })
22
22
  : content;
23
23
  fs.writeFileSync(filePath, stamped, "utf8");
24
24
  created.push(path.relative(targetRoot, filePath));
@@ -270,8 +270,8 @@ instead of letting it turn into a historical log. Durable task status belongs in
270
270
  `;
271
271
  }
272
272
 
273
- export function buildContextJson(projectName) {
274
- return `${JSON.stringify({
273
+ export function buildContextJson(projectName, { attribution = null } = {}) {
274
+ const base = {
275
275
  format: "json",
276
276
  savedAt: new Date().toISOString(),
277
277
  source: "construct-init",
@@ -280,5 +280,11 @@ export function buildContextJson(projectName) {
280
280
  recentDecisions: [],
281
281
  architectureNotes: [],
282
282
  openQuestions: [],
283
- }, null, 2)}\n`;
283
+ };
284
+ if (attribution) {
285
+ base.createdBy = attribution.createdBy;
286
+ base.createdByAgent = attribution.createdByAgent;
287
+ base.createdAt = attribution.createdAt;
288
+ }
289
+ return `${JSON.stringify(base, null, 2)}\n`;
284
290
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * lib/project-root.mjs — resolve "what Construct project am I in?" from any cwd.
3
+ *
4
+ * Writers that own per-project state (contract violations, audit reads, agent
5
+ * dispatches, intent verifications) call `resolveProjectScope()` to decide
6
+ * which `<project>/.cx/<file>.jsonl` to write to. When no project is
7
+ * detected, the call returns `null` and writers fall back to the legacy
8
+ * user-scope path (`~/.cx/<file>.jsonl`) so existing standalone hook
9
+ * invocations don't lose data.
10
+ *
11
+ * Writers that own cross-project telemetry (skill calls, role-pending,
12
+ * session cost) keep using `~/.cx/` but tag each entry with the
13
+ * `projectId` from `resolveProjectId()` so a reader can attribute the
14
+ * entry to a specific project later.
15
+ *
16
+ * The project marker is `.cx/` or `.construct/` at the project root. The
17
+ * lookup walks upward from cwd; the first ancestor matching either marker
18
+ * wins. Result is memoized per cwd so the hot path doesn't restat the
19
+ * filesystem on every append.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import os from 'node:os';
25
+ import { createHash } from 'node:crypto';
26
+
27
+ const HOME = os.homedir();
28
+ const MARKERS = ['.cx', '.construct'];
29
+ const cache = new Map(); // cwd → { projectRoot, projectId, scope } | null
30
+
31
+ /**
32
+ * Walk upward from `start` until a directory containing `.cx/` or
33
+ * `.construct/` is found. Returns the absolute project-root path or null.
34
+ * Stops at the filesystem root and at $HOME (so `~/.cx/` doesn't make
35
+ * the user's entire home directory look like a project).
36
+ */
37
+ export function findProjectRoot(start = process.cwd()) {
38
+ let dir = path.resolve(start);
39
+ const stop = path.resolve(HOME);
40
+ while (true) {
41
+ if (MARKERS.some((m) => fs.existsSync(path.join(dir, m)))) return dir;
42
+ if (dir === stop) return null;
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) return null;
45
+ dir = parent;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Deterministic short identifier for a project. Stable across sessions on
51
+ * the same machine, derived from the absolute project-root path. Useful for
52
+ * tagging cross-project telemetry entries (`projectId: "a7c4f2"`) without
53
+ * leaking the absolute filesystem path.
54
+ */
55
+ export function projectIdFor(projectRoot) {
56
+ if (!projectRoot) return null;
57
+ return createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
58
+ }
59
+
60
+ /**
61
+ * Resolve the project scope for a writer call site. Returns:
62
+ * { projectRoot, projectId, cxDir }
63
+ * when the cwd (or a parent) is a Construct project. `cxDir` is the
64
+ * absolute path to `<projectRoot>/.cx/` (created on demand by callers).
65
+ * null
66
+ * when no project is detected; the caller should fall back to the
67
+ * legacy user-scope path.
68
+ */
69
+ export function resolveProjectScope(cwd = process.cwd()) {
70
+ if (cache.has(cwd)) return cache.get(cwd);
71
+ const projectRoot = findProjectRoot(cwd);
72
+ if (!projectRoot) {
73
+ cache.set(cwd, null);
74
+ return null;
75
+ }
76
+ const result = {
77
+ projectRoot,
78
+ projectId: projectIdFor(projectRoot),
79
+ cxDir: path.join(projectRoot, '.cx'),
80
+ };
81
+ cache.set(cwd, result);
82
+ return result;
83
+ }
84
+
85
+ /**
86
+ * For project-scoped writers: returns the `<project>/.cx/<basename>` path
87
+ * when cwd is inside a Construct project, otherwise the legacy
88
+ * `~/.cx/<basename>` path. Callers pass just the file basename; this helper
89
+ * resolves the directory side. ensureDir=true creates the parent dir.
90
+ */
91
+ export function resolveProjectScopedPath(basename, { cwd, ensureDir = true } = {}) {
92
+ const scope = resolveProjectScope(cwd ?? process.cwd());
93
+ const dir = scope ? scope.cxDir : path.join(HOME, '.cx');
94
+ if (ensureDir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
95
+ return path.join(dir, basename);
96
+ }
97
+
98
+ /**
99
+ * Clear the memoization cache. Test-only — every other caller benefits
100
+ * from the cache surviving across calls in the same process.
101
+ */
102
+ export function _resetCache() {
103
+ cache.clear();
104
+ }
@@ -11,7 +11,7 @@ import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
 
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
- const REGISTRY_PATH = join(__dirname, '..', '..', 'agents', 'registry.json');
14
+ const REGISTRY_PATH = join(__dirname, '..', '..', 'specialists', 'registry.json');
15
15
 
16
16
  let cached = null;
17
17