@geraldmaron/construct 1.0.4 → 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/bin/construct +256 -4
- package/commands/understand/research.md +5 -3
- package/lib/auto-docs.mjs +2 -2
- package/lib/cli-commands.mjs +44 -0
- package/lib/comment-lint.mjs +7 -1
- package/lib/config/schema.mjs +3 -0
- package/lib/flavors/loader.mjs +136 -0
- package/lib/hooks/agent-tracker.mjs +22 -3
- package/lib/hooks/pre-push-gate.mjs +14 -1
- package/lib/hooks/session-optimize.mjs +3 -2
- package/lib/hooks/session-reflect.mjs +68 -0
- package/lib/init-unified.mjs +25 -2
- 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/knowledge/research-store.mjs +109 -0
- package/lib/observation-store.mjs +19 -0
- package/lib/outcomes/aggregate.mjs +104 -0
- package/lib/outcomes/record.mjs +115 -0
- package/lib/parity.mjs +6 -9
- package/lib/profiles/lifecycle.mjs +388 -0
- package/lib/profiles/loader.mjs +123 -0
- package/lib/profiles/validate-custom.mjs +114 -0
- package/lib/reflect/extractor.mjs +193 -0
- package/lib/reflect.mjs +89 -2
- package/lib/sandbox.mjs +102 -0
- package/package.json +6 -1
- package/personas/construct.md +20 -20
- package/platforms/claude/settings.template.json +13 -0
- package/scripts/sync-agents.mjs +11 -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 +4 -2
- 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 +11 -9
- 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/templates/docs/persona-artifact.md +36 -0
- package/templates/docs/research-finding.md +26 -0
- package/templates/docs/skill-artifact.md +27 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/flavors/loader.mjs — Role flavor overlay loader and validator.
|
|
3
|
+
*
|
|
4
|
+
* Reads frontmatter from skills/roles/<role>.<flavor>.md files, exposes:
|
|
5
|
+
* - listFlavors(role?, profile?) — overlays that apply to role and profile
|
|
6
|
+
* - validateFlavor(path) — frontmatter conformance check against the schema
|
|
7
|
+
* - perRoleFlavorCount(profile) — flavor counts per role for cap enforcement
|
|
8
|
+
*
|
|
9
|
+
* The cap is enforced at sync time and in lint-prose's sibling lint-flavors.
|
|
10
|
+
* Six flavors per role per profile is the current ceiling; bumping requires
|
|
11
|
+
* an ADR. Existing roles like qa already have 6, so the cap is set tight.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const REPO_ROOT = path.resolve(MODULE_DIR, '..', '..');
|
|
19
|
+
const FLAVORS_DIR = path.join(REPO_ROOT, 'skills', 'roles');
|
|
20
|
+
|
|
21
|
+
export const FLAVOR_CAP_PER_ROLE_PER_PROFILE = 6;
|
|
22
|
+
|
|
23
|
+
// Files may start with an HTML comment block before the YAML frontmatter.
|
|
24
|
+
// The /m flag lets ^ match line-start so we find the YAML wherever it sits.
|
|
25
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/m;
|
|
26
|
+
|
|
27
|
+
function parseFrontmatter(content) {
|
|
28
|
+
const m = FRONTMATTER_RE.exec(content);
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
const out = {};
|
|
31
|
+
let currentArrayKey = null;
|
|
32
|
+
for (const rawLine of m[1].split('\n')) {
|
|
33
|
+
const line = rawLine.replace(/\r$/, '');
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
if (currentArrayKey && /^\s*-\s+/.test(line)) {
|
|
36
|
+
out[currentArrayKey].push(line.replace(/^\s*-\s+/, '').trim());
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
currentArrayKey = null;
|
|
40
|
+
const kv = /^([a-z_]+):\s*(.*)$/.exec(line);
|
|
41
|
+
if (!kv) continue;
|
|
42
|
+
const [, key, valueRaw] = kv;
|
|
43
|
+
const value = valueRaw.trim();
|
|
44
|
+
if (value === '') {
|
|
45
|
+
out[key] = [];
|
|
46
|
+
currentArrayKey = key;
|
|
47
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
48
|
+
const items = value.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
|
|
49
|
+
out[key] = items;
|
|
50
|
+
} else if (value === 'null') {
|
|
51
|
+
out[key] = null;
|
|
52
|
+
} else if (/^-?\d+$/.test(value)) {
|
|
53
|
+
out[key] = Number(value);
|
|
54
|
+
} else {
|
|
55
|
+
out[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read and parse every overlay in skills/roles/. Returns { path, frontmatter }
|
|
63
|
+
* tuples. Files without parseable frontmatter are skipped silently.
|
|
64
|
+
*/
|
|
65
|
+
export function listAllFlavors() {
|
|
66
|
+
if (!fs.existsSync(FLAVORS_DIR)) return [];
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const f of fs.readdirSync(FLAVORS_DIR)) {
|
|
69
|
+
if (!f.endsWith('.md') || f === 'README.md') continue;
|
|
70
|
+
const full = path.join(FLAVORS_DIR, f);
|
|
71
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
72
|
+
const fm = parseFrontmatter(content);
|
|
73
|
+
if (fm) out.push({ path: full, file: f, frontmatter: fm });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Return overlays that match the given role (any-flavor for that role) and
|
|
80
|
+
* apply to the given profile. Pass undefined for either to skip that filter.
|
|
81
|
+
*/
|
|
82
|
+
export function listFlavors({ role, profile } = {}) {
|
|
83
|
+
return listAllFlavors().filter(({ frontmatter }) => {
|
|
84
|
+
if (role) {
|
|
85
|
+
const roleId = frontmatter.role || '';
|
|
86
|
+
const baseRole = roleId.split('.')[0];
|
|
87
|
+
if (baseRole !== role && roleId !== role) return false;
|
|
88
|
+
}
|
|
89
|
+
if (profile) {
|
|
90
|
+
const profiles = frontmatter.profiles;
|
|
91
|
+
if (!Array.isArray(profiles) || profiles.length === 0) return true;
|
|
92
|
+
if (!profiles.includes(profile)) return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Count flavor overlays per base role for a given profile.
|
|
100
|
+
* Returns { [baseRole]: count }. Excludes the canonical base file (e.g.
|
|
101
|
+
* architect.md) so only true flavors count toward the cap.
|
|
102
|
+
*/
|
|
103
|
+
export function perRoleFlavorCount(profile = 'rnd') {
|
|
104
|
+
const counts = {};
|
|
105
|
+
for (const entry of listFlavors({ profile })) {
|
|
106
|
+
const roleId = entry.frontmatter.role || '';
|
|
107
|
+
const isFlavor = roleId.includes('.');
|
|
108
|
+
if (!isFlavor) continue;
|
|
109
|
+
const base = roleId.split('.')[0];
|
|
110
|
+
counts[base] = (counts[base] || 0) + 1;
|
|
111
|
+
}
|
|
112
|
+
return counts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate one overlay's frontmatter against the schema invariants. Returns
|
|
117
|
+
* an array of error strings; empty array means valid.
|
|
118
|
+
*/
|
|
119
|
+
export function validateFlavor(filePath) {
|
|
120
|
+
const errors = [];
|
|
121
|
+
if (!fs.existsSync(filePath)) return [`file not found: ${filePath}`];
|
|
122
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
123
|
+
const fm = parseFrontmatter(content);
|
|
124
|
+
if (!fm) return [`${filePath}: missing or unparseable frontmatter`];
|
|
125
|
+
|
|
126
|
+
if (!fm.role || !/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)?$/.test(fm.role)) {
|
|
127
|
+
errors.push(`${filePath}: invalid role "${fm.role}"`);
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(fm.applies_to) || fm.applies_to.length === 0) {
|
|
130
|
+
errors.push(`${filePath}: applies_to must be a non-empty array`);
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(fm.profiles) || fm.profiles.length === 0) {
|
|
133
|
+
errors.push(`${filePath}: profiles must be a non-empty array (run scripts/migrate-flavors.mjs)`);
|
|
134
|
+
}
|
|
135
|
+
return errors;
|
|
136
|
+
}
|
|
@@ -119,7 +119,7 @@ try {
|
|
|
119
119
|
if (success !== null) {
|
|
120
120
|
const learningDir = join(cxDir, 'learning', agentName);
|
|
121
121
|
mkdirSync(learningDir, { recursive: true });
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
const learningFile = join(learningDir, `${Date.now()}-${success ? 'success' : 'failure'}.json`);
|
|
124
124
|
const learningEntry = {
|
|
125
125
|
agent: agentName,
|
|
@@ -127,13 +127,32 @@ try {
|
|
|
127
127
|
description,
|
|
128
128
|
outcome,
|
|
129
129
|
timestamp: new Date().toISOString(),
|
|
130
|
-
// Extract potential patterns from description
|
|
131
130
|
keywords: extractKeywords(description),
|
|
132
131
|
taskType: classifyTaskType(description)
|
|
133
132
|
};
|
|
134
|
-
|
|
133
|
+
|
|
135
134
|
writeFileSync(learningFile, JSON.stringify(learningEntry, null, 2) + '\n');
|
|
136
135
|
}
|
|
136
|
+
|
|
137
|
+
// A3 outcome capture: record the per-project outcome JSONL that feeds
|
|
138
|
+
// .cx/outcomes/_summary.json and the classifier's tiebreaker. Best-effort;
|
|
139
|
+
// any failure leaves the existing telemetry paths above untouched.
|
|
140
|
+
if (success !== null) {
|
|
141
|
+
try {
|
|
142
|
+
const { recordOutcome } = await import('../outcomes/record.mjs');
|
|
143
|
+
const { resolveActiveProfile } = await import('../profiles/loader.mjs');
|
|
144
|
+
const roleId = agentName.replace(/^cx-/, '');
|
|
145
|
+
const projectCwd = input?.cwd || process.cwd();
|
|
146
|
+
const activeProfile = resolveActiveProfile(projectCwd);
|
|
147
|
+
recordOutcome(projectCwd, {
|
|
148
|
+
role: roleId,
|
|
149
|
+
success,
|
|
150
|
+
notes: outcome,
|
|
151
|
+
source: 'agent-tracker',
|
|
152
|
+
profile: activeProfile?.id ?? null,
|
|
153
|
+
});
|
|
154
|
+
} catch { /* best effort */ }
|
|
155
|
+
}
|
|
137
156
|
}
|
|
138
157
|
|
|
139
158
|
} catch (error) {
|
|
@@ -189,13 +189,26 @@ function runNpmGate(projectDir) {
|
|
|
189
189
|
const jobs = [];
|
|
190
190
|
if (hasTest || hasCi) jobs.push({ label: 'tests', cmd: runner, args: ['run', testScript], timeout: 90_000 });
|
|
191
191
|
if (hasBuild) jobs.push({ label: 'build', cmd: runner, args: ['run', 'build'], timeout: 120_000 });
|
|
192
|
-
|
|
192
|
+
// Use the project's package manager for audit so the lockfile format matches.
|
|
193
|
+
const auditArgs = runner === 'pnpm'
|
|
194
|
+
? ['audit', '--prod', '--audit-level=high']
|
|
195
|
+
: runner === 'yarn'
|
|
196
|
+
? ['npm-audit', '--groups', 'dependencies', '--level', 'high']
|
|
197
|
+
: ['audit', '--omit=dev', '--audit-level=high'];
|
|
198
|
+
jobs.push({ label: 'audit', cmd: runner, args: auditArgs, timeout: 30_000 });
|
|
193
199
|
|
|
194
200
|
if (existsSync(join(projectDir, 'bin/construct'))) {
|
|
195
201
|
jobs.push({ label: 'evals', cmd: 'node', args: ['bin/construct', 'evals', 'retrieval'], timeout: 60_000 });
|
|
196
202
|
jobs.push({ label: 'docs', cmd: 'node', args: ['bin/construct', 'docs:verify'], timeout: 15_000 });
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
if (existsSync(join(projectDir, 'scripts/lint-prose.mjs'))) {
|
|
206
|
+
jobs.push({ label: 'prose', cmd: 'node', args: ['scripts/lint-prose.mjs'], timeout: 15_000 });
|
|
207
|
+
}
|
|
208
|
+
if (existsSync(join(projectDir, 'scripts/lint-profiles.mjs'))) {
|
|
209
|
+
jobs.push({ label: 'profiles', cmd: 'node', args: ['scripts/lint-profiles.mjs', '--quiet'], timeout: 15_000 });
|
|
210
|
+
}
|
|
211
|
+
|
|
199
212
|
return { projectDir, jobs };
|
|
200
213
|
}
|
|
201
214
|
|
|
@@ -128,8 +128,9 @@ function optimizeAgent(agentName) {
|
|
|
128
128
|
const startTime = Date.now();
|
|
129
129
|
|
|
130
130
|
try {
|
|
131
|
-
//
|
|
132
|
-
|
|
131
|
+
// Preview only. The new contract requires an explicit --apply from the
|
|
132
|
+
// operator; hook-driven runs must never modify prompt files.
|
|
133
|
+
const result = spawnSync(CONSTRUCT_BIN, ['optimize', agentName, `--days=${OPTIMIZATION_DAYS}`, '--dry-run'], {
|
|
133
134
|
cwd,
|
|
134
135
|
encoding: 'utf8',
|
|
135
136
|
timeout: 300000, // 5 minutes timeout for optimization
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lib/hooks/session-reflect.mjs — Session end auto-reflect hook.
|
|
4
|
+
*
|
|
5
|
+
* Runs as a Stop hook. Reads the transcript, extracts a deterministic session
|
|
6
|
+
* summary, and records it via the observation store so subsequent sessions
|
|
7
|
+
* can search what happened. Closes the manual-reflect gap that left learning
|
|
8
|
+
* to operator discipline.
|
|
9
|
+
*
|
|
10
|
+
* Hard 500ms wall-clock budget. Exits 0 on any failure — observation capture
|
|
11
|
+
* is best-effort by design.
|
|
12
|
+
*
|
|
13
|
+
* Opt-out: CONSTRUCT_REFLECT_AUTO=off
|
|
14
|
+
*
|
|
15
|
+
* @p95ms 300
|
|
16
|
+
* @maxBlockingScope Stop
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const HARD_BUDGET_MS = 500;
|
|
22
|
+
const startedAt = Date.now();
|
|
23
|
+
|
|
24
|
+
// Opt-out before any heavy work — the env check is cheaper than parsing input.
|
|
25
|
+
if (process.env.CONSTRUCT_REFLECT_AUTO === 'off') process.exit(0);
|
|
26
|
+
|
|
27
|
+
let input = {};
|
|
28
|
+
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
29
|
+
|
|
30
|
+
const cwd = input?.cwd || process.cwd();
|
|
31
|
+
const projectName = cwd.split('/').pop() || 'project';
|
|
32
|
+
|
|
33
|
+
// Only run for Construct projects — same gate as session-optimize.mjs.
|
|
34
|
+
if (projectName !== 'construct' && !existsSync(join(cwd, '.cx'))) process.exit(0);
|
|
35
|
+
|
|
36
|
+
const transcriptPath =
|
|
37
|
+
input?.transcript_path ||
|
|
38
|
+
input?.transcriptPath ||
|
|
39
|
+
process.env.CLAUDE_TRANSCRIPT_PATH ||
|
|
40
|
+
null;
|
|
41
|
+
|
|
42
|
+
if (!transcriptPath) process.exit(0);
|
|
43
|
+
|
|
44
|
+
// Wall-clock budget guard. If the extractor blows past the cap (it shouldn't
|
|
45
|
+
// — it's deterministic and O(n) over transcript lines), the parent process
|
|
46
|
+
// exits without writing. The next session-stop tries again.
|
|
47
|
+
const deadline = setTimeout(() => process.exit(0), HARD_BUDGET_MS);
|
|
48
|
+
deadline.unref();
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const { runReflectAuto } = await import('../reflect.mjs');
|
|
52
|
+
const sessionId = input?.session_id || input?.sessionId || null;
|
|
53
|
+
const durationMs = input?.session_duration_ms || null;
|
|
54
|
+
await runReflectAuto({ transcriptPath, cwd, sessionId, durationMs });
|
|
55
|
+
} catch {
|
|
56
|
+
// Best-effort — observation capture must not affect session exit.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const elapsed = Date.now() - startedAt;
|
|
60
|
+
if (elapsed > HARD_BUDGET_MS) {
|
|
61
|
+
// Log only if budget exceeded — surfaces in construct doctor if it becomes
|
|
62
|
+
// a pattern. Otherwise silent.
|
|
63
|
+
try {
|
|
64
|
+
process.stderr.write(`[session-reflect] over budget: ${elapsed}ms\n`);
|
|
65
|
+
} catch { /* stderr closed — nothing to do */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.exit(0);
|
package/lib/init-unified.mjs
CHANGED
|
@@ -49,6 +49,12 @@ const interactive = args.includes("--interactive") || args.includes("-i");
|
|
|
49
49
|
const quiet = args.includes("--quiet") || args.includes("-q");
|
|
50
50
|
const skipInteractive = !interactive;
|
|
51
51
|
|
|
52
|
+
// Active profile selector. `--profile=<id>` writes the field into the
|
|
53
|
+
// project's construct.config.json so resolveActiveProfile picks it up
|
|
54
|
+
// on first run. Unknown ids are rejected with the available catalog.
|
|
55
|
+
const profileArg = args.find((arg) => arg.startsWith("--profile="));
|
|
56
|
+
const profileId = profileArg ? profileArg.split("=")[1] : null;
|
|
57
|
+
|
|
52
58
|
const created = [];
|
|
53
59
|
const skipped = [];
|
|
54
60
|
|
|
@@ -795,12 +801,29 @@ function initializeBeadsTracker(target) {
|
|
|
795
801
|
|
|
796
802
|
async function main() {
|
|
797
803
|
const projectName = inferProjectName(target);
|
|
798
|
-
|
|
804
|
+
|
|
799
805
|
const { clean } = preflight(target);
|
|
800
|
-
|
|
806
|
+
|
|
801
807
|
if (!quiet) {
|
|
802
808
|
console.log(`Initializing Construct in ${path.relative(process.cwd(), target) || "."}`);
|
|
803
809
|
}
|
|
810
|
+
|
|
811
|
+
// Persist --profile=<id> into construct.config.json. Validate against the
|
|
812
|
+
// curated catalog so a typo never silently falls back to rnd.
|
|
813
|
+
if (profileId) {
|
|
814
|
+
const { loadProfile, listProfiles } = await import('./profiles/loader.mjs');
|
|
815
|
+
if (!loadProfile(profileId)) {
|
|
816
|
+
console.error(`Unknown profile: ${profileId}. Available: ${listProfiles().join(', ')}`);
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
const { findProjectConfigPath, loadProjectConfig, writeProjectConfig, PROJECT_CONFIG_FILENAME } = await import('./config/project-config.mjs');
|
|
820
|
+
const found = findProjectConfigPath(target);
|
|
821
|
+
const cfgPath = found || path.join(target, PROJECT_CONFIG_FILENAME);
|
|
822
|
+
const cfg = found ? (loadProjectConfig(target) || { version: 1 }) : { version: 1 };
|
|
823
|
+
cfg.profile = profileId;
|
|
824
|
+
writeProjectConfig(cfgPath, cfg, { validate: true });
|
|
825
|
+
if (!quiet) console.log(`Profile set to ${profileId}.`);
|
|
826
|
+
}
|
|
804
827
|
|
|
805
828
|
// Always create core Construct files
|
|
806
829
|
writeStampedIfMissing({
|
package/lib/intake/classify.mjs
CHANGED
|
@@ -1,47 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/intake/classify.mjs —
|
|
2
|
+
* lib/intake/classify.mjs — Profile-aware triage classification for intake packets.
|
|
3
3
|
*
|
|
4
|
-
* Deterministic, keyword-driven classifier that maps a raw intake signal
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Deterministic, keyword-driven classifier that maps a raw intake signal onto
|
|
5
|
+
* the active org profile's loop. Returns a triage object with: intakeType,
|
|
6
|
+
* rdStage, primaryOwner, recommendedChain, recommendedAction, risk,
|
|
7
|
+
* requiresApproval, confidence, rationale.
|
|
8
8
|
*
|
|
9
9
|
* NO LLM call. The daemon path must remain synchronous and cheap; intent
|
|
10
10
|
* verification belongs in offline measurement, not inline classification.
|
|
11
|
-
*
|
|
11
|
+
*
|
|
12
|
+
* Default profile is `rnd` so existing R&D users see no behavior change.
|
|
13
|
+
* The `tests/intake/golden-rnd.test.mjs` test locks that invariant.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import path from 'node:path';
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'eval-finding',
|
|
23
|
-
'architecture',
|
|
24
|
-
'incident',
|
|
25
|
-
'launch-asset',
|
|
26
|
-
'ops',
|
|
27
|
-
'security',
|
|
28
|
-
'legal-compliance',
|
|
29
|
-
'unknown',
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
export const RD_STAGES = [
|
|
33
|
-
'signal',
|
|
34
|
-
'framing',
|
|
35
|
-
'hypothesis',
|
|
36
|
-
'research',
|
|
37
|
-
'artifact',
|
|
38
|
-
'design',
|
|
39
|
-
'implementation',
|
|
40
|
-
'evaluation',
|
|
41
|
-
'release',
|
|
42
|
-
'operations',
|
|
43
|
-
'unknown',
|
|
44
|
-
];
|
|
18
|
+
// Backward-compatible exports. Default to RND values for any module that
|
|
19
|
+
// imports these constants directly (the symbol surface predates B2 and is
|
|
20
|
+
// referenced by intake-config, prepare, and tests).
|
|
21
|
+
import rndTable from './tables/rnd.mjs';
|
|
22
|
+
export const INTAKE_TYPES = rndTable.INTAKE_TYPES;
|
|
23
|
+
export const RD_STAGES = rndTable.STAGES;
|
|
45
24
|
|
|
46
25
|
export const RECOMMENDED_ACTIONS = [
|
|
47
26
|
'summarize',
|
|
@@ -60,146 +39,36 @@ export const RECOMMENDED_ACTIONS = [
|
|
|
60
39
|
'archive',
|
|
61
40
|
];
|
|
62
41
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
requiresApproval: true,
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
intakeType: 'incident',
|
|
83
|
-
keywords: ['incident', 'outage', 'slo breach', 'sla breach', 'latency spike', 'availability', 'down', 'p0 ', 'p1 ', 'pagerduty', '5xx', 'oncall'],
|
|
84
|
-
rdStage: 'operations',
|
|
85
|
-
primaryOwner: 'sre',
|
|
86
|
-
recommendedChain: ['sre', 'debugger', 'platform-engineer'],
|
|
87
|
-
recommendedAction: 'create-runbook',
|
|
88
|
-
risk: 'high',
|
|
89
|
-
requiresApproval: true,
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
intakeType: 'legal-compliance',
|
|
93
|
-
keywords: ['gdpr', 'ccpa', 'hipaa', 'sox', 'soc2', 'license', 'lawsuit', 'dpa', 'data retention', 'pii', 'subpoena', 'compliance audit'],
|
|
94
|
-
rdStage: 'operations',
|
|
95
|
-
primaryOwner: 'legal-compliance',
|
|
96
|
-
recommendedChain: ['legal-compliance', 'security', 'product-manager'],
|
|
97
|
-
recommendedAction: 'clarify',
|
|
98
|
-
risk: 'high',
|
|
99
|
-
requiresApproval: true,
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
intakeType: 'architecture',
|
|
103
|
-
keywords: ['architecture', 'adr', 'rfc', 'interface', 'tradeoff', 'boundary', 'system design', 'data model', 'api contract', 'migration plan'],
|
|
104
|
-
rdStage: 'design',
|
|
105
|
-
primaryOwner: 'architect',
|
|
106
|
-
recommendedChain: ['architect', 'devil-advocate', 'engineer'],
|
|
107
|
-
recommendedAction: 'draft-rfc',
|
|
108
|
-
risk: 'medium',
|
|
109
|
-
requiresApproval: false,
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
intakeType: 'eval-finding',
|
|
113
|
-
keywords: ['eval', 'evaluation', 'hallucination', 'judge', 'trace', 'score regression', 'recall@', 'precision@', 'mrr', 'ndcg', 'failure case', 'rubric'],
|
|
114
|
-
rdStage: 'evaluation',
|
|
115
|
-
primaryOwner: 'evaluator',
|
|
116
|
-
recommendedChain: ['evaluator', 'ai-engineer', 'trace-reviewer'],
|
|
117
|
-
recommendedAction: 'evaluate',
|
|
118
|
-
risk: 'medium',
|
|
119
|
-
requiresApproval: false,
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
intakeType: 'bug',
|
|
123
|
-
keywords: ['bug', 'broken', 'error', 'stack trace', 'regression', 'crash', 'exception', 'fails', 'failing', 'throws', 'not working', 'reproduce', 'repro:'],
|
|
124
|
-
rdStage: 'implementation',
|
|
125
|
-
primaryOwner: 'debugger',
|
|
126
|
-
recommendedChain: ['debugger', 'engineer', 'qa', 'reviewer'],
|
|
127
|
-
recommendedAction: 'diagnose',
|
|
128
|
-
risk: 'medium',
|
|
129
|
-
requiresApproval: false,
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
intakeType: 'experiment',
|
|
133
|
-
keywords: ['hypothesis', 'experiment', 'spike', 'prototype', 'falsifiable', 'research question', 'a/b test', 'pilot'],
|
|
134
|
-
rdStage: 'hypothesis',
|
|
135
|
-
primaryOwner: 'rd-lead',
|
|
136
|
-
recommendedChain: ['rd-lead', 'researcher', 'evaluator'],
|
|
137
|
-
recommendedAction: 'create-experiment',
|
|
138
|
-
risk: 'low',
|
|
139
|
-
requiresApproval: false,
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
intakeType: 'launch-asset',
|
|
143
|
-
keywords: ['release', 'changelog', 'version bump', 'ship', 'launch', 'rollout', 'cut a release', 'rc1', 'rc2', 'release candidate'],
|
|
144
|
-
rdStage: 'release',
|
|
145
|
-
primaryOwner: 'release-manager',
|
|
146
|
-
recommendedChain: ['release-manager', 'qa', 'docs-keeper'],
|
|
147
|
-
recommendedAction: 'release-review',
|
|
148
|
-
risk: 'medium',
|
|
149
|
-
requiresApproval: false,
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
intakeType: 'research',
|
|
153
|
-
keywords: ['competitor', 'market', 'pricing', 'positioning', 'industry', 'state of the art', 'literature', 'benchmark study', 'desk research'],
|
|
154
|
-
rdStage: 'research',
|
|
155
|
-
primaryOwner: 'business-strategist',
|
|
156
|
-
recommendedChain: ['business-strategist', 'researcher', 'product-manager'],
|
|
157
|
-
recommendedAction: 'research',
|
|
158
|
-
risk: 'low',
|
|
159
|
-
requiresApproval: false,
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
intakeType: 'user-signal',
|
|
163
|
-
keywords: ['customer', 'feedback', 'pain point', 'user says', 'user feedback', 'support ticket', 'churn', 'nps', 'usability', 'frustrated'],
|
|
164
|
-
rdStage: 'signal',
|
|
165
|
-
primaryOwner: 'product-manager',
|
|
166
|
-
recommendedChain: ['product-manager', 'ux-researcher', 'researcher'],
|
|
167
|
-
recommendedAction: 'clarify',
|
|
168
|
-
risk: 'low',
|
|
169
|
-
requiresApproval: false,
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
intakeType: 'requirement',
|
|
173
|
-
keywords: ['acceptance criteria', 'requirement', 'must have', 'should have', 'feature request', 'prd', 'use case', 'success metric'],
|
|
174
|
-
rdStage: 'framing',
|
|
175
|
-
primaryOwner: 'product-manager',
|
|
176
|
-
recommendedChain: ['product-manager', 'architect', 'engineer'],
|
|
177
|
-
recommendedAction: 'draft-prd',
|
|
178
|
-
risk: 'low',
|
|
179
|
-
requiresApproval: false,
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
intakeType: 'ops',
|
|
183
|
-
keywords: ['runbook', 'cron', 'scheduled job', 'maintenance', 'backup', 'restore', 'capacity plan', 'cost optimization', 'dependency upgrade'],
|
|
184
|
-
rdStage: 'operations',
|
|
185
|
-
primaryOwner: 'operations',
|
|
186
|
-
recommendedChain: ['operations', 'sre', 'engineer'],
|
|
187
|
-
recommendedAction: 'create-runbook',
|
|
188
|
-
risk: 'low',
|
|
189
|
-
requiresApproval: false,
|
|
190
|
-
},
|
|
191
|
-
];
|
|
42
|
+
// Static profile → table map. New curated profiles register themselves here.
|
|
43
|
+
// Custom profiles (escape hatch) declare classificationTable as a repo-relative
|
|
44
|
+
// path; that path is loaded dynamically in classifyRdIntake when a profile arg
|
|
45
|
+
// supplies it.
|
|
46
|
+
import operationsTable from './tables/operations.mjs';
|
|
47
|
+
import creativeTable from './tables/creative.mjs';
|
|
48
|
+
import researchTable from './tables/research.mjs';
|
|
49
|
+
import { outcomeBoost } from '../outcomes/aggregate.mjs';
|
|
50
|
+
|
|
51
|
+
// Outcome boost is a soft re-rank applied when a cwd is supplied. Capped at
|
|
52
|
+
// ±0.05 in outcomeBoost itself, so the primary keyword signal (integer hits)
|
|
53
|
+
// always dominates. Returns 0 if no outcome data exists for the role.
|
|
54
|
+
function outcomeBoostFor(cwd, role) {
|
|
55
|
+
if (!cwd || !role) return 0;
|
|
56
|
+
try { return outcomeBoost(cwd, role); } catch { return 0; }
|
|
57
|
+
}
|
|
192
58
|
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
recommendedAction: 'summarize',
|
|
199
|
-
risk: 'low',
|
|
200
|
-
requiresApproval: false,
|
|
59
|
+
const TABLES = {
|
|
60
|
+
rnd: rndTable,
|
|
61
|
+
operations: operationsTable,
|
|
62
|
+
creative: creativeTable,
|
|
63
|
+
research: researchTable,
|
|
201
64
|
};
|
|
202
65
|
|
|
66
|
+
function resolveTable(profile) {
|
|
67
|
+
if (!profile) return rndTable;
|
|
68
|
+
const id = typeof profile === 'string' ? profile : profile.id;
|
|
69
|
+
return TABLES[id] || rndTable;
|
|
70
|
+
}
|
|
71
|
+
|
|
203
72
|
function normalize(text) {
|
|
204
73
|
return String(text || '').toLowerCase();
|
|
205
74
|
}
|
|
@@ -225,27 +94,35 @@ function buildSignalText({ sourcePath, extractedText, related }) {
|
|
|
225
94
|
}
|
|
226
95
|
|
|
227
96
|
/**
|
|
228
|
-
* Classify an
|
|
97
|
+
* Classify an intake signal against the active profile (defaults to RND).
|
|
98
|
+
*
|
|
99
|
+
* Backward-compatible: callers that did not pass a `profile` arg keep getting
|
|
100
|
+
* RND output, byte-identical to the pre-B2 behavior.
|
|
229
101
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
102
|
+
* @param {object} input
|
|
103
|
+
* @param {string} [input.sourcePath]
|
|
104
|
+
* @param {string} [input.extractedText]
|
|
105
|
+
* @param {Array} [input.related]
|
|
106
|
+
* @param {string|object} [input.profile] - profile id (string) or full profile object
|
|
233
107
|
*/
|
|
234
|
-
export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [] } = {}) {
|
|
108
|
+
export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null, cwd = null } = {}) {
|
|
109
|
+
const table = resolveTable(profile);
|
|
235
110
|
const signal = buildSignalText({ sourcePath, extractedText, related });
|
|
236
111
|
|
|
237
112
|
let best = null;
|
|
238
|
-
for (const entry of CLASSIFICATION_TABLE) {
|
|
113
|
+
for (const entry of table.CLASSIFICATION_TABLE) {
|
|
239
114
|
const { hits, matched } = countMatches(signal, entry.keywords);
|
|
240
115
|
if (hits === 0) continue;
|
|
241
|
-
|
|
242
|
-
|
|
116
|
+
const boost = cwd ? outcomeBoostFor(cwd, entry.primaryOwner) : 0;
|
|
117
|
+
const score = hits + boost;
|
|
118
|
+
if (!best || score > best.score) {
|
|
119
|
+
best = { entry, hits, matched, score };
|
|
243
120
|
}
|
|
244
121
|
}
|
|
245
122
|
|
|
246
123
|
if (!best) {
|
|
247
124
|
return {
|
|
248
|
-
...UNKNOWN_TRIAGE,
|
|
125
|
+
...table.UNKNOWN_TRIAGE,
|
|
249
126
|
confidence: 0.3,
|
|
250
127
|
rationale: 'No classification keywords matched filename, content excerpt, or related-doc titles.',
|
|
251
128
|
};
|
|
@@ -272,7 +149,8 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
|
|
|
272
149
|
export function formatTriageLine(sourcePath, triage) {
|
|
273
150
|
const basename = sourcePath ? path.basename(sourcePath) : '(unknown source)';
|
|
274
151
|
if (!triage || triage.intakeType === 'unknown') {
|
|
275
|
-
return `${basename} → unclassified · owner: orchestrator · next: summarize`;
|
|
152
|
+
return `${basename} → unclassified · owner: ${triage?.primaryOwner ?? 'orchestrator'} · next: ${triage?.recommendedAction ?? 'summarize'}`;
|
|
276
153
|
}
|
|
277
|
-
|
|
154
|
+
const ownerLabel = triage.primaryOwner ?? 'unassigned';
|
|
155
|
+
return `${basename} → ${triage.intakeType} / ${triage.rdStage} · owner: ${ownerLabel} · next: ${triage.recommendedAction}`;
|
|
278
156
|
}
|
package/lib/intake/prepare.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { suggestDocsLaneForFile } from '../docs-routing.mjs';
|
|
|
29
29
|
import { createIntakeQueue } from './queue.mjs';
|
|
30
30
|
import { classifyRdIntake } from './classify.mjs';
|
|
31
31
|
import { detectCustomerMentions, linkSignalToCustomer, updateCustomerProfile } from '../embed/customer-profiles.mjs';
|
|
32
|
+
import { resolveActiveProfile } from '../profiles/loader.mjs';
|
|
32
33
|
|
|
33
34
|
const DEFAULT_RELATED_LIMIT = 5;
|
|
34
35
|
const EXCERPT_CHARS = 800;
|
|
@@ -67,10 +68,16 @@ export async function prepareIntakeForIngestedFile({
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
const lane = suggestDocsLaneForFile(ingestedFile.sourcePath, extracted) || null;
|
|
71
|
+
// Resolve the active profile so non-RND projects route to their own table.
|
|
72
|
+
// Falls back to rnd for any project without explicit configuration, which
|
|
73
|
+
// preserves prior behavior for every existing user.
|
|
74
|
+
const activeProfile = resolveActiveProfile(rootDir);
|
|
70
75
|
const triage = classifyFn({
|
|
71
76
|
sourcePath: ingestedFile.sourcePath,
|
|
72
77
|
extractedText: extracted,
|
|
73
78
|
related,
|
|
79
|
+
profile: activeProfile?.id,
|
|
80
|
+
cwd: rootDir,
|
|
74
81
|
});
|
|
75
82
|
|
|
76
83
|
// Detect customer mentions and update profiles
|