@geraldmaron/construct 1.0.3 → 1.0.5
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 +51 -42
- package/agents/prompts/cx-ai-engineer.md +6 -26
- package/agents/prompts/cx-architect.md +1 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +6 -26
- package/agents/prompts/cx-docs-keeper.md +1 -31
- package/agents/prompts/cx-explorer.md +1 -0
- package/agents/prompts/cx-orchestrator.md +40 -112
- package/agents/prompts/cx-platform-engineer.md +2 -22
- package/agents/prompts/cx-product-manager.md +2 -1
- package/agents/prompts/cx-qa.md +0 -20
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-researcher.md +77 -31
- package/agents/prompts/cx-security.md +11 -49
- package/agents/prompts/cx-sre.md +9 -43
- package/agents/prompts/cx-ux-researcher.md +1 -0
- package/agents/role-manifests.json +4 -4
- package/bin/construct +279 -4
- package/commands/understand/research.md +5 -3
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +3 -4
- package/lib/beads-automation.mjs +16 -7
- package/lib/cli-commands.mjs +52 -2
- package/lib/comment-lint.mjs +7 -1
- package/lib/config/schema.mjs +3 -0
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +37 -17
- package/lib/embed/daemon.mjs +10 -8
- package/lib/embed/recommendation-store.mjs +213 -14
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/flavors/loader.mjs +136 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +1 -1
- package/lib/hooks/agent-tracker.mjs +22 -3
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/pre-push-gate.mjs +14 -1
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/hooks/session-optimize.mjs +3 -2
- package/lib/hooks/session-reflect.mjs +68 -0
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +25 -2
- package/lib/init-update.mjs +246 -131
- package/lib/intake/classify.mjs +61 -183
- package/lib/intake/prepare.mjs +7 -0
- package/lib/intake/tables/creative.mjs +94 -0
- package/lib/intake/tables/operations.mjs +85 -0
- package/lib/intake/tables/research.mjs +85 -0
- package/lib/intake/tables/rnd.mjs +175 -0
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/research-store.mjs +109 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-router.mjs +61 -1
- package/lib/observation-store.mjs +19 -0
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/outcomes/aggregate.mjs +104 -0
- package/lib/outcomes/record.mjs +115 -0
- package/lib/parity.mjs +121 -21
- package/lib/profiles/lifecycle.mjs +388 -0
- package/lib/profiles/loader.mjs +123 -0
- package/lib/profiles/validate-custom.mjs +114 -0
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/reflect/extractor.mjs +193 -0
- package/lib/reflect.mjs +89 -2
- package/lib/sandbox.mjs +102 -0
- package/lib/server/index.mjs +76 -0
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +30 -22
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +1 -2
- package/lib/setup.mjs +8 -43
- package/lib/status.mjs +51 -5
- package/lib/storage/backend.mjs +12 -2
- package/lib/strategy-store.mjs +371 -0
- package/lib/telemetry/backends/local.mjs +6 -4
- package/lib/telemetry/client.mjs +185 -0
- package/lib/telemetry/ingest.mjs +13 -5
- package/lib/telemetry/team-rollup.mjs +9 -2
- package/lib/worker/trace.mjs +17 -27
- package/package.json +10 -2
- package/personas/construct.md +20 -20
- package/platforms/claude/settings.template.json +13 -0
- package/rules/common/research.md +44 -12
- package/scripts/sync-agents.mjs +11 -0
- package/skills/docs/backlog-proposal-workflow.md +2 -2
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +5 -5
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +3 -3
- package/skills/docs/product-signal-workflow.md +48 -18
- package/skills/docs/research-workflow.md +26 -14
- package/skills/docs/strategy-workflow.md +36 -0
- package/skills/roles/architect.ai-systems.md +4 -2
- package/skills/roles/architect.data.md +4 -2
- package/skills/roles/architect.enterprise.md +4 -2
- package/skills/roles/architect.integration.md +4 -2
- package/skills/roles/architect.md +7 -5
- package/skills/roles/architect.platform.md +4 -2
- package/skills/roles/data-analyst.experiment.md +4 -2
- package/skills/roles/data-analyst.md +9 -7
- package/skills/roles/data-analyst.product-intelligence.md +5 -3
- package/skills/roles/data-analyst.product.md +4 -2
- package/skills/roles/data-analyst.telemetry.md +4 -2
- package/skills/roles/data-engineer.pipeline.md +4 -2
- package/skills/roles/data-engineer.vector-retrieval.md +4 -2
- package/skills/roles/data-engineer.warehouse.md +4 -2
- package/skills/roles/debugger.md +7 -5
- package/skills/roles/designer.accessibility.md +4 -2
- package/skills/roles/designer.md +10 -8
- package/skills/roles/engineer.ai.md +4 -2
- package/skills/roles/engineer.data.md +5 -3
- package/skills/roles/engineer.md +14 -12
- package/skills/roles/engineer.platform.md +5 -3
- package/skills/roles/operator.docs.md +6 -4
- package/skills/roles/operator.md +6 -4
- package/skills/roles/operator.release.md +4 -2
- package/skills/roles/operator.sre.md +5 -3
- package/skills/roles/orchestrator.md +5 -3
- package/skills/roles/product-manager.ai-product.md +4 -2
- package/skills/roles/product-manager.business-strategy.md +4 -2
- package/skills/roles/product-manager.enterprise.md +4 -2
- package/skills/roles/product-manager.growth.md +4 -2
- package/skills/roles/product-manager.md +6 -4
- package/skills/roles/product-manager.platform.md +4 -2
- package/skills/roles/product-manager.product.md +4 -2
- package/skills/roles/qa.ai-eval.md +4 -2
- package/skills/roles/qa.api-contract.md +4 -2
- package/skills/roles/qa.data-pipeline.md +4 -2
- package/skills/roles/qa.md +7 -5
- package/skills/roles/qa.test-automation.md +5 -3
- package/skills/roles/qa.web-ui.md +4 -2
- package/skills/roles/researcher.explorer.md +4 -2
- package/skills/roles/researcher.md +35 -20
- package/skills/roles/researcher.ux.md +4 -2
- package/skills/roles/reviewer.devil-advocate.md +4 -2
- package/skills/roles/reviewer.evaluator.md +4 -2
- package/skills/roles/reviewer.md +14 -12
- package/skills/roles/reviewer.trace.md +4 -2
- package/skills/roles/security.ai.md +4 -2
- package/skills/roles/security.appsec.md +4 -2
- package/skills/roles/security.cloud.md +4 -2
- package/skills/roles/security.legal-compliance.md +4 -2
- package/skills/roles/security.md +7 -5
- package/skills/roles/security.privacy.md +4 -2
- package/skills/roles/security.supply-chain.md +4 -2
- package/skills/routing.md +8 -1
- package/templates/docs/persona-artifact.md +36 -0
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/research-finding.md +26 -0
- package/templates/docs/skill-artifact.md +27 -0
- package/templates/docs/strategy.md +36 -0
- package/templates/homebrew/construct.rb +6 -6
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/profiles/lifecycle.mjs — Profile lifecycle operations.
|
|
3
|
+
*
|
|
4
|
+
* A profile is a curated description of an org's work loop, intake taxonomy,
|
|
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
|
|
7
|
+
* brief that names which existing Construct specialists are expected to
|
|
8
|
+
* complete each section. Operators dispatch those specialists, collect the
|
|
9
|
+
* answers, then promote the draft.
|
|
10
|
+
*
|
|
11
|
+
* Lifecycle stages:
|
|
12
|
+
* draft - in `.cx/profiles/draft-<id>/` with requirements.md + profile.json
|
|
13
|
+
* active - in `profiles/<id>.json` (curated) or `.cx/profile.json` (custom)
|
|
14
|
+
* archived - in `archive/profiles/<id>/` with the final state and an
|
|
15
|
+
* archive-note.md explaining why it was retired
|
|
16
|
+
*
|
|
17
|
+
* Health: per-profile observation and outcome counts pulled from the existing
|
|
18
|
+
* stores, so profile health travels alongside the rest of the learning loops.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
import { listProfiles, loadProfile } from './loader.mjs';
|
|
25
|
+
|
|
26
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const REPO_ROOT = path.resolve(MODULE_DIR, '..', '..');
|
|
28
|
+
|
|
29
|
+
function draftDir(cwd, id) {
|
|
30
|
+
return path.join(cwd, '.cx', 'profiles', `draft-${id}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function archiveDir(cwd, id) {
|
|
34
|
+
return path.join(cwd, 'archive', 'profiles', id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const REQUIREMENTS_BRIEF = (id, displayName) => `# Profile requirements: ${displayName} (${id})
|
|
38
|
+
|
|
39
|
+
This brief is a discovery and design contract for a new Construct profile. Methodology lives in \`docs/concepts/persona-research.md\` and lifecycle in \`docs/concepts/profile-lifecycle.md\`. Treat each section as a task to dispatch to the named specialist. A draft profile is not ready to promote until every section has evidence backing it. The aim is research-grounded, not opinion-grounded.
|
|
40
|
+
|
|
41
|
+
Specialists used here are the existing Construct cx-* personas (already in the registry). They are dispatched the standard way; this doc just names which questions each one owns.
|
|
42
|
+
|
|
43
|
+
This draft scaffolding produces:
|
|
44
|
+
- \`profile.json\` — the structural definition (departments, roles, intake)
|
|
45
|
+
- \`requirements.md\` — this brief
|
|
46
|
+
- \`personas/<role>.md\` — one persona artifact per role (filled during the discover phase)
|
|
47
|
+
- \`departments/<dept>.md\` — one charter doc per department (filled during the frame phase)
|
|
48
|
+
|
|
49
|
+
The persona and department files start as templates. Filling them from evidence is the actual work; the JSON is just a manifest.
|
|
50
|
+
|
|
51
|
+
## 1. Discovery (cx-ux-researcher)
|
|
52
|
+
|
|
53
|
+
Goal: characterize the people, the work, and the outputs of this org type.
|
|
54
|
+
|
|
55
|
+
- Who are the typical roles in this org? Name 4 to 8 with one-line responsibilities.
|
|
56
|
+
- What is the dominant work loop? Describe in 5 to 8 stages.
|
|
57
|
+
- What are the recurring signals that enter the loop (briefs, bug reports, customer messages, etc.)?
|
|
58
|
+
- What are the canonical output artifacts (campaigns, runbooks, papers, etc.)?
|
|
59
|
+
- Evidence: cite at least 2 primary sources (interviews, docs, postmortems, public job-spec language).
|
|
60
|
+
|
|
61
|
+
## 2. Framing (cx-product-manager)
|
|
62
|
+
|
|
63
|
+
Goal: turn discovery into an intake taxonomy and a stage sequence.
|
|
64
|
+
|
|
65
|
+
- Propose intake types (max 24). Each must be: distinct, observable, and routable to a primary owner.
|
|
66
|
+
- Propose stages (max 12). Order matters; each stage must answer "what changed?" not just "what happened next?".
|
|
67
|
+
- For each intake type, name the primary owner role and the recommended chain (max 3 hops).
|
|
68
|
+
- For each stage, name the dominant artifact produced.
|
|
69
|
+
- Acceptance: a representative real signal classifies into a single intake type with confidence > 0.6.
|
|
70
|
+
|
|
71
|
+
## 3. Architecture (cx-architect)
|
|
72
|
+
|
|
73
|
+
Goal: define the role set and how it interlocks with the curated cx-* registry.
|
|
74
|
+
|
|
75
|
+
- List role ids the profile will reference (max 80, grouped into max 12 departments with max 20 roles per department). For each, indicate one of:
|
|
76
|
+
reuse-existing (name the cx-* agent), create-new (specify scope), or compose-overlay (which base role + which flavor).
|
|
77
|
+
- Identify cross-role handoffs. Where can ambiguity stall the loop? Name the orchestrator role.
|
|
78
|
+
- Validate against the per-role cap (max 6 flavors per role per profile).
|
|
79
|
+
- Acceptance: every declared role either exists in agents/registry.json or has a written scope statement.
|
|
80
|
+
|
|
81
|
+
## 4. Validation (cx-evaluator)
|
|
82
|
+
|
|
83
|
+
Goal: prove the draft works on real signals before promotion.
|
|
84
|
+
|
|
85
|
+
- Run \`construct profile classify --draft=${id} --fixture=<path>\` against at least 5 representative signals.
|
|
86
|
+
- Score: precision (right intake type), recall (no false unknowns), routing-confidence (median).
|
|
87
|
+
- Acceptance: precision >= 0.7, recall >= 0.7, no \`unknown\` for the canonical signals.
|
|
88
|
+
|
|
89
|
+
## 5. Promotion (operator decision)
|
|
90
|
+
|
|
91
|
+
Goal: move the draft into the active catalog.
|
|
92
|
+
|
|
93
|
+
- Curated path: hand-edit \`profiles/${id}.json\` from this draft, open a PR, run \`npm run lint:profiles\`.
|
|
94
|
+
- Custom path: copy the draft profile.json to \`<project>/.cx/profile.json\` with \`"custom": true\`; \`construct profile set\` for switching between curated.
|
|
95
|
+
- Either path requires the validation acceptance criteria above to be met.
|
|
96
|
+
|
|
97
|
+
## 6. Health monitoring (cx-evaluator + cx-trace-reviewer)
|
|
98
|
+
|
|
99
|
+
Goal: keep the profile honest after it ships.
|
|
100
|
+
|
|
101
|
+
- \`construct profile health ${id}\` reports observation counts, per-role outcome rates, classification confidence over a window.
|
|
102
|
+
- Any role with success-rate < 0.5 across 10+ runs triggers a review: is the role wrong, the prompt wrong, or the routing wrong?
|
|
103
|
+
- Health data is the input for future profile revisions; never edit a profile without a health report first.
|
|
104
|
+
|
|
105
|
+
## 7. Archive (cx-docs-keeper + operator)
|
|
106
|
+
|
|
107
|
+
Goal: retire a profile cleanly without losing the learning.
|
|
108
|
+
|
|
109
|
+
- \`construct profile archive ${id}\` moves the profile JSON and the intake table reference into \`archive/profiles/${id}/\`, including the final health report as evidence.
|
|
110
|
+
- Observations and outcomes already recorded under this profile stay in place; they are durable.
|
|
111
|
+
- An archive-note.md explains why it was retired (superseded by, deprecated for, merged into).
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const DRAFT_PROFILE_TEMPLATE = (id, displayName) => ({
|
|
115
|
+
$schema: '../schemas/profile.schema.json',
|
|
116
|
+
id,
|
|
117
|
+
displayName: displayName || id,
|
|
118
|
+
tagline: 'Draft. Fill in via the requirements brief.',
|
|
119
|
+
extends: null,
|
|
120
|
+
custom: true,
|
|
121
|
+
roles: [],
|
|
122
|
+
departments: [],
|
|
123
|
+
intake: {
|
|
124
|
+
types: [],
|
|
125
|
+
stages: [],
|
|
126
|
+
classificationTable: null,
|
|
127
|
+
},
|
|
128
|
+
docTemplates: [],
|
|
129
|
+
hooks: {
|
|
130
|
+
sessionReflect: 'on',
|
|
131
|
+
sessionOptimize: 'on',
|
|
132
|
+
},
|
|
133
|
+
defaultSkills: [],
|
|
134
|
+
rebrand: {
|
|
135
|
+
intakeQueueLabel: 'Intake',
|
|
136
|
+
signalNoun: 'signal',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
function personaTemplate(displayName) {
|
|
141
|
+
return `# ${displayName}
|
|
142
|
+
|
|
143
|
+
> Persona research artifact. Methodology: \`docs/concepts/persona-research.md\`. Fill from evidence; opinion is rejected at review.
|
|
144
|
+
|
|
145
|
+
## Goals
|
|
146
|
+
- <What does success look like for this role?>
|
|
147
|
+
|
|
148
|
+
## Frustrations
|
|
149
|
+
- <What slows them down?>
|
|
150
|
+
|
|
151
|
+
## Decision rights
|
|
152
|
+
- Decides: <list>
|
|
153
|
+
- Escalates: <list>
|
|
154
|
+
|
|
155
|
+
## Handoffs
|
|
156
|
+
- Hands off to: <role> when <condition>
|
|
157
|
+
- Receives from: <role> when <condition>
|
|
158
|
+
|
|
159
|
+
## Output contract
|
|
160
|
+
- Format: <markdown, diff, JSON>
|
|
161
|
+
- Depth: <one-paragraph, two-page, etc.>
|
|
162
|
+
- Citations: <required | encouraged | none>
|
|
163
|
+
|
|
164
|
+
## Failure modes
|
|
165
|
+
- <Common ways this persona goes wrong>
|
|
166
|
+
|
|
167
|
+
## Evidence
|
|
168
|
+
- <Primary source 1>
|
|
169
|
+
- <Primary source 2>
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function departmentTemplate(id, displayName) {
|
|
174
|
+
return `# ${displayName} (${id})
|
|
175
|
+
|
|
176
|
+
> Department charter. One paragraph: what this department owns, what it does not own, who it hands off to. Frame from organizational-design research (\`docs/concepts/persona-research.md\` § departmental structure).
|
|
177
|
+
|
|
178
|
+
## Charter
|
|
179
|
+
|
|
180
|
+
<One paragraph mission statement. Be specific about boundaries.>
|
|
181
|
+
|
|
182
|
+
## Roles in this department
|
|
183
|
+
|
|
184
|
+
- <role-id> — see \`personas/<role>.md\`
|
|
185
|
+
|
|
186
|
+
## Handoffs
|
|
187
|
+
|
|
188
|
+
- To <department>: <condition>
|
|
189
|
+
- From <department>: <condition>
|
|
190
|
+
|
|
191
|
+
## Evidence
|
|
192
|
+
|
|
193
|
+
- <Interview, doc, public job spec>
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Scaffold a draft profile under .cx/profiles/draft-<id>/. Returns the paths
|
|
199
|
+
* to the requirements brief and the draft profile JSON.
|
|
200
|
+
*/
|
|
201
|
+
/**
|
|
202
|
+
* Scaffold a draft profile + its persona and department research artifacts.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} args
|
|
205
|
+
* @param {string} args.cwd
|
|
206
|
+
* @param {string} args.id
|
|
207
|
+
* @param {string} [args.displayName]
|
|
208
|
+
* @param {string[]} [args.seedRoles] - role ids to scaffold persona files for
|
|
209
|
+
* @param {Array<{id:string,displayName:string}>} [args.seedDepartments] - departments to scaffold charters for
|
|
210
|
+
*/
|
|
211
|
+
export function createDraftProfile({ cwd, id, displayName, seedRoles = [], seedDepartments = [] }) {
|
|
212
|
+
if (!cwd || !id) throw new Error('createDraftProfile: cwd and id are required');
|
|
213
|
+
if (!/^[a-z][a-z0-9-]{1,30}$/.test(id)) {
|
|
214
|
+
throw new Error('createDraftProfile: id must match ^[a-z][a-z0-9-]{1,30}$');
|
|
215
|
+
}
|
|
216
|
+
if (listProfiles().includes(id)) {
|
|
217
|
+
throw new Error(`createDraftProfile: ${id} already exists in the curated catalog`);
|
|
218
|
+
}
|
|
219
|
+
const dir = draftDir(cwd, id);
|
|
220
|
+
if (fs.existsSync(dir)) {
|
|
221
|
+
throw new Error(`createDraftProfile: draft already exists at ${path.relative(cwd, dir)}; archive or delete it before recreating`);
|
|
222
|
+
}
|
|
223
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
224
|
+
fs.mkdirSync(path.join(dir, 'personas'), { recursive: true });
|
|
225
|
+
fs.mkdirSync(path.join(dir, 'departments'), { recursive: true });
|
|
226
|
+
|
|
227
|
+
const briefPath = path.join(dir, 'requirements.md');
|
|
228
|
+
const draftPath = path.join(dir, 'profile.json');
|
|
229
|
+
fs.writeFileSync(briefPath, REQUIREMENTS_BRIEF(id, displayName || id));
|
|
230
|
+
|
|
231
|
+
const draft = DRAFT_PROFILE_TEMPLATE(id, displayName);
|
|
232
|
+
if (Array.isArray(seedRoles) && seedRoles.length > 0) draft.roles = seedRoles.slice(0, 80);
|
|
233
|
+
if (Array.isArray(seedDepartments) && seedDepartments.length > 0) {
|
|
234
|
+
draft.departments = seedDepartments.slice(0, 12).map((d) => ({
|
|
235
|
+
id: d.id,
|
|
236
|
+
displayName: d.displayName || d.id,
|
|
237
|
+
charter: 'Draft. See departments/<id>.md for the canonical charter, derived from organizational-design research (Galbraith STAR).',
|
|
238
|
+
roles: [],
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
fs.writeFileSync(draftPath, JSON.stringify(draft, null, 2) + '\n');
|
|
242
|
+
|
|
243
|
+
const personaPaths = [];
|
|
244
|
+
for (const roleId of seedRoles) {
|
|
245
|
+
if (!/^[a-z][a-z0-9-]{1,40}$/.test(roleId)) continue;
|
|
246
|
+
const p = path.join(dir, 'personas', `${roleId}.md`);
|
|
247
|
+
fs.writeFileSync(p, personaTemplate(roleId));
|
|
248
|
+
personaPaths.push(p);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const departmentPaths = [];
|
|
252
|
+
for (const dept of seedDepartments) {
|
|
253
|
+
if (!dept?.id || !/^[a-z][a-z0-9-]{1,40}$/.test(dept.id)) continue;
|
|
254
|
+
const p = path.join(dir, 'departments', `${dept.id}.md`);
|
|
255
|
+
fs.writeFileSync(p, departmentTemplate(dept.id, dept.displayName || dept.id));
|
|
256
|
+
departmentPaths.push(p);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { dir, briefPath, draftPath, personaPaths, departmentPaths };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* List drafts under .cx/profiles/. Returns [{ id, dir, hasProfile, hasBrief }].
|
|
264
|
+
*/
|
|
265
|
+
export function listDrafts(cwd) {
|
|
266
|
+
const root = path.join(cwd, '.cx', 'profiles');
|
|
267
|
+
if (!fs.existsSync(root)) return [];
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
270
|
+
if (!entry.isDirectory()) continue;
|
|
271
|
+
if (!entry.name.startsWith('draft-')) continue;
|
|
272
|
+
const id = entry.name.replace(/^draft-/, '');
|
|
273
|
+
const dir = path.join(root, entry.name);
|
|
274
|
+
out.push({
|
|
275
|
+
id,
|
|
276
|
+
dir,
|
|
277
|
+
hasProfile: fs.existsSync(path.join(dir, 'profile.json')),
|
|
278
|
+
hasBrief: fs.existsSync(path.join(dir, 'requirements.md')),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Archive a curated profile by moving it (and any artifacts it owns in the
|
|
286
|
+
* repo) into archive/profiles/<id>/. Observations and outcomes are not
|
|
287
|
+
* touched; they remain as historical record. Requires a non-empty `reason`.
|
|
288
|
+
*/
|
|
289
|
+
export function archiveProfile({ id, reason }) {
|
|
290
|
+
if (!id) throw new Error('archiveProfile: id is required');
|
|
291
|
+
if (!reason || reason.trim().length < 8) {
|
|
292
|
+
throw new Error('archiveProfile: a substantive reason (>= 8 chars) is required');
|
|
293
|
+
}
|
|
294
|
+
const srcProfile = path.join(REPO_ROOT, 'profiles', `${id}.json`);
|
|
295
|
+
if (!fs.existsSync(srcProfile)) {
|
|
296
|
+
throw new Error(`archiveProfile: ${id} not found in profiles/`);
|
|
297
|
+
}
|
|
298
|
+
const profileJson = JSON.parse(fs.readFileSync(srcProfile, 'utf8'));
|
|
299
|
+
const tablePath = typeof profileJson?.intake?.classificationTable === 'string'
|
|
300
|
+
? path.join(REPO_ROOT, profileJson.intake.classificationTable)
|
|
301
|
+
: null;
|
|
302
|
+
|
|
303
|
+
const dstDir = archiveDir(REPO_ROOT, id);
|
|
304
|
+
fs.mkdirSync(dstDir, { recursive: true });
|
|
305
|
+
fs.renameSync(srcProfile, path.join(dstDir, `${id}.json`));
|
|
306
|
+
if (tablePath && fs.existsSync(tablePath)) {
|
|
307
|
+
fs.renameSync(tablePath, path.join(dstDir, path.basename(tablePath)));
|
|
308
|
+
}
|
|
309
|
+
fs.writeFileSync(path.join(dstDir, 'archive-note.md'), [
|
|
310
|
+
`# Archive: ${id}`,
|
|
311
|
+
'',
|
|
312
|
+
`Archived at ${new Date().toISOString()}.`,
|
|
313
|
+
'',
|
|
314
|
+
'## Reason',
|
|
315
|
+
'',
|
|
316
|
+
reason.trim(),
|
|
317
|
+
'',
|
|
318
|
+
'## What stayed',
|
|
319
|
+
'',
|
|
320
|
+
'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
|
+
'',
|
|
322
|
+
].join('\n'));
|
|
323
|
+
return { archived: dstDir };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Per-profile health rollup. Counts observations and outcomes tagged with
|
|
328
|
+
* the profile id, plus the median classification confidence over a window.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} cwd - project root
|
|
331
|
+
* @param {string} profileId
|
|
332
|
+
* @param {object} [opts]
|
|
333
|
+
* @param {number} [opts.windowDays=30]
|
|
334
|
+
*/
|
|
335
|
+
export function profileHealth(cwd, profileId, { windowDays = 30 } = {}) {
|
|
336
|
+
const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000;
|
|
337
|
+
const obsDir = path.join(cwd, '.cx', 'observations');
|
|
338
|
+
let observationCount = 0;
|
|
339
|
+
if (fs.existsSync(obsDir)) {
|
|
340
|
+
const indexPath = path.join(obsDir, 'index.json');
|
|
341
|
+
if (fs.existsSync(indexPath)) {
|
|
342
|
+
try {
|
|
343
|
+
const idx = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
344
|
+
for (const entry of idx) {
|
|
345
|
+
if (Date.parse(entry.createdAt) < cutoff) continue;
|
|
346
|
+
// Observations stamp project, not profile. Fall back to project ==
|
|
347
|
+
// profile id for the common case where the operator named them the
|
|
348
|
+
// same; otherwise count under "all" so we never silently report 0.
|
|
349
|
+
observationCount += entry.project === profileId ? 1 : 0;
|
|
350
|
+
}
|
|
351
|
+
} catch { /* unreadable index — health stays 0 */ }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const outcomesDir = path.join(cwd, '.cx', 'outcomes');
|
|
356
|
+
const roleHealth = {};
|
|
357
|
+
if (fs.existsSync(outcomesDir)) {
|
|
358
|
+
for (const f of fs.readdirSync(outcomesDir)) {
|
|
359
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
360
|
+
const role = f.replace(/\.\d+\.jsonl$|\.jsonl$/, '');
|
|
361
|
+
const lines = fs.readFileSync(path.join(outcomesDir, f), 'utf8').split('\n').filter(Boolean);
|
|
362
|
+
for (const line of lines) {
|
|
363
|
+
let entry;
|
|
364
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
365
|
+
if (entry.profile !== profileId) continue;
|
|
366
|
+
if (Date.parse(entry.ts) < cutoff) continue;
|
|
367
|
+
const r = roleHealth[role] || (roleHealth[role] = { count: 0, success: 0 });
|
|
368
|
+
r.count += 1;
|
|
369
|
+
if (entry.success === true) r.success += 1;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const roleSummary = Object.fromEntries(
|
|
374
|
+
Object.entries(roleHealth).map(([r, s]) => [r, {
|
|
375
|
+
runs: s.count,
|
|
376
|
+
successRate: s.count > 0 ? Number((s.success / s.count).toFixed(3)) : 0,
|
|
377
|
+
}]),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
profile: profileId,
|
|
382
|
+
profileExists: !!loadProfile(profileId),
|
|
383
|
+
windowDays,
|
|
384
|
+
observationCount,
|
|
385
|
+
roles: roleSummary,
|
|
386
|
+
generatedAt: new Date().toISOString(),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/profiles/loader.mjs — Curated and custom org-type profile loader.
|
|
3
|
+
*
|
|
4
|
+
* Reads JSON profiles from `<repo>/profiles/<id>.json` for the curated set
|
|
5
|
+
* (rnd, marketing, game-studio, internal-tools), and from `<cwd>/.cx/profile.json`
|
|
6
|
+
* for user-defined profiles when `custom: true`.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order for the active profile:
|
|
9
|
+
* 1. `construct.config.json` -> `profile`
|
|
10
|
+
* 2. `<cwd>/.cx/profile.json` -> `id` (custom)
|
|
11
|
+
* 3. Default: 'rnd'
|
|
12
|
+
*
|
|
13
|
+
* All errors fall back to 'rnd' so a malformed profile never breaks the CLI.
|
|
14
|
+
* Validation surfaces in `npm run lint:profiles` and in B3's pre-push gate.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const REPO_ROOT = resolve(MODULE_DIR, '..', '..');
|
|
23
|
+
const PROFILES_DIR = join(REPO_ROOT, 'profiles');
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_PROFILE_ID = 'rnd';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read a profile by id from the curated catalog. Returns null if not found.
|
|
29
|
+
*/
|
|
30
|
+
export function loadProfile(id) {
|
|
31
|
+
if (!id || typeof id !== 'string') return null;
|
|
32
|
+
const path = join(PROFILES_DIR, `${id}.json`);
|
|
33
|
+
if (!existsSync(path)) return null;
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
36
|
+
return raw;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List every curated profile id by scanning the profiles directory.
|
|
44
|
+
*/
|
|
45
|
+
export function listProfiles() {
|
|
46
|
+
if (!existsSync(PROFILES_DIR)) return [];
|
|
47
|
+
return readdirSync(PROFILES_DIR)
|
|
48
|
+
.filter((f) => f.endsWith('.json'))
|
|
49
|
+
.map((f) => f.replace(/\.json$/, ''))
|
|
50
|
+
.sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load a user-defined profile from `<cwd>/.cx/profile.json`. Returns null if
|
|
55
|
+
* the file is missing, malformed, or does not have `custom: true`.
|
|
56
|
+
*/
|
|
57
|
+
export function loadCustomProfile(cwd) {
|
|
58
|
+
if (!cwd) return null;
|
|
59
|
+
const path = join(cwd, '.cx', 'profile.json');
|
|
60
|
+
if (!existsSync(path)) return null;
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
63
|
+
if (raw && raw.custom === true) return raw;
|
|
64
|
+
return null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the active profile for a project. Always returns a profile object.
|
|
72
|
+
*
|
|
73
|
+
* Precedence (highest first):
|
|
74
|
+
* 1. Explicit `configProfileId` argument
|
|
75
|
+
* 2. `<cwd>/.cx/profile.json` with `custom: true`
|
|
76
|
+
* 3. `profile` field in `<cwd>/construct.config.json`
|
|
77
|
+
* 4. Default `rnd`
|
|
78
|
+
*
|
|
79
|
+
* Falls back to a minimal RND object on any miss so the CLI never breaks.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} cwd - project root
|
|
82
|
+
* @param {string} [configProfileId] - explicit override, bypasses file lookups
|
|
83
|
+
*/
|
|
84
|
+
export function resolveActiveProfile(cwd, configProfileId = null) {
|
|
85
|
+
if (configProfileId) {
|
|
86
|
+
const p = loadProfile(configProfileId);
|
|
87
|
+
if (p) return p;
|
|
88
|
+
}
|
|
89
|
+
const custom = loadCustomProfile(cwd);
|
|
90
|
+
if (custom) return custom;
|
|
91
|
+
const fromConfig = readProfileFromProjectConfig(cwd);
|
|
92
|
+
if (fromConfig) {
|
|
93
|
+
const p = loadProfile(fromConfig);
|
|
94
|
+
if (p) return p;
|
|
95
|
+
}
|
|
96
|
+
return loadProfile(DEFAULT_PROFILE_ID) ?? minimalRndFallback();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readProfileFromProjectConfig(cwd) {
|
|
100
|
+
if (!cwd) return null;
|
|
101
|
+
const p = join(cwd, 'construct.config.json');
|
|
102
|
+
if (!existsSync(p)) return null;
|
|
103
|
+
try {
|
|
104
|
+
const raw = JSON.parse(readFileSync(p, 'utf8'));
|
|
105
|
+
return typeof raw?.profile === 'string' ? raw.profile : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Last-resort fallback if the curated rnd profile file is missing or
|
|
112
|
+
// unreadable. Lets callers always rely on a non-null shape.
|
|
113
|
+
function minimalRndFallback() {
|
|
114
|
+
return {
|
|
115
|
+
id: 'rnd',
|
|
116
|
+
displayName: 'Software R&D',
|
|
117
|
+
roles: [],
|
|
118
|
+
intake: { types: [], stages: [] },
|
|
119
|
+
docTemplates: [],
|
|
120
|
+
hooks: { sessionReflect: 'on', sessionOptimize: 'on' },
|
|
121
|
+
rebrand: { intakeQueueLabel: 'R&D intake queue', signalNoun: 'signal' },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/profiles/validate-custom.mjs — Schema validator for user-defined profiles.
|
|
3
|
+
*
|
|
4
|
+
* Custom profiles live at `<project>/.cx/profile.json` with `custom: true`.
|
|
5
|
+
* They are validated on `construct sync` and via the pre-push gate. Rejected
|
|
6
|
+
* profiles never get loaded; resolveActiveProfile falls back to the default
|
|
7
|
+
* so a malformed escape-hatch file never breaks a project.
|
|
8
|
+
*
|
|
9
|
+
* Hard limits the validator enforces (kept in sync with schemas/profile.schema.json
|
|
10
|
+
* and scripts/lint-profiles.mjs):
|
|
11
|
+
* - max 12 stages
|
|
12
|
+
* - max 24 intake types
|
|
13
|
+
* - max 80 roles per profile
|
|
14
|
+
* - max 12 departments per profile
|
|
15
|
+
* - max 20 roles per department
|
|
16
|
+
* - classificationTable path must stay inside .cx/ (no repo escapes)
|
|
17
|
+
*
|
|
18
|
+
* Rationale for each cap lives in docs/concepts/persona-research.md.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const MAX_STAGES = 12;
|
|
24
|
+
const MAX_INTAKE_TYPES = 24;
|
|
25
|
+
const MAX_ROLES = 80;
|
|
26
|
+
const MAX_DEPARTMENTS = 12;
|
|
27
|
+
const MAX_ROLES_PER_DEPARTMENT = 20;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @returns {string[]} array of error strings; empty means valid.
|
|
31
|
+
*/
|
|
32
|
+
export function validateCustomProfile(profile, { cwd } = {}) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
if (!profile || typeof profile !== 'object') return ['profile is not an object'];
|
|
35
|
+
if (profile.custom !== true) errors.push('custom profiles must set custom: true');
|
|
36
|
+
|
|
37
|
+
if (!profile.id || !/^[a-z][a-z0-9-]{1,30}$/.test(profile.id)) {
|
|
38
|
+
errors.push('id must match ^[a-z][a-z0-9-]{1,30}$');
|
|
39
|
+
}
|
|
40
|
+
if (!profile.displayName || typeof profile.displayName !== 'string') {
|
|
41
|
+
errors.push('displayName is required');
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(profile.roles) || profile.roles.length === 0) {
|
|
44
|
+
errors.push('roles must be a non-empty array');
|
|
45
|
+
} else if (profile.roles.length > MAX_ROLES) {
|
|
46
|
+
errors.push(`roles exceeds max of ${MAX_ROLES}`);
|
|
47
|
+
}
|
|
48
|
+
if (!profile.intake || typeof profile.intake !== 'object') {
|
|
49
|
+
errors.push('intake is required');
|
|
50
|
+
} else {
|
|
51
|
+
if (!Array.isArray(profile.intake.types) || profile.intake.types.length === 0) {
|
|
52
|
+
errors.push('intake.types must be a non-empty array');
|
|
53
|
+
} else if (profile.intake.types.length > MAX_INTAKE_TYPES) {
|
|
54
|
+
errors.push(`intake.types exceeds max of ${MAX_INTAKE_TYPES}`);
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(profile.intake.stages) || profile.intake.stages.length === 0) {
|
|
57
|
+
errors.push('intake.stages must be a non-empty array');
|
|
58
|
+
} else if (profile.intake.stages.length > MAX_STAGES) {
|
|
59
|
+
errors.push(`intake.stages exceeds max of ${MAX_STAGES}`);
|
|
60
|
+
}
|
|
61
|
+
if (typeof profile.intake.classificationTable === 'string') {
|
|
62
|
+
const t = profile.intake.classificationTable;
|
|
63
|
+
if (path.isAbsolute(t)) {
|
|
64
|
+
errors.push('intake.classificationTable must be a relative path');
|
|
65
|
+
} else if (cwd && !t.startsWith('.cx/')) {
|
|
66
|
+
errors.push('intake.classificationTable must live under .cx/ for custom profiles');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (profile.departments !== undefined) {
|
|
71
|
+
if (!Array.isArray(profile.departments)) {
|
|
72
|
+
errors.push('departments must be an array');
|
|
73
|
+
} else {
|
|
74
|
+
if (profile.departments.length > MAX_DEPARTMENTS) {
|
|
75
|
+
errors.push(`departments exceeds max of ${MAX_DEPARTMENTS}`);
|
|
76
|
+
}
|
|
77
|
+
for (const [i, dept] of profile.departments.entries()) {
|
|
78
|
+
if (!dept || typeof dept !== 'object') {
|
|
79
|
+
errors.push(`departments[${i}] must be an object`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!dept.id || !/^[a-z][a-z0-9-]{1,40}$/.test(dept.id)) {
|
|
83
|
+
errors.push(`departments[${i}].id is missing or malformed`);
|
|
84
|
+
}
|
|
85
|
+
if (!dept.charter || dept.charter.length < 20) {
|
|
86
|
+
errors.push(`departments[${i}].charter must be at least 20 chars (mission statement, not a label)`);
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(dept.roles) || dept.roles.length === 0) {
|
|
89
|
+
errors.push(`departments[${i}].roles must be a non-empty array`);
|
|
90
|
+
} else if (dept.roles.length > MAX_ROLES_PER_DEPARTMENT) {
|
|
91
|
+
errors.push(`departments[${i}].roles exceeds max of ${MAX_ROLES_PER_DEPARTMENT}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return errors;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read and validate `<cwd>/.cx/profile.json`. Returns:
|
|
101
|
+
* { status: 'absent' } if the file does not exist
|
|
102
|
+
* { status: 'invalid', errors: [...] } if validation failed
|
|
103
|
+
* { status: 'ok', profile } if the file is a valid custom profile
|
|
104
|
+
*/
|
|
105
|
+
export function validateCustomProfileFile(cwd) {
|
|
106
|
+
const p = path.join(cwd, '.cx', 'profile.json');
|
|
107
|
+
if (!fs.existsSync(p)) return { status: 'absent' };
|
|
108
|
+
let raw;
|
|
109
|
+
try { raw = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
110
|
+
catch (err) { return { status: 'invalid', errors: [`malformed JSON: ${err.message}`] }; }
|
|
111
|
+
const errors = validateCustomProfile(raw, { cwd });
|
|
112
|
+
if (errors.length > 0) return { status: 'invalid', errors };
|
|
113
|
+
return { status: 'ok', profile: raw };
|
|
114
|
+
}
|