@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.
- package/README.md +55 -18
- package/bin/construct +259 -11
- package/bin/construct-postinstall.mjs +23 -2
- package/lib/auto-docs.mjs +23 -22
- package/lib/cli-commands.mjs +2 -0
- package/lib/dashboard-static.mjs +7 -4
- package/lib/doc-stamp.mjs +16 -0
- package/lib/docs-verify.mjs +1 -8
- package/lib/doctor/watchers/bd-watch.mjs +6 -2
- package/lib/embed/daemon.mjs +20 -0
- package/lib/embed/docs-lifecycle.mjs +19 -0
- package/lib/embed/inbox.mjs +85 -2
- package/lib/embed/recommendation-store.mjs +29 -0
- package/lib/gates-audit.mjs +18 -12
- package/lib/hooks/_lib/input.mjs +52 -0
- package/lib/hooks/adaptive-lint.mjs +4 -0
- package/lib/hooks/agent-tracker.mjs +59 -15
- package/lib/hooks/audit-reads.mjs +83 -42
- package/lib/hooks/audit-trail.mjs +28 -18
- package/lib/hooks/bash-output-logger.mjs +8 -2
- package/lib/hooks/block-no-verify.mjs +4 -0
- package/lib/hooks/ci-status-check.mjs +4 -0
- package/lib/hooks/comment-lint.mjs +6 -4
- package/lib/hooks/config-protection.mjs +4 -0
- package/lib/hooks/context-watch.mjs +4 -0
- package/lib/hooks/context-window-recovery.mjs +4 -0
- package/lib/hooks/dep-audit.mjs +12 -5
- package/lib/hooks/doc-coupling-check.mjs +5 -1
- package/lib/hooks/edit-accumulator.mjs +25 -10
- package/lib/hooks/edit-error-recovery.mjs +4 -0
- package/lib/hooks/edit-guard.mjs +4 -0
- package/lib/hooks/guard-bash.mjs +4 -0
- package/lib/hooks/mcp-audit.mjs +4 -0
- package/lib/hooks/mcp-health-check.mjs +4 -0
- package/lib/hooks/model-fallback.mjs +7 -11
- package/lib/hooks/policy-engine.mjs +4 -0
- package/lib/hooks/post-merge-docs-check.mjs +4 -0
- package/lib/hooks/post-merge-tracking.mjs +82 -0
- package/lib/hooks/pre-compact.mjs +4 -0
- package/lib/hooks/pre-push-gate.mjs +84 -231
- package/lib/hooks/proactive-activation.mjs +5 -2
- package/lib/hooks/readme-age-check.mjs +4 -0
- package/lib/hooks/registry-sync.mjs +35 -20
- package/lib/hooks/rule-verifier.mjs +3 -0
- package/lib/hooks/scan-secrets.mjs +4 -0
- package/lib/hooks/session-optimize.mjs +4 -0
- package/lib/hooks/session-reflect.mjs +4 -0
- package/lib/hooks/session-start.mjs +48 -1
- package/lib/hooks/session-tracking-refresh.mjs +70 -0
- package/lib/hooks/stop-notify.mjs +13 -2
- package/lib/hooks/stop-typecheck.mjs +4 -0
- package/lib/hooks/test-watch.mjs +8 -4
- package/lib/init-unified.mjs +90 -23
- package/lib/intake/attribution.mjs +77 -0
- package/lib/intake/intake-config.mjs +3 -0
- package/lib/intake/manifest.mjs +107 -0
- package/lib/intake/poll-lock.mjs +136 -0
- package/lib/intake/prepare.mjs +42 -4
- package/lib/logging/rotate.mjs +394 -0
- package/lib/mcp/tools/project.mjs +2 -2
- package/lib/mcp/tools/telemetry.mjs +1 -1
- package/lib/opencode-config.mjs +10 -3
- package/lib/orchestration/routing-tables.mjs +176 -0
- package/lib/orchestration-policy.mjs +18 -106
- package/lib/parity.mjs +48 -7
- package/lib/profiles/lifecycle.mjs +19 -1
- package/lib/project-init-shared.mjs +11 -5
- package/lib/project-root.mjs +104 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/event-bus.mjs +29 -9
- package/lib/roles/router.mjs +8 -7
- package/lib/server/index.mjs +31 -9
- package/lib/server/static/index.html +1 -15
- package/lib/specialist-contracts-enforce.mjs +24 -10
- package/lib/status.mjs +1 -1
- package/lib/storage/backup.mjs +2 -2
- package/lib/sync/skill-frontmatter.mjs +71 -0
- package/lib/telemetry/intent-verifications.mjs +16 -3
- package/lib/telemetry/skill-calls.mjs +12 -3
- package/lib/tracking-surfaces.mjs +375 -0
- package/lib/worker/trace.mjs +19 -2
- package/package.json +6 -2
- package/platforms/claude/settings.template.json +28 -27
- package/scripts/sync-specialists.mjs +296 -94
- package/specialists/registry.json +158 -13
- package/lib/hooks/env-check.mjs +0 -83
- package/lib/hooks/read-tracker.mjs +0 -61
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/server/static/assets/index-ab25c707.js +0 -70
- 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
|
|
11
|
-
* producer→consumer
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
621
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
package/lib/roles/catalog.mjs
CHANGED
|
@@ -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, '..', '..', '
|
|
14
|
+
const REGISTRY_PATH = join(__dirname, '..', '..', 'specialists', 'registry.json');
|
|
15
15
|
|
|
16
16
|
let cached = null;
|
|
17
17
|
|