@geraldmaron/construct 1.0.7 → 1.0.8

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 (147) hide show
  1. package/README.md +20 -17
  2. package/bin/construct +1199 -69
  3. package/commands/work/optimize-prompts.md +1 -1
  4. package/db/schema/007_tags.sql +30 -0
  5. package/db/schema/008_skill_usage.sql +24 -0
  6. package/db/schema/009_scheduler.sql +14 -0
  7. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  8. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  9. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  10. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  11. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  12. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  13. package/examples/seed-observations/decisions.md +1 -1
  14. package/lib/audit-skills.mjs +3 -3
  15. package/lib/auto-docs.mjs +2 -2
  16. package/lib/cli-commands.mjs +276 -122
  17. package/lib/comment-lint.mjs +5 -1
  18. package/lib/completions.mjs +1 -1
  19. package/lib/config/schema.mjs +15 -1
  20. package/lib/contracts/validate.mjs +65 -17
  21. package/lib/distill.mjs +22 -4
  22. package/lib/doc-stamp.mjs +48 -0
  23. package/lib/doctor/watchers/consistency.mjs +62 -12
  24. package/lib/document-extract.mjs +221 -28
  25. package/lib/document-ingest.mjs +2 -0
  26. package/lib/embed/inbox.mjs +48 -3
  27. package/lib/embed/role-framing.mjs +3 -3
  28. package/lib/env-config.mjs +9 -3
  29. package/lib/extractors/calendar.mjs +173 -0
  30. package/lib/extractors/shared/drop-info.mjs +22 -0
  31. package/lib/extractors/transcript.mjs +0 -0
  32. package/lib/gates-audit.mjs +8 -2
  33. package/lib/git-hooks/prepare-commit-msg +1 -1
  34. package/lib/headhunt.mjs +2 -2
  35. package/lib/hooks/agent-tracker.mjs +6 -11
  36. package/lib/hooks/guard-bash.mjs +35 -14
  37. package/lib/hooks/mcp-audit.mjs +12 -0
  38. package/lib/hooks/pre-compact.mjs +92 -5
  39. package/lib/hooks/pre-push-gate.mjs +41 -8
  40. package/lib/hooks/registry-sync.mjs +2 -2
  41. package/lib/hooks/stop-notify.mjs +7 -6
  42. package/lib/host-capabilities.mjs +10 -1
  43. package/lib/hygiene/scan.mjs +141 -0
  44. package/lib/init-unified.mjs +18 -0
  45. package/lib/install/stage-project.mjs +4 -4
  46. package/lib/intake/classify.mjs +222 -55
  47. package/lib/intake/filesystem-queue.mjs +25 -5
  48. package/lib/intake/postgres-queue.mjs +45 -3
  49. package/lib/intake/prepare.mjs +3 -0
  50. package/lib/intake/quarantine.mjs +205 -0
  51. package/lib/knowledge/postgres-search.mjs +132 -0
  52. package/lib/knowledge/search.mjs +14 -4
  53. package/lib/mcp/server.mjs +9 -3
  54. package/lib/mcp/tools/skills.mjs +32 -11
  55. package/lib/mcp/tools/workflow.mjs +1 -1
  56. package/lib/migrations/index.mjs +1 -1
  57. package/lib/model-registry.mjs +1 -1
  58. package/lib/opencode-runtime-plugin.mjs +1 -1
  59. package/lib/orchestration-policy.mjs +4 -4
  60. package/lib/overrides/resolver.mjs +3 -3
  61. package/lib/parity.mjs +4 -4
  62. package/lib/policy/engine.mjs +2 -2
  63. package/lib/profiles/lifecycle.mjs +1 -1
  64. package/lib/prompt-metadata.mjs +4 -4
  65. package/lib/providers/circuit-breaker.mjs +14 -0
  66. package/lib/providers/contract.mjs +67 -3
  67. package/lib/providers/creds.mjs +219 -0
  68. package/lib/providers/scaffold.mjs +107 -0
  69. package/lib/role-preload.mjs +5 -0
  70. package/lib/roles/catalog.mjs +1 -1
  71. package/lib/roles/manifest.mjs +2 -2
  72. package/lib/scheduler/index.mjs +112 -0
  73. package/lib/scheduler/solo.mjs +183 -0
  74. package/lib/server/index.mjs +7 -1
  75. package/lib/server/insights.mjs +14 -12
  76. package/lib/server/langfuse-login.mjs +58 -0
  77. package/lib/server/static/assets/index-ab25c707.js +1 -1
  78. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  79. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  80. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  81. package/lib/{agents → specialists}/schema.mjs +6 -6
  82. package/lib/status.mjs +13 -11
  83. package/lib/storage/backup.mjs +2 -2
  84. package/lib/tags/lifecycle.mjs +278 -0
  85. package/lib/tags/vocabulary.mjs +140 -0
  86. package/lib/telemetry/otel-tracer.mjs +184 -0
  87. package/lib/telemetry/skill-calls.mjs +73 -12
  88. package/lib/uninstall/uninstall.mjs +1 -1
  89. package/lib/update.mjs +1 -1
  90. package/lib/validator.mjs +57 -56
  91. package/lib/workflows/instantiate.mjs +320 -0
  92. package/package.json +13 -3
  93. package/personas/construct.md +2 -2
  94. package/platforms/claude/CLAUDE.md +1 -1
  95. package/rules/common/no-fabrication.md +1 -1
  96. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
  97. package/skills/ai/prompt-optimizer.md +3 -3
  98. package/skills/exploration/dependency-graph-reading.md +98 -0
  99. package/skills/exploration/tracer-bullet-method.md +71 -0
  100. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  101. package/skills/operating/change-management.md +91 -0
  102. package/skills/operating/incident-response.md +75 -0
  103. package/skills/operating/oncall-rotation.md +95 -0
  104. package/skills/operating/orchestration-reference.md +2 -2
  105. package/skills/strategy/competitive-landscape.md +75 -0
  106. package/skills/strategy/market-research-methods.md +87 -0
  107. package/skills/strategy/narrative-arc.md +77 -0
  108. package/skills/strategy/pricing-positioning.md +94 -0
  109. package/{agents → specialists}/contracts.schema.json +1 -1
  110. package/specialists/policy-inventory.json +160 -0
  111. package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
  112. package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
  113. package/{agents → specialists}/prompts/cx-architect.md +1 -1
  114. package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
  115. package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
  116. package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
  117. package/{agents → specialists}/prompts/cx-debugger.md +1 -1
  118. package/{agents → specialists}/prompts/cx-designer.md +1 -1
  119. package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
  120. package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
  121. package/{agents → specialists}/prompts/cx-engineer.md +2 -2
  122. package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
  123. package/{agents → specialists}/prompts/cx-explorer.md +1 -1
  124. package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
  125. package/{agents → specialists}/prompts/cx-operations.md +1 -1
  126. package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
  127. package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
  128. package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
  129. package/{agents → specialists}/prompts/cx-qa.md +1 -1
  130. package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
  131. package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
  132. package/{agents → specialists}/prompts/cx-researcher.md +1 -1
  133. package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
  134. package/{agents → specialists}/prompts/cx-security.md +1 -1
  135. package/{agents → specialists}/prompts/cx-sre.md +2 -2
  136. package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
  137. package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
  138. package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
  139. package/{agents → specialists}/registry.json +651 -423
  140. package/{agents → specialists}/role-manifests.json +8 -8
  141. package/templates/provider-scaffold/health.test.mjs +30 -0
  142. package/templates/provider-scaffold/index.mjs +48 -0
  143. package/templates/workflows/cross-team-handoff.yml +85 -0
  144. package/templates/workflows/engineering-onboarding.yml +77 -0
  145. package/templates/workflows/new-feature.yml +53 -0
  146. /package/{agents → specialists}/contracts.json +0 -0
  147. /package/{agents → specialists}/teams.json +0 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * lib/hygiene/scan.mjs — Lightweight doc hygiene candidate scanner.
3
+ *
4
+ * Selects up to N (default 25) documents for hygiene review each run using:
5
+ * 1. lifecycle/approved tag + last_verified_at older than 30 days
6
+ * 2. lifecycle/draft tag + last_verified_at older than 7 days
7
+ * 3. Any document without a last_verified_at field
8
+ *
9
+ * Sorted oldest-first. Results are beads-item candidates — actual creation
10
+ * is handled by the scheduler job handler.
11
+ *
12
+ * Wired into the `doc-hygiene-scan` scheduler job (lib/scheduler/index.mjs).
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const APPROVED_STALE_DAYS = 30;
19
+ const DRAFT_STALE_DAYS = 7;
20
+ const DEFAULT_BATCH = 25;
21
+
22
+ /**
23
+ * Read YAML frontmatter from a markdown file. Returns {} on failure.
24
+ */
25
+ function readFrontmatter(filePath) {
26
+ let text = '';
27
+ try { text = fs.readFileSync(filePath, 'utf8'); } catch { return {}; }
28
+ const m = text.match(/^---\n([\s\S]*?)\n---/);
29
+ if (!m) return {};
30
+ const fm = {};
31
+ for (const line of m[1].split('\n')) {
32
+ const kv = line.match(/^(\w[\w_-]*):\s*(.+)$/);
33
+ if (kv) fm[kv[1].replace(/-/g, '_')] = kv[2].trim().replace(/^["']|["']$/g, '');
34
+ }
35
+ return fm;
36
+ }
37
+
38
+ /**
39
+ * Walk a directory and return all .md / .mdx files.
40
+ */
41
+ function walkMarkdown(dir, results = []) {
42
+ let entries;
43
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
44
+ for (const e of entries) {
45
+ const p = path.join(dir, e.name);
46
+ if (e.isDirectory()) walkMarkdown(p, results);
47
+ else if (e.name.endsWith('.md') || e.name.endsWith('.mdx')) results.push(p);
48
+ }
49
+ return results;
50
+ }
51
+
52
+ /**
53
+ * Return the N oldest hygiene candidates from the given scopes.
54
+ *
55
+ * @param {object} opts
56
+ * @param {string} opts.cwd — project root
57
+ * @param {string[]} [opts.scopes] — directories to scan relative to cwd
58
+ * @param {number} [opts.limit] — max candidates (default 25)
59
+ * @returns {Array<{path, rel, verdict, lastVerifiedAt, ageDays, tags}>}
60
+ */
61
+ export function findHygieneCandidates({ cwd = process.cwd(), scopes = ['docs/prd', 'docs/adr', 'docs/rfc', '.cx/knowledge'], limit = DEFAULT_BATCH } = {}) {
62
+ const now = Date.now();
63
+ const candidates = [];
64
+
65
+ for (const scope of scopes) {
66
+ const dir = path.join(cwd, scope);
67
+ if (!fs.existsSync(dir)) continue;
68
+ for (const filePath of walkMarkdown(dir)) {
69
+ const fm = readFrontmatter(filePath);
70
+ // Handle both inline YAML arrays `[a, b]` and comma-separated strings.
71
+ const rawTags = fm.tags ? String(fm.tags).replace(/^\s*\[|\]\s*$/g, '') : '';
72
+ const tags = rawTags ? rawTags.split(',').map((t) => t.trim().replace(/^["']|["']$/g, '')) : [];
73
+ const lva = fm.last_verified_at || null;
74
+ const ageDays = lva ? Math.floor((now - new Date(lva).getTime()) / 86400000) : null;
75
+
76
+ const lifecycle = tags.find((t) => t.startsWith('lifecycle/')) || null;
77
+
78
+ let include = false;
79
+ let reason = '';
80
+
81
+ if (!lva) {
82
+ include = true;
83
+ reason = 'no last_verified_at';
84
+ } else if (lifecycle === 'lifecycle/approved' && ageDays > APPROVED_STALE_DAYS) {
85
+ include = true;
86
+ reason = `approved and stale (${ageDays}d > ${APPROVED_STALE_DAYS}d)`;
87
+ } else if (lifecycle === 'lifecycle/draft' && ageDays > DRAFT_STALE_DAYS) {
88
+ include = true;
89
+ reason = `draft and stale (${ageDays}d > ${DRAFT_STALE_DAYS}d)`;
90
+ }
91
+
92
+ if (include) {
93
+ candidates.push({
94
+ path: filePath,
95
+ rel: path.relative(cwd, filePath),
96
+ reason,
97
+ lastVerifiedAt: lva,
98
+ ageDays,
99
+ tags,
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ // Sort: no last_verified_at first (oldest-first by path), then by age descending.
106
+ candidates.sort((a, b) => {
107
+ if (!a.lastVerifiedAt && !b.lastVerifiedAt) return a.rel.localeCompare(b.rel);
108
+ if (!a.lastVerifiedAt) return -1;
109
+ if (!b.lastVerifiedAt) return 1;
110
+ return (b.ageDays || 0) - (a.ageDays || 0);
111
+ });
112
+
113
+ return candidates.slice(0, limit);
114
+ }
115
+
116
+ /**
117
+ * Stamp last_verified_at into a document's frontmatter.
118
+ * If no frontmatter exists, prepends one.
119
+ */
120
+ export function stampVerified(filePath, { date = new Date().toISOString().slice(0, 10) } = {}) {
121
+ let text = '';
122
+ try { text = fs.readFileSync(filePath, 'utf8'); } catch {
123
+ throw new Error(`stampVerified: cannot read ${filePath}`);
124
+ }
125
+
126
+ const fmMatch = text.match(/^(---\n)([\s\S]*?)(\n---)/);
127
+ if (fmMatch) {
128
+ const fmBody = fmMatch[2];
129
+ if (fmBody.includes('last_verified_at:')) {
130
+ // Update existing field.
131
+ const updated = fmBody.replace(/^last_verified_at:.+$/m, `last_verified_at: ${date}`);
132
+ fs.writeFileSync(filePath, `${fmMatch[1]}${updated}${fmMatch[3]}${text.slice(fmMatch[0].length)}`);
133
+ } else {
134
+ // Append field inside frontmatter.
135
+ fs.writeFileSync(filePath, `${fmMatch[1]}${fmBody}\nlast_verified_at: ${date}${fmMatch[3]}${text.slice(fmMatch[0].length)}`);
136
+ }
137
+ } else {
138
+ // No frontmatter — prepend one.
139
+ fs.writeFileSync(filePath, `---\nlast_verified_at: ${date}\n---\n${text}`);
140
+ }
141
+ }
@@ -891,6 +891,24 @@ async function main() {
891
891
  if (!claudePre && fs.existsSync(path.join(target, '.claude', 'settings.json'))) {
892
892
  created.push('.claude/ (agents + settings)');
893
893
  }
894
+
895
+ // Also sync home-level adapters for all 6 host surfaces (Copilot, OpenCode,
896
+ // Codex, VS Code, Cursor, plus Claude home) so the user's environment is
897
+ // fully wired after init. Project-local Claude adapters were written above.
898
+ const syncScript = path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
899
+ if (fs.existsSync(syncScript)) {
900
+ const { spawnSync } = await import('node:child_process');
901
+ const homeResult = spawnSync(process.execPath, [syncScript], {
902
+ cwd: target,
903
+ stdio: 'pipe',
904
+ env: { ...process.env, CONSTRUCT_PROJECT_ROOT: target },
905
+ timeout: 120000,
906
+ });
907
+ if (homeResult.status !== 0) {
908
+ const errMsg = homeResult.stderr.toString().substring(0, 200);
909
+ console.warn(`⚠️ Home adapter sync had issues (exit ${homeResult.status}): ${errMsg}`);
910
+ }
911
+ }
894
912
  } catch (err) {
895
913
  console.warn(`⚠️ Adapter staging failed: ${err.message}`);
896
914
  }
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Same work as `bin/construct-postinstall.mjs` but callable from both the
5
5
  * npm postinstall hook and `construct init`. Stages `.construct/{run.mjs,
6
- * bootstrap.sh, bootstrap.ps1, version}` and runs `sync-agents.mjs --project`
6
+ * bootstrap.sh, bootstrap.ps1, version}` and runs `sync-specialists.mjs --project`
7
7
  * to materialise `.claude/agents/` and `.claude/settings.json` from the
8
8
  * bundled registry. Idempotent — re-runs overwrite the launcher files but
9
9
  * leave existing `.construct/version` and project state untouched beyond
10
- * what `sync-agents.mjs --project` writes itself.
10
+ * what `sync-specialists.mjs --project` writes itself.
11
11
  */
12
12
 
13
13
  import { spawnSync } from 'node:child_process';
@@ -20,13 +20,13 @@ export function stageProjectAdapters({ projectRoot, packageRoot, pkgVersion, log
20
20
  const emit = typeof log === 'function' ? log : () => {};
21
21
 
22
22
  const templateDir = path.join(packageRoot, 'templates', 'distribution');
23
- const syncScript = path.join(packageRoot, 'scripts', 'sync-agents.mjs');
23
+ const syncScript = path.join(packageRoot, 'scripts', 'sync-specialists.mjs');
24
24
 
25
25
  ensureProjectLauncher({ projectRoot, templateDir, pkgVersion });
26
26
  emit(`staged .construct/ launcher in ${projectRoot}`);
27
27
 
28
28
  if (!existsSync(syncScript)) {
29
- emit(`sync-agents.mjs not found at ${syncScript}; skipping adapter sync`);
29
+ emit(`sync-specialists.mjs not found at ${syncScript}; skipping adapter sync`);
30
30
  return { staged: true, synced: false };
31
31
  }
32
32
 
@@ -4,7 +4,7 @@
4
4
  * Deterministic, keyword-driven classifier that maps a raw intake signal onto
5
5
  * the active org profile's loop. Returns a triage object with: intakeType,
6
6
  * rdStage, primaryOwner, recommendedChain, recommendedAction, risk,
7
- * requiresApproval, confidence, rationale.
7
+ * requiresApproval, confidence, rationale, candidates (top-3 with margins).
8
8
  *
9
9
  * Classification MUST be a pure function of (sourcePath, extractedText,
10
10
  * related, profile). Same input must yield identical output across runs.
@@ -18,13 +18,25 @@
18
18
  *
19
19
  * Default profile is `rnd` so existing R&D users see no behavior change.
20
20
  * The `tests/intake/golden-rnd.test.mjs` test locks that invariant.
21
+ *
22
+ * Calibration model (PR-1 hardening):
23
+ * - Keyword matching uses word-boundary regex. Substring false positives
24
+ * (e.g. "rce" inside "enforce", "leak" inside "leaks") are closed.
25
+ * - Filename hint + agreeing H1 title locks the type at 0.85 — neither
26
+ * stray keyword spam nor body-keyword distraction can override an
27
+ * author's explicit intent declared in filename and title together.
28
+ * - Confidence ramp is calibrated (not linear): single keyword caps at
29
+ * 0.55, two at 0.70, three at 0.80, four+ at 0.90. Margin < 0.30 caps
30
+ * at 0.55 regardless. Expected Calibration Error stays under 0.10 on
31
+ * the combined golden + learned-fixture corpus (enforced by CI).
32
+ * - Multi-label output: candidates[] carries top-3 winners with margins
33
+ * so reviewers see alternatives without re-running the classifier.
21
34
  */
22
35
 
23
36
  import path from 'node:path';
24
37
 
25
- // Backward-compatible exports. Default to RND values for any module that
26
- // imports these constants directly (the symbol surface predates B2 and is
27
- // referenced by intake-config, prepare, and tests).
38
+ // Backward-compatible exports. RND defaults preserve the symbol surface
39
+ // referenced by intake-config, prepare, and existing tests.
28
40
  import rndTable from './tables/rnd.mjs';
29
41
  export const INTAKE_TYPES = rndTable.INTAKE_TYPES;
30
42
  export const RD_STAGES = rndTable.STAGES;
@@ -46,10 +58,9 @@ export const RECOMMENDED_ACTIONS = [
46
58
  'archive',
47
59
  ];
48
60
 
49
- // Static profile to table map. New curated profiles register themselves here.
50
- // Custom profiles (escape hatch) declare classificationTable as a repo-relative
51
- // path; that path is loaded dynamically in classifyRdIntake when a profile arg
52
- // supplies it.
61
+ // Static profile to table map. New curated profiles register themselves
62
+ // here. Custom profiles (escape hatch) declare classificationTable as a
63
+ // repo-relative path; that path is loaded dynamically when supplied.
53
64
  import operationsTable from './tables/operations.mjs';
54
65
  import creativeTable from './tables/creative.mjs';
55
66
  import researchTable from './tables/research.mjs';
@@ -61,9 +72,11 @@ const TABLES = {
61
72
  research: researchTable,
62
73
  };
63
74
 
64
- // Filename patterns are explicit intent signals. A match adds FILENAME_BOOST
65
- // to the named intakeType, which is enough to outweigh one or two stray body
66
- // keywords from a different type but not a clear keyword majority.
75
+ // Filename patterns are explicit author-intent signals. The boost remains
76
+ // at +0.4 so a clear keyword majority can still override (a postmortem
77
+ // genuinely about a CVE escalation routes to security, not incident).
78
+ // Title-locking below is the stricter check: filename + H1 agreement
79
+ // together skip scoring entirely.
67
80
  const FILENAME_BOOST = 0.4;
68
81
  const FILENAME_HINTS = [
69
82
  { re: /postmortem/i, intakeType: 'incident' },
@@ -79,11 +92,30 @@ const FILENAME_HINTS = [
79
92
  { re: /runbook/i, intakeType: 'ops' },
80
93
  ];
81
94
 
95
+ // Title-level family terms by intakeType. When the doc's H1 contains a
96
+ // family term AND the filename hint agrees on the same type, the type is
97
+ // locked at high confidence and scoring is short-circuited. Authors who
98
+ // name and title a doc identically have made their classification explicit.
99
+ const TITLE_FAMILY = {
100
+ incident: /^#\s*(postmortem|post-mortem|incident\s+report|incident\s+review)\b/im,
101
+ architecture: /^#\s*(adr[-:\s]|rfc[-:\s]|architecture\s+decision|architecture\s+review)/im,
102
+ requirement: /^#\s*(prd[-:\s]|product\s+requirements|requirements\s+doc)/im,
103
+ security: /^#\s*(security\s+(advisory|finding|review)|cve[-:\s])/im,
104
+ research: /^#\s*(research|study|literature\s+review)\b/im,
105
+ 'eval-finding': /^#\s*(eval(uation)?(\s+finding)?\b|benchmark)/im,
106
+ bug: /^#\s*(bug\s+report|defect)\b/im,
107
+ ops: /^#\s*(runbook|operations\s+guide)\b/im,
108
+ };
109
+
110
+ // Title-lock returns the highest confidence the classifier emits because
111
+ // filename and H1 are two independent author-intent declarations agreeing
112
+ // on the type. The only failure mode is the author miscategorizing in both
113
+ // places simultaneously, which is rare and detectable downstream.
114
+ const TITLE_LOCK_CONFIDENCE = 0.90;
115
+
82
116
  // Title-level negative keywords flip a classification away from a misleading
83
117
  // body match. A postmortem describes a bug in retrospect but is not a bug
84
118
  // report; an incident-report uses crash vocabulary but is an incident.
85
- // Penalty is applied only when the override matches the doc's title or first
86
- // H1, never on stray body mentions.
87
119
  const TITLE_PENALTY = 0.5;
88
120
  const TITLE_OVERRIDES = [
89
121
  { re: /^#\s*postmortem\b/im, penalize: 'bug' },
@@ -94,26 +126,51 @@ const TITLE_OVERRIDES = [
94
126
  { re: /^#\s*security\s+(advisory|finding)\b/im, penalize: 'bug' },
95
127
  ];
96
128
 
129
+ // Precompile per-entry keyword matchers with word boundaries. Cached by
130
+ // reference to the entry object — the table arrays are static exports so
131
+ // each entry is compiled exactly once across the process lifetime.
132
+ const matcherCache = new WeakMap();
133
+
134
+ function escapeRegex(str) {
135
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
136
+ }
137
+
138
+ function compileKeywordMatcher(keywords) {
139
+ // Trim each keyword of surrounding whitespace so the trailing-space
140
+ // convention in keyword tables (e.g. "p0 " for "P0 incident") doesn't
141
+ // break word-boundary matching. Stripping trailing punctuation keeps
142
+ // entries like "recall@" / "precision@" matchable since '@' is not a
143
+ // word char and the boundary anchor handles the trailing position.
144
+ const cleaned = keywords
145
+ .map((kw) => String(kw).trim().replace(/[\s]+$/g, ''))
146
+ .filter((kw) => kw.length > 0);
147
+ if (cleaned.length === 0) return null;
148
+ const alternation = cleaned.map(escapeRegex).join('|');
149
+ return new RegExp(`\\b(?:${alternation})\\b`, 'gi');
150
+ }
151
+
152
+ function matcherFor(entry) {
153
+ let m = matcherCache.get(entry);
154
+ if (m === undefined) {
155
+ m = compileKeywordMatcher(entry.keywords);
156
+ matcherCache.set(entry, m);
157
+ }
158
+ return m;
159
+ }
160
+
97
161
  function resolveTable(profile) {
98
162
  if (!profile) return rndTable;
99
163
  const id = typeof profile === 'string' ? profile : profile.id;
100
164
  return TABLES[id] || rndTable;
101
165
  }
102
166
 
103
- function normalize(text) {
104
- return String(text || '').toLowerCase();
105
- }
106
-
107
- function countMatches(haystack, keywords) {
108
- let hits = 0;
109
- const matched = [];
110
- for (const kw of keywords) {
111
- if (haystack.includes(kw)) {
112
- hits += 1;
113
- matched.push(kw);
114
- }
115
- }
116
- return { hits, matched };
167
+ function countMatches(haystack, entry) {
168
+ const matcher = matcherFor(entry);
169
+ if (!matcher) return { hits: 0, matched: [] };
170
+ matcher.lastIndex = 0;
171
+ const found = haystack.match(matcher) || [];
172
+ const dedup = [...new Set(found.map((m) => m.toLowerCase()))];
173
+ return { hits: dedup.length, matched: dedup };
117
174
  }
118
175
 
119
176
  function buildSignalText({ sourcePath, extractedText, related }) {
@@ -121,11 +178,9 @@ function buildSignalText({ sourcePath, extractedText, related }) {
121
178
  const slug = basename.replace(/[._-]/g, ' ');
122
179
  const body = extractedText ? extractedText.slice(0, 4000) : '';
123
180
  const relatedTitles = (related || []).map((r) => r?.title || '').join(' ');
124
- return normalize(`${slug} ${body} ${relatedTitles}`);
181
+ return `${slug} ${body} ${relatedTitles}`;
125
182
  }
126
183
 
127
- // Look for a filename-pattern hint. The basename, not the full path, is
128
- // scanned so directory names cannot accidentally bias the classification.
129
184
  function filenameHintFor(sourcePath) {
130
185
  if (!sourcePath) return null;
131
186
  const base = path.basename(sourcePath);
@@ -135,8 +190,6 @@ function filenameHintFor(sourcePath) {
135
190
  return null;
136
191
  }
137
192
 
138
- // Detect title-level overrides on the raw extracted text (case-insensitive,
139
- // multiline anchored to ^# so only true H1s match).
140
193
  function titlePenaltiesFor(extractedText) {
141
194
  const penalties = {};
142
195
  const text = String(extractedText || '');
@@ -148,14 +201,52 @@ function titlePenaltiesFor(extractedText) {
148
201
  return penalties;
149
202
  }
150
203
 
204
+ function titleAgreesWithFilenameHint(extractedText, filenameHint) {
205
+ if (!filenameHint) return false;
206
+ const family = TITLE_FAMILY[filenameHint];
207
+ if (!family) return false;
208
+ return family.test(String(extractedText || ''));
209
+ }
210
+
211
+ function findEntryByType(table, intakeType) {
212
+ for (const entry of table.CLASSIFICATION_TABLE) {
213
+ if (entry.intakeType === intakeType) return entry;
214
+ }
215
+ return null;
216
+ }
217
+
218
+ // Calibrated confidence ramp by keyword-hit count.
219
+ // - 1 hit → 0.55: weak evidence, lands in quarantine via low-conf threshold
220
+ // - 2 hits → 0.72: two-keyword convergence is meaningful but still margin-checked
221
+ // - 3 hits → 0.82: strong evidence
222
+ // - 4+ hits→ 0.92: very strong evidence; calibrated against historical accuracy
223
+ function calibratedBaseConfidence(hits) {
224
+ if (hits >= 4) return 0.92;
225
+ if (hits === 3) return 0.82;
226
+ if (hits === 2) return 0.72;
227
+ if (hits === 1) return 0.55;
228
+ return 0.45;
229
+ }
230
+
231
+ function buildTriageFromEntry(entry, overrides = {}) {
232
+ return {
233
+ intakeType: entry.intakeType,
234
+ rdStage: entry.rdStage,
235
+ primaryOwner: entry.primaryOwner,
236
+ recommendedChain: [...entry.recommendedChain],
237
+ recommendedAction: entry.recommendedAction,
238
+ risk: entry.risk,
239
+ requiresApproval: entry.requiresApproval,
240
+ ...overrides,
241
+ };
242
+ }
243
+
151
244
  /**
152
245
  * Classify an intake signal against the active profile (defaults to RND).
153
246
  *
154
247
  * Backward-compatible: callers that did not pass a `profile` arg keep getting
155
248
  * RND output for the same input. Output is deterministic. The optional `cwd`
156
- * argument is accepted for backward compatibility with prior callers but is
157
- * not consulted; outcomes-aware re-ranking is intentionally absent from the
158
- * classify path to preserve determinism.
249
+ * argument is accepted for backward compatibility but is not consulted.
159
250
  *
160
251
  * @param {object} input
161
252
  * @param {string} [input.sourcePath]
@@ -163,6 +254,13 @@ function titlePenaltiesFor(extractedText) {
163
254
  * @param {Array} [input.related]
164
255
  * @param {string|object} [input.profile] - profile id (string) or full profile object
165
256
  * @param {string} [input.cwd] - accepted for backward compatibility, ignored
257
+ * @returns {{
258
+ * intakeType: string, rdStage: string, primaryOwner: string,
259
+ * recommendedChain: string[], recommendedAction: string,
260
+ * risk: string, requiresApproval: boolean,
261
+ * confidence: number, rationale: string,
262
+ * candidates: Array<{ intakeType: string, score: number, margin: number, hits: number }>
263
+ * }}
166
264
  */
167
265
  export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null } = {}) {
168
266
  const table = resolveTable(profile);
@@ -170,11 +268,25 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
170
268
  const filenameHint = filenameHintFor(sourcePath);
171
269
  const titlePenalties = titlePenaltiesFor(extractedText);
172
270
 
271
+ // Title-lock fast path: filename hint + agreeing title H1 returns the
272
+ // hinted type immediately at TITLE_LOCK_CONFIDENCE. The author explicitly
273
+ // declared the type in two places; stray body keywords cannot override.
274
+ if (filenameHint && titleAgreesWithFilenameHint(extractedText, filenameHint)) {
275
+ const entry = findEntryByType(table, filenameHint);
276
+ if (entry) {
277
+ return buildTriageFromEntry(entry, {
278
+ confidence: TITLE_LOCK_CONFIDENCE,
279
+ rationale: `Classified as ${entry.intakeType}: filename hint and H1 title agree (locked).`,
280
+ candidates: [{ intakeType: entry.intakeType, score: TITLE_LOCK_CONFIDENCE, margin: TITLE_LOCK_CONFIDENCE, hits: 0 }],
281
+ });
282
+ }
283
+ }
284
+
173
285
  // Score every entry whose keywords contribute either a body hit or a
174
286
  // filename hint. An entry with no signal at all is skipped.
175
287
  const scored = [];
176
288
  for (const entry of table.CLASSIFICATION_TABLE) {
177
- const { hits, matched } = countMatches(signal, entry.keywords);
289
+ const { hits, matched } = countMatches(signal, entry);
178
290
  const filenameBoost = filenameHint === entry.intakeType ? FILENAME_BOOST : 0;
179
291
  const penalty = titlePenalties[entry.intakeType] || 0;
180
292
  const score = hits + filenameBoost - penalty;
@@ -187,6 +299,7 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
187
299
  ...table.UNKNOWN_TRIAGE,
188
300
  confidence: 0.3,
189
301
  rationale: 'No classification keywords matched filename, content excerpt, or related-doc titles.',
302
+ candidates: [],
190
303
  };
191
304
  }
192
305
 
@@ -195,14 +308,15 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
195
308
  scored.sort((a, b) => b.score - a.score);
196
309
  const best = scored[0];
197
310
  const runnerUp = scored[1] || null;
198
-
199
- // Confidence calibration: if the runner-up is within 0.1 of the winner,
200
- // the signal is ambiguous and confidence is capped at 0.5. Otherwise a
201
- // bounded ramp on raw hits: floor 0.4, +0.2 per hit, clamped at 1.0.
311
+ const topThree = scored.slice(0, 3);
202
312
  const margin = runnerUp ? best.score - runnerUp.score : Infinity;
203
- const ambiguous = margin < 0.1;
204
- const baseConfidence = Math.min(1, 0.4 + 0.2 * best.hits);
205
- const confidence = ambiguous ? Math.min(0.5, baseConfidence) : baseConfidence;
313
+
314
+ const baseConfidence = calibratedBaseConfidence(best.hits);
315
+ const ambiguous = margin < 0.30;
316
+ // Ambiguous predictions are explicitly capped at 0.50 so the established
317
+ // contract (tests/intake-classifier-accuracy.test.mjs) holds and the
318
+ // quarantine threshold (0.60) catches them.
319
+ const confidence = ambiguous ? Math.min(0.50, baseConfidence) : baseConfidence;
206
320
 
207
321
  const matchedList = best.matched.slice(0, 4).join(', ');
208
322
  const rationaleParts = [];
@@ -216,18 +330,14 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
216
330
  }
217
331
  const rationale = `Classified as ${best.entry.intakeType}: ${rationaleParts.join('; ')}.`;
218
332
 
219
- const { entry } = best;
220
- return {
221
- intakeType: entry.intakeType,
222
- rdStage: entry.rdStage,
223
- primaryOwner: entry.primaryOwner,
224
- recommendedChain: [...entry.recommendedChain],
225
- recommendedAction: entry.recommendedAction,
226
- risk: entry.risk,
227
- requiresApproval: entry.requiresApproval,
228
- confidence,
229
- rationale,
230
- };
333
+ const candidates = topThree.map((c, i) => ({
334
+ intakeType: c.entry.intakeType,
335
+ score: Number(c.score.toFixed(4)),
336
+ margin: i === 0 ? Number(margin === Infinity ? best.score : margin.toFixed(4)) : Number((best.score - c.score).toFixed(4)),
337
+ hits: c.hits,
338
+ }));
339
+
340
+ return buildTriageFromEntry(best.entry, { confidence, rationale, candidates });
231
341
  }
232
342
 
233
343
  export function formatTriageLine(sourcePath, triage) {
@@ -238,3 +348,60 @@ export function formatTriageLine(sourcePath, triage) {
238
348
  const ownerLabel = triage.primaryOwner ?? 'unassigned';
239
349
  return `${basename} → ${triage.intakeType} / ${triage.rdStage} · owner: ${ownerLabel} · next: ${triage.recommendedAction}`;
240
350
  }
351
+
352
+ /**
353
+ * Pure function that proposes tag attributions based on triage + related docs.
354
+ * Does NOT perform any I/O or LLM calls — safe to call from the daemon path.
355
+ *
356
+ * @param {object} triage — output of classifyRdIntake()
357
+ * @param {Array} related — related doc metadata (each may have .tags[])
358
+ * @param {object} vocab — result of loadVocabulary() from lib/tags/vocabulary.mjs
359
+ * @returns {Array<{tag, source, confidence}>}
360
+ */
361
+ export function suggestTags(triage, related = [], vocab = null) {
362
+ const suggestions = [];
363
+
364
+ // Winning intakeType → corresponding intake/<type> tag.
365
+ if (triage?.intakeType && triage.intakeType !== 'unknown') {
366
+ const tagId = `intake/${triage.intakeType}`;
367
+ const confidence = triage.confidence ?? 0.5;
368
+
369
+ // Check against vocab threshold if provided; fall back to 0.70.
370
+ let threshold = 0.70;
371
+ if (vocab?.facets?.['intake-type']?.auto_threshold) {
372
+ threshold = vocab.facets['intake-type'].auto_threshold;
373
+ }
374
+
375
+ if (confidence >= threshold) {
376
+ suggestions.push({ tag: tagId, source: 'agent:classifier', confidence });
377
+ }
378
+ }
379
+
380
+ // Related-doc tag inheritance: tags held by 2+ related docs.
381
+ const tagCounts = new Map();
382
+ for (const doc of related) {
383
+ const tags = Array.isArray(doc?.tags) ? doc.tags : [];
384
+ for (const t of tags) {
385
+ tagCounts.set(t, (tagCounts.get(t) || 0) + 1);
386
+ }
387
+ }
388
+ for (const [tag, count] of tagCounts.entries()) {
389
+ if (count >= 2) {
390
+ const alreadySuggested = suggestions.some((s) => s.tag === tag);
391
+ if (!alreadySuggested) {
392
+ suggestions.push({ tag, source: 'agent:related-inherit', confidence: 0.70 });
393
+ }
394
+ }
395
+ }
396
+
397
+ // Filter against vocab if provided: skip deprecated/archived tags.
398
+ if (vocab) {
399
+ return suggestions.filter((s) => {
400
+ const entry = vocab._tagMap?.get(s.tag);
401
+ if (!entry) return true; // unknown — allowed through (daemon does not block unknowns)
402
+ return entry.status !== 'archived' && entry.status !== 'deprecated';
403
+ });
404
+ }
405
+
406
+ return suggestions;
407
+ }
@@ -11,6 +11,7 @@
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { ensureCxDir } from '../project-init-shared.mjs';
14
+ import { shouldQuarantine, writeQuarantinePacket, quarantineDir } from './quarantine.mjs';
14
15
 
15
16
  const QUEUE_SUBDIR = '.cx/intake';
16
17
 
@@ -30,6 +31,8 @@ export function skippedDir(rootDir) {
30
31
  return path.join(queueRoot(rootDir), 'skipped');
31
32
  }
32
33
 
34
+ export { quarantineDir };
35
+
33
36
  function slugify(value) {
34
37
  return String(value || 'untitled')
35
38
  .toLowerCase()
@@ -55,18 +58,29 @@ export class FilesystemIntakeQueue {
55
58
 
56
59
  enqueue(entry) {
57
60
  if (!entry?.intake?.sourcePath) throw new Error('enqueue: entry.intake.sourcePath is required');
58
- const dir = pendingDir(this.rootDir);
59
61
  ensureCxDir(this.rootDir);
60
- mkdirSync(dir, { recursive: true });
61
62
 
62
63
  const ts = timestamp();
63
64
  const slug = slugify(path.basename(entry.intake.sourcePath, path.extname(entry.intake.sourcePath)));
64
65
  const id = `${ts}-${slug}`;
65
- const filePath = path.join(dir, `${id}.json`);
66
66
 
67
+ // Quarantine check: low confidence or close margin routes the packet
68
+ // to .cx/intake/quarantine/ instead of pending/. Downstream specialists
69
+ // must not see uncertain triage as if it were confident; the human
70
+ // reroute path closes the loop and writes a learned-fixture for CI.
71
+ const quarantineDecision = shouldQuarantine(entry?.triage);
72
+ if (quarantineDecision.quarantine) {
73
+ const packet = { id, createdAt: new Date().toISOString(), ...entry };
74
+ const written = writeQuarantinePacket(this.rootDir, packet, quarantineDecision.reason);
75
+ return { id: written.id, filePath: written.filePath, route: 'quarantine', reason: quarantineDecision.reason };
76
+ }
77
+
78
+ const dir = pendingDir(this.rootDir);
79
+ mkdirSync(dir, { recursive: true });
80
+ const filePath = path.join(dir, `${id}.json`);
67
81
  const payload = { id, createdAt: new Date().toISOString(), status: 'pending', ...entry };
68
82
  writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
69
- return { id, filePath };
83
+ return { id, filePath, route: 'pending' };
70
84
  }
71
85
 
72
86
  listPending() {
@@ -94,7 +108,13 @@ export class FilesystemIntakeQueue {
94
108
  }
95
109
 
96
110
  read(id) {
97
- for (const dir of [pendingDir(this.rootDir), processedDir(this.rootDir), skippedDir(this.rootDir)]) {
111
+ const dirs = [
112
+ pendingDir(this.rootDir),
113
+ processedDir(this.rootDir),
114
+ skippedDir(this.rootDir),
115
+ quarantineDir(this.rootDir),
116
+ ];
117
+ for (const dir of dirs) {
98
118
  const filePath = path.join(dir, `${id}.json`);
99
119
  if (existsSync(filePath)) {
100
120
  const data = JSON.parse(readFileSync(filePath, 'utf8'));