@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.
Files changed (90) hide show
  1. package/README.md +51 -42
  2. package/bin/construct +256 -4
  3. package/commands/understand/research.md +5 -3
  4. package/lib/auto-docs.mjs +2 -2
  5. package/lib/cli-commands.mjs +44 -0
  6. package/lib/comment-lint.mjs +7 -1
  7. package/lib/config/schema.mjs +3 -0
  8. package/lib/flavors/loader.mjs +136 -0
  9. package/lib/hooks/agent-tracker.mjs +22 -3
  10. package/lib/hooks/pre-push-gate.mjs +14 -1
  11. package/lib/hooks/session-optimize.mjs +3 -2
  12. package/lib/hooks/session-reflect.mjs +68 -0
  13. package/lib/init-unified.mjs +25 -2
  14. package/lib/intake/classify.mjs +61 -183
  15. package/lib/intake/prepare.mjs +7 -0
  16. package/lib/intake/tables/creative.mjs +94 -0
  17. package/lib/intake/tables/operations.mjs +85 -0
  18. package/lib/intake/tables/research.mjs +85 -0
  19. package/lib/intake/tables/rnd.mjs +175 -0
  20. package/lib/knowledge/research-store.mjs +109 -0
  21. package/lib/observation-store.mjs +19 -0
  22. package/lib/outcomes/aggregate.mjs +104 -0
  23. package/lib/outcomes/record.mjs +115 -0
  24. package/lib/parity.mjs +6 -9
  25. package/lib/profiles/lifecycle.mjs +388 -0
  26. package/lib/profiles/loader.mjs +123 -0
  27. package/lib/profiles/validate-custom.mjs +114 -0
  28. package/lib/reflect/extractor.mjs +193 -0
  29. package/lib/reflect.mjs +89 -2
  30. package/lib/sandbox.mjs +102 -0
  31. package/package.json +6 -1
  32. package/personas/construct.md +20 -20
  33. package/platforms/claude/settings.template.json +13 -0
  34. package/scripts/sync-agents.mjs +11 -0
  35. package/skills/roles/architect.ai-systems.md +4 -2
  36. package/skills/roles/architect.data.md +4 -2
  37. package/skills/roles/architect.enterprise.md +4 -2
  38. package/skills/roles/architect.integration.md +4 -2
  39. package/skills/roles/architect.md +7 -5
  40. package/skills/roles/architect.platform.md +4 -2
  41. package/skills/roles/data-analyst.experiment.md +4 -2
  42. package/skills/roles/data-analyst.md +9 -7
  43. package/skills/roles/data-analyst.product-intelligence.md +4 -2
  44. package/skills/roles/data-analyst.product.md +4 -2
  45. package/skills/roles/data-analyst.telemetry.md +4 -2
  46. package/skills/roles/data-engineer.pipeline.md +4 -2
  47. package/skills/roles/data-engineer.vector-retrieval.md +4 -2
  48. package/skills/roles/data-engineer.warehouse.md +4 -2
  49. package/skills/roles/debugger.md +7 -5
  50. package/skills/roles/designer.accessibility.md +4 -2
  51. package/skills/roles/designer.md +10 -8
  52. package/skills/roles/engineer.ai.md +4 -2
  53. package/skills/roles/engineer.data.md +5 -3
  54. package/skills/roles/engineer.md +14 -12
  55. package/skills/roles/engineer.platform.md +5 -3
  56. package/skills/roles/operator.docs.md +6 -4
  57. package/skills/roles/operator.md +6 -4
  58. package/skills/roles/operator.release.md +4 -2
  59. package/skills/roles/operator.sre.md +5 -3
  60. package/skills/roles/orchestrator.md +5 -3
  61. package/skills/roles/product-manager.ai-product.md +4 -2
  62. package/skills/roles/product-manager.business-strategy.md +4 -2
  63. package/skills/roles/product-manager.enterprise.md +4 -2
  64. package/skills/roles/product-manager.growth.md +4 -2
  65. package/skills/roles/product-manager.md +6 -4
  66. package/skills/roles/product-manager.platform.md +4 -2
  67. package/skills/roles/product-manager.product.md +4 -2
  68. package/skills/roles/qa.ai-eval.md +4 -2
  69. package/skills/roles/qa.api-contract.md +4 -2
  70. package/skills/roles/qa.data-pipeline.md +4 -2
  71. package/skills/roles/qa.md +7 -5
  72. package/skills/roles/qa.test-automation.md +5 -3
  73. package/skills/roles/qa.web-ui.md +4 -2
  74. package/skills/roles/researcher.explorer.md +4 -2
  75. package/skills/roles/researcher.md +11 -9
  76. package/skills/roles/researcher.ux.md +4 -2
  77. package/skills/roles/reviewer.devil-advocate.md +4 -2
  78. package/skills/roles/reviewer.evaluator.md +4 -2
  79. package/skills/roles/reviewer.md +14 -12
  80. package/skills/roles/reviewer.trace.md +4 -2
  81. package/skills/roles/security.ai.md +4 -2
  82. package/skills/roles/security.appsec.md +4 -2
  83. package/skills/roles/security.cloud.md +4 -2
  84. package/skills/roles/security.legal-compliance.md +4 -2
  85. package/skills/roles/security.md +7 -5
  86. package/skills/roles/security.privacy.md +4 -2
  87. package/skills/roles/security.supply-chain.md +4 -2
  88. package/templates/docs/persona-artifact.md +36 -0
  89. package/templates/docs/research-finding.md +26 -0
  90. 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
- jobs.push({ label: 'audit', cmd: 'npm', args: ['audit', '--omit=dev', '--audit-level=high'], timeout: 30_000 });
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
- // Run construct optimize with auto-apply (no --dry-run)
132
- const result = spawnSync(CONSTRUCT_BIN, ['optimize', agentName, `--days=${OPTIMIZATION_DAYS}`], {
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);
@@ -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({
@@ -1,47 +1,26 @@
1
1
  /**
2
- * lib/intake/classify.mjs — R&D triage classification for intake packets.
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
- * onto the R&D loop: intakeType (what kind of work), rdStage (where in
6
- * the loop), primaryOwner persona, recommendedChain (handoff sequence),
7
- * recommendedAction, risk, requiresApproval, confidence, rationale.
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
- * Persona names match agents/registry.json (unprefixed).
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
- export const INTAKE_TYPES = [
17
- 'user-signal',
18
- 'bug',
19
- 'requirement',
20
- 'research',
21
- 'experiment',
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
- * Classification table intakeType triage spine.
65
- * Keyword set drives detection; the rest of the row is the canonical mapping
66
- * for that signal class. Order matters for tie-breaking: earlier rows win on
67
- * equal raw scores so that high-stakes classes (security, incident) take
68
- * precedence over generic ones (research) when keywords overlap.
69
- */
70
- const CLASSIFICATION_TABLE = [
71
- {
72
- intakeType: 'security',
73
- keywords: ['security', 'secret', 'cve', 'vulnerability', 'vuln', 'exploit', 'leak', 'auth bypass', 'privilege escalation', 'sqli', 'xss', 'csrf', 'rce'],
74
- rdStage: 'operations',
75
- primaryOwner: 'security',
76
- recommendedChain: ['security', 'engineer', 'reviewer'],
77
- recommendedAction: 'diagnose',
78
- risk: 'high',
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 UNKNOWN_TRIAGE = {
194
- intakeType: 'unknown',
195
- rdStage: 'unknown',
196
- primaryOwner: 'orchestrator',
197
- recommendedChain: ['orchestrator'],
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 R&D intake signal.
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
- * Returns a triage object with: intakeType, rdStage, primaryOwner,
231
- * recommendedChain, recommendedAction, risk, requiresApproval, confidence,
232
- * rationale. The function is pure and deterministic; same input → same output.
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
- if (!best || hits > best.hits) {
242
- best = { entry, hits, matched };
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
- return `${basename} ${triage.intakeType} / ${triage.rdStage} · owner: ${triage.primaryOwner} · next: ${triage.recommendedAction}`;
154
+ const ownerLabel = triage.primaryOwner ?? 'unassigned';
155
+ return `${basename} → ${triage.intakeType} / ${triage.rdStage} · owner: ${ownerLabel} · next: ${triage.recommendedAction}`;
278
156
  }
@@ -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